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    TabBarChromeConfig, TabBarOrientation, TabIndicatorPosition, TabStyle, TabStyleConfig,
36};
37use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
38use teksilo_core::widget_id::WidgetId;
39use teksilo_tokens::CornerRadius;
40
41use crate::primitives::{HStack, RectWidget, ZStack};
42
43// IntUI design tokens for Tab. The recipe and parent TabHeader own
44// their own dimensions.
45pub const TAB_EDITOR_HEIGHT: f32 = 50.0;
46pub const TAB_TOOL_WINDOW_HEIGHT: f32 = 28.0;
47pub const TAB_PADDING_HORIZONTAL: f32 = 12.0;
48pub const TAB_UNDERLINE_ACTIVE: f32 = 2.0;
49pub const TAB_UNDERLINE_HOVER: f32 = 2.0;
50pub const TAB_CLOSE_BUTTON_SIZE: f32 = 16.0;
51/// Thickness of the drag-reorder drop-indicator line.
52const DROP_INDICATOR_WIDTH: f32 = 2.0;
53
54/// Dimension recipe for [`RecipeTabStyle`].
55///
56/// All fields default to the corresponding `TAB_*` constants so that
57/// [`RecipeTabStyle::default()`] reproduces the built-in IntUI look.
58#[derive(Debug, Clone, Copy, PartialEq)]
59pub struct TabRecipe {
60    pub editor_height: f32,
61    pub tool_window_height: f32,
62    pub padding_horizontal: f32,
63    pub underline_active: f32,
64    pub underline_hover: f32,
65    pub close_button_size: f32,
66}
67
68impl Default for TabRecipe {
69    fn default() -> Self {
70        Self {
71            editor_height: TAB_EDITOR_HEIGHT,
72            tool_window_height: TAB_TOOL_WINDOW_HEIGHT,
73            padding_horizontal: TAB_PADDING_HORIZONTAL,
74            underline_active: TAB_UNDERLINE_ACTIVE,
75            underline_hover: TAB_UNDERLINE_HOVER,
76            close_button_size: TAB_CLOSE_BUTTON_SIZE,
77        }
78    }
79}
80
81/// Default `TabStyle` shipped with Teksilo.
82#[derive(Debug, Default, Clone, Copy)]
83pub struct RecipeTabStyle {
84    pub recipe: TabRecipe,
85}
86
87impl RecipeTabStyle {
88    pub fn new(recipe: TabRecipe) -> Self {
89        Self { recipe }
90    }
91}
92
93impl TabStyle for RecipeTabStyle {
94    fn make_body(&self, cfg: &TabStyleConfig, ctx: &mut BuildContext) -> WidgetId {
95        // Leaf painter for the chrome bits that live at the bounds
96        // edges: accent indicator and focus ring.
97        let painter = ctx.add(TabBodyPainter {
98            is_active: cfg.is_active.clone(),
99            is_focused: cfg.is_focused.clone(),
100            is_disabled: cfg.is_disabled.clone(),
101            orientation: cfg.orientation,
102            indicator_position: cfg.indicator_position,
103            recipe: self.recipe,
104        });
105
106        // Compose the slots. The widget today bundles everything into
107        // `label` and passes None for leading/trailing — but custom
108        // impls may use the three slots directly, so we honour them.
109        let mut row = HStack::new();
110        if let Some(id) = cfg.leading {
111            row = row.add_child(id);
112        }
113        row = row.add_child(cfg.label);
114        if let Some(id) = cfg.trailing {
115            row = row.add_child(id);
116        }
117        let row_id = ctx.add(row);
118
119        ctx.add(ZStack::new().add_child(painter).add_child(row_id))
120    }
121
122    fn make_bar(&self, cfg: &TabBarChromeConfig, ctx: &mut BuildContext) -> WidgetId {
123        // Bar chrome z-order (back → front): optional backdrop fill,
124        // the separator + drop-indicator leaf painter, then the bar
125        // content. This mirrors the old `TabBar::paint` order, where
126        // the widget painted backdrop/separator/indicator before its
127        // children drew on top.
128        //
129        // A plain `ZStack` won't do here: it sizes itself by querying
130        // children with an *unspecified* proposal, which collapses the
131        // content's inner `Expand` to zero width. `TabBarChrome`
132        // instead sizes to the content child under the *real*
133        // proposal and places every layer at the full bar bounds.
134        let painter = ctx.add(TabBarChromePainter {
135            orientation: cfg.orientation,
136            show_separator: cfg.show_separator,
137            drop_indicator: cfg.drop_indicator.clone(),
138        });
139
140        let mut layers = Vec::with_capacity(3);
141        if let Some(role) = &cfg.surface_role {
142            // A `RectWidget` (not a painted fill in the leaf) so the
143            // backdrop tracks `ColorProp` bindings — static role,
144            // `Signal<Color>`, or `Signal<Role>` — correctly.
145            let backdrop = ctx.add(RectWidget::new().background(role.clone()));
146            layers.push(backdrop);
147        }
148        layers.push(painter);
149        layers.push(cfg.content);
150
151        ctx.add(TabBarChrome {
152            layers,
153            content: cfg.content,
154        })
155    }
156}
157
158/// Bar-chrome container produced by [`RecipeTabStyle::make_bar`].
159/// Stacks the backdrop / chrome-painter / content layers at the full
160/// bar bounds, but — unlike `ZStack` — sizes itself to the content
161/// child under the real layout proposal so the content's inner
162/// `Expand` fills the bar instead of collapsing to zero.
163#[derive(Debug)]
164struct TabBarChrome {
165    /// Back-to-front: `[backdrop?, painter, content]`.
166    layers: Vec<WidgetId>,
167    /// The layer that drives the bar's size.
168    content: WidgetId,
169}
170
171impl Widget for TabBarChrome {
172    fn build(&mut self, _ctx: &mut BuildContext) -> Vec<WidgetId> {
173        self.layers.clone()
174    }
175
176    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
177        ctx.child_size(self.content, proposal)
178            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
179            .into()
180    }
181
182    fn place_children(
183        &self,
184        bounds: Rect,
185        _proposal: SizeProposal,
186        children: &mut [WidgetPlacement],
187        _ctx: &LayoutContext,
188    ) {
189        for child in children.iter_mut() {
190            child.origin = bounds.origin();
191            child.size = bounds.size();
192        }
193    }
194
195    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
196        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
197    }
198
199    fn children(&self) -> Vec<WidgetId> {
200        self.layers.clone()
201    }
202}
203
204/// Internal leaf widget that paints the bar-level chrome at the
205/// strip's bounds: the content-pane separator and the drag-reorder
206/// drop indicator. The backdrop fill is a sibling `RectWidget`, not
207/// painted here, so `ColorProp` bindings resolve correctly.
208struct TabBarChromePainter {
209    orientation: TabBarOrientation,
210    show_separator: bool,
211    drop_indicator: Signal<Option<f32>>,
212}
213
214impl std::fmt::Debug for TabBarChromePainter {
215    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216        f.debug_struct("TabBarChromePainter")
217            .field("orientation", &self.orientation)
218            .field("show_separator", &self.show_separator)
219            .finish()
220    }
221}
222
223impl Widget for TabBarChromePainter {
224    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
225        self.drop_indicator.bind_to(
226            ctx.self_id(),
227            ctx.binding_registry(),
228            BindingLevel::RepaintOnly,
229        );
230        vec![]
231    }
232
233    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
234        // Leaf painter — no intrinsic size, so accept whatever the
235        // parent proposes. ZStack/TabBarChrome propose the full bar
236        // bounds, then use the returned size as the placement. If we
237        // returned ZERO here, the painter would be placed at zero
238        // bounds and the separator + drop indicator would never show.
239        proposal.resolve(0.0, 0.0).into()
240    }
241
242    fn place_children(
243        &self,
244        _bounds: Rect,
245        _proposal: SizeProposal,
246        _children: &mut [WidgetPlacement],
247        _ctx: &LayoutContext,
248    ) {
249    }
250
251    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
252        if self.show_separator {
253            // 1 dp separator: bottom in horizontal mode, trailing
254            // edge (right) in vertical mode. Painted *inside* the
255            // focus-ring envelope reserved by each header so the
256            // selected header's `surface_content` fill overpaints
257            // the separator in its own column (the "tab merges into
258            // content pane" effect).
259            let border_width = ctx.theme.shape.border_width;
260            let envelope = ctx.theme.shape.focus_ring_offset + ctx.theme.shape.focus_ring_width;
261            let separator = match self.orientation {
262                TabBarOrientation::Horizontal => Rect::new(
263                    bounds.x,
264                    (bounds.bottom() - envelope - border_width).max(bounds.y),
265                    bounds.width,
266                    border_width,
267                ),
268                TabBarOrientation::Vertical => Rect::new(
269                    (bounds.right() - envelope - border_width).max(bounds.x),
270                    bounds.y,
271                    border_width,
272                    bounds.height,
273                ),
274            };
275            canvas.fill_rect(separator, ctx.theme.colors.border);
276        }
277
278        // Drop indicator: a vertical accent-color line at the
279        // would-be insertion x in horizontal mode, a horizontal line
280        // at the insertion y in vertical mode. The position is the
281        // layout-axis offset stored in bar-local coords by the bar's
282        // `on_drag_hover` handler.
283        if let Some(local_pos) = self.drop_indicator.get() {
284            let indicator = match self.orientation {
285                TabBarOrientation::Horizontal => {
286                    let world_x = bounds.x + local_pos;
287                    Rect::new(
288                        (world_x - DROP_INDICATOR_WIDTH * 0.5).max(bounds.x),
289                        bounds.y,
290                        DROP_INDICATOR_WIDTH,
291                        bounds.height,
292                    )
293                }
294                TabBarOrientation::Vertical => {
295                    let world_y = bounds.y + local_pos;
296                    Rect::new(
297                        bounds.x,
298                        (world_y - DROP_INDICATOR_WIDTH * 0.5).max(bounds.y),
299                        bounds.width,
300                        DROP_INDICATOR_WIDTH,
301                    )
302                }
303            };
304            canvas.fill_rect(indicator, ctx.theme.colors.accent);
305        }
306    }
307
308    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
309        // Presentational only — the parent TabBar carries Role::TabList.
310        builder.set_hidden();
311    }
312}
313
314/// Internal leaf widget that paints the per-state chrome bits at the
315/// edges of the tab's bounds. Not exposed publicly because custom
316/// `TabStyle` impls compose their own body.
317struct TabBodyPainter {
318    is_active: Signal<bool>,
319    is_focused: Signal<bool>,
320    is_disabled: Signal<bool>,
321    orientation: TabBarOrientation,
322    indicator_position: TabIndicatorPosition,
323    recipe: TabRecipe,
324}
325
326impl std::fmt::Debug for TabBodyPainter {
327    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
328        f.debug_struct("TabBodyPainter")
329            .field("orientation", &self.orientation)
330            .field("indicator_position", &self.indicator_position)
331            .finish()
332    }
333}
334
335impl TabBodyPainter {
336    /// The active-tab highlight rect for a tab at `bounds`, given the bar
337    /// orientation, the configured indicator edge, and the layout
338    /// direction (for the RTL-dependent vertical edges).
339    ///
340    /// Horizontal: `OuterEdge` → top, `InnerEdge` → bottom (RTL-invariant).
341    /// Vertical: `OuterEdge` → leading, `InnerEdge` → trailing — leading is
342    /// the left edge in LTR and the right edge in RTL.
343    fn indicator_rect(&self, bounds: Rect, thickness: f32, rtl: bool) -> Rect {
344        match self.orientation {
345            TabBarOrientation::Horizontal => match self.indicator_position {
346                TabIndicatorPosition::OuterEdge => {
347                    Rect::new(bounds.x, bounds.y, bounds.width, thickness)
348                }
349                TabIndicatorPosition::InnerEdge => Rect::new(
350                    bounds.x,
351                    bounds.bottom() - thickness,
352                    bounds.width,
353                    thickness,
354                ),
355            },
356            TabBarOrientation::Vertical => {
357                // Leading edge = left in LTR / right in RTL; trailing is the
358                // opposite. OuterEdge hugs leading, InnerEdge hugs trailing.
359                let on_left = match self.indicator_position {
360                    TabIndicatorPosition::OuterEdge => !rtl,
361                    TabIndicatorPosition::InnerEdge => rtl,
362                };
363                let x = if on_left {
364                    bounds.x
365                } else {
366                    bounds.right() - thickness
367                };
368                Rect::new(x, bounds.y, thickness, bounds.height)
369            }
370        }
371    }
372}
373
374impl Widget for TabBodyPainter {
375    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
376        let id = ctx.self_id();
377        let registry = ctx.binding_registry();
378        self.is_active
379            .bind_to(id, registry, BindingLevel::RepaintOnly);
380        self.is_focused
381            .bind_to(id, registry, BindingLevel::RepaintOnly);
382        self.is_disabled
383            .bind_to(id, registry, BindingLevel::RepaintOnly);
384        vec![]
385    }
386
387    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
388        // Leaf painter — no intrinsic size, so accept whatever the
389        // parent ZStack proposes. ZStack proposes the full tab
390        // bounds, then uses the returned size as the placement. If we
391        // returned ZERO here, the painter would be placed at zero
392        // bounds and both the accent indicator and the focus ring
393        // would never show.
394        proposal.resolve(0.0, 0.0).into()
395    }
396
397    fn place_children(
398        &self,
399        _bounds: Rect,
400        _proposal: SizeProposal,
401        _children: &mut [WidgetPlacement],
402        _ctx: &LayoutContext,
403    ) {
404    }
405
406    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
407        let colors = &ctx.theme.colors;
408        let shape = &ctx.theme.shape;
409        let active = self.is_active.get();
410        let focused = self.is_focused.get();
411        let disabled = self.is_disabled.get();
412
413        // Accent indicator on the selected, enabled tab. The edge is
414        // chosen by `indicator_position` (default `OuterEdge`):
415        //   - Horizontal bar → TOP for OuterEdge (browser-tab look, the
416        //     selected tab "merges" into the content panel below) or
417        //     BOTTOM for InnerEdge (the indicator sits below the label).
418        //   - Vertical bar → LEADING edge for OuterEdge (IDE perspective
419        //     look) or TRAILING for InnerEdge. Leading/trailing follow the
420        //     layout direction.
421        let indicator_thickness = self.recipe.underline_active;
422        if active && !disabled {
423            let rtl = matches!(
424                ctx.layout_direction,
425                teksilo_core::environment::LayoutDirection::RightToLeft
426            );
427            let indicator = self.indicator_rect(bounds, indicator_thickness, rtl);
428            canvas.fill_rect(indicator, colors.accent);
429        }
430
431        // Focus ring — keyboard focus only. Drawn inside `bounds`
432        // (inset by `focus_ring_width / 2 + focus_ring_offset`) so
433        // adjacent tabs aren't visually overlapped by the ring.
434        if focused {
435            let half_stroke = shape.focus_ring_width * 0.5;
436            let inset = half_stroke + shape.focus_ring_offset;
437            let ring_rect = Rect::new(
438                bounds.x + inset,
439                bounds.y + inset,
440                (bounds.width - inset * 2.0).max(0.0),
441                (bounds.height - inset * 2.0).max(0.0),
442            );
443            canvas.stroke_rounded_rect(
444                ring_rect,
445                CornerRadius::uniform(shape.radius_control),
446                colors.focus_ring,
447                shape.focus_ring_width,
448            );
449        }
450    }
451
452    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
453        // The parent TabHeader carries the Role::Tab. This painter is
454        // presentational only.
455        builder.set_hidden();
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    fn painter(
464        orientation: TabBarOrientation,
465        indicator_position: TabIndicatorPosition,
466    ) -> TabBodyPainter {
467        TabBodyPainter {
468            is_active: Signal::new(true),
469            is_focused: Signal::new(false),
470            is_disabled: Signal::new(false),
471            orientation,
472            indicator_position,
473            recipe: TabRecipe::default(),
474        }
475    }
476
477    // 100×40 tab, 3 dp indicator.
478    const B: Rect = Rect {
479        x: 10.0,
480        y: 20.0,
481        width: 100.0,
482        height: 40.0,
483    };
484    const T: f32 = TAB_UNDERLINE_ACTIVE;
485
486    #[test]
487    fn horizontal_outer_edge_is_top_and_rtl_invariant() {
488        let p = painter(
489            TabBarOrientation::Horizontal,
490            TabIndicatorPosition::OuterEdge,
491        );
492        let expected = Rect::new(B.x, B.y, B.width, T);
493        assert_eq!(p.indicator_rect(B, T, false), expected);
494        assert_eq!(
495            p.indicator_rect(B, T, true),
496            expected,
497            "top edge is RTL-invariant"
498        );
499    }
500
501    #[test]
502    fn horizontal_inner_edge_is_bottom() {
503        let p = painter(
504            TabBarOrientation::Horizontal,
505            TabIndicatorPosition::InnerEdge,
506        );
507        let expected = Rect::new(B.x, B.bottom() - T, B.width, T);
508        assert_eq!(p.indicator_rect(B, T, false), expected);
509        assert_eq!(p.indicator_rect(B, T, true), expected);
510    }
511
512    #[test]
513    fn vertical_outer_edge_is_leading() {
514        let p = painter(TabBarOrientation::Vertical, TabIndicatorPosition::OuterEdge);
515        // LTR leading = left, RTL leading = right.
516        assert_eq!(
517            p.indicator_rect(B, T, false),
518            Rect::new(B.x, B.y, T, B.height)
519        );
520        assert_eq!(
521            p.indicator_rect(B, T, true),
522            Rect::new(B.right() - T, B.y, T, B.height)
523        );
524    }
525
526    #[test]
527    fn vertical_inner_edge_is_trailing() {
528        let p = painter(TabBarOrientation::Vertical, TabIndicatorPosition::InnerEdge);
529        // LTR trailing = right, RTL trailing = left.
530        assert_eq!(
531            p.indicator_rect(B, T, false),
532            Rect::new(B.right() - T, B.y, T, B.height)
533        );
534        assert_eq!(
535            p.indicator_rect(B, T, true),
536            Rect::new(B.x, B.y, T, B.height)
537        );
538    }
539}