rust_widgets 2.9.2

Pure Rust cross-platform native GUI library with hardware-adaptive rendering, 180 widgets, touch/gesture support, i18n, and SVG-pipeline-accurate output
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
// SPDX-FileCopyrightText: Copyright (c) 2026 Mike Li/Mikewolfli/Wei Li(mikewolfli@163.com)
// SPDX-License-Identifier: MIT

//! Group box widget.
//!
//! # The rule this control embodies
//!
//! BLUE22 §B.8 cites the reference group box: `topPadding = padding + label_height + spacing`, i.e.
//! the content area begins below the title **plus the gap that separates the two**. This
//! control derived the same quantity as a bare literal `14` in `content_rect` while the title
//! band was separately anchored to the frame's top edge, so "where the title is" and "where the
//! content starts" were two numbers that happened to describe the same layout. Deriving the
//! content's top edge from the title's own box and the named gap is what makes the two
//! move together — a larger font or a taller title band pushes the content down instead of
//! letting it overlap.
//!
//! # The title row is split into its two columns by one derivation
//!
//! §B.8 also lists this control's title slot as hand-computed: the title's leading inset was
//! `TITLE_INSET + indicator_reserve()` and the indicator was then placed *backwards* from the
//! title's own leading edge (`title.x - spacing - box`). Those are the same relation stated twice,
//! with the sign of the second one inverted — which is how the indicator once hung outside the
//! frame while its width was computed correctly. `GroupBox::title_columns` states it once, so the
//! reserve and the placement are one answer.

