teksilo-core 0.9.1

Core of the Teksilo GUI framework — widget trait, arena, layout engine, event dispatch, focus, signals and theming.
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
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
3295
3296
3297
3298
3299
3300
3301
3302
3303
3304
3305
3306
3307
3308
3309
3310
3311
3312
3313
3314
3315
3316
3317
3318
3319
3320
3321
3322
3323
3324
3325
3326
3327
3328
3329
3330
3331
3332
3333
3334
3335
3336
3337
3338
3339
3340
3341
3342
3343
3344
3345
3346
3347
3348
3349
3350
3351
3352
3353
3354
3355
3356
3357
3358
3359
3360
3361
3362
3363
3364
3365
3366
3367
3368
3369
3370
3371
3372
3373
3374
3375
3376
3377
3378
3379
3380
3381
3382
3383
3384
3385
3386
3387
3388
3389
3390
3391
3392
3393
3394
3395
3396
3397
3398
3399
3400
3401
3402
3403
3404
3405
3406
3407
3408
3409
3410
3411
3412
3413
3414
3415
3416
3417
3418
3419
3420
3421
3422
3423
3424
3425
3426
3427
3428
3429
3430
3431
3432
3433
3434
3435
3436
3437
3438
3439
3440
3441
3442
3443
3444
3445
3446
3447
3448
3449
3450
3451
3452
3453
3454
3455
3456
3457
3458
3459
3460
3461
3462
3463
3464
3465
3466
3467
3468
3469
3470
3471
3472
3473
3474
3475
3476
3477
3478
3479
3480
3481
3482
3483
3484
3485
3486
3487
3488
3489
3490
3491
3492
3493
3494
3495
3496
3497
3498
3499
3500
3501
3502
3503
3504
3505
3506
3507
3508
3509
3510
3511
3512
3513
3514
3515
3516
3517
3518
3519
3520
3521
3522
3523
3524
3525
3526
3527
3528
3529
3530
3531
3532
3533
3534
3535
3536
3537
3538
3539
3540
3541
3542
3543
3544
3545
3546
3547
3548
3549
3550
3551
3552
3553
3554
3555
3556
3557
3558
3559
3560
3561
3562
3563
3564
3565
3566
3567
3568
3569
3570
3571
3572
3573
3574
3575
3576
3577
3578
3579
3580
3581
3582
3583
3584
3585
3586
3587
3588
3589
3590
3591
3592
3593
3594
3595
3596
3597
3598
3599
3600
3601
3602
3603
3604
3605
3606
3607
3608
3609
3610
3611
3612
3613
3614
3615
3616
3617
3618
3619
3620
3621
3622
3623
3624
3625
3626
3627
3628
3629
3630
3631
3632
3633
3634
3635
3636
3637
3638
3639
3640
3641
3642
3643
3644
3645
3646
3647
3648
3649
3650
3651
3652
3653
3654
3655
3656
3657
3658
3659
3660
3661
3662
3663
3664
3665
3666
3667
3668
3669
3670
3671
3672
3673
3674
3675
3676
3677
3678
3679
3680
3681
3682
3683
3684
3685
3686
3687
3688
3689
3690
3691
3692
3693
3694
3695
3696
3697
3698
3699
3700
3701
3702
3703
3704
3705
3706
3707
3708
3709
3710
3711
3712
3713
3714
3715
3716
3717
3718
3719
3720
3721
3722
3723
3724
3725
3726
3727
3728
3729
3730
3731
3732
3733
3734
3735
3736
3737
3738
3739
3740
3741
3742
3743
3744
3745
3746
3747
3748
3749
3750
3751
3752
3753
3754
3755
3756
3757
3758
3759
3760
3761
3762
3763
3764
3765
3766
3767
3768
3769
3770
3771
3772
3773
3774
3775
3776
3777
3778
3779
3780
3781
3782
3783
3784
3785
3786
3787
3788
3789
3790
3791
3792
3793
3794
3795
3796
3797
3798
3799
3800
3801
3802
3803
3804
3805
3806
3807
3808
3809
3810
3811
3812
3813
3814
3815
3816
3817
3818
3819
3820
3821
3822
3823
3824
3825
3826
3827
3828
3829
3830
3831
3832
3833
3834
3835
3836
3837
3838
3839
3840
3841
3842
3843
3844
3845
3846
3847
3848
3849
3850
3851
3852
3853
3854
3855
3856
3857
3858
3859
3860
3861
3862
3863
3864
3865
3866
3867
3868
3869
3870
3871
3872
3873
3874
3875
3876
3877
3878
3879
3880
3881
3882
3883
3884
3885
3886
3887
3888
3889
3890
3891
3892
3893
3894
3895
3896
3897
3898
3899
3900
3901
3902
3903
3904
3905
3906
3907
3908
3909
3910
3911
3912
3913
3914
3915
3916
3917
3918
3919
3920
3921
3922
3923
3924
3925
3926
3927
3928
3929
3930
3931
3932
3933
3934
3935
3936
3937
3938
3939
3940
3941
3942
3943
3944
3945
3946
3947
3948
3949
3950
3951
3952
3953
3954
3955
3956
3957
3958
3959
3960
3961
3962
3963
3964
3965
3966
3967
3968
3969
3970
3971
3972
3973
3974
3975
3976
3977
3978
3979
3980
3981
3982
3983
3984
3985
3986
3987
3988
3989
3990
3991
3992
3993
3994
3995
3996
3997
3998
3999
4000
4001
4002
4003
4004
4005
4006
4007
4008
4009
4010
4011
4012
4013
4014
4015
4016
4017
4018
4019
4020
4021
4022
4023
4024
4025
4026
4027
4028
4029
4030
4031
4032
4033
4034
4035
4036
4037
4038
4039
4040
4041
4042
4043
4044
4045
4046
4047
4048
4049
4050
4051
4052
4053
4054
4055
4056
4057
4058
4059
4060
4061
4062
4063
4064
4065
4066
4067
4068
4069
4070
4071
4072
4073
4074
4075
4076
4077
4078
4079
4080
4081
4082
4083
4084
4085
4086
4087
4088
4089
4090
4091
4092
4093
4094
4095
4096
4097
4098
4099
4100
4101
4102
4103
4104
4105
4106
4107
4108
4109
4110
4111
4112
4113
4114
4115
4116
4117
4118
4119
4120
4121
4122
4123
4124
4125
4126
4127
4128
4129
4130
4131
4132
4133
4134
4135
4136
4137
4138
4139
4140
4141
4142
4143
4144
4145
4146
4147
4148
4149
4150
4151
4152
4153
4154
4155
4156
4157
4158
4159
4160
4161
4162
4163
4164
4165
4166
4167
4168
4169
4170
4171
4172
4173
4174
4175
4176
4177
4178
4179
4180
4181
4182
4183
4184
4185
4186
4187
4188
4189
4190
4191
4192
4193
4194
4195
4196
4197
4198
4199
4200
4201
4202
4203
4204
4205
4206
4207
4208
4209
4210
4211
4212
4213
4214
4215
4216
4217
4218
4219
4220
4221
4222
4223
4224
4225
4226
4227
4228
4229
4230
4231
4232
4233
4234
4235
4236
4237
4238
4239
4240
4241
4242
4243
4244
4245
4246
4247
4248
4249
4250
4251
4252
4253
4254
4255
4256
4257
4258
4259
4260
4261
4262
4263
4264
4265
4266
4267
4268
4269
4270
4271
4272
4273
4274
4275
4276
4277
4278
4279
4280
4281
4282
4283
4284
4285
4286
4287
4288
4289
4290
4291
4292
4293
4294
4295
4296
4297
4298
4299
4300
4301
4302
4303
4304
4305
4306
4307
4308
4309
4310
4311
4312
4313
4314
4315
4316
4317
4318
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech

use std::cell::RefCell;
use std::rc::Rc;

use crate::styles::Theme;
use teksilo_canvas::{Canvas, Point, Rect, RenderFrame, SizeProposal};

use crate::arena::WidgetArena;
use crate::event::{EventResponse, Key, Modifiers, PointerButton, WidgetEvent};
use crate::widget::{EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement};
use crate::widget_id::WidgetId;

mod accessibility_impl;
mod drag_drop_impl;
mod event_dispatch_impl;
mod focus_impl;
mod gesture_dispatch_impl;
mod layout_impl;
mod overlay_impl;
mod query_impl;
mod rendering_impl;
mod test_api;

/// The main widget tree orchestrating arena, layout, events, accessibility, and paint.
/// Provides both the runtime API and the headless test API.
struct AnimatedRegistration {
    weak: crate::signal::WeakAnimatedSignal,
    owner: WidgetId,
}

impl AnimatedRegistration {
    fn same_signal(&self, signal: &crate::signal::Signal<f32>) -> bool {
        self.weak.same_signal(signal)
    }

    fn is_alive(&self) -> bool {
        self.weak.upgrade().is_some()
    }

    fn take_pending_animation(
        &self,
    ) -> Option<(
        crate::signal::Signal<f32>,
        crate::animation::AnimationRequest,
        WidgetId,
    )> {
        let signal = self.weak.upgrade()?;
        let request = signal.take_pending_animation()?;
        Some((signal, request, self.owner))
    }

    /// Non-consuming counterpart to [`take_pending_animation`](Self::take_pending_animation),
    /// for [`WidgetTree::needs_reconcile`] — which must be able to ask
    /// "is there work here?" without doing any.
    fn has_pending_animation(&self) -> bool {
        self.weak
            .upgrade()
            .is_some_and(|signal| signal.has_pending_animation())
    }
}

#[allow(clippy::type_complexity)]
pub struct WidgetTree {
    arena: WidgetArena,
    /// Current theme value cached for `&Theme` accessors used by layout/paint
    /// contexts and by widgets that need an immediate read. The reactive source
    /// of truth is `theme_signal`; both are updated in lockstep by `set_theme`.
    theme: Theme,
    /// Reactive theme signal. Widgets that want their visual or derived state
    /// to track theme changes bind to this signal or build derived signals via
    /// `zip`/`map`. `set_theme` updates the signal (firing observers) without
    /// rebuilding the widget tree, so interaction state (focus, scroll, expanded
    /// panels, …) survives theme switches.
    theme_signal: crate::signal::Signal<Theme>,
    /// User-controlled global text-scale factor (`1.0` = 100 %). Layered on top
    /// of the OS `text_scale_factor`: the two multiply. Set via
    /// `set_user_text_scale`; persisted by the application through
    /// `teksilo_settings::TEXT_SCALE_KEY`.
    user_text_scale: f32,
    /// Cached projection of `theme` whose `typography` is scaled by
    /// `user_text_scale * text_scale_factor`. Recomputed by
    /// `recompute_effective_theme` whenever the theme or either scale factor
    /// changes; the layout and paint walkers read this instead of `theme` so
    /// all text grows uniformly. Equal to `theme` when the combined factor is 1.
    effective_theme: Theme,
    /// The combined `user_text_scale * text_scale_factor`, cached so the
    /// layout/paint context construction sites don't recompute it. The single
    /// scalar published to widgets that size from a source *other* than
    /// `Theme.typography` (icons, the rich-text engine, calendar constants,
    /// scene text). Written alongside `effective_theme` in
    /// `recompute_effective_theme`.
    effective_text_scale: f32,
    /// Reactive mirror of `effective_text_scale`, for build-time binders that
    /// must react to a scale change without a rebuild path of their own (e.g.
    /// `Calendar` binds this at `Rebuild` level). Fired by
    /// `recompute_effective_theme`.
    text_scale_signal: crate::signal::Signal<f32>,
    /// Reactive window-active state (`focused AND not occluded`), the
    /// occlusion-aware companion to `WindowState::focused` (which is raw OS
    /// focus only). The single source of truth for "is this window active",
    /// read by `is_window_active()` and published to widgets via
    /// `window_active_signal()` / `BuildContext::window_active*` /
    /// `PaintContext::window_active`. Drives caret hiding, selection
    /// desaturation and `DimWhenInactive`. Starts `true` — winit may not send
    /// `Focused(true)` for the first window, so a window must not be born
    /// inactive. Mutated only by `set_window_active`.
    window_active_signal: crate::signal::Signal<bool>,
    text_backend: Option<Rc<RefCell<dyn teksilo_canvas::TextBackend>>>,
    focused: Option<WidgetId>,
    /// Reactive mirror of `focused`. Same pattern as `hovered_signal`
    /// — kept in sync via `set_focused`. Drives the inspector's Focus
    /// tab without polling.
    focused_signal: crate::signal::Signal<Option<WidgetId>>,
    hovered: Option<WidgetId>,
    /// Reactive mirror of `hovered`. Set whenever `hovered` changes
    /// during dispatch / hit-test recovery so external observers
    /// (notably the debug inspector's hover tooltip) can react without
    /// polling. Held by handle so the field is a cheap clone.
    hovered_signal: crate::signal::Signal<Option<WidgetId>>,
    /// Last known pointer position from `PointerMove`. Used by
    /// `revalidate_interaction_state` to re-hit-test the hover after
    /// a rebuild shifts content under a stationary cursor — without
    /// this, the next `Scroll` event routes to `focused` (or falls
    /// through to an ancestor scrollable) instead of the item the
    /// user is actually pointing at.
    last_pointer_position: Option<teksilo_canvas::Point>,
    /// A rebuild destroyed the focused widget: the subtree that owned focus,
    /// remembered so the end of the layout pass can land focus back inside it.
    ///
    /// A rebuild allocates fresh `WidgetId`s for its children, so the focused
    /// node dies and `revalidate_interaction_state` drops focus to `None`.
    /// Leaving it there kicks the user out of the widget they were in — most
    /// visibly, a popover that refreshes its content when it opens would throw
    /// away the very row the popover had just focused, so the menu comes up with
    /// no keyboard focus at all. Focus is re-entered *after* the layout walk
    /// (see the tail of `layout_with_ops`), once the fresh children have real
    /// bounds for the focus-driven scroll-into-view — the same shape as the
    /// post-layout hover refresh next to it.
    pending_focus_restore: Option<WidgetId>,
    last_proposal: SizeProposal,
    pending_modal_requests: Vec<crate::modal::QueuedModalRequest>,
    pending_modal_dismissal: bool,
    shortcut_registry: crate::shortcut::ShortcutRegistry,
    /// Queue of intents awaiting dispatch. Populated either by the
    /// keystroke interception path (`dispatch_event` for KeyDown) or
    /// by handlers calling `ctx.send_intent(...)`. Drained between
    /// event-handler calls by [`WidgetTree::drain_pending_intents`].
    /// The tuple carries the source widget (dispatch anchor), the
    /// intent itself, and the firing shortcut's
    /// `propagate_when_disabled` policy.
    pending_intents: Vec<(WidgetId, crate::intent::Intent, bool)>,
    /// Window-global actions registered via
    /// [`BuildContext::register_action_global`](crate::BuildContext::register_action_global).
    /// Consulted as a fallback at the end of every intent dispatch — *after* the
    /// source→root walk finds no consuming node action — so an app-global command
    /// is reachable no matter where the intent originated (a menu-bar dropdown
    /// overlay, deep content, a global shortcut anchored at the root). Each entry
    /// is owned by the registering widget and torn down on its rebuild/destroy,
    /// mirroring `register_shortcut_global`.
    global_actions: Vec<(WidgetId, crate::action::Action)>,
    /// The widgets that edit text, registered via
    /// [`BuildContext::register_text_surface`](crate::BuildContext::register_text_surface).
    ///
    /// Owned by the registering widget and torn down on its rebuild/destroy,
    /// exactly like `global_actions` above. Read through
    /// [`WidgetTree::focused_text_surface`] by a host that has taken a text
    /// chord — `Ctrl+Z`, `Ctrl+C` — for itself and owes every text widget in the
    /// tree an answer about what happens to it. See
    /// [`crate::text_surface`] for why the framework is the only place that
    /// question can be answered completely.
    text_surfaces: crate::text_surface::TextSurfaces,
    /// Currently-armed key-capture slot. `Some` when
    /// [`WidgetTree::begin_key_capture`] has been called and the
    /// returned [`CaptureHandle`](crate::shortcut::CaptureHandle)
    /// is still alive. The slot is shared (via `Rc`) with the handle
    /// so dropping the handle cancels the capture, and calling
    /// `begin_key_capture` again creates a fresh slot without
    /// touching the previous one (whose handle, if dropped later,
    /// only clears its own orphaned slot).
    key_capture: Option<crate::shortcut::KeyCaptureSlot>,
    binding_registry: crate::binding::BindingRegistry,
    idle_queue: crate::idle::IdleQueue,
    /// Simulated clock for deterministic time-dependent testing.
    sim_clock: std::time::Instant,
    /// Whether [`tick_animations`](Self::tick_animations) has ever driven this
    /// tree — i.e. whether [`Self::sim_clock`], rather than the wall clock, is
    /// the one animations are measured against. See [`Self::animation_clock`].
    sim_driven: bool,
    /// Overlay manager for tooltips, menus, popovers.
    pub(crate) overlay_manager: crate::overlay::OverlayManager,
    /// Tooltip attachments: (anchor_id, content_id, text, delay, hover_start, overlay_id).
    tooltips: Vec<TooltipEntry>,
    /// Simulated-clock end of the tooltip "reshow session". While any tip is
    /// visible, or until this instant after the last tip dismissed, subsequent
    /// anchors use `MotionTokens::tooltip_reshow_delay` instead of the full
    /// initial delay (Windows `TTDT_RESHOW` behaviour).
    tooltip_session_until_sim: Option<std::time::Instant>,
    /// Real-clock counterpart of [`Self::tooltip_session_until_sim`].
    tooltip_session_until_real: Option<std::time::Instant>,
    /// The tooltip currently surfaced by keyboard menu navigation
    /// (`show_highlight_tooltip`): `(overlay_id, content_id)`. At most one
    /// is shown at a time; moving the highlight or closing the menu clears
    /// it. Distinct from the hover/focus tooltip paths — this one is a
    /// `Manual`-dismiss child of the menu overlay so a single Escape closes
    /// the menu (and cascades the tooltip) rather than only the tooltip.
    highlight_tooltip: Option<(crate::overlay::OverlayId, WidgetId)>,
    /// How the currently focused widget gained focus.
    focus_origin: Option<crate::focus::FocusOrigin>,
    /// Input-modality "focus-visible" state: `true` after keyboard input,
    /// `false` after pointer input. Focus rings (e.g. `StandardItem`'s current
    /// row) show only while this is `true`, the standard `:focus-visible`
    /// behaviour — so a mouse click selects without a ring, and keyboard
    /// navigation reveals it.
    focus_visible: crate::signal::Signal<bool>,
    /// Active focus-scope stack during build. A data view pushes its scope
    /// (`begin_view_focus`) around its row loop so each row reads *its view's*
    /// focus deterministically — independent of arena parenting, which may not
    /// be wired yet while rows build (docked / virtualized content). Drives
    /// focus-aware selection + focus rings in `StandardItem`.
    view_focus_stack: Vec<crate::signal::Signal<bool>>,
    /// Layout direction for RTL/LTR support.
    layout_direction: crate::environment::LayoutDirection,
    /// Animation scheduler for smooth animated state and signal transitions.
    animation_scheduler: crate::animation::AnimationScheduler,
    /// Weakly tracked animated values from both state and signal APIs.
    animated_values: Vec<AnimatedRegistration>,
    /// Registry of shader-driven animated quads (opt-in alternative to
    /// `Signal<f32>::animate_looping` for decorative motion — progress
    /// sweeps, sprite-atlas frame cycling, future pulse/shimmer). The
    /// scheduler-style signal path stays for everything else. Per-slot
    /// `AnimParams` are ticked and attached to every `RenderFrame`
    /// produced by `render()` — the renderer reads them from there.
    animated_quads: crate::animated_quad::AnimatedQuadRegistry,
    /// Per-frame-effect scheduler. Owns the registry of widgets that
    /// asked for a frame-tick subscription (Pulse, Cycle, …). Sits
    /// alongside `animation_scheduler` and `animated_quads` as the
    /// third visibility-aware motion source — they all consult the
    /// same [`motion_visibility`](crate::motion_visibility) helpers.
    /// After every `render()` the tree calls
    /// `FrameTickScheduler::should_arm_frame_tick` and re-arms
    /// `frame_tick_requested` if any subscriber's owner was painted
    /// this frame.
    pub(crate) frame_tick_scheduler: crate::frame_tick_scheduler::FrameTickScheduler,
    /// Monotonic counter bumped at the start of each `render()` call.
    /// Each widget's `last_painted_epoch` is set to this value whenever
    /// the paint pass (or the cache-hit early-out) confirms the widget
    /// intersects the window viewport. The animation scheduler uses it
    /// to detect and pause animations for widgets that have scrolled
    /// off-screen. Starts at `0`, which serves as the "never painted"
    /// sentinel; tests that only call `layout()` see the gate bypass.
    paint_epoch: u64,
    /// Cached accessibility tree update, rebuilt only when something that
    /// changes the AT tree has happened, not on every layout.
    cached_a11y: Option<accesskit::TreeUpdate>,
    /// Whether the accessibility tree needs rebuilding. Set by focus moves,
    /// overlay changes, widget rebuilds, active↔dormant transitions,
    /// `AccessibilityOnly` binding flips and `request_accessibility_update()`;
    /// a plain relayout does not set it.
    a11y_dirty: bool,
    /// Snapshot of `shortcut_registry.version()` at the last
    /// `sync_accessibility` call. When the live version differs the
    /// AT cache is dirtied, so widgets that bound their announced
    /// shortcut via `access_shortcut_id(id)` track user rebinds
    /// without any explicit signaling from the settings UI.
    last_synced_shortcut_version: u64,
    /// Snapshot of `locale_signal` at the last `sync_accessibility`
    /// call. When the locale differs the AT cache is dirtied, so
    /// `access_label(tr!(...))` (stored as a locale-bound
    /// `Prop<String>`) re-resolves into the announced node — even on a
    /// same-direction switch that doesn't rebuild the composite.
    last_synced_locale: Option<String>,
    /// Reverse map from synthetic (widget-emitted) AccessKit NodeIds
    /// to the WidgetId that owns them. Rebuilt on every full
    /// accessibility walk. `handle_accessibility_actions` uses this
    /// to route an `ActionRequest` targeting a TextRun child back
    /// to the owning rich-text editor, since synthetic NodeIds
    /// can't be decoded back to a WidgetId by value alone.
    pub(crate) synthetic_parent_map: std::collections::HashMap<accesskit::NodeId, WidgetId>,
    /// Cached full render frame — reused when no widget needs painting.
    /// `Rc<RenderFrame>` rather than `RenderFrame` so cache-hit frames
    /// cost an atomic refcount bump instead of a deep clone of every
    /// draw-command Vec. `render()` uses `Rc::make_mut` to update
    /// `anim_params` in place when the tree is the sole owner (the
    /// common case — the caller usually drops the previous frame
    /// before calling render() again).
    cached_frame: Option<std::rc::Rc<RenderFrame>>,
    /// Widget that has captured the pointer (receives all PointerMove/PointerUp
    /// regardless of hit-test). Set via `EventContext::capture_pointer()`.
    pointer_captured_by: Option<WidgetId>,
    /// Strict ancestors of the captured widget that carry a drag/swipe
    /// recognizer, armed on `PointerDown` so an ancestor drag can still start
    /// while a descendant tap holds the capture (tap-vs-drag disambiguation
    /// across the hit-path). Innermost-first. Drained when a drag latches or
    /// the pointer sequence ends. See `arm_drag_observers`.
    drag_observers: Vec<WidgetId>,
    /// Current cursor selected by hover/interaction routing.
    current_cursor: crate::widget::CursorIcon,
    /// Delayed overlay requests (e.g., submenu hover-open delay).
    pending_delayed_overlays: Vec<PendingDelayedOverlay>,
    /// Reusable scratch buffer for active-id snapshots taken on hot
    /// paths that mutate per-widget state inside the loop
    /// (`tick_gestures_with_ops`, post-render dirty-bit clear,
    /// post-layout `needs_layout` clear). Cleared and refilled on
    /// every use via `WidgetArena::fill_active_ids`. Previously these
    /// sites called the allocating `arena.active_ids()` per frame,
    /// which `perf record` ranked at ~13 % of CPU on the
    /// `widget_catalog --tab animations` scene.
    active_ids_scratch: Vec<WidgetId>,
    /// Widgets currently carrying a non-`None` `EventHandlers::gesture_arena`.
    /// Updated on attach (`ensure_gesture_arena` install) and on
    /// teardown (rebuild / destroy / handler-clear). Every per-frame
    /// gesture pass (`tick_gestures_with_ops`, `next_gesture_deadline`)
    /// iterates this set instead of every active widget — most active
    /// widgets have `gesture_arena = None`, so the savings come from
    /// not even visiting them. Filtered by `arena.is_active(id)` at
    /// iteration time so dormant entries don't fire (a widget can be
    /// dormant while still holding its handlers).
    gesture_owners: std::collections::HashSet<WidgetId>,
    /// OS-level accessibility preferences (high contrast, reduced motion, text scale).
    prefers_high_contrast: bool,
    prefers_reduced_motion: bool,
    text_scale_factor: f64,
    /// Host window HiDPI device scale (physical px per logical px), fed by
    /// `teksilo-app` before each layout. Surfaced to widgets via
    /// `LayoutContext::scale_factor`. The widget tree is otherwise fully
    /// logical (the renderer applies this scale at the vertex stage); this is
    /// the escape hatch for widgets that must size a device-pixel OS resource
    /// (e.g. a `WebView`'s native subview). 1.0 in headless / test contexts.
    device_scale_factor: f32,
    /// Active drag-and-drop session, if any.
    pub(crate) active_drag: Option<crate::drag_state::DragSession>,
    /// Source widget of an in-flight OS (outbound) drag that escalated past
    /// the window boundary. Set only on the window that *started* the drag.
    /// The in-app `active_drag` session is torn down at escalation (the OS owns
    /// the pointer); this remembers who started it so the eventual `DragEnded`
    /// can fire the source's `on_drag_ended`.
    pub(crate) outbound_drag_source: Option<WidgetId>,
    /// True while *this* window currently holds the re-entered internal session
    /// for an in-flight app-originated OS drag (the OS drag wandered back over
    /// this window — possibly a different window than the source — and we
    /// restored the original typed payload). Distinguishes that session from a
    /// plain internal drag so leaving again re-stashes instead of starting a
    /// second OS drag, and dropping doesn't double-fire `on_drag_ended`.
    pub(crate) os_drag_reentered: bool,
    /// Optional platform host for custom window chrome (set when the
    /// application opts in via `WindowConfig::custom_chrome(true)`). Stored
    /// here so that the root-builder closure has access during widget
    /// construction; the same `Rc` is also held by `WindowManager` so it
    /// outlives the widget tree if needed.
    title_bar_host: Option<Rc<dyn crate::PlatformTitleBarHost>>,
    /// App-level subscription state: registered event source adapter,
    /// proxy poster, UI-side subscription callbacks. Default is empty;
    /// teksilo-app installs a populated context when an event source is
    /// registered on the builder.
    pub(crate) app_context: Rc<crate::event_source::TreeAppContext>,
    /// Active locale identifier. Cached for `Option<&str>` accessors; the
    /// reactive source of truth is `locale_signal`. Both are updated in
    /// lockstep by `set_locale`.
    pub(crate) locale: Option<String>,
    /// Reactive locale signal. Widgets and `LocalizedString` adapters bind to
    /// this signal to react to locale changes; `set_locale` updates the signal
    /// without rebuilding the widget tree.
    pub(crate) locale_signal: crate::signal::Signal<Option<String>>,
    /// Per-frame delta-seconds signal, advanced by `layout()` **only when
    /// a widget has explicitly requested a frame** via `request_frame()`.
    /// This preserves Teksilo's draw-when-needed model: idle trees stay
    /// idle even if widgets have registered observers on this signal.
    pub(crate) frame_tick: crate::signal::Signal<f32>,
    /// Set by `request_frame()`; consumed by `advance_frame_tick()` on
    /// the next `layout()`. Observers that need another tick after the
    /// current one must re-request. Stored as `Rc<Cell>` so observers
    /// fired from inside the layout pass (`ctx.effect` closures on
    /// `frame_tick`) can chain-request without needing &mut access
    /// to the tree — see `FrameRequestHandle`.
    pub(crate) frame_tick_requested: std::rc::Rc<std::cell::Cell<bool>>,
    /// Debug-only re-entrancy flag: `true` while a focus-change dispatch
    /// (`FocusGained` / `FocusLost` handlers) is running. Threaded into each
    /// `EventContext` so `open_window` / `focus_window` can warn if a handler
    /// changes context merely because a control gained focus (WCAG 3.2.1). A
    /// shared `Rc<Cell<bool>>` (like `frame_tick_requested`) so the flag is
    /// readable from an `EventContext` that holds no `&mut` to the tree.
    pub(crate) in_focus_dispatch: std::rc::Rc<std::cell::Cell<bool>>,
    /// Shared "accessibility re-walk requested" flag. Set via
    /// [`request_accessibility_update`](Self::request_accessibility_update)
    /// (or its `BuildContext` / `EventContext` wrappers) and drained at the
    /// top of [`sync_accessibility`](Self::sync_accessibility) into
    /// `a11y_dirty`. A relayout no longer re-walks the AT tree on its own, so
    /// widgets that restructure their subtree in an AT-affecting way (e.g.
    /// `SceneView` materialising / destroying scene widgets) need this lever.
    /// `Rc<Cell>` so the shared `&self` paths can toggle it like
    /// `frame_tick_requested`.
    pub(crate) a11y_update_requested: std::rc::Rc<std::cell::Cell<bool>>,
    /// Delayed frame wake-up deadline. Widgets that need to schedule
    /// a future frame without pumping at full framerate (caret blink,
    /// etc.) store the target instant here via
    /// [`wake_at_handle`](Self::wake_at_handle). `next_timer_deadline`
    /// rolls it into the event loop's WaitUntil; when reached, the
    /// next `layout()` automatically re-arms `frame_tick_requested`
    /// so the frame-tick effects run on the wake-up pass.
    pub(crate) pending_wake_at: std::rc::Rc<std::cell::Cell<Option<std::time::Instant>>>,
    /// One-shot post-mount actions enqueued during `build()` via
    /// [`BuildContext::run_after_mount`](crate::BuildContext::run_after_mount), drained by the app loop (and by
    /// tests) through [`WidgetTree::run_mount_actions`] with a real
    /// [`EventContext`] — the only place a widget
    /// can read the OS parent handle / app-state / poster together after it is
    /// mounted. Used by widgets that own a native resource needing a window
    /// handle to initialise (a `WebView`'s engine subview).
    pub(crate) pending_mount_actions: Vec<Box<dyn FnOnce(&mut crate::widget::EventContext)>>,
    /// Wall-clock time of the previous `layout()` call (for delta computation).
    pub(crate) last_frame_time: Option<std::time::Instant>,
    /// Set by [`EventContext::close_window`] during dispatch; drained
    /// by the application event loop after each event via
    /// [`WidgetTree::take_close_window_request`]. A *guarded* close
    /// request — the app routes it through the window's close guard.
    pub(crate) close_window_requested: bool,
    /// Set by [`EventContext::close_window_forced`] during dispatch;
    /// drained via [`WidgetTree::take_force_close_request`]. An
    /// *unconditional* close request that bypasses the window's close
    /// guard.
    pub(crate) force_close_requested: bool,
    /// Raised by [`EventContext::set_locale`] during dispatch; drained by
    /// the application event loop (see
    /// `WindowManager::drain_pending_locale_requests`) so the switch can be
    /// routed through the `I18nManager` (active locale + version signal +
    /// RTL direction). `WidgetTree::set_locale` alone would only update the
    /// tree's local locale signal — the i18n thread-local would stay put
    /// and `tr!` lookups would not re-resolve.
    pub(crate) pending_locale_request: Option<String>,
    /// Raised by [`EventContext::set_theme`] during dispatch; drained by
    /// the application event loop (see
    /// `WindowManager::drain_pending_theme_requests`) so the switch is
    /// routed through `WindowManager::set_theme`, which fans the new theme
    /// out to *every* window. Applying via `WidgetTree::set_theme` inline
    /// would only re-theme the originating window — the rest of the app
    /// would stay on the old theme. Mirrors `pending_locale_request`.
    pub(crate) pending_theme_request: Option<crate::styles::Theme>,
    /// Raised by [`EventContext::follow_system_theme`] during dispatch;
    /// drained by the application event loop (see
    /// `WindowManager::drain_pending_follow_system_requests`), which switches
    /// the app to `ThemeMode::Native` and recomputes the theme from current
    /// OS colours. Mirrors `pending_theme_request`.
    pub(crate) pending_follow_system_request: bool,
    /// Raised by [`EventContext::set_text_scale`] during dispatch; drained by
    /// the application event loop (see
    /// `WindowManager::drain_pending_text_scale_requests`) so the change is
    /// routed through `WindowManager::set_text_scale`, fanning the new factor
    /// out to *every* window. Mirrors `pending_theme_request`.
    pub(crate) pending_text_scale_request: Option<f32>,
    /// Monotonic version counter bumped after every *real* accessibility
    /// rebuild in [`Self::sync_accessibility`] (cache hits don't bump).
    /// Mirror of [`crate::shortcut::ShortcutRegistry::version`]: an
    /// automation / test harness can poll it to know whether the AT tree
    /// changed without diffing the whole `TreeUpdate`.
    at_version: crate::signal::Signal<u64>,
    /// The framework's own live regions, one per politeness level. See
    /// [`crate::announcer`]: each owns a reserved AccessKit node and cycles it
    /// in and out of the filtered tree, which is the only mechanism all three
    /// platform adapters agree announces.
    announcer_polite: crate::announcer::Announcer,
    announcer_assertive: crate::announcer::Announcer,
    /// Ring buffer of captured live-region announcements (see
    /// [`crate::accessibility::Announcement`]). Filled by a `&mut self`
    /// post-pass in `sync_accessibility` that diffs `Live::{Polite,
    /// Assertive}` nodes against `automation_last_text`. Capped at
    /// [`AUTOMATION_ANNOUNCE_CAP`]; drained by
    /// [`Self::announcements_since`].
    automation_announcements: std::collections::VecDeque<crate::accessibility::Announcement>,
    /// Monotonic sequence number for the next announcement (starts at 0;
    /// the first announcement is assigned `1`).
    automation_announce_seq: u64,
    /// Last announced text per live-region node, so a re-sync only emits a
    /// new announcement when the text actually changes. Pruned each pass
    /// to the set of currently-present live nodes, so a node that
    /// disappears and reappears with the same text re-announces.
    automation_last_text: std::collections::HashMap<accesskit::NodeId, String>,
    /// Whether the `WidgetEvent::AccessAction` currently being dispatched was
    /// consumed by a handler. Written by the dispatcher's `AccessAction` arm,
    /// read (and reset) by [`Self::dispatch_access_action`], which is the only
    /// caller that can answer "did anything happen?" to its own caller.
    ///
    /// A side channel because `dispatch_event_with_ops` returns `()` for every
    /// event kind, and routing AT actions through the *same* path as everything
    /// else is load-bearing (it is what lets an action open a window). The
    /// alternative — reporting success whenever a live widget merely *existed*
    /// at the target — is how an unhandled action came to look like a
    /// successful one to every automation client.
    access_action_handled: bool,
    /// The `WindowState` for this tree's hosting window. Populated
    /// by the app-level window manager when the tree is registered;
    /// `None` for standalone trees. Cloned into every `EventContext`
    /// and `BuildContext` so widgets can bind to the current window's
    /// signals via `ctx.window()`.
    pub(crate) window_state: Option<crate::window::WindowState>,
}

