operad 9.0.1

A cross-platform GUI library for Rust.
Documentation
use super::*;

#[derive(Debug, Clone)]
pub struct ToggleSwitchOptions {
    pub layout: LayoutStyle,
    pub track_visual: UiVisual,
    pub on_track_visual: UiVisual,
    pub disabled_track_visual: Option<UiVisual>,
    pub thumb_visual: UiVisual,
    pub disabled_thumb_visual: Option<UiVisual>,
    pub track_size: UiSize,
    pub thumb_size: UiSize,
    pub gap: f32,
    pub text_style: TextStyle,
    pub enabled: bool,
    pub action: Option<WidgetActionBinding>,
    pub accessibility_label: Option<String>,
    pub accessibility_hint: Option<String>,
}

impl Default for ToggleSwitchOptions {
    fn default() -> Self {
        Self {
            layout: LayoutStyle::from_taffy_style(Style {
                display: Display::Flex,
                flex_direction: FlexDirection::Row,
                align_items: Some(AlignItems::Center),
                size: TaffySize {
                    width: Dimension::auto(),
                    height: length(30.0),
                },
                ..Default::default()
            }),
            track_visual: UiVisual::panel(ColorRgba::new(42, 49, 58, 255), None, 11.0),
            on_track_visual: UiVisual::panel(ColorRgba::new(21, 92, 78, 255), None, 11.0),
            disabled_track_visual: Some(UiVisual::panel(
                ColorRgba::new(35, 39, 45, 160),
                None,
                11.0,
            )),
            thumb_visual: UiVisual::panel(
                ColorRgba::new(235, 240, 247, 255),
                Some(StrokeStyle::new(ColorRgba::new(79, 93, 113, 255), 1.0)),
                9.0,
            ),
            disabled_thumb_visual: Some(UiVisual::panel(
                ColorRgba::new(150, 158, 170, 180),
                Some(StrokeStyle::new(ColorRgba::new(81, 90, 104, 180), 1.0)),
                9.0,
            )),
            track_size: UiSize::new(44.0, 22.0),
            thumb_size: UiSize::new(18.0, 18.0),
            gap: 8.0,
            text_style: TextStyle::default(),
            enabled: true,
            action: None,
            accessibility_label: None,
            accessibility_hint: None,
        }
    }
}

impl ToggleSwitchOptions {
    pub fn with_layout(mut self, layout: impl Into<LayoutStyle>) -> Self {
        self.layout = layout.into();
        self
    }

    pub fn with_track_visual(mut self, visual: UiVisual) -> Self {
        self.track_visual = visual;
        self
    }

    pub fn with_on_track_visual(mut self, visual: UiVisual) -> Self {
        self.on_track_visual = visual;
        self
    }

    pub fn with_disabled_track_visual(mut self, visual: UiVisual) -> Self {
        self.disabled_track_visual = Some(visual);
        self
    }

    pub fn with_thumb_visual(mut self, visual: UiVisual) -> Self {
        self.thumb_visual = visual;
        self
    }

    pub fn with_disabled_thumb_visual(mut self, visual: UiVisual) -> Self {
        self.disabled_thumb_visual = Some(visual);
        self
    }

    pub fn with_track_size(mut self, size: UiSize) -> Self {
        self.track_size = UiSize::new(size.width.max(1.0), size.height.max(1.0));
        self
    }

    pub fn with_thumb_size(mut self, size: UiSize) -> Self {
        self.thumb_size = UiSize::new(size.width.max(1.0), size.height.max(1.0));
        self
    }

    pub fn with_gap(mut self, gap: f32) -> Self {
        self.gap = gap.max(0.0);
        self
    }

    pub fn with_text_style(mut self, style: TextStyle) -> Self {
        self.text_style = style;
        self
    }

    pub const fn enabled(mut self, enabled: bool) -> Self {
        self.enabled = enabled;
        self
    }

    pub fn accessibility_label(mut self, label: impl Into<String>) -> Self {
        self.accessibility_label = Some(label.into());
        self
    }

