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