/// Maximum number of live-region [`crate::accessibility::Announcement`]s
/// the [`WidgetTree`] retains. The oldest is evicted when the buffer is
/// full; `announcements_since` only ever returns the retained tail.
const AUTOMATION_ANNOUNCE_CAP: usize = 256;

/// How long the shortened reshow delay stays active after the last tooltip
/// dismisses. Long enough to cover moving between adjacent toolbar icons;
/// short enough that a later, deliberate hover still pays the full initial
/// delay. Not a theme token — it is session bookkeeping, not a visual feel.
const TOOLTIP_SESSION_GRACE: std::time::Duration = std::time::Duration::from_millis(1000);

/// Number of visible steps a sticky-on-dwell tooltip's promotion window is
/// divided into.
///
/// The tree uses this only to decide *how often to wake* while a dwell is
/// running — one redraw per step boundary rather than a free-run — but it must
/// match the step count the content widget actually renders, or the indicator
/// would advance on a different beat from the wake-ups driving it.
/// `teksilo-widgets`' `DWELL_STEPS` is pinned to this value by a compile-time
/// assertion; the per-step *duration* is derived from each entry's own
/// `sticky_after`, so a caller that picks a non-default promotion window still
/// gets correctly-spaced wake-ups.
pub const TOOLTIP_DWELL_STEPS: u32 = 4;

/// Max pointer travel (logical px) from the hover-origin before a pending
/// tooltip timer restarts. Mirrors Windows hover-tracking slop
/// (`SPI_GETMOUSEHOVERWIDTH` / height, typically ~4 px): the tip waits for a
/// *paused* pointer, not merely "entered the bounds."
const TOOLTIP_STATIONARY_SLOP: f32 = 4.0;

/// A tooltip attachment managed by the WidgetTree.
struct TooltipEntry {
    anchor_id: WidgetId,
    content_id: WidgetId,
    /// The widget whose accessibility node should carry this tooltip's
    /// description, which is not always the node the overlay hangs off.
    ///
    /// A composing control anchors the *overlay* on an inner chrome node it
    /// built -- the thing with the right bounds to open a tooltip against --
    /// while its role, its name and its focusability live on its own outer
    /// node. A description on the inner one is a description an assistive
    /// technology never reads, because it never lands there.
    ///
    /// Recorded by `BuildContext`'s `attach_tooltip*` wrappers as the widget
    /// that was building at the time. Defaults to `anchor_id`, which is both
    /// the historic behaviour and the right answer for a widget that anchors
    /// its tooltip on itself.
    ///
    /// Naming an owner is a *claim*, not a guarantee: the accessibility walk
    /// honours it only where exactly one tooltip claims that node. See
    /// `WidgetTree::build_accessibility_recursive`.
    description_owner_id: WidgetId,
    delay: std::time::Duration,
    /// Simulated hover start (for deterministic tests via advance_time).
    hover_start: Option<std::time::Instant>,
    /// Real hover start (for windowed apps via layout).
    real_hover_start: Option<std::time::Instant>,
    /// Pointer position when the current pending hover started. Used to
    /// restart the delay if the pointer keeps moving inside the anchor
    /// (stationary-pointer intent filter).
    hover_origin: Option<teksilo_canvas::Point>,
    overlay_id: Option<crate::overlay::OverlayId>,
    /// When set, the tooltip auto-promotes to "sticky" after this
    /// much elapsed time since it was shown. The entry stays in the
    /// table and is just flagged sticky — the difference is that
    /// pointer-leave no longer dismisses it and the overlay's
    /// dismiss behavior is swapped to `EscapeOrClickOutside`.
    sticky_after: Option<std::time::Duration>,
    /// True when the dwell timer reached `sticky_after`. Causes
    /// `tooltip_pointer_leave` to skip the dismissal and lets the
    /// overlay survive pointer-leave until the user explicitly
    /// dismisses it via Escape or a click outside.
    is_sticky: bool,
    /// When the overlay was shown (simulated). Together with
    /// `sticky_after` drives auto-promotion.
    shown_at_sim: Option<std::time::Instant>,
    /// When the overlay was shown (real).
    shown_at_real: Option<std::time::Instant>,
    /// Optional shared sink the tooltip widget can read from to
    /// compute its own dwell progress. Mirrors `shown_at_real`:
    /// set on show, cleared on dismissal. Used by `RichTooltipWidget`
    /// to drive the dwell indicator without relying on a fragile
    /// paint-gap heuristic.
    shown_at_sink: Option<std::rc::Rc<std::cell::Cell<Option<std::time::Instant>>>>,
    /// True when the tooltip was shown by the keyboard-focus path
    /// rather than the pointer-hover path. Focus-promoted tooltips
    /// dismiss when focus moves outside both the anchor and the
    /// tooltip content subtree (preventing accumulation as the user
    /// Tabs through a form); pointer-dwelled stickies survive
    /// focus changes and only dismiss via Escape or click-outside.
    promoted_by_focus: bool,
    /// Set while this entry's pending delay was started by keyboard focus
    /// rather than by the pointer. Decides, at show time, that the surface
    /// dismisses on `Escape`/click-outside rather than on pointer-leave (there
    /// is no pointer in the story), and that it counts as focus-shown.
    armed_by_focus: bool,
    /// Set when the tip was dismissed while the focus that summoned it is
    /// still inside its anchor — i.e. Escape on a focus-promoted tooltip.
    ///
    /// Escape restores focus to the anchor, and that restore runs the ordinary
    /// focus path, which ends in `tooltip_focus_enter`. Without this flag the
    /// tip the user just dismissed re-opens on the same keystroke, because
    /// `dormant_dismissed_content` has already cleared `overlay_id` by then and
    /// the entry looks eligible again. Cleared when focus genuinely leaves the
    /// anchor (`tooltip_focus_leave_outside`), so Tabbing away and back
    /// re-summons it normally. The hover path needs no equivalent: a stationary
    /// pointer never re-fires `tooltip_pointer_enter`.
    suppressed_until_focus_leaves: bool,
    /// Where the tooltip opens relative to its anchor. `Below` (default)
    /// for the common case; `Side` for anchors stacked vertically (menu
    /// items, a vertical tab strip, list/tree rows) so the tooltip does
    /// not cover the next sibling. Consulted at show time in both the
    /// hover (`process_tooltips_impl`) and focus (`tooltip_focus_enter`)
    /// paths.
    placement: crate::overlay::TooltipPlacement,
}

/// A delayed overlay request (e.g., submenu hover-open delay).
struct PendingDelayedOverlay {
    request: crate::overlay::OverlayRequest,
    delay: std::time::Duration,
    focus_target: Option<WidgetId>,
    /// When the request was made (real time, for windowed apps).
    real_requested_at: std::time::Instant,
    /// When the request was made (simulated time, for tests).
    sim_requested_at: std::time::Instant,
}

impl WidgetTree {
    pub fn new() -> Self {
        let initial_theme = crate::presets::intui::light();
        // One signal, shared: the field the tree writes and the registry the
        // application reads must be the same one, or a host mirroring "is the
        // caret in a text widget" would never see focus move.
        let focused_signal = crate::signal::Signal::new(None);
        Self {
            arena: WidgetArena::new(),
            theme: initial_theme.clone(),
            theme_signal: crate::signal::Signal::new(initial_theme.clone()),
            user_text_scale: 1.0,
            effective_theme: initial_theme,
            effective_text_scale: 1.0,
            text_scale_signal: crate::signal::Signal::new(1.0),
            // Starts active: winit may not send `Focused(true)` for the first
            // window, so a window must not be born inactive (caret hidden,
            // selection muted) before the first focus event arrives.
            window_active_signal: crate::signal::Signal::new(true),
            text_backend: None,
            focused: None,
            focused_signal: focused_signal.clone(),
            hovered: None,
            hovered_signal: crate::signal::Signal::new(None),
            focus_visible: crate::signal::Signal::new(false),
            view_focus_stack: Vec::new(),
            last_pointer_position: None,
            pending_focus_restore: None,
            last_proposal: SizeProposal::exact(800.0, 600.0),
            pending_modal_requests: Vec::new(),
            pending_modal_dismissal: false,
            shortcut_registry: crate::shortcut::ShortcutRegistry::new(),
            pending_intents: Vec::new(),
            global_actions: Vec::new(),
            text_surfaces: crate::text_surface::TextSurfaces::new(focused_signal.clone()),
            key_capture: None,
            binding_registry: crate::binding::BindingRegistry::new(),
            idle_queue: crate::idle::IdleQueue::new(),
            sim_clock: std::time::Instant::now(),
            sim_driven: false,
            focus_origin: None,
            overlay_manager: crate::overlay::OverlayManager::new(),
            tooltips: Vec::new(),
            tooltip_session_until_sim: None,
            tooltip_session_until_real: None,
            highlight_tooltip: None,
            layout_direction: crate::environment::LayoutDirection::default(),
            animation_scheduler: crate::animation::AnimationScheduler::new(),
            animated_values: Vec::new(),
            animated_quads: crate::animated_quad::AnimatedQuadRegistry::new(),
            frame_tick_scheduler: crate::frame_tick_scheduler::FrameTickScheduler::new(),
            paint_epoch: 0,
            cached_a11y: None,
            a11y_dirty: true,
            last_synced_shortcut_version: 0,
            last_synced_locale: None,
            synthetic_parent_map: std::collections::HashMap::new(),
            cached_frame: None,
            pointer_captured_by: None,
            drag_observers: Vec::new(),
            current_cursor: crate::widget::CursorIcon::Default,
            pending_delayed_overlays: Vec::new(),
            active_ids_scratch: Vec::new(),
            gesture_owners: std::collections::HashSet::new(),
            prefers_high_contrast: false,
            prefers_reduced_motion: false,
            text_scale_factor: 1.0,
            device_scale_factor: 1.0,
            active_drag: None,
            outbound_drag_source: None,
            os_drag_reentered: false,
            title_bar_host: None,
            app_context: Rc::new(crate::event_source::TreeAppContext::empty()),
            locale: None,
            locale_signal: crate::signal::Signal::new(None),
            frame_tick: crate::signal::Signal::new(0.0_f32),
            frame_tick_requested: std::rc::Rc::new(std::cell::Cell::new(false)),
            in_focus_dispatch: std::rc::Rc::new(std::cell::Cell::new(false)),
            a11y_update_requested: std::rc::Rc::new(std::cell::Cell::new(false)),
            pending_wake_at: std::rc::Rc::new(std::cell::Cell::new(None)),
            pending_mount_actions: Vec::new(),
            last_frame_time: None,
            close_window_requested: false,
            force_close_requested: false,
            pending_locale_request: None,
            pending_theme_request: None,
            pending_follow_system_request: false,
            pending_text_scale_request: None,
            at_version: crate::signal::Signal::new(0),
            announcer_polite: crate::announcer::Announcer::new(
                crate::announcer::Politeness::Polite,
            ),
            announcer_assertive: crate::announcer::Announcer::new(
                crate::announcer::Politeness::Assertive,
            ),
            automation_announcements: std::collections::VecDeque::new(),
            automation_announce_seq: 0,
            automation_last_text: std::collections::HashMap::new(),
            access_action_handled: false,
            window_state: None,
        }
    }