use crate::compat::{Rc, RefCell, String, ToString};
use crate::core::{Alignment, Color, HorizontalAlignment, ObjectId, Point, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::widget::capability::coercion::{
    alignment_to_str, expect_alignment, expect_bool, expect_string,
};
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::metrics::{dimensions, effective_font, ControlMetrics};
use crate::widget::{BaseWidget, Draw, IconName, SimpleRegistry, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};

/// Padding between the title's own box and the band that erases the border behind it.
///
/// Named rather than written as `10`/`20` in two places: the band's left edge and its
/// width were derived from the same number with different signs, which is how a band
/// twice as wide as intended was easy to leave behind.
const TITLE_PADDING: i32 = 10;

/// Height of the title's band, in pixels. The band is what hides the border behind the
/// label; `2` is the frame's own drawn border thickness, so the two cannot mismatch.
const BORDER_WIDTH: u32 = 2;

/// The vertical gap between the title row and the content below it: 6.
///
/// This is the `spacing` of the reference group-box formula `topPadding = padding + label_height +
/// spacing` — the distance between two adjacent *elements* of this control, as distinct from
/// the frame's edge-to-content padding. It used to be folded into a single `top: 14` literal,
/// which is why the title and the content could not be moved independently and why nothing
/// recorded which part of the 14 was air and which was the label.
const TITLE_CONTENT_SPACING: u32 = dimensions::BUTTON_ICON_SPACING;

/// The frame's own edge-to-content padding: 4.
///
/// The `padding` of the same formula, kept separate from [`TITLE_CONTENT_SPACING`] for the
/// reason rule 4 gives: padding is edge-to-content, spacing is element-to-element. The old
/// `content_rect` used one `inset = 4` for both the horizontal and the vertical edges and let
/// the title band stand in for the top one, so there was no place for a second value to live.
const FRAME_PADDING: u32 = 4;

/// Group box widget.
pub struct GroupBox {
    base: BaseWidget,
    title: String,
    alignment: Alignment,
    checkable: bool,
    checked: bool,
    /// Emitted when the group box is toggled, with the new checked state.
    /// `checked` starts as `true` even when `checkable` is false; the signal is
    /// only emitted for a real state change.
    pub toggled: Signal1<bool>,
    /// Cached title width computed in draw() via RenderContext::measure_text().
    cached_title_width: Option<u32>,
    /// The semantic material this container's face asks for.
    ///
    /// `Panel` is a type alias for this control, so a card reaches the same method through
    /// either name. Only a device build has the theme module that defines the vocabulary; a
    /// stripped build has no material to name, so the field and its accessors are absent rather
    /// than carrying a duplicate local enum — the same gate `Window` uses.
    #[cfg(device_profile)]
    material: crate::theme::Material,
    registry: Option<Rc<RefCell<SimpleRegistry>>>,
    /// Whether the frame and face are omitted, leaving only the caption and content.
    flat: bool,
    /// Optional content size, used to make the size hint content-driven.
    content_size_hint: Option<Size>,
    /// Whether the group can be collapsed down to its title band (C-08).
    collapsible: bool,
    /// Whether the content is currently collapsed away.
    collapsed: bool,
    /// Emitted with the new collapsed state when it changes.
    /// Mirrors `toggled`: only a real edge emits.
    pub collapse_changed: Signal1<bool>,
}
impl GroupBox {
    /// Creates a group box.
    pub fn new(geometry: Rect) -> Self {
        Self {
            base: BaseWidget::new(WidgetKind::GroupBox, geometry, "GroupBox"),
            title: String::new(),
            alignment: Alignment::Left,
            checkable: false,
            checked: true,
            toggled: Signal1::new(),
            cached_title_width: None,
            flat: false,
            content_size_hint: None,
            collapsible: false,
            collapsed: false,
            collapse_changed: Signal1::new(),
            #[cfg(device_profile)]
            material: crate::theme::Material::None,
            registry: None,
        }
    }
    /// Whether this group can be collapsed to its title band (C-08).
    pub fn is_collapsible(&self) -> bool {
        self.collapsible
    }

    /// Enables or disables the collapsible form (C-08).
    ///
    /// Turning the capability off expands the group, because there would otherwise be no
    /// affordance left that could bring the content back.
    pub fn set_collapsible(&mut self, collapsible: bool) {
        if self.collapsible == collapsible {
            return;
        }
        self.collapsible = collapsible;
        if !collapsible && self.collapsed {
            self.collapsed = false;
            self.apply_content_visibility();
            self.collapse_changed.emit(false);
        }
        self.base.request_redraw();
    }

    /// Whether the content is currently collapsed away.
    pub fn is_collapsed(&self) -> bool {
        self.collapsed
    }

    /// Collapses or expands the content (C-08).
    ///
    /// Refused on a group that was not made collapsible: the state would be unreachable
    /// from the UI, which is the same reason [`Self::set_checked`] refuses on a plain box.
    /// Collapsing hides every mounted child and gives `Self::content_rect` zero height,
    /// so a layout that follows the rect also gives the space back.
    pub fn set_collapsed(&mut self, collapsed: bool) {
        if !self.collapsible || self.collapsed == collapsed {
            return;
        }
        self.collapsed = collapsed;
        self.apply_content_visibility();
        self.base.request_redraw();
        self.collapse_changed.emit(collapsed);
    }

    /// Toggles the collapsed state; no-op when not collapsible.
    pub fn toggle_collapsed(&mut self) {
        self.set_collapsed(!self.collapsed);
    }

    /// Pushes the collapsed state onto every mounted child.
    fn apply_content_visibility(&self) {
        #[cfg(not(alloc_frugal))]
        {
            for child in self.base.children.iter().copied() {
                crate::set_widget_visible(child, !self.collapsed);
            }
        }
        // A sized profile holds no mounted surfaces, so there is no visibility flag to write.
        #[cfg(alloc_frugal)]
        let _ = self.collapsed;
    }
    /// Whether the group box draws without a frame or face.
    pub fn is_flat(&self) -> bool {
        self.flat
    }

    /// Enables or disables the flat appearance.
    ///
    /// A flat group box keeps its title and content but omits the face and border, which is
    /// the `flat` variant a form uses to group fields without drawing another box.
    pub fn set_flat(&mut self, flat: bool) {
        if self.flat == flat {
            return;
        }
        self.flat = flat;
        self.base.request_redraw();
    }

    /// The content size this group box should reserve room for.
    pub fn content_size_hint(&self) -> Option<Size> {
        self.content_size_hint
    }

    /// Sets (or clears, with `None`) the content size used by [`Widget::size_hint`].
    pub fn set_content_size_hint(&mut self, size: Option<Size>) {
        if self.content_size_hint == size {
            return;
        }
        self.content_size_hint = size;
        self.base.request_layout();
        self.base.request_redraw();
    }

    /// Returns title.
    pub fn title(&self) -> &str {
        &self.title
    }
    /// Sets title.
    pub fn set_title(&mut self, title: String) {
        if self.title == title {
            return;
        }
        // The cached width belongs to the *old* title, and it is read by `title_row` (placement)
        // and by `size_hint` (the width reported to a parent). Leaving it set would make the box
        // reserve the previous caption's room until the next draw happened to run — and since the
        // cache was only written when the title was non-empty, clearing the title would have kept
        // a stale non-zero width indefinitely. Dropping it here falls back to the context-free
        // estimate until the next `draw` measures the new title exactly.
        self.cached_title_width = None;
        self.title = title;
        self.base.request_redraw();
    }
    /// Returns alignment.
    pub fn alignment(&self) -> Alignment {
        self.alignment
    }
    /// Sets alignment.
    pub fn set_alignment(&mut self, alignment: Alignment) {
        if self.alignment == alignment {
            return;
        }
        self.alignment = alignment;
        self.base.request_redraw();
    }
    /// Returns whether group box is checkable.
    pub fn is_checkable(&self) -> bool {
        self.checkable
    }
    /// Sets checkable state.
    ///
    /// Requests a redraw because the flag changes what is painted: a checkable box grows an
    /// indicator column (see `Self::indicator_reserve`), so toggling it is visible. Without this
    /// a programmatic `set_checkable` (and the published `set("checkable", ..)` / JSON path that
    /// reaches it) left the frame unchanged until some unrelated repaint happened.
    pub fn set_checkable(&mut self, checkable: bool) {
        if self.checkable == checkable {
            return;
        }
        self.checkable = checkable;
        if !checkable {
            // Turning the gate off leaves the content enabled: the box no longer offers a way to
            // reach the unchecked state, so leaving the subtree disabled would strand it there.
            self.checked = true;
            self.apply_content_enabled();
        }
        self.base.request_redraw();
    }
    /// Returns whether group box is checked.
    pub fn is_checked(&self) -> bool {
        self.checked
    }
    /// Sets checked state.
    ///
    /// Requests a redraw because the indicator's tick is what `checked` selects. The early return
    /// keeps a repeated write free; the emit still happens only on a real edge.
    ///
    /// # Why a non-checkable box refuses the write
    ///
    /// `checked` is only meaningful where an indicator exists. Writing it on a box that is not
    /// checkable changed a flag nothing painted and nothing could read back through the UI, and it
    /// emitted `toggled` for a state the user has no way to reach — a signal that lied about what
    /// happened. The write is therefore refused, and [`Self::toggle`] therefore does nothing on a
    /// plain (non-checkable) box too.
    ///
    /// # The content link
    ///
    /// A checkable group's content is enabled exactly when the box is checked, matching the
    /// reference control: unchecking a group disables everything inside it, re-checking restores
    /// it. The rule is applied to the mounted subtree here rather than left to the host, because a
    /// disabled group that still delivered clicks to its fields is the one thing the checkbox is
    /// for. Re-checking re-enables the whole subtree — a control that its owner had disabled on its
    /// own keeps the last write it was given, which is this one.
    pub fn set_checked(&mut self, checked: bool) {
        if !self.checkable {
            return;
        }
        if self.checked == checked {
            return;
        }
        self.checked = checked;
        self.apply_content_enabled();
        self.base.request_redraw();
        self.toggled.emit(checked);
    }
    /// Toggles checked state.
    pub fn toggle(&mut self) {
        self.set_checked(!self.checked);
    }

    /// Pushes [`Self::checked`] onto every mounted content control.
    fn apply_content_enabled(&self) {
        for child in self.base.children.iter().copied() {
            apply_subtree_enabled(child, self.checked, 0);
        }
    }

    /// Sets the shared widget registry for child forwarding.
    pub fn set_registry(&mut self, registry: Rc<RefCell<SimpleRegistry>>) {
        self.registry = Some(registry);
    }

    /// The material this container asks the theme for.
    ///
    /// [`crate::theme::Material::None`] is the default and means "paint the ordinary container
    /// face".
    #[cfg(device_profile)]
    pub fn material(&self) -> crate::theme::Material {
        self.material
    }

    /// Requests `material` for this container's face.
    ///
    /// # Why a control resolves the material to an opaque surface
    ///
    /// A window can sample the desktop behind it because it owns the frame; a control inside a
    /// window is painted into the window's own composition, so there is no backdrop for it to
    /// sample. The theme answers with the palette role that material stands in for
    /// ([`crate::theme::ThemeManager::material_surface`]), which is the same resolution a host
    /// that reduced transparency gets — so the intent is expressible and its fallback is the
    /// truthful picture rather than a silently ignored request.
    ///
    /// Requests a redraw only on a real change, so a repeated write schedules no frame.
    #[cfg(device_profile)]
    pub fn set_material(&mut self, material: crate::theme::Material) {
        if self.material == material {
            return;
        }
        self.material = material;
        self.base.request_redraw();
    }
    /// The leading inset of the title's own box from the frame's edge: 10.
    ///
    /// Half of the frame's own padding plus the label's air, and deliberately *not*
    /// [`FRAME_PADDING`]: the title is not content, it is chrome that interrupts the border, and
    /// the classic group-box look has it start further in than the content below it.
    const TITLE_INSET: i32 = TITLE_PADDING;

    /// Returns title rectangle.
    /// The rectangle the title occupies, **inside** the frame.
    ///
    /// # Why the title sits on the border rather than above it
    ///
    /// The title was previously centred on the top edge (`y - text_height / 2`),
    /// which put three quarters of every glyph above the widget. The software
    /// rasteriser paints a glyph *downward* from its origin, so the pixels landed on
    /// rows `-8..+8`: over half of the title — `Sample` reads as `Sampl` — fell outside
    /// the widget's own rectangle. The software backend draws into a rectangular buffer,
    /// so those rows were silently clipped and the frame *looked* right; the SVG backend
    /// emits absolute coordinates with no canvas bound, so the same defect showed up as
    /// text above the picture. Neither reading was the truth, and the two disagreed.
    ///
    /// The title is therefore banded **inside** the frame, its centre line on the top
    /// border. That keeps the classic group-box look (the title interrupts the border)
    /// while making every painted pixel fall within the widget, which is the property
    /// both backends then agree on.
    ///
    /// # Why the horizontal inset is a named constant
    ///
    /// The three alignment arms each spelled the inset as a literal `10` — three copies of one
    /// fact, any of which could be edited alone. `Alignment::Top`/`Bottom` are not horizontal
    /// positions at all, so they take the leading inset rather than pretending to place the
    /// title; the previous `=> rect.x + 10` arm is now the documented leading case.
    fn title_rect(&self) -> Rect {
        let rect = self.geometry();
        self.title_row(rect).1
    }

    /// The width the title must leave for the indicator, if the box is checkable: 0 otherwise.
    ///
    /// Read by [`Self::title_columns`] and by the tests, so the reserve and the delivered columns
    /// cannot disagree.
    fn indicator_reserve(&self) -> u32 {
        if !self.checkable {
            return 0;
        }
        dimensions::CHECKBOX_BOX + dimensions::INDICATOR_TEXT_SPACING
    }

    /// The indicator's column and the title, side by side.
    ///
    /// # Why the two boxes are one derivation
    ///
    /// They used to be two derivations with opposite signs: the title's leading inset was
    /// `TITLE_INSET + indicator_reserve()`, and the indicator was then placed *backwards* from the
    /// title's own leading edge (`title.x - INDICATOR_TEXT_SPACING - box`). Two subtractions of the
    /// same two numbers in two directions agree only while both are right, which is exactly how the
    /// indicator came to hang outside the frame when the reserve was computed but not honoured.
    ///
    /// Splitting the row through [`GroupBox::title_columns`] makes the reserve and the placement the
    /// same answer: the indicator is a column `indicator_reserve()` wide (the box plus its gap) and
    /// the title is whatever the row leaves.
    ///
    /// # What this covers, and what it does not
    ///
    /// It resolves the **horizontal** placement of the two columns. The vertical position (the
    /// title band's centre line on the frame's top border) is not a layout question — it is where
    /// this control's chrome sits — so it stays here. The `Alignment` of the title within the room
    /// it was given is applied to the title box afterwards, because "left / center / right"
    /// describe where the label sits in that room.
    ///
    /// Returns `(indicator_column, title)` in frame coordinates. The first is `None` when the box
    /// is not checkable; when it is `Some`, its width is `indicator_reserve()` — see
    /// [`Self::checkbox_rect`] for the square inside it.
    fn title_row(&self, frame: Rect) -> (Option<Rect>, Rect) {
        let text_width = self.cached_title_width.unwrap_or_else(|| self.title.len() as u32 * 8);
        let band_height = self.title_band_height();
        let band_y = (frame.y + band_height as i32 / 2).max(frame.y);
        // The row spans the frame's own width, minus the leading inset the title row reserves and
        // the trailing inset that keeps a long title from touching the far border.
        let leading = Self::TITLE_INSET.max(0) as u32;
        let row = Rect::new(
            frame.x + leading as i32,
            band_y,
            frame.width.saturating_sub(leading * 2),
            band_height,
        );
        if row.width == 0 {
            return (None, Rect::new(frame.x, band_y, 0, band_height));
        }
        let (indicator, title) = self.title_columns(row, text_width, band_height);
        // The label's own alignment inside the room the row left it. `Top`/`Bottom` are not
        // horizontal positions at all, so they take the leading edge — which is what the previous
        // `=> rect.x + 10` arm documented.
        let free = title.width.saturating_sub(text_width);
        let offset = match self.alignment {
            Alignment::Left | Alignment::Top | Alignment::Bottom => 0,
            Alignment::Center => free / 2,
            Alignment::Right => free,
        };
        (
            indicator,
            Rect::new(title.x + offset as i32, band_y, text_width.min(title.width), band_height),
        )
    }

    /// The two columns of the title row: the indicator's column and the title's box.
    ///
    /// Both widths come from the same two constants ([`dimensions::CHECKBOX_BOX`],
    /// [`dimensions::INDICATOR_TEXT_SPACING`]) through [`Self::indicator_reserve`], so the reserve
    /// and the delivered columns are the same numbers.
    ///
    /// # The two cases
    ///
    /// * The row holds the indicator and the measured label: the indicator keeps its column and the
    ///   title takes the remainder. The title is **the whole remainder**, not its own text width, so
    ///   a short label still has room to be centred or right-aligned in — which is what the caller's
    ///   alignment offset reads.
    /// * The row is narrower than the two columns' sum: the shortfall is shared **proportionally**,
    ///   with the integer remainder going to the leading (indicator) column, so the pair sums to
    ///   exactly `row.width` and neither column is pushed outside the frame.
    fn title_columns(&self, row: Rect, text_width: u32, band_height: u32) -> (Option<Rect>, Rect) {
        let indicator_wanted = if self.checkable { dimensions::CHECKBOX_BOX } else { 0 };
        // The gap is the *rest* of the declared reserve: the reserve is "the indicator's box plus
        // the gap to the label", so reading it and subtracting the box is the same relation as
        // adding the two parts. Taking it from the accessor is what keeps the accessor a delivered
        // number rather than a declared one.
        let gap = if self.checkable {
            self.indicator_reserve().saturating_sub(dimensions::CHECKBOX_BOX)
        } else {
            0
        };
        let total = indicator_wanted.saturating_add(text_width).saturating_add(gap);
        let (indicator_width, title_width) = if total <= row.width || total == 0 {
            let indicator = indicator_wanted.min(row.width);
            (indicator, row.width.saturating_sub(indicator).saturating_sub(gap))
        } else {
            let scaled = |width: u32| -> u32 {
                ((u64::from(width) * u64::from(row.width)) / u64::from(total)) as u32
            };
            let indicator = scaled(indicator_wanted);
            let title_outer = scaled(text_width.saturating_add(gap));
            let short = row.width.saturating_sub(indicator.saturating_add(title_outer));
            (indicator + short, title_outer.saturating_sub(gap))
        };
        let indicator = if self.checkable {
            Some(Rect::new(row.x, row.y, indicator_width, band_height))
        } else {
            None
        };
        // The gap is the title's own leading margin, so it is room the label never draws in — and
        // it belongs to the column that *follows* it, which is why the indicator's column cannot
        // absorb it.
        let title =
            Rect::new(row.x + indicator_width as i32 + gap as i32, row.y, title_width, band_height);
        (indicator, title)
    }

    /// The height of one row of the control's own chrome: the title band.
    ///
    /// One line of the default font, which is what the title is drawn in and what the content
    /// below it must yield to. Deriving it here (rather than using a `16` literal) is what lets
    /// a larger font push the content down instead of overlapping the title.
    fn title_band_height(&self) -> u32 {
        // The **effective font** — the resolved theme/caller font, the same one the title is painted
        // with — so the band and the ink cannot disagree (D09-STYLE-01).
        self.title_font().size().max(1.0) as u32
    }

    /// The font the group's title is drawn with: the resolved theme/caller font (D09-STYLE-01).
    ///
    /// Borrowed from the style: `effective_font` already returns a reference, and the old
    /// `.clone()` copied `Font.family` (a `String`) on every call — up to three times per frame.
    fn title_font(&self) -> &crate::core::Font {
        effective_font(self.style())
    }

    /// The top edge the frame's content begins at, below the title band and its gap.
    ///
    /// This is BLUE22 §B.8's `topPadding = padding + label_height + spacing`.
    /// The three terms are separately named because they are three
    /// different facts: the frame's edge-to-content padding, the label's own height, and the
    /// element-to-element gap. Summing them is what makes the content start exactly below the
    /// title, so the pair cannot drift into an overlap.
    fn content_top(&self) -> i32 {
        let rect = self.geometry();
        let offset = FRAME_PADDING + self.title_band_height() + TITLE_CONTENT_SPACING;
        rect.y.saturating_add(offset as i32)
    }

    /// Returns checkbox rectangle if checkable.
    ///
    /// The box comes from [`Self::title_row`] — the row's leading column — so the room reserved for
    /// the indicator and the room it occupies are one answer. The indicator's own size and its gap
    /// to the label read the shared table, so a group box's checkbox matches the one a `CheckBox`
    /// draws in the same form rather than being a second, slightly different box.
    fn checkbox_rect(&self) -> Option<Rect> {
        let frame = self.geometry();
        let (column, title) = self.title_row(frame);
        let column = column?;
        // The square is one `CHECKBOX_BOX`, bounded by the band it sits on — a tall glyph cannot
        // give it more room than the title row is tall. It is drawn at the column's **trailing**
        // edge box-wise, because the column is `CHECKBOX_BOX + gap` wide and the gap is the part
        // adjacent to the label — which is the same placement the reserve describes.
        let size = dimensions::CHECKBOX_BOX.min(title.height).min(column.width) as i32;
        Some(Rect::new(
            (column.x + column.width as i32 - size).max(frame.x),
            column.y + (column.height as i32 - size) / 2,
            size.max(0) as u32,
            size.max(0) as u32,
        ))
    }

    /// The area the group's children are clipped to and forwarded events from.
    ///
    /// Bounded by the title band above, the frame's own padding on the three other edges, and
    /// the same padding below — so it is the frame's content box minus the title, and it can
    /// never describe a negative extent when the control is squeezed.
    ///
    /// A collapsed group (C-08) reports a zero-height rect at the content top: the content
    /// band has been given back to the caller's layout, not merely hidden behind the title.
    fn content_rect(&self) -> Rect {
        let rect = self.geometry();
        let top = self.content_top();
        if self.collapsed {
            return Rect::new(
                rect.x + FRAME_PADDING as i32,
                top,
                rect.width.saturating_sub(FRAME_PADDING * 2),
                0,
            );
        }
        Rect::new(
            rect.x + FRAME_PADDING as i32,
            top,
            rect.width.saturating_sub(FRAME_PADDING * 2),
            (rect.y + rect.height as i32)
                .saturating_sub(top)
                .saturating_sub(FRAME_PADDING as i32)
                .max(0) as u32,
        )
    }

    /// The title band a click is accepted on (C-09).
    ///
    /// It spans the frame's full width from its top edge down to where the content starts —
    /// including the inter-row gap, because that gap is visually part of the title row. The
    /// checkbox square lies inside this band, so the shared target cannot make the box
    /// itself unclickable.
    fn title_band_rect(&self) -> Rect {
        let frame = self.geometry();
        let height = self.content_top().saturating_sub(frame.y).max(0) as u32;
        Rect::new(frame.x, frame.y, frame.width, height)
    }

    /// The content control that owns `pos`, searched back to front.
    ///
    /// # Why the walk is reversed
    ///
    /// [`Draw`] paints the children in list order, so the last child is the one on top; a press
    /// must therefore be offered to it first. That is the same order a plain z-stack would use,
    /// and it is why the last of two overlapping fields receives the click.
    ///
    /// A child that reports no rectangle at all (an id-only registry entry the host places itself)
    /// cannot answer a positional question. Such a child is remembered and used only when no child
    /// could be located, which keeps the historical "forward into the content area" behaviour for
    /// registries that hold no geometry — the forward target is still a real child, just not a
    /// located one.
    fn hit_test_child(&self, pos: Point) -> Option<ObjectId> {
        let mut unlocated = None;
        for child in self.base.children.iter().rev().copied() {
            match child_rect(child) {
                Some(rect) => {
                    if rect.contains(pos) {
                        return Some(child);
                    }
                }
                None => {
                    if unlocated.is_none() {
                        unlocated = Some(child);
                    }
                }
            }
        }
        unlocated
    }
}

/// The rectangle `child` occupies, preferring the live widget and falling back to the control
/// backend's record.
///
/// A mounted control owns its own geometry (`BaseWidget`), and that is what the tree painter draws
/// it at. The backend's record is the fallback for a control that is addressed by id only, which is
/// the shape a designer registry has while its widget lives elsewhere. The mini profile holds no
/// widget objects and has no backend geometry, so it answers `None` and the group box forwards into
/// the content area as a single-child container.
fn child_rect(child: ObjectId) -> Option<Rect> {
    #[cfg(not(alloc_frugal))]
    {
        if let Some(rect) = crate::widget::runtime::with_widget(child, |widget| widget.geometry()) {
            return Some(rect);
        }
        crate::widget_geometry(child).map(|(x, y, width, height)| Rect::new(x, y, width, height))
    }
    #[cfg(alloc_frugal)]
    {
        let _ = child;
        None
    }
}

/// Sets `enabled` on `id` and every mounted descendant.
///
/// The walk is bounded rather than trusting the data: a widget tree is a tree, but 32 levels is
/// already far past any real nesting and a bound turns a malformed graph into "stops walking"
/// instead of a hang. The mini profile has no widget registry to descend, so only the control the
/// group box names directly is reached there.
fn apply_subtree_enabled(id: ObjectId, enabled: bool, depth: u32) {
    #[cfg(not(alloc_frugal))]
    {
        if depth > 32 {
            return;
        }
        crate::set_widget_enabled(id, enabled);
        // C-P11: read each child by index instead of cloning the child list. The old
        // `children().to_vec()` allocated one Vec per visited node, so a deep or wide
        // subtree paid O(nodes) allocations for every enable transition.
        let count =
            crate::widget::runtime::with_widget(id, |widget| widget.children().len()).unwrap_or(0);
        for index in 0..count {
            let child = crate::widget::runtime::with_widget(id, |widget| {
                widget.children().get(index).copied()
            });
            if let Some(Some(child)) = child {
                apply_subtree_enabled(child, enabled, depth + 1);
            }
        }
    }
    #[cfg(alloc_frugal)]
    let _ = (id, enabled, depth);
}
// Implement Widget trait
impl Widget for GroupBox {
    fn base(&self) -> &BaseWidget {
        &self.base
    }
    fn set_state_theme_hook(&mut self) {
        crate::style::reapply_active_theme_state(self);
    }

    fn base_mut(&mut self) -> &mut BaseWidget {
        &mut self.base
    }

    fn size_hint(&self) -> Size {
        // A group box is a panel: it has no content of its own, so its size wish is the chrome
        // floor plus room for one title, and nothing about it is content-driven. The floor is
        // expressed through the same two constants the frame's own geometry is derived from,
        // and the height through `content_top`, so the reported size cannot describe a frame
        // narrower than its title band or shorter than its own padding.
        let padding = crate::style::EdgeOffsets {
            top: FRAME_PADDING + self.title_band_height() + TITLE_CONTENT_SPACING,
            right: FRAME_PADDING,
            bottom: FRAME_PADDING,
            left: FRAME_PADDING,
        };
        let title = self.cached_title_width.unwrap_or_else(|| self.title.len() as u32 * 8);
        let floor = Size::new(
            title + padding.horizontal_total() + (Self::TITLE_INSET as u32) * 2,
            self.content_top() as u32 + padding.bottom,
        );
        let hint = ControlMetrics::implicit_size(Size::new(0, 0), padding, floor);
        match self.content_size_hint {
            Some(content) => Size::new(
                hint.width.max(content.width.saturating_add(padding.horizontal_total())),
                hint.height.max(
                    content
                        .height
                        .saturating_add(self.content_top() as u32)
                        .saturating_add(padding.bottom),
                ),
            ),
            None => hint,
        }
    }
    impl_draw_bridge!();
    impl_widget_property_hooks!();
    /// Resolves a published event name to the control's own signal.
    ///
    /// # Why this is explicit per control
    ///
    /// `connect_event` validates a name against the capability table and registers a hub slot; only
    /// `event_signal_dyn` joins that name to the signal the control actually emits. Without it a name
    /// is valid and inert, which is the silent failure `tools/check_event_signal_dyn.sh` exists to
    /// make impossible. The arm set is checked against the capability's published names, so this
    /// list cannot drift from what the control advertises.
    fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
        use crate::signal::EventSignalRef;
        #[allow(unused_imports)]
        use crate::widget::capability::CapabilityValue;
        match name {
            "toggled" => Some(EventSignalRef::mapped("toggled", &self.toggled, |v| {
                CapabilityValue::Bool(*v)
            })),
            "collapse_changed" => {
                Some(EventSignalRef::mapped("collapse_changed", &self.collapse_changed, |v| {
                    CapabilityValue::Bool(*v)
                }))
            }
            _ => None,
        }
    }
}

