Skip to main content

frus_widgets/
tabs.rs

1//! [`TabBar`]: a **controlled** tab bar plus the selected panel.
2//!
3//! It is composite: its children are `[bar, panel]`, in a column. Only the selected tab's
4//! content is realised, the application rebuilding the view every frame.
5//!
6//! The bar is a **tab bar**, not a row of buttons: labels on the surface, a sliding
7//! indicator under the selected one, and a hairline across the whole bar separating it from
8//! the panel. The two variants are the reference's — a *primary* bar's indicator is as wide
9//! as its **label** and rounded at the top, a *secondary* bar's spans the whole **tab** and
10//! is square — and every measurement and colour in either is overridable, by the caller or
11//! by [`crate::TabBarTheme`].
12
13use std::rc::Rc;
14
15use frus_core::{BorderRadius, Color, Point, Rect, Scene, TextStyle};
16use frus_layout::{Dimension, FlexDirection, Style};
17
18use crate::disabled::disabled_content;
19use crate::icons::IconData;
20use crate::interaction::Status;
21use crate::pageview::PageView;
22use crate::theme::Theme;
23use crate::transparent::Shared;
24use crate::widget::Widget;
25
26/// The height of the tabs themselves, indicator excluded.
27pub const TAB_HEIGHT: f32 = 46.0;
28/// The height of the tabs when **any** of them stacks an icon over a label. The
29/// reference's second figure, and it applies to the whole row: tabs of two heights in
30/// one bar would put their labels on two different lines.
31pub const TAB_ICON_HEIGHT: f32 = 72.0;
32/// The side of a tab's icon.
33pub const TAB_ICON_SIZE: f32 = 24.0;
34/// The room between a tab's icon and the label under it.
35pub const TAB_ICON_GAP: f32 = 2.0;
36/// The room on either side of a label.
37pub const TAB_LABEL_PADDING: f32 = 16.0;
38/// The inset [`TabAlignment::StartOffset`] leaves before the first tab.
39pub const TAB_START_OFFSET: f32 = 52.0;
40/// The thickness of a **primary** bar's indicator, which is also its corner radius.
41pub const TAB_INDICATOR_PRIMARY: f32 = 3.0;
42/// The thickness of a **secondary** bar's indicator.
43pub const TAB_INDICATOR_SECONDARY: f32 = 2.0;
44/// The hairline between the bar and the panel.
45pub const TAB_DIVIDER_HEIGHT: f32 = 1.0;
46
47/// Which of the two tab bars this is.
48///
49/// They differ in more than looks: a *primary* bar marks the selected tab with an indicator
50/// as wide as its label, and a *secondary* bar with one as wide as the whole tab. A primary
51/// bar is for the top level of a screen, a secondary one for a division inside it.
52#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
53pub enum TabBarVariant {
54    /// Label-wide indicator, rounded at the top; the selected label takes the accent.
55    #[default]
56    Primary,
57    /// Tab-wide indicator, square.
58    Secondary,
59}
60
61/// Where the tabs sit in a bar with room to spare.
62///
63/// The question only arises when the tabs do not already fill the bar — which is either
64/// because they were told not to share it, or because there are two of them in a window
65/// meant for six.
66#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
67pub enum TabAlignment {
68    /// Whatever the bar's mode implies: [`Fill`](Self::Fill) when it shares its width,
69    /// [`Start`](Self::Start) when it scrolls. The default, and the reference's.
70    #[default]
71    Auto,
72    /// Each tab as wide as it needs, packed against the leading edge.
73    Start,
74    /// The same, after an inset of [`TAB_START_OFFSET`].
75    StartOffset,
76    /// The tabs share the bar's width in equal parts.
77    ///
78    /// Meaningless on a bar that **scrolls**, since sharing a width is the opposite of
79    /// exceeding it, and it reads as [`Start`](Self::Start) there. The reference throws
80    /// instead; a layout that throws is a crash on somebody's phone for a combination
81    /// that has an obvious reading.
82    Fill,
83    /// Each tab as wide as it needs, the row centred in the bar.
84    ///
85    /// For a bar that does **not** scroll. Centring needs something definite to centre
86    /// in, and the content of a scroll region has no definite width across the axis it
87    /// scrolls along — the strip is measured by its own tabs, so a percentage of its
88    /// parent resolves against nothing and it comes out exactly as wide as the tabs it
89    /// holds, with no room left over to share. So it reads as [`Start`](Self::Start)
90    /// there.
91    ///
92    /// The two are not really alternatives anyway: a bar whose tabs might overflow wants
93    /// `Start` and a scroll, and a bar whose tabs certainly fit wants `Center` and no
94    /// scroll at all.
95    Center,
96}
97
98impl TabAlignment {
99    /// What this means for a bar that does or does not scroll.
100    ///
101    /// The reference forbids [`Start`](Self::Start) and [`StartOffset`](Self::StartOffset)
102    /// on a bar that shares its width, for reasons that are its own history rather than
103    /// anything about the layout — packing natural-width tabs against the leading edge of
104    /// a bar that does not scroll is a perfectly ordinary thing to want, and it is what
105    /// they do here.
106    fn resolved(self, scrolls: bool) -> Self {
107        match (self, scrolls) {
108            (Self::Auto, true) | (Self::Fill, true) | (Self::Center, true) => Self::Start,
109            (Self::Auto, false) => Self::Fill,
110            (other, _) => other,
111        }
112    }
113
114    /// The inset before the first tab.
115    fn lead(self) -> f32 {
116        match self {
117            Self::StartOffset => TAB_START_OFFSET,
118            _ => 0.0,
119        }
120    }
121}
122
123/// One tab's content: what it says, what it shows, and which of the two is drawn.
124#[derive(Clone, Debug, Default, PartialEq)]
125pub(crate) struct TabSpec {
126    /// The tab's text — drawn when `show_label`, and what a screen reader is told
127    /// either way.
128    pub label: String,
129    /// The icon above the label, if any.
130    pub icon: Option<IconData>,
131    /// Whether the label is **drawn**. False for an icon-only tab, whose label is still
132    /// its name as far as anything that cannot see is concerned. The reference leaves an
133    /// icon-only tab nameless and expects the caller to wrap it; asking for the word up
134    /// front costs nothing and cannot be forgotten.
135    pub show_label: bool,
136    /// Whether the caller draws this tab themselves, in which case the label and the icon
137    /// above are only what it is **called** and nothing is painted from them.
138    pub drawn: bool,
139    /// How wide this tab asks to be when the bar scrolls. `None` — the ordinary answer —
140    /// leaves it to the label and the icon.
141    pub width: Option<f32>,
142}
143
144impl TabSpec {
145    /// A tab that shows its text.
146    fn text(label: impl Into<String>) -> Self {
147        Self {
148            label: label.into(),
149            icon: None,
150            show_label: true,
151            drawn: false,
152            width: None,
153        }
154    }
155
156    /// Does this tab stack an icon **over** a label? The question the row's height turns
157    /// on: an icon on its own sits in an ordinary tab.
158    fn stacked(&self) -> bool {
159        self.icon.is_some() && self.show_label && !self.label.is_empty()
160    }
161}
162
163/// Everything about a tab bar's appearance, each part `None` until someone says otherwise.
164///
165/// Held by the bar and by each tab, so that both measure the indicator the same way — the
166/// bar draws it, the tabs are what it is measured against, and the two disagreeing would be
167/// an indicator that does not line up with the label it points at.
168#[derive(Clone, Copy, Debug, Default, PartialEq)]
169pub(crate) struct TabStyle {
170    pub variant: Option<TabBarVariant>,
171    pub indicator_color: Option<Color>,
172    pub indicator_weight: Option<f32>,
173    pub label_color: Option<Color>,
174    pub unselected_label_color: Option<Color>,
175    pub label_style: Option<TextStyle>,
176    pub divider_color: Option<Color>,
177    pub divider_height: Option<f32>,
178    pub label_padding: Option<f32>,
179    pub tab_height: Option<f32>,
180    /// Where the tabs sit when they do not fill the bar; see [`TabBar::alignment`].
181    pub alignment: Option<TabAlignment>,
182    /// Does any tab in this bar stack an icon over a label? Not an override but a fact
183    /// about the row, recorded here because the bar and every tab must measure the same
184    /// height and this is the one thing both already hold.
185    pub tall: bool,
186}
187
188impl TabStyle {
189    fn variant(&self, theme: &Theme) -> TabBarVariant {
190        self.variant
191            .or(theme.widgets.tab_bar.variant)
192            .unwrap_or_default()
193    }
194
195    fn indicator_weight(&self, theme: &Theme) -> f32 {
196        self.indicator_weight
197            .or(theme.widgets.tab_bar.indicator_weight)
198            .unwrap_or(match self.variant(theme) {
199                TabBarVariant::Primary => TAB_INDICATOR_PRIMARY,
200                TabBarVariant::Secondary => TAB_INDICATOR_SECONDARY,
201            })
202    }
203
204    fn indicator_color(&self, theme: &Theme) -> Color {
205        self.indicator_color
206            .or(theme.widgets.tab_bar.indicator_color)
207            .unwrap_or(theme.scheme.primary)
208    }
209
210    fn label_color(&self, theme: &Theme) -> Color {
211        self.label_color
212            .or(theme.widgets.tab_bar.label_color)
213            .unwrap_or(match self.variant(theme) {
214                // A primary bar's selected label takes the accent; a secondary bar's stays
215                // on the surface, the indicator alone marking it.
216                TabBarVariant::Primary => theme.scheme.primary,
217                TabBarVariant::Secondary => theme.scheme.on_surface,
218            })
219    }
220
221    fn unselected_label_color(&self, theme: &Theme) -> Color {
222        self.unselected_label_color
223            .or(theme.widgets.tab_bar.unselected_label_color)
224            .unwrap_or(theme.scheme.on_surface_variant)
225    }
226
227    fn label_style(&self, theme: &Theme) -> TextStyle {
228        self.label_style
229            .or(theme.widgets.tab_bar.label_style)
230            .unwrap_or(theme.text.title_small)
231    }
232
233    fn divider_color(&self, theme: &Theme) -> Color {
234        self.divider_color
235            .or(theme.widgets.tab_bar.divider_color)
236            .unwrap_or(theme.scheme.outline_variant)
237    }
238
239    fn divider_height(&self, theme: &Theme) -> f32 {
240        self.divider_height
241            .or(theme.widgets.tab_bar.divider_height)
242            .unwrap_or(TAB_DIVIDER_HEIGHT)
243    }
244
245    fn label_padding(&self, theme: &Theme) -> f32 {
246        self.label_padding
247            .or(theme.widgets.tab_bar.label_padding)
248            .unwrap_or(TAB_LABEL_PADDING)
249    }
250
251    /// Where the tabs sit, resolved against what the bar does.
252    fn alignment(&self, theme: &Theme, scrolls: bool) -> TabAlignment {
253        self.alignment
254            .or(theme.widgets.tab_bar.alignment)
255            .unwrap_or_default()
256            .resolved(scrolls)
257    }
258
259    fn tab_height(&self, theme: &Theme) -> f32 {
260        self.tab_height
261            .or(theme.widgets.tab_bar.tab_height)
262            .unwrap_or(match self.tall {
263                true => TAB_ICON_HEIGHT,
264                false => TAB_HEIGHT,
265            })
266    }
267
268    /// The **whole bar**'s height: the tabs, plus the indicator sitting under them.
269    fn bar_height(&self, theme: &Theme) -> f32 {
270        self.tab_height(theme) + self.indicator_weight(theme)
271    }
272
273    /// The width the indicator takes under the tab holding `label`, within a tab `tab_width`
274    /// wide. A primary bar measures the label; a secondary one takes the tab.
275    /// How wide one tab is when the bar **scrolls**: its label, plus the label's own
276    /// room either side.
277    ///
278    /// The tab lays itself out to this number and the indicator is placed by it, which
279    /// is the point of there being one function. A tab that measured itself one way and
280    /// an indicator that measured it another would agree on every label until they did
281    /// not, and the failure would be an underline creeping away from its tab as the bar
282    /// filled up.
283    fn scrolled_tab_width(&self, theme: &Theme, spec: &TabSpec) -> f32 {
284        self.content_width(theme, spec) + 2.0 * self.label_padding(theme)
285    }
286
287    /// How wide a tab's **content** is: its text, its icon, or whichever of the two is
288    /// wider when it has both, since they are stacked rather than side by side.
289    ///
290    /// One function, and both the tab's width and the indicator's come from it. A tab
291    /// measured one way and an indicator measured another would agree on every label
292    /// until they did not.
293    fn content_width(&self, theme: &Theme, spec: &TabSpec) -> f32 {
294        // A tab the caller drew cannot be asked how wide it would like to be
295        // ([#52](https://github.com/KalybosPro/frus/issues/52)), so it says. Unsaid, its
296        // name is the proxy — right for a label with a dot beside it, short for a label
297        // with a count in a pill, and never consulted at all on a bar that does not
298        // scroll, where the tabs share the width in equal parts.
299        if let Some(width) = spec.width {
300            return width;
301        }
302        let text = match spec.show_label && !spec.label.is_empty() {
303            true => {
304                let style = self.label_style(theme);
305                frus_text::measure_style(&spec.label, style).width
306            }
307            false => 0.0,
308        };
309        let icon = match spec.icon {
310            Some(_) => TAB_ICON_SIZE,
311            None => 0.0,
312        };
313        text.max(icon)
314    }
315
316    /// Where each tab starts and how wide it is, in the bar's own coordinates.
317    ///
318    /// Not scrollable, the tabs share the width in equal parts — the reference's rule,
319    /// and the one that keeps a tab still when another is renamed. Scrollable, each takes
320    /// the room its label needs.
321    fn tab_spans(
322        &self,
323        theme: &Theme,
324        tabs: &[TabSpec],
325        width: f32,
326        alignment: TabAlignment,
327    ) -> Vec<(f32, f32)> {
328        if alignment == TabAlignment::Fill {
329            let each = width / tabs.len().max(1) as f32;
330            return (0..tabs.len()).map(|i| (i as f32 * each, each)).collect();
331        }
332        let widths: Vec<f32> = tabs
333            .iter()
334            .map(|spec| self.scrolled_tab_width(theme, spec))
335            .collect();
336        // Centred, the row's own leading inset is half of whatever is left over — and
337        // nothing when there is nothing left over, which is the case that matters: a
338        // scrolling bar's strip is as wide as its tabs, so the two agree at zero rather
339        // than the indicator drifting off by half an overflow.
340        let lead = match alignment {
341            TabAlignment::Center => (width - widths.iter().sum::<f32>()) / 2.0,
342            other => other.lead(),
343        }
344        .max(0.0);
345        let mut spans = Vec::with_capacity(tabs.len());
346        let mut x = lead;
347        for w in widths {
348            spans.push((x, w));
349            x += w;
350        }
351        spans
352    }
353
354    fn indicator_width(&self, theme: &Theme, spec: &TabSpec, tab_width: f32) -> f32 {
355        match self.variant(theme) {
356            TabBarVariant::Secondary => tab_width,
357            TabBarVariant::Primary => {
358                // Never past the content's own room: an indicator running into the next
359                // tab's would point at two labels at once.
360                let room = (tab_width - 2.0 * self.label_padding(theme)).max(0.0);
361                self.content_width(theme, spec).min(room)
362            }
363        }
364    }
365}
366
367/// One tab: a label, a tap, and the ink a tap leaves.
368struct Tab<Msg> {
369    spec: TabSpec,
370    /// `[the caller's widget]` for a tab they drew, empty otherwise.
371    children: Vec<Box<dyn Widget<Msg>>>,
372    selected: bool,
373    style: TabStyle,
374    /// The bar's availability, handed down to every tab.
375    enabled: bool,
376    /// Whether the bar scrolls. Half of the question "does this tab share the bar's
377    /// width"; the other half is the [`TabAlignment`], which only the theme can settle.
378    scrolls: bool,
379    message: Msg,
380}
381
382impl<Msg: Clone> Widget<Msg> for Tab<Msg> {
383    fn style(&self) -> Style {
384        self.style_themed(&Theme::default())
385    }
386
387    fn style_themed(&self, theme: &Theme) -> Style {
388        // A zero basis with equal growth: the tabs share the bar's width in equal parts,
389        // which is what the reference does with a bar that is not scrollable. Sharing it in
390        // proportion to the labels instead would move every tab whenever one was renamed.
391        // The padding is the label's own room, and the smallest a tab will agree to be.
392        let pad = self.style.label_padding(theme);
393        // Asked of the **theme**, here rather than once at build time: an application
394        // that sets the alignment on its theme sets it for tabs that were built before
395        // the theme was ever consulted.
396        // A tab the caller drew holds their widget as a child, centred in whatever room
397        // the tab ends up with — a row of one, rather than a slot the tab paints into.
398        let drawn = match self.spec.drawn {
399            true => Style {
400                flex_direction: FlexDirection::Row,
401                align: frus_layout::Align::Center,
402                justify: frus_layout::Justify::Center,
403                ..Default::default()
404            },
405            false => Style::default(),
406        };
407        if self.style.alignment(theme, self.scrolls) == TabAlignment::Fill {
408            return Style {
409                width: Dimension::Length(0.0),
410                flex_grow: 1.0,
411                padding: frus_core::Insets::new(0.0, pad, 0.0, pad),
412                ..drawn
413            };
414        }
415        // Otherwise the tab takes the room its content needs and no more, which is the
416        // whole difference — eight tabs on a phone stop being eight crushed columns and
417        // become eight readable ones the bar scrolls through.
418        //
419        // The width is **stated**, not left to the content, because a tab paints its own
420        // label rather than holding a `Text` child: there is nothing here for the layout
421        // engine to measure. Stating it also makes the tab and the indicator agree by
422        // construction, since both ask the same function.
423        Style {
424            width: Dimension::Length(self.style.scrolled_tab_width(theme, &self.spec)),
425            flex_grow: 0.0,
426            flex_shrink: 0.0,
427            padding: frus_core::Insets::new(0.0, pad, 0.0, pad),
428            ..drawn
429        }
430    }
431
432    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
433        &self.children
434    }
435
436    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
437        // A tab the caller drew paints **nothing** of its own. Its name is still its name
438        // — the semantics below say it — but a bar that painted a label under a caller's
439        // badge would be drawing the same thing twice.
440        if self.spec.drawn {
441            return;
442        }
443        let o = status.opacity;
444        let label_style = self.style.label_style(theme);
445        // A label is content whichever tab it is on, so both the selected and the
446        // unselected one flatten to the same grey. Fading the accent instead would read as
447        // *quietly selected* rather than as unavailable.
448        let color = if !self.enabled {
449            disabled_content(theme)
450        } else if self.selected {
451            self.style.label_color(theme)
452        } else {
453            self.style.unselected_label_color(theme)
454        };
455        let shows_label = self.spec.show_label && !self.spec.label.is_empty();
456        let measured = match shows_label {
457            true => frus_text::measure_style(&self.spec.label, label_style),
458            false => frus_core::Size::new(0.0, 0.0),
459        };
460        // Centred across the tab, and centred in the **tabs' own** height rather than the
461        // bar's: the indicator's weight belongs to the bar, not to the row of labels, so
462        // counting it here would push every label a pixel and a half down.
463        let height = self.style.tab_height(theme);
464        // Icon over label, as one block, centred as one block. Measuring the two
465        // separately and centring each would leave the pair looking untidy in a row
466        // where one tab has an icon and its neighbour does not.
467        let icon_block = match self.spec.icon {
468            Some(_) if shows_label => TAB_ICON_SIZE + TAB_ICON_GAP,
469            Some(_) => TAB_ICON_SIZE,
470            None => 0.0,
471        };
472        let block = icon_block + measured.height * f32::from(u8::from(shows_label));
473        let top = bounds.y + (height - block) / 2.0;
474        if let Some(icon) = self.spec.icon {
475            // The 24x24 grid, unscaled, centred across the tab.
476            let path = icon.placed(
477                TAB_ICON_SIZE,
478                bounds.x + (bounds.width - TAB_ICON_SIZE) / 2.0,
479                top,
480                theme.direction,
481            );
482            scene.fill_path(&path, color.fade(o));
483        }
484        if shows_label {
485            scene.text(
486                Point::new(
487                    bounds.x + (bounds.width - measured.width) / 2.0,
488                    top + icon_block,
489                ),
490                self.spec.label.clone(),
491                &label_style.resolved(),
492                color.fade(o),
493            );
494        }
495    }
496
497    fn on_click(&self) -> Option<Msg> {
498        if !self.enabled {
499            return None;
500        }
501        Some(self.message.clone())
502    }
503
504    fn ink(&self, theme: &Theme) -> Option<crate::InkStyle> {
505        // A disabled tab does not answer a tap, so it does not splash either.
506        if !self.enabled {
507            return None;
508        }
509        // The splash follows the label: the accent under a selected tab, the surface's own
510        // ink under the others — the reference's overlay, which changes with the state
511        // rather than being one colour for the bar.
512        let splash = if self.selected {
513            self.style.indicator_color(theme).fade(0.10)
514        } else {
515            theme.scheme.on_surface.fade(0.08)
516        };
517        Some(crate::InkStyle::of(theme).color(splash).radius(0.0))
518    }
519
520    fn focusable(&self) -> bool {
521        self.enabled
522    }
523
524    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
525        // Which tab is showing survives: a reader who cannot switch is still owed where
526        // they are.
527        let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Tab)
528            .label(self.spec.label.clone())
529            .toggled(self.selected);
530        Some(if self.enabled {
531            semantics.clickable()
532        } else {
533            semantics.disabled(true)
534        })
535    }
536}
537
538/// The bar itself: the tabs in a row, the hairline under them, and the indicator that
539/// slides from one to the next.
540struct TabStrip<Msg> {
541    selected: usize,
542    tabs: Vec<TabSpec>,
543    style: TabStyle,
544    /// Whether the bar scrolls: it decides both the strip's width and where the
545    /// indicator goes.
546    scrolls: bool,
547    /// The bar's availability. The **indicator** is the part that would otherwise keep the
548    /// accent on a disabled bar: milestone 324's golden showed exactly that, flattened
549    /// labels above a bar still painted in the accent colour.
550    enabled: bool,
551    children: Vec<Box<dyn Widget<Msg>>>,
552}
553
554impl<Msg: Clone> Widget<Msg> for TabStrip<Msg> {
555    fn style(&self) -> Style {
556        self.style_themed(&Theme::default())
557    }
558
559    fn style_themed(&self, theme: &Theme) -> Style {
560        // Centring needs something to centre **in**: the strip takes the bar's whole
561        // width, and the row is placed inside it. Only reachable on a bar that does not
562        // scroll, for exactly that reason — see [`TabAlignment::Center`].
563        let alignment = self.style.alignment(theme, self.scrolls);
564        let centred = alignment == TabAlignment::Center;
565        Style {
566            // Scrollable, the strip is as wide as its tabs, which is what gives the
567            // scroll something to scroll through.
568            width: match self.scrolls && !centred {
569                true => Dimension::Auto,
570                false => Dimension::Percent(1.0),
571            },
572            height: Dimension::Length(self.style.bar_height(theme)),
573            flex_direction: FlexDirection::Row,
574            justify: match centred {
575                true => frus_layout::Justify::Center,
576                false => frus_layout::Justify::Start,
577            },
578            padding: frus_core::Insets::new(0.0, 0.0, 0.0, alignment.lead()),
579            ..Default::default()
580        }
581    }
582
583    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
584        &self.children
585    }
586
587    /// The selected index, handed to the runtime so that it arrives here as a **fractional**
588    /// index while the selection moves: that is what the indicator slides along.
589    fn anim_target(&self) -> Option<f32> {
590        Some(self.selected as f32)
591    }
592
593    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
594        let count = self.tabs.len();
595        if count == 0 || bounds.width <= 0.0 {
596            return;
597        }
598        let o = status.opacity;
599        let weight = self.style.indicator_weight(theme);
600
601        // The hairline is **not** here. It divides the bar from the panel and belongs to
602        // neither tab -- and once the strip can scroll, drawing it here would send it
603        // sliding away with the labels and leave a scrollable bar with two tabs ruling a
604        // stub instead of a line. `TabBar` draws it, across its own width, which is the
605        // width it always meant.
606        if weight <= 0.0 {
607            return;
608        }
609
610        // Where the indicator is, which is generally **between** two tabs: the runtime
611        // tweens the selected index, so this is a fractional position and the indicator
612        // slides rather than jumps. Its width travels with it, so an indicator moving
613        // between a short label and a long one grows on the way.
614        let spans = self.style.tab_spans(
615            theme,
616            &self.tabs,
617            bounds.width,
618            self.style.alignment(theme, self.scrolls),
619        );
620        let last = (count - 1) as f32;
621        let value = status.value.clamp(0.0, last);
622        let (low, high) = (value.floor(), value.ceil());
623        let t = value - low;
624        let span = |index: f32| {
625            let i = index as usize;
626            let (start, tab_width) = spans[i];
627            let centre = bounds.x + start + tab_width / 2.0;
628            let width = self.style.indicator_width(theme, &self.tabs[i], tab_width);
629            (centre, width)
630        };
631        let (centre_low, width_low) = span(low);
632        let (centre_high, width_high) = span(high);
633        let centre = centre_low + (centre_high - centre_low) * t;
634        let width = width_low + (width_high - width_low) * t;
635
636        // It sits **on** the hairline, at the bar's bottom edge, rounded at the top for a
637        // primary bar only — square where it spans the whole tab, since a rounded end
638        // touching the next tab's would read as a gap that is not there.
639        let radius = match self.style.variant(theme) {
640            TabBarVariant::Primary => BorderRadius {
641                top_left: weight,
642                top_right: weight,
643                ..BorderRadius::ZERO
644            },
645            TabBarVariant::Secondary => BorderRadius::ZERO,
646        };
647        scene.draw_rect(
648            Rect::new(
649                centre - width / 2.0,
650                bounds.y + bounds.height - weight,
651                width,
652                weight,
653            ),
654            if self.enabled {
655                self.style.indicator_color(theme)
656            } else {
657                disabled_content(theme)
658            }
659            .fade(o),
660            radius,
661            0.0,
662            Color::TRANSPARENT,
663        );
664    }
665
666    fn on_click(&self) -> Option<Msg> {
667        None
668    }
669
670    /// The selected tab, so a bar wider than its window slides to it.
671    ///
672    /// Only when the bar **scrolls**. A bar that shares its width has every tab on
673    /// screen already, and the region it would ask does not exist.
674    ///
675    /// Centred rather than merely brought in, which is the reference's behaviour and the
676    /// readable one: a selected tab flush against the edge reads as the end of the row
677    /// when it is only the edge of the window. The clamp keeps the first and last tabs
678    /// where they belong.
679    fn keep_visible(&self, size: frus_core::Size, theme: &Theme) -> Option<crate::ui::KeepVisible> {
680        if !self.scrolls {
681            return None;
682        }
683        let spans = self.style.tab_spans(
684            theme,
685            &self.tabs,
686            size.width,
687            self.style.alignment(theme, self.scrolls),
688        );
689        let &(x, width) = spans.get(self.selected)?;
690        Some(crate::ui::KeepVisible {
691            key: self.selected as u64,
692            rect: Rect::new(x, 0.0, width, size.height),
693            centre: true,
694        })
695    }
696}
697
698/// **One tab of a [`TabBar`]**: what is drawn on it, and what it is called.
699///
700/// The four shorthands on the bar — [`tab`](TabBar::tab), [`icon_tab`](TabBar::icon_tab),
701/// [`icon_only_tab`](TabBar::icon_only_tab) and [`tab_widget`](TabBar::tab_widget) — each
702/// build one of these and are what most call sites should write. This is the door to the
703/// one thing they cannot carry: how wide a tab asks to be on a bar that **scrolls**.
704///
705/// **The label is never optional**, even on a tab that draws neither the word nor
706/// anything resembling one. It is what a screen reader says, and a tab nobody can name is
707/// a tab nobody can use. The reference leaves such a tab nameless and expects the caller
708/// to wrap it in something that names it; asking for the word here costs nothing and
709/// cannot be forgotten.
710///
711/// ```
712/// use frus_widgets::{Align, Badge, Container, Flex, TabBar, TabItem, text};
713///
714/// # #[derive(Clone)] enum Msg { Select(usize) }
715/// let unread: Flex<Msg> = Flex::row()
716///     .align(Align::Center)
717///     .gap(6.0)
718///     .child(text("Inbox").size(14.0))
719///     .child(Badge::new("12"));
720/// let bar = TabBar::new(0, Msg::Select)
721///     .scrollable(true)
722///     .item(
723///         TabItem::widget(unread, "Inbox").width(110.0),
724///         Container::<Msg>::new(),
725///     )
726///     .tab("Archive", Container::<Msg>::new());
727/// ```
728pub struct TabItem<Msg = crate::callback::Callback> {
729    spec: TabSpec,
730    child: Option<Rc<dyn Widget<Msg>>>,
731}
732
733impl<Msg> TabItem<Msg> {
734    /// A tab that is a word — what [`TabBar::tab`] builds.
735    pub fn new(label: impl Into<String>) -> Self {
736        Self {
737            spec: TabSpec::text(label),
738            child: None,
739        }
740    }
741
742    /// A tab with an **icon over its label**.
743    pub fn icon(icon: IconData, label: impl Into<String>) -> Self {
744        Self {
745            spec: TabSpec {
746                icon: Some(icon),
747                ..TabSpec::text(label)
748            },
749            child: None,
750        }
751    }
752
753    /// A tab showing **only** an icon, in a row of the ordinary height. The label is what
754    /// it is called, not what is drawn.
755    pub fn icon_only(icon: IconData, label: impl Into<String>) -> Self {
756        Self {
757            spec: TabSpec {
758                icon: Some(icon),
759                show_label: false,
760                ..TabSpec::text(label)
761            },
762            child: None,
763        }
764    }
765
766    /// A tab **the caller draws**: a label with an unread count beside it, a coloured dot,
767    /// two lines, rich text.
768    ///
769    /// `label` is what it is called. The bar paints nothing of its own on such a tab —
770    /// drawing a label under the caller's own would be drawing the same thing twice — but
771    /// the ink of a press, the indicator and the announcement are all still the bar's.
772    pub fn widget(child: impl Widget<Msg> + 'static, label: impl Into<String>) -> Self {
773        Self {
774            spec: TabSpec {
775                drawn: true,
776                ..TabSpec::text(label)
777            },
778            child: Some(Rc::new(child)),
779        }
780    }
781
782    /// How wide this tab asks to be **when the bar scrolls**, in logical pixels.
783    ///
784    /// A bar that does not scroll shares its width between its tabs in equal parts and
785    /// never asks, so this is meaningless there. When it does scroll, an ordinary tab is
786    /// measured from its label and a tab the caller drew cannot be measured at all — a
787    /// widget cannot be asked how wide it would like to be
788    /// ([#52](https://github.com/KalybosPro/frus/issues/52)) — so it falls back to its
789    /// name, which is right for a label with a dot beside it and short for a label with a
790    /// count in a pill. This is how the caller says.
791    #[must_use]
792    pub fn width(mut self, width: f32) -> Self {
793        self.spec.width = Some(width);
794        self
795    }
796}
797
798/// A tabbed view: the bar, and the selected tab's panel under it.
799pub struct TabBar<Msg = crate::callback::Callback> {
800    selected: usize,
801    on_select: Rc<dyn Fn(usize) -> Msg>,
802    tabs: Vec<TabSpec>,
803    /// The widget on each tab the caller drew, by tab index.
804    drawn: Vec<Option<Rc<dyn Widget<Msg>>>>,
805    enabled: bool,
806    style: TabStyle,
807    /// Whether the bar scrolls rather than sharing its width; see [`TabBar::scrollable`].
808    scrollable: bool,
809    /// Whether a drag across the panel changes tab; see [`TabBar::swipeable`].
810    swipeable: bool,
811    /// Every tab's panel, kept only while [`swipeable`](TabBar::swipeable) — the page view
812    /// needs to be able to build any of them, not just the one showing.
813    panels: Vec<Rc<dyn Widget<Msg>>>,
814    /// Either `[bar]` or `[bar, panel]`.
815    children: Vec<Box<dyn Widget<Msg>>>,
816}
817
818impl<Msg: Clone + 'static> TabBar<Msg> {
819    /// Creates tabs: `selected` is the active index, `on_select(i)` the message on click.
820    pub fn new(selected: usize, on_select: impl Fn(usize) -> Msg + 'static) -> Self {
821        let mut tabs = Self {
822            selected,
823            on_select: Rc::new(on_select),
824            tabs: Vec::new(),
825            drawn: Vec::new(),
826            enabled: true,
827            style: TabStyle::default(),
828            scrollable: false,
829            swipeable: false,
830            panels: Vec::new(),
831            children: Vec::new(),
832        };
833        tabs.rebuild_bar();
834        tabs
835    }
836
837    /// Adds a tab, a label plus content. The content is realised only when it belongs
838    /// to the selected tab.
839    pub fn tab(self, label: impl Into<String>, content: impl Widget<Msg> + 'static) -> Self {
840        self.item(TabItem::new(label), content)
841    }
842
843    /// Adds a tab **the caller draws** — a label with an unread count beside it, a
844    /// coloured dot, two lines, rich text — plus the word it is called by and its content.
845    ///
846    /// See [`TabItem::widget`], and [`TabItem::width`] for a bar that scrolls.
847    pub fn tab_widget(
848        self,
849        child: impl Widget<Msg> + 'static,
850        label: impl Into<String>,
851        content: impl Widget<Msg> + 'static,
852    ) -> Self {
853        self.item(TabItem::widget(child, label), content)
854    }
855
856    /// Adds a tab however it was built, plus its content — the door to
857    /// [`TabItem::width`], which no shorthand carries.
858    pub fn item(mut self, tab: TabItem<Msg>, content: impl Widget<Msg> + 'static) -> Self {
859        self.push(tab, content);
860        self
861    }
862
863    /// Adds a tab with an **icon over its label**.
864    ///
865    /// The whole row grows to [`TAB_ICON_HEIGHT`] as soon as one tab does this, because
866    /// tabs of two heights in one bar would put their labels on two different lines.
867    pub fn icon_tab(
868        self,
869        icon: IconData,
870        label: impl Into<String>,
871        content: impl Widget<Msg> + 'static,
872    ) -> Self {
873        self.item(TabItem::icon(icon, label), content)
874    }
875
876    /// Adds a tab showing **only** an icon, in a row of the ordinary height.
877    ///
878    /// The label is not drawn, and it is not optional: it is what a screen reader says,
879    /// and a tab nobody can name is a tab nobody can use. The reference leaves an
880    /// icon-only tab nameless and expects the caller to wrap it in something that names
881    /// it; asking for the word here costs nothing and cannot be forgotten.
882    pub fn icon_only_tab(
883        self,
884        icon: IconData,
885        label: impl Into<String>,
886        content: impl Widget<Msg> + 'static,
887    ) -> Self {
888        self.item(TabItem::icon_only(icon, label), content)
889    }
890
891    /// Adds a tab to the row, keeping `content` only when it is the selected tab's —
892    /// unless the panel can be swiped, in which case every one of them is kept, because
893    /// the page view has to be able to build the tab you are dragging **towards**.
894    fn push(&mut self, tab: TabItem<Msg>, content: impl Widget<Msg> + 'static) {
895        let index = self.tabs.len();
896        self.tabs.push(tab.spec);
897        self.drawn.push(tab.child);
898        self.panels.push(Rc::new(content));
899        self.rebuild_bar();
900        self.rebuild_panel(index);
901    }
902
903    /// Puts the panel in place: the selected tab's content, or the page view when the
904    /// panel can be swiped.
905    fn rebuild_panel(&mut self, added: usize) {
906        let panel: Option<Box<dyn Widget<Msg>>> = if self.swipeable {
907            let panels = self.panels.clone();
908            let on_select = self.on_select.clone();
909            Some(Box::new(
910                PageView::new(panels.len(), move |i| Shared::new(panels[i].clone()))
911                    .page(self.selected)
912                    // The page view reports where the finger left it; the bar's own
913                    // selection is still the application's, so this is the same message a
914                    // press on the tab sends. One rule, reached two ways.
915                    .on_page_changed(move |i| on_select(i))
916                    .flex(1.0),
917            ))
918        } else if added == self.selected {
919            Some(Box::new(Shared::new(self.panels[added].clone())))
920        } else {
921            None
922        };
923        let Some(panel) = panel else { return };
924        if self.children.len() > 1 {
925            self.children[1] = panel;
926        } else {
927            self.children.push(panel);
928        }
929    }
930
931    /// **Whether a drag across the panel changes tab.**
932    ///
933    /// Off by default, and deliberately opt-in: it is a behaviour change for every bar
934    /// already written, and a panel that scrolls sideways of its own accord would fight
935    /// the gesture rather than answer it.
936    ///
937    /// On, the panel becomes a [`PageView`] over every tab's content — the milestone-277
938    /// physics, so the drag, the fling and the spring to rest are the ones a page view
939    /// already has — and the bar's indicator slides to the tab the finger settled on.
940    ///
941    /// Two things follow, and both are the point rather than side effects:
942    ///
943    /// - **Every tab's content is kept**, not only the showing one, because the page view
944    ///   has to be able to build the tab you are dragging *towards*. The panels were
945    ///   already built by the caller either way; what changes is that they are held.
946    /// - **The set takes the height it is offered**, as it already takes the width. A
947    ///   paged panel cannot be sized by whichever page happens to be longest, or the
948    ///   control would change height as you swiped through it.
949    pub fn swipeable(mut self, swipeable: bool) -> Self {
950        self.swipeable = swipeable;
951        self.children.truncate(1);
952        for index in 0..self.panels.len() {
953            self.rebuild_panel(index);
954        }
955        self
956    }
957
958    /// Lets the bar **scroll** rather than sharing its width between the tabs.
959    ///
960    /// Off by default, which is the reference's default and the right one for the two or
961    /// three tabs most bars have: equal columns, and a tab that stays where it is when
962    /// another is renamed.
963    ///
964    /// On, each tab takes the room its label needs and the bar scrolls through them. That
965    /// is what eight tabs on a phone want — the alternative is eight columns of forty-odd
966    /// pixels, which is not a tab bar with more tabs, it is a tab bar with none you can
967    /// read.
968    ///
969    /// The indicator follows the tabs at their real widths, and the hairline still runs
970    /// the whole foot of the bar even when the tabs do not fill it.
971    pub fn scrollable(mut self, scrollable: bool) -> Self {
972        self.scrollable = scrollable;
973        self.rebuild_bar();
974        self
975    }
976
977    /// Where the tabs sit when they do not fill the bar.
978    ///
979    /// [`Auto`](TabAlignment::Auto) — the default — is what the bar's mode implies:
980    /// shared width when it does not scroll, packed at the leading edge when it does.
981    pub fn alignment(mut self, alignment: TabAlignment) -> Self {
982        self.style.alignment = Some(alignment);
983        self.rebuild_bar();
984        self
985    }
986
987    /// Whether the bar can be switched. Disabled every tab is **inert** - no press, no
988    /// ink, out of the tab order - and the **panel stays**, because which tab is showing
989    /// is still the answer even when it cannot be changed.
990    ///
991    /// See [`crate::disabled`] for the whole contract.
992    pub fn enabled(mut self, enabled: bool) -> Self {
993        self.enabled = enabled;
994        self.rebuild_bar();
995        self
996    }
997
998    /// Chooses between the two bars. See [`TabBarVariant`].
999    pub fn variant(mut self, variant: TabBarVariant) -> Self {
1000        self.style.variant = Some(variant);
1001        self.rebuild_bar();
1002        self
1003    }
1004
1005    /// The indicator's colour. Defaults to the theme's accent.
1006    pub fn indicator_color(mut self, color: Color) -> Self {
1007        self.style.indicator_color = Some(color);
1008        self.rebuild_bar();
1009        self
1010    }
1011
1012    /// The indicator's thickness — and, on a primary bar, the radius of its top corners.
1013    /// `0.0` removes it.
1014    pub fn indicator_weight(mut self, weight: f32) -> Self {
1015        self.style.indicator_weight = Some(weight);
1016        self.rebuild_bar();
1017        self
1018    }
1019
1020    /// The **selected** label's colour.
1021    pub fn label_color(mut self, color: Color) -> Self {
1022        self.style.label_color = Some(color);
1023        self.rebuild_bar();
1024        self
1025    }
1026
1027    /// The colour of every label but the selected one.
1028    pub fn unselected_label_color(mut self, color: Color) -> Self {
1029        self.style.unselected_label_color = Some(color);
1030        self.rebuild_bar();
1031        self
1032    }
1033
1034    /// The labels' type. Defaults to the theme's `title_small` step.
1035    pub fn label_style(mut self, style: TextStyle) -> Self {
1036        self.style.label_style = Some(style);
1037        self.rebuild_bar();
1038        self
1039    }
1040
1041    /// The hairline's colour.
1042    pub fn divider_color(mut self, color: Color) -> Self {
1043        self.style.divider_color = Some(color);
1044        self.rebuild_bar();
1045        self
1046    }
1047
1048    /// The hairline's thickness. `0.0` removes it — a bar sitting directly on a coloured
1049    /// surface usually wants that.
1050    pub fn divider_height(mut self, height: f32) -> Self {
1051        self.style.divider_height = Some(height);
1052        self.rebuild_bar();
1053        self
1054    }
1055
1056    /// The room on either side of a label.
1057    pub fn label_padding(mut self, padding: f32) -> Self {
1058        self.style.label_padding = Some(padding);
1059        self.rebuild_bar();
1060        self
1061    }
1062
1063    /// The tabs' height, the indicator excluded.
1064    pub fn tab_height(mut self, height: f32) -> Self {
1065        self.style.tab_height = Some(height);
1066        self.rebuild_bar();
1067        self
1068    }
1069
1070    /// (Re)builds the bar at index 0.
1071    ///
1072    /// Every builder calls this, because the bar carries the style and a builder run after
1073    /// `tab(…)` would otherwise be written into a bar that had already been built — the
1074    /// order of a builder chain is not something a caller should have to think about.
1075    fn rebuild_bar(&mut self) {
1076        // One tall tab makes the whole row tall. Recorded on the style, which the bar and
1077        // every tab already share, so the height cannot be answered two ways.
1078        self.style.tall = self.tabs.iter().any(TabSpec::stacked);
1079        let tabs: Vec<Box<dyn Widget<Msg>>> = self
1080            .tabs
1081            .iter()
1082            .enumerate()
1083            .map(|(i, spec)| {
1084                Box::new(Tab {
1085                    spec: spec.clone(),
1086                    children: self.drawn[i]
1087                        .clone()
1088                        .map(|inner| Box::new(Shared::new(inner)) as Box<dyn Widget<Msg>>)
1089                        .into_iter()
1090                        .collect(),
1091                    selected: i == self.selected,
1092                    style: self.style,
1093                    enabled: self.enabled,
1094                    scrolls: self.scrollable,
1095                    message: (self.on_select)(i),
1096                }) as Box<dyn Widget<Msg>>
1097            })
1098            .collect();
1099        let strip = TabStrip {
1100            selected: self.selected,
1101            tabs: self.tabs.clone(),
1102            style: self.style,
1103            enabled: self.enabled,
1104            scrolls: self.scrollable,
1105            children: tabs,
1106        };
1107        // Scrollable, the strip goes inside a horizontal scroll -- and inside nothing
1108        // else, because the scroll is the only difference. Everything that makes the bar
1109        // a bar (the indicator, the hairline, the ink, the tap) stays in the strip and
1110        // does not know it is being scrolled.
1111        let bar: Box<dyn Widget<Msg>> = match self.scrollable {
1112            true => Box::new(
1113                crate::SingleChildScrollView::new()
1114                    .axis(crate::scroll::Axis::Horizontal)
1115                    .child(strip),
1116            ),
1117            false => Box::new(strip),
1118        };
1119        if self.children.is_empty() {
1120            self.children.push(bar);
1121        } else {
1122            self.children[0] = bar;
1123        }
1124    }
1125}
1126
1127impl<Msg: Clone> Widget<Msg> for TabBar<Msg> {
1128    fn style(&self) -> Style {
1129        Style {
1130            width: Dimension::Auto,
1131            flex_direction: FlexDirection::Column,
1132            // No gap: the hairline at the bar's foot is what separates it from the panel,
1133            // and a gap on top of it would leave the line floating.
1134            ..Default::default()
1135        }
1136    }
1137
1138    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1139        &self.children
1140    }
1141
1142    /// A tab set takes the **width it is offered**, as the reference's does. Sized by its
1143    /// content instead, the widest thing on the busiest tab decides how wide the whole
1144    /// control is — so the bar jumps from tab to tab, and a panel that does not fit hangs
1145    /// out of whatever is centring it rather than being told to fit.
1146    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
1147        // And the **height** too once the panel can be swiped: a page view's pages fill
1148        // its viewport, so a viewport sized by whichever page happens to be longest would
1149        // make the control change height as the finger moved through it.
1150        match self.swipeable {
1151            true => crate::widget::FillAxes::BOTH,
1152            false => crate::widget::FillAxes::WIDTH,
1153        }
1154    }
1155
1156    /// The hairline at the foot of the bar.
1157    ///
1158    /// Drawn here rather than by the strip because it belongs to the bar: it runs the
1159    /// bar's whole width whatever the tabs do, and a scrollable strip would carry it off
1160    /// the side. The measurement is the same one the strip uses for its own height, so
1161    /// the line lands exactly where it always did.
1162    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
1163        let divider = self.style.divider_height(theme);
1164        if divider <= 0.0 || bounds.width <= 0.0 {
1165            return;
1166        }
1167        scene.fill_rect(
1168            Rect::new(
1169                bounds.x,
1170                bounds.y + self.style.bar_height(theme) - divider,
1171                bounds.width,
1172                divider,
1173            ),
1174            self.style.divider_color(theme).fade(status.opacity),
1175        );
1176    }
1177
1178    fn on_click(&self) -> Option<Msg> {
1179        None
1180    }
1181}
1182
1183#[cfg(test)]
1184mod tests {
1185    use super::*;
1186    use crate::icons::Icons;
1187    use crate::runtime::Runtime;
1188    use crate::ui::build_ui;
1189    use crate::Text;
1190    use frus_core::{Primitive, Size};
1191
1192    #[derive(Clone, Debug, PartialEq)]
1193    enum Msg {
1194        Select(usize),
1195    }
1196
1197    /// **A tab can be a widget the caller drew.** An unread count beside a label, a
1198    /// coloured dot, two lines, rich text — none of which a `&str` can say, and the first
1199    /// of which is on half the tab bars ever shipped.
1200    ///
1201    /// The bar paints **nothing** of its own on such a tab: a label under the caller's own
1202    /// label would be drawing the same thing twice. What stays the bar's is the press, the
1203    /// ink, the indicator — and the name, which is what a reader hears.
1204    #[test]
1205    fn a_tab_can_be_a_widget_the_caller_drew() {
1206        let mark = Color::rgb(0.9, 0.1, 0.1);
1207        let bar = TabBar::new(0, Msg::Select)
1208            .tab_widget(
1209                crate::Container::<Msg>::new()
1210                    .width(40.0)
1211                    .height(18.0)
1212                    .color(mark),
1213                "Inbox",
1214                crate::Container::<Msg>::new(),
1215            )
1216            .tab("Archive", crate::Container::<Msg>::new());
1217        let root = crate::flex::Flex::column()
1218            .width(300.0)
1219            .height(120.0)
1220            .child(bar);
1221        let ui = build_ui(
1222            &root,
1223            Size::new(300.0, 120.0),
1224            &Runtime::default(),
1225            &Theme::default(),
1226        );
1227        assert!(
1228            rects(ui.scene()).iter().any(|(_, color)| *color == mark),
1229            "the caller's own widget is drawn"
1230        );
1231        let drawn = texts(ui.scene());
1232        assert!(
1233            !drawn.iter().any(|t| t == "Inbox"),
1234            "and the bar draws no label of its own under it: {drawn:?}"
1235        );
1236        assert!(
1237            drawn.iter().any(|t| t == "Archive"),
1238            "while an ordinary tab still does"
1239        );
1240    }
1241
1242    /// **A drawn tab is still named**, because a tab nobody can name is a tab nobody can
1243    /// use — the same demand `icon_only_tab` has always made, for the same reason.
1244    #[test]
1245    fn a_drawn_tab_is_still_named_for_a_reader() {
1246        let bar = TabBar::new(0, Msg::Select).tab_widget(
1247            crate::Container::<Msg>::new().width(20.0).height(20.0),
1248            "Inbox",
1249            crate::Container::<Msg>::new(),
1250        );
1251        let strip = &Widget::<Msg>::children(&bar)[0];
1252        let tab = &strip.children()[0];
1253        let announced = tab.semantics().expect("announced");
1254        assert_eq!(announced.label.as_deref(), Some("Inbox"));
1255        assert!(tab.focusable(), "and it is still a tab");
1256        assert_eq!(tab.on_click(), Some(Msg::Select(0)));
1257    }
1258
1259    /// **A drawn tab says how wide it is when the bar scrolls**, because it cannot be
1260    /// asked ([#52](https://github.com/KalybosPro/frus/issues/52)). Unsaid, its name is
1261    /// the proxy — right for a label with a dot beside it, short for a label with a count
1262    /// in a pill.
1263    ///
1264    /// Never consulted at all on a bar that does not scroll, where the tabs share the
1265    /// width in equal parts: the second half of this is that the same two bars come out
1266    /// identical when they do not scroll.
1267    #[test]
1268    fn a_drawn_tab_says_how_wide_it_is_when_the_bar_scrolls() {
1269        let bar = |width: Option<f32>, scrollable: bool| {
1270            let mut item = TabItem::widget(
1271                crate::Container::<Msg>::new().width(20.0).height(20.0),
1272                "Hi",
1273            );
1274            if let Some(width) = width {
1275                item = item.width(width);
1276            }
1277            TabBar::new(0, Msg::Select)
1278                .scrollable(scrollable)
1279                .item(item, crate::Container::<Msg>::new())
1280                .tab("Archive", crate::Container::<Msg>::new())
1281        };
1282        // The stated width reaches the tab's box: a press a hundred across lands on the
1283        // first tab when it said a hundred and fifty, and on the second when it said
1284        // nothing, since "Hi" measures nowhere near that.
1285        let press = |bar: TabBar<Msg>| {
1286            let root = crate::flex::Flex::column()
1287                .width(400.0)
1288                .height(120.0)
1289                .child(bar);
1290            let ui = build_ui(
1291                &root,
1292                Size::new(400.0, 120.0),
1293                &Runtime::default(),
1294                &Theme::default(),
1295            );
1296            ui.hit(crate::Point::new(100.0, 20.0))
1297                .and_then(|id| ui.msg_for(id))
1298        };
1299        assert_eq!(press(bar(Some(150.0), true)), Some(Msg::Select(0)));
1300        assert_eq!(press(bar(None, true)), Some(Msg::Select(1)));
1301
1302        // And on a bar that does not scroll the two come out identical, because the tabs
1303        // share the width in equal parts and nobody is asked.
1304        assert_eq!(press(bar(Some(150.0), false)), press(bar(None, false)));
1305    }
1306
1307    /// **A drag across the panel lands on the next tab**, once the bar has been told the
1308    /// panel can be swiped.
1309    ///
1310    /// Driven the way the shell drives it: the offset is moved one page across, and the
1311    /// runtime is asked which paged regions have turned. The message that comes back is
1312    /// the **same one a press on the tab sends** — one rule, reached two ways, rather than
1313    /// a second path into the selection that can drift from the first.
1314    #[test]
1315    fn a_drag_across_the_panel_lands_on_the_next_tab() {
1316        let bar = || {
1317            TabBar::new(0, Msg::Select)
1318                .swipeable(true)
1319                .tab("One", crate::Container::<Msg>::new())
1320                .tab("Two", crate::Container::<Msg>::new())
1321                .tab("Three", crate::Container::<Msg>::new())
1322        };
1323        let root = |bar| {
1324            crate::flex::Flex::column()
1325                .width(300.0)
1326                .height(400.0)
1327                .child(bar)
1328        };
1329        let mut runtime = Runtime::default();
1330        let ui = build_ui(
1331            &root(bar()),
1332            Size::new(300.0, 400.0),
1333            &runtime,
1334            &Theme::default(),
1335        );
1336        let region = ui
1337            .scroll_regions()
1338            .iter()
1339            .find(|area| area.page.is_some())
1340            .expect("the panel is paged");
1341        let id = region.id;
1342        let snap = region.page.expect("a paged region");
1343        assert_eq!(snap.count, 3, "one page per tab");
1344
1345        // The first frame settles the runtime on the page it opened at, so the second is
1346        // where a change can be seen at all.
1347        runtime.page_changes(ui.scroll_regions());
1348        runtime.scroll.insert(id, (snap.extent, 0.0));
1349        let ui = build_ui(
1350            &root(bar()),
1351            Size::new(300.0, 400.0),
1352            &runtime,
1353            &Theme::default(),
1354        );
1355        let turned = runtime.page_changes(ui.scroll_regions());
1356        assert_eq!(turned, vec![(id, 1)], "one page across");
1357
1358        let bar = bar();
1359        let panel = &Widget::<Msg>::children(&bar)[1];
1360        assert_eq!(
1361            panel.on_page_changed(1),
1362            Some(Msg::Select(1)),
1363            "and it reports the tab a press would have"
1364        );
1365    }
1366
1367    /// **A bar that cannot be swiped keeps only the tab that is showing**, which is what
1368    /// it has always done; one that can keeps them all, because the page view has to be
1369    /// able to build the tab you are dragging towards.
1370    #[test]
1371    fn only_a_swipeable_bar_keeps_every_panel() {
1372        let bar = |swipeable: bool| {
1373            TabBar::new(0, Msg::Select)
1374                .swipeable(swipeable)
1375                .tab("One", crate::Container::<Msg>::new().width(11.0))
1376                .tab("Two", crate::Container::<Msg>::new().width(22.0))
1377        };
1378        let plain = bar(false);
1379        assert_eq!(
1380            Widget::<Msg>::children(&plain).len(),
1381            2,
1382            "a bar and a panel"
1383        );
1384        let root = crate::flex::Flex::column()
1385            .width(300.0)
1386            .height(400.0)
1387            .child(bar(true));
1388        let ui = build_ui(
1389            &root,
1390            Size::new(300.0, 400.0),
1391            &Runtime::default(),
1392            &Theme::default(),
1393        );
1394        assert!(
1395            ui.scroll_regions().iter().any(|area| area.page.is_some()),
1396            "and the swipeable one has a paged panel instead"
1397        );
1398    }
1399
1400    /// Every filled rectangle in the frame, innermost layers included.
1401    fn rects(scene: &frus_core::Scene) -> Vec<(Rect, Color)> {
1402        fn walk(primitives: &[frus_core::Primitive], out: &mut Vec<(Rect, Color)>) {
1403            for p in primitives {
1404                match p {
1405                    frus_core::Primitive::Rect { rect, color, .. } => out.push((*rect, *color)),
1406                    frus_core::Primitive::Layer { primitives, .. } => walk(primitives, out),
1407                    _ => {}
1408                }
1409            }
1410        }
1411        let mut out = Vec::new();
1412        walk(scene.primitives(), &mut out);
1413        out
1414    }
1415
1416    /// Every run of text in the frame.
1417    fn texts(scene: &frus_core::Scene) -> Vec<String> {
1418        fn walk(primitives: &[frus_core::Primitive], out: &mut Vec<String>) {
1419            for p in primitives {
1420                match p {
1421                    frus_core::Primitive::Text { text, .. } => out.push(text.clone()),
1422                    frus_core::Primitive::Layer { primitives, .. } => walk(primitives, out),
1423                    _ => {}
1424                }
1425            }
1426        }
1427        let mut out = Vec::new();
1428        walk(scene.primitives(), &mut out);
1429        out
1430    }
1431
1432    /// Ten tabs in a 300 px window: far more than fits, which is what `scrollable` is
1433    /// for and where the selected tab can go missing.
1434    fn wide(selected: usize) -> TabBar<Msg> {
1435        let mut tabs = TabBar::new(selected, Msg::Select).scrollable(true);
1436        for i in 0..10 {
1437            tabs = tabs.tab(format!("Section {i}"), Text::new("panel"));
1438        }
1439        tabs
1440    }
1441
1442    fn frame(tabs: TabBar<Msg>, runtime: &Runtime) -> crate::Ui<Msg> {
1443        build_ui(&tabs, Size::new(300.0, 400.0), runtime, &Theme::default())
1444    }
1445
1446    /// Where a label was drawn, or `None` if it was not.
1447    fn label_x(ui: &crate::Ui<Msg>, label: &str) -> Option<f32> {
1448        ui.scene().primitives().iter().find_map(|p| match p {
1449            Primitive::Text { position, text, .. } if text == label => Some(position.x),
1450            _ => None,
1451        })
1452    }
1453
1454    /// **A scrollable bar's panel starts under its hairline**, not two hundred pixels
1455    /// below it (#65).
1456    ///
1457    /// A scrollable bar wraps its strip in a horizontal viewport, and a viewport's default
1458    /// height — two hundred pixels, the height of a window onto something taller — belongs
1459    /// to the axis that scrolls. A horizontal one took it too, so a bar forty-eight pixels
1460    /// tall claimed two hundred and everything under it began in empty space.
1461    ///
1462    /// The picture is the whole bug, which is why one was added with it; this pins the
1463    /// arithmetic. Nothing in the suite caught it because nothing rendered a scrollable bar
1464    /// **with something under it** — every test read the strip's own geometry, which was
1465    /// right whatever box the scroll claimed.
1466    #[test]
1467    fn a_scrolling_bar_does_not_push_its_panel_down_the_page() {
1468        let panel = crate::Container::new()
1469            .height(30.0)
1470            .color(Color::rgb(1.0, 0.0, 0.0));
1471        let tabs = TabBar::new(0, Msg::Select)
1472            .scrollable(true)
1473            .tab("One", panel)
1474            .tab("Two", crate::Container::new().height(30.0));
1475        let ui = build_ui(
1476            &tabs,
1477            Size::new(400.0, 400.0),
1478            &Runtime::default(),
1479            &Theme::default(),
1480        );
1481        let red = ui
1482            .scene()
1483            .primitives()
1484            .iter()
1485            .find_map(|p| match p {
1486                Primitive::Rect { rect, color, .. } if color.r > 0.9 && color.g < 0.1 => {
1487                    Some(*rect)
1488                }
1489                _ => None,
1490            })
1491            .expect("the panel is drawn");
1492        // The bar is `tab_height + the hairline` tall, and the panel starts there. The
1493        // measurement in the issue found it at y > 150 against a hairline at 48.
1494        let bar = TAB_HEIGHT;
1495        assert!(
1496            (red.y - bar).abs() <= 4.0,
1497            "the panel starts under the bar ({bar}), not at {}",
1498            red.y
1499        );
1500    }
1501
1502    /// A bar wider than its window **opens** on the selected tab. Before this, an
1503    /// application restored on its ninth tab showed the first three and nothing said
1504    /// where the selection had gone.
1505    #[test]
1506    fn a_scrolling_bar_opens_on_its_selected_tab() {
1507        let runtime = Runtime::default();
1508        let ui = frame(wide(8), &runtime);
1509        let area = ui.scroll_regions()[0];
1510        let keep = area
1511            .keep_visible
1512            .expect("the bar keeps its selected tab in view");
1513        assert_eq!(keep.key, 8);
1514        assert!(keep.centre, "centred, as the reference's bar is");
1515
1516        // And it is really on screen: the ninth label is drawn inside the window.
1517        let x = label_x(&ui, "Section 8").expect("the selected tab is drawn");
1518        assert!(x > 0.0 && x < 300.0, "Section 8 drawn at {x}");
1519        // The first one is off the leading edge, which is the point.
1520        assert!(label_x(&ui, "Section 0").is_none_or(|x| x < 0.0));
1521    }
1522
1523    /// A bar that shares its width has every tab on screen already, and asks for
1524    /// nothing: there is no region to ask.
1525    #[test]
1526    fn a_bar_that_shares_its_width_asks_for_nothing() {
1527        let runtime = Runtime::default();
1528        let mut tabs = TabBar::new(8, Msg::Select);
1529        for i in 0..10 {
1530            tabs = tabs.tab(format!("Section {i}"), Text::new("panel"));
1531        }
1532        assert!(frame(tabs, &runtime).scroll_regions().is_empty());
1533    }
1534
1535    /// Changing the selection **glides**: a target, not a jump, so the bar reads as one
1536    /// movement rather than a cut.
1537    #[test]
1538    fn changing_the_tab_slides_the_bar() {
1539        let mut runtime = Runtime::default();
1540        let regions = frame(wide(0), &runtime).scroll_regions().to_vec();
1541        let id = regions[0].id;
1542        // The first sighting is where the bar opens, and it opens on tab 0, at the start.
1543        runtime.sync_visible(&regions);
1544        assert_eq!(runtime.scroll[&id], (0.0, 0.0));
1545        assert!(!runtime.scroll_target.contains_key(&id));
1546
1547        let regions = frame(wide(8), &runtime).scroll_regions().to_vec();
1548        runtime.sync_visible(&regions);
1549        let target = runtime.scroll_target[&id];
1550        assert!(
1551            target.0 > 100.0,
1552            "glides across to the ninth tab: {target:?}"
1553        );
1554        assert_eq!(runtime.scroll[&id], (0.0, 0.0), "a target, not a jump");
1555    }
1556
1557    /// The same selection twice does not pull the bar back: the reader is allowed to
1558    /// look at the other tabs. A region chasing the box every frame would pin the strip
1559    /// in place and no finger could move it.
1560    #[test]
1561    fn the_same_tab_twice_does_not_fight_the_finger() {
1562        let mut runtime = Runtime::default();
1563        let regions = frame(wide(3), &runtime).scroll_regions().to_vec();
1564        let id = regions[0].id;
1565        runtime.sync_visible(&regions);
1566        runtime.scroll_target.remove(&id);
1567
1568        // The reader drags the bar somewhere else.
1569        runtime.scroll.insert(id, (500.0, 0.0));
1570        let regions = frame(wide(3), &runtime).scroll_regions().to_vec();
1571        runtime.sync_visible(&regions);
1572        assert!(
1573            !runtime.scroll_target.contains_key(&id),
1574            "nothing pulls it back"
1575        );
1576        assert_eq!(runtime.scroll[&id], (500.0, 0.0));
1577    }
1578
1579    /// The bar's own height, as the hairline it draws across its foot reveals it.
1580    fn drawn_bar_height(tabs: TabBar<Msg>) -> f32 {
1581        let root = crate::flex::Flex::column()
1582            .width(400.0)
1583            .height(300.0)
1584            .child(tabs);
1585        let mut runtime = Runtime::default();
1586        runtime.advance_values::<Msg>(&root, 0.0);
1587        build_ui(&root, Size::new(400.0, 300.0), &runtime, &Theme::default())
1588            .scene()
1589            .primitives()
1590            .iter()
1591            .filter_map(|p| match p {
1592                // The hairline: the bar's full width, one pixel tall.
1593                Primitive::Rect { rect, .. } if rect.width > 399.0 && rect.height < 2.0 => {
1594                    Some(rect.y + rect.height)
1595                }
1596                _ => None,
1597            })
1598            .next()
1599            .expect("the hairline is drawn")
1600    }
1601
1602    /// What the frame drew for a bar: the paths (icons) and the texts (labels).
1603    fn drawn(tabs: TabBar<Msg>) -> (usize, Vec<String>) {
1604        let root = crate::flex::Flex::column()
1605            .width(400.0)
1606            .height(300.0)
1607            .child(tabs);
1608        let mut runtime = Runtime::default();
1609        runtime.advance_values::<Msg>(&root, 0.0);
1610        let ui = build_ui(&root, Size::new(400.0, 300.0), &runtime, &Theme::default());
1611        let mut paths = 0;
1612        let mut texts = Vec::new();
1613        for p in ui.scene().primitives() {
1614            match p {
1615                Primitive::Path { .. } => paths += 1,
1616                Primitive::Text { text, .. } => texts.push(text.clone()),
1617                _ => {}
1618            }
1619        }
1620        (paths, texts)
1621    }
1622
1623    /// An icon over a label draws both, and the row grows to the taller measurement.
1624    #[test]
1625    fn an_icon_tab_draws_its_icon_and_its_label() {
1626        let tabs = TabBar::new(0, Msg::Select)
1627            .icon_tab(Icons::STAR, "Home", Text::new("panel"))
1628            .icon_tab(Icons::FAVORITE, "Search", Text::new("panel"));
1629        let (paths, texts) = drawn(tabs);
1630        assert_eq!(paths, 2, "one icon per tab");
1631        assert!(texts.contains(&"Home".to_string()));
1632        assert!(texts.contains(&"Search".to_string()));
1633    }
1634
1635    /// One tall tab makes the **whole row** tall. Two heights in one bar would put the
1636    /// labels on two different lines.
1637    #[test]
1638    fn one_icon_tab_raises_the_whole_row() {
1639        let plain = TabBar::new(0, Msg::Select)
1640            .tab("One", Text::new("panel"))
1641            .tab("Two", Text::new("panel"));
1642        let mixed = TabBar::new(0, Msg::Select)
1643            .tab("One", Text::new("panel"))
1644            .icon_tab(Icons::FAVORITE, "Two", Text::new("panel"));
1645        let short = drawn_bar_height(plain);
1646        let tall = drawn_bar_height(mixed);
1647        assert!(
1648            (short - (TAB_HEIGHT + TAB_INDICATOR_PRIMARY)).abs() < 0.5,
1649            "{short}"
1650        );
1651        assert!(
1652            (tall - (TAB_ICON_HEIGHT + TAB_INDICATOR_PRIMARY)).abs() < 0.5,
1653            "{tall}"
1654        );
1655    }
1656
1657    /// An icon **on its own** stays in an ordinary row, and its label is not drawn —
1658    /// but it is still what a reader is told.
1659    #[test]
1660    fn an_icon_only_tab_is_short_and_still_named() {
1661        let tabs = TabBar::new(0, Msg::Select)
1662            .icon_only_tab(Icons::STAR, "Home", Text::new("panel"))
1663            .icon_only_tab(Icons::FAVORITE, "Search", Text::new("panel"));
1664        let (paths, texts) = drawn(tabs);
1665        assert_eq!(paths, 2, "the icons");
1666        assert!(!texts.contains(&"Home".to_string()), "no label is drawn");
1667
1668        let short = drawn_bar_height(TabBar::new(0, Msg::Select).icon_only_tab(
1669            Icons::STAR,
1670            "Home",
1671            Text::new("panel"),
1672        ));
1673        assert!(
1674            (short - (TAB_HEIGHT + TAB_INDICATOR_PRIMARY)).abs() < 0.5,
1675            "{short}"
1676        );
1677
1678        // The word survives where it matters.
1679        let root = crate::flex::Flex::column()
1680            .width(400.0)
1681            .height(300.0)
1682            .child(TabBar::new(0, Msg::Select).icon_only_tab(
1683                Icons::STAR,
1684                "Home",
1685                Text::new("panel"),
1686            ));
1687        let ui = build_ui(
1688            &root,
1689            Size::new(400.0, 300.0),
1690            &Runtime::default(),
1691            &Theme::default(),
1692        );
1693        assert!(
1694            ui.semantics()
1695                .iter()
1696                .any(|(_, _, s)| s.label.as_deref() == Some("Home")),
1697            "the icon-only tab still has a name"
1698        );
1699    }
1700
1701    /// A scrolling bar measures an icon tab by whichever of its two parts is wider,
1702    /// since they are stacked rather than side by side.
1703    #[test]
1704    fn a_stacked_tab_is_as_wide_as_its_widest_part() {
1705        let style = TabStyle::default();
1706        let theme = Theme::default();
1707        let narrow = TabSpec {
1708            icon: Some(Icons::STAR),
1709            ..TabSpec::text("Hi")
1710        };
1711        // "Hi" is narrower than a 24 px icon, so the icon decides.
1712        assert_eq!(
1713            style.scrolled_tab_width(&theme, &narrow),
1714            TAB_ICON_SIZE + 2.0 * TAB_LABEL_PADDING
1715        );
1716        // A long label decides instead.
1717        let wide = TabSpec {
1718            icon: Some(Icons::STAR),
1719            ..TabSpec::text("Notifications")
1720        };
1721        assert!(
1722            style.scrolled_tab_width(&theme, &wide) > style.scrolled_tab_width(&theme, &narrow)
1723        );
1724    }
1725
1726    /// Where each tab's label was actually drawn, left to right.
1727    fn label_xs(tabs: TabBar<Msg>, width: f32) -> Vec<f32> {
1728        let root = crate::flex::Flex::column()
1729            .width(width)
1730            .height(300.0)
1731            .child(tabs);
1732        let mut runtime = Runtime::default();
1733        runtime.advance_values::<Msg>(&root, 0.0);
1734        let ui = build_ui(&root, Size::new(width, 300.0), &runtime, &Theme::default());
1735        // The tabs' own labels, and not the panel's text, which is empty and sits at
1736        // the origin: a stray zero at the head of the list would make every comparison
1737        // below quietly about the wrong thing.
1738        let mut xs: Vec<f32> = ui
1739            .scene()
1740            .primitives()
1741            .iter()
1742            .filter_map(|p| match p {
1743                Primitive::Text { position, text, .. } if text == "One" || text == "Two" => {
1744                    Some(position.x)
1745                }
1746                _ => None,
1747            })
1748            .collect();
1749        xs.sort_by(f32::total_cmp);
1750        xs
1751    }
1752
1753    fn two(alignment: TabAlignment, scrolls: bool) -> TabBar<Msg> {
1754        TabBar::new(0, Msg::Select)
1755            .scrollable(scrolls)
1756            .alignment(alignment)
1757            .tab("One", crate::text(""))
1758            .tab("Two", crate::text(""))
1759    }
1760
1761    /// The default is the bar's mode, said out loud: shared width when it does not
1762    /// scroll, packed at the leading edge when it does.
1763    #[test]
1764    fn auto_is_whatever_the_bar_already_did() {
1765        assert_eq!(TabAlignment::Auto.resolved(false), TabAlignment::Fill);
1766        assert_eq!(TabAlignment::Auto.resolved(true), TabAlignment::Start);
1767        // And filling a bar that scrolls is a contradiction, so it reads as `Start`
1768        // rather than throwing the way the reference does.
1769        assert_eq!(TabAlignment::Fill.resolved(true), TabAlignment::Start);
1770        // The rest mean what they say either way.
1771        assert_eq!(TabAlignment::Center.resolved(false), TabAlignment::Center);
1772        assert_eq!(TabAlignment::Center.resolved(true), TabAlignment::Start);
1773        assert_eq!(TabAlignment::Start.resolved(false), TabAlignment::Start);
1774    }
1775
1776    /// Two tabs in a wide bar: filled they sit in the middle of their halves, started
1777    /// they hug the leading edge, centred they sit together in the middle.
1778    #[test]
1779    fn the_tabs_go_where_they_were_told() {
1780        let filled = label_xs(two(TabAlignment::Fill, false), 600.0);
1781        let started = label_xs(two(TabAlignment::Start, false), 600.0);
1782        let centred = label_xs(two(TabAlignment::Center, false), 600.0);
1783
1784        // Filled, each tab is 300 wide, so the labels are nowhere near each other.
1785        assert!(filled[1] - filled[0] > 250.0, "{filled:?}");
1786        // Started, they are as wide as their words and sit at the leading edge.
1787        assert!((started[0] - TAB_LABEL_PADDING).abs() < 1.0, "{started:?}");
1788        assert!(started[1] - started[0] < 120.0, "{started:?}");
1789        // Centred, the pair keeps its spacing and moves right as a block.
1790        let gap = |xs: &[f32]| xs[1] - xs[0];
1791        assert!((gap(&centred) - gap(&started)).abs() < 1.0, "{centred:?}");
1792        assert!(centred[0] > started[0] + 100.0, "{centred:?}");
1793        // And the block really is centred: as much room after it as before it.
1794        let width: f32 = (0..2)
1795            .map(|i| {
1796                TabStyle::default().scrolled_tab_width(
1797                    &Theme::default(),
1798                    &TabSpec::text(if i == 0 { "One" } else { "Two" }),
1799                )
1800            })
1801            .sum();
1802        assert!(
1803            (centred[0] - TAB_LABEL_PADDING - (600.0 - width) / 2.0).abs() < 1.0,
1804            "{centred:?}"
1805        );
1806    }
1807
1808    /// The offset is an inset before the first tab and nothing else: the second tab
1809    /// moves by exactly the same amount.
1810    #[test]
1811    fn the_start_offset_moves_the_whole_row() {
1812        let started = label_xs(two(TabAlignment::Start, false), 600.0);
1813        let offset = label_xs(two(TabAlignment::StartOffset, false), 600.0);
1814        for (a, b) in started.iter().zip(&offset) {
1815            assert!(
1816                (b - a - TAB_START_OFFSET).abs() < 1.0,
1817                "{started:?} {offset:?}"
1818            );
1819        }
1820    }
1821
1822    /// A **scrolling** bar cannot centre, and says so by reading as `Start` rather than
1823    /// by pretending.
1824    ///
1825    /// Centring wants something definite to centre in, and the content of a scroll has
1826    /// no definite width across the axis it scrolls along: the strip is measured by its
1827    /// own tabs, so a percentage of its parent resolves against nothing and it comes out
1828    /// exactly as wide as the tabs, with nothing left over to share. Measured here
1829    /// rather than assumed — this is the trap of milestones 368 and 377, and the third
1830    /// time it has been paid for.
1831    #[test]
1832    fn a_scrolling_bar_cannot_centre_and_does_not_pretend() {
1833        let started = label_xs(two(TabAlignment::Start, true), 600.0);
1834        let centred = label_xs(two(TabAlignment::Center, true), 600.0);
1835        assert_eq!(started, centred);
1836        assert!((started[0] - TAB_LABEL_PADDING).abs() < 1.0, "{started:?}");
1837        // Without the scroll, the same bar centres.
1838        let still = label_xs(two(TabAlignment::Center, false), 600.0);
1839        assert!(still[0] > started[0] + 100.0, "{still:?}");
1840    }
1841
1842    /// The indicator follows, because it reads the same spans the tabs are laid out to.
1843    /// This is the part that would drift.
1844    #[test]
1845    fn the_indicator_follows_the_alignment() {
1846        let indicator = |alignment: TabAlignment| {
1847            let root = crate::flex::Flex::column()
1848                .width(600.0)
1849                .height(300.0)
1850                .child(two(alignment, false));
1851            let mut runtime = Runtime::default();
1852            runtime.advance_values::<Msg>(&root, 0.0);
1853            build_ui(&root, Size::new(600.0, 300.0), &runtime, &Theme::default())
1854                .scene()
1855                .primitives()
1856                .iter()
1857                .filter_map(|p| match p {
1858                    // The indicator: as thick as a primary bar's, not the hairline.
1859                    Primitive::Rect { rect, .. }
1860                        if (rect.height - TAB_INDICATOR_PRIMARY).abs() < 0.5 =>
1861                    {
1862                        Some(rect.x + rect.width / 2.0)
1863                    }
1864                    _ => None,
1865                })
1866                .next()
1867                .expect("an indicator")
1868        };
1869        let started = indicator(TabAlignment::Start);
1870        let centred = indicator(TabAlignment::Center);
1871        let labels_started = label_xs(two(TabAlignment::Start, false), 600.0);
1872        let labels_centred = label_xs(two(TabAlignment::Center, false), 600.0);
1873        // The indicator moved by exactly as much as the label it marks.
1874        assert!(
1875            ((centred - started) - (labels_centred[0] - labels_started[0])).abs() < 1.0,
1876            "{started} {centred}"
1877        );
1878    }
1879
1880    fn three(selected: usize) -> TabBar<Msg> {
1881        TabBar::new(selected, Msg::Select)
1882            .tab("One", Text::new("panel one"))
1883            .tab("Two", Text::new("panel two"))
1884            .tab("Three", Text::new("panel three"))
1885    }
1886
1887    /// A tab set takes the width it is offered rather than the width of whatever is on
1888    /// the busiest tab. Sized by its content, a centring row lets it hang out either side
1889    /// instead of telling it to fit — which is how a settings screen came to draw 5 px
1890    /// off a phone (milestone 335).
1891    #[test]
1892    fn a_tab_set_takes_the_width_it_is_offered() {
1893        let root = crate::flex::Flex::row()
1894            .width(300.0)
1895            .height(200.0)
1896            .justify(frus_layout::Justify::Center)
1897            .child(three(0));
1898        let mut runtime = Runtime::default();
1899        runtime.advance_values::<Msg>(&root, 0.0);
1900        let ui = build_ui(&root, Size::new(300.0, 200.0), &runtime, &Theme::default());
1901        // The bar is `width: 100%` of the tab set, so the hairline under it is the tab
1902        // set's width made visible.
1903        let widest = ui
1904            .scene()
1905            .primitives()
1906            .iter()
1907            .filter_map(|p| match p {
1908                Primitive::Rect { rect, .. } => Some(rect.width),
1909                _ => None,
1910            })
1911            .fold(0.0_f32, f32::max);
1912        assert!(
1913            (widest - 300.0).abs() < 0.5,
1914            "the whole row, not the widest label: {widest}"
1915        );
1916    }
1917
1918    /// The frame's crisp boxes: the hairline, then the indicator.
1919    fn boxes(tabs: TabBar<Msg>) -> Vec<(Rect, Color)> {
1920        let root = crate::flex::Flex::column()
1921            .width(300.0)
1922            .height(200.0)
1923            .child(tabs);
1924        // The shell settles the animated values before it builds; a bare runtime would
1925        // catch the indicator on its way to the selected tab rather than under it.
1926        let mut runtime = Runtime::default();
1927        runtime.advance_values::<Msg>(&root, 0.0);
1928        build_ui(&root, Size::new(300.0, 200.0), &runtime, &Theme::default())
1929            .scene()
1930            .primitives()
1931            .iter()
1932            .filter_map(|p| match p {
1933                Primitive::Rect {
1934                    rect, color, blur, ..
1935                } if *blur == 0.0 => Some((*rect, *color)),
1936                _ => None,
1937            })
1938            .collect()
1939    }
1940
1941    #[test]
1942    fn shows_bar_and_selected_panel() {
1943        let tabs = three(1);
1944        // [bar, panel] — the panel is the selected tab's content, tab 1.
1945        let children = Widget::<Msg>::children(&tabs);
1946        assert_eq!(children.len(), 2);
1947        assert_eq!(children[0].children().len(), 3, "three tabs in the bar");
1948    }
1949
1950    #[test]
1951    fn no_panel_when_selection_out_of_range() {
1952        let tabs = TabBar::new(9, Msg::Select).tab("One", Text::new("x"));
1953        assert_eq!(Widget::<Msg>::children(&tabs).len(), 1); // the bar alone
1954    }
1955
1956    #[test]
1957    fn the_indicator_sits_under_the_selected_tab() {
1958        // The point of the whole widget: which tab is selected has to be visible without
1959        // reading the labels' colours.
1960        let theme = Theme::default();
1961        let painted = boxes(three(1));
1962        let indicator = painted
1963            .iter()
1964            .find(|(_, color)| *color == theme.scheme.primary)
1965            .expect("an indicator in the accent colour");
1966        // Three tabs across 300: the middle one runs from 100 to 200.
1967        let centre = indicator.0.x + indicator.0.width / 2.0;
1968        assert!(
1969            (centre - 150.0).abs() < 1.0,
1970            "centred on the second tab, not at {centre}"
1971        );
1972        assert_eq!(indicator.0.height, TAB_INDICATOR_PRIMARY);
1973    }
1974
1975    #[test]
1976    fn a_primary_indicator_is_as_wide_as_its_label_and_a_secondary_one_as_wide_as_its_tab() {
1977        // The difference between the two bars is not decoration: it is what the indicator
1978        // measures itself against.
1979        let theme = Theme::default();
1980        let width = |tabs: TabBar<Msg>| {
1981            boxes(tabs)
1982                .iter()
1983                .find(|(_, color)| *color == theme.scheme.primary)
1984                .expect("an indicator")
1985                .0
1986                .width
1987        };
1988        let primary = width(three(1));
1989        let secondary = width(three(1).variant(TabBarVariant::Secondary));
1990        assert_eq!(secondary, 100.0, "a third of 300 — the whole tab");
1991        assert!(
1992            primary < secondary,
1993            "a label is narrower than its tab: {primary} against {secondary}"
1994        );
1995        assert!(primary > 0.0, "and it is not nothing");
1996    }
1997
1998    #[test]
1999    fn the_hairline_runs_the_whole_bar() {
2000        let theme = Theme::default();
2001        let painted = boxes(three(0));
2002        let line = painted
2003            .iter()
2004            .find(|(_, color)| *color == theme.scheme.outline_variant)
2005            .expect("a divider");
2006        assert_eq!(line.0.width, 300.0, "across the bar, not across a tab");
2007        assert_eq!(line.0.height, TAB_DIVIDER_HEIGHT);
2008        // The indicator sits on it: both end at the bar's foot.
2009        let indicator = painted
2010            .iter()
2011            .find(|(_, color)| *color == theme.scheme.primary)
2012            .expect("an indicator");
2013        assert_eq!(
2014            line.0.y + line.0.height,
2015            indicator.0.y + indicator.0.height,
2016            "the indicator sits on the hairline"
2017        );
2018    }
2019
2020    #[test]
2021    fn the_bar_is_the_tabs_plus_the_indicator() {
2022        let theme = Theme::default();
2023        let tabs = three(0);
2024        let bar = &Widget::<Msg>::children(&tabs)[0];
2025        assert_eq!(
2026            bar.style_themed(&theme).height,
2027            Dimension::Length(TAB_HEIGHT + TAB_INDICATOR_PRIMARY)
2028        );
2029    }
2030
2031    #[test]
2032    fn every_measurement_is_the_callers_and_then_the_themes() {
2033        // `caller ?? theme ?? framework`, on the one measurement that reaches layout.
2034        let mut theme = Theme::default();
2035        theme.widgets.tab_bar.tab_height = Some(60.0);
2036        let height = |tabs: TabBar<Msg>, theme: &Theme| {
2037            Widget::<Msg>::style_themed(&Widget::<Msg>::children(&tabs)[0], theme).height
2038        };
2039        assert_eq!(
2040            height(three(0), &Theme::default()),
2041            Dimension::Length(TAB_HEIGHT + TAB_INDICATOR_PRIMARY),
2042            "the framework's"
2043        );
2044        assert_eq!(
2045            height(three(0), &theme),
2046            Dimension::Length(60.0 + TAB_INDICATOR_PRIMARY),
2047            "the theme's"
2048        );
2049        assert_eq!(
2050            height(three(0).tab_height(20.0), &theme),
2051            Dimension::Length(20.0 + TAB_INDICATOR_PRIMARY),
2052            "the caller's, over the theme's"
2053        );
2054    }
2055
2056    #[test]
2057    fn a_builder_after_the_tabs_still_reaches_them() {
2058        // The trap in a builder that assembles its children as it goes: `.tab(…)` builds
2059        // the bar, so anything said after it has to rebuild the bar or be dropped.
2060        let painted = boxes(three(1).indicator_weight(9.0));
2061        assert!(
2062            painted.iter().any(|(rect, _)| rect.height == 9.0),
2063            "the indicator took the weight set after the tabs"
2064        );
2065    }
2066
2067    #[test]
2068    fn tabs_are_tabs_to_a_screen_reader() {
2069        let tabs = three(0);
2070        let bar = &Widget::<Msg>::children(&tabs)[0];
2071        let first = &bar.children()[0];
2072        let semantics = first.semantics().expect("a tab announces itself");
2073        assert_eq!(semantics.role, frus_core::Role::Tab);
2074        assert_eq!(semantics.label.as_deref(), Some("One"));
2075        assert!(semantics.clickable);
2076    }
2077
2078    #[test]
2079    fn a_tab_emits_its_index() {
2080        let tabs = three(0);
2081        let bar = &Widget::<Msg>::children(&tabs)[0];
2082        assert_eq!(bar.children()[2].on_click(), Some(Msg::Select(2)));
2083    }
2084
2085    #[test]
2086    fn a_disabled_bar_is_inert_but_keeps_its_panel() {
2087        let dead = TabBar::new(1, Msg::Select)
2088            .tab("One", Text::new("first"))
2089            .tab("Two", Text::new("second"))
2090            .enabled(false);
2091        // The panel survives: which tab is showing is still the answer.
2092        assert_eq!(
2093            Widget::<Msg>::children(&dead).len(),
2094            2,
2095            "the bar and its panel"
2096        );
2097        let bar = &Widget::<Msg>::children(&dead)[0];
2098        for (i, tab) in bar.children().iter().enumerate() {
2099            assert_eq!(tab.on_click(), None, "tab {i} still answers");
2100            assert!(!tab.focusable(), "tab {i} is still in the tab order");
2101            assert!(
2102                tab.ink(&Theme::default()).is_none(),
2103                "tab {i} still splashes"
2104            );
2105            let semantics = tab.semantics().expect("still announced");
2106            assert!(semantics.disabled, "tab {i} does not say it is disabled");
2107        }
2108        // And the selected one still says so.
2109        assert_eq!(
2110            bar.children()[1].semantics().unwrap().toggled,
2111            frus_core::Toggled::True
2112        );
2113    }
2114
2115    #[test]
2116    fn a_disabled_bar_flattens_rather_than_fading_the_accent() {
2117        // Selected and unselected labels go to the same grey; the accent never appears.
2118        let theme = Theme::default();
2119        let dead = TabBar::new(0, Msg::Select)
2120            .tab("One", Text::new("first"))
2121            .tab("Two", Text::new("second"))
2122            .enabled(false);
2123        let bar = &Widget::<Msg>::children(&dead)[0];
2124        for tab in bar.children() {
2125            let mut scene = frus_core::Scene::new();
2126            tab.paint(
2127                Rect::new(0.0, 0.0, 80.0, 48.0),
2128                Status {
2129                    opacity: 1.0,
2130                    ..Default::default()
2131                },
2132                &theme,
2133                &mut scene,
2134            );
2135            let color = scene
2136                .primitives()
2137                .iter()
2138                .find_map(|p| match p {
2139                    Primitive::Text { color, .. } => Some(*color),
2140                    _ => None,
2141                })
2142                .expect("a label");
2143            assert_eq!(color, disabled_content(&theme));
2144        }
2145    }
2146
2147    /// The indicator is the part a flattened bar would otherwise keep the accent on -
2148    /// found by reading milestone 324's golden, not by a test.
2149    #[test]
2150    fn a_disabled_bars_indicator_loses_the_accent_too() {
2151        let theme = Theme::default();
2152        let indicator = |enabled: bool| {
2153            let tabs = TabBar::new(0, Msg::Select)
2154                .tab("One", Text::new("first"))
2155                .tab("Two", Text::new("second"))
2156                .enabled(enabled);
2157            let bar = &Widget::<Msg>::children(&tabs)[0];
2158            let mut scene = frus_core::Scene::new();
2159            bar.paint(
2160                Rect::new(0.0, 0.0, 200.0, 48.0),
2161                Status {
2162                    opacity: 1.0,
2163                    ..Default::default()
2164                },
2165                &theme,
2166                &mut scene,
2167            );
2168            // The indicator is the last box the bar paints, over the hairline.
2169            scene
2170                .primitives()
2171                .iter()
2172                .rev()
2173                .find_map(|p| match p {
2174                    Primitive::Rect { color, .. } => Some(*color),
2175                    _ => None,
2176                })
2177                .expect("an indicator")
2178        };
2179        assert_eq!(indicator(false), disabled_content(&theme));
2180        assert_ne!(indicator(true), disabled_content(&theme));
2181        assert_ne!(
2182            indicator(false),
2183            indicator(true),
2184            "the instrument must be able to tell the two apart"
2185        );
2186    }
2187}
2188
2189/// A bar with more tabs than fit.
2190#[cfg(test)]
2191mod scrollable_tests {
2192    use super::*;
2193    use crate::{build_ui, Runtime, Size};
2194    use frus_core::Primitive;
2195
2196    #[derive(Clone, Debug, PartialEq)]
2197    enum Msg {
2198        Pick(usize),
2199    }
2200
2201    /// Eight tabs of deliberately unequal label lengths.
2202    const LABELS: [&str; 8] = [
2203        "All", "Unread", "Starred", "Drafts", "Sent", "Archived", "Spam", "Bin",
2204    ];
2205
2206    fn bar(scrollable: bool) -> TabBar<Msg> {
2207        let mut tabs = TabBar::new(0, Msg::Pick);
2208        for label in LABELS {
2209            tabs = tabs.tab(label, crate::text(""));
2210        }
2211        tabs.scrollable(scrollable)
2212    }
2213
2214    /// The rects the bar painted, largest first -- the indicator is the small one.
2215    fn painted(tabs: TabBar<Msg>, width: f32) -> Vec<frus_core::Rect> {
2216        build_ui(
2217            &tabs,
2218            Size::new(width, 200.0),
2219            &Runtime::default(),
2220            &Theme::default(),
2221        )
2222        .scene()
2223        .primitives()
2224        .iter()
2225        .filter_map(|p| match p {
2226            Primitive::Rect { rect, .. } => Some(*rect),
2227            _ => None,
2228        })
2229        .collect()
2230    }
2231
2232    /// Off by default: nothing about an ordinary bar changes.
2233    #[test]
2234    fn a_bar_says_nothing_and_shares_its_width() {
2235        let tabs = bar(false);
2236        let ui = build_ui(
2237            &tabs,
2238            Size::new(360.0, 200.0),
2239            &Runtime::default(),
2240            &Theme::default(),
2241        );
2242        assert!(
2243            ui.scrollable_maxes().is_empty(),
2244            "an ordinary bar registers no scrollable"
2245        );
2246    }
2247
2248    /// Scrollable, the strip is wider than the bar and there is somewhere to scroll to.
2249    ///
2250    /// Eight labels at their own widths do not fit in 360 px -- which is the whole reason
2251    /// this exists, and the assertion says so rather than assuming it.
2252    #[test]
2253    fn eight_tabs_on_a_phone_scroll() {
2254        let ui = build_ui(
2255            &bar(true),
2256            Size::new(360.0, 200.0),
2257            &Runtime::default(),
2258            &Theme::default(),
2259        );
2260        let maxes = ui.scrollable_maxes();
2261        assert_eq!(maxes.len(), 1, "the strip is inside a scroll");
2262        assert!(maxes[0].1 > 0.0, "and it has room to move: {:?}", maxes[0]);
2263        assert_eq!(maxes[0].2, 0.0, "across, not down");
2264    }
2265
2266    /// Each tab takes its label's room, so a long label gets a wide tab and a short one
2267    /// does not. Shared equally, every tab is the same width and this cannot be true.
2268    #[test]
2269    fn a_long_label_gets_a_wide_tab() {
2270        let theme = Theme::default();
2271        let style = TabStyle::default();
2272        let narrow = style.scrolled_tab_width(&theme, &TabSpec::text("Bin"));
2273        let wide = style.scrolled_tab_width(&theme, &TabSpec::text("Archived"));
2274        assert!(wide > narrow, "{wide} should exceed {narrow}");
2275    }
2276
2277    /// The indicator follows the **real** tab widths.
2278    ///
2279    /// This is the part that would drift: the tab lays itself out to one number and the
2280    /// indicator is placed by another. They ask the same function, and this checks the
2281    /// answer against a hand-computed prefix sum rather than against that function --
2282    /// otherwise the test would agree with a mistake.
2283    #[test]
2284    fn the_indicator_sits_over_the_tab_it_marks() {
2285        let theme = Theme::default();
2286        let style = TabStyle::default();
2287        let labels: Vec<TabSpec> = LABELS.iter().map(|s| TabSpec::text(*s)).collect();
2288        let spans = style.tab_spans(&theme, &labels, 360.0, TabAlignment::Start);
2289
2290        let mut expected = 0.0;
2291        for (i, label) in labels.iter().enumerate() {
2292            assert!(
2293                (spans[i].0 - expected).abs() < 0.01,
2294                "tab {i} starts at {} rather than {expected}",
2295                spans[i].0
2296            );
2297            expected += style.scrolled_tab_width(&theme, label);
2298        }
2299        // And the last tab ends where the strip does.
2300        let (start, width) = spans[labels.len() - 1];
2301        assert!((start + width - expected).abs() < 0.01);
2302    }
2303
2304    /// Not scrollable, the spans are still equal parts -- the old rule, untouched.
2305    #[test]
2306    fn an_ordinary_bar_still_divides_its_width_evenly() {
2307        let labels: Vec<TabSpec> = LABELS.iter().map(|s| TabSpec::text(*s)).collect();
2308        let spans =
2309            TabStyle::default().tab_spans(&Theme::default(), &labels, 800.0, TabAlignment::Fill);
2310        assert_eq!(spans[0], (0.0, 100.0));
2311        assert_eq!(spans[7], (700.0, 100.0));
2312    }
2313
2314    /// Two tabs in a scrollable bar still get a hairline across the whole foot, rather
2315    /// than a stub under the labels. The strip is never narrower than the bar.
2316    #[test]
2317    fn a_short_scrollable_bar_still_rules_a_full_line() {
2318        let mut tabs = TabBar::new(0, Msg::Pick);
2319        tabs = tabs.tab("A", crate::text("")).tab("B", crate::text(""));
2320        let rects = painted(tabs.scrollable(true), 400.0);
2321        assert!(
2322            rects.iter().any(|r| r.width >= 399.0),
2323            "something runs the full width: {rects:?}"
2324        );
2325    }
2326}