Skip to main content

azul_layout/widgets/
switch.rs

1//! Switch (toggle) widget — a boolean on/off control rendered as a rounded,
2//! pill-shaped "track" with a sliding circular "knob". A near-clone of
3//! [`crate::widgets::check_box::CheckBox`] (boolean state + an `on_toggle`
4//! callback) restyled as a switch: toggling flips the knob's horizontal
5//! position (via `margin-left`) and the track's background colour.
6//!
7//! Key types: [`Switch`], [`SwitchState`], [`SwitchOnToggle`].
8
9use azul_core::{
10    callbacks::{CoreCallbackData, Update},
11    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
12    refany::RefAny,
13};
14use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
15use azul_css::{
16    props::{
17        basic::{color::ColorU, *},
18        layout::{LayoutDisplay, LayoutFlexDirection, LayoutAlignItems, LayoutAlignSelf, LayoutFlexGrow, LayoutWidth, LayoutHeight, LayoutPaddingLeft, LayoutPaddingRight, LayoutPaddingTop, LayoutPaddingBottom, LayoutMarginLeft},
19        property::{CssProperty, *},
20        style::{StyleBackgroundContent, StyleBackgroundContentVec, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius, StyleCursor},
21    },
22    impl_option_inner, AzString,
23};
24
25use crate::callbacks::{Callback, CallbackInfo};
26
27static SWITCH_TRACK_CLASS: &[IdOrClass] =
28    &[Class(AzString::from_const_str("__azul-native-switch"))];
29static SWITCH_KNOB_CLASS: &[IdOrClass] =
30    &[Class(AzString::from_const_str("__azul-native-switch-knob"))];
31
32/// Callback function type invoked when the switch is toggled.
33pub type SwitchOnToggleCallbackType = extern "C" fn(RefAny, CallbackInfo, SwitchState) -> Update;
34impl_widget_callback!(
35    SwitchOnToggle,
36    OptionSwitchOnToggle,
37    SwitchOnToggleCallback,
38    SwitchOnToggleCallbackType
39);
40
41azul_core::impl_managed_callback! {
42    wrapper:        SwitchOnToggleCallback,
43    info_ty:        CallbackInfo,
44    return_ty:      Update,
45    default_ret:    Update::DoNothing,
46    invoker_static: SWITCH_ON_TOGGLE_INVOKER,
47    invoker_ty:     AzSwitchOnToggleCallbackInvoker,
48    thunk_fn:       az_switch_on_toggle_callback_thunk,
49    setter_fn:      AzApp_setSwitchOnToggleCallbackInvoker,
50    from_handle_fn: AzSwitchOnToggleCallback_createFromHostHandle,
51    extra_args:     [ state: SwitchState ],
52}
53
54/// A toggleable on/off switch widget with a sliding knob and toggle callback.
55#[derive(Debug, Clone, PartialEq, Eq)]
56#[repr(C)]
57pub struct Switch {
58    pub switch_state: SwitchStateWrapper,
59    /// Style for the switch track (the pill-shaped container)
60    pub track_style: CssPropertyWithConditionsVec,
61    /// Style for the sliding knob
62    pub knob_style: CssPropertyWithConditionsVec,
63}
64
65#[derive(Debug, Default, Clone, PartialEq, Eq)]
66#[repr(C)]
67pub struct SwitchStateWrapper {
68    /// On/off state of this Switch
69    pub inner: SwitchState,
70    /// Optional: function to call when the Switch is toggled
71    pub on_toggle: OptionSwitchOnToggle,
72}
73
74/// The on/off state of a [`Switch`].
75#[derive(Copy, Debug, Default, Clone, PartialEq, Eq)]
76#[repr(C)]
77pub struct SwitchState {
78    /// `true` = on (knob slid right), `false` = off (knob at left)
79    pub checked: bool,
80}
81
82// ---- dimensions ----
83const TRACK_WIDTH: isize = 36;
84const TRACK_HEIGHT: isize = 20;
85const TRACK_PADDING: isize = 2;
86const TRACK_RADIUS: isize = 10;
87const KNOB_SIZE: isize = 16;
88const KNOB_RADIUS: isize = 8;
89/// Horizontal travel of the knob = `track_width` − 2·padding − `knob_size`.
90const KNOB_TRAVEL: isize = TRACK_WIDTH - (2 * TRACK_PADDING) - KNOB_SIZE;
91
92// ---- colours ----
93const TRACK_OFF_COLOR: ColorU = ColorU {
94    r: 204,
95    g: 204,
96    b: 204,
97    a: 255,
98}; // #cccccc
99const TRACK_ON_COLOR: ColorU = ColorU {
100    r: 76,
101    g: 217,
102    b: 100,
103    a: 255,
104}; // #4cd964
105const KNOB_COLOR: ColorU = ColorU {
106    r: 255,
107    g: 255,
108    b: 255,
109    a: 255,
110}; // white
111
112const TRACK_OFF_BG_ITEMS: &[StyleBackgroundContent] =
113    &[StyleBackgroundContent::Color(TRACK_OFF_COLOR)];
114const TRACK_OFF_BG: StyleBackgroundContentVec =
115    StyleBackgroundContentVec::from_const_slice(TRACK_OFF_BG_ITEMS);
116const TRACK_ON_BG_ITEMS: &[StyleBackgroundContent] =
117    &[StyleBackgroundContent::Color(TRACK_ON_COLOR)];
118const TRACK_ON_BG: StyleBackgroundContentVec =
119    StyleBackgroundContentVec::from_const_slice(TRACK_ON_BG_ITEMS);
120const KNOB_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(KNOB_COLOR)];
121const KNOB_BG: StyleBackgroundContentVec =
122    StyleBackgroundContentVec::from_const_slice(KNOB_BG_ITEMS);
123
124/// Build the track (pill container) style. Background colour is the only
125/// state-dependent property, so the style is built at runtime per the recipe's
126/// "runtime vec if param-dependent" path.
127fn build_track_style(checked: bool) -> CssPropertyWithConditionsVec {
128    let bg = if checked { TRACK_ON_BG } else { TRACK_OFF_BG };
129    CssPropertyWithConditionsVec::from_vec(alloc::vec![
130        CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
131        CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
132            LayoutFlexDirection::Row,
133        )),
134        CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
135        CssPropertyWithConditions::simple(CssProperty::align_self(LayoutAlignSelf::Center)),
136        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(
137            0,
138        ))),
139        CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(
140            TRACK_WIDTH,
141        ))),
142        CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(
143            TRACK_HEIGHT,
144        ))),
145        CssPropertyWithConditions::simple(CssProperty::const_padding_left(
146            LayoutPaddingLeft::const_px(TRACK_PADDING),
147        )),
148        CssPropertyWithConditions::simple(CssProperty::const_padding_right(
149            LayoutPaddingRight::const_px(TRACK_PADDING),
150        )),
151        CssPropertyWithConditions::simple(CssProperty::const_padding_top(
152            LayoutPaddingTop::const_px(TRACK_PADDING),
153        )),
154        CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
155            LayoutPaddingBottom::const_px(TRACK_PADDING),
156        )),
157        CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
158            StyleBorderTopLeftRadius::const_px(TRACK_RADIUS),
159        )),
160        CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
161            StyleBorderTopRightRadius::const_px(TRACK_RADIUS),
162        )),
163        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
164            StyleBorderBottomLeftRadius::const_px(TRACK_RADIUS),
165        )),
166        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
167            StyleBorderBottomRightRadius::const_px(TRACK_RADIUS),
168        )),
169        CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
170        CssPropertyWithConditions::simple(CssProperty::const_background_content(bg)),
171    ])
172}
173
174/// Build the knob style. The knob's `margin-left` is the state-dependent
175/// property that slides it between the off (left) and on (right) positions.
176fn build_knob_style(checked: bool) -> CssPropertyWithConditionsVec {
177    let margin = if checked { KNOB_TRAVEL } else { 0 };
178    CssPropertyWithConditionsVec::from_vec(alloc::vec![
179        CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(
180            KNOB_SIZE,
181        ))),
182        CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(
183            KNOB_SIZE,
184        ))),
185        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(
186            0,
187        ))),
188        CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
189            StyleBorderTopLeftRadius::const_px(KNOB_RADIUS),
190        )),
191        CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
192            StyleBorderTopRightRadius::const_px(KNOB_RADIUS),
193        )),
194        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
195            StyleBorderBottomLeftRadius::const_px(KNOB_RADIUS),
196        )),
197        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
198            StyleBorderBottomRightRadius::const_px(KNOB_RADIUS),
199        )),
200        CssPropertyWithConditions::simple(CssProperty::const_background_content(KNOB_BG)),
201        CssPropertyWithConditions::simple(CssProperty::const_margin_left(
202            LayoutMarginLeft::const_px(margin),
203        )),
204    ])
205}
206
207impl Switch {
208    /// Creates a new switch in the given on/off state with default styling.
209    #[must_use] pub fn create(checked: bool) -> Self {
210        Self {
211            switch_state: SwitchStateWrapper {
212                inner: SwitchState { checked },
213                ..Default::default()
214            },
215            track_style: build_track_style(checked),
216            knob_style: build_knob_style(checked),
217        }
218    }
219
220    #[inline]
221    #[must_use] pub fn swap_with_default(&mut self) -> Self {
222        let mut s = Self::create(false);
223        core::mem::swap(&mut s, self);
224        s
225    }
226
227    #[inline]
228    pub fn set_on_toggle<C: Into<SwitchOnToggleCallback>>(&mut self, data: RefAny, on_toggle: C) {
229        self.switch_state.on_toggle = Some(SwitchOnToggle {
230            callback: on_toggle.into(),
231            refany: data,
232        })
233        .into();
234    }
235
236    #[inline]
237    #[must_use] pub fn with_on_toggle<C: Into<SwitchOnToggleCallback>>(
238        mut self,
239        data: RefAny,
240        on_toggle: C,
241    ) -> Self {
242        self.set_on_toggle(data, on_toggle);
243        self
244    }
245
246    #[inline]
247    #[must_use] pub fn dom(self) -> Dom {
248        use azul_core::{
249            callbacks::{CoreCallback, CoreCallbackData},
250            dom::{Dom, EventFilter, HoverEventFilter},
251        };
252
253        Dom::create_div()
254            .with_ids_and_classes(IdOrClassVec::from(SWITCH_TRACK_CLASS))
255            .with_css_props(self.track_style)
256            .with_callbacks(
257                vec![CoreCallbackData {
258                    event: EventFilter::Hover(HoverEventFilter::MouseUp),
259                    callback: CoreCallback {
260                        cb: input::default_on_switch_clicked as usize,
261                        ctx: azul_core::refany::OptionRefAny::None,
262                    },
263                    refany: RefAny::new(self.switch_state),
264                }]
265                .into(),
266            )
267            .with_tab_index(TabIndex::Auto)
268            .with_children(
269                vec![Dom::create_div()
270                    .with_ids_and_classes(IdOrClassVec::from(SWITCH_KNOB_CLASS))
271                    .with_css_props(self.knob_style)]
272                .into(),
273            )
274    }
275}
276
277impl Default for Switch {
278    fn default() -> Self {
279        Self::create(false)
280    }
281}
282
283// handle input events for the switch
284mod input {
285
286    use azul_core::{callbacks::Update, refany::RefAny};
287    use azul_css::props::{layout::LayoutMarginLeft, property::CssProperty};
288
289    use super::{SwitchOnToggle, SwitchStateWrapper, KNOB_TRAVEL, TRACK_OFF_BG, TRACK_ON_BG};
290    use crate::callbacks::CallbackInfo;
291
292    pub(super) extern "C" fn default_on_switch_clicked(
293        mut switch: RefAny,
294        mut info: CallbackInfo,
295    ) -> Update {
296        let Some(mut switch) = switch.downcast_mut::<SwitchStateWrapper>() else {
297            return Update::DoNothing;
298        };
299
300        let track_id = info.get_hit_node();
301        let Some(knob_id) = info.get_first_child(track_id) else {
302            return Update::DoNothing;
303        };
304
305        switch.inner.checked = !switch.inner.checked;
306
307        let result = {
308            // rustc doesn't understand the borrowing lifetime here
309            let switch = &mut *switch;
310            let on_toggle = &mut switch.on_toggle;
311            let inner = switch.inner;
312
313            match on_toggle.as_mut() {
314                Some(SwitchOnToggle {
315                    callback,
316                    refany: data,
317                }) => (callback.cb)(data.clone(), info, inner),
318                None => Update::DoNothing,
319            }
320        };
321
322        // CallbackInfo is Copy, so `info` is still usable after the call above.
323        if switch.inner.checked {
324            info.set_css_property(track_id, CssProperty::const_background_content(TRACK_ON_BG));
325            info.set_css_property(
326                knob_id,
327                CssProperty::const_margin_left(LayoutMarginLeft::const_px(KNOB_TRAVEL)),
328            );
329        } else {
330            info.set_css_property(track_id, CssProperty::const_background_content(TRACK_OFF_BG));
331            info.set_css_property(
332                knob_id,
333                CssProperty::const_margin_left(LayoutMarginLeft::const_px(0)),
334            );
335        }
336
337        result
338    }
339}
340
341impl From<Switch> for Dom {
342    fn from(s: Switch) -> Self {
343        s.dom()
344    }
345}
346
347#[cfg(test)]
348#[allow(clippy::float_cmp)] // every float here is an exact, integral px constant
349// `assertions_on_constants`: these are deliberate invariant guards over sibling
350// `const`s in this module. They are const-foldable *today*, which is exactly the
351// point — they must go red the moment someone edits one of those constants into an
352// inconsistent value. Deleting them (clippy's suggestion) would delete the check.
353#[allow(clippy::assertions_on_constants)]
354mod autotest_generated {
355    use std::{
356        collections::{BTreeMap, HashMap},
357        mem::discriminant,
358        sync::{Arc, Mutex},
359    };
360
361    use azul_core::{
362        dom::{DomId, DomNodeId, EventFilter, HoverEventFilter, NodeId, NodeType},
363        geom::{LogicalRect, OptionLogicalPosition},
364        gl::OptionGlContextPtr,
365        hit_test::ScrollPosition,
366        refany::OptionRefAny,
367        resources::RendererResources,
368        styled_dom::{NodeHierarchyItemId, StyledDom},
369        window::{MonitorVec, RawWindowHandle},
370    };
371    use azul_css::props::basic::{length::SizeMetric, pixel::PixelValue};
372    use rust_fontconfig::FcFontCache;
373
374    use super::*;
375    #[cfg(feature = "icu")]
376    use crate::icu::IcuLocalizerHandle;
377    use crate::{
378        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
379        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
380        window::{DomLayoutResult, LayoutWindow},
381        window_state::FullWindowState,
382    };
383
384    // ------------------------------------------------------------------
385    // Geometry, spelled out independently of the module's own constants
386    // ------------------------------------------------------------------
387    //
388    // These literals are deliberately *not* derived from `TRACK_WIDTH` & friends:
389    // they are the numbers a designer signed off on. If a constant upstream drifts,
390    // the relation tests below fail instead of silently re-deriving themselves.
391
392    const TRACK_W: f32 = 36.0;
393    const TRACK_H: f32 = 20.0;
394    const PAD: f32 = 2.0;
395    const TRACK_R: f32 = 10.0;
396    const KNOB: f32 = 16.0;
397    const KNOB_R: f32 = 8.0;
398    /// `36 - 2*2 - 16`
399    const TRAVEL: f32 = 16.0;
400
401    /// Flattened node ids of `Switch::dom()` (pre-order).
402    const TRACK: usize = 0;
403    const KNOB_NODE: usize = 1;
404
405    // ------------------------------------------------------------------
406    // Callback harness
407    // ------------------------------------------------------------------
408
409    /// A `DomNodeId` in the root DOM pointing at flattened node `idx`.
410    fn node(idx: usize) -> DomNodeId {
411        DomNodeId {
412            dom: DomId::ROOT_ID,
413            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(idx))),
414        }
415    }
416
417    /// A `DomNodeId` whose node component is `None` — the "no concrete node was hit"
418    /// case. `CallbackInfo::set_css_property` *panics* on such an id, so the handler
419    /// must bail out before ever reaching it.
420    fn node_none() -> DomNodeId {
421        DomNodeId {
422            dom: DomId::ROOT_ID,
423            node: NodeHierarchyItemId::NONE,
424        }
425    }
426
427    /// A `DomLayoutResult` carrying only a `styled_dom`: the switch handler reaches
428    /// exactly two `CallbackInfo` queries (`get_hit_node`, `get_first_child`), and both
429    /// read the node hierarchy only — no real layout (and no font) is needed.
430    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
431        DomLayoutResult {
432            styled_dom,
433            layout_tree: LayoutTree {
434                nodes: Vec::new(),
435                warm: Vec::new(),
436                cold: Vec::new(),
437                root: 0,
438                dom_to_layout: BTreeMap::new(),
439                children_arena: Vec::new(),
440                children_offsets: Vec::new(),
441                subtree_needs_intrinsic: Vec::new(),
442            },
443            calculated_positions: Vec::new(),
444            viewport: LogicalRect::zero(),
445            display_list: DisplayList::default(),
446            scroll_ids: HashMap::new(),
447            scroll_id_to_node_id: HashMap::new(),
448        }
449    }
450
451    /// Runs `f` with a `CallbackInfo` whose window holds `styled_dom` as the root DOM
452    /// and whose hit node is `hit`. Returns `f`'s value plus every change the callback
453    /// pushed onto the transaction log.
454    fn with_info<R>(
455        styled_dom: StyledDom,
456        hit: DomNodeId,
457        f: impl FnOnce(&mut CallbackInfo) -> R,
458    ) -> (R, Vec<CallbackChange>) {
459        let mut layout_window =
460            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
461        layout_window
462            .layout_results
463            .insert(DomId::ROOT_ID, layout_result(styled_dom));
464
465        let renderer_resources = RendererResources::default();
466        let previous_window_state: Option<FullWindowState> = None;
467        let current_window_state = FullWindowState::default();
468        let gl_context = OptionGlContextPtr::None;
469        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
470            BTreeMap::new();
471        let window_handle = RawWindowHandle::Unsupported;
472        let system_callbacks = ExternalSystemCallbacks::rust_internal();
473
474        let ref_data = CallbackInfoRefData {
475            layout_window: &layout_window,
476            renderer_resources: &renderer_resources,
477            previous_window_state: &previous_window_state,
478            current_window_state: &current_window_state,
479            gl_context: &gl_context,
480            current_scroll_manager: &scroll_states,
481            current_window_handle: &window_handle,
482            system_callbacks: &system_callbacks,
483            system_style: Arc::new(azul_css::system::SystemStyle::default()),
484            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
485            #[cfg(feature = "icu")]
486            icu_localizer: IcuLocalizerHandle::default(),
487            ctx: OptionRefAny::None,
488        };
489
490        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
491
492        let mut info = CallbackInfo::new(
493            &ref_data,
494            &changes,
495            hit,
496            OptionLogicalPosition::None,
497            OptionLogicalPosition::None,
498        );
499
500        let r = f(&mut info);
501        let pushed = info.take_changes();
502        (r, pushed)
503    }
504
505    /// Renders `switch`, then hands back both the laid-out DOM *and* the very `RefAny`
506    /// the widget registered on its own mouse-up callback. Driving the handler with
507    /// these two is the real wiring — nothing is re-created by hand, so a mismatch
508    /// between what `dom()` stores and what the handler expects cannot hide behind the
509    /// fixture.
510    fn laid_out(switch: Switch) -> (StyledDom, RefAny) {
511        let dom = switch.dom();
512        let state = dom.root.callbacks.as_ref()[0].refany.clone();
513        (StyledDom::create_from_dom(dom), state)
514    }
515
516    /// One "mouse-up on `hit`" delivered to the widget's own registered handler.
517    fn click(
518        styled_dom: StyledDom,
519        state: &RefAny,
520        hit: DomNodeId,
521    ) -> (Update, Vec<CallbackChange>) {
522        with_info(styled_dom, hit, |info| {
523            input::default_on_switch_clicked(state.clone(), *info)
524        })
525    }
526
527    fn is_checked(state: &RefAny) -> bool {
528        let mut state = state.clone();
529        let wrapper = state
530            .downcast_ref::<SwitchStateWrapper>()
531            .expect("the widget state changed type");
532        wrapper.inner.checked
533    }
534
535    /// Every `(node, property)` pair the handler wrote, flattened and in push order.
536    fn pushed_pairs(changes: &[CallbackChange]) -> Vec<(NodeId, CssProperty)> {
537        changes
538            .iter()
539            .filter_map(|c| match c {
540                CallbackChange::ChangeNodeCssProperties {
541                    node_id, properties, ..
542                } => Some((*node_id, properties.as_ref().to_vec())),
543                _ => None,
544            })
545            .flat_map(|(n, ps)| ps.into_iter().map(move |p| (n, p)))
546            .collect()
547    }
548
549    fn pushed_backgrounds(changes: &[CallbackChange]) -> Vec<(NodeId, StyleBackgroundContentVec)> {
550        pushed_pairs(changes)
551            .into_iter()
552            .filter_map(|(n, p)| match p {
553                CssProperty::BackgroundContent(b) => b.get_property().cloned().map(|b| (n, b)),
554                _ => None,
555            })
556            .collect()
557    }
558
559    fn pushed_margins(changes: &[CallbackChange]) -> Vec<(NodeId, f32)> {
560        pushed_pairs(changes)
561            .into_iter()
562            .filter_map(|(n, p)| match p {
563                CssProperty::MarginLeft(m) => m.get_property().map(|m| (n, px(&m.inner))),
564                _ => None,
565            })
566            .collect()
567    }
568
569    // ------------------------------------------------------------------
570    // Style-vec probes
571    // ------------------------------------------------------------------
572
573    fn properties(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
574        v.as_ref().iter().map(|p| p.property.clone()).collect()
575    }
576
577    fn find<T>(v: &CssPropertyWithConditionsVec, f: impl Fn(&CssProperty) -> Option<T>) -> Option<T> {
578        v.as_ref().iter().find_map(|p| f(&p.property))
579    }
580
581    /// The `f32` of a `PixelValue`, asserting it is an absolute `px` length. An `em`
582    /// or `%` slipping into the switch geometry would resolve against the parent
583    /// font/box, so a 36px track could render at any size at all — and the knob's
584    /// travel would no longer line up with it.
585    fn px(pv: &PixelValue) -> f32 {
586        assert_eq!(
587            pv.metric,
588            SizeMetric::Px,
589            "switch geometry must be absolute px, got {:?}",
590            pv.metric,
591        );
592        pv.number.get()
593    }
594
595    fn width_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
596        find(v, |p| match p {
597            CssProperty::Width(w) => match w.get_property() {
598                Some(LayoutWidth::Px(pv)) => Some(px(pv)),
599                _ => None,
600            },
601            _ => None,
602        })
603    }
604
605    fn height_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
606        find(v, |p| match p {
607            CssProperty::Height(h) => match h.get_property() {
608                Some(LayoutHeight::Px(pv)) => Some(px(pv)),
609                _ => None,
610            },
611            _ => None,
612        })
613    }
614
615    /// The knob's `margin-left` — the single property that encodes "which side is the
616    /// knob on". This is the only thing distinguishing the two knob styles.
617    fn margin_left_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
618        find(v, |p| match p {
619            CssProperty::MarginLeft(m) => m.get_property().map(|m| px(&m.inner)),
620            _ => None,
621        })
622    }
623
624    /// `(top, right, bottom, left)` padding, each as an absolute px.
625    fn paddings_px(v: &CssPropertyWithConditionsVec) -> (Option<f32>, Option<f32>, Option<f32>, Option<f32>) {
626        let get = |f: fn(&CssProperty) -> Option<f32>| find(v, f);
627        (
628            get(|p| match p {
629                CssProperty::PaddingTop(x) => x.get_property().map(|x| px(&x.inner)),
630                _ => None,
631            }),
632            get(|p| match p {
633                CssProperty::PaddingRight(x) => x.get_property().map(|x| px(&x.inner)),
634                _ => None,
635            }),
636            get(|p| match p {
637                CssProperty::PaddingBottom(x) => x.get_property().map(|x| px(&x.inner)),
638                _ => None,
639            }),
640            get(|p| match p {
641                CssProperty::PaddingLeft(x) => x.get_property().map(|x| px(&x.inner)),
642                _ => None,
643            }),
644        )
645    }
646
647    /// The four corner radii, in declaration order. (Each corner is its own newtype, so
648    /// the arms cannot be collapsed into an or-pattern.)
649    fn radii_px(v: &CssPropertyWithConditionsVec) -> Vec<f32> {
650        v.as_ref()
651            .iter()
652            .filter_map(|p| match &p.property {
653                CssProperty::BorderTopLeftRadius(r) => r.get_property().map(|r| px(&r.inner)),
654                CssProperty::BorderTopRightRadius(r) => r.get_property().map(|r| px(&r.inner)),
655                CssProperty::BorderBottomLeftRadius(r) => r.get_property().map(|r| px(&r.inner)),
656                CssProperty::BorderBottomRightRadius(r) => r.get_property().map(|r| px(&r.inner)),
657                _ => None,
658            })
659            .collect()
660    }
661
662    fn flex_grow(v: &CssPropertyWithConditionsVec) -> Option<f32> {
663        find(v, |p| match p {
664            CssProperty::FlexGrow(g) => g.get_property().map(|g| g.inner.get()),
665            _ => None,
666        })
667    }
668
669    fn background(v: &CssPropertyWithConditionsVec) -> Option<StyleBackgroundContentVec> {
670        find(v, |p| match p {
671            CssProperty::BackgroundContent(b) => b.get_property().cloned(),
672            _ => None,
673        })
674    }
675
676    /// The solid-colour layers of a background, in order. A gradient/image layer is
677    /// dropped, so `solid_colors(bg).len() != bg.len()` means something non-solid crept in.
678    fn solid_colors(bg: &StyleBackgroundContentVec) -> Vec<ColorU> {
679        bg.as_ref()
680            .iter()
681            .filter_map(|c| match c {
682                StyleBackgroundContent::Color(c) => Some(*c),
683                _ => None,
684            })
685            .collect()
686    }
687
688    /// Every absolute-px number declared by a style vec.
689    fn px_values(v: &CssPropertyWithConditionsVec) -> Vec<f32> {
690        v.as_ref()
691            .iter()
692            .filter_map(|p| match &p.property {
693                CssProperty::Width(x) => match x.get_property() {
694                    Some(LayoutWidth::Px(pv)) => Some(px(pv)),
695                    _ => None,
696                },
697                CssProperty::Height(x) => match x.get_property() {
698                    Some(LayoutHeight::Px(pv)) => Some(px(pv)),
699                    _ => None,
700                },
701                CssProperty::PaddingTop(x) => x.get_property().map(|x| px(&x.inner)),
702                CssProperty::PaddingRight(x) => x.get_property().map(|x| px(&x.inner)),
703                CssProperty::PaddingBottom(x) => x.get_property().map(|x| px(&x.inner)),
704                CssProperty::PaddingLeft(x) => x.get_property().map(|x| px(&x.inner)),
705                CssProperty::MarginLeft(x) => x.get_property().map(|x| px(&x.inner)),
706                CssProperty::BorderTopLeftRadius(x) => x.get_property().map(|x| px(&x.inner)),
707                CssProperty::BorderTopRightRadius(x) => x.get_property().map(|x| px(&x.inner)),
708                CssProperty::BorderBottomLeftRadius(x) => x.get_property().map(|x| px(&x.inner)),
709                CssProperty::BorderBottomRightRadius(x) => x.get_property().map(|x| px(&x.inner)),
710                _ => None,
711            })
712            .collect()
713    }
714
715    fn classes(dom: &Dom) -> Vec<String> {
716        dom.root
717            .get_ids_and_classes()
718            .as_ref()
719            .iter()
720            .filter_map(|c| match c {
721                IdOrClass::Class(s) => Some(s.as_str().to_string()),
722                IdOrClass::Id(_) => None,
723            })
724            .collect()
725    }
726
727    /// The properties of a rendered node's *inline* style, in declaration order.
728    fn inline_properties(dom: &Dom) -> Vec<CssProperty> {
729        dom.root
730            .style
731            .iter_inline_properties()
732            .map(|(p, _)| p.clone())
733            .collect()
734    }
735
736    // ------------------------------------------------------------------
737    // Toggle callbacks
738    // ------------------------------------------------------------------
739
740    /// A payload the toggle callback writes into. It arrives as the `data: RefAny`
741    /// argument — a *shared* clone of what the test still holds — so the test can read
742    /// back exactly what the widget passed, without any global state.
743    #[derive(Debug, Clone, PartialEq, Eq)]
744    struct ToggleLog {
745        seen: Vec<bool>,
746        payload: u32,
747    }
748
749    extern "C" fn record_toggle(mut data: RefAny, _info: CallbackInfo, state: SwitchState) -> Update {
750        if let Some(mut log) = data.downcast_mut::<ToggleLog>() {
751            log.seen.push(state.checked);
752        }
753        Update::RefreshDom
754    }
755
756    extern "C" fn toggle_do_nothing(
757        _data: RefAny,
758        _info: CallbackInfo,
759        _state: SwitchState,
760    ) -> Update {
761        Update::DoNothing
762    }
763
764    extern "C" fn toggle_refresh_all(
765        _data: RefAny,
766        _info: CallbackInfo,
767        _state: SwitchState,
768    ) -> Update {
769        Update::RefreshDomAllWindows
770    }
771
772    /// A `Callback`-shaped (2-arg) function — the shape FFI bindings hand in, which the
773    /// `From<Callback>` arm *transmutes* into the 3-arg switch slot. Never called.
774    extern "C" fn generic_shaped(_data: RefAny, _info: CallbackInfo) -> Update {
775        Update::DoNothing
776    }
777
778    fn log_refany() -> RefAny {
779        RefAny::new(ToggleLog {
780            seen: Vec::new(),
781            payload: 0xDEAD_BEEF,
782        })
783    }
784
785    fn read_log(probe: &RefAny) -> ToggleLog {
786        let mut probe = probe.clone();
787        let log = probe
788            .downcast_ref::<ToggleLog>()
789            .expect("the user payload changed type");
790        log.clone()
791    }
792
793    // ==================================================================
794    // Geometry constants — numeric limits / relations
795    // ==================================================================
796
797    #[test]
798    fn knob_travel_matches_its_documented_formula_and_is_positive() {
799        // The doc comment on KNOB_TRAVEL *is* the spec. A negative travel (knob wider
800        // than the track's content box) would slide the knob left, off the widget.
801        assert_eq!(
802            KNOB_TRAVEL,
803            TRACK_WIDTH - (2 * TRACK_PADDING) - KNOB_SIZE,
804            "KNOB_TRAVEL no longer matches its own documented formula",
805        );
806        assert!(
807            KNOB_TRAVEL > 0,
808            "the knob has no room to travel: KNOB_TRAVEL = {KNOB_TRAVEL}",
809        );
810        assert_eq!(KNOB_TRAVEL as f32, TRAVEL);
811    }
812
813    #[test]
814    fn the_knob_exactly_fills_the_track_from_edge_to_edge_when_on() {
815        // padding + travel + knob + padding == track width. One px of drift either way
816        // and the "on" knob either overhangs the pill or leaves a visible gap.
817        assert_eq!(
818            TRACK_PADDING + KNOB_TRAVEL + KNOB_SIZE + TRACK_PADDING,
819            TRACK_WIDTH,
820            "the switched-on knob does not sit flush against the track's right padding edge",
821        );
822    }
823
824    #[test]
825    fn the_knob_exactly_fills_the_tracks_vertical_padding_box() {
826        // A knob taller than `track_height - 2*padding` overflows the pill vertically;
827        // a shorter one floats. 16 == 20 - 2*2.
828        assert_eq!(
829            KNOB_SIZE,
830            TRACK_HEIGHT - (2 * TRACK_PADDING),
831            "the knob no longer fits the track's vertical padding box",
832        );
833    }
834
835    #[test]
836    fn both_shapes_are_fully_round_not_merely_rounded() {
837        // radius == half the cross-axis extent is what makes a pill a pill and a knob a
838        // circle. Anything less renders a rounded rectangle.
839        assert_eq!(TRACK_RADIUS * 2, TRACK_HEIGHT, "the track is not a pill");
840        assert_eq!(KNOB_RADIUS * 2, KNOB_SIZE, "the knob is not a circle");
841    }
842
843    #[test]
844    fn every_geometry_constant_survives_floatvalues_fixed_point_encoding() {
845        // `FloatValue::const_new` multiplies by 1000 in *isize* arithmetic with no
846        // checked path — a constant near isize::MAX/1000 would wrap silently and produce
847        // a nonsense length. Assert every constant is comfortably representable, and
848        // that the encode/decode round-trips exactly.
849        const MULT: isize = 1000;
850        for (name, v) in [
851            ("TRACK_WIDTH", TRACK_WIDTH),
852            ("TRACK_HEIGHT", TRACK_HEIGHT),
853            ("TRACK_PADDING", TRACK_PADDING),
854            ("TRACK_RADIUS", TRACK_RADIUS),
855            ("KNOB_SIZE", KNOB_SIZE),
856            ("KNOB_RADIUS", KNOB_RADIUS),
857            ("KNOB_TRAVEL", KNOB_TRAVEL),
858        ] {
859            assert!(
860                v.checked_mul(MULT).is_some(),
861                "{name} = {v} overflows FloatValue's fixed-point encoding",
862            );
863            assert!(v >= 0, "{name} = {v} is negative");
864
865            // encode -> decode must be lossless for these integral px values.
866            let encoded = LayoutMarginLeft::const_px(v);
867            assert_eq!(
868                px(&encoded.inner),
869                v as f32,
870                "{name} = {v} does not round-trip through PixelValue",
871            );
872        }
873    }
874
875    #[test]
876    fn no_declared_px_value_is_nan_infinite_or_negative() {
877        for checked in [false, true] {
878            for (name, v) in [
879                ("track", build_track_style(checked)),
880                ("knob", build_knob_style(checked)),
881            ] {
882                let values = px_values(&v);
883                assert!(
884                    !values.is_empty(),
885                    "checked={checked}: the {name} style declares no px lengths at all",
886                );
887                for x in values {
888                    assert!(
889                        x.is_finite(),
890                        "checked={checked}: the {name} style declares a non-finite length {x}",
891                    );
892                    assert!(
893                        x >= 0.0,
894                        "checked={checked}: the {name} style declares a negative length {x}",
895                    );
896                }
897            }
898        }
899    }
900
901    // ==================================================================
902    // build_track_style
903    // ==================================================================
904
905    #[test]
906    fn build_track_style_is_pure() {
907        for checked in [false, true] {
908            assert_eq!(
909                properties(&build_track_style(checked)),
910                properties(&build_track_style(checked)),
911                "build_track_style({checked}) is not deterministic",
912            );
913        }
914    }
915
916    #[test]
917    fn build_track_style_differs_between_the_two_states_only_in_the_background() {
918        // Everything but the colour must be byte-for-byte identical: a track that
919        // changed size or radius when flipped would reflow its neighbours mid-animation.
920        let on = properties(&build_track_style(true));
921        let off = properties(&build_track_style(false));
922        assert_eq!(
923            on.len(),
924            off.len(),
925            "the two track styles declare a different number of properties",
926        );
927
928        let differing: Vec<_> = on
929            .iter()
930            .zip(off.iter())
931            .filter(|(a, b)| a != b)
932            .map(|(a, _)| discriminant(a))
933            .collect();
934        assert_eq!(
935            differing,
936            vec![discriminant(&CssProperty::const_background_content(TRACK_ON_BG))],
937            "the on/off track styles differ in something other than the background",
938        );
939    }
940
941    #[test]
942    fn build_track_style_maps_on_to_green_and_off_to_grey() {
943        // A swapped branch here yields a switch that reads as "on" when it is off —
944        // which still type-checks and still animates.
945        let on = background(&build_track_style(true)).expect("the on track has no background");
946        let off = background(&build_track_style(false)).expect("the off track has no background");
947
948        assert_eq!(solid_colors(&on), vec![TRACK_ON_COLOR]);
949        assert_eq!(solid_colors(&off), vec![TRACK_OFF_COLOR]);
950        assert_ne!(
951            solid_colors(&on),
952            solid_colors(&off),
953            "the on and off tracks are the same colour — the switch has no visible state",
954        );
955    }
956
957    #[test]
958    fn the_track_background_is_exactly_one_fully_opaque_solid_layer() {
959        // A translucent (or multi-layer, or gradient) track would let whatever is behind
960        // it show through, so "off grey" would not actually be grey.
961        for checked in [false, true] {
962            let bg = background(&build_track_style(checked)).expect("no background");
963            assert_eq!(
964                bg.as_ref().len(),
965                1,
966                "checked={checked}: the track stacks {} background layers",
967                bg.as_ref().len(),
968            );
969            let colors = solid_colors(&bg);
970            assert_eq!(
971                colors.len(),
972                1,
973                "checked={checked}: the track background is not a plain solid colour",
974            );
975            assert_eq!(
976                colors[0].a, 255,
977                "checked={checked}: the track background is translucent (a = {})",
978                colors[0].a,
979            );
980        }
981    }
982
983    #[test]
984    fn build_track_style_geometry_is_absolute_px_in_both_states() {
985        for checked in [false, true] {
986            let v = build_track_style(checked);
987            // `px()` asserts SizeMetric::Px — an em/% here would scale with the parent
988            // and desynchronise the knob's (absolute px) travel from the track.
989            assert_eq!(width_px(&v), Some(TRACK_W), "checked={checked}: track width");
990            assert_eq!(height_px(&v), Some(TRACK_H), "checked={checked}: track height");
991            assert_eq!(
992                paddings_px(&v),
993                (Some(PAD), Some(PAD), Some(PAD), Some(PAD)),
994                "checked={checked}: the track padding is not uniform",
995            );
996            assert_eq!(
997                radii_px(&v),
998                vec![TRACK_R; 4],
999                "checked={checked}: the track's four corners are not all {TRACK_R}px",
1000            );
1001        }
1002    }
1003
1004    #[test]
1005    fn build_track_style_declares_no_property_twice() {
1006        // A duplicate declaration means the later one silently wins — a latent
1007        // "why is my override ignored" bug that never surfaces as an error.
1008        for checked in [false, true] {
1009            let props = properties(&build_track_style(checked));
1010            let mut seen = Vec::new();
1011            for p in &props {
1012                let d = discriminant(p);
1013                assert!(
1014                    !seen.contains(&d),
1015                    "checked={checked}: the track style declares {p:?} twice",
1016                );
1017                seen.push(d);
1018            }
1019        }
1020    }
1021
1022    #[test]
1023    fn build_track_style_marks_the_track_as_clickable_and_non_growing() {
1024        for checked in [false, true] {
1025            let v = build_track_style(checked);
1026            let cursor = find(&v, |p| match p {
1027                CssProperty::Cursor(c) => c.get_property().copied(),
1028                _ => None,
1029            });
1030            assert_eq!(
1031                cursor,
1032                Some(StyleCursor::Pointer),
1033                "checked={checked}: the switch does not present as clickable",
1034            );
1035            // flex-grow > 0 would let the track stretch past 36px inside a flex row,
1036            // and the knob's fixed 16px travel would no longer reach the right edge.
1037            assert_eq!(
1038                flex_grow(&v),
1039                Some(0.0),
1040                "checked={checked}: the track is allowed to grow",
1041            );
1042        }
1043    }
1044
1045    #[test]
1046    fn build_track_style_lays_the_knob_out_as_a_centred_row() {
1047        // The knob is positioned by `margin-left` alone, which only behaves as a
1048        // left-anchored offset inside a row flex container.
1049        for checked in [false, true] {
1050            let v = build_track_style(checked);
1051            assert_eq!(
1052                find(&v, |p| match p {
1053                    CssProperty::Display(d) => d.get_property().copied(),
1054                    _ => None,
1055                }),
1056                Some(LayoutDisplay::Flex),
1057            );
1058            assert_eq!(
1059                find(&v, |p| match p {
1060                    CssProperty::FlexDirection(d) => d.get_property().copied(),
1061                    _ => None,
1062                }),
1063                Some(LayoutFlexDirection::Row),
1064                "checked={checked}: margin-left only slides the knob in a row container",
1065            );
1066            assert_eq!(
1067                find(&v, |p| match p {
1068                    CssProperty::AlignItems(a) => a.get_property().copied(),
1069                    _ => None,
1070                }),
1071                Some(LayoutAlignItems::Center),
1072            );
1073        }
1074    }
1075
1076    // ==================================================================
1077    // build_knob_style
1078    // ==================================================================
1079
1080    #[test]
1081    fn build_knob_style_is_pure() {
1082        for checked in [false, true] {
1083            assert_eq!(
1084                properties(&build_knob_style(checked)),
1085                properties(&build_knob_style(checked)),
1086                "build_knob_style({checked}) is not deterministic",
1087            );
1088        }
1089    }
1090
1091    #[test]
1092    fn build_knob_style_differs_between_the_two_states_only_in_margin_left() {
1093        let on = properties(&build_knob_style(true));
1094        let off = properties(&build_knob_style(false));
1095        assert_eq!(
1096            on.len(),
1097            off.len(),
1098            "the two knob styles declare a different number of properties",
1099        );
1100
1101        let differing: Vec<_> = on
1102            .iter()
1103            .zip(off.iter())
1104            .filter(|(a, b)| a != b)
1105            .map(|(a, _)| discriminant(a))
1106            .collect();
1107        assert_eq!(
1108            differing,
1109            vec![discriminant(&CssProperty::const_margin_left(
1110                LayoutMarginLeft::const_px(0)
1111            ))],
1112            "the on/off knob styles differ in something other than margin-left",
1113        );
1114    }
1115
1116    #[test]
1117    fn build_knob_style_parks_the_knob_left_when_off_and_right_when_on() {
1118        assert_eq!(
1119            margin_left_px(&build_knob_style(false)),
1120            Some(0.0),
1121            "the off knob is not flush against the track's left padding edge",
1122        );
1123        assert_eq!(
1124            margin_left_px(&build_knob_style(true)),
1125            Some(TRAVEL),
1126            "the on knob does not travel the full width of the track",
1127        );
1128    }
1129
1130    #[test]
1131    fn build_knob_style_geometry_is_a_circle_in_absolute_px() {
1132        for checked in [false, true] {
1133            let v = build_knob_style(checked);
1134            assert_eq!(width_px(&v), Some(KNOB), "checked={checked}: knob width");
1135            assert_eq!(height_px(&v), Some(KNOB), "checked={checked}: knob height");
1136            assert_eq!(
1137                width_px(&v),
1138                height_px(&v),
1139                "checked={checked}: the knob is not square, so it cannot be a circle",
1140            );
1141            assert_eq!(
1142                radii_px(&v),
1143                vec![KNOB_R; 4],
1144                "checked={checked}: the knob's four corners are not all {KNOB_R}px",
1145            );
1146            assert_eq!(
1147                flex_grow(&v),
1148                Some(0.0),
1149                "checked={checked}: the knob is allowed to grow and would fill the track",
1150            );
1151        }
1152    }
1153
1154    #[test]
1155    fn build_knob_style_declares_no_property_twice() {
1156        // Two `margin-left` declarations would make the knob's position depend on
1157        // declaration order rather than on `checked`.
1158        for checked in [false, true] {
1159            let props = properties(&build_knob_style(checked));
1160            let mut seen = Vec::new();
1161            for p in &props {
1162                let d = discriminant(p);
1163                assert!(
1164                    !seen.contains(&d),
1165                    "checked={checked}: the knob style declares {p:?} twice",
1166                );
1167                seen.push(d);
1168            }
1169        }
1170    }
1171
1172    #[test]
1173    fn the_knob_is_opaque_white_in_both_states() {
1174        // The knob must never inherit or blend with the track colour, or the "off" knob
1175        // would disappear into the grey.
1176        for checked in [false, true] {
1177            let bg = background(&build_knob_style(checked)).expect("the knob has no background");
1178            assert_eq!(bg.as_ref().len(), 1, "checked={checked}: the knob stacks layers");
1179            assert_eq!(solid_colors(&bg), vec![KNOB_COLOR]);
1180            assert_eq!(
1181                KNOB_COLOR.a, 255,
1182                "the knob is translucent and would tint with the track",
1183            );
1184            assert_ne!(
1185                solid_colors(&bg),
1186                vec![TRACK_OFF_COLOR],
1187                "checked={checked}: the knob is the same colour as the off track",
1188            );
1189            assert_ne!(
1190                solid_colors(&bg),
1191                vec![TRACK_ON_COLOR],
1192                "checked={checked}: the knob is the same colour as the on track",
1193            );
1194        }
1195    }
1196
1197    #[test]
1198    fn the_knob_never_leaves_the_track_in_either_state() {
1199        // The real safety property, read back out of the *built styles* rather than the
1200        // constants: left edge >= 0 and right edge <= the track's content width.
1201        let content_w = TRACK_W - 2.0 * PAD;
1202        for checked in [false, true] {
1203            let margin = margin_left_px(&build_knob_style(checked)).expect("no margin-left");
1204            let size = width_px(&build_knob_style(checked)).expect("no width");
1205
1206            assert!(margin >= 0.0, "checked={checked}: the knob is pushed off the left edge");
1207            assert!(
1208                margin + size <= content_w,
1209                "checked={checked}: the knob overhangs the track ({margin} + {size} > {content_w})",
1210            );
1211            assert!(
1212                height_px(&build_knob_style(checked)).expect("no height") <= TRACK_H - 2.0 * PAD,
1213                "checked={checked}: the knob overflows the track vertically",
1214            );
1215        }
1216    }
1217
1218    // ==================================================================
1219    // Switch::create / Default
1220    // ==================================================================
1221
1222    #[test]
1223    fn create_stores_the_flag_and_installs_no_callback() {
1224        for checked in [false, true] {
1225            let s = Switch::create(checked);
1226            assert_eq!(
1227                s.switch_state.inner.checked, checked,
1228                "create({checked}) did not store the flag it was given",
1229            );
1230            assert!(
1231                s.switch_state.on_toggle.as_ref().is_none(),
1232                "create({checked}) invented a toggle callback out of nowhere",
1233            );
1234        }
1235    }
1236
1237    #[test]
1238    fn create_is_pure_and_its_two_states_are_distinguishable() {
1239        assert_eq!(Switch::create(true), Switch::create(true));
1240        assert_eq!(Switch::create(false), Switch::create(false));
1241        assert_ne!(
1242            Switch::create(true),
1243            Switch::create(false),
1244            "an on and an off switch are indistinguishable",
1245        );
1246    }
1247
1248    #[test]
1249    fn create_wires_the_flag_through_to_both_style_builders() {
1250        // The one way `create` can be wrong without any test noticing: passing the flag
1251        // to one builder and a literal (or the negation) to the other, so the track says
1252        // "on" while the knob sits left.
1253        for checked in [false, true] {
1254            let s = Switch::create(checked);
1255            assert_eq!(
1256                properties(&s.track_style),
1257                properties(&build_track_style(checked)),
1258                "create({checked}) did not build the track for state {checked}",
1259            );
1260            assert_eq!(
1261                properties(&s.knob_style),
1262                properties(&build_knob_style(checked)),
1263                "create({checked}) did not build the knob for state {checked}",
1264            );
1265        }
1266    }
1267
1268    #[test]
1269    fn the_rendered_colour_and_the_knob_position_always_agree_with_the_stored_flag() {
1270        for checked in [false, true] {
1271            let s = Switch::create(checked);
1272            let bg = background(&s.track_style).expect("no track background");
1273            let margin = margin_left_px(&s.knob_style).expect("no knob margin");
1274
1275            let (expected_color, expected_margin) = if s.switch_state.inner.checked {
1276                (TRACK_ON_COLOR, TRAVEL)
1277            } else {
1278                (TRACK_OFF_COLOR, 0.0)
1279            };
1280            assert_eq!(solid_colors(&bg), vec![expected_color]);
1281            assert_eq!(
1282                margin, expected_margin,
1283                "checked={checked}: the knob position contradicts the track colour",
1284            );
1285        }
1286    }
1287
1288    #[test]
1289    fn default_is_an_off_switch() {
1290        assert_eq!(Switch::default(), Switch::create(false));
1291        assert!(!Switch::default().switch_state.inner.checked);
1292        assert!(!SwitchState::default().checked, "the default SwitchState is not off");
1293        assert!(!SwitchStateWrapper::default().inner.checked);
1294        assert!(SwitchStateWrapper::default().on_toggle.as_ref().is_none());
1295    }
1296
1297    // ==================================================================
1298    // Switch::swap_with_default
1299    // ==================================================================
1300
1301    #[test]
1302    fn swap_with_default_returns_the_old_widget_and_leaves_an_off_switch_behind() {
1303        let mut s = Switch::create(true);
1304        let old = s.swap_with_default();
1305
1306        assert_eq!(old, Switch::create(true), "the old widget was not returned intact");
1307        assert_eq!(s, Switch::create(false), "what was left behind is not a fresh off switch");
1308    }
1309
1310    #[test]
1311    fn swap_with_default_on_an_already_default_widget_is_a_no_op() {
1312        let mut s = Switch::create(false);
1313        let old = s.swap_with_default();
1314        assert_eq!(old, s, "swapping a default with a default produced two different widgets");
1315        assert_eq!(old, Switch::create(false));
1316    }
1317
1318    #[test]
1319    fn swapping_twice_round_trips_the_original_widget() {
1320        let mut a = Switch::create(true);
1321        let mut b = a.swap_with_default(); // a = default, b = on
1322        let c = b.swap_with_default(); // b = default, c = on
1323
1324        assert_eq!(c, Switch::create(true));
1325        assert_eq!(a, Switch::create(false));
1326        assert_eq!(b, Switch::create(false));
1327    }
1328
1329    #[test]
1330    fn swap_with_default_moves_the_toggle_callback_out_rather_than_copying_or_dropping_it() {
1331        let probe = log_refany();
1332        let mut s = Switch::create(true)
1333            .with_on_toggle(probe.clone(), record_toggle as SwitchOnToggleCallbackType);
1334
1335        let old = s.swap_with_default();
1336
1337        // The callback (and its payload) left with the returned value ...
1338        let moved = old
1339            .switch_state
1340            .on_toggle
1341            .as_ref()
1342            .expect("the toggle callback vanished during the swap");
1343        assert_eq!(
1344            moved.callback.cb as *const () as usize,
1345            record_toggle as SwitchOnToggleCallbackType as *const () as usize,
1346            "the fn pointer was mangled by the swap",
1347        );
1348
1349        // ... and did NOT stay behind: a duplicated callback would fire twice, and a
1350        // duplicated RefAny would double-free its payload.
1351        assert!(
1352            s.switch_state.on_toggle.as_ref().is_none(),
1353            "the toggle callback was copied instead of moved",
1354        );
1355
1356        // The payload is still alive and unchanged after the move.
1357        assert_eq!(read_log(&probe).payload, 0xDEAD_BEEF);
1358    }
1359
1360    // ==================================================================
1361    // Switch::set_on_toggle / with_on_toggle
1362    // ==================================================================
1363
1364    #[test]
1365    fn set_on_toggle_stores_the_function_pointer_and_the_payload_verbatim() {
1366        let mut s = Switch::create(false);
1367        s.set_on_toggle(
1368            RefAny::new(0xDEAD_BEEF_u32),
1369            toggle_do_nothing as SwitchOnToggleCallbackType,
1370        );
1371
1372        let t = s
1373            .switch_state
1374            .on_toggle
1375            .as_ref()
1376            .expect("set_on_toggle did not store anything");
1377        assert_eq!(
1378            t.callback.cb as *const () as usize,
1379            toggle_do_nothing as SwitchOnToggleCallbackType as *const () as usize,
1380            "the fn pointer was corrupted on the way in",
1381        );
1382
1383        let mut data = t.refany.clone();
1384        assert_eq!(
1385            *data.downcast_ref::<u32>().expect("the payload changed type"),
1386            0xDEAD_BEEF,
1387            "the payload was corrupted",
1388        );
1389        assert!(
1390            data.downcast_ref::<u64>().is_none(),
1391            "downcasting to the wrong type must fail, not reinterpret the bytes",
1392        );
1393    }
1394
1395    #[test]
1396    fn set_on_toggle_replaces_rather_than_accumulates() {
1397        // `OptionSwitchOnToggle` is a single slot; setting twice must leave the *second*
1398        // callback installed (and must not leak or free the first one's RefAny).
1399        let first = log_refany();
1400        let mut s = Switch::create(false);
1401        s.set_on_toggle(first.clone(), toggle_do_nothing as SwitchOnToggleCallbackType);
1402        s.set_on_toggle(RefAny::new(1u8), toggle_refresh_all as SwitchOnToggleCallbackType);
1403
1404        let t = s.switch_state.on_toggle.as_ref().expect("the callback vanished");
1405        assert_eq!(
1406            t.callback.cb as *const () as usize,
1407            toggle_refresh_all as SwitchOnToggleCallbackType as *const () as usize,
1408            "the second set_on_toggle did not win",
1409        );
1410        // The displaced payload is still a valid, readable RefAny (not freed twice).
1411        assert_eq!(read_log(&first).payload, 0xDEAD_BEEF);
1412    }
1413
1414    #[test]
1415    fn set_on_toggle_does_not_disturb_the_state_or_the_styles() {
1416        for checked in [false, true] {
1417            let pristine = Switch::create(checked);
1418            let mut s = Switch::create(checked);
1419            s.set_on_toggle(RefAny::new(0u8), toggle_do_nothing as SwitchOnToggleCallbackType);
1420
1421            assert_eq!(
1422                s.switch_state.inner.checked, checked,
1423                "installing a callback flipped the switch",
1424            );
1425            assert_eq!(
1426                properties(&s.track_style),
1427                properties(&pristine.track_style),
1428                "installing a callback rewrote the track style",
1429            );
1430            assert_eq!(
1431                properties(&s.knob_style),
1432                properties(&pristine.knob_style),
1433                "installing a callback rewrote the knob style",
1434            );
1435        }
1436    }
1437
1438    #[test]
1439    fn with_on_toggle_is_exactly_set_on_toggle_in_builder_form() {
1440        let by_builder = Switch::create(true)
1441            .with_on_toggle(RefAny::new(7u32), toggle_do_nothing as SwitchOnToggleCallbackType);
1442
1443        let mut by_setter = Switch::create(true);
1444        by_setter.set_on_toggle(RefAny::new(7u32), toggle_do_nothing as SwitchOnToggleCallbackType);
1445
1446        assert_eq!(by_builder.switch_state.inner, by_setter.switch_state.inner);
1447        assert_eq!(
1448            properties(&by_builder.track_style),
1449            properties(&by_setter.track_style),
1450        );
1451        assert_eq!(
1452            properties(&by_builder.knob_style),
1453            properties(&by_setter.knob_style),
1454        );
1455
1456        let a = by_builder.switch_state.on_toggle.as_ref().expect("builder lost the callback");
1457        let b = by_setter.switch_state.on_toggle.as_ref().expect("setter lost the callback");
1458        assert_eq!(a.callback.cb as *const () as usize, b.callback.cb as *const () as usize);
1459
1460        let (mut a, mut b) = (a.refany.clone(), b.refany.clone());
1461        assert_eq!(
1462            *a.downcast_ref::<u32>().expect("builder payload changed type"),
1463            *b.downcast_ref::<u32>().expect("setter payload changed type"),
1464        );
1465    }
1466
1467    #[test]
1468    fn with_on_toggle_accepts_a_generic_callback_without_mangling_the_pointer() {
1469        // The `From<Callback>` arm *transmutes* a 2-arg fn pointer into the 3-arg switch
1470        // slot — this is the FFI (Python/C) path. The pointer must come out bit-identical;
1471        // a mangled one would be called as a wild jump on the first click.
1472        let generic = Callback {
1473            cb: generic_shaped,
1474            ctx: azul_core::refany::OptionRefAny::None,
1475        };
1476        let expected = generic_shaped as *const () as usize;
1477
1478        let s = Switch::create(false).with_on_toggle(RefAny::new(0u8), generic);
1479        let t = s.switch_state.on_toggle.as_ref().expect("the generic callback was dropped");
1480        assert_eq!(
1481            t.callback.cb as *const () as usize,
1482            expected,
1483            "the Callback -> SwitchOnToggleCallback transmute mangled the pointer",
1484        );
1485    }
1486
1487    // ==================================================================
1488    // Switch::dom
1489    // ==================================================================
1490
1491    #[test]
1492    fn dom_builds_a_focusable_track_with_exactly_one_knob_child() {
1493        for checked in [false, true] {
1494            let dom = Switch::create(checked).dom();
1495
1496            assert!(matches!(dom.root.get_node_type(), NodeType::Div));
1497            assert_eq!(
1498                dom.root.flags.get_tab_index(),
1499                Some(TabIndex::Auto),
1500                "checked={checked}: the switch is not keyboard-focusable",
1501            );
1502            assert_eq!(classes(&dom), vec!["__azul-native-switch".to_string()]);
1503
1504            let children = dom.children.as_ref();
1505            assert_eq!(children.len(), 1, "checked={checked}: the switch must have exactly one knob");
1506            assert_eq!(
1507                classes(&children[0]),
1508                vec!["__azul-native-switch-knob".to_string()],
1509                "checked={checked}: the knob carries the wrong class (external CSS would miss it)",
1510            );
1511            assert!(
1512                children[0].children.as_ref().is_empty(),
1513                "checked={checked}: the knob grew children",
1514            );
1515            assert_eq!(
1516                children[0].root.flags.get_tab_index(),
1517                None,
1518                "checked={checked}: the knob is separately focusable, so Tab stops twice",
1519            );
1520        }
1521    }
1522
1523    #[test]
1524    fn dom_puts_the_track_style_on_the_track_and_the_knob_style_on_the_knob() {
1525        // Swapping the two would style the 16px knob like a 36px pill (and vice versa) —
1526        // the widget would still render, just wrong.
1527        for checked in [false, true] {
1528            let s = Switch::create(checked);
1529            let track = properties(&s.track_style);
1530            let knob = properties(&s.knob_style);
1531
1532            let dom = s.dom();
1533            assert_eq!(
1534                inline_properties(&dom),
1535                track,
1536                "checked={checked}: the track style did not land on the track",
1537            );
1538            assert_eq!(
1539                inline_properties(&dom.children.as_ref()[0]),
1540                knob,
1541                "checked={checked}: the knob style did not land on the knob",
1542            );
1543        }
1544    }
1545
1546    #[test]
1547    fn dom_registers_exactly_one_mouse_up_handler_and_it_is_the_widgets_own() {
1548        for checked in [false, true] {
1549            let dom = Switch::create(checked).dom();
1550            let callbacks = dom.root.callbacks.as_ref();
1551
1552            assert_eq!(callbacks.len(), 1, "checked={checked}: expected exactly one callback");
1553            assert_eq!(
1554                callbacks[0].event,
1555                EventFilter::Hover(HoverEventFilter::MouseUp),
1556                "checked={checked}: the switch must toggle on mouse-up",
1557            );
1558            assert_eq!(
1559                callbacks[0].callback.cb,
1560                input::default_on_switch_clicked as usize,
1561                "checked={checked}: the registered handler is not default_on_switch_clicked",
1562            );
1563
1564            // The knob itself must stay inert — a second handler there would toggle twice
1565            // per click (the event bubbles).
1566            assert!(
1567                dom.children.as_ref()[0].root.callbacks.as_ref().is_empty(),
1568                "checked={checked}: the knob registered a handler of its own",
1569            );
1570        }
1571    }
1572
1573    #[test]
1574    fn dom_hands_the_widget_state_to_the_handler_not_the_user_payload() {
1575        // `dom()` moves `switch_state` (state + on_toggle + user RefAny) into the
1576        // callback's RefAny. If it stored the *user's* payload instead, the handler's
1577        // `downcast_mut::<SwitchStateWrapper>()` would fail and every click would be a
1578        // silent no-op.
1579        for checked in [false, true] {
1580            let dom = Switch::create(checked)
1581                .with_on_toggle(RefAny::new(9u32), toggle_do_nothing as SwitchOnToggleCallbackType)
1582                .dom();
1583
1584            let mut state = dom.root.callbacks.as_ref()[0].refany.clone();
1585            let wrapper = state
1586                .downcast_ref::<SwitchStateWrapper>()
1587                .expect("the handler's RefAny is not a SwitchStateWrapper");
1588
1589            assert_eq!(
1590                wrapper.inner.checked, checked,
1591                "the on/off flag was lost on the way into the DOM",
1592            );
1593            assert!(
1594                wrapper.on_toggle.as_ref().is_some(),
1595                "the user's toggle callback was lost on the way into the DOM",
1596            );
1597        }
1598    }
1599
1600    #[test]
1601    fn dom_of_a_callback_less_switch_still_registers_the_toggle_handler() {
1602        // The switch must always install its own handler: the knob has to slide even
1603        // with no user callback.
1604        let dom = Switch::create(false).dom();
1605        assert_eq!(dom.root.callbacks.as_ref().len(), 1);
1606
1607        let mut state = dom.root.callbacks.as_ref()[0].refany.clone();
1608        let wrapper = state.downcast_ref::<SwitchStateWrapper>().expect("wrong RefAny type");
1609        assert!(wrapper.on_toggle.as_ref().is_none());
1610    }
1611
1612    #[test]
1613    fn from_switch_for_dom_is_the_same_as_calling_dom() {
1614        for checked in [false, true] {
1615            let via_from = Dom::from(Switch::create(checked));
1616            let via_dom = Switch::create(checked).dom();
1617
1618            assert_eq!(classes(&via_from), classes(&via_dom));
1619            assert_eq!(inline_properties(&via_from), inline_properties(&via_dom));
1620            assert_eq!(via_from.children.as_ref().len(), via_dom.children.as_ref().len());
1621            assert_eq!(
1622                inline_properties(&via_from.children.as_ref()[0]),
1623                inline_properties(&via_dom.children.as_ref()[0]),
1624            );
1625            assert_eq!(
1626                via_from.root.callbacks.as_ref().len(),
1627                via_dom.root.callbacks.as_ref().len(),
1628            );
1629            assert_eq!(via_from.root.flags.get_tab_index(), via_dom.root.flags.get_tab_index());
1630        }
1631    }
1632
1633    #[test]
1634    fn the_rendered_dom_flattens_to_exactly_two_nodes() {
1635        // `Dom::estimated_total_children` is a *cached* count; if it under-reports, the
1636        // flatten under-allocates its arenas. Two nodes: track (0), knob (1).
1637        for checked in [false, true] {
1638            let styled = StyledDom::create_from_dom(Switch::create(checked).dom());
1639            assert_eq!(
1640                styled.node_data.as_ref().len(),
1641                2,
1642                "checked={checked}: the switch no longer flattens to a track + a knob",
1643            );
1644        }
1645    }
1646
1647    // ==================================================================
1648    // input::default_on_switch_clicked
1649    // ==================================================================
1650
1651    #[test]
1652    fn clicking_an_off_switch_turns_it_on_and_pushes_the_on_visuals() {
1653        let (styled, state) = laid_out(Switch::create(false));
1654        assert!(!is_checked(&state));
1655
1656        let (update, changes) = click(styled, &state, node(TRACK));
1657
1658        assert!(is_checked(&state), "the click did not turn the switch on");
1659        assert!(
1660            matches!(update, Update::DoNothing),
1661            "with no user callback installed, the handler must report DoNothing",
1662        );
1663        assert_eq!(
1664            pushed_backgrounds(&changes)
1665                .iter()
1666                .map(|(n, b)| (*n, solid_colors(b)))
1667                .collect::<Vec<_>>(),
1668            vec![(NodeId::new(TRACK), vec![TRACK_ON_COLOR])],
1669            "turning the switch on did not repaint the *track* green",
1670        );
1671        assert_eq!(
1672            pushed_margins(&changes),
1673            vec![(NodeId::new(KNOB_NODE), TRAVEL)],
1674            "turning the switch on did not slide the *knob* right",
1675        );
1676    }
1677
1678    #[test]
1679    fn clicking_an_on_switch_turns_it_off_and_pushes_the_off_visuals() {
1680        let (styled, state) = laid_out(Switch::create(true));
1681        let (_, changes) = click(styled, &state, node(TRACK));
1682
1683        assert!(!is_checked(&state), "the click did not turn the switch off");
1684        assert_eq!(
1685            pushed_backgrounds(&changes)
1686                .iter()
1687                .map(|(n, b)| (*n, solid_colors(b)))
1688                .collect::<Vec<_>>(),
1689            vec![(NodeId::new(TRACK), vec![TRACK_OFF_COLOR])],
1690        );
1691        assert_eq!(pushed_margins(&changes), vec![(NodeId::new(KNOB_NODE), 0.0)]);
1692    }
1693
1694    #[test]
1695    fn a_click_pushes_exactly_what_a_freshly_created_switch_would_render() {
1696        // The handler re-derives the visuals by hand instead of calling
1697        // `build_track_style`/`build_knob_style`. That duplication is the bug surface:
1698        // a colour or a travel distance can drift in one place and not the other, and
1699        // the switch then renders differently after a click than it did on mount.
1700        for start in [false, true] {
1701            let (styled, state) = laid_out(Switch::create(start));
1702            let (_, changes) = click(styled, &state, node(TRACK));
1703
1704            let expected = Switch::create(!start);
1705            assert_eq!(
1706                pushed_backgrounds(&changes).into_iter().map(|(_, b)| b).collect::<Vec<_>>(),
1707                vec![background(&expected.track_style).expect("no background")],
1708                "start={start}: the clicked track colour differs from a freshly built one",
1709            );
1710            assert_eq!(
1711                pushed_margins(&changes).into_iter().map(|(_, m)| m).collect::<Vec<_>>(),
1712                vec![margin_left_px(&expected.knob_style).expect("no margin")],
1713                "start={start}: the clicked knob offset differs from a freshly built one",
1714            );
1715        }
1716    }
1717
1718    #[test]
1719    fn clicking_twice_returns_to_the_original_state() {
1720        let (styled, state) = laid_out(Switch::create(false));
1721
1722        // Two independent deliveries against the same widget state — the styled DOM is
1723        // rebuilt each time because the harness consumes it, but the RefAny is shared,
1724        // which is exactly how the real event loop drives it.
1725        let (styled2, _) = laid_out(Switch::create(false));
1726        let (_, first) = click(styled, &state, node(TRACK));
1727        let (_, second) = click(styled2, &state, node(TRACK));
1728
1729        assert!(!is_checked(&state), "two clicks did not return the switch to its original state");
1730        assert_eq!(pushed_margins(&first), vec![(NodeId::new(KNOB_NODE), TRAVEL)]);
1731        assert_eq!(pushed_margins(&second), vec![(NodeId::new(KNOB_NODE), 0.0)]);
1732    }
1733
1734    #[test]
1735    fn clicking_with_a_refany_of_the_wrong_type_is_a_silent_no_op() {
1736        // The handler downcasts blind; a foreign RefAny must bail out, not reinterpret
1737        // the bytes as a SwitchStateWrapper.
1738        let (styled, _) = laid_out(Switch::create(false));
1739        let foreign = RefAny::new(0xDEAD_BEEF_u32);
1740
1741        let (update, changes) = click(styled, &foreign, node(TRACK));
1742
1743        assert!(matches!(update, Update::DoNothing));
1744        assert!(changes.is_empty(), "the handler wrote to the DOM through a foreign RefAny");
1745
1746        let mut foreign = foreign;
1747        assert_eq!(
1748            *foreign.downcast_ref::<u32>().expect("the foreign payload was reinterpreted"),
1749            0xDEAD_BEEF,
1750            "the handler corrupted a RefAny it did not understand",
1751        );
1752    }
1753
1754    #[test]
1755    fn clicking_the_knob_itself_does_not_half_apply_the_toggle() {
1756        // The knob is the child that sits under the cursor for most of the track's area,
1757        // and it has no first child of its own. The handler needs that child to slide the
1758        // knob, so it must leave the *flag* alone too — a flipped flag with no visual
1759        // update is a switch that renders the opposite of what it reports.
1760        let (styled, state) = laid_out(Switch::create(false));
1761
1762        let (update, changes) = click(styled, &state, node(KNOB_NODE));
1763
1764        assert!(matches!(update, Update::DoNothing));
1765        assert!(changes.is_empty(), "a change was pushed for a node the handler could not resolve");
1766        assert!(
1767            !is_checked(&state),
1768            "the flag was flipped even though the knob could not be moved",
1769        );
1770    }
1771
1772    #[test]
1773    fn stale_or_missing_hit_ids_do_not_panic_or_toggle() {
1774        // Stale hit ids reach callbacks after a DOM mutation. `set_css_property` *panics*
1775        // on a None node id, so the handler has to bail out before that point.
1776        // usize::MAX is unencodable by NodeId's 1-based scheme and would overflow while
1777        // building this fixture, before `click()` is even called; usize::MAX - 1 is the
1778        // repo's MAX_ENCODABLE_NODE and still absent from the layout.
1779        for hit in [node(2), node(99), node(usize::MAX - 1), node_none()] {
1780            let (styled, state) = laid_out(Switch::create(true));
1781            let (update, changes) = click(styled, &state, hit);
1782
1783            assert!(matches!(update, Update::DoNothing), "{hit:?}: a stale hit was acted on");
1784            assert!(changes.is_empty(), "{hit:?}: a stale hit pushed a DOM change");
1785            assert!(is_checked(&state), "{hit:?}: a stale hit toggled the switch");
1786        }
1787    }
1788
1789    #[test]
1790    fn the_toggle_callback_sees_the_new_state_and_its_verdict_is_forwarded() {
1791        // Order matters: the flag is flipped *before* the user callback runs, so the
1792        // callback observes the state the user just asked for — not the stale one.
1793        let probe = log_refany();
1794        let (styled, state) = laid_out(
1795            Switch::create(false)
1796                .with_on_toggle(probe.clone(), record_toggle as SwitchOnToggleCallbackType),
1797        );
1798
1799        let (update, changes) = click(styled, &state, node(TRACK));
1800
1801        assert_eq!(
1802            read_log(&probe).seen,
1803            vec![true],
1804            "the toggle callback was not called exactly once with the NEW state",
1805        );
1806        assert!(
1807            matches!(update, Update::RefreshDom),
1808            "the user callback's Update was swallowed instead of forwarded",
1809        );
1810        // ... and the visual sync still happens *after* the user callback returns.
1811        assert_eq!(pushed_margins(&changes), vec![(NodeId::new(KNOB_NODE), TRAVEL)]);
1812    }
1813
1814    #[test]
1815    fn the_toggle_callback_receives_the_user_payload_not_the_widget_state() {
1816        let probe = log_refany();
1817        let (styled, state) = laid_out(
1818            Switch::create(true)
1819                .with_on_toggle(probe.clone(), record_toggle as SwitchOnToggleCallbackType),
1820        );
1821
1822        click(styled, &state, node(TRACK));
1823
1824        let log = read_log(&probe);
1825        assert_eq!(
1826            log.payload, 0xDEAD_BEEF,
1827            "the callback was handed something other than the user's own RefAny",
1828        );
1829        // create(true) -> clicked once -> the callback must have seen `false`.
1830        assert_eq!(log.seen, vec![false]);
1831    }
1832
1833    #[test]
1834    fn a_toggle_callback_that_declines_the_update_still_gets_the_visuals_synced() {
1835        // A user callback returning DoNothing must not suppress the widget's own visual
1836        // bookkeeping — otherwise the flag says "on" and the knob stays parked left.
1837        let (styled, state) = laid_out(
1838            Switch::create(false)
1839                .with_on_toggle(RefAny::new(0u8), toggle_do_nothing as SwitchOnToggleCallbackType),
1840        );
1841
1842        let (update, changes) = click(styled, &state, node(TRACK));
1843
1844        assert!(matches!(update, Update::DoNothing));
1845        assert!(is_checked(&state));
1846        assert_eq!(
1847            pushed_margins(&changes),
1848            vec![(NodeId::new(KNOB_NODE), TRAVEL)],
1849            "a DoNothing user callback suppressed the knob slide",
1850        );
1851    }
1852
1853    #[test]
1854    fn a_toggle_callback_on_an_unresolvable_node_is_never_called_at_all() {
1855        // The bail-out happens before the flip *and* before the user callback: a click
1856        // that cannot be rendered must not be reported to the app either.
1857        let probe = log_refany();
1858        let (styled, state) = laid_out(
1859            Switch::create(false)
1860                .with_on_toggle(probe.clone(), record_toggle as SwitchOnToggleCallbackType),
1861        );
1862
1863        let (update, changes) = click(styled, &state, node(KNOB_NODE));
1864
1865        assert!(matches!(update, Update::DoNothing));
1866        assert!(changes.is_empty());
1867        assert!(!is_checked(&state));
1868        assert!(
1869            read_log(&probe).seen.is_empty(),
1870            "the user was notified of a toggle that never happened",
1871        );
1872    }
1873
1874    #[test]
1875    fn many_clicks_leave_the_flag_the_colour_and_the_knob_in_agreement() {
1876        // 51 clicks starting off -> on. Every push must agree with the flag it
1877        // accompanies; a drift between the three is exactly the class of bug that makes
1878        // a switch render inverted after a while.
1879        let mut expected_on = false;
1880        let (_, state) = laid_out(Switch::create(false));
1881
1882        for i in 0..51u32 {
1883            let (styled, _) = laid_out(Switch::create(false));
1884            let (_, changes) = click(styled, &state, node(TRACK));
1885            expected_on = !expected_on;
1886
1887            let (color, margin) = if expected_on {
1888                (TRACK_ON_COLOR, TRAVEL)
1889            } else {
1890                (TRACK_OFF_COLOR, 0.0)
1891            };
1892            assert_eq!(
1893                pushed_backgrounds(&changes)
1894                    .iter()
1895                    .map(|(n, b)| (*n, solid_colors(b)))
1896                    .collect::<Vec<_>>(),
1897                vec![(NodeId::new(TRACK), vec![color])],
1898                "click #{i}: the pushed track colour disagrees with the flag",
1899            );
1900            assert_eq!(
1901                pushed_margins(&changes),
1902                vec![(NodeId::new(KNOB_NODE), margin)],
1903                "click #{i}: the pushed knob offset disagrees with the flag",
1904            );
1905            assert_eq!(is_checked(&state), expected_on, "click #{i}: the flag drifted");
1906        }
1907
1908        assert!(is_checked(&state), "an odd number of clicks left the switch off");
1909    }
1910
1911    #[test]
1912    fn a_click_writes_to_two_distinct_nodes_and_never_to_the_wrong_one() {
1913        // The track gets the colour, the knob gets the offset — never the other way
1914        // round, and never both onto the same node.
1915        let (styled, state) = laid_out(Switch::create(false));
1916        let (_, changes) = click(styled, &state, node(TRACK));
1917
1918        let bg_nodes: Vec<_> = pushed_backgrounds(&changes).into_iter().map(|(n, _)| n).collect();
1919        let margin_nodes: Vec<_> = pushed_margins(&changes).into_iter().map(|(n, _)| n).collect();
1920
1921        assert_eq!(bg_nodes, vec![NodeId::new(TRACK)]);
1922        assert_eq!(margin_nodes, vec![NodeId::new(KNOB_NODE)]);
1923        assert_ne!(
1924            bg_nodes, margin_nodes,
1925            "the colour and the knob offset landed on the same node",
1926        );
1927    }
1928}