    pub fn accessibility_hint(mut self, hint: impl Into<String>) -> Self {
        self.accessibility_hint = Some(hint.into());
        self
    }

    pub fn with_action(mut self, action: impl Into<WidgetActionBinding>) -> Self {
        self.action = Some(action.into());
        self
    }
}

pub fn toggle_switch(
    document: &mut UiDocument,
    parent: UiNodeId,
    name: impl Into<String>,
    label_text: impl Into<String>,
    value: ToggleValue,
    options: ToggleSwitchOptions,
) -> UiNodeId {
    let name = name.into();
    let label_text = label_text.into();
    let has_label = !label_text.is_empty();
    let mut accessibility = AccessibilityMeta::new(AccessibilityRole::Switch)
        .label(options.accessibility_label.clone().unwrap_or_else(|| {
            if has_label {
                label_text.clone()
            } else {
                name.clone()
            }
        }))
        .value(value.label())
        .action(AccessibilityAction::new("toggle", "Toggle"));
    accessibility = if value.is_mixed() {
        accessibility.mixed()
    } else {
        accessibility.checked(value.is_on())
    };
    if let Some(hint) = options.accessibility_hint.clone() {
        accessibility = accessibility.hint(hint);
    }
    if options.enabled {
        accessibility = accessibility.focusable();
    } else {
        accessibility = accessibility.disabled();
    }

    let root_layout = options.layout.style.clone();
    let mut root_node = UiNode::container(
        name.clone(),
        UiNodeStyle {
            layout: root_layout.clone(),
            clip: ClipBehavior::Clip,
            ..Default::default()
        },
    )
    .with_input(if options.enabled {
        InputBehavior::BUTTON
    } else {
        InputBehavior::NONE
    })
    .with_accessibility(accessibility);
    if let Some(action) = options.action.clone() {
        root_node = root_node.with_action(action);
    }
    let root = document.add_child(parent, root_node);

    let track_visual = if !options.enabled {
        options
            .disabled_track_visual
            .unwrap_or(options.track_visual)
    } else if value.is_on() {
        options.on_track_visual
    } else {
        options.track_visual
    };
    let thumb_visual = if options.enabled {
        options.thumb_visual
    } else {
        options
            .disabled_thumb_visual
            .unwrap_or(options.thumb_visual)
    };
    let track_layout = Style {
        display: Display::Flex,
        flex_direction: FlexDirection::Row,
        align_items: Some(AlignItems::Center),
        size: TaffySize {
            width: length(options.track_size.width),
            height: length(options.track_size.height),
        },
        padding: taffy::prelude::Rect::length(
            ((options.track_size.height - options.thumb_size.height) * 0.5).max(0.0),
        ),
        margin: taffy::prelude::Rect {
            right: taffy::prelude::LengthPercentageAuto::length(if has_label {
                options.gap
            } else {
                0.0
            }),
            ..taffy::prelude::Rect::length(0.0_f32)
        },
        flex_shrink: 0.0,
        ..Default::default()
    };
    let track = document.add_child(
        root,
        UiNode::container(
            format!("{name}.track"),
            LayoutStyle::from_taffy_style(track_layout.clone()),
        )
        .with_visual(track_visual),
    );
    if value.is_on() {
        document.add_child(
            track,
            UiNode::container(
                format!("{name}.track.before_thumb"),
                LayoutStyle::new().with_flex_grow(1.0).with_height(1.0),
            ),
        );
    }
    document.add_child(
        track,
        UiNode::container(
            format!("{name}.thumb"),
            LayoutStyle::size(options.thumb_size.width, options.thumb_size.height)
                .with_flex_shrink(0.0),
        )
        .with_visual(thumb_visual),
    );
    if !value.is_on() {
        document.add_child(
            track,
            UiNode::container(
                format!("{name}.track.after_thumb"),
                LayoutStyle::new().with_flex_grow(1.0).with_height(1.0),
            ),
        );
    }
    let label = if has_label {
        let label_style = single_line_text_style(options.text_style);
        Some(label(
            document,
            root,
            format!("{name}.label"),
            label_text,
            label_style,
            LayoutStyle::new(),
        ))
    } else {
        None
    };
    publish_inline_intrinsic_size(
        document,
        root,
        label.into_iter().collect::<Vec<_>>(),
        inline_intrinsic_base_size(
            &root_layout,
            &[&track_layout],
            if has_label { 2 } else { 1 },
        ),
    );
    root
}