    /// Construct an [`EventContext`]
    /// pre-populated with the tree's app-state registry, hosting
    /// `WindowState`, and a `&mut dyn WindowOps` handle so handlers
    /// can synchronously reach the multi-window API. Used by every
    /// dispatch site.
    pub(crate) fn make_event_context<'ops>(
        &self,
        ops: &'ops mut dyn crate::window::WindowOps,
    ) -> crate::widget::EventContext<'ops> {
        let drag_is_external = self.active_drag.as_ref().is_some_and(|d| d.is_external);
        // Read-only snapshot of tree query state that handlers may
        // need synchronously. Today this carries the last pointer
        // position and a (content_id, bounds) slice of open overlays
        // — both read by the safe-triangle submenu hover gate.
        let overlay_snapshot: Vec<(crate::widget_id::WidgetId, teksilo_canvas::Rect)> = self
            .overlay_manager
            .active_content_ids()
            .into_iter()
            .filter_map(|cid| {
                self.overlay_manager
                    .bounds_for_content(cid)
                    .map(|r| (cid, r))
            })
            .collect();
        crate::widget::EventContext::new()
            .with_app_context(self.app_context.clone())
            .with_window_context(ops, self.window_state.clone())
            .with_drag_external(drag_is_external)
            .with_query_snapshot(self.last_pointer_position, overlay_snapshot)
            .with_layout_direction(self.layout_direction)
            .with_window_active(self.is_window_active())
            .with_focus_dispatch_flag(self.in_focus_dispatch.clone())
    }

    /// Run a closure with a fresh [`EventContext`] anchored at this
    /// tree, then collect any pending operations queued through the
    /// context (intents, modal requests, frame requests, idle
    /// callbacks…) so they take effect on the next event-loop tick.
    ///
    /// Used by the `teksilo-app` event-loop dispatcher to deliver
    /// async-result callbacks (file dialogs, future background
    /// tasks) on the main thread with full handler-equivalent
    /// semantics. There is no source widget for app-level events,
    /// so intents are anchored at the tree's first root id (or
    /// silently dropped when the tree is empty).
    pub fn run_with_event_context<F>(&mut self, ops: &mut dyn crate::window::WindowOps, f: F)
    where
        F: FnOnce(&mut crate::widget::EventContext),
    {
        let mut ctx = self.make_event_context(ops);
        f(&mut ctx);
        let anchor = self.arena.roots().first().copied();
        if let Some(anchor_id) = anchor {
            self.collect_from_ctx(ctx, anchor_id);
        } else {
            // Empty tree — nothing to anchor intents on. Drop ctx;
            // its only side effects (frame requests, cursor) are
            // not meaningful for an empty tree.
            drop(ctx);
        }
    }

    /// Enqueue a one-shot action to run with a real
    /// [`EventContext`] after the current build,
    /// once the tree is mounted under its window. Used via
    /// [`BuildContext::run_after_mount`](crate::BuildContext::run_after_mount). Drained by
    /// [`Self::run_mount_actions`].
    pub(crate) fn queue_mount_action(
        &mut self,
        action: Box<dyn FnOnce(&mut crate::widget::EventContext)>,
    ) {
        self.pending_mount_actions.push(action);
    }

    /// Whether any post-mount actions are waiting to run.
    pub fn has_pending_mount_actions(&self) -> bool {
        !self.pending_mount_actions.is_empty()
    }

    /// Drain and run every queued post-mount action with a fresh
    /// [`EventContext`] built over `ops`. The app
    /// loop calls this each iteration with a real `WindowOps` sink (so
    /// `ctx.parent_window_handle()` resolves); headless tests call it with a
    /// `NoopWindowOps`. Actions enqueued *by* an action (rare) are left for the
    /// next drain rather than run re-entrantly.
    pub fn run_mount_actions(&mut self, ops: &mut dyn crate::window::WindowOps) {
        if self.pending_mount_actions.is_empty() {
            return;
        }
        let actions = std::mem::take(&mut self.pending_mount_actions);
        self.run_with_event_context(ops, move |ctx| {
            for action in actions {
                action(ctx);
            }
        });
    }

    /// Attach the [`WindowState`](crate::window::WindowState) for this
    /// tree's hosting window. Called by `WindowManager::create_window`.
    pub fn set_window_state(&mut self, state: crate::window::WindowState) {
        self.window_state = Some(state);
    }

    pub fn window_state(&self) -> Option<&crate::window::WindowState> {
        self.window_state.as_ref()
    }

    /// Clone the shared "frame requested" flag. Widgets stash this
    /// in their state and call `.set(true)` from inside frame-tick
    /// closures to chain-request another frame without needing
    /// mutable access to the tree. See `RichTextEditor` for the
    /// canonical use (caret blink, drag-select auto-scroll).
    pub fn frame_request_handle(&self) -> std::rc::Rc<std::cell::Cell<bool>> {
        self.frame_tick_requested.clone()
    }

    /// Clone the shared wake-at deadline cell. Widgets stash this in
    /// their state and call `request_wake_at` from frame-tick effects
    /// to schedule a one-shot deadline without keeping the event loop
    /// in `Poll` mode. On the next `layout()` at or past the deadline,
    /// the tree auto-arms `frame_tick_requested` so the effect runs on
    /// the wake-up pass. Canonical use: the rich text editor's caret
    /// blink schedules a 500 ms wake instead of pumping every frame.
    pub fn wake_at_handle(&self) -> std::rc::Rc<std::cell::Cell<Option<std::time::Instant>>> {
        self.pending_wake_at.clone()
    }

    /// Schedule a one-shot frame wake at `at`. Merges with any existing
    /// deadline — keeps the earlier instant so the most urgent wake
    /// wins.
    pub fn request_wake_at(&self, at: std::time::Instant) {
        let current = self.pending_wake_at.get();
        let merged = match current {
            Some(existing) if existing <= at => existing,
            _ => at,
        };
        self.pending_wake_at.set(Some(merged));
    }

    /// The per-frame delta-seconds signal. Observers fire **only on frames
    /// the tree was asked to pump** via [`request_frame`](Self::request_frame);
    /// merely observing the signal does not keep the event loop awake.
    /// See `BuildContext::frame_tick` for widget-side access and
    /// `BuildContext::request_frame` for the opt-in request side.
    pub fn frame_tick(&self) -> crate::signal::Signal<f32> {
        self.frame_tick.clone()
    }

    /// Ask the tree to pump exactly one more frame. `needs_redraw()`
    /// returns true until the request is consumed by the next
    /// `layout()` call, which fires the per-frame tick signal and
    /// clears the flag. Observers that still need more frames (drag
    /// auto-scroll, caret blink, pending document events) must call
    /// `request_frame()` again from inside their tick closure.
    ///
    /// Takes `&self` on purpose: widget handlers and per-frame effects
    /// receive a shared reference to the tree via `EventContext` /
    /// `BuildContext`, and the request flag is a `Cell` specifically so
    /// those shared paths can toggle it without ceremony.
    pub fn request_frame(&self) {
        self.frame_tick_requested.set(true);
    }

    /// Request that the AccessKit tree be re-walked on the next
    /// [`sync_accessibility`](Self::sync_accessibility). Takes `&self` (the
    /// flag is a `Cell`) so handlers and `build()` closures reaching the tree
    /// through a shared reference can request a re-walk without `&mut` access.
    /// The drain at the top of `sync_accessibility` flips `a11y_dirty`.
    pub fn request_accessibility_update(&self) {
        self.a11y_update_requested.set(true);
    }

    /// Speak `message` to the screen reader, politely.
    ///
    /// For anything the user needs told that is not the name of a widget: a
    /// completed action, a changed count, the result of an undo. The message is
    /// delivered on the next two accessibility syncs, which this schedules.
    ///
    /// Prefer `EventContext::announce` inside a handler and
    /// `BuildContext::announce` inside a build; this is the tree-level entry
    /// point both of those reach.
    ///
    /// Takes `impl Into<String>`, so `tr!(…)` works directly. See
    /// [`crate::announcer`] for why it is a `String` and not a
    /// `LocalizedString`, and for why an announcement beside a `Toast` says
    /// everything twice.
    pub fn announce(&mut self, message: impl Into<String>) {
        self.announce_with(message, crate::announcer::Politeness::Polite);
    }

    /// Speak `message` to the screen reader at the given urgency.
    ///
    /// [`Politeness::Assertive`](crate::announcer::Politeness::Assertive)
    /// interrupts whatever is being spoken; reserve it for something the user
    /// must not miss and cannot recover by re-reading the screen.
    pub fn announce_with(
        &mut self,
        message: impl Into<String>,
        politeness: crate::announcer::Politeness,
    ) {
        match politeness {
            crate::announcer::Politeness::Polite => self.announcer_polite.push(message.into()),
            crate::announcer::Politeness::Assertive => {
                self.announcer_assertive.push(message.into())
            }
        }
        // Two syncs are needed per message (expose, then retract), and a sync
        // only happens on a frame. Without both of these a message queued from
        // a handler that changed nothing visible would sit unspoken until
        // something else happened to redraw.
        self.request_accessibility_update();
        self.request_frame();
    }

    /// Clone the shared "accessibility re-walk requested" flag, for the same
    /// stash-and-toggle pattern as [`frame_request_handle`](Self::frame_request_handle).
    pub fn a11y_request_handle(&self) -> std::rc::Rc<std::cell::Cell<bool>> {
        self.a11y_update_requested.clone()
    }

    /// Whether a frame was explicitly requested. Exposed for tests and
    /// for the event-loop driver that decides when to schedule the next
    /// wake-up.
    pub fn frame_requested(&self) -> bool {
        self.frame_tick_requested.get()
    }

    /// The next wake-up deadline for the per-frame-effect path, or
    /// `None` when no per-frame effect is armed.
    ///
    /// This is the **60 Hz cap** for continuous per-frame animations
    /// (`Pulse`, caret blink, drag auto-scroll, `--cycle` drivers). The
    /// per-frame-effect path used to force `ControlFlow::Poll`, which
    /// free-runs at the display's refresh rate — so on a 300 Hz panel a
    /// single `Pulse`/`Cycle` rendered at 300 fps (measured ~45 % CPU) for
    /// motion that looks identical at 60 fps. Routing it through a fixed
    /// 16.667 ms deadline (folded into
    /// [`next_timer_deadline`](Self::next_timer_deadline)) makes it pace at
    /// 60 Hz regardless of refresh rate, matching the signal-tween
    /// [`AnimationScheduler`](crate::animation::AnimationScheduler) and
    /// shader-quad [`AnimatedQuadRegistry`](crate::animated_quad::AnimatedQuadRegistry),
    /// which already share the same interval.
    ///
    /// A **throttled** subscriber (registered via
    /// [`FrameTickScheduler::subscribe_throttled`](crate::frame_tick_scheduler::FrameTickScheduler::subscribe_throttled)
    /// — e.g. `Cycle`, whose visible child only changes once per period)
    /// stretches the deadline to its own interval: the loop then sleeps to
    /// the period instead of rendering identical 60 fps frames in between.
    /// The interval used is the **minimum across all currently-visible
    /// subscribers**, so a `Cycle` next to a `Pulse` still ticks at 60 Hz
    /// while a lone `Cycle` sleeps to its period. Raw `request_frame`
    /// consumers with no subscription fall back to 60 Hz.
    ///
    /// Paces from `last_frame_time` so the cadence is drift-free; before
    /// the first render it fires on the next loop turn.
    pub fn frame_tick_deadline(&self) -> Option<std::time::Instant> {
        // 60 Hz fallback for raw `request_frame` consumers (no subscriber).
        const DEFAULT_INTERVAL: std::time::Duration = std::time::Duration::from_micros(16_667);
        if !self.frame_tick_requested.get() {
            return None;
        }
        let interval = self
            .frame_tick_scheduler
            .min_visible_interval(&self.arena, self.paint_epoch)
            .unwrap_or(DEFAULT_INTERVAL);
        Some(match self.last_frame_time {
            Some(prev) => prev + interval,
            None => std::time::Instant::now(),
        })
    }

    /// Subscribe `owner` to the per-frame-effect scheduler. The
    /// returned [`FrameTickSubscription`](crate::frame_tick_scheduler::FrameTickSubscription)
    /// is an RAII guard — drop it (typically by replacing the field on
    /// the owning widget on rebuild, or letting the widget's `Drop`
    /// run) to remove the subscription. While the guard is alive, the
    /// tree will keep arming `frame_tick_requested` after every render
    /// in which `owner` was painted, and stop on frames where it
    /// wasn't — so a subscribed widget hidden inside a non-selected
    /// `Switcher` branch contributes zero idle frames.
    ///
    /// Apps should not call this directly — use
    /// [`BuildContext::subscribe_frame_tick`](crate::build_context::BuildContext::subscribe_frame_tick)
    /// from inside `Widget::build`.
    pub fn subscribe_frame_tick(
        &self,
        owner: WidgetId,
    ) -> crate::frame_tick_scheduler::FrameTickSubscription {
        self.frame_tick_scheduler.subscribe(owner)
    }

    /// Like [`subscribe_frame_tick`](Self::subscribe_frame_tick), but the
    /// owner only needs to wake **at most once per `interval`** while
    /// visible. Same visibility gate; between wakes the event loop sleeps
    /// to the interval deadline instead of rendering identical 60 fps
    /// frames. Use for effects whose visible output changes far less often
    /// than 60 Hz — e.g. `Cycle`'s once-per-period index advance.
    ///
    /// Apps should not call this directly — use
    /// [`BuildContext::subscribe_frame_tick_throttled`](crate::build_context::BuildContext::subscribe_frame_tick_throttled)
    /// from inside `Widget::build`.
    pub fn subscribe_frame_tick_throttled(
        &self,
        owner: WidgetId,
        interval: std::time::Duration,
    ) -> crate::frame_tick_scheduler::FrameTickSubscription {
        self.frame_tick_scheduler
            .subscribe_throttled(owner, interval)
    }

    /// Advance the frame tick signal when (and only when) a frame was
    /// requested. Called by `layout()` before the scheduler tick so the
    /// per-frame observers fire on the same frame they asked for.
    pub(crate) fn advance_frame_tick(&mut self, now: std::time::Instant) {
        if !self.frame_tick_requested.get() {
            self.last_frame_time = Some(now);
            return;
        }
        self.frame_tick_requested.set(false);
        let delta = match self.last_frame_time {
            Some(prev) => {
                let d = now.saturating_duration_since(prev).as_secs_f32();
                // Clamp absurd deltas (pause/breakpoint) so observers never see a spike.
                d.clamp(0.0, 0.1)
            }
            None => 0.0,
        };
        self.last_frame_time = Some(now);
        self.frame_tick.set(delta);
    }

    /// Replace the per-tree app context. Called by `teksilo-app` when
    /// constructing a window so the widget tree can reach the registered
    /// event source adapter and post subscription events through the
    /// event-loop proxy.
    pub fn set_app_context(&mut self, app_context: Rc<crate::event_source::TreeAppContext>) {
        self.app_context = app_context;
    }

    /// Get the per-tree app context. Used by `BuildContext::subscribe_event`
    /// and by the event-loop handler when dispatching incoming
    /// `AppEvent::SubscriptionEvent`.
    pub fn app_context(&self) -> &Rc<crate::event_source::TreeAppContext> {
        &self.app_context
    }

    /// Switch the tree-level locale at runtime.
    ///
    /// Updates `locale_signal` (a reactive `Signal<Option<String>>`) and marks
    /// all widgets dirty for relayout and repaint. Widgets are **not** rebuilt:
    /// per-string reactivity flows through `LocalizedString::to_signal()` which
    /// observes the teksilo-i18n manager, and anything else that depends on the
    /// tree-level locale can bind to `locale_signal()`.
    pub fn set_locale(&mut self, locale: String) {
        if self.locale.as_deref() == Some(locale.as_str()) {
            return;
        }
        let new = Some(locale);
        self.locale = new.clone();
        self.locale_signal.set(new);
        self.arena.mark_all_dirty();
    }

    /// Currently active locale identifier, if any.
    pub fn locale(&self) -> Option<&str> {
        self.locale.as_deref()
    }

    /// Reactive handle on the current locale. Mirrors `locale()` but updates
    /// observers when `set_locale` is called.
    pub fn locale_signal(&self) -> &crate::signal::Signal<Option<String>> {
        &self.locale_signal
    }

    fn pointer_inside_overlay_region(
        &self,
        overlay_id: crate::overlay::OverlayId,
        position: Point,
    ) -> bool {
        let Some(overlay) = self
            .overlay_manager
            .stack
            .iter()
            .find(|overlay| overlay.id == overlay_id)
        else {
            return false;
        };

        if self.arena.is_active(overlay.anchor)
            && self.arena.bounds(overlay.anchor).contains(position)
        {
            return true;
        }

        self.overlay_manager.stack.iter().any(|candidate| {
            (candidate.id == overlay_id
                || self
                    .overlay_manager
                    .is_descendant_of(candidate.id, overlay_id))
                && candidate.bounds.contains(position)
        })
    }

    fn update_pointer_leave_overlays(
        &mut self,
        position: Point,
        ops: &mut dyn crate::window::WindowOps,
    ) {
        let overlay_ids: Vec<crate::overlay::OverlayId> = self
            .overlay_manager
            .stack
            .iter()
            .filter(|overlay| {
                matches!(
                    overlay.dismiss,
                    crate::overlay::DismissBehavior::PointerLeave { .. }
                )
            })
            .map(|overlay| overlay.id)
            .collect();

        let real_now = std::time::Instant::now();
        let sim_now = self.sim_clock;

        for overlay_id in overlay_ids {
            let inside = self.pointer_inside_overlay_region(overlay_id, position);
            if let Some(overlay) = self
                .overlay_manager
                .stack
                .iter_mut()
                .find(|overlay| overlay.id == overlay_id)
            {
                if inside {
                    overlay.pointer_leave_started_real = None;
                    overlay.pointer_leave_started_sim = None;
                } else if overlay.pointer_leave_started_real.is_none() {
                    overlay.pointer_leave_started_real = Some(real_now);
                    overlay.pointer_leave_started_sim = Some(sim_now);
                    self.arena.mark_needs_paint(overlay.anchor);
                }
            }
        }

        self.process_pointer_leave_overlays_real(&mut *ops);
    }

    fn process_pointer_leave_overlays(&mut self) {
        let sim_now = self.sim_clock;
        let mut noop = crate::window::NoopWindowOps;
        self.process_pointer_leave_overlays_impl(
            |overlay| {
                overlay
                    .pointer_leave_started_sim
                    .map(|started| sim_now.saturating_duration_since(started))
            },
            &mut noop,
        );
    }

    fn process_pointer_leave_overlays_real(&mut self, ops: &mut dyn crate::window::WindowOps) {
        let real_now = std::time::Instant::now();
        self.process_pointer_leave_overlays_impl(
            |overlay| {
                overlay
                    .pointer_leave_started_real
                    .map(|started| real_now.saturating_duration_since(started))
            },
            &mut *ops,
        );
    }

    fn process_auto_dismiss_overlays(&mut self) {
        let sim_now = self.sim_clock;
        let mut noop = crate::window::NoopWindowOps;
        self.process_auto_dismiss_overlays_impl(
            |overlay| {
                overlay
                    .auto_dismiss_after
                    .map(|_| sim_now.saturating_duration_since(overlay.shown_at_sim))
            },
            &mut noop,
        );
    }

    fn process_auto_dismiss_overlays_real(&mut self, ops: &mut dyn crate::window::WindowOps) {
        let real_now = std::time::Instant::now();
        self.process_auto_dismiss_overlays_impl(
            |overlay| {
                overlay
                    .auto_dismiss_after
                    .map(|_| real_now.saturating_duration_since(overlay.shown_at_real))
            },
            &mut *ops,
        );
    }

    /// Drain overlays whose fade-out tween has completed (set up by
    /// `OverlayRequest::with_fade`). Same dormant-and-restore-focus
    /// flow as the normal dismiss path; called once per layout pass
    /// after `process_auto_dismiss_overlays_real` so an overlay that
    /// hits its auto-dismiss deadline kicks off its fade-out tween
    /// in the same pass.
    pub(crate) fn process_overlay_fade_dismissals_real(
        &mut self,
        ops: &mut dyn crate::window::WindowOps,
    ) {
        let now = std::time::Instant::now();
        let pending = self.overlay_manager.process_pending_fade_dismissals(now);
        for (_id, dismissed, focus_restore) in pending {
            self.dormant_dismissed_content(&dismissed, &mut *ops);
            if let Some(restore_id) = focus_restore
                && self.arena.is_active(restore_id)
            {
                self.focus_ops(restore_id, &mut *ops);
            }
        }
    }

    /// Sim-clock variant for headless tests. Same shape as
    /// [`process_overlay_fade_dismissals_real`](Self::process_overlay_fade_dismissals_real)
    /// but reads `dismissing_started_sim`.
    pub(crate) fn process_overlay_fade_dismissals_sim(&mut self) {
        let mut noop = crate::window::NoopWindowOps;
        let pending = self
            .overlay_manager
            .process_pending_fade_dismissals_sim(self.sim_clock);
        for (_id, dismissed, focus_restore) in pending {
            self.dormant_dismissed_content(&dismissed, &mut noop);
            if let Some(restore_id) = focus_restore
                && self.arena.is_active(restore_id)
            {
                self.focus_ops(restore_id, &mut noop);
            }
        }
    }

    fn process_auto_dismiss_overlays_impl(
        &mut self,
        elapsed_fn: impl Fn(&crate::overlay::ActiveOverlay) -> Option<std::time::Duration>,
        ops: &mut dyn crate::window::WindowOps,
    ) {
        let mut to_dismiss = Vec::new();

        for overlay in self.overlay_manager.stack.iter().rev() {
            let Some(delay) = overlay.auto_dismiss_after else {
                continue;
            };

            if to_dismiss
                .iter()
                .any(|ancestor| self.overlay_manager.is_descendant_of(overlay.id, *ancestor))
            {
                continue;
            }

            if let Some(elapsed) = elapsed_fn(overlay)
                && elapsed >= delay
            {
                to_dismiss.push(overlay.id);
            }
        }

        for overlay_id in to_dismiss {
            let (dismissed, focus_restore) =
                self.overlay_manager.dismiss_with_focus_restore(overlay_id);
            self.dormant_dismissed_content(&dismissed, &mut *ops);
            if let Some(restore_id) = focus_restore
                && self.arena.is_active(restore_id)
            {
                self.focus_ops(restore_id, &mut *ops);
            }
        }
    }

    fn process_pointer_leave_overlays_impl(
        &mut self,
        elapsed_fn: impl Fn(&crate::overlay::ActiveOverlay) -> Option<std::time::Duration>,
        ops: &mut dyn crate::window::WindowOps,
    ) {
        let mut to_dismiss = Vec::new();

        for overlay in self.overlay_manager.stack.iter().rev() {
            let crate::overlay::DismissBehavior::PointerLeave { delay } = overlay.dismiss else {
                continue;
            };

            if to_dismiss
                .iter()
                .any(|ancestor| self.overlay_manager.is_descendant_of(overlay.id, *ancestor))
            {
                continue;
            }

            if let Some(elapsed) = elapsed_fn(overlay)
                && elapsed >= delay
            {
                to_dismiss.push(overlay.id);
            }
        }

        for overlay_id in to_dismiss {
            let (dismissed, focus_restore) =
                self.overlay_manager.dismiss_with_focus_restore(overlay_id);
            self.dormant_dismissed_content(&dismissed, &mut *ops);
            if let Some(restore_id) = focus_restore
                && self.arena.is_active(restore_id)
            {
                self.focus_ops(restore_id, &mut *ops);
            }
        }
    }

    pub fn with_theme(mut self, theme: Theme) -> Self {
        // Update both the cached `Theme` AND the reactive
        // `theme_signal` — widgets that observe the signal (e.g.
        // `TextInputField` resetting the rich-text engine's default
        // text colour) would otherwise see the constructor's
        // `light_default()` initial value forever, even when
        // `TeksiloAppBuilder.theme(crate::presets::intui::dark())` was used.
        // `set_theme` already does this; `with_theme` was the
        // builder-time analogue that forgot to keep them aligned.
        self.theme = theme.clone();
        self.theme_signal.set(theme);
        self.recompute_effective_theme();
        self
    }

    pub fn with_text_backend(
        mut self,
        backend: Rc<RefCell<dyn teksilo_canvas::TextBackend>>,
    ) -> Self {
        self.text_backend = Some(backend);
        self
    }

    /// Attach a platform host for custom window chrome. Set by the
    /// `WindowManager` when the application opts in via
    /// `WindowConfig::custom_chrome(true)`. Widgets like `TitleBar` retrieve
    /// it from inside the root-builder closure via [`Self::title_bar_host`].
    pub fn with_title_bar_host(mut self, host: Rc<dyn crate::PlatformTitleBarHost>) -> Self {
        self.title_bar_host = Some(host);
        self
    }

    pub fn set_title_bar_host(&mut self, host: Rc<dyn crate::PlatformTitleBarHost>) {
        self.title_bar_host = Some(host);
    }

    /// Get the platform title bar host, if one was attached. Returns `None`
    /// when the application did not opt into custom chrome, or when the
    /// platform does not support it (X11 without an EWMH-capable window
    /// manager, or a headless build).
    pub fn title_bar_host(&self) -> Option<Rc<dyn crate::PlatformTitleBarHost>> {
        self.title_bar_host.clone()
    }

    pub fn theme(&self) -> &Theme {
        &self.theme
    }

    /// Reactive handle on the current theme. Updates fire when `set_theme`
    /// is called; widgets that want theme-derived values to stay live should
    /// build derived signals via `theme_signal.map(...)` or combine with
    /// other inputs using `.zip(...)`.
    pub fn theme_signal(&self) -> &crate::signal::Signal<Theme> {
        &self.theme_signal
    }

    /// Whether any widget needs layout or paint (i.e., a redraw would be useful).
    ///
    /// Uses `has_running` rather than `has_active` so that animations
    /// parked by the window-inactive gate stop forcing the event loop
    /// into `ControlFlow::WaitUntil`. Without this, an unfocused window
    /// would still wake at the animation frame interval and the
    /// pause would save nothing. Both the signal scheduler AND the
    /// shader-driven animated-quad registry are consulted — a
    /// ProgressBar::indeterminate whose widget has no pending paint
    /// dirt still needs the loop to keep waking at the animation
    /// frame interval so its phase advances.
    pub fn needs_redraw(&self) -> bool {
        self.arena.any_needs_layout()
            || self.arena.any_needs_paint()
            || self.animation_scheduler.has_running()
            || self.animated_quads.has_running()
            || self.frame_tick_requested.get()
    }

    /// Whether a render pass is needed (any widget needs layout or paint).
    pub fn needs_render(&self) -> bool {
        self.arena.any_needs_layout() || self.arena.any_needs_paint()
    }

    /// Whether this tree has reactive work that only a `layout()` pass can
    /// turn into arena dirt — i.e. whether reconciling it right now could
    /// change what [`needs_render`](Self::needs_render) reports.
    ///
    /// Read-only and cheap: `O(unique bound sources)` `u64` comparisons
    /// plus one peek per registered animated signal. No arena walk, no
    /// rebuilds, no geometry. Asking does not consume the answer, so it
    /// can be asked every dispatch.
    ///
    /// # Why this is exactly the right question, and no broader
    ///
    /// `teksilo_app::WindowManager::request_redraw_needing_render` exists
    /// for ONE case: a handler in window A wrote a `Signal` that window
    /// B's widgets also bind, and B — which never saw the event — must be
    /// reconciled before anyone can tell it needs repainting. Every OTHER
    /// thing `layout_with_ops` drives already has its own scheduling
    /// path and does not need this sweep:
    ///
    /// - tooltip dwell + sticky steps, delayed overlays, auto-dismiss,
    ///   overlay fades, the animation scheduler, animated quads,
    ///   gestures, `wake_at` and the 60 Hz frame tick are all timing
    ///   driven, and every one of them contributes to
    ///   [`next_timer_deadline`](Self::next_timer_deadline) — which
    ///   `request_redraw_due` polls to wake precisely the due windows;
    /// - drag ticks follow that window's own pointer stream;
    /// - a handler that called `request_rebuild` marked the arena
    ///   directly, so `needs_render()` is already true without any
    ///   reconcile.
    ///
    /// So the two terms below are what the sweep uniquely covers:
    /// binding-registry staleness (the whole point), and a *pending*
    /// `animate_to` — which the scheduler has not started yet, so it
    /// contributes no deadline, and which only `process_pending_animations`
    /// (inside `layout`) can promote into one. The second term is
    /// belt-and-braces: arming an animation also advances the signal's
    /// generation, so a bound animated signal is already covered by the
    /// first — but an animated signal registered without being bound
    /// would not be, and this makes that impossible to get wrong.
    pub fn needs_reconcile(&self) -> bool {
        self.binding_registry.any_dirty()
            || self
                .animated_values
                .iter()
                .any(AnimatedRegistration::has_pending_animation)
    }

    /// Register a `Signal<f32>` for animation support. The framework
    /// checks registered signals each frame for pending `animate_to`
    /// requests. Called automatically by `BuildContext::animated_signal()`
    /// — `owner` is `ctx.self_id()` of the widget whose `build()` created
    /// the signal. Used by the scheduler to pause/cancel animations when
    /// the owning widget is offscreen, dormant, or destroyed.
    pub fn register_animated_signal(
        &mut self,
        signal: &crate::signal::Signal<f32>,
        owner: WidgetId,
    ) {
        self.animated_values
            .retain(|registration| registration.is_alive());
        if let Some(existing) = self
            .animated_values
            .iter_mut()
            .find(|registration| registration.same_signal(signal))
        {
            // Signal may have been registered earlier with a placeholder
            // owner (e.g. a widget field constructed pre-build and
            // re-registered during build()) — prefer the latest owner.
            existing.owner = owner;
            return;
        }
        if let Some(weak_signal) = signal.weak_handle() {
            self.animated_values.push(AnimatedRegistration {
                weak: weak_signal,
                owner,
            });
        }
    }

    /// Whether any animation is currently running.
    pub fn has_active_animations(&self) -> bool {
        self.animation_scheduler.has_active()
    }

    /// The clock a newly promoted animation must be stamped with: the same one
    /// the scheduler will later be ticked against.
    ///
    /// Normally the wall clock. But once [`tick_animations`](Self::tick_animations)
    /// has driven this tree, the scheduler is *only* ever ticked at
    /// [`Self::sim_clock`] — so an animation stamped `Instant::now()` is measured
    /// against a clock that may never reach its start. A headless test
    /// interleaving `layout()` (which promotes) with `tick_animations()` (which
    /// ticks) advances the two clocks independently: simulated time by whatever
    /// the test asks for, real time by however long the test actually takes. The
    /// moment real time overtakes simulated time, every animation armed from then
    /// on has a start in the scheduler's future and its progress **freezes** —
    /// not slowly, completely, and no number of further ticks recovers it.
    ///
    /// That made animated layout tests fail as a function of machine load rather
    /// than of behaviour: green run alone or on a couple of threads, red once the
    /// runner filled the cores and each test's wall-clock time stretched past the
    /// simulated time it was asking for. The overlay manager already keeps its
    /// real and simulated timestamps apart for this reason; animations now agree
    /// on one clock the same way.
    fn animation_clock(&self) -> std::time::Instant {
        if self.sim_driven {
            self.sim_clock
        } else {
            std::time::Instant::now()
        }
    }

    /// Pick up pending `animate_to` requests from registered signals
    /// and start them on the animation scheduler.
    fn process_pending_animations(&mut self) {
        let now = self.animation_clock();
        self.process_pending_animations_at(now);
    }

    /// Pick up pending animations using the given time (for sim clock).
    fn process_pending_animations_at(&mut self, now: std::time::Instant) {
        let mut pending = Vec::new();
        self.animated_values.retain(|registration| {
            if let Some(animation) = registration.take_pending_animation() {
                pending.push(animation);
                true
            } else {
                registration.is_alive()
            }
        });

        for (signal, req, owner) in pending {
            if req.looping {
                let start = signal.get();
                self.animation_scheduler.animate_looping(
                    &signal,
                    owner,
                    start,
                    req.target,
                    req.duration,
                    req.easing,
                    req.frame_interval,
                    req.epsilon,
                    req.max_duration,
                    now,
                );
            } else {
                self.animation_scheduler.animate_with_options(
                    &signal,
                    owner,
                    req.target,
                    req.duration,
                    req.easing,
                    req.frame_interval,
                    req.epsilon,
                    req.max_duration,
                    now,
                );
            }
        }
    }

    /// Mark the owning window as active (focused AND not occluded) or
    /// inactive. Propagates to the animation scheduler AND the
    /// animated-quad registry so both pause-resume in lockstep — no
    /// ticks, no frame wakes, no GPU submits.
    ///
    /// On an actual state change it also fires `window_active_signal`
    /// (so build-time binders and `DimWhenInactive` react) and issues a
    /// global paint-only dirty mark, so every widget that reads
    /// `PaintContext::window_active` (caret gates, selection bands) repaints
    /// once. This is a repaint, not a relayout — geometry never changes when
    /// the window's active state flips (the caret keeps its space). Window
    /// focus changes are rare and user-driven, so the O(n) mark is cheap and
    /// Mark every node paint-dirty (no relayout, no rebuild) so the next
    /// render re-runs their `paint()`. This is the paint-cache invalidation an
    /// off-thread source needs after posting a [`RepaintWindowRequest`](crate::RepaintWindowRequest):
    /// a bare redraw request re-presents the cached frame, so a widget whose
    /// content changed off the UI thread (a terminal's PTY output) must be
    /// marked dirty for its `paint()` to run again.
    pub fn mark_all_needs_paint_only(&mut self) {
        self.arena.mark_all_needs_paint_only();
    }

    /// strictly lighter than `set_theme`'s `mark_all_dirty` (layout + paint).
    pub fn set_window_active(&mut self, active: bool) {
        let now = std::time::Instant::now();
        self.animation_scheduler.set_window_active(active, now);
        self.animated_quads.set_window_active(active, now);
        if self.window_active_signal.get() != active {
            self.window_active_signal.set(active);
            self.arena.mark_all_needs_paint_only();
            if !active {
                // The pointer has left for another window; the OS sends no
                // leave event we can rely on, so a tooltip shown at the moment
                // of the switch would float over the newly-focused window's
                // chrome with nothing left to dismiss it. Retire tips and
                // cancel pending dwells — but leave *sticky* ones, which the
                // user pinned deliberately and expects to find on return.
                self.tooltip_window_deactivated();
                // Same reasoning for a held pointer: a widget that captured
                // the pointer for a drag (a column-resize grip, a splitter
                // divider, a scrollbar thumb, a slider) will never see the
                // matching PointerUp — the user releases the button over the
                // window that took focus, and this window is told nothing.
                // Capture is otherwise cleared only by that Up or by the
                // widget going inactive, so leaving it set strands the whole
                // window: every subsequent PointerMove is redelivered to the
                // abandoned widget instead of hit-testing (killing hover,
                // cursor shapes and tooltips everywhere else), and the next
                // click's Up is swallowed by it, so the first press on any
                // release-activated control silently does nothing.
                self.pointer_captured_by = None;
            }
        }
    }

    /// Whether the owning window is currently active (`focused AND not
    /// occluded`). The reactive companion is [`Self::window_active_signal`].
    pub fn is_window_active(&self) -> bool {
        self.window_active_signal.get()
    }

    /// Reactive handle on window-active state. Fires when the window gains or
    /// loses active status. Bind at [`BindingLevel::RepaintOnly`] — an
    /// active-state flip never affects geometry. Starts `true`.
    ///
    /// [`BindingLevel::RepaintOnly`]: crate::binding::BindingLevel::RepaintOnly
    pub fn window_active_signal(&self) -> crate::signal::Signal<bool> {
        self.window_active_signal.clone()
    }

    /// Register a new animated quad for the currently-building widget.
    /// Called by [`crate::build_context::BuildContext::animated_quad`];
    /// returns an opaque handle the widget stashes for its `paint()`
    /// call.
    pub fn register_animated_quad(
        &mut self,
        owner: WidgetId,
        kind: crate::animated_quad::AnimatedQuadKind,
    ) -> crate::animated_quad::AnimatedQuadHandle {
        self.animated_quads
            .register(owner, kind, std::time::Instant::now())
    }

    /// Active animated-quad slot count. Test / debug helper.
    pub fn animated_quad_count(&self) -> usize {
        self.animated_quads.active_count()
    }

    /// Advance time-driven gesture recognizers (currently only
    /// [`crate::gesture::LongPressRecognizer`]) across every widget that
    /// has a gesture arena. Must be called by the event loop on each
    /// wake-up; otherwise long-press will never fire during an idle hold.
    ///
    /// When a recognizer transitions to `Recognized`, the corresponding
    /// handler on the owning widget is invoked with a fresh
    /// [`EventContext`], and any commands / overlay requests it emits are
    /// collected through the normal post-event path.
    pub fn tick_gestures(&mut self, now: std::time::Instant) {
        let mut noop = crate::window::NoopWindowOps;
        self.tick_gestures_with_ops(now, &mut noop);
    }

    /// App-facing variant of [`tick_gestures`](Self::tick_gestures)
    /// that accepts a real [`WindowOps`](crate::window::WindowOps)
    /// sink so gesture-recognized handlers can call the multi-window
    /// API synchronously.
    pub fn tick_gestures_with_ops(
        &mut self,
        now: std::time::Instant,
        ops: &mut dyn crate::window::WindowOps,
    ) {
        // Snapshot the gesture-owners set into the reusable scratch.
        // Previously this iterated every active widget; in practice
        // only a tiny fraction carry a gesture arena, so visiting the
        // rest was pure overhead.
        // `mem::take` lets the loop borrow `&mut self` for
        // `make_event_context` etc. without conflicting with the
        // scratch buffer; we put the storage back at the end.
        let mut ids = std::mem::take(&mut self.active_ids_scratch);
        ids.clear();
        ids.extend(
            self.gesture_owners
                .iter()
                .copied()
                .filter(|id| self.arena.is_active(*id)),
        );
        for &id in &ids {
            let gesture = match self.arena.get_mut(id) {
                Some(node) => node
                    .handlers
                    .gesture_arena
                    .as_mut()
                    .and_then(|arena| arena.tick(now)),
                None => None,
            };
            let Some(gesture) = gesture else { continue };

            let mut ctx = self.make_event_context(&mut *ops);
            if let Some(node) = self.arena.get_mut(id) {
                Self::dispatch_recognized_gesture(node, gesture, &mut ctx);
            }
            self.collect_from_ctx(ctx, id);
            self.arena.mark_needs_paint(id);
        }
        self.active_ids_scratch = ids;
    }

    /// Earliest wall-clock deadline at which any active gesture arena
    /// needs [`WidgetTree::tick_gestures`] called — typically a pending
    /// long-press timeout. Returns `None` when no recognizer is waiting.
    pub fn next_gesture_deadline(&self) -> Option<std::time::Instant> {
        // Iterate just the widgets that actually carry a gesture arena.
        // `filter` for `is_active` skips dormant entries that may still
        // be in the set after a hide-without-detach.
        self.gesture_owners
            .iter()
            .copied()
            .filter(|id| self.arena.is_active(*id))
            .filter_map(|id| self.arena.get(id))
            .filter_map(|node| node.handlers.gesture_arena.as_ref())
            .filter_map(|arena| arena.next_deadline())
            .min()
    }

    /// Advance animations by simulated time (for deterministic testing).
    /// Pending `animate_to` requests are started at the current sim_clock,
    /// then time advances by `duration`, and the scheduler ticks at the new time.
    pub fn tick_animations(&mut self, duration: std::time::Duration) {
        // From here on this tree is simulation-driven: `layout` must stamp the
        // animations it promotes with `sim_clock` too, or they are measured
        // against a clock that never reaches them. See `animation_clock`.
        self.sim_driven = true;
        self.process_pending_animations_at(self.sim_clock);

        self.sim_clock += duration;
        // Mirror onto the overlay manager so any fade-out tween
        // started during this tick stamps its sim-time start in
        // lockstep with real time.
        self.overlay_manager.set_sim_clock(self.sim_clock);

        if self.frame_tick_requested.get() {
            self.frame_tick_requested.set(false);
            let delta = duration.as_secs_f32().clamp(0.0, 0.1);
            self.frame_tick.set(delta);
        }

        self.animation_scheduler
            .tick(self.sim_clock, &self.arena, self.paint_epoch);

        // Simulated-time test helper — use NoopWindowOps; tests that
        // need a real sink call layout_with_ops / dispatch_event_with_ops
        // themselves.
        let mut noop = crate::window::NoopWindowOps;
        self.process_state_changes(&mut noop);
    }

    /// Switch the tree-level theme at runtime.
    ///
    /// Updates `theme_signal` (a reactive `Signal<Theme>`) and marks all widgets
    /// dirty for relayout and repaint. Widgets are **not** rebuilt: the
    /// `LayoutContext` and `PaintContext` already resolve the current theme on
    /// every pass, and any widget that derives state from theme tokens should
    /// do so through a `theme_signal()` subscription rather than a build-time
    /// capture. Preserves focus, scroll offsets, and other interaction state.
    pub fn set_theme(&mut self, theme: Theme) {
        self.theme = theme.clone();
        self.theme_signal.set(theme);
        self.recompute_effective_theme();
        self.arena.mark_all_dirty();
    }

    /// Recompute [`Self::effective_theme`] from the current `theme` and the
    /// combined text scale (`user_text_scale * text_scale_factor`). Callers
    /// that change either input are responsible for `mark_all_dirty()`.
    fn recompute_effective_theme(&mut self) {
        let combined = (self.user_text_scale as f64 * self.text_scale_factor) as f32;
        self.effective_theme = if (combined - 1.0).abs() < f32::EPSILON {
            self.theme.clone()
        } else {
            let mut t = self.theme.clone();
            t.typography = t.typography.scaled(combined);
            t
        };
        // Single source: every downstream consumer (the layout/paint context
        // `text_scale` field, the reactive `text_scale_signal`) reads from here.
        self.effective_text_scale = combined;
        self.text_scale_signal.set(combined);
    }

    /// The combined effective text scale (`user_text_scale * OS text_scale_factor`).
    /// Read by the layout/paint walkers to populate `ctx.text_scale` for widgets
    /// that size from a source other than `Theme.typography`.
    pub fn effective_text_scale(&self) -> f32 {
        self.effective_text_scale
    }

    /// Reactive handle on [`Self::effective_text_scale`]. Build-time binders that
    /// must react to a scale change without their own rebuild path bind this
    /// (e.g. `Calendar` binds it at `Rebuild` level so its fixed cell constants
    /// recompute). Fires on `set_user_text_scale` / theme / OS-pref change.
    pub fn text_scale_signal(&self) -> crate::signal::Signal<f32> {
        self.text_scale_signal.clone()
    }

    /// Set the user-controlled global text-scale factor (`1.0` = 100 %).
    ///
    /// The factor multiplies with the OS accessibility text-scale preference to
    /// produce the rendered scale. Recomputes the effective theme and marks all
    /// widgets dirty so every text widget grows on the next pass; no rebuild,
    /// so focus/scroll/interaction state survive. Values outside `[0.25, 8.0]`
    /// are clamped. Persisted by the application via
    /// `teksilo_settings::TEXT_SCALE_KEY`.
    pub fn set_user_text_scale(&mut self, factor: f32) {
        let clamped = factor.clamp(0.25, 8.0);
        if (self.user_text_scale - clamped).abs() < f32::EPSILON {
            return;
        }
        self.user_text_scale = clamped;
        self.recompute_effective_theme();
        self.arena.mark_all_dirty();
    }

    /// The current user-controlled text-scale factor (`1.0` = 100 %).
    pub fn user_text_scale(&self) -> f32 {
        self.user_text_scale
    }

    /// After a rebuild that destroyed subtrees — or after a `visible_when` /
    /// `Switcher` pass parks the focused widget dormant — drop any interaction
    /// state (focus, hover) whose target `WidgetId` is no longer active.
    ///
    /// **FocusLost is load-bearing.** Clearing `self.focused` alone leaves the
    /// widget's own `on_focus` / `has_focus` / caret-blink state thinking it is
    /// still focused. A rich-text editor in that state keeps scheduling
    /// `wake_at` caret toggles and re-arming `frame_request` from its tick
    /// effect — and because `frame_tick` observers are **not** gated on
    /// dormancy, every open tab's editor (TabWidget mounts them all) still runs
    /// on those wakes. Rapid tab switches that park a focused editor without a
    /// real focus move (programmatic selection, race with pointer focus) used
    /// to accumulate stuck "focused" editors and unbounded frame work. Dispatch
    /// `FocusLost` first so widgets clear that state, then drop the tree's
    /// focus pointer.
    ///
    /// Preserves state when the target still exists *and* is active. Called from
    /// data-driven rebuild paths (`process_state_changes`) and after every
    /// rebuild drain; theme and locale switches no longer rebuild.
    pub(crate) fn revalidate_interaction_state(&mut self, ops: &mut dyn crate::window::WindowOps) {
        if let Some(id) = self.focused
            && !self.arena.is_active(id)
        {
            let old = self.focused;
            // Deliver FocusLost while the node still exists (dormant or about to
            // be torn down). Skip if the node is already gone — destroy paths
            // take care of bookkeeping without a deliverable target.
            if self.arena.get(id).is_some() {
                // Direct: no bubble through dormant ancestors, no overlay
                // dismiss side-effects — this is a teardown signal, not a
                // user-driven focus move.
                self.dispatch_to_widget_direct(
                    id,
                    &crate::event::WidgetEvent::FocusLost,
                    &mut *ops,
                );
            }
            self.set_focused(None);
            self.focus_origin = None;
            self.update_focus_within_signals(old, None);
            self.update_view_focus_signals(old, None);
            self.a11y_dirty = true;
        }
        if self.focused.is_none() {
            self.focus_origin = None;
        }
        if let Some(id) = self.hovered
            && !self.arena.is_active(id)
        {
            let old = self.hovered;
            self.set_hovered(None);
            self.update_hover_within_signals(old, None);
        }
        // Pointer capture anchored at a destroyed widget would otherwise
        // swallow every subsequent Move/Up — dispatch_to_widget rejects
        // inactive targets. Drop the capture so events resume normal
        // hit-test dispatch. Same for any in-flight drag session whose
        // source was torn down: the user sees the drag "stick".
        if let Some(id) = self.pointer_captured_by
            && !self.arena.is_active(id)
        {
            self.pointer_captured_by = None;
        }
        // External (OS) drags have no in-app source widget, so they are never
        // torn down by source destruction — only internal drags are salvaged.
        let source_gone = self
            .active_drag
            .as_ref()
            .and_then(|s| s.source_widget)
            .is_some_and(|sw| !self.arena.is_active(sw));
        if source_gone {
            // `cancel_active_drag` fires on_drag_leave on the current
            // target before cleanup — the same contract as Escape.
            self.cancel_active_drag(&mut *ops);
        } else {
            // The drag *source* survived but its current hover **target** was
            // torn down by the rebuild (e.g. a side disabled / collapsed
            // mid-drag destroyed the panel under the pointer). Clear the stale
            // target id so a subsequent drop doesn't resolve to a destroyed
            // widget (and silently vanish); the next move — or the drop's own
            // re-hit-test — re-engages a live target.
            let stale_target = self
                .active_drag
                .as_ref()
                .and_then(|d| d.current_target)
                .is_some_and(|t| !self.arena.is_active(t));
            if stale_target && let Some(drag) = self.active_drag.as_mut() {
                drag.current_target = None;
            }
        }
    }

    /// Rebuild a single composite widget: destroy old children, re-run `build()`,
    /// and wire up new children. Called from `process_state_changes()` when a
    /// binding at `BindingLevel::Rebuild` fires (data-driven rebuild). Theme
    /// and locale changes do **not** rebuild — they update reactive signals
    /// that widgets bind to via `theme_signal()` / `locale_signal()`.
    /// Test-only: force-mark a widget for rebuild on the next layout
    /// pass. Lets regression tests exercise the rebuild path without
    /// needing to trip a Signal binding. Exposed cross-crate (not
    /// `#[cfg(test)]`-gated) so widget-crate tests in `teksilo-widgets`
    /// and elsewhere can also drive rebuilds; the `_for_testing`
    /// suffix marks it as not intended for application code.
    pub fn arena_mark_needs_rebuild_for_testing(&mut self, id: WidgetId) {
        self.arena.mark_needs_rebuild(id);
    }

    /// Force a [`DeferredSubtree`](crate::deferred_subtree::DeferredSubtree) at
    /// `id` to build its content now. A no-op for any other widget.
    ///
    /// The framework's own door into deferred content, for the case where the
    /// decision to show is the tree's rather than a widget's: a tooltip whose
    /// dwell has just matured has no open signal anyone could have handed over.
    pub(crate) fn materialize_deferred(&mut self, id: WidgetId) {
        let forced = self
            .arena
            .get_mut(id)
            .and_then(|n| n.widget.as_any_mut())
            .and_then(|any| any.downcast_mut::<crate::deferred_subtree::DeferredSubtree>())
            .map(|deferred| {
                let needed = !deferred.is_materialized();
                deferred.force();
                needed
            })
            .unwrap_or(false);
        if forced {
            self.rebuild_single_widget(id);
        }
    }

    pub(crate) fn rebuild_single_widget(&mut self, widget_id: WidgetId) {
        // Per §9.4.5, drop the source handle first (stops further source-side
        // dispatch) and then remove the UI-side callback. Either order gives
        // the same user-visible outcome for events that get posted between
        // the two steps, but dropping the source handle first stops the
        // publisher thread's work sooner.
        //
        // ⚠ That reasoning is about the two steps below, and it used to be
        // read as covering the whole problem. It does not. The dangerous gap
        // is not the microseconds between these two lines, it is the whole
        // span from *publish* to *dispatch*: a backend event is posted with
        // the id its publisher captured and is handled by the UI thread
        // frames later, so any rebuild in between used to strand it. The ids
        // are therefore carried across into the new build (see
        // `BuildContext::reusable_sub_ids`) rather than being retired here.
        // Skribisto's Analysis pane hit this every time it was the restored
        // view at project open: it starts a long operation in `build()` and
        // sets its own `Rebuild`-bound state signal, the operation finished
        // inside its own rebuild, and the pane sat on "Reading the
        // manuscript…" for the rest of the session with nothing logged.
        // Cancel any looping/one-shot animations owned by this widget
        // before build() runs. Without this, a widget that creates a
        // fresh `animated_signal` in build() would leak the previous
        // instance's scheduler entry: the old Signal<f32> clone lives
        // in `animations` forever, ticking against an orphaned signal
        // (silent CPU waste) and, for looping animations, doubling up
        // when the new one registers.
        self.animation_scheduler.cancel_by_widget(widget_id);
        // Same pattern for shader-driven animated quads: free the
        // widget's slot(s) so `build()` can allocate fresh handles.
        // The old cached_paint (if any) carries stale slot indices —
        // clear it so paint() re-runs and re-emits DrawCommands with
        // the newly-allocated slot.
        self.animated_quads.cancel_by_widget(widget_id);

        let drained_subs = if let Some(node) = self.arena.get_mut(widget_id) {
            node.effect_handles.clear();
            node.actions.clear();
            node.dirty.needs_rebuild = false;
            node.cached_paint = None;
            node.dirty.needs_paint = true;
            // Reset only the OWN handler bucket so `apply_self_handlers`
            // during this build's fresh build() starts from empty and
            // doesn't stack N-fold handler chains across rebuilds.
            // `external_handlers` — set by the `WidgetBuilder` chain at
            // creation time or by a composing parent's
            // `apply_handlers(child_id, ...)` — persists: those handlers
            // come from outside the widget and aren't re-emitted by its
            // own `build()`.
            //
            // `node_focusable` / `node_tab_index` / `node_cursor` /
            // `clips_children` / `context_menu_factory` are simple
            // values, not accumulating closures. Leave them alone —
            // apply_self_handlers rewrites them if the new build
            // specifies non-None values; otherwise values from the
            // creation site survive the rebuild.
            node.handlers = crate::event_handlers::EventHandlers::new();
            std::mem::take(&mut node.subscription_handles)
        } else {
            Vec::new()
        };
        // Rebuild wiped the OWN handler bucket above (the gesture arena
        // lived there), so the widget no longer owns any recognizers.
        // The next pointer hit re-runs `ensure_gesture_arena` and
        // re-inserts if the new build still wires gesture handlers.
        // External handlers (set via the builder chain at creation time)
        // persist, but `external_handlers` never carries a gesture arena
        // directly — it's always built by `ensure_gesture_arena` into
        // the OWN bucket.
        self.gesture_owners.remove(&widget_id);
        // Shortcuts the widget declared are torn down too — they will
        // be re-registered during the upcoming `build()` call. User
        // overrides live in a separate map keyed by id, so user
        // rebindings survive this round-trip (see ShortcutRegistry
        // graveyard semantics).
        self.shortcut_registry.unregister_all_for_owner(widget_id);
        self.global_actions.retain(|(owner, _)| *owner != widget_id);
        self.text_surfaces.remove(widget_id);
        // Re-apply `Widget::declare_shortcuts` so the static metadata
        // survives the rebuild (the unregister above wiped both
        // declared and build-registered entries; build() will refill
        // the handler-bearing ones, but it can't be relied on to
        // refill the metadata-only declarations).
        self.apply_declared_shortcuts(widget_id);
        // Drop any signal→widget bindings from the previous build
        // cycle so `build()` can re-register a fresh set without
        // accumulating duplicates across rebuilds.
        self.binding_registry.unregister_for_widget(widget_id);
        // Kept, in order, and handed to the upcoming `build()` so it re-subscribes under
        // the same ids. A subscription's id is what a publisher captured and posted with;
        // minting new ones here would leave every event already queued for this widget
        // naming an id nothing answers to. See `BuildContext::reusable_sub_ids`.
        let mut reusable_sub_ids = Vec::with_capacity(drained_subs.len());
        for (sub_id, handle) in drained_subs {
            drop(handle);
            self.app_context
                .subscription_callbacks
                .borrow_mut()
                .remove(&sub_id);
            self.app_context
                .subscription_ctx_callbacks
                .borrow_mut()
                .remove(&sub_id);
            reusable_sub_ids.push(sub_id);
        }

        // Decide how to treat the existing children. Two modes:
        //
        // * Default (`preserves_children_on_rebuild() == false`): the widget
        //   re-derives its whole subtree, so tear down every old child up
        //   front and let `build()` produce a fresh set.
        //
        // * Reconcile (`preserves_children_on_rebuild() == true`): the widget
        //   re-attaches the children it keeps (by id) and drops the rest. We
        //   snapshot the old children, run `build()`, then destroy only the
        //   old children the new build did NOT re-attach and did NOT re-parent
        //   elsewhere. Re-attached children keep their state (focus, scroll,
        //   text, subscriptions); dropped children are reaped rather than left
        //   as stranded, still-active orphans.
        let preserve_children = self
            .arena
            .get(widget_id)
            .map(|n| n.widget.preserves_children_on_rebuild())
            .unwrap_or(false);
        let old_children: Vec<WidgetId> = self.arena.children(widget_id).to_vec();
        if !preserve_children {
            for child_id in &old_children {
                self.destroy_subtree(*child_id);
            }
        }

        // The parentless nodes the *previous* build owned. Taken now so
        // `build()` records its new set into an empty list, and destroyed after
        // it returns — by then the widget's own fields point at the new nodes,
        // so tearing the old ones down cannot strand a live id in the widget.
        // Both the `preserve_children` reconcile and the plain path want this:
        // detached content is rebuilt wholesale either way (it is not addressed
        // by id from the outside, so there is nothing to preserve).
        let old_detached: Vec<WidgetId> = self
            .arena
            .get_mut(widget_id)
            .map(|node| std::mem::take(&mut node.detached))
            .unwrap_or_default();

        let mut widget_box = match self.arena.take_widget(widget_id) {
            Some(widget) => widget,
            None => return,
        };

        let mut build_ctx = crate::build_context::BuildContext {
            tree: self,
            composite_id: Some(widget_id),
            effect_handles: Vec::new(),
            subscription_handles: Vec::new(),
            reusable_sub_ids,
        };
        let new_children = widget_box.build(&mut build_ctx);
        let effect_handles = std::mem::take(&mut build_ctx.effect_handles);
        let subscription_handles = std::mem::take(&mut build_ctx.subscription_handles);

        self.arena.restore_widget(widget_id, widget_box);

        for &child_id in &new_children {
            if let Some(child_node) = self.arena.get_mut(child_id) {
                child_node.parent = Some(widget_id);
            }
        }

        // Reconcile the preserve path: reap any old child the new build
        // dropped (not in `new_children`) and did not re-parent elsewhere
        // (its `parent` still points here). Authoritative parent pointers mean
        // a kept subtree re-parented out of a dropped sibling survives. Runs
        // before `node.children` is overwritten so the destroy walk can't see
        // the new list. Re-parented survivors already have their new parent by
        // now (`ctx.add` builds nested widgets synchronously and re-homes their
        // children), so the `parent == widget_id` test correctly excludes them.
        if preserve_children {
            let new_set: std::collections::HashSet<WidgetId> =
                new_children.iter().copied().collect();
            for &old_c in &old_children {
                if !new_set.contains(&old_c) && self.arena.parent(old_c) == Some(widget_id) {
                    self.destroy_subtree_inner(old_c, true);
                }
            }
        }

        if let Some(node) = self.arena.get_mut(widget_id) {
            node.children = new_children;
            node.effect_handles = effect_handles;
            node.subscription_handles = subscription_handles;
        }

        // Reap the previous build's parentless content, now that the fresh set
        // is recorded and the widget points at it.
        for id in old_detached {
            self.destroy_subtree_inner(id, false);
        }
    }

    /// Record that `owner` created and owns the parentless node `detached` —
    /// pre-built overlay content that is deliberately not a child. See
    /// [`BuildContext::add_detached`](crate::build_context::BuildContext::add_detached)
    /// and [`WidgetNode::detached`](crate::arena::WidgetNode).
    pub(crate) fn record_detached(&mut self, owner: WidgetId, detached: WidgetId) {
        if let Some(node) = self.arena.get_mut(owner) {
            node.detached.push(detached);
        }
    }

    /// Destroy every parentless node `owner` owns, and forget them.
    ///
    /// Taken out of the node first: the destroy walk below can re-enter this
    /// function (a detached node may own detached nodes of its own — a rich
    /// tooltip's cascade children each pre-build their own), and it must not
    /// see a list it is halfway through consuming.
    fn destroy_detached_of(&mut self, owner: WidgetId) {
        let detached = self
            .arena
            .get_mut(owner)
            .map(|node| std::mem::take(&mut node.detached))
            .unwrap_or_default();
        for id in detached {
            self.destroy_subtree_inner(id, false);
        }
    }

    /// Recursively destroy a subtree, dropping per-widget subscription
    /// handles and removing their UI-side callbacks. Use this in place of
    /// `arena.destroy()` whenever a widget that may have subscribed to
    /// events is being torn down.
    pub(crate) fn destroy_subtree(&mut self, widget_id: WidgetId) {
        self.destroy_subtree_inner(widget_id, false);
    }

    /// Shared teardown for [`destroy_subtree`](Self::destroy_subtree) and the
    /// reconciling rebuild path. When `reparent_aware` is `true`, recursion
    /// descends into a child only if that child's `parent` still points at
    /// `widget_id`.
    ///
    /// A reconciling rebuild (a [`preserves_children_on_rebuild`] widget) may
    /// re-parent a kept subtree *out* of a dropped sibling and *into* the new
    /// tree. The dropped sibling's `children` list still lists that subtree
    /// (stale), so following it would tear down a node that is actually alive
    /// elsewhere. Following the authoritative `parent` pointer instead stops
    /// at the boundary of what genuinely still belongs to the node being
    /// destroyed. The per-node teardown ends with `arena.remove_node` (a
    /// single-node removal), NOT `arena.destroy` (which would re-recurse the
    /// stale `children` list and undo the skip).
    ///
    /// [`preserves_children_on_rebuild`]: crate::widget::Widget::preserves_children_on_rebuild
    fn destroy_subtree_inner(&mut self, widget_id: WidgetId, reparent_aware: bool) {
        // See the matching cancel in `rebuild_single_widget` — the
        // scheduler holds strong Signal<f32> clones, so the animation
        // would outlive its widget without this explicit cancellation.
        self.animation_scheduler.cancel_by_widget(widget_id);
        // Release the animated-quad slot(s) too.
        self.animated_quads.cancel_by_widget(widget_id);
        // A tooltip's content widget is parentless (`ctx.add`), so the child
        // walk below never reaches it — reap it explicitly or the entry and
        // its node outlive the anchor for the lifetime of the tree.
        self.retire_tooltips_of_destroyed_anchor(widget_id);
        // Same reasoning, one level up: every *other* parentless node this
        // widget built (a dropdown menu, a calendar, a tooltip's nested
        // cascade children) is unreachable from the child walk and dies here
        // or never.
        self.destroy_detached_of(widget_id);

        let children: Vec<WidgetId> = self.arena.children(widget_id).to_vec();
        for child in children {
            if reparent_aware && self.arena.parent(child) != Some(widget_id) {
                // Re-parented into the surviving tree by this rebuild — leave it.
                continue;
            }
            self.destroy_subtree_inner(child, reparent_aware);
        }
        let drained_subs = self
            .arena
            .get_mut(widget_id)
            .map(|node| std::mem::take(&mut node.subscription_handles))
            .unwrap_or_default();
        for (sub_id, handle) in drained_subs {
            drop(handle);
            self.app_context
                .subscription_callbacks
                .borrow_mut()
                .remove(&sub_id);
            self.app_context
                .subscription_ctx_callbacks
                .borrow_mut()
                .remove(&sub_id);
        }
        // Drop any shortcuts the destroyed widget owned. Unlike
        // `rebuild_single_widget`, destruction is permanent; if the
        // user had overrides, they stay in the graveyard.
        self.shortcut_registry.unregister_all_for_owner(widget_id);
        self.global_actions.retain(|(owner, _)| *owner != widget_id);
        self.text_surfaces.remove(widget_id);
        // Bindings from this widget stop being relevant; clean them
        // up so the registry doesn't leak dead entries for the
        // lifetime of the app.
        self.binding_registry.unregister_for_widget(widget_id);
        // Keep `gesture_owners` honest — destroying the widget tears
        // down its handlers, so the per-frame gesture pass must stop
        // visiting it.
        self.gesture_owners.remove(&widget_id);
        // If focus pointed at the widget about to disappear, drop it
        // so later dispatch doesn't anchor intent walks at a dead id
        // (which would silently swallow the intent).
        if self.focused == Some(widget_id) {
            let old = self.focused;
            self.set_focused(None);
            self.focus_origin = None;
            self.update_focus_within_signals(old, None);
            self.update_view_focus_signals(old, None);
        }
        if self.hovered == Some(widget_id) {
            let old = self.hovered;
            self.set_hovered(None);
            self.update_hover_within_signals(old, None);
        }
        // Symmetric with focus/hover above: a pointer capture anchored at the
        // widget about to disappear would otherwise swallow every subsequent
        // Move/Up (dispatch rejects inactive targets) until the next layout
        // pass runs `revalidate_interaction_state`. Drop it eagerly so capture
        // never outlives its owner, even when a destroy happens mid-gesture.
        if self.pointer_captured_by == Some(widget_id) {
            self.pointer_captured_by = None;
        }
        // Single-node removal: this function already recursed into the
        // children above (honouring re-parenting when `reparent_aware`).
        // `arena.destroy` would re-recurse the now-stale `children` list and
        // tear down a survivor re-homed out of this subtree.
        self.arena.remove_node(widget_id);
    }

    /// Set the layout direction (LTR/RTL). Marks all widgets as needing layout.
    pub fn set_layout_direction(&mut self, direction: crate::environment::LayoutDirection) {
        self.layout_direction = direction;
        self.arena.mark_all_dirty();
    }

    /// The current layout direction.
    pub fn layout_direction(&self) -> crate::environment::LayoutDirection {
        self.layout_direction
    }

    /// Set OS-level accessibility preferences.
    ///
    /// Called by `teksilo-app` after querying the platform layer. Updates the
    /// values fed into `PaintContext` and `Environment` on subsequent frames.
    /// Marks all widgets dirty so the new preferences take effect immediately.
    pub fn set_accessibility_preferences(
        &mut self,
        high_contrast: bool,
        reduced_motion: bool,
        text_scale_factor: f64,
    ) {
        let changed = self.prefers_high_contrast != high_contrast
            || self.prefers_reduced_motion != reduced_motion
            || (self.text_scale_factor - text_scale_factor).abs() > f64::EPSILON;

        if changed {
            self.prefers_high_contrast = high_contrast;
            self.prefers_reduced_motion = reduced_motion;
            self.text_scale_factor = text_scale_factor;
            // The OS factor feeds the effective text scale (multiplied with the
            // user factor), so refresh the cached scaled typography.
            self.recompute_effective_theme();
            self.arena.mark_all_dirty();
        }
    }

    /// Whether the OS has requested high-contrast mode.
    pub fn prefers_high_contrast(&self) -> bool {
        self.prefers_high_contrast
    }

    /// Whether the OS has requested reduced motion.
    pub fn prefers_reduced_motion(&self) -> bool {
        self.prefers_reduced_motion
    }

    /// OS text scaling factor (1.0 = normal).
    pub fn text_scale_factor(&self) -> f64 {
        self.text_scale_factor
    }

    /// Set the host window HiDPI device scale (physical px per logical px).
    /// Called by `teksilo-app` before each layout from
    /// `platform_window.scale_factor()`. Surfaced to widgets via
    /// `LayoutContext::scale_factor`. No dirty-marking: it rides the layout
    /// pass that follows, and a scale change already triggers a relayout.
    pub fn set_device_scale_factor(&mut self, scale_factor: f32) {
        self.device_scale_factor = scale_factor;
    }

    /// The host window HiDPI device scale most recently set (1.0 by default).
    pub fn device_scale_factor(&self) -> f32 {
        self.device_scale_factor
    }

    /// Mark a widget as clipping its children to its bounds (scroll areas).
    pub fn set_clips_children(&mut self, id: WidgetId, clips: bool) {
        self.arena.set_clips_children(id, clips);
    }

    /// Apply a `HandlerSet` to an existing node in the arena, routed
    /// into the rebuild-cleared `handlers` slot (the widget's own
    /// self-applied handlers).
    /// Register any *bound* builder-level accessibility Props
    /// (`access_hidden` / `access_label` / `access_description` /
    /// `access_value`) at `BindingLevel::AccessibilityOnly`, so that a change
    /// to the underlying signal flips `a11y_dirty` and the AccessKit tree
    /// re-walks — re-resolving the announced hidden-state / name / description
    /// / value — without a visual relayout. Static Props are ignored by
    /// `register_if_bound`. Takes the registry explicitly (rather than `&self`)
    /// so insertion-path callers can keep a disjoint `&mut self.arena` borrow
    /// on the node alive.
    fn register_access_prop_bindings(
        access: &crate::widget_builder::AccessibilityOverrides,
        id: WidgetId,
        registry: &crate::binding::BindingRegistry,
    ) {
        use crate::binding::BindingLevel::AccessibilityOnly;
        if let Some(p) = access.hidden.as_ref() {
            p.register_if_bound(id, registry, AccessibilityOnly);
        }
        if let Some(p) = access.label.as_ref() {
            p.register_if_bound(id, registry, AccessibilityOnly);
        }
        if let Some(p) = access.description.as_ref() {
            p.register_if_bound(id, registry, AccessibilityOnly);
        }
        if let Some(p) = access.value.as_ref() {
            p.register_if_bound(id, registry, AccessibilityOnly);
        }
    }

    pub(crate) fn apply_self_handler_set(
        &mut self,
        id: WidgetId,
        mut handler_set: crate::widget_builder::HandlerSet,
    ) {
        // `visible_when` needs the binding registry (which the arena lacks), so
        // pull it out here and apply it via `self.visible_when` after.
        let visible_when = handler_set.visible_when.take();
        // Same reason for the reactive access Props: the arena can't reach the
        // registry, so register them here before handing the set to the arena.
        if let Some(access) = handler_set.access.as_ref() {
            Self::register_access_prop_bindings(access, id, &self.binding_registry);
        }
        self.arena
            .apply_handler_set(id, handler_set, crate::arena::HandlerScope::Own);
        if let Some(prop) = visible_when {
            self.visible_when(id, prop);
        }
    }

    /// Apply a `HandlerSet` to an existing node as *external* handlers —
    /// the kind attached by a composing parent via
    /// `BuildContext::apply_handlers(child_id, ...)` or by the
    /// `WidgetBuilder` chain at insertion time. These persist across
    /// the target widget's own rebuilds.
    pub(crate) fn apply_external_handler_set(
        &mut self,
        id: WidgetId,
        mut handler_set: crate::widget_builder::HandlerSet,
    ) {
        // See `apply_self_handler_set`: route `visible_when` and the reactive
        // access Props through the registry (the arena can't reach it).
        let visible_when = handler_set.visible_when.take();
        if let Some(access) = handler_set.access.as_ref() {
            Self::register_access_prop_bindings(access, id, &self.binding_registry);
        }
        self.arena
            .apply_handler_set(id, handler_set, crate::arena::HandlerScope::External);
        if let Some(prop) = visible_when {
            self.visible_when(id, prop);
        }
    }

    /// Append an accessibility `labelled_by` relation onto an already-mounted
    /// node, *preserving* any overrides the widget already carries — unlike
    /// `apply_external_handler_set`, which replaces the whole override struct.
    /// Used by container widgets (e.g. `FormLayout`) to name a field after its
    /// label once both ids are known. Idempotent-ish: re-adding the same target
    /// pushes a duplicate, so call once per pairing.
    pub(crate) fn push_access_labelled_by(&mut self, id: WidgetId, label_id: WidgetId) {
        if let Some(node) = self.arena.get_mut(id) {
            node.access_overrides
                .get_or_insert_with(|| {
                    Box::new(crate::widget_builder::AccessibilityOverrides::default())
                })
                .labelled_by
                .push(label_id);
            self.a11y_dirty = true;
        }
    }

    /// Append an accessibility `described_by` relation onto an already-mounted
    /// node, preserving existing overrides (the `described_by` counterpart of
    /// [`push_access_labelled_by`](Self::push_access_labelled_by)).
    pub(crate) fn push_access_described_by(&mut self, id: WidgetId, target_id: WidgetId) {
        if let Some(node) = self.arena.get_mut(id) {
            node.access_overrides
                .get_or_insert_with(|| {
                    Box::new(crate::widget_builder::AccessibilityOverrides::default())
                })
                .described_by
                .push(target_id);
            self.a11y_dirty = true;
        }
    }

    /// Set a per-child alignment override on a widget.
    pub fn set_alignment(&mut self, id: WidgetId, alignment: teksilo_tokens::Alignment) {
        self.arena.set_alignment_override(id, alignment);
    }

    /// Get the binding registry for registering State→Widget bindings.
    pub fn binding_registry(&self) -> &crate::binding::BindingRegistry {
        &self.binding_registry
    }

    /// Shared access to the shortcut registry. Widgets register their
    /// default shortcuts through here during `build()` (via
    /// `BuildContext::register_shortcut`); settings UIs and
    /// persistence layers read and mutate overrides directly.
    pub fn shortcut_registry(&self) -> &crate::shortcut::ShortcutRegistry {
        &self.shortcut_registry
    }

    pub fn shortcut_registry_mut(&mut self) -> &mut crate::shortcut::ShortcutRegistry {
        &mut self.shortcut_registry
    }

    /// Install a one-shot key-capture callback, returning a
    /// [`CaptureHandle`](crate::shortcut::CaptureHandle) whose `Drop`
    /// cancels the capture if it hasn't already fired. The next
    /// `KeyDown` the tree receives bypasses shortcut-registry lookup
    /// and invokes the callback with:
    /// - the captured [`KeyStroke`](crate::shortcut::KeyStroke)
    /// - mutable access to the registry (rebind in-place)
    /// - a mutable [`EventContext`] (so the handler can also emit
    ///   commands, send intents, dismiss overlays, …)
    ///
    /// Calling this while a previous capture is armed creates a
    /// **separate** slot; the prior handle, when eventually dropped,
    /// cancels only its own (now-orphaned) slot. The new capture
    /// wins.
    pub fn begin_key_capture(
        &mut self,
        callback: impl FnOnce(
            crate::shortcut::KeyStroke,
            &mut crate::shortcut::ShortcutRegistry,
            &mut EventContext,
        ) + 'static,
    ) -> crate::shortcut::CaptureHandle {
        let slot: crate::shortcut::KeyCaptureSlot =
            std::rc::Rc::new(std::cell::RefCell::new(Some(Box::new(callback))));
        self.key_capture = Some(slot.clone());
        crate::shortcut::CaptureHandle::new(slot)
    }

    /// Cancel any currently-armed key capture without invoking it.
    /// Equivalent to dropping the [`CaptureHandle`](crate::shortcut::CaptureHandle),
    /// but exposed here so callers that lost the handle (or never
    /// kept one) can still bail out.
    pub fn cancel_key_capture(&mut self) {
        if let Some(slot) = self.key_capture.take() {
            slot.borrow_mut().take();
        }
    }

    /// Whether a key-capture callback is currently armed.
    pub fn is_capturing_keys(&self) -> bool {
        self.key_capture
            .as_ref()
            .map(|slot| slot.borrow().is_some())
            .unwrap_or(false)
    }

    /// Consume any pending key-capture callback. Used internally by
    /// the dispatch path — returns the boxed closure so the caller
    /// can invoke it once the KeyStroke has been constructed. Also
    /// drops the outer `Option<Rc<...>>` so `is_capturing_keys` goes
    /// back to `false`.
    pub(crate) fn take_key_capture(&mut self) -> Option<crate::shortcut::KeyCaptureCallback> {
        let slot = self.key_capture.take()?;
        slot.borrow_mut().take()
    }

    /// Append an [`Action`](crate::action::Action) to a widget's arena
    /// node. Invoked by `BuildContext::register_action`; not meant
    /// to be called directly.
    /// Record that `widget_id` edits text. Replaces any previous registration
    /// from the same widget, so a rebuild re-points rather than accumulating.
    pub(crate) fn push_text_surface(
        &mut self,
        widget_id: WidgetId,
        surface: std::rc::Rc<dyn crate::text_surface::TextSurface>,
    ) {
        self.text_surfaces.insert(widget_id, surface);
    }

    /// A cloneable view of this tree's text surfaces, for a caller that must ask
    /// the question later, without a `&WidgetTree` in hand.
    pub fn text_surfaces(&self) -> crate::text_surface::TextSurfaces {
        self.text_surfaces.clone()
    }

    /// The text-editing widget that currently holds the keyboard focus.
    ///
    /// `None` when focus is elsewhere — or nowhere — which is exactly what a
    /// host needs in order to know that a text chord is safe to route itself.
    pub fn focused_text_surface(
        &self,
    ) -> Option<std::rc::Rc<dyn crate::text_surface::TextSurface>> {
        self.text_surfaces.focused()
    }

    /// Is the keyboard focus inside a widget that edits text?
    ///
    /// The cheap half of [`focused_text_surface`](Self::focused_text_surface),
    /// for a host that only needs to decide whether to step aside.
    pub fn focused_is_text_surface(&self) -> bool {
        self.text_surfaces.focused_is_text_surface()
    }

    pub(crate) fn push_action(&mut self, widget_id: WidgetId, action: crate::action::Action) {
        if let Some(node) = self.arena.get_mut(widget_id) {
            node.actions.push(action);
        }
    }

    /// Telemetry dispatch tap. Looks up a registered
    /// [`crate::telemetry::TelemetryContext`] in `app_state` and emits
    /// an `intent.dispatched` event with the intent's name. No-op when
    /// no telemetry is configured. Errors and consent gating are
    /// handled inside the reporter — this site only needs to call
    /// `record`.
    fn tap_intent_dispatched(&self, intent: &crate::intent::Intent) {
        let Some(tcx) = self
            .app_context()
            .app_state::<crate::telemetry::TelemetryContext>()
        else {
            return;
        };
        let install_id = tcx.reporter.install_id();
        let props = [
            crate::telemetry::Prop {
                key: "name",
                value: crate::telemetry::PropValue::StaticStr(intent.name),
            },
            crate::telemetry::Prop {
                key: "source",
                value: crate::telemetry::PropValue::Enum {
                    variant: intent.source.as_str(),
                },
            },
        ];
        let event = crate::telemetry::Event {
            name: "intent.dispatched",
            category: crate::telemetry::EventCategory::Intent,
            timestamp: std::time::SystemTime::now(),
            install_id,
            session_id: &tcx.session_id,
            schema_version: tcx.schema_version,
            props: &props,
        };
        tcx.reporter.record(&event);
    }

    /// Histogram of widget concrete-type names across the active
    /// arena. Used by the `widget.census` telemetry emitter to surface
    /// "which widgets does this app actually use" data back to the
    /// framework. Keyed by
    /// `std::any::type_name::<T>()` of the concrete widget — a
    /// dotted, fully-qualified path like
    /// `teksilo_widgets::button::Button`.
    ///
    /// `&'static str` keys: `type_name_of_val` returns a
    /// compile-time string, so the histogram preserves the static
    /// lifetime all the way to the wire-format prop. This avoids
    /// any allocation for the type-name strings themselves.
    ///
    /// Cost: one `Box<dyn Widget>` indirection per active node plus
    /// a `HashMap` insert. Sub-millisecond on arenas with thousands
    /// of widgets. Safe to call every frame in tests; in production
    /// gate behind a periodic ticker (hourly or on-idle).
    pub fn widget_type_histogram(&self) -> std::collections::HashMap<&'static str, u32> {
        let mut out = std::collections::HashMap::<&'static str, u32>::new();
        for id in self.arena.active_ids_iter() {
            if let Some(node) = self.arena.get(id) {
                // `Widget::type_name` is monomorphized per impl, so
                // calling through the vtable correctly resolves to
                // the concrete type — `type_name_of_val(&*widget)`
                // alone would collapse to `"dyn teksilo_core::widget::Widget"`.
                let name: &'static str = node.widget.type_name();
                *out.entry(name).or_insert(0) += 1;
            }
        }
        out
    }

    /// Number of active widgets in the arena. Cheap; matches the
    /// totals returned by `widget_type_histogram` when summed.
    pub fn active_widget_count(&self) -> usize {
        self.arena.active_ids_iter().count()
    }

    /// Enqueue an intent for dispatch from `source`. Called from
    /// `collect_from_ctx` after a handler runs `ctx.send_intent(...)`
    /// and from the KeyDown shortcut-interception path.
    pub(crate) fn enqueue_intent(
        &mut self,
        source: WidgetId,
        intent: crate::intent::Intent,
        propagate_when_disabled: bool,
    ) {
        self.pending_intents
            .push((source, intent, propagate_when_disabled));
    }

    /// Dispatch every queued intent. Handlers may call
    /// `ctx.send_intent(...)` to enqueue more; the loop consumes
    /// those too until the queue drains. No ordering guarantee
    /// beyond "first-enqueued is first-dispatched"; the `pop` path
    /// uses `remove(0)` to keep that FIFO behavior.
    pub(crate) fn drain_pending_intents(&mut self, ops: &mut dyn crate::window::WindowOps) {
        while !self.pending_intents.is_empty() {
            let (source, intent, propagate) = self.pending_intents.remove(0);
            self.dispatch_intent(source, intent, propagate, &mut *ops);
        }
    }

    /// Walk `source → root` invoking any [`Action`](crate::action::Action)
    /// whose `intent` name matches. The first enabled, `Handled`
    /// response stops the walk. A `Propagated` or disabled action
    /// (when the shortcut's `propagate_when_disabled` is true) lets
    /// the walk continue. A disabled action with
    /// `propagate_when_disabled == false` consumes the intent at that
    /// level without invoking a handler.
    pub(crate) fn dispatch_intent(
        &mut self,
        source: WidgetId,
        intent: crate::intent::Intent,
        propagate_when_disabled: bool,
        ops: &mut dyn crate::window::WindowOps,
    ) {
        // Telemetry tap. Single insertion point catches every intent
        // — shortcut-driven, programmatic via `send_intent`, etc. —
        // because every dispatch funnels through here. A no-op when
        // no `TelemetryContext` is registered.
        self.tap_intent_dispatched(&intent);

        // Pre-compute the source → root chain so the walk doesn't
        // need to hold any arena borrow while invoking handlers.
        let chain: Vec<WidgetId> = {
            let mut v = vec![source];
            let mut current = self.arena.parent(source);
            while let Some(id) = current {
                v.push(id);
                current = self.arena.parent(id);
            }
            v
        };

        for id in chain {
            if !self.arena.is_active(id) || !self.arena.is_enabled(id) {
                continue;
            }

            // Take out the first matching action by intent name so
            // we can invoke its FnMut handler without holding an
            // arena-wide borrow. The action is reinserted at its
            // original position so declaration order is preserved
            // for any follow-on dispatch.
            let Some((mut action, idx, enabled)) = self.arena.get_mut(id).and_then(|node| {
                let idx = node.actions.iter().position(|a| a.intent == intent.name)?;
                let enabled = node.actions[idx].is_enabled();
                Some((node.actions.remove(idx), idx, enabled))
            }) else {
                continue;
            };

            if !enabled {
                // Return the action untouched.
                if let Some(node) = self.arena.get_mut(id) {
                    node.actions.insert(idx, action);
                }
                if propagate_when_disabled {
                    continue;
                }
                return;
            }

            let mut ctx = self.make_event_context(&mut *ops);
            let response = (action.handler)(&intent, &mut ctx);
            if let Some(node) = self.arena.get_mut(id) {
                node.actions.insert(idx, action);
            }
            self.collect_from_ctx(ctx, id);

            match response {
                crate::intent::IntentResponse::Handled => return,
                crate::intent::IntentResponse::Propagated => continue,
            }
        }

        // Fallback: window-global actions (registered via
        // `register_action_global`). The source→root walk found no consuming
        // node action, so consult app-global commands — reachable regardless of
        // where the intent originated (menu-bar overlay, content, shortcut).
        let mut i = 0;
        while i < self.global_actions.len() {
            let matches = {
                let (_, action) = &self.global_actions[i];
                action.intent == intent.name && action.is_enabled()
            };
            if !matches {
                i += 1;
                continue;
            }
            // Take the action out so the FnMut handler can run without holding a
            // borrow on `self`; reinsert at its slot afterwards.
            let (owner, mut action) = self.global_actions.remove(i);
            let mut ctx = self.make_event_context(&mut *ops);
            let response = (action.handler)(&intent, &mut ctx);
            self.global_actions.insert(i, (owner, action));
            self.collect_from_ctx(ctx, owner);
            match response {
                crate::intent::IntentResponse::Handled => return,
                crate::intent::IntentResponse::Propagated => {
                    i += 1;
                    continue;
                }
            }
        }
    }

    /// Register a window-global [`Action`](crate::action::Action) owned by
    /// `owner`. Consulted as a dispatch fallback (see [`Self::dispatch_intent`]);
    /// torn down when `owner` rebuilds or is destroyed. Backs
    /// [`BuildContext::register_action_global`](crate::BuildContext::register_action_global).
    pub(crate) fn push_global_action(&mut self, owner: WidgetId, action: crate::action::Action) {
        self.global_actions.push((owner, action));
    }

    // --- Window-close request (drained by the app loop) ---

    /// Drain the "close this window" flag set by
    /// [`EventContext::close_window`] during dispatch. A *guarded* close
    /// — the app routes it through the window's close guard.
    pub fn take_close_window_request(&mut self) -> bool {
        std::mem::replace(&mut self.close_window_requested, false)
    }

    /// Drain the "close this window, no questions asked" flag set by
    /// [`EventContext::close_window_forced`] during dispatch. An
    /// *unconditional* close that bypasses the window's close guard.
    pub fn take_force_close_request(&mut self) -> bool {
        std::mem::replace(&mut self.force_close_requested, false)
    }

    /// Drain the pending locale switch raised by
    /// [`EventContext::set_locale`] during dispatch. The app layer
    /// (`WindowManager::drain_pending_locale_requests`) parses the
    /// result and routes it through `WindowManager::set_locale` so the
    /// `I18nManager`'s active locale, version signal, and layout
    /// direction all stay in sync with the tree.
    pub fn take_pending_locale_request(&mut self) -> Option<String> {
        self.pending_locale_request.take()
    }

    /// Drain the pending theme switch raised by
    /// [`EventContext::set_theme`] during dispatch. The app layer
    /// (`WindowManager::drain_pending_theme_requests`) routes it through
    /// `WindowManager::set_theme` so the new theme is applied to every
    /// window, not just the one whose handler requested it.
    pub fn take_pending_theme_request(&mut self) -> Option<crate::styles::Theme> {
        self.pending_theme_request.take()
    }

    /// Drain the pending "follow OS theme" request raised by
    /// [`EventContext::follow_system_theme`] during dispatch. The app layer
    /// (`WindowManager::drain_pending_follow_system_requests`) switches to
    /// `ThemeMode::Native` and recomputes the theme from the OS for every
    /// window. Returns `true` if a request was pending.
    pub fn take_pending_follow_system_request(&mut self) -> bool {
        std::mem::take(&mut self.pending_follow_system_request)
    }

    /// Drain the pending text-scale change raised by
    /// [`EventContext::set_text_scale`] during dispatch. The app layer
    /// (`WindowManager::drain_pending_text_scale_requests`) routes it through
    /// `WindowManager::set_text_scale` so the new factor is applied to every
    /// window, not just the one whose handler requested it.
    pub fn take_pending_text_scale_request(&mut self) -> Option<f32> {
        self.pending_text_scale_request.take()
    }

    /// Drain all pending modal requests recorded during event handling.
    ///
    /// Each request includes the originating widget so higher layers can
    /// resolve routing and focus behavior relative to the source tree.
    pub fn drain_pending_modal_requests(&mut self) -> Vec<crate::modal::QueuedModalRequest> {
        std::mem::take(&mut self.pending_modal_requests)
    }

    /// Drain whether the current native modal window should be dismissed.
    pub fn drain_pending_modal_dismissal(&mut self) -> bool {
        std::mem::replace(&mut self.pending_modal_dismissal, false)
    }

    // --- Widget insertion ---

    /// Walk `Widget::declare_shortcuts` for an already-inserted widget
    /// and register every returned shortcut with the registry, owned
    /// by `id`. Called at insertion AND at rebuild so the declared
    /// metadata survives across rebuilds (which `unregister_all_for_owner`
    /// would otherwise wipe). Build-time `ctx.register_shortcut` calls
    /// upsert handlers on top; the registry is idempotent on id.
    pub(crate) fn apply_declared_shortcuts(&mut self, id: WidgetId) {
        let declared = self
            .arena
            .get(id)
            .map(|n| n.widget.declare_shortcuts())
            .unwrap_or_default();
        for shortcut in declared {
            self.shortcut_registry.register_owned(shortcut, id);
        }
    }

    /// Internal: insert a widget, call build(), wire children, register clips.
    fn insert_widget(&mut self, widget: Box<dyn Widget>) -> WidgetId {
        let id = self.arena.insert(widget);

        {
            if let Some(mut widget_box) = self.arena.take_widget(id) {
                if let Some(handler_set) = widget_box.take_handler_set() {
                    self.arena.restore_widget(id, widget_box);
                    if let Some(node) = self.arena.get_mut(id) {
                        // Handlers attached at the widget's creation site
                        // are external from its own perspective — keep
                        // them out of the rebuild-cleared `handlers`
                        // slot so they survive data-driven rebuilds.
                        node.external_handlers = handler_set.handlers;
                        node.node_focusable = handler_set.focusable;
                        node.node_tab_index = handler_set.tab_index;
                        node.node_cursor = handler_set.cursor;
                        // `clips_children` and `event_pass_through` are
                        // node-level flags on `WidgetNode` — they must
                        // be mirrored here too. Without this an
                        // `Inner::new().event_pass_through(true)` chain
                        // silently no-ops (the flag stays at default
                        // `false`), and any widget wrapped with it
                        // catches every pointer event in its bounds.
                        if let Some(clips) = handler_set.clips_children {
                            node.clips_children = clips;
                        }
                        if let Some(pass_through) = handler_set.event_pass_through {
                            node.event_pass_through = pass_through;
                        }
                        if let Some(dead_zone) = handler_set.gesture_dead_zone {
                            node.gesture_dead_zone = dead_zone;
                        }
                        if let Some(keyboard_capture) = handler_set.keyboard_capture {
                            node.keyboard_capture = keyboard_capture;
                        }
                        if let Some(hit_transparent) = handler_set.hit_transparent {
                            node.hit_transparent = hit_transparent;
                        }
                        if handler_set.context_menu_factory.is_some() {
                            node.context_menu_factory = handler_set.context_menu_factory;
                        }
                        if let Some(sig) = handler_set.focus_within {
                            node.focus_within_signal = Some(sig);
                        }
                        if let Some(sig) = handler_set.hover_within {
                            node.hover_within_signal = Some(sig);
                        }
                        // Builder-chained `visible_when: prop`. Mirror of
                        // `WidgetTree::visible_when`: register a bound prop at
                        // Relayout, then store it on the node. (Disjoint field
                        // borrow, like `access_hidden` below.)
                        if let Some(prop) = handler_set.visible_when {
                            prop.register_if_bound(
                                id,
                                &self.binding_registry,
                                crate::binding::BindingLevel::Relayout,
                            );
                            node.visible_state = Some(prop);
                        }
                        // Builder-level accessibility overrides + subtree
                        // mode. Mirrored here because this insertion path
                        // bypasses `apply_handler_set`.
                        if handler_set.access.is_some() {
                            // Register any bound access_hidden/label/description/
                            // value Props at AccessibilityOnly so the AT tree
                            // re-walks when they flip. (Disjoint field borrow:
                            // `node` borrows `self.arena`, this reads
                            // `self.binding_registry`.)
                            if let Some(access) = handler_set.access.as_ref() {
                                Self::register_access_prop_bindings(
                                    access,
                                    id,
                                    &self.binding_registry,
                                );
                            }
                            node.access_overrides = handler_set.access;
                        }
                        if let Some(mode) = handler_set.access_subtree {
                            node.access_subtree = mode;
                        }
                    }
                } else {
                    self.arena.restore_widget(id, widget_box);
                }
            }
        }

        // Walk Widget::declare_shortcuts before build() so the
        // declared metadata lands in the registry first; if build()
        // also registers the same id with a real on_activate, the
        // registry upserts (preserving any user override).
        self.apply_declared_shortcuts(id);

        {
            let mut widget_box = match self.arena.take_widget(id) {
                Some(widget) => widget,
                None => return id,
            };
            let mut build_ctx = crate::build_context::BuildContext {
                tree: self,
                composite_id: Some(id),
                effect_handles: Vec::new(),
                subscription_handles: Vec::new(),
                // A first mount has no previous build to inherit ids from.
                reusable_sub_ids: Vec::new(),
            };
            let built_children = widget_box.build(&mut build_ctx);
            let effect_handles = std::mem::take(&mut build_ctx.effect_handles);
            let subscription_handles = std::mem::take(&mut build_ctx.subscription_handles);

            self.arena.restore_widget(id, widget_box);

            // Transfer per-widget handles to the node. Both lists are
            // stored unconditionally — a leaf widget that registers an
            // effect in its build() still needs its ObserverHandle to
            // persist (otherwise the effect unregisters the moment
            // BuildContext drops).
            if let Some(node) = self.arena.get_mut(id) {
                node.subscription_handles = subscription_handles;
                node.effect_handles = effect_handles;
            }

            if !built_children.is_empty() {
                for &child_id in &built_children {
                    if let Some(child_node) = self.arena.get_mut(child_id) {
                        child_node.parent = Some(id);
                    }
                }
                if let Some(node) = self.arena.get_mut(id) {
                    node.children = built_children;
                }
            }
        }

        let clips = self
            .arena
            .get(id)
            .is_some_and(|node| node.widget.clips_children());
        if clips {
            self.arena.set_clips_children(id, true);
        }

        id
    }

    /// Add a widget to the tree.
    pub fn add(&mut self, widget: impl Widget + 'static) -> WidgetId {
        self.insert_widget(Box::new(widget))
    }

    /// Add a pre-boxed widget to the tree.
    pub fn add_boxed(&mut self, widget: Box<dyn Widget>) -> WidgetId {
        self.insert_widget(widget)
    }

    /// Add a widget as a child of another widget.
    pub fn add_child(&mut self, parent: WidgetId, widget: impl Widget + 'static) -> WidgetId {
        let boxed: Box<dyn Widget> = Box::new(widget);

        let id = self.arena.insert_child(parent, boxed);

        {
            if let Some(mut widget_box) = self.arena.take_widget(id) {
                if let Some(handler_set) = widget_box.take_handler_set() {
                    self.arena.restore_widget(id, widget_box);
                    if let Some(node) = self.arena.get_mut(id) {
                        // Creation-site handlers are external (persist
                        // across the widget's own rebuilds) — see the
                        // matching block in `insert_widget`.
                        node.external_handlers = handler_set.handlers;
                        node.node_focusable = handler_set.focusable;
                        node.node_tab_index = handler_set.tab_index;
                        node.node_cursor = handler_set.cursor;
                        if let Some(clips) = handler_set.clips_children {
                            node.clips_children = clips;
                        }
                        if let Some(pass_through) = handler_set.event_pass_through {
                            node.event_pass_through = pass_through;
                        }
                        if let Some(dead_zone) = handler_set.gesture_dead_zone {
                            node.gesture_dead_zone = dead_zone;
                        }
                        if let Some(keyboard_capture) = handler_set.keyboard_capture {
                            node.keyboard_capture = keyboard_capture;
                        }
                        if let Some(hit_transparent) = handler_set.hit_transparent {
                            node.hit_transparent = hit_transparent;
                        }
                        if handler_set.context_menu_factory.is_some() {
                            node.context_menu_factory = handler_set.context_menu_factory;
                        }
                        if let Some(sig) = handler_set.focus_within {
                            node.focus_within_signal = Some(sig);
                        }
                        if let Some(sig) = handler_set.hover_within {
                            node.hover_within_signal = Some(sig);
                        }
                        // Builder-chained `visible_when: prop`. Same as in
                        // `insert_widget`.
                        if let Some(prop) = handler_set.visible_when {
                            prop.register_if_bound(
                                id,
                                &self.binding_registry,
                                crate::binding::BindingLevel::Relayout,
                            );
                            node.visible_state = Some(prop);
                        }
                        // Builder-level accessibility overrides + subtree
                        // mode. Same rationale as in `insert_widget`.
                        if handler_set.access.is_some() {
                            // Register any bound access_hidden/label/description/
                            // value Props at AccessibilityOnly so the AT tree
                            // re-walks when they flip. (Disjoint field borrow:
                            // `node` borrows `self.arena`, this reads
                            // `self.binding_registry`.)
                            if let Some(access) = handler_set.access.as_ref() {
                                Self::register_access_prop_bindings(
                                    access,
                                    id,
                                    &self.binding_registry,
                                );
                            }
                            node.access_overrides = handler_set.access;
                        }
                        if let Some(mode) = handler_set.access_subtree {
                            node.access_subtree = mode;
                        }
                    }
                } else {
                    self.arena.restore_widget(id, widget_box);
                }
            }
        }

        // Same shortcut-declaration walk as `insert_widget` — keeps
        // metadata visible from the moment the child mounts, before
        // build() runs.
        self.apply_declared_shortcuts(id);

        {
            if let Some(mut widget_box) = self.arena.take_widget(id) {
                let mut build_ctx = crate::build_context::BuildContext {
                    tree: self,
                    composite_id: Some(id),
                    effect_handles: Vec::new(),
                    subscription_handles: Vec::new(),
                    // A first mount has no previous build to inherit ids from.
                    reusable_sub_ids: Vec::new(),
                };
                let built_children = widget_box.build(&mut build_ctx);
                let effect_handles = std::mem::take(&mut build_ctx.effect_handles);
                let subscription_handles = std::mem::take(&mut build_ctx.subscription_handles);

                self.arena.restore_widget(id, widget_box);

                // Transfer per-widget handles to the node. See the
                // matching block in `insert_widget` — effect and
                // subscription handles must persist for leaf widgets
                // too, not only composite ones.
                if let Some(node) = self.arena.get_mut(id) {
                    node.subscription_handles = subscription_handles;
                    node.effect_handles = effect_handles;
                }

                if !built_children.is_empty() {
                    for &child_id in &built_children {
                        if let Some(child_node) = self.arena.get_mut(child_id) {
                            child_node.parent = Some(id);
                        }
                    }
                    if let Some(node) = self.arena.get_mut(id) {
                        node.children = built_children;
                    }
                }
            }
        }

        let clips = self
            .arena
            .get(id)
            .is_some_and(|node| node.widget.clips_children());
        if clips {
            self.arena.set_clips_children(id, true);
        }

        id
    }

    // --- Property bindings ---

    /// Bind a widget's visibility to a boolean prop or compatibility state binding.
    /// When false, the widget is set dormant; when true, it is activated.
    /// Accepts `Signal<bool>`, `Prop<bool>`, compatibility state bindings, or plain `bool`.
    pub fn visible_when(&mut self, id: WidgetId, state: impl Into<crate::signal::Prop<bool>>) {
        let prop = state.into();
        prop.register_if_bound(
            id,
            &self.binding_registry,
            crate::binding::BindingLevel::Relayout,
        );
        if let Some(node) = self.arena.get_mut(id) {
            node.visible_state = Some(prop);
        }
    }

    /// Install (or reuse) the activation signal on a node and return a
    /// handle to it. The framework sets it to `false` when the node is
    /// parked dormant (`Switcher` / `visible_when`) and `true` when it is
    /// re-activated — see [`crate::arena::WidgetArena::set_dormant`] /
    /// [`activate`](crate::arena::WidgetArena::activate). The returned
    /// signal is initialised to the node's current active state. Used by
    /// widgets owning a resource outside the paint pass (a native subview)
    /// that must hide/show it in lockstep with framework activation.
    pub fn activation_signal(&mut self, id: WidgetId) -> crate::signal::Signal<bool> {
        if let Some(node) = self.arena.get_mut(id) {
            if let Some(existing) = node.activation_signal.clone() {
                return existing;
            }
            let active = node.activation == crate::arena::ActivationState::Active;
            let sig = crate::signal::Signal::new(active);
            node.activation_signal = Some(sig.clone());
            sig
        } else {
            // Node missing (shouldn't happen in build) — hand back a
            // detached signal so the caller still gets a valid handle.
            crate::signal::Signal::new(true)
        }
    }

    /// Fire the `activation_signal` of every node that transitioned
    /// Active↔Dormant since the last flush. Called at a well-defined tree-level
    /// point (the end of `process_state_changes`), never from inside the arena
    /// recursion — so an observer (e.g. a `WebView` calling the engine's
    /// `set_visible`) runs after the visibility pass has fully committed,
    /// matching the `focus_within` / `hover_within` update discipline.
    pub(crate) fn flush_activation_signals(&mut self) {
        let changes = self.arena.take_activation_changes();
        for (id, _recorded) in changes {
            // Re-read the node at flush time; it still exists (the transition
            // was recorded in the same synchronous operation).
            let Some(node) = self.arena.get(id) else {
                continue;
            };
            let Some(sig) = node.activation_signal.clone() else {
                continue;
            };
            // Fire the node's **current** state, not the value recorded at the
            // transition, and only when it differs from what observers last
            // saw. `pending_activation_changes` is an append-only queue: a
            // node parked and re-activated inside one batch records both
            // edges, and replaying them in order hands observers a `false`
            // that was never observable — the node is already Active by the
            // time anyone is told anything.
            //
            // The in-tree modal path does exactly that on every open: build
            // the content, `set_dormant` it, mount the scrim, `activate` it,
            // then move focus in. Both edges land in one batch and flush
            // *after* the focus dispatch, so the stale `false` arrives last
            // and observers act on a state that has already been superseded.
            // For a text editor that meant its dormancy handler wiped the
            // `has_focus` it had just been granted, and the dialog opened with
            // no caret; a `WebView` would have taken a real `set_visible(false)`
            // OS call for a subview that never left the screen.
            //
            // Collapsing to the final state also makes the flush idempotent
            // over duplicate ids: the first iteration syncs the signal, the
            // rest find it already equal and skip. `Signal::set` notifies
            // unconditionally, so the equality guard is what stops the
            // redundant fanout.
            let active = node.activation == crate::arena::ActivationState::Active;
            if sig.get() != active {
                sig.set(active);
            }
        }
    }

    /// Bind an opacity multiplier (0..1) to a widget. The render walker
    /// emits `SetOpacity(value)` before painting the widget's subtree
    /// and `RestoreOpacity` afterwards, so the multiplier composes
    /// correctly with ancestor opacity scopes via the canvas's stacked
    /// opacity model. Bound at `Repaint` level: opacity changes never
    /// trigger relayout. Pass any `Prop<f32>` or `Signal<f32>` source
    /// (typically an animated signal driven by a `Fade` wrapper).
    pub fn set_opacity(&mut self, id: WidgetId, opacity: impl Into<crate::signal::Prop<f32>>) {
        let prop = opacity.into();
        prop.register_if_bound(
            id,
            &self.binding_registry,
            crate::binding::BindingLevel::RepaintOnly,
        );
        if let Some(node) = self.arena.get_mut(id) {
            node.opacity_prop = Some(prop);
        }
    }

    /// Bind a 2D affine transform to a widget. The render walker emits
    /// `PushTransform(value)` before painting the widget's subtree and
    /// `PopTransform` afterwards; the renderer composes the transform
    /// onto its stack so nested wrappers and widget-internal canvas
    /// transforms compose correctly. Bound at `Repaint` level: visual-
    /// only transforms never trigger relayout. Wrappers that want the
    /// transform's *value change* to also drive layout (e.g.
    /// `Scale::reflow(true)`) must additionally bind the *driver*
    /// signal to themselves at `Relayout` level — the transform prop
    /// itself stays at Repaint. Pass a `Transform2D`, `Signal<Transform2D>`,
    /// or `Prop<Transform2D>`.
    pub fn set_transform(
        &mut self,
        id: WidgetId,
        transform: impl Into<crate::signal::Prop<teksilo_canvas::Transform2D>>,
    ) {
        let prop = transform.into();
        prop.register_if_bound(
            id,
            &self.binding_registry,
            crate::binding::BindingLevel::RepaintOnly,
        );
        if let Some(node) = self.arena.get_mut(id) {
            node.transform_prop = Some(prop);
            // A plain transform is a *self* transform — clear any prior
            // content-transform marker so the flag can never go stale if a
            // node switches from `set_content_transform` to `set_transform`.
            node.content_transform = false;
        }
    }

    /// Like [`set_transform`](Self::set_transform), but marks the transform as
    /// a **content** transform: it positions the node's content within a fixed
    /// parent-space viewport (the node's bounds) rather than transforming the
    /// node itself. Hit-testing then keeps the whole viewport interactive at
    /// any pan / zoom. Used by `SceneView`; see
    /// `WidgetNode::content_transform`.
    pub fn set_content_transform(
        &mut self,
        id: WidgetId,
        transform: impl Into<crate::signal::Prop<teksilo_canvas::Transform2D>>,
    ) {
        let prop = transform.into();
        prop.register_if_bound(
            id,
            &self.binding_registry,
            crate::binding::BindingLevel::RepaintOnly,
        );
        if let Some(node) = self.arena.get_mut(id) {
            node.transform_prop = Some(prop);
            node.content_transform = true;
        }
    }

    /// Bind a Gaussian-equivalent blur radius to a widget. The render
    /// walker emits `BeginBlurredSubtree { bounds, radius }` before
    /// painting the widget's subtree and `EndBlurredSubtree` afterwards;
    /// the renderer redirects drawing into an intermediate texture, runs
    /// a dual-Kawase blur chain at the requested radius, and composites
    /// the blurred result back into the parent pass. Bound at `Repaint`
    /// level: blur radius changes never trigger relayout. Sub-perceptual
    /// radii (< 0.5) skip the Begin/End pair entirely so animated
    /// enable/disable patterns have zero per-frame cost when fully off.
    /// Pass any `Prop<f32>` or `Signal<f32>` source.
    pub fn set_blur(&mut self, id: WidgetId, radius: impl Into<crate::signal::Prop<f32>>) {
        let prop = radius.into();
        prop.register_if_bound(
            id,
            &self.binding_registry,
            crate::binding::BindingLevel::RepaintOnly,
        );
        if let Some(node) = self.arena.get_mut(id) {
            node.blur_prop = Some(prop);
        }
    }

    /// Bind a widget's enabled state to a boolean prop or compatibility state binding.
    /// When false, the widget and its entire subtree ignore all events but remain
    /// visible. Focus traversal skips disabled subtrees and AccessKit marks their
    /// nodes as disabled. Accepts `Signal<bool>`, `Prop<bool>`, compatibility state
    /// bindings, or plain `bool`.
    ///
    /// The bound signal registers at `BindingLevel::SubtreeRepaint`: when
    /// it flips, the entire subtree rooted at `id` is marked for repaint
    /// (not relayout — geometry doesn't change). Leaves like
    /// `IconWidget` then re-resolve their role color
    /// using the new `PaintContext::effective_enabled` value, so a
    /// disabled subtree's icons and text dim automatically.
    pub fn enabled_when(&mut self, id: WidgetId, state: impl Into<crate::signal::Prop<bool>>) {
        let prop = state.into();
        // SubtreeRepaint propagates the visual dirty mark through the
        // disabled subtree so leaves re-resolve their role colors.
        prop.register_if_bound(
            id,
            &self.binding_registry,
            crate::binding::BindingLevel::SubtreeRepaint,
        );
        // AccessibilityOnly is orthogonal — it flips `a11y_dirty` so
        // AccessKit's `disabled` flag refreshes on the next a11y sync
        // (the accessibility walker reads `arena.is_enabled(id)`,
        // which is already correct via the prop, but the tree needs
        // to be told to rebuild).
        prop.register_if_bound(
            id,
            &self.binding_registry,
            crate::binding::BindingLevel::AccessibilityOnly,
        );
        if let Some(node) = self.arena.get_mut(id) {
            node.enabled_state = Some(prop);
        }
    }

    /// Reactive view of "is this widget effectively enabled?" — the AND
    /// of the widget's own `enabled_state` and every ancestor's.
    /// [`Self::is_enabled`] is the non-reactive equivalent; this method
    /// gives composite widgets a `Signal<bool>` for derived state.
    ///
    /// Leaves (`IconWidget`, `TextWidget`, `RectWidget`) do NOT need this
    /// — they receive the resolved bool via
    /// [`crate::widget::PaintContext::effective_enabled`] at paint time.
    /// This method is for composites that want to derive cursor / custom
    /// paint roles / etc. reactively.
    ///
    /// Install-or-reuse, exactly like [`Self::activation_signal`]: the signal
    /// lives on the node and the framework refreshes it from the live arena
    /// once per state-change pass (`flush_effective_enabled_signals`).
    ///
    /// It is deliberately NOT a signal derived by walking the ancestor chain
    /// here. A widget's `parent` is still `None` while its own `build()` runs
    /// — `insert_widget` inserts the node parentless and wires the parent link
    /// only after `build()` returns — so an ancestor walk performed from
    /// inside `build()` (which is how every caller uses this) sees an empty
    /// chain and would capture the widget's OWN `enabled` prop as the whole
    /// answer, permanently. That was a real bug: a Button inside a disabled
    /// form stayed painted as if enabled.
    ///
    /// The value is seeded from the live arena and corrected on the next
    /// flush, so a first-`build()` caller (parent not yet wired) and a
    /// rebuild caller (parent wired) both converge before anything paints.
    pub fn effective_enabled_signal(&mut self, id: WidgetId) -> crate::signal::Signal<bool> {
        if let Some(existing) = self
            .arena
            .get(id)
            .and_then(|n| n.effective_enabled_signal.clone())
        {
            return existing;
        }
        // Seed from the live tree. Mid-`build()` the parent is not wired yet,
        // so this is the widget's own state only; `flush_effective_enabled_signals`
        // corrects it against the fully-wired tree before the first paint.
        let seed = self.arena.is_enabled(id);
        let sig = crate::signal::Signal::new(seed);
        let Some(node) = self.arena.get_mut(id) else {
            // Node missing (shouldn't happen in build) — hand back a detached
            // handle so the caller still gets a valid signal.
            return crate::signal::Signal::new(true);
        };
        node.effective_enabled_signal = Some(sig.clone());
        self.arena.watch_effective_enabled(id);
        sig
    }

    /// Refresh every node-resident `effective_enabled_signal` against the live
    /// arena, firing observers only where the value actually changed.
    ///
    /// Unlike [`Self::flush_activation_signals`] this cannot be driven off a
    /// change queue: a node's `enabled_state` is a `Prop<bool>` that may be
    /// bound to an app `Signal` which flips without the arena being notified,
    /// so there is no mutation site at which to record a transition. Instead
    /// this recomputes the (cheap, `O(depth)`) ancestor AND for each opted-in
    /// node and diffs. Only nodes that called
    /// [`Self::effective_enabled_signal`] are visited, so a tree with no
    /// interactive widgets pays nothing.
    ///
    /// Values are collected first and set afterwards: a `Signal::set` observer
    /// may mutate the tree, and must not run while the arena is being walked —
    /// the same discipline as `flush_activation_signals` and the
    /// `focus_within` / `hover_within` updates.
    pub(crate) fn flush_effective_enabled_signals(&mut self) {
        self.arena.prune_effective_enabled_watchers();
        let mut updates: Vec<(crate::signal::Signal<bool>, bool)> = Vec::new();
        for id in self.arena.effective_enabled_watchers() {
            let Some(sig) = self
                .arena
                .get(id)
                .and_then(|n| n.effective_enabled_signal.clone())
            else {
                continue;
            };
            let now = self.arena.is_enabled(id);
            if sig.get() != now {
                updates.push((sig, now));
            }
        }
        for (sig, value) in updates {
            sig.set(value);
        }
    }

    /// Whether a widget is effectively enabled. Returns `false` if the widget
    /// itself or any ancestor has `enabled_state` bound to `false`.
    pub fn is_enabled(&self, id: WidgetId) -> bool {
        self.arena.is_enabled(id)
    }

    /// Bind a widget's Tab-key participation to a boolean prop or
    /// compatibility state binding. When false, the widget is removed
    /// from Tab / Shift+Tab traversal (`cycle_focus`) but remains
    /// reachable via `request_focus` and arrow-key navigation that
    /// calls `request_focus`. Implements the ARIA roving-tabindex
    /// pattern (HTML `tabindex="-1"` semantics). Accepts
    /// `Signal<bool>`, `Prop<bool>`, or plain `bool`.
    pub fn set_tab_stop(&mut self, id: WidgetId, state: impl Into<crate::signal::Prop<bool>>) {
        let prop = state.into();
        // Bind at the lightest level — tab-stop changes never affect
        // layout or paint; cycle_focus reads the current value on
        // each Tab keypress.
        prop.register_if_bound(
            id,
            &self.binding_registry,
            crate::binding::BindingLevel::RepaintOnly,
        );
        if let Some(node) = self.arena.get_mut(id) {
            node.tab_stop = Some(prop);
        }
    }

    /// Current Tab-key participation for a widget. Returns the value
    /// of the `tab_stop` prop if bound, or `true` (the default) when
    /// no binding is present. Mirrors the filter used by
    /// `cycle_focus` — primarily for tests asserting the
    /// roving-tabindex contract.
    pub fn tab_stop(&self, id: WidgetId) -> bool {
        self.arena
            .get(id)
            .and_then(|node| node.tab_stop.as_ref())
            .map(|prop| prop.get())
            .unwrap_or(true)
    }

    /// Declare `id` as a **traversal-scope boundary** with the given policy.
    /// `cycle_focus` then treats the node's subtree as an independent Tab
    /// group: `tab_index` values inside it are scoped (they never collide
    /// with sibling scopes) and `policy` governs Tab at the scope's ends.
    ///
    /// The scope node is forced non-focusable — it is a transparent boundary,
    /// never itself a Tab stop. Called from `BuildContext::set_traversal_scope`
    /// (which the `FocusScope` wrapper widget invokes during `build`), and
    /// directly usable from tests with no dependency on the widgets crate.
    pub fn set_traversal_scope(
        &mut self,
        id: WidgetId,
        policy: crate::focus::TraversalScopePolicy,
    ) {
        if let Some(node) = self.arena.get_mut(id) {
            node.node_traversal_scope = Some(policy);
            node.node_focusable = Some(false);
        }
    }

    /// Remove a previously set traversal-scope marker from `id` (rebuild
    /// paths where a `FocusScope` is replaced by a non-scope widget). Leaves
    /// `node_focusable` untouched — a later handler-set application resets it.
    pub fn clear_traversal_scope(&mut self, id: WidgetId) {
        if let Some(node) = self.arena.get_mut(id) {
            node.node_traversal_scope = None;
        }
    }

    /// Current traversal-scope policy on `id`, if any. For tests asserting
    /// the scope marker contract.
    pub fn traversal_scope(&self, id: WidgetId) -> Option<crate::focus::TraversalScopePolicy> {
        self.arena
            .get(id)
            .and_then(|node| node.node_traversal_scope)
    }

    // --- Theme override ---

    /// Set a theme override on a widget. All descendants of this widget
    /// will see the modified theme during layout and paint.
    /// The override function receives a mutable `Theme` to modify.
    ///
    /// ```
    /// # use teksilo_core::{Widget, LayoutResponse, LayoutContext, widget_tree::WidgetTree};
    /// # use teksilo_canvas::{Size, SizeProposal};
    /// # use teksilo_tokens::ColorTokens;
    /// # #[derive(Debug)] struct MinWidget;
    /// # impl Widget for MinWidget {
    /// #     fn layout_response(&self, _: SizeProposal, _: &LayoutContext) -> LayoutResponse {
    /// #         Size::new(0.0, 0.0).into()
    /// #     }
    /// # }
    /// # let mut tree = WidgetTree::new();
    /// # let panel_id = tree.add(MinWidget);
    /// tree.set_theme_override(panel_id, |theme| {
    ///     theme.colors = ColorTokens::dark_default();
    /// });
    /// ```
    pub fn set_theme_override(
        &mut self,
        id: WidgetId,
        f: impl Fn(&mut crate::styles::Theme) + 'static,
    ) {
        let had_override = self
            .arena
            .get(id)
            .is_some_and(|n| n.theme_override.is_some());
        if let Some(node) = self.arena.get_mut(id) {
            node.theme_override = Some(crate::environment::ThemeOverride { func: Box::new(f) });
            node.dirty.needs_layout = true;
            node.dirty.needs_paint = true;
        }
        if !had_override {
            self.arena.theme_override_count += 1;
        }
    }

    /// Get the resolved theme for a specific widget (applying ancestor overrides).
    pub fn resolved_theme(&self, id: WidgetId) -> crate::styles::Theme {
        self.arena.resolve_theme(id, &self.theme).into_owned()
    }
}

