Skip to main content

teksilo_widgets/styles/
recipe_toggle_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `ToggleStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeToggleStyle` ships the IntUI look out of the box; apps that
7//! want a different design language (Material 3 switch, neumorphic
8//! toggle, Cupertino) write their own `impl ToggleStyle` block and
9//! install it per-call (`Toggle::style(...)`) or theme-wide
10//! (`theme.style_slots.toggle = Some(Rc::new(MyToggle))`).
11//!
12//! The visual body is a tiny custom leaf widget (`ToggleBody`) that
13//! paints track + knob on the canvas. We could compose
14//! `RectWidget(track) | Position(knob_x, RectWidget(knob))` instead,
15//! but no general-purpose absolute-positioning primitive exists today
16//! and the compositional version isn't free either — it adds one
17//! layout pass and two arena nodes per Toggle. The leaf body keeps
18//! parity with the paint-cost of the pre-refactor Toggle. Custom
19//! `ToggleStyle` impls are free to compose if they prefer.
20
21use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
22use teksilo_core::accessibility::AccessNodeBuilder;
23use teksilo_core::binding::BindingLevel;
24use teksilo_core::build_context::BuildContext;
25use teksilo_core::focus::FocusOrigin;
26use teksilo_core::signal::Signal;
27use teksilo_core::styles::density::spacing;
28use teksilo_core::styles::{FocusRingStroke, ToggleStyle, ToggleStyleConfig, ToggleVariant};
29use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
30use teksilo_core::widget_id::WidgetId;
31use teksilo_tokens::{Color, CornerRadius, InputTokens, ShapeTokens};
32
33// IntUI design tokens for the Toggle chrome — the `Default` source for
34// [`ToggleRecipe`]. Custom design languages either construct a
35// `RecipeToggleStyle::new(ToggleRecipe { .. })` with different dimensions
36// or, for a different *shape*, provide their own `impl ToggleStyle`.
37pub const TOGGLE_TRACK_WIDTH: f32 = 28.0;
38pub const TOGGLE_TRACK_HEIGHT: f32 = 16.0;
39pub const TOGGLE_THUMB_DIAMETER: f32 = 12.0;
40pub const TOGGLE_THUMB_INSET: f32 = 2.0;
41
42/// Tunable dimensions for [`RecipeToggleStyle`]. `Default` yields the
43/// IntUI `TOGGLE_*` constants; a theme can override individual fields.
44#[derive(Debug, Clone, Copy, PartialEq)]
45pub struct ToggleRecipe {
46    pub track_width: f32,
47    pub track_height: f32,
48    pub thumb_diameter: f32,
49    pub thumb_inset: f32,
50}
51
52impl ToggleRecipe {
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            track_width: TOGGLE_TRACK_WIDTH,
61            track_height: TOGGLE_TRACK_HEIGHT,
62            thumb_diameter: TOGGLE_THUMB_DIAMETER,
63            thumb_inset: spacing(TOGGLE_THUMB_INSET, tokens),
64        }
65    }
66}
67
68impl Default for ToggleRecipe {
69    fn default() -> Self {
70        Self::for_tokens(&InputTokens::default())
71    }
72}
73
74/// Default `ToggleStyle` shipped with Teksilo. Reads its dimensions from
75/// a [`ToggleRecipe`] (defaulting to the IntUI `TOGGLE_*` constants) and
76/// its colors from `theme.colors.{accent, surface_sunken, ...}`.
77#[derive(Debug, Default, Clone, Copy)]
78pub struct RecipeToggleStyle {
79    pub recipe: ToggleRecipe,
80}
81
82impl RecipeToggleStyle {
83    /// Construct with custom dimensions.
84    pub fn new(recipe: ToggleRecipe) -> Self {
85        Self { recipe }
86    }
87
88    /// This style with every dimension resolved against a density's
89    /// [`InputTokens`], as `RecipeToggleStyle::for_tokens(&ctx.theme().input)` at
90    /// the widget's own build site.
91    ///
92    /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
93    /// tree gets exactly the values this module documents.
94    pub fn for_tokens(tokens: &InputTokens) -> Self {
95        Self {
96            recipe: ToggleRecipe::for_tokens(tokens),
97        }
98    }
99}
100
101impl ToggleStyle for RecipeToggleStyle {
102    fn make_body(&self, cfg: &ToggleStyleConfig, ctx: &mut BuildContext) -> WidgetId {
103        // Animated knob position — separate signal, registered with
104        // the animation scheduler. Tracks `is_on` via an effect.
105        let initial = if cfg.is_on.get() { 1.0 } else { 0.0 };
106        let knob_position = ctx.animated_signal(initial);
107        let knob_anim = ctx.animate().fast().standard();
108
109        // When `is_on` flips, tween the knob position to the new end.
110        // `to_or_snap` honours `prefers-reduced-motion` (snaps under
111        // that flag instead of tweening).
112        {
113            let knob_position = knob_position.clone();
114            let knob_anim = knob_anim.clone();
115            ctx.effect(&cfg.is_on, move |on| {
116                let target = if *on { 1.0 } else { 0.0 };
117                knob_anim.to_or_snap(&knob_position, target);
118            });
119        }
120
121        // Focus-origin signal, derived from is_focused × is_focus_visible
122        // (`:focus-visible`). Pointer-induced focus skips the focus ring;
123        // keyboard focus shows it. Driven by the live input-modality signal,
124        // so clicking to focus then pressing a key reveals the ring.
125        let focus_origin = cfg
126            .is_focused
127            .zip(&cfg.is_focus_visible)
128            .map(|(focused, visible)| {
129                if *focused {
130                    Some(if *visible {
131                        FocusOrigin::Keyboard
132                    } else {
133                        FocusOrigin::POINTER
134                    })
135                } else {
136                    None
137                }
138            });
139
140        ctx.add(ToggleBody {
141            knob_position,
142            is_disabled: cfg.is_disabled.clone(),
143            focus_origin,
144            variant: cfg.variant,
145            recipe: self.recipe,
146        })
147    }
148}
149
150/// Internal leaf widget that paints the track + knob. Owned by
151/// `RecipeToggleStyle::make_body`; not exposed publicly because custom
152/// `ToggleStyle` impls compose their own body instead.
153struct ToggleBody {
154    knob_position: Signal<f32>,
155    is_disabled: Signal<bool>,
156    focus_origin: Signal<Option<FocusOrigin>>,
157    variant: ToggleVariant,
158    recipe: ToggleRecipe,
159}
160
161impl std::fmt::Debug for ToggleBody {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        f.debug_struct("ToggleBody")
164            .field("variant", &self.variant)
165            .finish()
166    }
167}
168
169impl Widget for ToggleBody {
170    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
171        let id = ctx.self_id();
172        let registry = ctx.binding_registry();
173        self.knob_position
174            .bind_to(id, registry, BindingLevel::RepaintOnly);
175        self.is_disabled
176            .bind_to(id, registry, BindingLevel::RepaintOnly);
177        self.focus_origin
178            .bind_to(id, registry, BindingLevel::RepaintOnly);
179        vec![]
180    }
181
182    fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
183        let row_h = self.recipe.track_height.max(24.0);
184        Size::new(self.recipe.track_width, row_h).into()
185    }
186
187    fn place_children(
188        &self,
189        _bounds: Rect,
190        _proposal: SizeProposal,
191        _children: &mut [WidgetPlacement],
192        _ctx: &LayoutContext,
193    ) {
194    }
195
196    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
197        let colors = &ctx.theme.colors;
198        let track_w = self.recipe.track_width;
199        let track_h = self.recipe.track_height;
200        let knob_size = self.recipe.thumb_diameter;
201        let knob_inset = self.recipe.thumb_inset;
202        let enabled = !self.is_disabled.get();
203
204        // Track is centered in the (possibly larger) hit-area row.
205        let track_x = bounds.x;
206        let track_y = bounds.y + (bounds.height - track_h) / 2.0;
207        let track_rect = Rect::new(track_x, track_y, track_w, track_h);
208
209        // Track background — interpolates surface→accent with the knob
210        // position so the slide reads visually as the toggle "filling".
211        let t = self.knob_position.get();
212        let track_color = if !enabled {
213            colors.accent_disabled
214        } else {
215            let off = colors.surface_sunken;
216            let on = colors.accent;
217            Color::new(
218                teksilo_tokens::lerp(off.r(), on.r(), t),
219                teksilo_tokens::lerp(off.g(), on.g(), t),
220                teksilo_tokens::lerp(off.b(), on.b(), t),
221                teksilo_tokens::lerp(off.a(), on.a(), t),
222            )
223        };
224
225        // Variant-specific corner radius. Switch / Pill use full
226        // pill ends; Square is sharp; Inset is slightly rounded.
227        let track_corner = match self.variant {
228            ToggleVariant::Switch | ToggleVariant::Pill => CornerRadius::uniform(track_h / 2.0),
229            ToggleVariant::Square => CornerRadius::uniform(0.0),
230            ToggleVariant::Inset => CornerRadius::uniform(4.0),
231        };
232        canvas.fill_rounded_rect(track_rect, track_corner, track_color);
233
234        // Focus ring — keyboard-only, drawn outside the track in the
235        // theme-defined gap.
236        if self.focus_origin.get() == Some(FocusOrigin::Keyboard) {
237            let ring = focus_ring_stroke(self.variant, track_rect, &ctx.theme.shape);
238            canvas.stroke_rounded_rect(
239                ring.rect,
240                CornerRadius::uniform(ring.corner_radius),
241                colors.focus_ring,
242                ctx.theme.shape.focus_ring_width,
243            );
244        }
245
246        // Knob — only painted for variants that have one.
247        if matches!(self.variant, ToggleVariant::Switch | ToggleVariant::Square) {
248            let min_x = track_x + knob_inset;
249            let max_x = track_x + track_w - knob_size - knob_inset;
250            let knob_x = teksilo_tokens::lerp(min_x, max_x, t.clamp(0.0, 1.0));
251            let knob_y = track_y + (track_h - knob_size) / 2.0;
252            let knob_rect = Rect::new(knob_x, knob_y, knob_size, knob_size);
253            let knob_color = if !enabled {
254                colors.text_disabled
255            } else {
256                Color::WHITE
257            };
258            let knob_corner = match self.variant {
259                ToggleVariant::Switch => CornerRadius::uniform(knob_size / 2.0),
260                ToggleVariant::Square => CornerRadius::uniform(0.0),
261                _ => unreachable!(),
262            };
263            canvas.fill_rounded_rect(knob_rect, knob_corner, knob_color);
264        }
265    }
266
267    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
268        // Accessibility lives on the parent Toggle widget, not the
269        // body. The body is presentational — the AT walker reaches
270        // the Toggle node directly.
271    }
272}
273
274/// The keyboard focus ring's centre line around the track, hugging the
275/// variant's corner shape. A negative `focus_ring_offset` draws it partly
276/// inside the track.
277fn focus_ring_stroke(
278    variant: ToggleVariant,
279    track_rect: Rect,
280    shape: &ShapeTokens,
281) -> FocusRingStroke {
282    let track_radius = match variant {
283        ToggleVariant::Switch | ToggleVariant::Pill => track_rect.height / 2.0,
284        ToggleVariant::Square => 0.0,
285        ToggleVariant::Inset => 4.0,
286    };
287    FocusRingStroke::around(track_rect, track_radius, shape)
288}
289
290#[cfg(test)]
291mod focus_ring_tests {
292    use super::*;
293
294    fn shape(offset: f32) -> ShapeTokens {
295        ShapeTokens {
296            focus_ring_offset: offset,
297            focus_ring_width: 2.0,
298            ..ShapeTokens::default()
299        }
300    }
301
302    #[test]
303    fn a_negative_focus_ring_offset_shrinks_the_ring_without_inverting() {
304        let track = Rect::new(0.0, 0.0, 40.0, 20.0);
305        for variant in [
306            ToggleVariant::Switch,
307            ToggleVariant::Pill,
308            ToggleVariant::Square,
309            ToggleVariant::Inset,
310        ] {
311            let outer = focus_ring_stroke(variant, track, &shape(2.0));
312            let inner = focus_ring_stroke(variant, track, &shape(-1.0));
313            assert!(inner.rect.width < outer.rect.width, "{variant:?}");
314            assert!(inner.rect.height < outer.rect.height, "{variant:?}");
315            assert!(inner.rect.width >= 0.0 && inner.rect.height >= 0.0);
316            // The square variant's ring at offset -1 is centred on the
317            // track edge, so its corners stay sharp instead of going
318            // negative.
319            assert!(inner.corner_radius >= 0.0, "{variant:?}");
320
321            let deep = focus_ring_stroke(variant, track, &shape(-30.0));
322            assert!(deep.rect.width >= 0.0 && deep.rect.height >= 0.0);
323            assert!(deep.corner_radius >= 0.0);
324        }
325    }
326}