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::{ToggleStyle, ToggleStyleConfig, ToggleVariant};
28use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
29use teksilo_core::widget_id::WidgetId;
30use teksilo_tokens::{Color, CornerRadius};
31
32// IntUI design tokens for the Toggle chrome — the `Default` source for
33// [`ToggleRecipe`]. Custom design languages either construct a
34// `RecipeToggleStyle::new(ToggleRecipe { .. })` with different dimensions
35// or, for a different *shape*, provide their own `impl ToggleStyle`.
36pub const TOGGLE_TRACK_WIDTH: f32 = 28.0;
37pub const TOGGLE_TRACK_HEIGHT: f32 = 16.0;
38pub const TOGGLE_THUMB_DIAMETER: f32 = 12.0;
39pub const TOGGLE_THUMB_INSET: f32 = 2.0;
40
41/// Tunable dimensions for [`RecipeToggleStyle`]. `Default` yields the
42/// IntUI `TOGGLE_*` constants; a theme can override individual fields.
43#[derive(Debug, Clone, Copy, PartialEq)]
44pub struct ToggleRecipe {
45    pub track_width: f32,
46    pub track_height: f32,
47    pub thumb_diameter: f32,
48    pub thumb_inset: f32,
49}
50
51impl Default for ToggleRecipe {
52    fn default() -> Self {
53        Self {
54            track_width: TOGGLE_TRACK_WIDTH,
55            track_height: TOGGLE_TRACK_HEIGHT,
56            thumb_diameter: TOGGLE_THUMB_DIAMETER,
57            thumb_inset: TOGGLE_THUMB_INSET,
58        }
59    }
60}
61
62/// Default `ToggleStyle` shipped with Teksilo. Reads its dimensions from
63/// a [`ToggleRecipe`] (defaulting to the IntUI `TOGGLE_*` constants) and
64/// its colors from `theme.colors.{accent, surface_sunken, ...}`.
65#[derive(Debug, Default, Clone, Copy)]
66pub struct RecipeToggleStyle {
67    pub recipe: ToggleRecipe,
68}
69
70impl RecipeToggleStyle {
71    /// Construct with custom dimensions.
72    pub fn new(recipe: ToggleRecipe) -> Self {
73        Self { recipe }
74    }
75}
76
77impl ToggleStyle for RecipeToggleStyle {
78    fn make_body(&self, cfg: &ToggleStyleConfig, ctx: &mut BuildContext) -> WidgetId {
79        // Animated knob position — separate signal, registered with
80        // the animation scheduler. Tracks `is_on` via an effect.
81        let initial = if cfg.is_on.get() { 1.0 } else { 0.0 };
82        let knob_position = ctx.animated_signal(initial);
83        let knob_anim = ctx.animate().fast().standard();
84
85        // When `is_on` flips, tween the knob position to the new end.
86        // `to_or_snap` honours `prefers-reduced-motion` (snaps under
87        // that flag instead of tweening).
88        {
89            let knob_position = knob_position.clone();
90            let knob_anim = knob_anim.clone();
91            ctx.effect(&cfg.is_on, move |on| {
92                let target = if *on { 1.0 } else { 0.0 };
93                knob_anim.to_or_snap(&knob_position, target);
94            });
95        }
96
97        // Focus-origin signal, derived from is_focused × is_focus_visible
98        // (`:focus-visible`). Pointer-induced focus skips the focus ring;
99        // keyboard focus shows it. Driven by the live input-modality signal,
100        // so clicking to focus then pressing a key reveals the ring.
101        let focus_origin = cfg
102            .is_focused
103            .zip(&cfg.is_focus_visible)
104            .map(|(focused, visible)| {
105                if *focused {
106                    Some(if *visible {
107                        FocusOrigin::Keyboard
108                    } else {
109                        FocusOrigin::Pointer
110                    })
111                } else {
112                    None
113                }
114            });
115
116        ctx.add(ToggleBody {
117            knob_position,
118            is_disabled: cfg.is_disabled.clone(),
119            focus_origin,
120            variant: cfg.variant,
121            recipe: self.recipe,
122        })
123    }
124}
125
126/// Internal leaf widget that paints the track + knob. Owned by
127/// `RecipeToggleStyle::make_body`; not exposed publicly because custom
128/// `ToggleStyle` impls compose their own body instead.
129struct ToggleBody {
130    knob_position: Signal<f32>,
131    is_disabled: Signal<bool>,
132    focus_origin: Signal<Option<FocusOrigin>>,
133    variant: ToggleVariant,
134    recipe: ToggleRecipe,
135}
136
137impl std::fmt::Debug for ToggleBody {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        f.debug_struct("ToggleBody")
140            .field("variant", &self.variant)
141            .finish()
142    }
143}
144
145impl Widget for ToggleBody {
146    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
147        let id = ctx.self_id();
148        let registry = ctx.binding_registry();
149        self.knob_position
150            .bind_to(id, registry, BindingLevel::RepaintOnly);
151        self.is_disabled
152            .bind_to(id, registry, BindingLevel::RepaintOnly);
153        self.focus_origin
154            .bind_to(id, registry, BindingLevel::RepaintOnly);
155        vec![]
156    }
157
158    fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
159        let row_h = self.recipe.track_height.max(24.0);
160        Size::new(self.recipe.track_width, row_h).into()
161    }
162
163    fn place_children(
164        &self,
165        _bounds: Rect,
166        _proposal: SizeProposal,
167        _children: &mut [WidgetPlacement],
168        _ctx: &LayoutContext,
169    ) {
170    }
171
172    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
173        let colors = &ctx.theme.colors;
174        let track_w = self.recipe.track_width;
175        let track_h = self.recipe.track_height;
176        let knob_size = self.recipe.thumb_diameter;
177        let knob_inset = self.recipe.thumb_inset;
178        let enabled = !self.is_disabled.get();
179
180        // Track is centered in the (possibly larger) hit-area row.
181        let track_x = bounds.x;
182        let track_y = bounds.y + (bounds.height - track_h) / 2.0;
183        let track_rect = Rect::new(track_x, track_y, track_w, track_h);
184
185        // Track background — interpolates surface→accent with the knob
186        // position so the slide reads visually as the toggle "filling".
187        let t = self.knob_position.get();
188        let track_color = if !enabled {
189            colors.accent_disabled
190        } else {
191            let off = colors.surface_sunken;
192            let on = colors.accent;
193            Color::new(
194                teksilo_tokens::lerp(off.r(), on.r(), t),
195                teksilo_tokens::lerp(off.g(), on.g(), t),
196                teksilo_tokens::lerp(off.b(), on.b(), t),
197                teksilo_tokens::lerp(off.a(), on.a(), t),
198            )
199        };
200
201        // Variant-specific corner radius. Switch / Pill use full
202        // pill ends; Square is sharp; Inset is slightly rounded.
203        let track_corner = match self.variant {
204            ToggleVariant::Switch | ToggleVariant::Pill => CornerRadius::uniform(track_h / 2.0),
205            ToggleVariant::Square => CornerRadius::uniform(0.0),
206            ToggleVariant::Inset => CornerRadius::uniform(4.0),
207        };
208        canvas.fill_rounded_rect(track_rect, track_corner, track_color);
209
210        // Focus ring — keyboard-only, drawn outside the track in the
211        // theme-defined gap.
212        if self.focus_origin.get() == Some(FocusOrigin::Keyboard) {
213            let offset = ctx.theme.shape.focus_ring_offset + ctx.theme.shape.focus_ring_width / 2.0;
214            let ring_rect = Rect::new(
215                track_rect.x - offset,
216                track_rect.y - offset,
217                track_rect.width + offset * 2.0,
218                track_rect.height + offset * 2.0,
219            );
220            let ring_corner = match self.variant {
221                ToggleVariant::Switch | ToggleVariant::Pill => {
222                    CornerRadius::uniform(track_h / 2.0 + offset)
223                }
224                ToggleVariant::Square => CornerRadius::uniform(offset),
225                ToggleVariant::Inset => CornerRadius::uniform(4.0 + offset),
226            };
227            canvas.stroke_rounded_rect(
228                ring_rect,
229                ring_corner,
230                colors.focus_ring,
231                ctx.theme.shape.focus_ring_width,
232            );
233        }
234
235        // Knob — only painted for variants that have one.
236        if matches!(self.variant, ToggleVariant::Switch | ToggleVariant::Square) {
237            let min_x = track_x + knob_inset;
238            let max_x = track_x + track_w - knob_size - knob_inset;
239            let knob_x = teksilo_tokens::lerp(min_x, max_x, t.clamp(0.0, 1.0));
240            let knob_y = track_y + (track_h - knob_size) / 2.0;
241            let knob_rect = Rect::new(knob_x, knob_y, knob_size, knob_size);
242            let knob_color = if !enabled {
243                colors.text_disabled
244            } else {
245                Color::WHITE
246            };
247            let knob_corner = match self.variant {
248                ToggleVariant::Switch => CornerRadius::uniform(knob_size / 2.0),
249                ToggleVariant::Square => CornerRadius::uniform(0.0),
250                _ => unreachable!(),
251            };
252            canvas.fill_rounded_rect(knob_rect, knob_corner, knob_color);
253        }
254    }
255
256    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
257        // Accessibility lives on the parent Toggle widget, not the
258        // body. The body is presentational — the AT walker reaches
259        // the Toggle node directly.
260    }
261}