impl Default for WidgetTree {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod activation_signal_tests {
    use super::*;
    use crate::build_context::BuildContext;
    use crate::signal::Signal;
    use crate::widget::{LayoutContext, LayoutResponse, Widget};
    use teksilo_canvas::SizeProposal;

    /// A leaf that, on build, opts into its activation signal and mirrors it
    /// into an out-of-band signal the test can read.
    #[derive(Debug)]
    struct ActivationProbe {
        log: Signal<Vec<bool>>,
    }

    impl Widget for ActivationProbe {
        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
            let id = ctx.self_id();
            let vis = ctx.activation_signal(id);
            let log = self.log.clone();
            ctx.effect(&vis, move |active| {
                let mut v = log.get();
                v.push(*active);
                log.set(v);
            });
            Vec::new()
        }

        fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
            proposal.resolve(10.0, 10.0).into()
        }
    }

    /// A widget that enqueues a post-mount action via `run_after_mount` has it
    /// run exactly once, with a real `EventContext`, when the tree drains
    /// (`run_mount_actions`) — and `has_pending_mount_actions` reflects the
    /// queue state.
    #[derive(Debug)]
    struct MountActionProbe {
        ran: Signal<u32>,
    }

    impl Widget for MountActionProbe {
        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
            let ran = self.ran.clone();
            ctx.run_after_mount(move |_ectx| ran.set(ran.get() + 1));
            Vec::new()
        }

        fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
            proposal.resolve(10.0, 10.0).into()
        }
    }

    #[test]
    fn run_after_mount_runs_once_on_drain() {
        let mut tree = WidgetTree::new();
        let ran = Signal::new(0_u32);
        tree.add(MountActionProbe { ran: ran.clone() });
        tree.layout(SizeProposal::exact(100.0, 100.0));

        // Queued during build, not yet run.
        assert!(tree.has_pending_mount_actions());
        assert_eq!(ran.get(), 0);

        // Drain with a Noop sink (as headless callers do).
        tree.run_mount_actions(&mut crate::window::NoopWindowOps);
        assert_eq!(ran.get(), 1);
        assert!(!tree.has_pending_mount_actions());

        // Draining again is a no-op (the queue is empty).
        tree.run_mount_actions(&mut crate::window::NoopWindowOps);
        assert_eq!(ran.get(), 1);
    }

    #[test]
    fn activation_signal_fires_on_dormant_and_reactivate() {
        let mut tree = WidgetTree::new();
        let log = Signal::new(Vec::<bool>::new());
        let probe = tree.add(ActivationProbe { log: log.clone() });

        // Gate the probe's visibility on a signal.
        let visible = Signal::new(true);
        tree.visible_when(probe, visible.clone());
        tree.layout(SizeProposal::exact(100.0, 100.0));

        // Initially active: effect registration alone fires nothing.
        assert_eq!(log.get(), Vec::<bool>::new());

        // Hide → dormant → activation signal false.
        visible.set(false);
        tree.layout(SizeProposal::exact(100.0, 100.0));
        assert_eq!(log.get(), vec![false]);

        // Show → active → activation signal true.
        visible.set(true);
        tree.layout(SizeProposal::exact(100.0, 100.0));
        assert_eq!(log.get(), vec![false, true]);

        // Redundant relayout while active fires nothing new.
        tree.layout(SizeProposal::exact(100.0, 100.0));
        assert_eq!(log.get(), vec![false, true]);
    }

    /// **A node parked and re-woken before the flush never looks dormant.**
    ///
    /// `pending_activation_changes` is an append-only queue, so this records
    /// two edges; replaying them in order would hand observers a `false` that
    /// was already superseded — for a state signal that is a lie, not a
    /// history. `present_in_tree_modal_request` takes exactly this route on
    /// every dialog (build the content, park it, mount the scrim, wake it,
    /// *then* move focus in), and the stale `false` landed after the focus
    /// dispatch: a text editor's dormancy handler wiped the focus it had just
    /// been granted and the dialog opened with no caret.
    #[test]
    fn a_park_and_wake_inside_one_batch_fires_nothing() {
        let mut tree = WidgetTree::new();
        let log = Signal::new(Vec::<bool>::new());
        let probe = tree.add(ActivationProbe { log: log.clone() });
        tree.layout(SizeProposal::exact(100.0, 100.0));
        assert_eq!(log.get(), Vec::<bool>::new());

        tree.set_dormant(probe);
        tree.activate(probe);
        tree.layout(SizeProposal::exact(100.0, 100.0));
        assert_eq!(
            log.get(),
            Vec::<bool>::new(),
            "a node that ends the batch where it started never observably \
             changed — firing the intermediate `false` makes observers act on \
             a state that was never visible",
        );

        // The converse still reports: a batch with a *net* transition fires
        // once, with the state the node actually ended in.
        tree.activate(probe);
        tree.set_dormant(probe);
        tree.layout(SizeProposal::exact(100.0, 100.0));
        assert_eq!(
            log.get(),
            vec![false],
            "a net Active→Dormant batch must still report, exactly once",
        );

        tree.activate(probe);
        tree.layout(SizeProposal::exact(100.0, 100.0));
        assert_eq!(log.get(), vec![false, true]);
    }
}

