operad 8.0.1

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

#[derive(Debug, Clone)]
pub struct CollapsingHeaderOptions {
    pub layout: LayoutStyle,
    pub header_layout: LayoutStyle,
    pub body_layout: LayoutStyle,
    pub header_visual: UiVisual,
    pub hovered_visual: UiVisual,
    pub pressed_visual: UiVisual,
    pub body_visual: UiVisual,
    pub text_style: TextStyle,
    pub indicator_text_style: TextStyle,
    pub expanded: bool,
    pub enabled: bool,
    pub toggle_action: Option<WidgetActionBinding>,
    pub accessibility_label: Option<String>,
    pub accessibility_hint: Option<String>,
}

impl Default for CollapsingHeaderOptions {
    fn default() -> Self {
        Self {
            layout: LayoutStyle::column().with_width_percent(1.0),
            header_layout: LayoutStyle::row()
                .with_width_percent(1.0)
                .with_height(32.0)
                .with_padding(6.0)
                .with_gap(6.0)
                .with_align_items(AlignItems::Center),
            body_layout: LayoutStyle::column()
                .with_width_percent(1.0)
                .with_padding(8.0)
                .with_gap(8.0),
            header_visual: UiVisual::panel(ColorRgba::TRANSPARENT, None, 0.0),
            hovered_visual: UiVisual::panel(ColorRgba::new(38, 48, 61, 255), None, 4.0),
            pressed_visual: UiVisual::panel(ColorRgba::new(27, 36, 48, 255), None, 4.0),
            body_visual: UiVisual::TRANSPARENT,
            text_style: TextStyle::default(),
            indicator_text_style: TextStyle::default(),
            expanded: true,
            enabled: true,
            toggle_action: None,
            accessibility_label: None,
            accessibility_hint: None,
        }
    }
}

impl CollapsingHeaderOptions {
    pub fn expanded(mut self, expanded: bool) -> Self {
        self.expanded = expanded;
        self
    }

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

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

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CollapsingHeaderNodes {
    pub root: UiNodeId,
    pub header: UiNodeId,
    pub indicator: UiNodeId,
    pub label: UiNodeId,
    pub body: Option<UiNodeId>,
}

pub fn collapsing_header(
    document: &mut UiDocument,
    parent: UiNodeId,
    name: impl Into<String>,
    label_text: impl Into<String>,
    options: CollapsingHeaderOptions,
) -> CollapsingHeaderNodes {
    let name = name.into();
    let label_text = label_text.into();
    let text_style = single_line_text_style(options.text_style.clone());
    let indicator_text_style = single_line_text_style(options.indicator_text_style.clone());
    let root = document.add_child(
        parent,
        UiNode::container(
            name.clone(),
            UiNodeStyle {
                layout: options.layout.style.clone(),
                clip: ClipBehavior::Clip,
                ..Default::default()
            },
        )
        .with_visual(UiVisual::TRANSPARENT)
        .with_accessibility(
            AccessibilityMeta::new(AccessibilityRole::Group).label(
                options
                    .accessibility_label
                    .clone()
                    .unwrap_or_else(|| label_text.clone()),
            ),
        ),
    );

    let mut header_accessibility = AccessibilityMeta::new(AccessibilityRole::Button)
        .label(
            options
                .accessibility_label
                .clone()
                .unwrap_or_else(|| label_text.clone()),
        )
        .expanded(options.expanded)
        .action(AccessibilityAction::new("toggle", "Toggle"));
    if options.enabled {
        header_accessibility = header_accessibility.focusable();
    } else {
        header_accessibility = header_accessibility.disabled();
    }
    if let Some(hint) = options.accessibility_hint.clone() {
        header_accessibility = header_accessibility.hint(hint);
    }

    let header_visuals = InteractionVisuals::new(options.header_visual)
        .hovered(options.hovered_visual)
        .pressed(options.pressed_visual)
        .pressed_hovered(options.pressed_visual)
        .disabled(options.header_visual);
    let mut header_node = UiNode::container(
        format!("{name}.header"),
        UiNodeStyle {
            layout: options.header_layout.style.clone(),
            clip: ClipBehavior::Clip,
            ..Default::default()
        },
    )
    .with_interaction_visuals(header_visuals)
    .with_accessibility(header_accessibility);
    if options.enabled {
        header_node = header_node.with_input(InputBehavior::BUTTON);
    }
    if let Some(action) = options.toggle_action.clone() {
        header_node = header_node.with_action(action);
    }
    let header = document.add_child(root, header_node);

    let indicator = document.add_child(
        header,
        UiNode::text(
            format!("{name}.indicator"),
            if options.expanded { "v" } else { ">" },
            indicator_text_style,
            LayoutStyle::new().with_width(18.0).with_flex_shrink(0.0),
        )
        .with_accessibility(AccessibilityMeta::new(AccessibilityRole::Label).hidden()),
    );

    let label = document.add_child(
        header,
        UiNode::text(
            format!("{name}.label"),
            label_text,
            text_style,
            LayoutStyle::new().with_width_percent(1.0),
        ),
    );
    publish_inline_intrinsic_size(
        document,
        header,
        vec![indicator, label],
        inline_intrinsic_base_size(&options.header_layout.style, &[], 2),
    );

    let body = options.expanded.then(|| {
        document.add_child(
            root,
            UiNode::container(
                format!("{name}.body"),
                UiNodeStyle {
                    layout: options.body_layout.style.clone(),
                    clip: ClipBehavior::Clip,
                    ..Default::default()
                },
            )
            .with_visual(options.body_visual)
            .with_accessibility(AccessibilityMeta::new(AccessibilityRole::Group).label("Content")),
        )
    });

    if let Some(body) = body {
        if let Some(accessibility) = document.node_mut(header).accessibility.as_mut() {
            accessibility.relations.controls.push(body);
        }
    }

    CollapsingHeaderNodes {
        root,
        header,
        indicator,
        label,
        body,
    }
}

pub fn collapsing_header_actions_from_input_result(
    document: &UiDocument,
    nodes: CollapsingHeaderNodes,
    options: &CollapsingHeaderOptions,
    result: &UiInputResult,
) -> WidgetActionQueue {
    let mut queue = WidgetActionQueue::new();
    let Some(clicked) = result.clicked else {
        return queue;
    };
    if !document.node_is_descendant_or_self(nodes.header, clicked)
        || !action_target_enabled(document, nodes.header)
    {
        return queue;
    }
    if let Some(binding) = options.toggle_action.clone() {
        if options.expanded {
            queue.close(nodes.header, binding);
        } else {
            queue.open(nodes.header, binding);
        }
    }
    queue
}

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

