operad 8.0.1

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

const TAU: f64 = std::f64::consts::TAU;

#[derive(Debug, Clone)]
pub struct DragValueOptions {
    pub layout: LayoutStyle,
    pub visual: UiVisual,
    pub hovered_visual: Option<UiVisual>,
    pub pressed_visual: Option<UiVisual>,
    pub disabled_visual: Option<UiVisual>,
    pub text_style: TextStyle,
    pub precision: NumericPrecision,
    pub range: Option<NumericRange>,
    pub unit: NumericUnitFormat,
    pub enabled: bool,
    pub action: Option<WidgetActionBinding>,
    pub accessibility_label: Option<String>,
    pub accessibility_hint: Option<String>,
}

impl Default for DragValueOptions {
    fn default() -> Self {
        Self {
            layout: LayoutStyle::from_taffy_style(Style {
                display: Display::Flex,
                align_items: Some(AlignItems::Center),
                justify_content: Some(JustifyContent::Center),
                size: TaffySize {
                    width: length(92.0),
                    height: length(30.0),
                },
                padding: taffy::prelude::Rect {
                    left: taffy::prelude::LengthPercentage::length(8.0),
                    right: taffy::prelude::LengthPercentage::length(8.0),
                    top: taffy::prelude::LengthPercentage::length(4.0),
                    bottom: taffy::prelude::LengthPercentage::length(4.0),
                },
                ..Default::default()
            }),
            visual: UiVisual::panel(
                ColorRgba::new(36, 42, 52, 255),
                Some(StrokeStyle::new(ColorRgba::new(74, 85, 104, 255), 1.0)),
                4.0,
            ),
            hovered_visual: Some(UiVisual::panel(
                ColorRgba::new(48, 58, 72, 255),
                Some(StrokeStyle::new(ColorRgba::new(120, 170, 230, 255), 1.0)),
                4.0,
            )),
            pressed_visual: Some(UiVisual::panel(
                ColorRgba::new(28, 36, 48, 255),
                Some(StrokeStyle::new(ColorRgba::new(120, 170, 230, 255), 1.0)),
                4.0,
            )),
            disabled_visual: Some(UiVisual::panel(
                ColorRgba::new(30, 34, 40, 180),
                Some(StrokeStyle::new(ColorRgba::new(64, 72, 84, 180), 1.0)),
                4.0,
            )),
            text_style: TextStyle::default(),
            precision: NumericPrecision::default(),
            range: None,
            unit: NumericUnitFormat::default(),
            enabled: true,
            action: None,
            accessibility_label: None,
            accessibility_hint: None,
        }
    }
}

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

    pub fn with_precision(mut self, precision: NumericPrecision) -> Self {
        self.precision = precision;
        self
    }

    pub fn with_range(mut self, range: NumericRange) -> Self {
        self.range = Some(range);
        self
    }

    pub fn with_unit(mut self, unit: NumericUnitFormat) -> Self {
        self.unit = unit;
        self
    }

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

