Skip to main content

teksilo_widgets/styles/
recipe_segmented_control_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `SegmentedControlStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeSegmentedControlStyle` ports the IntUI segmented-control
7//! chrome: the rounded frame, per-segment hover tint, the
8//! selected-segment surface + border (accent when focused, inactive
9//! when not), a divider before the overflow trigger, and the keyboard
10//! focus ring drawn outside the visual envelope. The recipe builds a
11//! single `SegmentedControlChrome` widget that paints all of this from
12//! the config's state signals and the geometry the widget publishes each
13//! layout pass — repainting only when the bindings flip.
14
15use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
16use teksilo_core::accessibility::AccessNodeBuilder;
17use teksilo_core::binding::BindingLevel;
18use teksilo_core::build_context::BuildContext;
19use teksilo_core::focus::FocusOrigin;
20use teksilo_core::signal::Signal;
21use teksilo_core::styles::{SegmentSlots, SegmentedControlStyle, SegmentedControlStyleConfig};
22use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget};
23use teksilo_core::widget_id::WidgetId;
24use teksilo_tokens::CornerRadius;
25
26// IntUI design tokens for SegmentedControl. The recipe owns its own
27// dimensions.
28pub const SEGMENTED_CONTROL_HEIGHT: f32 = 24.0;
29pub const SEGMENTED_CONTROL_PADDING_HORIZONTAL: f32 = 12.0;
30pub const SEGMENTED_CONTROL_PADDING_VERTICAL: f32 = 6.0;
31pub const SEGMENTED_CONTROL_CORNER_RADIUS: f32 = 3.0;
32pub const SEGMENTED_CONTROL_BORDER_WIDTH: f32 = 1.0;
33
34/// Tuneable dimensions for [`RecipeSegmentedControlStyle`].
35///
36/// All fields default to the corresponding `SEGMENTED_CONTROL_*` consts so
37/// a `RecipeSegmentedControlStyle::default()` is identical to the original
38/// hard-coded behaviour.  Pass a customised `SegmentedControlRecipe` to
39/// `RecipeSegmentedControlStyle::new(recipe)` to override individual dims.
40#[derive(Debug, Clone, Copy, PartialEq)]
41pub struct SegmentedControlRecipe {
42    pub height: f32,
43    pub padding_horizontal: f32,
44    pub padding_vertical: f32,
45    pub corner_radius: f32,
46    pub border_width: f32,
47}
48
49impl Default for SegmentedControlRecipe {
50    fn default() -> Self {
51        Self {
52            height: SEGMENTED_CONTROL_HEIGHT,
53            padding_horizontal: SEGMENTED_CONTROL_PADDING_HORIZONTAL,
54            padding_vertical: SEGMENTED_CONTROL_PADDING_VERTICAL,
55            corner_radius: SEGMENTED_CONTROL_CORNER_RADIUS,
56            border_width: SEGMENTED_CONTROL_BORDER_WIDTH,
57        }
58    }
59}
60
61/// Default `SegmentedControlStyle` shipped with Teksilo.
62#[derive(Debug, Default, Clone, Copy)]
63pub struct RecipeSegmentedControlStyle {
64    pub recipe: SegmentedControlRecipe,
65}
66
67impl RecipeSegmentedControlStyle {
68    pub fn new(recipe: SegmentedControlRecipe) -> Self {
69        Self { recipe }
70    }
71}
72
73impl SegmentedControlStyle for RecipeSegmentedControlStyle {
74    fn make_body(&self, cfg: &SegmentedControlStyleConfig, ctx: &mut BuildContext) -> WidgetId {
75        ctx.add(SegmentedControlChrome {
76            slots: cfg.slots.clone(),
77            selected: cfg.selected.clone(),
78            hovered_segment: cfg.hovered_segment.clone(),
79            focus_origin: cfg.focus_origin.clone(),
80            is_enabled: cfg.is_enabled.clone(),
81            recipe: self.recipe,
82        })
83    }
84}
85
86/// Internal recipe widget that paints the segmented-control chrome
87/// *behind* the segment cells: the rounded frame, per-segment hover
88/// tint, the selected-segment surface + border, the overflow divider,
89/// and the keyboard focus ring. Labels and icons are composed widgets
90/// the `SegmentedControl` places on top — the chrome draws no text or
91/// icons.
92struct SegmentedControlChrome {
93    slots: SegmentSlots,
94    selected: Signal<usize>,
95    hovered_segment: Signal<Option<usize>>,
96    focus_origin: Signal<Option<FocusOrigin>>,
97    /// Reactive — re-paints on arena `enabled_state` flip.
98    is_enabled: Signal<bool>,
99    recipe: SegmentedControlRecipe,
100}
101
102impl std::fmt::Debug for SegmentedControlChrome {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        f.debug_struct("SegmentedControlChrome")
105            .field("slots", &self.slots.len())
106            .finish()
107    }
108}
109
110impl Widget for SegmentedControlChrome {
111    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
112        let id = ctx.self_id();
113        let registry = ctx.binding_registry();
114        // Repaint on any state-signal change. The segment cells own their
115        // own (reactive) labels/icons, so the chrome binds only the
116        // background-state signals. The slot geometry needs no binding:
117        // it is republished during the layout pass that precedes every
118        // paint that could have moved it.
119        self.selected
120            .bind_to(id, registry, BindingLevel::RepaintOnly);
121        self.hovered_segment
122            .bind_to(id, registry, BindingLevel::RepaintOnly);
123        self.focus_origin
124            .bind_to(id, registry, BindingLevel::RepaintOnly);
125        // Also subscribe to is_enabled so a reactive enable/disable
126        // flip via `enabled_when` re-paints the chrome with the
127        // dimmed palette.
128        self.is_enabled
129            .bind_to(id, registry, BindingLevel::RepaintOnly);
130        vec![]
131    }
132
133    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
134        Size::new(
135            proposal.width.unwrap_or(0.0),
136            proposal.height.unwrap_or(0.0),
137        )
138        .into()
139    }
140
141    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
142        let colors = &ctx.theme.colors;
143        let shape = &ctx.theme.shape;
144        let bw = self.recipe.border_width;
145        let frame_cr = CornerRadius::uniform(self.recipe.corner_radius);
146
147        let selected = self.selected.get();
148        let hovered = self.hovered_segment.get();
149        let focus_origin = self.focus_origin.get();
150        let focused = focus_origin.is_some();
151        let keyboard_focused = focus_origin == Some(FocusOrigin::Keyboard);
152        // Snapshot the reactive enabled-state once per paint. The
153        // chrome subscribed to this signal in build() so a flip
154        // re-paints with the new palette.
155        let is_enabled = self.is_enabled.get();
156
157        self.slots.with(|geometry| {
158            if geometry.segments.is_empty() {
159                // Before the first layout pass, or with every segment
160                // hidden. Nothing to frame.
161                return;
162            }
163
164            // 1. Outer frame.
165            let frame_border = if !is_enabled {
166                colors.border
167            } else {
168                colors.border_strong
169            };
170            canvas.stroke_rounded_rect(geometry.frame, frame_cr, frame_border, bw);
171
172            // Resolve the live segment indices carried by the state
173            // signals into slot positions. A segment that overflowed
174            // while hovered resolves to `None` and simply paints nothing.
175            let selected_slot = geometry.order.iter().position(|&s| s == selected);
176            let hovered_slot = hovered.and_then(|h| geometry.order.iter().position(|&s| s == h));
177
178            // 2. Non-selected segments — hover tint only (the cell widget
179            //    draws the label/icon on top).
180            if is_enabled
181                && let Some(slot) = hovered_slot
182                && Some(slot) != selected_slot
183                && let Some(rect) = geometry.segments.get(slot)
184            {
185                canvas.fill_rounded_rect(*rect, frame_cr, colors.surface_hover);
186            }
187
188            // 3. Selected segment — surface + border, extended by `bw` on
189            //    all sides so the stroke covers the frame border AND any
190            //    adjacent hover tint on middle segments. The label/icon is
191            //    drawn by the cell widget; its tint follows this background
192            //    reactively (OnAccent when focused).
193            if let Some(slot) = selected_slot
194                && let Some(base) = geometry.segments.get(slot)
195            {
196                let sel = Rect::new(
197                    base.x - bw,
198                    base.y - bw,
199                    base.width + bw * 2.0,
200                    base.height + bw * 2.0,
201                );
202                let (sel_bg, sel_border) = if !is_enabled {
203                    (colors.surface_selected_inactive, colors.border)
204                } else if focused {
205                    (colors.accent, colors.accent)
206                } else {
207                    (colors.surface_selected_inactive, colors.border_strong)
208                };
209                canvas.fill_rounded_rect(sel, frame_cr, sel_bg);
210                canvas.stroke_rounded_rect(sel, frame_cr, sel_border, bw);
211            }
212
213            // 4. Divider before the overflow trigger, so the chevron reads
214            //    as a slot of the strip rather than a floating button.
215            if let Some(overflow) = geometry.overflow {
216                canvas.fill_rect(
217                    Rect::new(overflow.x, overflow.y, bw, overflow.height),
218                    frame_border,
219                );
220            }
221        });
222
223        // 5. Focus ring — drawn OUTSIDE the visual, inside the reserved
224        //    envelope. Painted whether or not there are segments, so a
225        //    focused empty control still shows where focus is.
226        if keyboard_focused {
227            let half_stroke = shape.focus_ring_width * 0.5;
228            let ring_rect = Rect::new(
229                bounds.x + half_stroke,
230                bounds.y + half_stroke,
231                (bounds.width - half_stroke * 2.0).max(0.0),
232                (bounds.height - half_stroke * 2.0).max(0.0),
233            );
234            let ring_radius = self.recipe.corner_radius + shape.focus_ring_offset + half_stroke;
235            canvas.stroke_rounded_rect(
236                ring_rect,
237                CornerRadius::uniform(ring_radius),
238                colors.focus_ring,
239                shape.focus_ring_width,
240            );
241        }
242    }
243
244    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
245        // Presentational — the parent `SegmentedControl` emits the
246        // `Role::RadioGroup` and the per-segment cells emit
247        // `Role::RadioButton`.
248        builder.set_hidden();
249    }
250}