pub fn toggle_switch_actions_from_input_result(
    document: &UiDocument,
    toggle: UiNodeId,
    value: ToggleValue,
    options: &ToggleSwitchOptions,
    result: &UiInputResult,
) -> WidgetActionQueue {
    let mut queue = WidgetActionQueue::new();
    if result
        .clicked
        .is_some_and(|target| document.node_is_descendant_or_self(toggle, target))
        && action_target_enabled(document, toggle)
    {
        if let Some(binding) = options.action.clone() {
            queue.select(toggle, binding, value.toggled().is_on());
        }
    }
    queue
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn toggle_switch_builds_switch_with_track_thumb_and_label() {
        let mut document = UiDocument::new(root_style(240.0, 120.0));
        let root = document.root;
        let node = toggle_switch(
            &mut document,
            root,
            "autosave",
            "Autosave",
            ToggleValue::On,
            ToggleSwitchOptions::default().with_action("autosave.toggle"),
        );

        let accessibility = document.node(node).accessibility.as_ref().unwrap();
        assert_eq!(accessibility.role, AccessibilityRole::Switch);
        assert_eq!(accessibility.checked, Some(AccessibilityChecked::True));
        assert_eq!(document.node(node).children.len(), 2);
        assert_eq!(
            document.node(node).action.as_ref(),
            Some(&WidgetActionBinding::action("autosave.toggle"))
        );
    }

    #[test]
    fn toggle_switch_supports_no_label_and_custom_track_thumb_geometry() {
        let mut document = UiDocument::new(root_style(240.0, 120.0));
        let root = document.root;
        let node = toggle_switch(
            &mut document,
            root,
            "mode.no_label",
            "",
            ToggleValue::On,
            ToggleSwitchOptions::default()
                .accessibility_label("No-label switch")
                .with_track_size(UiSize::new(72.0, 24.0))
                .with_thumb_size(UiSize::new(28.0, 18.0))
                .with_gap(0.0)
                .with_on_track_visual(UiVisual::panel(ColorRgba::new(91, 65, 158, 255), None, 4.0))
                .with_thumb_visual(UiVisual::panel(
                    ColorRgba::new(255, 205, 90, 255),
                    Some(StrokeStyle::new(ColorRgba::new(255, 236, 171, 255), 1.0)),
                    3.0,
                )),
        );

        assert_eq!(document.node(node).children.len(), 1);
        assert_eq!(
            document.node(node).accessibility.as_ref().unwrap().label,
            Some("No-label switch".to_string())
        );
        assert!(document.node(node).layout_constraint.is_some());

        document
            .compute_layout(UiSize::new(240.0, 120.0), &mut ApproxTextMeasurer)
            .expect("layout");
        let track = document.node(document.node(node).children[0]);
        assert_eq!(track.layout().rect.width, 72.0);
        assert_eq!(track.layout().rect.height, 24.0);
        let thumb = document.node(node_id_by_name(&document, "mode.no_label.thumb"));
        assert_eq!(thumb.layout().rect.width, 28.0);
        assert_eq!(thumb.layout().rect.height, 18.0);
    }

    fn node_id_by_name(document: &UiDocument, name: &str) -> UiNodeId {
        document
            .nodes()
            .iter()
            .enumerate()
            .find_map(|(index, node)| (node.name() == name).then_some(UiNodeId::from_index(index)))
            .unwrap_or_else(|| panic!("missing node {name:?}"))
    }
}