zsui 0.3.0-alpha.2

Rust-first feature-gated native UI framework for Win32, AppKit, and Linux.
Documentation
use crate::{NativeDrawCommand, NativeDrawPlan, Rect, ViewHitTarget, ViewHitTargetKind};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct NativeMenuFlyoutAccessibilityItem {
    pub(crate) target: ViewHitTarget,
    pub(crate) label: String,
    pub(crate) enabled: bool,
}

impl NativeMenuFlyoutAccessibilityItem {
    pub(crate) const fn path(&self) -> crate::ZsMenuFlyoutPath {
        match self.target.kind {
            ViewHitTargetKind::MenuFlyoutItem { path, .. } => path,
            _ => unreachable!(),
        }
    }

    pub(crate) const fn expanded(&self) -> Option<bool> {
        match self.target.kind {
            ViewHitTargetKind::MenuFlyoutItem {
                row_kind: crate::ZsMenuFlyoutRowKind::Submenu,
                expanded,
                ..
            } => Some(expanded),
            _ => None,
        }
    }

    pub(crate) const fn checked(&self) -> Option<bool> {
        match self.target.kind {
            ViewHitTargetKind::MenuFlyoutItem {
                row_kind: crate::ZsMenuFlyoutRowKind::Command { checked },
                ..
            } => Some(checked),
            _ => None,
        }
    }

