rust_widgets 2.8.4

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
// SPDX-FileCopyrightText: Copyright (c) 2026 Mike Li/Mikewolfli/Wei Li(mikewolfli@163.com)
// SPDX-License-Identifier: MIT

//! TabView widget — iOS-style segmented tab page view.
//!
//! Displays a horizontal segmented tab bar at the top and a content area
//! below showing the selected tab's content. Supports add/remove/clear
//! operations on tabs and emits a `tab_changed` signal on selection.

use crate::core::{Color, HorizontalAlignment, ObjectId, Rect};
use crate::event::{Event, EventHandler};
#[cfg(full_widgets)]
use crate::layout::{
    AlignItems, AxisHints, FlexDirection, FlexLayout, FlexWrap, Hints, JustifyContent, LayoutParams,
};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::style::EdgeOffsets;
use crate::widget::capability::coercion::expect_usize;
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::{effective_font, estimate_text_width};
use crate::widget::{BaseWidget, Draw, SimpleRegistry, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
use std::cell::RefCell;
use std::rc::Rc;

/// A single tab page with a title, optional content, and optional icon name.
pub struct TabPage {
    /// Display title shown in the tab bar.
    pub title: String,
    /// Optional content widget displayed when this tab is selected.
    pub content: Option<Box<dyn Widget>>,
    /// Optional icon identifier for the tab.
    pub icon: Option<String>,
}

/// iOS-style segmented tab page view.
///
/// Manages a vector of `TabPage` instances and draws a top segmented bar
/// plus the content of the currently selected tab below.
pub struct TabView {
    base: BaseWidget,
    tabs: Vec<TabPage>,
    selected_index: usize,
    /// Emitted when the selected tab index changes.
    pub tab_changed: Signal1<usize>,
    /// The shared registry the selected tab's content widget is drawn and dispatched
    /// through. Without it a `TabPage::content` would be stored and never shown — the
    /// content area is otherwise a flat fill.
    registry: Option<Rc<RefCell<SimpleRegistry>>>,
}

impl TabView {
    /// Creates a new empty TabView widget.
    pub fn new(geometry: Rect) -> Self {
        Self {
            base: BaseWidget::new(WidgetKind::TabView, geometry, "TabView"),
            tabs: Vec::new(),
            selected_index: 0,
            tab_changed: Signal1::new(),
            registry: None,
        }
    }

    /// Sets the shared widget registry used to draw and dispatch the selected tab's content.
    ///
    /// The registry is what makes [`TabPage::content`] visible: the widget mounted on the
    /// selected tab is drawn into [`Self::content_rect`] by the host that owns the registry.
    pub fn set_registry(&mut self, registry: Rc<RefCell<SimpleRegistry>>) {
        self.registry = Some(registry);
    }

    /// Returns the shared widget registry, if set.
    pub fn registry(&self) -> Option<&Rc<RefCell<SimpleRegistry>>> {
        self.registry.as_ref()
    }

    /// Adds a new tab page at the end of the tab list.
    /// If this is the first tab, it becomes the selected tab.
    pub fn add_tab(
        &mut self,
        title: impl Into<String>,
        content: Option<Box<dyn Widget>>,
        icon: Option<impl Into<String>>,
    ) {
        let was_empty = self.tabs.is_empty();
        self.tabs.push(TabPage { title: title.into(), content, icon: icon.map(|i| i.into()) });
        if was_empty {
            self.set_current_index(0);
        }
        self.base.request_redraw();
    }

    /// Removes the tab at the given index.
    ///
    /// Adjusts selection if the removed tab was selected.
    ///
    /// The current index follows the tab it pointed at: removing an *earlier* tab shifts
    /// the rest left, so the index is decremented to keep naming the same tab rather than
    /// silently revealing its successor. Removing the visible tab clamps to the last one,
    /// and the change is announced through `tab_changed`.
    pub fn remove_tab(&mut self, index: usize) {
        if index >= self.tabs.len() {
            return;
        }
        let before = self.selected_index;
        self.tabs.remove(index);
        if self.tabs.is_empty() {
            self.selected_index = 0;
        } else if index < before {
            self.selected_index = before - 1;
        } else if self.selected_index >= self.tabs.len() {
            self.selected_index = self.tabs.len() - 1;
        }
        if self.selected_index != before {
            self.tab_changed.emit(self.selected_index);
        }
        self.base.request_redraw();
    }

    /// Returns the object id of the selected tab's content widget, when it has one.
    ///
    /// A `TabPage` carries its content as a `Box<dyn Widget>`, but a widget that is expected to
    /// be drawn by a host lives in that host's registry and is addressed by id. This is the
    /// bridge: the content's own id, or `None` when the selected page has no content.
    pub fn content_widget_id(&self) -> Option<ObjectId> {
        self.tabs.get(self.selected_index).and_then(|tab| tab.content.as_ref().map(|c| c.id()))
    }

    /// Routes `event` to the selected tab's content widget through the registry.
    ///
    /// Pointer events are only delivered while the pointer is inside [`Self::content_rect`],
    /// so a click on the strip cannot also reach the content behind it; every other event kind
    /// goes to the content unconditionally, matching `TabWidget`'s contract. Nothing is
    /// forwarded when the selected page has no content or the host set no registry.
    fn forward_to_content(&self, event: &Event) {
        let Some(widget_id) = self.content_widget_id() else {
            return;
        };
        let Some(ref reg) = self.registry else {
            return;
        };
        let content_rect = self.content_rect();
        let inside = match event {
            Event::MousePress { pos, .. }
            | Event::MouseRelease { pos, .. }
            | Event::MouseMove { pos } => content_rect.contains(*pos),
            _ => true,
        };
        if !inside {
            return;
        }
        reg.borrow_mut().set_widget_geometry(widget_id, content_rect);
        let _ = reg.borrow_mut().forward_event(widget_id, event);
    }

    /// Returns the number of tabs.
    pub fn tab_count(&self) -> usize {
        self.tabs.len()
    }

    /// Removes all tabs and resets the selection.
    pub fn clear_tabs(&mut self) {
        self.tabs.clear();
        self.selected_index = 0;
        self.base.request_redraw();
    }

    /// Sets the current tab index. Clamped to valid range.
    /// Emits `tab_changed` if the index actually changed.
    pub fn set_current_index(&mut self, index: usize) {
        if self.tabs.is_empty() {
            return;
        }
        let clamped = index.min(self.tabs.len() - 1);
        if self.selected_index != clamped {
            self.selected_index = clamped;
            self.tab_changed.emit(clamped);
            self.base.request_redraw();
        }
    }

    /// Returns the currently selected tab index.
    pub fn current_index(&self) -> usize {
        self.selected_index
    }

    /// Returns a reference to the tabs vector.
    pub fn tabs(&self) -> &[TabPage] {
        &self.tabs
    }

    /// Returns a mutable reference to the tabs vector.
    pub fn tabs_mut(&mut self) -> &mut Vec<TabPage> {
        &mut self.tabs
    }

    /// The tab strip's height: 40 (Material's `Tab` height, which is a 14 px line plus the
    /// 48 px touch floor this crate uses for a primary target).
    ///
    /// It is a constant rather than the two `let tab_bar_height: u32 = 40;` declarations it
    /// used to be — one in `draw` and one in `handle_event`. Two copies of one layout fact is
    /// the drift shape this crate keeps paying for: the hit test would keep accepting presses
    /// in a strip the renderer had stopped drawing whenever one of them was edited.
    pub const TAB_BAR_HEIGHT: u32 = 40;

    /// The strip band at the top of the control's own rectangle.
    ///
    /// The single derivation the strip's fill, the indicator, the hit test and the content
    /// area below all read.
    pub fn tab_bar_rect(&self) -> Rect {
        let rect = self.geometry();
        Rect::new(rect.x, rect.y, rect.width, Self::TAB_BAR_HEIGHT.min(rect.height))
    }

    /// The rectangle the selected tab's content occupies: what the strip leaves.
    pub fn content_rect(&self) -> Rect {
        let rect = self.geometry();
        let strip = self.tab_bar_rect();
        let top = strip.y + strip.height as i32;
        Rect::new(rect.x, top, rect.width, (rect.y + rect.height as i32 - top).max(0) as u32)
    }

    /// Each tab's own width, derived from its own label.
    ///
    /// # The defect this replaces
    ///
    /// The strip used to be divided **equally**: `tab_width = rect.width / tab_count`. That
    /// makes a tab's width a fact about its *neighbours' count* rather than about its own
    /// label, with two visible consequences. A short title got a cell far wider than it needed,
    /// and a long one got the same cell as every other — so it was elided by
    /// `draw_text_fitted` even when the strip had room to spare. Adding a fourth tab also
    /// silently narrowed the first three.
    ///
    /// A tab's width is `label_width + TAB_TEXT_PADDING`, clamped into
    /// `TAB_MIN_WIDTH..TAB_MAX_WIDTH` so a one-character tab is still tappable and a long one
    /// does not crowd out its siblings. This is the same derivation `TabWidget::tab_widths`
    /// uses, with the same constants, so the two tab strips in this crate cannot disagree.
    fn tab_widths(&self) -> crate::compat::Vec<i32> {
        // Measured with the **effective font** — the same font the caption is painted with — so the
        // tab's reserved width and its ink cannot disagree (D09-STYLE-02).
        let font = effective_font(self.style());
        self.tabs
            .iter()
            .map(|tab| {
                // The label's width comes from the shared estimate -- the same model the renderer
                // draws with -- rather than from `chars().count() * TAB_CHAR_WIDTH`. The hand-rolled
                // form was a second copy of the advance arithmetic and one that counts *clusters*
                // at a fixed 8 px: a CJK caption measured half its drawn width, so a Chinese tab
                // label overflowed the strip its own width had reserved.
                (estimate_text_width(&tab.title, font, 1.0) as i32 + TAB_TEXT_PADDING)
                    .clamp(TAB_MIN_WIDTH, TAB_MAX_WIDTH)
            })
            .collect()
    }

    /// The tab boxes, in strip order, positioned by a layout rather than by an accumulator.
    ///
    /// # Why the run is assembled and not summed
    ///
    /// The draw path used to compute each tab's x as `rect.x + i * tab_width`, and the hit test
    /// re-derived the same expression from a separate bookkeeping run — two accumulators of one
    /// fact. Handing the measured widths to a [`FlexLayout`] through
    /// [`CompositeBuilder`](crate::widget::composite::CompositeBuilder) makes the run the
    /// *layout's* answer, so the paint, the hit test and any future caller read one result. It
    /// also means the strip picks up the layout's device scaling for free.
    ///
    /// The returned boxes are in **strip coordinates** (origin at the strip's own top-left), so
    /// the caller has one translation to apply and the geometry is independent of where the
    /// control was placed.
    ///
    /// # Why each tab declares a `min`/`pref`/`max` triple rather than one width
    ///
    /// This used to call `add_sized(.., Size::new(widths[index], height), ..)`, and `add_sized`
    /// stores `Hints::fixed(width, height)` — the same number as `min`, `pref` and `max` alike.
    /// Three consequences, all of them observable:
    ///
    /// 1. **The squeeze became impossible.** A crowded strip needs to tell the layout "this tab
    ///    would like its share and may be squeezed toward [`TAB_MIN_WIDTH`]". One number cannot
    ///    say that, and passing the share as all three turns the share into a *floor* the layout
    ///    is obliged to honour — so a strip too narrow for its tabs overhung the band instead of
    ///    compressing, and `TAB_MIN_WIDTH` (documented as "narrowest a tab may be **drawn**, so a
    ///    one-character caption is still a tap target") never acted as a floor at all.
    /// 2. **The touch floor inflated every tab.** `add_sized`'s `min == max` is exactly the shape
    ///    `FlexLayout::arrange_with_context` reads as "the composite stated the size", which
    ///    *exempts* the child from `grow_to_min_touch_size`. Measured tab widths here vary with
    ///    the caption, so they are a measurement the layout is being *told*, not — as the
    ///    doc comment on `add_sized` describes the legitimate case — "a column of a control"
    ///    whose size the composite owns. A 2 px-tall tab is not a case the touch floor should
    ///    skip.
    /// 3. **`TAB_MAX_WIDTH` and `TAB_MIN_WIDTH` disagreed with the run.** `tab_widths()` clamps to
    ///    that range, so the clamp and the layout's own squeeze were two independent statements
    ///    about the same tab.
    ///
    /// Declaring the real triple — the measured width as the wish, [`TAB_MIN_WIDTH`] as the floor,
    /// unconstrained above — restores all three, and it is the same shape `tab_widget`'s own strip
    /// already uses (see `tab_width_hints`, BLUE22 · G-1).
    fn tab_run(&self, strip: Rect) -> crate::compat::Vec<Rect> {
        let widths = self.tab_widths();
        if widths.is_empty() {
            return crate::compat::Vec::new();
        }
        let height = strip.height;
        // # Why the stripped profiles take the direct route
        //
        // `full_widgets` is "a device profile *and* an unstripped widget set" (principle #47),
        // and a `mobile-api` build has no `WidgetFactory` here. Both arms read the same widths,
        // so the fallback is the same run written the only way that profile can express it.
        #[cfg(not(full_widgets))]
        {
            let mut placed: crate::compat::Vec<Rect> = crate::compat::Vec::new();
            let mut cursor = 0i32;
            for width in widths.iter() {
                placed.push(Rect::new(cursor, 0, *width as u32, height));
                cursor += *width + TAB_SPACING;
            }
            return placed;
        }
        #[cfg(full_widgets)]
        {
            use crate::compat::Box as _Box;
            // # Why the layout is given the *strip*, not the tabs' own total
            //
            // This used to be `Rect::new(0, 0, total + TAB_SPACING * n, height)` — a run
            // **derived from the tabs' own widths**, so the band handed to the layout was by
            // construction exactly as wide as what the layout was about to place in it. That
            // makes `remaining == 0` at the solver, which means the shrink pass never runs:
            // a tab can never be squeezed, the strip can never be over-full, and every
            // `min`/`max` a tab declares is unobservable here. It is the shape §B.5.1 calls a
            // change of address rather than a fix — the hand-computed accumulator moved from
            // `rect.x + i * tab_width` into the rect's *width*.
            //
            // Supplying the real strip makes the layout answer the question the caller actually
            // has ("what fits in the room I have?") and makes `strip.x`/`strip.y` meaningful: the
            // layout now places inside the strip's own coordinates, so the caller's translation
            // below is a plain `+= strip.x`, not an assumption that the run starts at zero.
            let run = strip;
            let factory = crate::widget::WidgetFactory::new_with_defaults();
            let mut row = crate::widget::composite::CompositeBuilder::new(
                _Box::new(FlexLayout::with_params(
                    FlexDirection::Row,
                    FlexWrap::NoWrap,
                    JustifyContent::FlexStart,
                    AlignItems::Stretch,
                    TAB_SPACING,
                    0,
                )),
                EdgeOffsets::all(0),
                crate::core::Size::new(0, 0),
            );
            for (index, tab) in self.tabs.iter().enumerate() {
                let along = widths.get(index).copied().unwrap_or(TAB_MIN_WIDTH);
                // The measured width is the wish, `TAB_MIN_WIDTH` the floor it may be squeezed to,
                // and the ceiling is left open: the strip supplies a finite `run`, so the layout
                // cannot overshoot whatever the triple says.
                let declared = Hints {
                    width: AxisHints::new(
                        TAB_MIN_WIDTH.max(0) as u32,
                        along.max(TAB_MIN_WIDTH).max(0) as u32,
                        u32::MAX,
                    ),
                    height: AxisHints::fixed(height),
                };
                let created = row.add_with_hints(
                    &factory,
                    "label",
                    &tab.title,
                    declared,
                    LayoutParams::new(),
                );
                debug_assert!(created.is_some(), "a tab is a core control");
            }
            let mut placed: crate::compat::Vec<Rect> = crate::compat::Vec::new();
            row.arrange(run, &mut |_, rect| placed.push(rect));
            // `FlexLayout`'s solver emits one rectangle per registered item, so the truncation
            // below is a **guard**, not a route: it exists so the returned vector is total no
            // matter what a future capacity cap in the solver does. When it does fire, a tab the
            // layout did not place gets a zero-width box at the strip's leading edge, which reads
            // as "not laid out" — the previous behaviour was `Rect::new(0, 0, 0, 0)`, an origin the
            // caller had to translate like a real box and could not tell from one.
            while placed.len() < self.tabs.len() {
                placed.push(Rect::new(strip.x, strip.y, 0, height));
            }
            placed
        }
    }
}

/// Padding added to a tab's measured label width.
pub(crate) const TAB_TEXT_PADDING: i32 = 24;
/// Narrowest a tab may be drawn, so a one-character caption is still a tap target.
pub(crate) const TAB_MIN_WIDTH: i32 = 40;
/// Widest a tab may be drawn, so one long caption cannot crowd out its siblings.
pub(crate) const TAB_MAX_WIDTH: i32 = 200;
/// Gap between adjacent tabs.
pub(crate) const TAB_SPACING: i32 = 2;

impl Widget for TabView {
    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) -> crate::core::Size {
        crate::core::Size::new(400, 300)
    }
    impl_draw_bridge!();
    impl_widget_property_hooks!();
    /// Resolves a published event name to the control's own signal.
    ///
    /// # Why this is explicit
    ///
    /// `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 an
    /// arm a published name is valid and inert, which is the silent failure
    /// `tools/check_event_signal_dyn.sh` exists to make impossible: the arm set is compared against
    /// the capability's published names, so the two cannot drift.
    fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
        use crate::signal::EventSignalRef;
        match name {
            "tab_changed" => Some(EventSignalRef::mapped("tab_changed", &self.tab_changed, |v| {
                CapabilityValue::UInt(*v as u64)
            })),
            _ => None,
        }
    }
}