#[cfg(test)]
mod visible_when_builder_tests {
    use super::*;
    use crate::signal::{Prop, Signal};
    use crate::widget_builder::WidgetBuilder;

    /// `.visible_when(signal)` on any widget builder threads a *bound*
    /// visibility prop onto the inserted node — so `teksu!`'s property form
    /// (`Widget { visible_when: sig }`) reaches the same `node.visible_state`
    /// slot as the imperative `ctx.visible_when(id, sig)`.
    #[test]
    fn visible_when_builder_binds_node_visibility() {
        let mut tree = WidgetTree::new();
        let shown = Signal::new(false);
        let id = tree.add(crate::test_widgets::FillWidget::new().visible_when(shown.clone()));

        let node = tree.arena.get(id).expect("node exists");
        assert!(
            matches!(node.visible_state, Some(Prop::Bound(_))),
            "`.visible_when(Signal)` must store a bound visibility prop"
        );
    }

    /// A static `bool` is accepted too (`Prop::Static`), matching
    /// `ctx.visible_when` / `access_hidden` semantics.
    #[test]
    fn visible_when_builder_accepts_static_bool() {
        let mut tree = WidgetTree::new();
        let id = tree.add(crate::test_widgets::FillWidget::new().visible_when(false));

        let node = tree.arena.get(id).expect("node exists");
        assert!(matches!(node.visible_state, Some(Prop::Static(false))));
    }
}

