Skip to main content

teksilo_widgets/styles/
recipe_tab_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `TabStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeTabStyle` ships the IntUI editor-tab look: an accent
7//! indicator on the active tab's outside edge (top for horizontal
8//! bars, leading for vertical bars), plus a keyboard focus ring
9//! inset from the tab's bounds.
10//!
11//! The tab's own per-state background (selected / hover / idle,
12//! controlled by `TabBar::tab_background` and the per-state overrides)
13//! is painted by the surrounding `TabHeader` as separate RectWidget
14//! siblings — the trait config doesn't carry the tab-surface roles
15//! through the cfg, and pulling them through every consumer would be
16//! more disruptive than letting the widget keep those rects.
17//!
18//! `TabStyle` carries two methods. `make_body` wraps a single tab
19//! header: a leaf `TabBodyPainter` (accent indicator + focus ring)
20//! sits behind the label / leading / trailing slot composition in a
21//! `ZStack`. `make_bar` wraps the whole strip: a `TabBarChrome`
22//! container stacks an optional backdrop `RectWidget`, a
23//! `TabBarChromePainter` leaf (content-pane separator +
24//! drag-reorder drop indicator), and the bar content — sizing to the
25//! content under the real proposal so its inner `Expand` fills the
26//! bar. Neither painter has an intrinsic size; each fills the bounds
27//! its parent hands it.
28
29use teksilo_canvas::{Canvas, Rect, SizeProposal};
30use teksilo_core::accessibility::AccessNodeBuilder;
31use teksilo_core::binding::BindingLevel;
32use teksilo_core::build_context::BuildContext;
33use teksilo_core::signal::Signal;
34use teksilo_core::styles::{
35    FocusRingStroke, SharedTabStyle, TabBarChromeConfig, TabBarOrientation, TabIndicatorPosition,
36    TabStyle, TabStyleConfig,
37};
38use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
39use teksilo_core::widget_id::WidgetId;
40use teksilo_tokens::{CornerRadius, InputTokens, TargetRole};
41
42use crate::primitives::{HStack, RectWidget, ZStack};
43use teksilo_core::styles::density::{dp, spacing};
44
45// IntUI design tokens for Tab. The recipe and parent TabHeader own
46// their own dimensions.
47pub const TAB_EDITOR_HEIGHT: f32 = 50.0;
48pub const TAB_PADDING_HORIZONTAL: f32 = 12.0;
49pub const TAB_UNDERLINE_ACTIVE: f32 = 2.0;
50pub const TAB_UNDERLINE_HOVER: f32 = 2.0;
51pub const TAB_CLOSE_BUTTON_SIZE: f32 = 16.0;
52/// Thickness of the drag-reorder drop-indicator line.
53const DROP_INDICATOR_WIDTH: f32 = 2.0;
54
55/// Dimension recipe for [`RecipeTabStyle`].
56///
57/// All fields default to the corresponding `TAB_*` constants so that
58/// [`RecipeTabStyle::default()`] reproduces the built-in IntUI look.
59#[derive(Debug, Clone, Copy, PartialEq)]
60pub struct TabRecipe {
61    /// The outer height of one tab header: the strip height of a horizontal
62    /// bar, the pill height of a vertical one. Read through
63    /// [`TabStyle::editor_height`].
64    pub editor_height: f32,
65    /// The gap between a tab header's edges and its content along the bar's
66    /// main axis. Read through [`TabStyle::padding_horizontal`].
67    pub padding_horizontal: f32,
68    pub underline_active: f32,
69    pub underline_hover: f32,
70    pub close_button_size: f32,
71}
72
73impl TabRecipe {
74    /// This recipe's dimensions resolved against a density's [`InputTokens`].
75    ///
76    /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
77    /// ladder — so the shipped values below are the Compact column by
78    /// construction and cannot drift from it.
79    pub fn for_tokens(tokens: &InputTokens) -> Self {
80        Self {
81            editor_height: dp(TAB_EDITOR_HEIGHT, TargetRole::Target, tokens),
82            padding_horizontal: spacing(TAB_PADDING_HORIZONTAL, tokens),
83            underline_active: TAB_UNDERLINE_ACTIVE,
84            underline_hover: TAB_UNDERLINE_HOVER,
85            close_button_size: TAB_CLOSE_BUTTON_SIZE,
86        }
87    }
88}
89
90impl Default for TabRecipe {
91    fn default() -> Self {
92        Self::for_tokens(&InputTokens::default())
93    }
94}
95
96/// Default `TabStyle` shipped with Teksilo.
97#[derive(Debug, Default, Clone, Copy)]
98pub struct RecipeTabStyle {
99    pub recipe: TabRecipe,
100}
101
102impl RecipeTabStyle {
103    pub fn new(recipe: TabRecipe) -> Self {
104        Self { recipe }
105    }
106
107    /// This style with every dimension resolved against a density's
108    /// [`InputTokens`], as `RecipeTabStyle::for_tokens(&ctx.theme().input)` at
109    /// the widget's own build site.
110    ///
111    /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
112    /// tree gets exactly the values this module documents.
113    pub fn for_tokens(tokens: &InputTokens) -> Self {
114        Self {
115            recipe: TabRecipe::for_tokens(tokens),
116        }
117    }
118}
119
120impl TabStyle for RecipeTabStyle {
121    fn make_body(&self, cfg: &TabStyleConfig, ctx: &mut BuildContext) -> WidgetId {
122        // Leaf painter for the chrome bits that live at the bounds
123        // edges: accent indicator and focus ring.
124        let painter = ctx.add(TabBodyPainter {
125            is_active: cfg.is_active.clone(),
126            is_focused: cfg.is_focused.clone(),
127            is_disabled: cfg.is_disabled.clone(),
128            orientation: cfg.orientation,
129            indicator_position: cfg.indicator_position,
130            recipe: self.recipe,
131        });
132
133        // Compose the slots. The widget today bundles everything into
134        // `label` and passes None for leading/trailing — but custom
135        // impls may use the three slots directly, so we honour them.
136        let mut row = HStack::new();
137        if let Some(id) = cfg.leading {
138            row = row.child(id);
139        }
140        row = row.child(cfg.label);
141        if let Some(id) = cfg.trailing {
142            row = row.child(id);
143        }
144        let row_id = ctx.add(row);
145
146        ctx.add(ZStack::new().child(painter).child(row_id))
147    }
148
149    fn make_bar(&self, cfg: &TabBarChromeConfig, ctx: &mut BuildContext) -> WidgetId {
150        // Bar chrome z-order (back → front): optional backdrop fill,
151        // the separator + drop-indicator leaf painter, then the bar
152        // content. This mirrors the old `TabBar::paint` order, where
153        // the widget painted backdrop/separator/indicator before its
154        // children drew on top.
155        //
156        // A plain `ZStack` won't do here: it sizes itself by querying
157        // children with an *unspecified* proposal, which collapses the
158        // content's inner `Expand` to zero width. `TabBarChrome`
159        // instead sizes to the content child under the *real*
160        // proposal and places every layer at the full bar bounds.
161        let painter = ctx.add(TabBarChromePainter {
162            orientation: cfg.orientation,
163            show_separator: cfg.show_separator,
164            drop_indicator: cfg.drop_indicator.clone(),
165        });
166
167        let mut layers = Vec::with_capacity(3);
168        if let Some(role) = &cfg.surface_role {
169            // A `RectWidget` (not a painted fill in the leaf) so the
170            // backdrop tracks `ColorProp` bindings — static role,
171            // `Signal<Color>`, or `Signal<Role>` — correctly.
172            let backdrop = ctx.add(RectWidget::new().background(role.clone()));
173            layers.push(backdrop);
174        }
175        layers.push(painter);
176        layers.push(cfg.content);
177
178        ctx.add(TabBarChrome {
179            layers,
180            content: cfg.content,
181        })
182    }
183
184    /// This style's own tab height, so a preset's header (macOS's 26 dp
185    /// `NSTabView` strip, Fluent's 32 dp `TabViewItem`) is the one a bar lays
186    /// out. `tokens` is unused: the recipe was already resolved against a
187    /// density when the style was made (`for_tokens`, or a preset's own
188    /// factory), and a second projection would apply the ladder twice.
189    fn editor_height(&self, _tokens: &InputTokens) -> f32 {
190        self.recipe.editor_height
191    }
192
193    /// This style's own header padding. See
194    /// [`editor_height`](Self::editor_height) for why `tokens` is unused.
195    fn padding_horizontal(&self, _tokens: &InputTokens) -> f32 {
196        self.recipe.padding_horizontal
197    }
198}
199
200/// The `TabStyle` a `TabBar` and its headers build against: the bar's
201/// per-call `.style(..)` if it has one, else the theme-wide `style_slots.tab`
202/// an app or a preset installed, else this module's default resolved against
203/// the active density.
204///
205/// Shared by the bar and every header it hosts, so the strip height the bar
206/// reserves and the height each header measures come from one style.
207pub(crate) fn resolve_tab_style(
208    style_override: Option<&SharedTabStyle>,
209    ctx: &BuildContext,
210) -> SharedTabStyle {
211    style_override
212        .cloned()
213        .or_else(|| ctx.theme().style_slots.tab.clone())
214        .unwrap_or_else(|| {
215            std::rc::Rc::new(RecipeTabStyle::for_tokens(&ctx.theme().input)) as SharedTabStyle
216        })
217}
218
219/// The tab height `style` declares at `tokens`, or the shipped recipe's when
220/// no style has been resolved yet (a header measured before its first build).
221pub(crate) fn tab_editor_height(style: Option<&SharedTabStyle>, tokens: &InputTokens) -> f32 {
222    match style {
223        Some(style) => style.editor_height(tokens),
224        None => TabRecipe::for_tokens(tokens).editor_height,
225    }
226}
227
228/// The header padding `style` declares at `tokens`, or the shipped recipe's
229/// when no style has been resolved yet.
230pub(crate) fn tab_padding_horizontal(style: Option<&SharedTabStyle>, tokens: &InputTokens) -> f32 {
231    match style {
232        Some(style) => style.padding_horizontal(tokens),
233        None => TabRecipe::for_tokens(tokens).padding_horizontal,
234    }
235}
236
237/// Bar-chrome container produced by [`RecipeTabStyle::make_bar`].
238/// Stacks the backdrop / chrome-painter / content layers at the full
239/// bar bounds, but — unlike `ZStack` — sizes itself to the content
240/// child under the real layout proposal so the content's inner
241/// `Expand` fills the bar instead of collapsing to zero.
242#[derive(Debug)]
243struct TabBarChrome {
244    /// Back-to-front: `[backdrop?, painter, content]`.
245    layers: Vec<WidgetId>,
246    /// The layer that drives the bar's size.
247    content: WidgetId,
248}
249
250impl Widget for TabBarChrome {
251    fn build(&mut self, _ctx: &mut BuildContext) -> Vec<WidgetId> {
252        self.layers.clone()
253    }
254
255    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
256        ctx.child_size(self.content, proposal)
257            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
258            .into()
259    }
260
261    fn place_children(
262        &self,
263        bounds: Rect,
264        _proposal: SizeProposal,
265        children: &mut [WidgetPlacement],
266        _ctx: &LayoutContext,
267    ) {
268        for child in children.iter_mut() {
269            child.origin = bounds.origin();
270            child.size = bounds.size();
271        }
272    }
273
274    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
275        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
276    }
277
278    fn children(&self) -> Vec<WidgetId> {
279        self.layers.clone()
280    }
281}
282
283/// Internal leaf widget that paints the bar-level chrome at the
284/// strip's bounds: the content-pane separator and the drag-reorder
285/// drop indicator. The backdrop fill is a sibling `RectWidget`, not
286/// painted here, so `ColorProp` bindings resolve correctly.
287struct TabBarChromePainter {
288    orientation: TabBarOrientation,
289    show_separator: bool,
290    drop_indicator: Signal<Option<f32>>,
291}
292
293impl std::fmt::Debug for TabBarChromePainter {
294    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295        f.debug_struct("TabBarChromePainter")
296            .field("orientation", &self.orientation)
297            .field("show_separator", &self.show_separator)
298            .finish()
299    }
300}
301
302impl Widget for TabBarChromePainter {
303    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
304        self.drop_indicator.bind_to(
305            ctx.self_id(),
306            ctx.binding_registry(),
307            BindingLevel::RepaintOnly,
308        );
309        vec![]
310    }
311
312    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
313        // Leaf painter — no intrinsic size, so accept whatever the
314        // parent proposes. ZStack/TabBarChrome propose the full bar
315        // bounds, then use the returned size as the placement. If we
316        // returned ZERO here, the painter would be placed at zero
317        // bounds and the separator + drop indicator would never show.
318        proposal.resolve(0.0, 0.0).into()
319    }
320
321    fn place_children(
322        &self,
323        _bounds: Rect,
324        _proposal: SizeProposal,
325        _children: &mut [WidgetPlacement],
326        _ctx: &LayoutContext,
327    ) {
328    }
329
330    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
331        if self.show_separator {
332            // 1 dp separator: bottom in horizontal mode, trailing
333            // edge (right) in vertical mode. Painted *inside* the
334            // focus-ring envelope reserved by each header so the
335            // selected header's `surface_content` fill overpaints
336            // the separator in its own column (the "tab merges into
337            // content pane" effect).
338            let border_width = ctx.theme.shape.border_width;
339            let envelope = ctx.theme.shape.focus_ring_envelope();
340            let separator = match self.orientation {
341                TabBarOrientation::Horizontal => Rect::new(
342                    bounds.x,
343                    (bounds.bottom() - envelope - border_width).max(bounds.y),
344                    bounds.width,
345                    border_width,
346                ),
347                TabBarOrientation::Vertical => Rect::new(
348                    (bounds.right() - envelope - border_width).max(bounds.x),
349                    bounds.y,
350                    border_width,
351                    bounds.height,
352                ),
353            };
354            canvas.fill_rect(separator, ctx.theme.colors.border);
355        }
356
357        // Drop indicator: a vertical accent-color line at the
358        // would-be insertion x in horizontal mode, a horizontal line
359        // at the insertion y in vertical mode. The position is the
360        // layout-axis offset stored in bar-local coords by the bar's
361        // `on_drag_hover` handler.
362        if let Some(local_pos) = self.drop_indicator.get() {
363            let indicator = match self.orientation {
364                TabBarOrientation::Horizontal => {
365                    let world_x = bounds.x + local_pos;
366                    Rect::new(
367                        (world_x - DROP_INDICATOR_WIDTH * 0.5).max(bounds.x),
368                        bounds.y,
369                        DROP_INDICATOR_WIDTH,
370                        bounds.height,
371                    )
372                }
373                TabBarOrientation::Vertical => {
374                    let world_y = bounds.y + local_pos;
375                    Rect::new(
376                        bounds.x,
377                        (world_y - DROP_INDICATOR_WIDTH * 0.5).max(bounds.y),
378                        bounds.width,
379                        DROP_INDICATOR_WIDTH,
380                    )
381                }
382            };
383            canvas.fill_rect(indicator, ctx.theme.colors.accent);
384        }
385    }
386
387    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
388        // Presentational only — the parent TabBar carries Role::TabList.
389        builder.set_hidden();
390    }
391}
392
393/// Internal leaf widget that paints the per-state chrome bits at the
394/// edges of the tab's bounds. Not exposed publicly because custom
395/// `TabStyle` impls compose their own body.
396struct TabBodyPainter {
397    is_active: Signal<bool>,
398    is_focused: Signal<bool>,
399    is_disabled: Signal<bool>,
400    orientation: TabBarOrientation,
401    indicator_position: TabIndicatorPosition,
402    recipe: TabRecipe,
403}
404
405impl std::fmt::Debug for TabBodyPainter {
406    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
407        f.debug_struct("TabBodyPainter")
408            .field("orientation", &self.orientation)
409            .field("indicator_position", &self.indicator_position)
410            .finish()
411    }
412}
413
414impl TabBodyPainter {
415    /// The active-tab highlight rect for a tab at `bounds`, given the bar
416    /// orientation, the configured indicator edge, and the layout
417    /// direction (for the RTL-dependent vertical edges).
418    ///
419    /// Horizontal: `OuterEdge` → top, `InnerEdge` → bottom (RTL-invariant).
420    /// Vertical: `OuterEdge` → leading, `InnerEdge` → trailing — leading is
421    /// the left edge in LTR and the right edge in RTL.
422    fn indicator_rect(&self, bounds: Rect, thickness: f32, rtl: bool) -> Rect {
423        match self.orientation {
424            TabBarOrientation::Horizontal => match self.indicator_position {
425                TabIndicatorPosition::OuterEdge => {
426                    Rect::new(bounds.x, bounds.y, bounds.width, thickness)
427                }
428                TabIndicatorPosition::InnerEdge => Rect::new(
429                    bounds.x,
430                    bounds.bottom() - thickness,
431                    bounds.width,
432                    thickness,
433                ),
434            },
435            TabBarOrientation::Vertical => {
436                // Leading edge = left in LTR / right in RTL; trailing is the
437                // opposite. OuterEdge hugs leading, InnerEdge hugs trailing.
438                let on_left = match self.indicator_position {
439                    TabIndicatorPosition::OuterEdge => !rtl,
440                    TabIndicatorPosition::InnerEdge => rtl,
441                };
442                let x = if on_left {
443                    bounds.x
444                } else {
445                    bounds.right() - thickness
446                };
447                Rect::new(x, bounds.y, thickness, bounds.height)
448            }
449        }
450    }
451}
452
453impl Widget for TabBodyPainter {
454    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
455        let id = ctx.self_id();
456        let registry = ctx.binding_registry();
457        self.is_active
458            .bind_to(id, registry, BindingLevel::RepaintOnly);
459        self.is_focused
460            .bind_to(id, registry, BindingLevel::RepaintOnly);
461        self.is_disabled
462            .bind_to(id, registry, BindingLevel::RepaintOnly);
463        vec![]
464    }
465
466    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
467        // Leaf painter — no intrinsic size, so accept whatever the
468        // parent ZStack proposes. ZStack proposes the full tab
469        // bounds, then uses the returned size as the placement. If we
470        // returned ZERO here, the painter would be placed at zero
471        // bounds and both the accent indicator and the focus ring
472        // would never show.
473        proposal.resolve(0.0, 0.0).into()
474    }
475
476    fn place_children(
477        &self,
478        _bounds: Rect,
479        _proposal: SizeProposal,
480        _children: &mut [WidgetPlacement],
481        _ctx: &LayoutContext,
482    ) {
483    }
484
485    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
486        let colors = &ctx.theme.colors;
487        let shape = &ctx.theme.shape;
488        let active = self.is_active.get();
489        let focused = self.is_focused.get();
490        let disabled = self.is_disabled.get();
491
492        // Accent indicator on the selected, enabled tab. The edge is
493        // chosen by `indicator_position` (default `OuterEdge`):
494        //   - Horizontal bar → TOP for OuterEdge (browser-tab look, the
495        //     selected tab "merges" into the content panel below) or
496        //     BOTTOM for InnerEdge (the indicator sits below the label).
497        //   - Vertical bar → LEADING edge for OuterEdge (IDE perspective
498        //     look) or TRAILING for InnerEdge. Leading/trailing follow the
499        //     layout direction.
500        let indicator_thickness = self.recipe.underline_active;
501        if active && !disabled {
502            let rtl = matches!(
503                ctx.layout_direction,
504                teksilo_core::environment::LayoutDirection::RightToLeft
505            );
506            let indicator = self.indicator_rect(bounds, indicator_thickness, rtl);
507            canvas.fill_rect(indicator, colors.accent);
508        }
509
510        // Focus ring — keyboard focus only. Drawn inside `bounds`
511        // (see `focus_ring_rect`) so adjacent tabs aren't visually
512        // overlapped by the ring.
513        if focused {
514            canvas.stroke_rounded_rect(
515                focus_ring_rect(bounds, shape),
516                CornerRadius::uniform(shape.radius_control),
517                colors.focus_ring,
518                shape.focus_ring_width,
519            );
520        }
521    }
522
523    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
524        // The parent TabHeader carries the Role::Tab. This painter is
525        // presentational only.
526        builder.set_hidden();
527    }
528}
529
530/// The centre line of a tab's focus ring: `bounds` inset by
531/// `focus_ring_width / 2 + focus_ring_offset`.
532///
533/// Tabs sit flush against each other, so the ring never leaves `bounds`:
534/// a negative `focus_ring_offset` brings it out to the tab's edge and no
535/// further (the inset is floored at half the stroke), and the rectangle
536/// never inverts on a tab narrower than the inset.
537fn focus_ring_rect(bounds: Rect, shape: &teksilo_tokens::ShapeTokens) -> Rect {
538    let half_stroke = shape.focus_ring_width * 0.5;
539    let inset = (half_stroke + shape.focus_ring_offset).max(half_stroke);
540    FocusRingStroke::at_distance(bounds, 0.0, -inset).rect
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    fn painter(
548        orientation: TabBarOrientation,
549        indicator_position: TabIndicatorPosition,
550    ) -> TabBodyPainter {
551        TabBodyPainter {
552            is_active: Signal::new(true),
553            is_focused: Signal::new(false),
554            is_disabled: Signal::new(false),
555            orientation,
556            indicator_position,
557            recipe: TabRecipe::default(),
558        }
559    }
560
561    // 100×40 tab, 3 dp indicator.
562    const B: Rect = Rect {
563        x: 10.0,
564        y: 20.0,
565        width: 100.0,
566        height: 40.0,
567    };
568    const T: f32 = TAB_UNDERLINE_ACTIVE;
569
570    #[test]
571    fn horizontal_outer_edge_is_top_and_rtl_invariant() {
572        let p = painter(
573            TabBarOrientation::Horizontal,
574            TabIndicatorPosition::OuterEdge,
575        );
576        let expected = Rect::new(B.x, B.y, B.width, T);
577        assert_eq!(p.indicator_rect(B, T, false), expected);
578        assert_eq!(
579            p.indicator_rect(B, T, true),
580            expected,
581            "top edge is RTL-invariant"
582        );
583    }
584
585    #[test]
586    fn horizontal_inner_edge_is_bottom() {
587        let p = painter(
588            TabBarOrientation::Horizontal,
589            TabIndicatorPosition::InnerEdge,
590        );
591        let expected = Rect::new(B.x, B.bottom() - T, B.width, T);
592        assert_eq!(p.indicator_rect(B, T, false), expected);
593        assert_eq!(p.indicator_rect(B, T, true), expected);
594    }
595
596    #[test]
597    fn vertical_outer_edge_is_leading() {
598        let p = painter(TabBarOrientation::Vertical, TabIndicatorPosition::OuterEdge);
599        // LTR leading = left, RTL leading = right.
600        assert_eq!(
601            p.indicator_rect(B, T, false),
602            Rect::new(B.x, B.y, T, B.height)
603        );
604        assert_eq!(
605            p.indicator_rect(B, T, true),
606            Rect::new(B.right() - T, B.y, T, B.height)
607        );
608    }
609
610    #[test]
611    fn vertical_inner_edge_is_trailing() {
612        let p = painter(TabBarOrientation::Vertical, TabIndicatorPosition::InnerEdge);
613        // LTR trailing = right, RTL trailing = left.
614        assert_eq!(
615            p.indicator_rect(B, T, false),
616            Rect::new(B.right() - T, B.y, T, B.height)
617        );
618        assert_eq!(
619            p.indicator_rect(B, T, true),
620            Rect::new(B.x, B.y, T, B.height)
621        );
622    }
623
624    fn shape(offset: f32) -> teksilo_tokens::ShapeTokens {
625        teksilo_tokens::ShapeTokens {
626            focus_ring_offset: offset,
627            focus_ring_width: 2.0,
628            ..teksilo_tokens::ShapeTokens::default()
629        }
630    }
631
632    #[test]
633    fn the_focus_ring_follows_the_offset_inside_the_tab() {
634        // Offset 2: 3 dp in from the tab edge.
635        assert_eq!(
636            focus_ring_rect(B, &shape(2.0)),
637            Rect::new(13.0, 23.0, 94.0, 34.0)
638        );
639        // Offset 0: flush with the tab edge.
640        assert_eq!(
641            focus_ring_rect(B, &shape(0.0)),
642            Rect::new(11.0, 21.0, 98.0, 38.0)
643        );
644    }
645
646    #[test]
647    fn a_negative_focus_ring_offset_keeps_the_ring_inside_the_tab() {
648        for offset in [-1.0, -5.0, -100.0] {
649            let ring = focus_ring_rect(B, &shape(offset));
650            // Outer edge of the 2 dp stroke stays on or inside `bounds`.
651            assert!(
652                ring.x - 1.0 >= B.x && ring.y - 1.0 >= B.y,
653                "{offset}: {ring:?}"
654            );
655            assert!(ring.right() + 1.0 <= B.right(), "{offset}: {ring:?}");
656            assert!(ring.bottom() + 1.0 <= B.bottom(), "{offset}: {ring:?}");
657        }
658    }
659
660    /// The clamp is deliberate: below zero the offset no longer moves a
661    /// tab's ring, which stays flush with the tab edge rather than spilling
662    /// into the neighbouring tab.
663    #[test]
664    fn every_offset_at_or_below_zero_draws_the_same_tab_ring() {
665        let flush = focus_ring_rect(B, &shape(0.0));
666        for offset in [-1.0, -5.0, -100.0] {
667            assert_eq!(focus_ring_rect(B, &shape(offset)), flush, "{offset}");
668        }
669    }
670
671    #[test]
672    fn the_focus_ring_never_inverts_on_a_narrow_tab() {
673        let narrow = Rect::new(0.0, 0.0, 4.0, 3.0);
674        for offset in [-1.0, 2.0, 50.0] {
675            let ring = focus_ring_rect(narrow, &shape(offset));
676            assert!(
677                ring.width >= 0.0 && ring.height >= 0.0,
678                "{offset}: {ring:?}"
679            );
680        }
681    }
682}