/// `TabView`'s property contract.
///
/// Read/write semantics are carried over unchanged from the centralised
/// `access_read_other.in.rs` / `access_write_other.in.rs` dispatch: the published
/// name is `selected_index`, backed by the `current_index` accessors.
impl WidgetProperties for TabView {
    fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
        match name {
            "selected_index" => Ok(CapabilityValue::UInt(self.current_index() as u64)),
            _ => base_property_get(self, name),
        }
    }

    fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
        match name {
            "selected_index" => {
                self.set_current_index(expect_usize(value)?);
                Ok(())
            }
            _ => base_property_set(self, name, value),
        }
    }

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

impl Draw for TabView {
    fn draw(&mut self, context: &mut RenderContext) {
        let rect = self.geometry();
        // The strip and the content area come from the control's own band derivation, so the
        // strip's height, the hit test and the content's top edge cannot disagree.
        let tab_bar_rect = self.tab_bar_rect();
        let tab_bar_height = tab_bar_rect.height;
        let content_rect = self.content_rect();

        // Chrome colours resolve explicit style first, then the theme's resolved
        // style for this control, and only then a literal. The theme step is what
        // makes an appearance switch visible; previously every colour below was a
        // hardcoded literal, so light and dark rendered identically.
        //
        // `resolved_theme_style` takes and releases the global manager's lock
        // internally, so no guard is held across the draw (the mutex is not
        // re-entrant).
        let style = self.base.style().clone();
        let theme = crate::style::resolved_theme_style("tab_view");
        // `tab_view` is not a control kind in the role table, so it classifies as
        // `Surface`, whose background is `theme.colors.background` — byte-identical
        // to the window behind it. The content area's fill is therefore a step toward
        // the foreground, so the page reads as a surface of its own.
        let resolved = style
            .background_color
            .or_else(|| theme.as_ref().and_then(|t| t.background_color))
            .unwrap_or(Color::WHITE);
        let text_color = style
            .text_color
            .or_else(|| theme.as_ref().and_then(|t| t.text_color))
            .unwrap_or(Color::rgb(0, 0, 0));
        let content_background = resolved.blend(&text_color, 0.08);
        // The strip behind the tabs is a further step away, so selected and
        // unselected tabs read as different states of the same chrome.
        let strip_background = content_background.blend(&text_color, 0.08);
        let inactive_tab = content_background.blend(&text_color, 0.05);
        // The selected indicator is a *selection* state, so it reads the theme's
        // primary token rather than a literal blue.
        let indicator = crate::style::resolved_theme_style("button")
            .and_then(|button| button.background_color)
            .unwrap_or_else(|| content_background.blend(&text_color, 0.6));
        let selected_text = indicator;
        let inactive_text = text_color.blend(&content_background, 0.3);
        // The separator under the strip is secondary chrome, derived from the same pair.
        let separator = content_background.blend(&text_color, 0.2);

        // Draw tab bar background
        context.fill_rect(tab_bar_rect, strip_background);

        if self.tabs.is_empty() {
            // Draw empty content area
            context.fill_rect(content_rect, content_background);
            return;
        }

        // Draw each tab header
        //
        // The boxes come from `tab_run`, the *same* derivation the hit test reads, so the tab a
        // press selects is the tab that was painted.
        //
        // # Why there is no translation here
        //
        // There used to be one (`tab_bar_rect.x + local.x`), because `tab_run` built its band from
        // `(0, 0)`. It now hands the layout the **caller's strip**, and `FlexLayout::arrange` emits
        // children in that band's own coordinates — so the boxes already carry `strip.x` and adding
        // it again placed every tab at `2 * strip.x`. The hit test below subtracted `strip.x` from
        // the pointer for the same reason and is now consistent with the paint by using the raw
        // position. Both were latent: every tab test used a control at `x = 0`.
        let strip = tab_bar_rect;
        // The **effective font** — the resolved theme/caller font — rather than the fixed 12 px
        // sans-serif this used to build (D09-STYLE-01).
        let font = effective_font(self.style());
        for (i, tab_rect) in self.tab_run(strip).into_iter().enumerate() {
            let is_selected = i == self.selected_index;

            // Background
            let bg_color = if is_selected { content_background } else { inactive_tab };
            context.fill_rect(tab_rect, bg_color);

            // Selected tab indicator line
            if is_selected {
                let indicator_rect = Rect::new(
                    tab_rect.x,
                    tab_rect.y + tab_rect.height as i32 - 3,
                    tab_rect.width,
                    3,
                );
                context.fill_rect(indicator_rect, indicator);
            }

            // Draw tab title (with icon prefix if available)
            let tab = &self.tabs[i];
            let display_text = if let Some(ref icon_name) = tab.icon {
                format!("{} {}", icon_name, tab.title)
            } else {
                tab.title.clone()
            };

            let text_color = if is_selected { selected_text } else { inactive_text };

            // The caption is centred on **both** axes through the shared line box. The previous
            // form computed `rect.y + (tab_bar_height - metrics.height) / 2` by hand and passed
            // it as the glyph box's top edge — and `metrics.height` is the *measurement* height,
            // which is the same number as the line height only for a single-line ASCII label.
            // The line box is what `text_line` derives from the same font, so the caption cannot
            // sit half a line off; `draw_text_fitted` with `Center` then bounds it to the tab, so
            // a long title is elided rather than running over its neighbour.
            context.draw_text_fitted(
                context.text_line(tab_rect, font),
                &display_text,
                font,
                text_color,
                HorizontalAlignment::Center,
            );
        }

        // Draw separator line below tab bar
        let separator_rect = Rect::new(rect.x, rect.y + tab_bar_height as i32 - 1, rect.width, 1);
        context.fill_rect(separator_rect, separator);

        // Draw selected tab content area. The background is laid down first so a tab whose
        // content widget is not registered (or a registry-less host) still paints the surface
        // the strip leaves; the widget itself is then drawn over it.
        context.fill_rect(content_rect, content_background);

        // The selected tab's content widget, drawn through the host's registry.
        //
        // `TabPage::content` used to be a public field that was stored and never read, so a
        // caller that mounted a widget on a tab got a flat rectangle: the content area is not a
        // child the widget system knows about, it is only whatever this control draws inside its
        // own rectangle. The registry is what bridges the two, and it is the same mechanism
        // `TabWidget` already uses (`set_widget_geometry` + `draw_widget`), so the geometry the
        // content is drawn at is the geometry the event path forwards at.
        if let Some(widget_id) = self.content_widget_id() {
            if let Some(ref reg) = self.registry {
                reg.borrow_mut().set_widget_geometry(widget_id, content_rect);
                context.push_clip(
                    content_rect.x,
                    content_rect.y,
                    content_rect.width,
                    content_rect.height,
                );
                reg.borrow_mut().draw_widget(widget_id, context);
                context.pop_clip();
            }
        }
    }
}