#[cfg(test)]
mod text_scale_tests {
    use super::*;

    #[test]
    fn effective_theme_starts_equal_to_theme() {
        let tree = WidgetTree::new();
        assert_eq!(
            tree.effective_theme.typography.body.size,
            tree.theme.typography.body.size
        );
        assert_eq!(tree.user_text_scale(), 1.0);
    }

    #[test]
    fn effective_text_scale_and_signal_track_the_combined_factor() {
        let mut tree = WidgetTree::new();
        assert_eq!(tree.effective_text_scale(), 1.0);
        assert_eq!(tree.text_scale_signal().get(), 1.0);

        tree.set_user_text_scale(1.5);
        assert!((tree.effective_text_scale() - 1.5).abs() < 0.001);
        assert!((tree.text_scale_signal().get() - 1.5).abs() < 0.001);

        // OS preference multiplies in.
        tree.set_accessibility_preferences(false, false, 2.0);
        assert!((tree.effective_text_scale() - 3.0).abs() < 0.01);
        assert!((tree.text_scale_signal().get() - 3.0).abs() < 0.01);
    }

    #[test]
    fn set_user_text_scale_scales_effective_typography() {
        let mut tree = WidgetTree::new();
        let base = tree.theme.typography.body.size;
        tree.set_user_text_scale(1.5);
        assert!((tree.effective_theme.typography.body.size - base * 1.5).abs() < 0.001);
        // The unscaled base theme is untouched.
        assert_eq!(tree.theme.typography.body.size, base);
    }