/// `GroupBox`'s property contract.
///
/// `Panel` is a type alias for this type (`pub type Panel = GroupBox`), so this
/// single impl serves both `WidgetKind::GroupBox` and every `WidgetKind::Panel`
/// value that is actually a group box. The alias is deliberately not given an
/// impl of its own — it would be a second, duplicate impl of the same type.
impl WidgetProperties for GroupBox {
    fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
        match name {
            "title" => Ok(CapabilityValue::String(self.title().to_string())),
            "alignment" => {
                Ok(CapabilityValue::String(alignment_to_str(self.alignment()).to_string()))
            }
            "checkable" => Ok(CapabilityValue::Bool(self.is_checkable())),
            "checked" => Ok(CapabilityValue::Bool(self.is_checked())),
            _ => base_property_get(self, name),
        }
    }

    fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
        match name {
            "title" => {
                self.set_title(expect_string(value)?);
                Ok(())
            }
            "alignment" => {
                self.set_alignment(expect_alignment(value)?);
                Ok(())
            }
            "checkable" => {
                self.set_checkable(expect_bool(value)?);
                Ok(())
            }
            "checked" => {
                self.set_checked(expect_bool(value)?);
                Ok(())
            }
            _ => base_property_set(self, name, value),
        }
    }

    fn property_names(&self) -> &'static [&'static str] {
        property_names_of!["title", "alignment", "checkable", "checked", BASE_PROPERTY_NAMES]
    }

    /// Runs one of the commands `group_box` publishes.
    ///
    /// `toggle` flips the group's checkable latch — the one zero-argument action in
    /// the list. `set_title`, `set_checkable` and `set_checked` assign state, so they
    /// are refused as [`CapabilityAccessError::OutOfRange`] (use the property route)
    /// rather than reported unknown.
    fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
        match name {
            "toggle" => {
                self.toggle();
                Ok(())
            }
            "set_title" | "set_checkable" | "set_checked" => Err(CapabilityAccessError::OutOfRange),
            _ => Err(CapabilityAccessError::UnknownCommand),
        }
    }
}