impl EventHandler for TabView {
    fn handle_event(&mut self, event: &Event) {
        if !self.base.is_enabled() {
            return;
        }
        match event {
            Event::MousePress { pos, button, .. } => {
                if *button == 1 && !self.tabs.is_empty() {
                    // The band comes from the control's own derivation, so the region that accepts
                    // a press is by construction the region the renderer drew.
                    let strip = self.tab_bar_rect();
                    if pos.y >= strip.y && pos.y < strip.y + strip.height as i32 {
                        // The tab boxes are the layout's answer, the *same* boxes the draw pass
                        // painted. Deriving the index from `relative_x / tab_width` (the old
                        // form) meant the hit test divided the strip equally while the paint
                        // path used each tab's own width — so once tabs stopped being equal, a
                        // press on a tab selected its neighbour. Reading the boxes removes the
                        // second derivation rather than trying to keep the two in step.
                        //
                        // The position is used **as given**, because `tab_run` returns boxes in the
                        // strip's own coordinates (see `draw`). Subtracting `strip.x` here was the
                        // mirror of the translation `draw` used to add; both are gone.
                        let clicked = self.tab_run(strip).into_iter().position(|box_rect| {
                            let left = box_rect.x;
                            let right = left + box_rect.width as i32;
                            pos.x >= left && pos.x < right
                        });
                        if let Some(index) = clicked {
                            self.set_current_index(index);
                            return;
                        }
                    }
                    // The press was outside the strip, so it belongs to the content area. Falls
                    // through to the shared dispatch below.
                    self.forward_to_content(event);
                }
            }
            _ => {
                self.base.handle_event(event);
                self.forward_to_content(event);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::{Font, Point};
    use crate::widget::svg::render_to_svg;
    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::sync::Arc;

    /// How far inside its glyph **box** a glyph's ink may begin and still count as sitting on that
    /// box's own line.
    ///
    /// Two captions on one line share the line box `draw_text` was handed, and under the bitmap
    /// face their ink tops are both that box's top exactly. Under an **outline** face each glyph is
    /// inset by its own shape — `Alpha`'s capital reaches higher than `Beta`'s, so one ink top is a
    /// pixel below the other's — and neither is the box top exactly. Pinning either shape would be
    /// a statement about the typeface, not the layout; the tolerance is tight enough (≤ 2 px of
    /// inset) that a caption placed on the wrong line still fails.
    const INK_INSET_TOLERANCE: i32 = 3;

    fn make_tab_view() -> TabView {
        TabView::new(Rect::new(0, 0, 300, 400))
    }

    /// The band the renderer draws and the band the hit test accepts are one derivation.
    ///
    /// The strip's height used to be two separate `let tab_bar_height: u32 = 40;` declarations
    /// — one in `draw`, one in `handle_event` — so editing one would leave the control
    /// accepting presses in a strip it had stopped drawing. Both now read
    /// [`TabView::tab_bar_rect`].
    #[test]
    fn the_drawn_strip_and_the_hit_region_are_one_derivation() {
        let tv = make_tab_view();
        let strip = tv.tab_bar_rect();
        let content = tv.content_rect();
        assert_eq!(strip.height, TabView::TAB_BAR_HEIGHT);
        assert_eq!(strip.y, tv.geometry().y);
        // The content area begins exactly where the strip ends — no gap, no overlap.
        assert_eq!(
            content.y,
            strip.y + strip.height as i32,
            "the content area starts at the strip's bottom edge"
        );
        // And together they tile the control's own rectangle.
        assert_eq!(content.height as i32 + strip.height as i32, tv.geometry().height as i32);
    }

    /// One ink box per text `<path>` in document order, as `(left, top, right, bottom)`.
    ///
    /// # Why the ink and not the string
    ///
    /// Text leaves the SVG backend as glyph geometry — one subpath per ink region — so a caption
    /// is not in the document in any form and a test has to locate a run by *where* it is. That is
    /// the stronger check: the old form matched `>Alpha</text>` and read the element's `y`, so a
    /// caption drawn on the wrong line with a correct attribute would have passed it.
    ///
    /// # Why this delegates instead of parsing `d` itself
    ///
    /// The first form read the `font8x8` rectangle spelling directly (`M{x} {y}h{w}v{h}`), which
    /// is the **bitmap** path's grammar and only its grammar, and it read each glyph rectangle as
    /// its own run rather than unioning a `<path>`'s subpaths. An outline face writes
    /// `M17.00 23.84L...Z` with fractional vertices, so that reader took `17` as `x` and `23` as
    /// `y` and, worse, the raw rectangle parse produced overlapping fragments — every caption came
    /// back as many boxes in the wrong places, so the per-tab lookup matched the wrong one. The
    /// crate's own reader knows both grammars and the `data-text` provenance tag; one reader, so
    /// the two cannot drift apart.
    fn text_run_boxes(svg: &str) -> Vec<(i32, i32, i32, i32)> {
        crate::widget::svg::text_ink_boxes(svg)
    }

    /// A tab caption is centred on its tab's own line box, not on a hand-computed pair of axes.
    ///
    /// The previous form derived `text_x` and `text_y` inline from `measure_text(..).height` and
    /// handed both to `draw_text`. The line box is the shared primitive for exactly this, and it
    /// is what makes a caption with a descender ("/g/j") sit the same as one without.
    ///
    /// The captions are located by the **tab they lie in** rather than by the string they spell:
    /// the string is no longer in the document, and a geometric lookup is what the assertion is
    /// about anyway.
    #[test]
    fn a_tab_caption_sits_on_its_tabs_line_box() {
        // Holds the crate-wide theme guard: this test renders, and a concurrent
        // test that switches the appearance would otherwise change a later frame.
        let _theme_guard = crate::style::theme_test_guard();
        let mut tv = make_tab_view();
        tv.add_tab("Alpha", None, None::<&str>);
        tv.add_tab("Beta", None, None::<&str>);
        let svg = render_to_svg(&mut tv);
        let strip = tv.tab_bar_rect();

        let font = Font::simple("sans-serif", 12.0);
        let mut backend = crate::render::SvgPaintBackend::new(crate::core::Size::new(300, 400));
        let context = crate::render::RenderContext::new(&mut backend);
        let line_h = context.measure_text("M", &font).height as i32;
        let expected_y = strip.y + (strip.height as i32 - line_h) / 2;

        // The boxes come from the control's own derivation, so the test asserts against the
        // *same* geometry the control painted rather than re-deriving a second expectation.
        // `tv.geometry().width / 2` used to stand here, and it pinned the equal division the
        // control has since stopped doing — a test that restated the implementation could not
        // notice the implementation was wrong.
        let strip = tv.tab_bar_rect();
        let boxes = tv.tab_run(strip);
        let runs = text_run_boxes(&svg);
        let caption_of = |index: usize| -> (i32, i32, i32, i32) {
            let local =
                boxes.get(index).copied().unwrap_or_else(|| panic!("tab {index} has no box"));
            let left = strip.x + local.x;
            let right = left + local.width as i32;
            runs.iter()
                .find(|(l, _, r, _)| {
                    let centre = (l + r) / 2;
                    centre >= left && centre < right
                })
                .copied()
                .unwrap_or_else(|| panic!("tab {index} painted no caption in {left}..{right}"))
        };
        let alpha = caption_of(0);
        let beta = caption_of(1);
        // Each caption begins on the strip's line box top (`draw_text`'s `origin.y`), allowing for
        // the outline face's inset; a caption on a different line is off by a whole line and fails.
        assert!(
            (alpha.1 - expected_y).abs() <= INK_INSET_TOLERANCE,
            "a caption belongs on the strip's line box: expected {expected_y}, ink top {}",
            alpha.1
        );
        assert!(
            (beta.1 - expected_y).abs() <= INK_INSET_TOLERANCE,
            "and every caption shares it: expected {expected_y}, ink top {}",
            beta.1
        );
        // The caption is bounded by its own tab, which the attribute assertion could not see: a
        // run centred on the strip but not on its tab would still have had the right `y`.
        for (index, run) in [alpha, beta].into_iter().enumerate() {
            let local = boxes[index];
            let left = strip.x + local.x;
            let right = left + local.width as i32;
            assert!(
                run.0 >= left && run.2 <= right,
                "tab {index}: caption {run:?} must stay inside {left}..{right}"
            );
            assert!(run.2 > run.0, "tab {index}: the caption laid down ink: {run:?}");
        }
        assert!(alpha.0 < beta.0, "the tabs read left to right: {alpha:?} then {beta:?}");
    }

    #[test]
    fn tab_view_default_state() {
        let tv = make_tab_view();
        assert_eq!(tv.tab_count(), 0);
        assert_eq!(tv.current_index(), 0);
        assert_eq!(tv.kind(), WidgetKind::TabView);
    }

    #[test]
    fn tab_view_add_and_select() {
        let mut tv = make_tab_view();
        tv.add_tab("Tab 1", None, None::<&str>);
        tv.add_tab("Tab 2", None, None::<&str>);
        assert_eq!(tv.tab_count(), 2);
        assert_eq!(tv.current_index(), 0);

        tv.set_current_index(1);
        assert_eq!(tv.current_index(), 1);
    }

    #[test]
    fn tab_view_signal_emits() {
        let mut tv = make_tab_view();
        tv.add_tab("First", None, None::<&str>);
        tv.add_tab("Second", None, None::<&str>);

        let captured = Arc::new(AtomicUsize::new(usize::MAX));
        tv.tab_changed.connect({
            let captured = Arc::clone(&captured);
            move |val: Arc<usize>| {
                captured.store(*val, Ordering::SeqCst);
            }
        });

        tv.set_current_index(1);
        assert_eq!(captured.load(Ordering::SeqCst), 1);
    }

    #[test]
    fn tab_view_remove_tab() {
        let mut tv = make_tab_view();
        tv.add_tab("A", None, None::<&str>);
        tv.add_tab("B", None, None::<&str>);
        tv.add_tab("C", None, None::<&str>);
        tv.set_current_index(2);
        tv.remove_tab(2);
        assert_eq!(tv.tab_count(), 2);
        assert_eq!(tv.current_index(), 1);
    }

    /// Removing a tab *before* the selected one keeps the same tab selected.
    ///
    /// Only the "removed the visible tab" direction was covered, which is exactly the
    /// direction that needs no index repair. Deleting an earlier tab shifted the rest
    /// left while the index stayed put, so the control quietly showed a different tab.
    #[test]
    fn tab_view_remove_earlier_tab_keeps_the_selection() {
        let mut tv = make_tab_view();
        tv.add_tab("A", None, None::<&str>);
        tv.add_tab("B", None, None::<&str>);
        tv.add_tab("C", None, None::<&str>);
        tv.set_current_index(2);
        tv.remove_tab(0);
        assert_eq!(tv.current_index(), 1, "the index follows the tab it pointed at");
        assert_eq!(tv.tabs()[tv.current_index()].title, "C", "still showing the same tab");
    }

    #[test]
    fn tab_view_clear_tabs() {
        let mut tv = make_tab_view();
        tv.add_tab("X", None, None::<&str>);
        tv.add_tab("Y", None, None::<&str>);
        tv.clear_tabs();
        assert_eq!(tv.tab_count(), 0);
        assert_eq!(tv.current_index(), 0);
    }

    #[test]
    fn tab_view_mouse_click_switches_tab() {
        let mut tv = make_tab_view();
        tv.add_tab("Foo", None, None::<&str>);
        tv.add_tab("Bar", None, None::<&str>);

        // The click point is derived from the second tab's *own* box rather than from the
        // old equal division (`x=150..299`). Tabs are sized by their captions now, so a
        // hardcoded coordinate would be asserting the geometry was divided equally — the
        // thing this control stopped doing — instead of asserting that a press on a tab
        // selects it.
        let strip = tv.tab_bar_rect();
        let boxes = tv.tab_run(strip);
        let second = boxes.get(1).copied().expect("two tabs were added");
        let centre = Point::new(
            strip.x + second.x + second.width as i32 / 2,
            strip.y + second.height as i32 / 2,
        );
        tv.handle_event(&Event::MousePress { pos: centre, button: 1, modifiers: 0 });
        assert_eq!(tv.current_index(), 1, "a press on the second tab selects it");

        // And the first tab's own box still selects the first, so the mapping is not merely
        // "anything selects index 1".
        let first = boxes.first().copied().expect("two tabs were added");
        let centre = Point::new(
            strip.x + first.x + first.width as i32 / 2,
            strip.y + first.height as i32 / 2,
        );
        tv.handle_event(&Event::MousePress { pos: centre, button: 1, modifiers: 0 });
        assert_eq!(tv.current_index(), 0, "a press on the first tab selects it");
    }

    #[test]
    fn a_tab_is_as_wide_as_its_own_caption_needs() {
        // The defect this pins: the strip used to be divided equally by the tab count, so a
        // short caption got a cell far wider than it needed and its neighbour's width changed
        // when a tab was added. A tab's width is now a fact about its own label.
        //
        // # Why the fixture is wider than the default
        //
        // Every assertion below is about a tab's **own** measured width, so the strip has to be
        // roomy enough that squeezing is not in play — otherwise the test would be measuring the
        // layout's compression instead. That is a real distinction the change below introduced:
        // `tab_run` now hands the layout the *strip* rather than a band sized to the tabs' own
        // sum, so a strip that cannot pay for its tabs compresses them (which is what
        // `a_strip_too_narrow_for_its_tabs_keeps_them_inside_it` pins, with a deliberately narrow
        // one). Before that change the band always fit by construction and this fixture's width
        // was irrelevant; it is load-bearing now.
        //
        // 900 px pays for `TAB_MIN_WIDTH + 170 + 200` and the two `TAB_SPACING` gaps with room to
        // spare, so each tab comes back at exactly its own measurement.
        let mut tv = TabView::new(Rect::new(0, 0, 900, 400));
        tv.add_tab("I", None, None::<&str>);
        tv.add_tab("A much longer caption", None, None::<&str>);
        let boxes = tv.tab_run(tv.tab_bar_rect());

        let short = boxes[0].width;
        let long = boxes[1].width;
        assert!(long > short, "a longer caption must ask for a wider tab: {short} vs {long}");
        assert_eq!(short, TAB_MIN_WIDTH as u32, "a one-character tab floors at the minimum");
        // The expected width is read from the same estimate the control uses, over a caption long
        // enough to stay under the 200 px ceiling -- so this asserts the *measured* width, and the
        // ceiling is asserted separately below with a caption long enough to reach it.
        //
        // The literal this replaced (`21 * TAB_CHAR_WIDTH + TAB_TEXT_PADDING`) was the old
        // `chars().count() * 8` arithmetic restated in the test. That is a second copy of the
        // measurement, and it is exactly what went stale when the shared estimate took over: the
        // real advance for 21 Latin clusters at the default 14 px font is 21 x 8.4, not 21 x 8.
        let caption = "A much longer caption";
        assert_eq!(caption.chars().count(), 21, "the arithmetic below is stated for 21 clusters");
        let measured = estimate_text_width(caption, &Font::default(), 1.0);
        assert!(
            measured + TAB_TEXT_PADDING as u32 <= TAB_MAX_WIDTH as u32,
            "this caption must stay under the ceiling for the assertion to be about measurement"
        );
        assert_eq!(long, measured + TAB_TEXT_PADDING as u32);

        tv.add_tab("A caption far longer than any tab could ever need to be", None, None::<&str>);
        let boxes = tv.tab_run(tv.tab_bar_rect());
        assert_eq!(
            boxes[2].width, TAB_MAX_WIDTH as u32,
            "an over-long caption ceilings at the maximum so it cannot crowd out its siblings"
        );

        // Adding a third tab must not narrow the first two: under equal division this was
        // exactly the silent regression that made a long caption elide.
        assert_eq!(boxes[0].width, short, "an added sibling must not resize tab 0");
        assert_eq!(boxes[1].width, long, "nor tab 1");
    }

    /// A strip too narrow for its tabs **compresses** them instead of painting them past the band.
    ///
    /// # The defect this pins
    ///
    /// [`TabView::tab_run`] used to declare each tab with `add_sized(.., Size::new(width, height),
    /// ..)`, and `add_sized` stores `Hints::fixed(width, height)` — the measured width as `min`,
    /// `pref` *and* `max`. That made the measured width a **floor**, so a strip whose tabs did not
    /// fit had nothing to give: the layout placed the tabs at their full widths, the last one
    /// landed past the control's right edge, and because the SVG backend emits absolute
    /// coordinates it was not clipped — it was simply drawn outside the control, or off the
    /// picture entirely.
    ///
    /// The assertion is the invariant rather than a pixel count: **every tab stays inside the
    /// strip**, and none is dropped. [`TAB_MIN_WIDTH`] is what makes that achievable — it is
    /// documented as "narrowest a tab may be **drawn**", and before this change it was only ever
    /// applied as a *ceiling on the same axis it floors*, in `tab_widths()`'s `clamp`, so it
    /// never reached the layout as a floor at all.
    ///
    /// The second half asserts the strip still *asks* for the room: the caller is told the tabs
    /// want more than they got, which is what lets a container grow. A test asserting only
    /// containment would also pass against a control that had silently shrunk every tab to
    /// nothing.
    #[test]
    fn a_strip_too_narrow_for_its_tabs_keeps_them_inside_it() {
        // Three long captions in a strip far too short for them: 3 x TAB_MAX_WIDTH at
        // TAB_SPACING between is the un-squeezed requirement, and 90 px cannot pay it.
        let mut tv = TabView::new(Rect::new(0, 0, 90, 400));
        for caption in ["Alpha is a long caption", "Beta is a long caption", "Gamma too"] {
            tv.add_tab(caption, None, None::<&str>);
        }
        let strip = tv.tab_bar_rect();
        let boxes = tv.tab_run(strip);
        assert_eq!(boxes.len(), 3, "no tab may be dropped when the strip is crowded");

        let unsqueezed: u32 = tv.tab_widths().iter().map(|w| *w as u32).sum();
        assert!(
            unsqueezed > strip.width,
            "the fixture must actually be over-full, or this test proves nothing: \
             wanted {unsqueezed} in {}",
            strip.width
        );

        for (index, tab) in boxes.iter().enumerate() {
            assert!(tab.width > 0, "a crowded tab is squeezed, not erased: tab {index} is {tab:?}");
            assert!(
                tab.x >= strip.x && tab.x + tab.width as i32 <= strip.x + strip.width as i32,
                "tab {index} must stay inside the strip, or it is painted off the control: \
                 {tab:?} in {strip:?}"
            );
        }
    }

    #[test]
    fn tab_view_svg_output() {
        let mut tv = make_tab_view();
        tv.add_tab("One", None, None::<&str>);
        tv.add_tab("Two", None, None::<&str>);
        let svg = render_to_svg(&mut tv);
        assert!(svg.starts_with("<svg"));
        assert!(svg.ends_with("</svg>"));
    }

    #[test]
    fn tab_view_set_current_index_noop_when_empty() {
        let mut tv = make_tab_view();
        tv.set_current_index(5); // no tabs, should not panic
        assert_eq!(tv.current_index(), 0);
    }

    /// A tab's mounted content is reachable through the registry.
    ///
    /// `TabPage::content` is a public field documented as "displayed when this tab is
    /// selected", but nothing read it: the content area was a flat fill and no event
    /// reached the widget, so a host that mounted one got a silent no-op. The registry
    /// is the bridge, and this pins the id the draw and dispatch paths resolve.
    #[test]
    fn the_selected_tabs_content_is_reachable_through_the_registry() {
        let mut tv = make_tab_view();
        let registry =
            std::rc::Rc::new(std::cell::RefCell::new(crate::widget::SimpleRegistry::new()));
        tv.set_registry(registry.clone());

        let first = crate::widget::base_widgets::label::Label::new(
            "first".to_string(),
            Rect::new(0, 0, 10, 10),
        );
        let first_id = first.id();
        tv.add_tab("A", Some(Box::new(first)), None::<&str>);
        tv.add_tab("B", None, None::<&str>);

        assert_eq!(tv.content_widget_id(), Some(first_id));

        tv.set_current_index(1);
        assert_eq!(tv.content_widget_id(), None, "the empty tab has no content to draw");
    }
}