    #[test]
    fn set_theme_preserves_existing_user_scale() {
        let mut tree = WidgetTree::new();
        tree.set_user_text_scale(2.0);
        let dark = crate::presets::intui::dark();
        let dark_base = dark.typography.body.size;
        tree.set_theme(dark);
        assert!((tree.effective_theme.typography.body.size - dark_base * 2.0).abs() < 0.001);
    }

    #[test]
    fn os_text_scale_multiplies_with_user_scale() {
        let mut tree = WidgetTree::new();
        let base = tree.theme.typography.body.size;
        tree.set_user_text_scale(1.5);
        // OS preference reports 1.2 → combined 1.8.
        tree.set_accessibility_preferences(false, false, 1.2);
        assert!((tree.effective_theme.typography.body.size - base * 1.8).abs() < 0.01);
    }

    #[test]
    fn same_scale_is_a_noop_and_factor_is_clamped() {
        let mut tree = WidgetTree::new();
        tree.set_user_text_scale(1.5);
        // Re-setting the same value should not panic / change anything.
        tree.set_user_text_scale(1.5);
        assert_eq!(tree.user_text_scale(), 1.5);
        // Out-of-range clamps into [0.25, 8.0].
        tree.set_user_text_scale(100.0);
        assert_eq!(tree.user_text_scale(), 8.0);
    }
}