    #[test]
    fn collapsing_header_expanded_creates_controlled_body() {
        let mut document = UiDocument::new(root_style(320.0, 180.0));
        let root = document.root;
        let nodes = collapsing_header(
            &mut document,
            root,
            "advanced",
            "Advanced",
            CollapsingHeaderOptions::default().with_toggle_action("toggle.advanced"),
        );

        let body = nodes.body.expect("expanded header body");
        let header_accessibility = document.node(nodes.header).accessibility.as_ref().unwrap();
        assert_eq!(header_accessibility.expanded, Some(true));
        assert_eq!(header_accessibility.relations.controls, vec![body]);
        assert!(document.node(nodes.header).input.pointer);
    }

    #[test]
    fn collapsing_header_label_is_measured_as_single_line() {
        let mut document = UiDocument::new(root_style(320.0, 180.0));
        let root = document.root;
        let nodes = collapsing_header(
            &mut document,
            root,
            "advanced",
            "Boolean input transition",
            CollapsingHeaderOptions::default().expanded(false),
        );

        let label = document.node(nodes.label);
        let UiContent::Text(text) = &label.content else {
            panic!("collapsing header label should be text");
        };
        assert_eq!(text.style.wrap, TextWrap::None);

        let intrinsic = document
            .intrinsic_size(nodes.label, &mut ApproxTextMeasurer)
            .expect("label intrinsic size");
        assert!(
            (intrinsic.preferred.width - intrinsic.min.width).abs() <= 0.01,
            "{intrinsic:?}"
        );

        document
            .compute_layout(UiSize::new(320.0, 180.0), &mut ApproxTextMeasurer)
            .expect("layout");
        let header_min_width =
            dimension_length(document.node(nodes.header).style.layout.min_size.width)
                .expect("header minimum width");
        assert!(
            header_min_width >= intrinsic.preferred.width + 36.0,
            "header min width {header_min_width} should include label, indicator, padding, and gap"
        );
    }

    fn dimension_length(value: Dimension) -> Option<f32> {
        let raw = value.into_raw();
        (raw.tag() == taffy::prelude::CompactLength::LENGTH_TAG).then_some(raw.value())
    }

    #[test]
    fn collapsing_header_collapsed_omits_body_and_queues_open() {
        let mut document = UiDocument::new(root_style(320.0, 180.0));
        let root = document.root;
        let options = CollapsingHeaderOptions::default()
            .expanded(false)
            .with_toggle_action("toggle.advanced");
        let nodes = collapsing_header(&mut document, root, "advanced", "Advanced", options.clone());
        document
            .compute_layout(UiSize::new(320.0, 180.0), &mut ApproxTextMeasurer)
            .expect("layout");

        assert!(nodes.body.is_none());
        let result = document.handle_input(UiInputEvent::PointerDown(UiPoint::new(8.0, 8.0)));
        assert_eq!(result.pressed, Some(nodes.header));
        let result = document.handle_input(UiInputEvent::PointerUp(UiPoint::new(8.0, 8.0)));
        let actions =
            collapsing_header_actions_from_input_result(&document, nodes, &options, &result);
        assert!(matches!(
            actions.as_slice().first().map(|action| &action.kind),
            Some(WidgetActionKind::Open)
        ));
    }
}