    pub(crate) const fn highlighted(&self) -> bool {
        matches!(
            self.target.kind,
            ViewHitTargetKind::MenuFlyoutItem {
                highlighted: true,
                ..
            }
        )
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct NativeMenuFlyoutAccessibilitySnapshot {
    pub(crate) widget: crate::WidgetId,
    pub(crate) bounds: Rect,
    pub(crate) items: Vec<NativeMenuFlyoutAccessibilityItem>,
}

impl NativeMenuFlyoutAccessibilitySnapshot {
    #[cfg(any(windows, target_os = "macos"))]
    pub(crate) fn item(
        &self,
        path: crate::ZsMenuFlyoutPath,
    ) -> Option<&NativeMenuFlyoutAccessibilityItem> {
        self.items.iter().find(|item| item.path() == path)
    }

    pub(crate) fn highlighted_item(&self) -> Option<&NativeMenuFlyoutAccessibilityItem> {
        self.items.iter().find(|item| item.highlighted())
    }
}

pub(crate) fn native_menu_flyout_accessibility_snapshot(
    plan: &NativeDrawPlan,
    interaction: &crate::ViewInteractionPlan,
    menu: Option<&crate::MenuSpec>,
) -> Option<NativeMenuFlyoutAccessibilitySnapshot> {
    let mut widget = None;
    let mut bounds = None;
    let mut items = Vec::new();
    for target in interaction.hit_targets.iter().copied() {
        let ViewHitTargetKind::MenuFlyoutItem { row_kind, .. } = target.kind else {
            continue;
        };
        if matches!(row_kind, crate::ZsMenuFlyoutRowKind::Separator) {
            continue;
        }
        widget.get_or_insert(target.widget);
        bounds = Some(union_rect(bounds, target.bounds));
        let label = match target.kind {
            ViewHitTargetKind::MenuFlyoutItem { path, .. } => menu
                .and_then(|menu| crate::menu_flyout::menu_flyout_item(menu, path))
                .and_then(menu_item_label)
                .map(str::to_string)
                .unwrap_or_else(|| native_accessible_label(plan, target)),
            _ => native_accessible_label(plan, target),
        };
        let enabled = match target.kind {
            ViewHitTargetKind::MenuFlyoutItem { path, .. } => menu
                .and_then(|menu| crate::menu_flyout::menu_flyout_item(menu, path))
                .is_none_or(menu_item_enabled),
            _ => true,
        };
        items.push(NativeMenuFlyoutAccessibilityItem {
            target,
            label,
            enabled,
        });
    }
    Some(NativeMenuFlyoutAccessibilitySnapshot {
        widget: widget?,
        bounds: bounds?,
        items,
    })
}

fn menu_item_label(item: &crate::MenuItemSpec) -> Option<&str> {
    match item {
        crate::MenuItemSpec::Command { label, .. } | crate::MenuItemSpec::Submenu { label, .. } => {
            Some(label)
        }
        crate::MenuItemSpec::Separator => None,
    }
}

fn menu_item_enabled(item: &crate::MenuItemSpec) -> bool {
    match item {
        crate::MenuItemSpec::Command { enabled, .. }
        | crate::MenuItemSpec::Submenu { enabled, .. } => *enabled,
        crate::MenuItemSpec::Separator => false,
    }
}

pub(crate) fn native_accessible_label(plan: &NativeDrawPlan, target: ViewHitTarget) -> String {
    let mut best: Option<(i64, &str)> = None;
    for command in &plan.commands {
        let NativeDrawCommand::Text(text) = command else {
            continue;
        };
        let value = text.text.trim();
        if value.is_empty() {
            continue;
        }
        let overlap = overlap_area(target.bounds, text.bounds);
        if overlap > 0 && best.is_none_or(|(score, _)| overlap > score) {
            best = Some((overlap, value));
        }
    }
    best.map(|(_, value)| value.to_string())
        .unwrap_or_else(|| format!("Menu item {}", target.widget.0))
}

fn union_rect(current: Option<Rect>, next: Rect) -> Rect {
    let Some(current) = current else {
        return next;
    };
    let left = current.x.min(next.x);
    let top = current.y.min(next.y);
    let right = current
        .x
        .saturating_add(current.width.max(0))
        .max(next.x.saturating_add(next.width.max(0)));
    let bottom = current
        .y
        .saturating_add(current.height.max(0))
        .max(next.y.saturating_add(next.height.max(0)));
    Rect {
        x: left,
        y: top,
        width: right.saturating_sub(left),
        height: bottom.saturating_sub(top),
    }
}

fn overlap_area(left: Rect, right: Rect) -> i64 {
    let x0 = left.x.max(right.x);
    let y0 = left.y.max(right.y);
    let x1 = left
        .x
        .saturating_add(left.width.max(0))
        .min(right.x.saturating_add(right.width.max(0)));
    let y1 = left
        .y
        .saturating_add(left.height.max(0))
        .min(right.y.saturating_add(right.height.max(0)));
    i64::from((x1 - x0).max(0)) * i64::from((y1 - y0).max(0))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{NativeDrawTextCommand, SemanticTextStyle, ZsuiThemeMode};

    #[test]
    fn menu_snapshot_keeps_native_labels_and_recursive_state() {
        let widget = crate::WidgetId(9);
        let checked = ViewHitTarget::with_kind(
            widget,
            Rect {
                x: 10,
                y: 20,
                width: 160,
                height: 28,
            },
            ViewHitTargetKind::MenuFlyoutItem {
                path: crate::ZsMenuFlyoutPath::root(0),
                row_kind: crate::ZsMenuFlyoutRowKind::Command { checked: true },
                expanded: false,
                highlighted: false,
            },
        );
        let submenu = ViewHitTarget::with_kind(
            widget,
            Rect {
                y: 48,
                ..checked.bounds
            },
            ViewHitTargetKind::MenuFlyoutItem {
                path: crate::ZsMenuFlyoutPath::root(1),
                row_kind: crate::ZsMenuFlyoutRowKind::Submenu,
                expanded: true,
                highlighted: true,
            },
        );
        let plan = NativeDrawPlan::new([
            NativeDrawCommand::Text(NativeDrawTextCommand {
                color: None,
                size: None,
                text: "自动保存 / Auto save".to_string(),
                bounds: checked.bounds,
                style: SemanticTextStyle::body(),
            }),
            NativeDrawCommand::Text(NativeDrawTextCommand {
                color: None,
                size: None,
                text: "更多 / More".to_string(),
                bounds: submenu.bounds,
                style: SemanticTextStyle::body(),
            }),
        ])
        .theme_mode(ZsuiThemeMode::Light);
        let snapshot = native_menu_flyout_accessibility_snapshot(
            &plan,
            &crate::ViewInteractionPlan::new([checked, submenu]),
            Some(
                &crate::MenuSpec::new()
                    .item("自动保存 / Auto save", crate::Command::custom("auto-save"))
                    .submenu(
                        "更多 / More",
                        crate::MenuSpec::new().item("复制 / Copy", crate::Command::custom("copy")),
                    ),
            ),
        )
        .expect("open MenuFlyout should produce an accessibility snapshot");

        assert_eq!(snapshot.widget, widget);
        assert_eq!(snapshot.bounds.height, 56);
        assert_eq!(snapshot.items[0].label, "自动保存 / Auto save");
        assert!(snapshot.items[0].enabled);
        assert_eq!(snapshot.items[0].checked(), Some(true));
        assert_eq!(snapshot.items[0].expanded(), None);
        assert_eq!(snapshot.items[1].label, "更多 / More");
        assert_eq!(snapshot.items[1].expanded(), Some(true));
        assert_eq!(snapshot.highlighted_item(), Some(&snapshot.items[1]));
    }
}