pub fn drag_value_input(
    document: &mut UiDocument,
    parent: UiNodeId,
    name: impl Into<String>,
    value: f64,
    options: DragValueOptions,
) -> UiNodeId {
    let name = name.into();
    let value = options.range.map_or(value, |range| range.clamp(value));
    let text = options.unit.format(options.precision.format(value));
    let label_text = options
        .accessibility_label
        .clone()
        .unwrap_or_else(|| name.clone());
    let mut accessibility = AccessibilityMeta::new(AccessibilityRole::SpinButton)
        .label(label_text)
        .value(text.clone())
        .focusable()
        .action(AccessibilityAction::new("adjust", "Adjust value"));
    if let Some(range) = options.range {
        accessibility =
            accessibility.value_range(AccessibilityValueRange::new(range.min, range.max));
    }
    if let Some(hint) = options.accessibility_hint.clone() {
        accessibility = accessibility.hint(hint);
    }
    if !options.enabled {
        accessibility = accessibility.disabled();
    }

    let layout = options.layout.style.clone();
    let interaction_visuals = InteractionVisuals::new(options.visual)
        .hovered(options.hovered_visual.unwrap_or(options.visual))
        .pressed(options.pressed_visual.unwrap_or(options.visual))
        .disabled(options.disabled_visual.unwrap_or(options.visual));
    let mut root_node = UiNode::container(
        name.clone(),
        UiNodeStyle {
            layout: layout.clone(),
            clip: ClipBehavior::Clip,
            ..Default::default()
        },
    )
    .with_input(if options.enabled {
        InputBehavior::BUTTON
    } else {
        InputBehavior::NONE
    })
    .with_interaction_visuals(interaction_visuals)
    .with_action_mode(WidgetActionMode::PointerEdit)
    .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 intrinsic_text = drag_value_intrinsic_text(value, &options);
    let mut text_node = UiNode::text(
        format!("{name}.value"),
        text,
        single_line_text_style(options.text_style),
        LayoutStyle::new(),
    );
    if let UiContent::Text(text_content) = &mut text_node.content {
        text_content.intrinsic_text = Some(intrinsic_text);
    }
    let value_node = document.add_child(root, text_node);
    publish_inline_intrinsic_size(
        document,
        root,
        vec![value_node],
        inline_intrinsic_base_size(&layout, &[], 1),
    );
    root
}

pub fn drag_angle(
    document: &mut UiDocument,
    parent: UiNodeId,
    name: impl Into<String>,
    radians: f64,
    options: DragValueOptions,
) -> UiNodeId {
    drag_value_input(
        document,
        parent,
        name,
        radians.to_degrees(),
        angle_drag_options(options, NumericRange::new(0.0, 360.0), " deg", 1),
    )
}

pub fn drag_angle_tau(
    document: &mut UiDocument,
    parent: UiNodeId,
    name: impl Into<String>,
    radians: f64,
    options: DragValueOptions,
) -> UiNodeId {
    drag_value_input(
        document,
        parent,
        name,
        radians / TAU,
        angle_drag_options(options, NumericRange::new(0.0, 1.0), " tau", 3),
    )
}

fn angle_drag_options(
    mut options: DragValueOptions,
    default_range: NumericRange,
    default_suffix: &str,
    default_decimals: u8,
) -> DragValueOptions {
    if options.range.is_none() {
        options.range = Some(default_range);
    }
    if options.unit.is_empty() {
        options.unit = NumericUnitFormat::default().suffix(default_suffix);
    }
    if options.precision == NumericPrecision::default() {
        options.precision = NumericPrecision::decimals(default_decimals);
    }
    options
}

fn drag_value_intrinsic_text(value: f64, options: &DragValueOptions) -> String {
    let mut candidates = vec![value];
    if let Some(range) = options.range {
        candidates.push(range.min);
        candidates.push(range.max);
        if range.contains(0.0) {
            candidates.push(0.0);
        }
    }
    candidates
        .into_iter()
        .map(|value| options.unit.format(options.precision.format(value)))
        .max_by(|left, right| {
            left.chars()
                .count()
                .cmp(&right.chars().count())
                .then_with(|| left.len().cmp(&right.len()))
        })
        .unwrap_or_default()
}