impl EventHandler for GroupBox {
    fn handle_event(&mut self, event: &Event) {
        self.base.handle_event(event);
        if !self.base.is_enabled() {
            return;
        }
        // Handle the title band (C-09) before the content link below: clicking the caption
        // toggles the group, exactly as clicking its checkbox does. A collapsible group uses
        // the same gesture to collapse, because that is the affordance the title band is for.
        if let Event::MousePress { pos, button, .. } = event {
            if *button == 1 && self.title_band_rect().contains(*pos) {
                if self.collapsible {
                    self.toggle_collapsed();
                } else if self.checkable {
                    self.toggle();
                }
                return;
            }
        }
        // An unchecked checkable group is disabled content, and a collapsed group has no
        // content band at all: the press must not reach a field the user could not see.
        if (self.checkable && !self.checked) || self.collapsed {
            return;
        }
        let content = self.content_rect();
        if let Some(ref reg) = self.registry {
            match event {
                // Positional events go to the control that owns the point. The old code sent every
                // one of them to `children.first()`, so in a box with several children the second
                // and later ones never received a press even though `draw` painted them.
                Event::MousePress { pos, .. }
                | Event::MouseRelease { pos, .. }
                | Event::MouseMove { pos }
                | Event::MouseDoubleClick { pos, .. }
                | Event::ContextMenu { pos } => {
                    if content.contains(*pos) {
                        if let Some(child_id) = self.hit_test_child(*pos) {
                            let _ = reg.borrow_mut().forward_event(child_id, event);
                        }
                    }
                }
                // A coordinate-free event (keys, a wheel, focus, …) cannot name one child, so it is
                // broadcast to every content control. A group box is documented as a multi-child
                // container, so "the first child receives all keyboard input" would be an arbitrary
                // choice made silently.
                _ => {
                    for child_id in self.base.children.iter().copied() {
                        let _ = reg.borrow_mut().forward_event(child_id, event);
                    }
                }
            }
        }
    }
}
impl Draw for GroupBox {
    fn draw(&mut self, context: &mut RenderContext) {
        // Cache the title width from the render context, so the hint path can use a real
        // measurement instead of the context-free estimate.
        //
        // # Why the cache is assigned unconditionally
        //
        // This used to be `if !self.title.is_empty() { self.cached_title_width = Some(..) }`, so
        // setting the title to `""` after a draw left the **previous** title's width in the cache.
        // `title_row` and the hint then kept reserving room for a caption that is no longer drawn —
        // an empty group box whose title band is as wide as the title it used to have. The `if` was
        // guarding nothing: measuring an empty string is cheap and yields `0`, which is exactly the
        // honest answer, so an empty title now caches `Some(0)` and the `.unwrap_or_else` fallbacks
        // stop being reached from a `draw`ed control at all.
        let metrics = context.measure_text(&self.title, self.title_font());
        self.cached_title_width = Some(metrics.width);
        // Draw base widget
        let rect = self.geometry();
        let content = self.content_rect();
        let title_rect = self.title_rect();
        let style = self.style();
        // Draw the panel's face.
        //
        // A panel is the crate's container surface — the thing a card *is* — so it takes the
        // theme's `surface_container` role, one step above the page. It used to draw **no
        // face at all**, only a border, so a panel on the window was indistinguishable from
        // the window itself and the whole set of layering roles had no consumer. A caller's
        // explicit colour still wins, so a hand-coloured panel is unaffected.
        let face = {
            // A requested material replaces the container role with the palette surface that
            // material resolves to. `Material::None` (the default) skips this entirely, so a
            // container that never asked for one paints exactly the pixels it painted before.
            #[cfg(device_profile)]
            {
                if self.material != crate::theme::Material::None {
                    Some(crate::style::theme_manager().material_surface(self.material))
                } else {
                    style.background_color.filter(|_| !style.theme_derived).or_else(|| {
                        crate::style::layer_color(crate::style::LayerColor::SurfaceContainer)
                    })
                }
            }
            #[cfg(not(device_profile))]
            {
                style.background_color.filter(|_| !style.theme_derived).or_else(|| {
                    crate::style::layer_color(crate::style::LayerColor::SurfaceContainer)
                })
            }
        };
        if !self.flat {
            if let Some(face) = face {
                context.face_with_gradient(
                    rect,
                    face,
                    self.style().background_gradient.as_ref(),
                    self.style().surface.unwrap_or_default(),
                    self.style().border_radius.unwrap_or(0),
                    Color::BLACK,
                );
            }
            // Rounded to the face's radius: a square outline left the four corners unpainted.
            crate::widget::draw::stroke_surface_border(
                context,
                rect,
                self.style().border_radius.unwrap_or(0),
                style.border_color.unwrap_or(Color::rgb(200, 200, 200)),
                1,
            );
        }
        // Draw title background to hide the border behind the title. The band is widened
        // by `TITLE_PADDING` on both sides, so it is clipped to the frame: a band that ran
        // past `rect.right()` would paint over the sibling to the right of this group. Its
        // height is the title band's own, so the erasure covers exactly the row the label
        // occupies rather than a fixed `BORDER_WIDTH` that a taller font would step past.
        let title_bg_left = (title_rect.x - TITLE_PADDING).max(rect.x);
        let title_bg_right = (title_rect.x + title_rect.width as i32 + TITLE_PADDING)
            .min(rect.x + rect.width as i32);
        let title_bg_width = (title_bg_right - title_bg_left).max(0) as u32;
        if title_bg_width > 0 && !self.flat {
            context.fill_rect(
                Rect::new(
                    title_bg_left,
                    rect.y,
                    title_bg_width,
                    BORDER_WIDTH.max(title_rect.height),
                ),
                // The erasure band must match the face it sits on, or it reads as a stripe.
                // The caller's colour wins, then the panel's own layer, then the page.
                style
                    .background_color
                    .filter(|_| !style.theme_derived)
                    .or_else(|| {
                        crate::style::layer_color(crate::style::LayerColor::SurfaceContainer)
                    })
                    .unwrap_or_else(|| {
                        crate::style::theme_manager()
                            .current_theme()
                            .map(|active| active.colors.background)
                            .unwrap_or(Color::rgb(255, 255, 255))
                    }),
            );
        }
        // Draw checkbox if checkable
        if self.checkable {
            if let Some(checkbox_rect) = self.checkbox_rect() {
                // The box takes the frame's own border colour rather than the near-invisible
                // mid-grey literal it carried (`Color::rgb(100, 100, 100)`). And the tick is
                // painted in the **contrast colour of that box's fill**, not pure black: a
                // pure-black stroke on a dark appearance is the least readable mark in the
                // control, and it is the very part the user toggles. Same rule as
                // `mdiarea.rs`, which draws its active title in `primary.contrast_color()`.
                //
                // The fill has to be painted for that rule to have a referent. This box used to
                // be an outline only, so the tick's contrast decision was made against the
                // *border* colour — a mid-grey, whose contrast colour is black on every
                // appearance. A theme resolved the ambiguity by accident (`style.background_color`
                // is `Some`), which is exactly why the defect survived: it only showed where no
                // theme was active. The box now paints its own field and the tick reads
                // `field.contrast_color()`, which is `CheckBox`'s derivation verbatim — one
                // control's indicator, one rule.
                let box_color = style.border_color.unwrap_or(Color::rgb(100, 100, 100));
                let field = style.background_color.unwrap_or(Color::WHITE);
                context.fill_rect(checkbox_rect, field);
                context.draw_rect(checkbox_rect, box_color);
                // Draw checkmark if checked
                if self.checked {
                    // The mark lands on the box's own fill, so its colour is that fill's contrast.
                    let tick_color = field.contrast_color();
                    context.draw_line(
                        Point::from_f32(
                            checkbox_rect.x as f32 + 2.0,
                            checkbox_rect.y as f32 + checkbox_rect.height as f32 * 0.5,
                        ),
                        Point::from_f32(
                            checkbox_rect.x as f32 + checkbox_rect.width as f32 * 0.5,
                            checkbox_rect.y as f32 + checkbox_rect.height as f32 - 2.0,
                        ),
                        tick_color,
                    );
                    context.draw_line(
                        Point::from_f32(
                            checkbox_rect.x as f32 + checkbox_rect.width as f32 * 0.5,
                            checkbox_rect.y as f32 + checkbox_rect.height as f32 - 2.0,
                        ),
                        Point::from_f32(
                            checkbox_rect.x as f32 + checkbox_rect.width as f32 - 2.0,
                            checkbox_rect.y as f32 + 2.0,
                        ),
                        tick_color,
                    );
                }
            }
        }
        // Draw title text. The separator is drawn first and the label on top of it, so
        // the label's own glyph pixels are what the eye reads at the join.
        if !self.title.is_empty() {
            // The disabled title recedes toward the surface it is painted on — the box's
            // own background — instead of a fixed grey that sat at the wrong end of the
            // ramp on a dark appearance.
            let base_ink = style.text_color.unwrap_or(Color::rgb(0, 0, 0));
            let text_color = if self.base.is_enabled() {
                base_ink
            } else {
                self.base.disabled_ink_on(base_ink, style.background_color.unwrap_or(Color::WHITE))
            };
            context.draw_text(
                Point::from_f32(title_rect.x as f32, title_rect.y as f32),
                &self.title,
                self.title_font(),
                text_color,
                HorizontalAlignment::Left,
            );
        }
        // A collapsible group advertises its affordance at the trailing edge of the title
        // band: a downward arrow while expanded ("this can fold away") and a rightward one
        // while collapsed ("this can unfold"). It is painted from the same ink the caption
        // uses, so it recedes with the title on a disabled group.
        if self.collapsible {
            let size = dimensions::CHECKBOX_BOX.min(title_rect.height.max(1));
            let arrow = Rect::new(
                (rect.x + rect.width as i32 - TITLE_PADDING - size as i32).max(rect.x),
                title_rect.y + title_rect.height.saturating_sub(size) as i32 / 2,
                size,
                size,
            );
            let ink = style.text_color.unwrap_or(Color::BLACK);
            crate::widget::draw_icon_at(
                context,
                arrow,
                if self.base.is_enabled() {
                    ink
                } else {
                    self.base.disabled_ink_on(ink, style.background_color.unwrap_or(Color::WHITE))
                },
                if self.collapsed { IconName::ArrowRight } else { IconName::ArrowDown },
            );
        }
        if let Some(ref reg) = self.registry {
            if !self.collapsed {
                context.push_clip(content.x, content.y, content.width, content.height);
                for child_id in &self.base.children {
                    reg.borrow_mut().draw_widget(*child_id, context);
                }
                context.pop_clip();
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::{ObjectId, Rect};
    #[cfg(device_profile)]
    use crate::theme::AppearanceMode;

    #[test]
    fn groupbox_creation_defaults() {
        let gb = GroupBox::new(Rect::new(0, 0, 200, 100));
        assert_eq!(gb.geometry(), Rect::new(0, 0, 200, 100));
        assert!(gb.title().is_empty());
        assert!(gb.is_checked());
        assert!(!gb.is_checkable());
    }

    /// A requested material replaces the container face, and the default changes nothing.
    ///
    /// `Panel` is an alias for this control, so this is also the card's material contract.
    /// `WindowChrome` resolves to the high container role, which differs from the plain
    /// container face — that difference is what proves the request reached the paint.
    #[cfg(device_profile)]
    #[test]
    fn a_panel_material_replaces_the_container_face() {
        let _guard = crate::style::theme_test_guard();
        let mut plain = GroupBox::new(Rect::new(0, 0, 80, 60));
        let plain_face = crate::widget::svg::render_to_svg(&mut plain);
        assert_eq!(plain.material(), crate::theme::Material::None, "the default asks for none");

        let mut material = GroupBox::new(Rect::new(0, 0, 80, 60));
        material.set_material(crate::theme::Material::WindowChrome);
        assert_eq!(material.material(), crate::theme::Material::WindowChrome);
        let material_face = crate::widget::svg::render_to_svg(&mut material);
        assert_ne!(plain_face, material_face, "a requested material must change the painted face");

        // Re-applying the same material is a no-op, not a second state change.
        material.set_material(crate::theme::Material::WindowChrome);
        assert_eq!(material.material(), crate::theme::Material::WindowChrome);
    }

    #[test]
    fn groupbox_title_and_toggle() {
        let mut gb = GroupBox::new(Rect::new(0, 0, 200, 100));
        gb.set_title("Options".to_string());
        assert_eq!(gb.title(), "Options");
        gb.set_checkable(true);
        assert!(gb.is_checkable());
        gb.set_checked(false);
        assert!(!gb.is_checked());
        gb.toggle();
        assert!(gb.is_checked());
    }

    /// A-6: `set_checkable` and `set_checked` ask for a repaint.
    ///
    /// # The defect this pins
    ///
    /// Both flags change what is painted — `checkable` grows the title row an indicator column,
    /// and `checked` selects the indicator's tick — but neither requested a redraw, so a
    /// programmatic change (and the published `set(..)` / JSON path that reaches it) was invisible
    /// until some unrelated repaint happened.
    ///
    /// The assertion counts `redraw_requested`, which is the signal the host listens on. It is a
    /// pointer to the same fact `request_redraw` records, so a control that only set the field
    /// cannot pass it.
    #[test]
    fn groupbox_flag_setters_request_a_repaint() {
        use std::sync::atomic::{AtomicUsize, Ordering};
        use std::sync::Arc;

        let mut gb = GroupBox::new(Rect::new(0, 0, 200, 100));
        let count = Arc::new(AtomicUsize::new(0));
        let sink = Arc::clone(&count);
        let subscription = crate::signal::ConnectionScope::new();
        gb.base().redraw_requested.connect_scoped(&subscription, move || {
            sink.fetch_add(1, Ordering::SeqCst);
        });

        // A repeated write is not a change, so it must stay quiet.
        let initial = gb.is_checkable();
        gb.set_checkable(initial);
        assert_eq!(count.load(Ordering::SeqCst), 0, "a no-op write must not repaint");

        gb.set_checkable(!initial);
        assert_eq!(count.load(Ordering::SeqCst), 1, "`set_checkable` must request a repaint");

        gb.set_checked(!gb.is_checked());
        assert_eq!(count.load(Ordering::SeqCst), 2, "`set_checked` must request a repaint");
    }

    // ── Panel / child management tests ─────────────────────────────────
    #[test]
    fn test_panel_add_remove_children() {
        let mut gb = GroupBox::new(Rect::new(0, 0, 200, 100));
        let child1: ObjectId = 100;
        let child2: ObjectId = 200;
        let child3: ObjectId = 300;

        assert!(gb.children().is_empty());

        gb.add_child(child1);
        assert_eq!(gb.children().len(), 1);
        assert_eq!(gb.children()[0], child1);

        gb.add_child(child2);
        assert_eq!(gb.children().len(), 2);

        gb.add_child(child3);
        assert_eq!(gb.children().len(), 3);

        // Remove middle child
        gb.remove_child(child2);
        assert_eq!(gb.children().len(), 2);
        assert_eq!(gb.children()[0], child1);
        assert_eq!(gb.children()[1], child3);

        // Remove remaining children
        gb.remove_child(child1);
        assert_eq!(gb.children().len(), 1);

        gb.remove_child(child3);
        assert!(gb.children().is_empty());
    }

    /// A checkable group's indicator is inside the frame, and the title yields to it.
    ///
    /// The indicator's *width* was already derived (18 from the shared table), but its **space
    /// was not reserved**: the title still started at the 10 px inset, so an 18 px box began at
    /// x = -14 and was partly clipped by the frame it belongs to. The reference group box states the
    /// relation — the title's padding includes the indicator's width when there is one.
    #[test]
    fn a_checkable_groups_indicator_is_inside_the_frame() {
        let frame = Rect::new(0, 0, 200, 100);
        let mut gb = GroupBox::new(frame);
        gb.set_title("Options".to_string());

        // Uncheckable: nothing is reserved and there is no indicator.
        assert!(gb.checkbox_rect().is_none());
        let plain = gb.title_rect();

        gb.set_checkable(true);
        let indicator = gb.checkbox_rect().expect("a checkable group has an indicator");
        let reserved = gb.title_rect();

        assert!(
            indicator.x >= frame.x,
            "the indicator must not start left of the frame: {indicator:?}"
        );
        assert!(
            indicator.x + indicator.width as i32 <= frame.x + frame.width as i32,
            "and it must not run past the frame: {indicator:?}"
        );
        // The indicator and the title tile the leading row: the indicator ends one spacing
        // before the title begins, and the title moved right by exactly the reserve.
        assert_eq!(
            reserved.x,
            indicator.x + indicator.width as i32 + dimensions::INDICATOR_TEXT_SPACING as i32,
            "the title begins one gap after the indicator ends"
        );
        assert_eq!(
            reserved.x - plain.x,
            (dimensions::CHECKBOX_BOX + dimensions::INDICATOR_TEXT_SPACING) as i32,
            "the title reserved exactly the indicator column"
        );
    }

    /// The space the title reserves is the space the indicator's **column** uses, at any frame width.
    ///
    /// # Why the reserve accessor is read here
    ///
    /// [`GroupBox::indicator_reserve`] is the *declared* reserve. The assembled title row builds an
    /// indicator column of that width instead of reading it, so without a caller the method sat
    /// unused in every profile — which is exactly the "declared and not delivered" shape principle
    /// #22 forbids. Asking it here makes the accessor and the placement one checked relation rather
    /// than two numbers that happen to agree.
    ///
    /// # Why the drawn square is compared to the *column*, not to the reserve
    ///
    /// The reserve is the column, and the column is the square plus the gap to the label. The square
    /// itself is additionally bounded by the band it sits on — a squeezed box gives the indicator less
    /// room than its nominal size (`checkbox_rect` says so) — so asserting ``size == reserve`` would
    /// pin the square to the column and fail on a narrow frame for a reason that has nothing to do with
    /// the relation under test. The gap is asserted to survive instead, which is the part that must.
    #[test]
    fn the_indicator_reserve_and_placement_are_one_derivation() {
        for width in [80u32, 200, 400] {
            let frame = Rect::new(0, 0, width, 100);
            let mut gb = GroupBox::new(frame);
            gb.set_title("T".to_string());
            gb.set_checkable(true);
            let indicator = gb.checkbox_rect().expect("checkable");
            let title = gb.title_rect();
            assert!(
                indicator.x >= frame.x,
                "width {width}: the indicator left the frame at {}",
                indicator.x
            );
            assert_eq!(
                gb.indicator_reserve(),
                dimensions::CHECKBOX_BOX + dimensions::INDICATOR_TEXT_SPACING,
                "width {width}: the declared reserve must be the checkbox plus its gap"
            );
            assert!(
                indicator.width <= dimensions::CHECKBOX_BOX,
                "width {width}: the square may shrink but never grow past its nominal size"
            );
            assert_eq!(
                title.x,
                indicator.x + indicator.width as i32 + dimensions::INDICATOR_TEXT_SPACING as i32,
                "width {width}: the reserved column and the used column must agree"
            );
        }
    }

    /// A box that is not checkable reserves nothing, so the title starts at its own leading inset.
    #[test]
    fn an_uncheckable_box_reserves_no_indicator_column() {
        let gb = GroupBox::new(Rect::new(0, 0, 200, 100));
        assert_eq!(gb.indicator_reserve(), 0);
        assert!(gb.checkbox_rect().is_none());
    }

    #[test]
    fn test_panel_empty_has_no_children() {
        let gb = GroupBox::new(Rect::new(0, 0, 200, 100));
        assert!(gb.children().is_empty());
        assert_eq!(gb.children().len(), 0);
    }

    /// The tick is painted in the **contrast colour of the box's fill** (BLUE23 §4.1, judgement 17a).
    ///
    /// # Why this reads the emitted document, and not a colour field
    ///
    /// The defect it pins is "the tick is a literal black on a dark appearance". A first version of
    /// this test computed `box_color.contrast_color()` itself and compared it against itself, so a
    /// mutation that replaced the draw path's colour with `Color::BLACK` still passed — measured.
    ///
    /// A second version asserted `tick == WHITE` unconditionally, which is also wrong: it silently
    /// assumed a theme was active. Under no theme the box's fill is `WHITE`, so the *correct*
    /// contrast colour is `BLACK`, and that assertion would have demanded a bug.
    ///
    /// So the assertion is a **relationship between two emitted values** — the fill the indicator
    /// paints and the stroke the tick paints — and, where a theme registry exists, evaluated under
    /// **both** appearances, installed the way the exporter installs them
    /// (`install_preset_appearances` and `apply_theme_to_widget`). That is the plan's rule stated
    /// literally, and no constant can satisfy it across both: a literal black fails on the dark
    /// fill, a literal white on the light one.
    ///
    /// # Why the appearance loop is gated on `device_profile`
    ///
    /// `crate::theme` is what compiles the theme registry, and the stripped profiles (`mini`,
    /// `embedded`) do not have it — `theme_manager()` there is a placeholder. Gating the *loop*
    /// rather than the whole test keeps the relationship assertion running everywhere: with no
    /// theme the box's fill is `WHITE`, so the correct contrast colour is `BLACK`, and a drawing
    /// that answered a constant would still be caught on the profile that has no appearances to
    /// compare against.
    #[test]
    fn the_tick_is_the_contrast_of_the_box_it_sits_in() {
        #[cfg(device_profile)]
        let _guard = crate::style::theme_test_guard();
        #[cfg(device_profile)]
        crate::widget::census::install_preset_appearances();
        let frame = Rect::new(0, 0, 200, 100);

        // The indicator is the last `<rect … stroke-width="1" />` before the tick, and the tick is
        // the `<line … stroke="rgba(…)" />` pair. Reading the document is what makes the
        // comparison a property of the drawing rather than a restatement of the source.
        let rgb = |fragment: &str, key: &str| -> Option<(u8, u8, u8)> {
            let at = fragment.find(key)? + key.len();
            let end = fragment[at..].find(')')? + at;
            let mut parts = fragment[at..end].split(',');
            let r = parts.next()?.trim().parse().ok()?;
            let g = parts.next()?.trim().parse().ok()?;
            let b = parts.next()?.trim().parse().ok()?;
            Some((r, g, b))
        };

        let mut seen_fills = crate::compat::Vec::new();
        let mut seen_ticks: crate::compat::Vec<crate::compat::Vec<(u8, u8, u8)>> =
            crate::compat::Vec::new();
        #[cfg(device_profile)]
        let appearances = [Some(AppearanceMode::Dark), Some(AppearanceMode::Light)].as_slice();
        #[cfg(not(device_profile))]
        let appearances = [Option::<crate::style::AppearanceMode>::None].as_slice();
        for appearance in appearances {
            // Without a device profile there is no theme, so the appearance this iteration names is
            // not something this build can install. Naming it as unused here keeps the loop's shape
            // identical in both profiles (one binding, one body) while being explicit that the
            // omission is a capability difference rather than an oversight.
            #[cfg(not(device_profile))]
            let _ = appearance;
            let mut ticks_this_appearance = crate::compat::Vec::new();
            // Installed exactly as `export_control_svgs` does it, so this test and the committed
            // snapshot for the same appearance describe the same drawing.
            #[cfg(device_profile)]
            let backdrop = {
                crate::theme::global_theme_manager()
                    .set_appearance(appearance.expect("an appearance"));
                crate::theme::global_theme_manager()
                    .current_theme()
                    .map(|active| active.colors.background)
                    .unwrap_or(Color::WHITE)
            };
            #[cfg(not(device_profile))]
            let backdrop = Color::WHITE;
            let mut gb = GroupBox::new(frame);
            gb.set_title("Options".to_string());
            gb.set_checkable(true);
            gb.set_checked(true);
            #[cfg(device_profile)]
            crate::theme::apply_theme_to_widget(&mut gb);
            let svg = crate::widget::svg::render_widget_to_svg_on(&mut gb, frame, backdrop);
            let tick_at = svg.find("<line").expect("a checked box draws the tick's two strokes");
            // The indicator's **fill** element is the last one before the tick that paints a
            // `fill="rgba(`, because the indicator's outline (`fill="none"`) sits between it and
            // the tick. Reading the fill directly is what picks the surface the tick sits *in*
            // rather than the box's edge.
            let fill_at = svg[..tick_at].rfind("fill=\"rgba(").expect("the indicator's fill");
            let fill = rgb(&svg[fill_at..], "fill=\"rgba(").expect("a parseable fill");

            // Both tick strokes, so "one of the two is right" cannot pass.
            let tick_lines: crate::compat::Vec<&str> =
                svg.match_indices("<line").map(|(i, _)| &svg[i..]).collect();
            assert!(tick_lines.len() >= 2, "the tick is two strokes");
            for (n, line) in tick_lines.iter().enumerate() {
                let tick = rgb(line, "stroke=\"rgba(").expect("the tick carries a stroke colour");
                let want = Color::rgb(fill.0, fill.1, fill.2).contrast_color();
                assert_eq!(
                    tick,
                    (want.r, want.g, want.b),
                    "tick stroke {n} ({tick:?}) must be the contrast colour of the box fill \
                     {fill:?} it sits in; a literal black is the BLUE21 B22 defect this pins"
                );
                ticks_this_appearance.push(tick);
            }
            seen_fills.push(fill);
            seen_ticks.push(ticks_this_appearance);
        }

        // The rule is only a rule if it *moves*: where there are two appearances, one whose tick
        // colour is the same constant for both would satisfy every assertion above by coincidence
        // for one of them. The stripped profiles have one backdrop, so there is nothing to compare
        // — the relationship assertion above is what carries them.
        #[cfg(device_profile)]
        assert_ne!(
            seen_ticks[0], seen_ticks[1],
            "the tick must follow the fill; the two appearances produced {seen_ticks:?} from fills \
             {seen_fills:?}"
        );
        // Not `unused`: the single-appearance profiles still record the fill, and keeping the
        // collection unconditional is what makes this test one shape rather than two.
        let _ = seen_fills;

        // And the two states must differ, or the `group_box_checked` snapshot would be
        // indistinguishable from the default one.
        let mut unchecked = GroupBox::new(frame);
        unchecked.set_title("Options".to_string());
        unchecked.set_checkable(true);
        unchecked.set_checked(false);
        let plain = crate::widget::svg::render_widget_to_svg(&mut unchecked, frame);
        assert_eq!(
            plain.matches("<line").count(),
            0,
            "an unchecked box draws no tick, so the checked state is not implied"
        );
    }

    /// A cleared title must not leave its width behind in the cache.
    ///
    /// # The defect this pins
    ///
    /// `cached_title_width` is written in `draw` from the render context and read by
    /// `title_row` (which places the caption) and `size_hint` (the width the box reports to its
    /// parent). Two things were wrong with it:
    ///
    /// 1. `draw` only assigned it `if !self.title.is_empty()`, so setting the title to `""`
    ///    left the *previous* caption's width in the cache. `title_row` then kept reserving
    ///    that room — an empty group box whose title band is as wide as the title it used to
    ///    have — and `size_hint` kept asking its parent for the same room.
    /// 2. `set_title` did not invalidate it at all, so any retitled box read the old caption's
    ///    measurement until the next draw happened to run. That is a wrong *size* being reported
    ///    to the layout for the whole interval, which is when a layout asks.
    ///
    /// # The assertion is on `size_hint`, which is the quantity the cache actually drives
    ///
    /// The cache reaches the **reported width** directly: `size_hint`'s floor is
    /// `title + padding + insets`, so a stale cache makes a cleared box ask its parent for the
    /// old caption's room. (It barely reaches `title_row`'s *width* — the title column is `filled`,
    /// so a preferred width only nudges a distribution the row's own extent decides — which is why
    /// an assertion on the row would have been too weak to fail. It did: the first draft of this
    /// test passed against the unfixed code.)
    ///
    /// The direction is what makes the assertion meaningful: a long caption must make the box
    /// **want more room than an empty one**, and clearing the title must take that room back. A
    /// test asserting only `cleared <= wide` passes against the defect whenever the caption happens
    /// to be narrower than the floor, which is the usual case.
    #[test]
    fn a_cleared_title_does_not_leave_its_width_cached() {
        let frame = Rect::new(0, 0, 300, 120);
        // A caption longer than the chrome floor, so its width is what the hint reports rather
        // than the floor absorbing it.
        let caption = "A very long caption indeed, longer than the chrome floor";

        let mut empty = GroupBox::new(frame);
        let _ = crate::widget::svg::render_widget_to_svg(&mut empty, frame);
        let empty_width = empty.size_hint().width;

        let mut gb = GroupBox::new(frame);
        gb.set_title(caption.to_string());
        // Draw once so the cache is populated from the render context — the wider measurement may
        // only take effect on the *next* read, which is the ordering the control has.
        let _ = crate::widget::svg::render_widget_to_svg(&mut gb, frame);
        let long_width = gb.size_hint().width;
        assert!(
            long_width > empty_width,
            "a long caption must widen the box's wish, or this test cannot distinguish a stale \
             cache from a correct one: {long_width} vs {empty_width}"
        );

        // Clear it: the box must stop asking for the old caption's room. Rendering first is what
        // gives the unfixed version its chance to keep the stale value, because the old `draw` only
        // ever *wrote* the cache for a non-empty title and never cleared it.
        gb.set_title(String::new());
        let _ = crate::widget::svg::render_widget_to_svg(&mut gb, frame);
        assert_eq!(
            gb.size_hint().width,
            empty_width,
            "a cleared title must report the same wish as a box that never had one"
        );
    }

    /// A flat box omits its frame, and a content size makes the hint content-driven.
    #[test]
    fn flat_mode_and_content_size_are_wired() {
        let mut gb = GroupBox::new(Rect::new(0, 0, 200, 100));
        let framed = gb.size_hint();
        gb.set_content_size_hint(Some(Size::new(120, 80)));
        assert!(gb.size_hint().height >= framed.height);
        assert!(gb.size_hint().height >= 80 + 4);

        gb.set_flat(true);
        assert!(gb.is_flat());
        let svg = crate::widget::svg::render_widget_to_svg(&mut gb, Rect::new(0, 0, 200, 100));
        assert!(!svg.is_empty());
    }

    /// C-09: a press anywhere on the title band toggles the group, not only on the checkbox.
    #[test]
    fn a_title_band_click_toggles_the_group() {
        let mut gb = GroupBox::new(Rect::new(0, 0, 200, 120));
        gb.set_title("Group".to_string());
        gb.set_checkable(true);
        assert!(gb.is_checked());
        // Far to the right of the checkbox, so only the widened band can explain the toggle.
        gb.handle_event(&Event::MousePress {
            pos: crate::core::Point::new(150, 4),
            button: 1,
            modifiers: 0,
        });
        assert!(!gb.is_checked(), "a title-band press toggles the checkbox state");
    }

    /// C-08: collapsing hides the content band and the title band brings it back.
    #[test]
    fn a_collapsible_group_gives_its_content_band_back() {
        let mut gb = GroupBox::new(Rect::new(0, 0, 200, 120));
        gb.set_title("Group".to_string());
        assert!(!gb.is_collapsed());
        gb.set_collapsed(true);
        assert!(!gb.is_collapsed(), "a plain box refuses a state it cannot express");
        gb.set_collapsible(true);
        gb.set_collapsed(true);
        assert!(gb.is_collapsed());
        assert_eq!(gb.content_rect().height, 0, "a collapsed band reserves no extent");
        // The title band is the affordance that reopens it.
        gb.handle_event(&Event::MousePress {
            pos: crate::core::Point::new(150, 4),
            button: 1,
            modifiers: 0,
        });
        assert!(!gb.is_collapsed(), "a title-band press expands the group");
        assert!(gb.content_rect().height > 0);
    }
}