/// Covers the `WidgetTree`-level facts
/// `teksilo_app::WindowManager::request_redraw_needing_render` (the
/// targeted cross-window redraw added for shared-`Signal` dispatch
/// fan-out) relies on. Neither test touches windows at all — they exist
/// to pin down `needs_render()`'s contract in isolation, since
/// teksilo-app cannot stand up a real `PlatformWindow` in a unit test.
#[cfg(test)]
mod cross_window_redraw_signal_tests {
    use super::*;
    use crate::signal::Signal;
    use crate::test_widgets::{FillWidget, StackWidget};
    use teksilo_canvas::SizeProposal;

    /// The premise the fix acts on, AND the trap a naive fix would fall
    /// into. A `Signal` shared by two independent trees (standing in for
    /// two windows) is supposed to dirty both when mutated, even though
    /// only one of them is the tree whose dispatch made the mutation —
    /// but `Signal::set` only flips a dirty flag on the signal itself and
    /// in the `BindingRegistry`; nothing walks that into a tree's
    /// `needs_layout` / `needs_paint` bits (what `needs_render()` reads)
    /// except that tree's OWN `process_state_changes`, run at the top of
    /// its OWN `layout()`. So immediately after the mutation, with
    /// neither tree having re-run `layout()`, BOTH read clean — a naive
    /// "just check `needs_render()`" cross-window redraw would see
    /// nothing to do and stay a permanent no-op. Once tree B's `layout()`
    /// runs (what `request_redraw_needing_render` does for every window
    /// before checking it), the same mutation is finally visible there.
    ///
    /// Each tree wraps its gated leaf in a `StackWidget` parent (rather
    /// than gating a bare root leaf) so the fact under test — an ACTIVE
    /// widget ending up dirty — is unambiguous: `any_needs_layout()` /
    /// `any_needs_paint()` only ever look at `Active` nodes, and a leaf
    /// that itself goes dormant is deliberately excluded from both (a
    /// hidden widget has nothing to paint). What must go dirty here is
    /// the STILL-ACTIVE stack, via `mark_ancestors_need_layout` — the
    /// same mechanism that makes a real window's content re-flow around
    /// a child that just appeared or disappeared.
    #[test]
    fn a_shared_signal_mutation_only_shows_up_after_that_trees_own_layout_reconciles_it() {
        let shared = Signal::new(true);

        let mut tree_a = WidgetTree::new();
        let stack_a = tree_a.add(StackWidget::new());
        let id_a = tree_a.add_child(stack_a, FillWidget::new());
        tree_a.visible_when(id_a, shared.clone());

        let mut tree_b = WidgetTree::new();
        let stack_b = tree_b.add(StackWidget::new());
        let id_b = tree_b.add_child(stack_b, FillWidget::new());
        tree_b.visible_when(id_b, shared.clone());

        let proposal = SizeProposal::exact(100.0, 100.0);

        // Bring both to the same clean baseline a real event loop reaches
        // after its initial layout + paint.
        tree_a.layout(proposal);
        tree_a.render();
        tree_b.layout(proposal);
        tree_b.render();
        assert!(!tree_a.needs_render(), "precondition: tree A starts clean");
        assert!(!tree_b.needs_render(), "precondition: tree B starts clean");

        // Simulate a handler mutating the shared Signal during tree A's
        // dispatch. Neither tree re-runs layout() here yet.
        shared.set(false);

        assert!(
            !tree_a.needs_render(),
            "the mutation alone does not retroactively dirty tree A either — \
             a Signal write cannot poke an arena directly, only the next \
             process_state_changes (inside layout()) can"
        );
        assert!(
            !tree_b.needs_render(),
            "and tree B reads exactly as clean as tree A does at this point — \
             checking needs_render() without reconciling first cannot tell them apart"
        );

        // ...but they are NOT indistinguishable to `needs_reconcile()`,
        // which is what `request_redraw_needing_render` actually gates
        // its reconcile on. Both trees observe the shared Signal, so
        // both report pending reactive work here — and asking is
        // read-only, so asking tree A first does not answer for tree B.
        assert!(
            tree_a.needs_reconcile() && tree_b.needs_reconcile(),
            "both trees must report pending reactive work from the shared write"
        );

        // This is what `request_redraw_needing_render` does for a window
        // whose gate is open — reconcile at the window's OWN current
        // size, which is what walks a pending Signal-driven change into
        // the arena.
        tree_b.layout(proposal);

        assert!(
            tree_b.needs_render(),
            "tree B, which merely OBSERVES the shared Signal, is now dirty — \
             this is the cross-tree fan-out request_redraw_needing_render \
             exists to notice (via its own reconcile-then-check) and repaint"
        );
        assert!(
            !tree_b.needs_reconcile(),
            "and having reconciled, tree B's gate closes again"
        );
        assert!(
            tree_a.needs_reconcile(),
            "while tree A — which has NOT reconciled — is still waiting; one \
             window's reconcile must never close another's gate"
        );
    }

    /// The gate `request_redraw_needing_render` gained must stay SHUT for
    /// a window with nothing reactive pending, or it saves nothing: that
    /// method runs after every dispatched event, and an open gate costs
    /// a full `layout_with_ops` — a dozen per-frame passes (pending
    /// animations, frame tick, scheduler tick, drag tick,
    /// `process_state_changes`, tooltips, four overlay passes) before it
    /// reaches the geometry short-circuit, for every open window, on
    /// every event of a fast mouse-move stream.
    #[test]
    fn needs_reconcile_is_false_for_an_idle_tree() {
        let shared = Signal::new(true);
        let mut idle = WidgetTree::new();
        let stack = idle.add(StackWidget::new());
        let leaf = idle.add_child(stack, FillWidget::new());
        idle.visible_when(leaf, shared.clone());
        idle.layout(SizeProposal::exact(100.0, 100.0));
        idle.render();

        assert!(!idle.needs_reconcile(), "nothing written — gate shut");
        assert!(!idle.needs_reconcile(), "and asking does not open it");

        shared.set(false);
        assert!(idle.needs_reconcile(), "a write opens it");
        idle.layout(SizeProposal::exact(100.0, 100.0));
        assert!(!idle.needs_reconcile(), "reconciling closes it again");
    }

    /// A *pending* `animate_to` contributes no scheduler deadline until
    /// `process_pending_animations` promotes it, so `next_timer_deadline`
    /// (and therefore `request_redraw_due`) cannot see it. The gate must,
    /// or arming an animation from another window's handler would leave
    /// it parked until something unrelated woke the window.
    #[test]
    fn needs_reconcile_sees_an_animation_armed_but_not_yet_started() {
        let mut tree = WidgetTree::new();
        let id = tree.add(FillWidget::new());
        tree.layout(SizeProposal::exact(50.0, 50.0));
        tree.render();

        // Registered but deliberately NOT bound to any widget, so the
        // binding-registry term cannot be what notices it.
        let anim = Signal::new_animated(0.0_f32);
        tree.register_animated_signal(&anim, id);
        assert!(!tree.needs_reconcile(), "precondition: nothing armed yet");

        anim.animate_to(
            1.0,
            std::time::Duration::from_millis(200),
            teksilo_tokens::Easing::Linear,
        );
        assert!(
            tree.needs_reconcile(),
            "an armed-but-unstarted animation is reactive work only layout() can pick up"
        );
        assert!(
            anim.has_pending_animation(),
            "and asking must not have consumed the request"
        );

        tree.layout(SizeProposal::exact(50.0, 50.0));
        assert!(
            !anim.has_pending_animation(),
            "the reconcile promoted it into the scheduler"
        );
    }

    /// An animation armed *after* the wall clock has overtaken the simulated one
    /// must still run.
    ///
    /// `layout` promotes a pending `animate_to` into the scheduler; once
    /// `tick_animations` is driving the tree, the scheduler is only ever ticked at
    /// `sim_clock`. Stamping the promotion with `Instant::now()` therefore put the
    /// start in the scheduler's *future* the moment a test's real time outran the
    /// simulated time it had asked for — and a start in the future does not run
    /// slow, it does not run at all. That made animated headless tests a function
    /// of machine load: green run alone, frozen once a full suite filled the cores
    /// and stretched each test's wall-clock time past its simulated budget.
    ///
    /// The two clocks below are the shape of that: 90 ms simulated against at
    /// least 100 ms real, so simulated time never catches up. Under the old
    /// behaviour the animation stays pinned at its start value forever.
    #[test]
    fn an_animation_armed_after_real_time_outran_the_sim_clock_still_runs() {
        let mut tree = WidgetTree::new();
        let id = tree.add(FillWidget::new());
        tree.layout(SizeProposal::exact(50.0, 50.0));

        // Put the tree in simulated time, then let the wall clock get ahead of it.
        tree.tick_animations(std::time::Duration::from_millis(10));
        std::thread::sleep(std::time::Duration::from_millis(100));

        let anim = Signal::new_animated(0.0_f32);
        tree.register_animated_signal(&anim, id);
        anim.animate_to(
            1.0,
            std::time::Duration::from_millis(50),
            teksilo_tokens::Easing::Linear,
        );
        tree.layout(SizeProposal::exact(50.0, 50.0));

        // 80 ms of simulated time against a 50 ms animation: comfortably finished
        // on the only clock the scheduler is ever ticked with, and still short of
        // the ~100 ms of real time that has passed.
        tree.tick_animations(std::time::Duration::from_millis(80));

        assert_eq!(
            anim.get(),
            1.0,
            "the animation must be measured against the clock it is ticked with, \
             not the wall clock that has already run past it"
        );
        assert!(
            !tree.has_active_animations(),
            "and having reached its target it must be off the scheduler"
        );
    }

    /// `needs_render()` (paint/layout dirt only) must stay `false` while a
    /// per-frame `Signal<f32>` animation is merely *running*, with nothing
    /// new to paint. `request_redraw_needing_render` filters on
    /// `needs_render()`, not the broader `needs_redraw()`, precisely so a
    /// window with a live animation isn't forced into an extra immediate
    /// redraw on every sibling-window event — that would defeat the 60 Hz
    /// `WaitUntil` pacing those animations already get elsewhere and
    /// reintroduce the uncapped free-running redraw bug that pacing was
    /// written to remove.
    #[test]
    fn needs_render_excludes_a_running_animation_with_no_dirty_paint() {
        let mut tree = WidgetTree::new();
        let id = tree.add(FillWidget::new());
        tree.layout(SizeProposal::exact(50.0, 50.0));
        tree.render();
        assert!(!tree.needs_render(), "precondition: tree starts clean");
        assert!(!tree.needs_redraw(), "precondition: nothing running yet");

        let anim = Signal::new_animated(0.0_f32);
        tree.register_animated_signal(&anim, id);
        anim.animate_to(
            1.0,
            std::time::Duration::from_millis(200),
            teksilo_tokens::Easing::Linear,
        );
        // `process_pending_animations` (which picks up the pending
        // `animate_to` and starts it on the scheduler) runs inside `layout`.
        tree.layout(SizeProposal::exact(50.0, 50.0));

        assert!(
            tree.needs_redraw(),
            "an animation just started, so needs_redraw() (has_running()) must be true"
        );
        assert!(
            !tree.needs_render(),
            "but nothing is actually dirty for paint/layout — needs_render() must stay false, \
             which is the whole point of using it (not needs_redraw()) as the cross-window filter"
        );
    }
}

#[cfg(test)]
mod effective_enabled_signal_tests {
    use super::*;
    use crate::build_context::BuildContext;
    use crate::signal::Signal;
    use crate::widget::{LayoutContext, LayoutResponse, Widget};
    use std::cell::RefCell;
    use std::rc::Rc;
    use teksilo_canvas::SizeProposal;

    type SignalSlot = Rc<RefCell<Option<Signal<bool>>>>;

    /// A leaf that opts into `effective_enabled_signal` from inside its own
    /// `build()` — the only way real widgets use it, and the case that was
    /// broken. It publishes the handle so the test can read the live value.
    #[derive(Debug)]
    struct EnabledProbe {
        out: SignalSlot,
    }

    impl Widget for EnabledProbe {
        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
            let id = ctx.self_id();
            let sig = ctx.effective_enabled_signal(id);
            // Also pins that the signal is *mutable*: the previous derived
            // implementation panicked here with "observe() is only supported
            // on mutable signals", which is why widgets could not use
            // `ctx.effect` to react to being disabled.
            ctx.effect(&sig, |_| {});
            *self.out.borrow_mut() = Some(sig);
            Vec::new()
        }

        fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
            proposal.resolve(10.0, 10.0).into()
        }
    }

    /// A composite that adds the probe through the ordinary `ctx.add` idiom, so
    /// the child is inserted PARENTLESS and builds before its parent link is
    /// wired — the exact situation that defeated the old
    /// walk-the-ancestors-at-call-time implementation.
    #[derive(Debug)]
    struct Form {
        enabled: Signal<bool>,
        out: SignalSlot,
    }

    impl Widget for Form {
        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
            let id = ctx.self_id();
            ctx.enabled_when(id, self.enabled.clone());
            vec![ctx.add(EnabledProbe {
                out: self.out.clone(),
            })]
        }

        fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
            proposal.resolve(10.0, 10.0).into()
        }
    }

    fn mount_form(enabled: Signal<bool>) -> (WidgetTree, WidgetId, Signal<bool>) {
        let out: SignalSlot = Rc::new(RefCell::new(None));
        let mut tree = WidgetTree::new();
        let form = tree.add(Form {
            enabled,
            out: out.clone(),
        });
        tree.layout(SizeProposal::exact(100.0, 100.0));
        let sig = out.borrow().clone().expect("probe published its signal");
        (tree, form, sig)
    }

    /// THE REGRESSION. A widget whose own `enabled` is untouched must still
    /// report disabled when an ANCESTOR is disabled. This failed before the
    /// signal became node-resident: `insert_widget` inserts the node with
    /// `parent: None` and wires the parent only after `build()` returns, so an
    /// ancestor walk done during `build()` saw an empty chain and captured
    /// "enabled" for the widget's whole life.
    #[test]
    fn tracks_an_ancestor_disabled_before_mount() {
        let (_tree, _form, sig) = mount_form(Signal::new(false));
        assert!(
            !sig.get(),
            "a child of a disabled ancestor must report effectively-disabled"
        );
    }

    /// The live case: the ancestor's bound signal flips after mount. The child
    /// must follow, in both directions, with no rebuild.
    #[test]
    fn follows_an_ancestor_flipping_after_mount() {
        let enabled = Signal::new(true);
        let (mut tree, _form, sig) = mount_form(enabled.clone());
        assert!(sig.get(), "starts enabled");

        enabled.set(false);
        tree.layout(SizeProposal::exact(100.0, 100.0));
        assert!(!sig.get(), "child follows the ancestor going disabled");

        enabled.set(true);
        tree.layout(SizeProposal::exact(100.0, 100.0));
        assert!(sig.get(), "and follows it coming back");
    }

    /// A widget's own `enabled_state` still works on its own.
    #[test]
    fn honours_the_widgets_own_state() {
        let out: SignalSlot = Rc::new(RefCell::new(None));
        let mut tree = WidgetTree::new();
        let probe = tree.add(EnabledProbe { out: out.clone() });
        tree.enabled_when(probe, false);
        tree.layout(SizeProposal::exact(100.0, 100.0));
        let sig = out.borrow().clone().unwrap();
        assert!(!sig.get(), "own enabled_state alone disables");
    }

    /// The signal must agree with the paint-time bool the render walker
    /// computes. If they disagreed, role-driven chrome (which dims from
    /// `PaintContext::effective_enabled`) and signal-driven chrome (which dims
    /// from this signal) would grey out at different moments.
    #[test]
    fn agrees_with_the_paint_time_effective_enabled() {
        let enabled = Signal::new(true);
        let (mut tree, form, sig) = mount_form(enabled.clone());
        let probe = tree.children(form)[0];

        for value in [false, true, false] {
            enabled.set(value);
            tree.layout(SizeProposal::exact(100.0, 100.0));
            assert_eq!(
                sig.get(),
                tree.is_enabled(probe),
                "signal and the arena's live is_enabled must agree (enabled={value})"
            );
        }
    }

    /// Install-or-reuse: asking twice hands back the same signal.
    #[test]
    fn is_install_or_reuse() {
        let out: SignalSlot = Rc::new(RefCell::new(None));
        let mut tree = WidgetTree::new();
        let id = tree.add(EnabledProbe { out });
        let a = tree.effective_enabled_signal(id);
        let b = tree.effective_enabled_signal(id);
        assert!(
            Signal::same(&a, &b),
            "must hand back the same signal handle"
        );
    }
}