pub fn drag_value_input_actions_from_gesture_event(
    document: &UiDocument,
    node: UiNodeId,
    options: &DragValueOptions,
    event: &GestureEvent,
) -> WidgetActionQueue {
    let mut queue = WidgetActionQueue::new();
    let GestureEvent::Drag(gesture) = event else {
        return queue;
    };
    if !document.node_is_descendant_or_self(node, gesture.target)
        || !action_target_enabled(document, node)
    {
        return queue;
    }
    let Some(binding) = options.action.clone() else {
        return queue;
    };
    let rect = document.node(node).layout.rect;
    queue.push(WidgetAction::pointer_edit(
        node,
        binding,
        WidgetPointerEdit::new(
            WidgetValueEditPhase::from(gesture.phase),
            gesture.current,
            UiPoint::new(gesture.current.x - rect.x, gesture.current.y - rect.y),
            rect,
        ),
    ));
    queue
}

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

    #[test]
    fn drag_value_input_builds_spinbutton_with_formatted_value() {
        let mut document = UiDocument::new(root_style(240.0, 120.0));
        let root = document.root;
        let node = drag_value_input(
            &mut document,
            root,
            "angle",
            42.25,
            DragValueOptions::default()
                .with_precision(NumericPrecision::decimals(1))
                .with_unit(NumericUnitFormat::default().suffix(" deg"))
                .with_range(NumericRange::new(0.0, 360.0))
                .with_action("angle.drag"),
        );

        let accessibility = document.node(node).accessibility.as_ref().unwrap();
        assert_eq!(accessibility.role, AccessibilityRole::SpinButton);
        assert_eq!(accessibility.value.as_deref(), Some("42.3 deg"));
        assert_eq!(
            document.node(node).action_mode,
            WidgetActionMode::PointerEdit
        );
        assert_eq!(
            document.node(node).action.as_ref(),
            Some(&WidgetActionBinding::action("angle.drag"))
        );
    }

    #[test]
    fn drag_angle_helpers_format_degrees_and_tau_fraction() {
        let mut document = UiDocument::new(root_style(240.0, 120.0));
        let root = document.root;
        let degrees = drag_angle(
            &mut document,
            root,
            "angle.degrees",
            std::f64::consts::FRAC_PI_2,
            DragValueOptions::default().with_action("angle.degrees.drag"),
        );
        let tau = drag_angle_tau(
            &mut document,
            root,
            "angle.tau",
            std::f64::consts::FRAC_PI_2,
            DragValueOptions::default().with_action("angle.tau.drag"),
        );

        assert_eq!(
            document
                .node(degrees)
                .accessibility
                .as_ref()
                .unwrap()
                .value
                .as_deref(),
            Some("90.0 deg")
        );
        assert_eq!(
            document
                .node(tau)
                .accessibility
                .as_ref()
                .unwrap()
                .value
                .as_deref(),
            Some("0.250 tau")
        );
    }

    #[test]
    fn drag_value_input_sizes_from_range_widest_value_without_wrapping() {
        let mut document = UiDocument::new(root_style(240.0, 120.0));
        let root = document.root;
        let node = drag_value_input(
            &mut document,
            root,
            "angle",
            42.25,
            DragValueOptions::default()
                .with_layout(LayoutStyle::from_taffy_style(Style {
                    display: Display::Flex,
                    align_items: Some(AlignItems::Center),
                    justify_content: Some(JustifyContent::Center),
                    size: TaffySize {
                        width: length(56.0),
                        height: length(30.0),
                    },
                    padding: taffy::prelude::Rect {
                        left: taffy::prelude::LengthPercentage::length(8.0),
                        right: taffy::prelude::LengthPercentage::length(8.0),
                        top: taffy::prelude::LengthPercentage::length(4.0),
                        bottom: taffy::prelude::LengthPercentage::length(4.0),
                    },
                    ..Default::default()
                }))
                .with_precision(NumericPrecision::decimals(1))
                .with_unit(NumericUnitFormat::default().suffix(" deg"))
                .with_range(NumericRange::new(0.0, 360.0)),
        );
        document
            .compute_layout(UiSize::new(240.0, 120.0), &mut ApproxTextMeasurer)
            .expect("layout");

        let text_node = document.node(document.node(node).children[0]);
        let UiContent::Text(text) = &text_node.content else {
            panic!("drag value child should be text");
        };
        assert_eq!(text.text, "42.3 deg");
        assert_eq!(text.intrinsic_text.as_deref(), Some("360.0 deg"));
        assert_eq!(text.style.wrap, TextWrap::None);
        assert!(
            document.node(node).layout.rect.width > 56.0,
            "{:?}",
            document.node(node).layout.rect
        );
    }
}