operad 6.1.0

A cross-platform GUI library for Rust.
Documentation
//! Menu bar widget implementations.

use taffy::prelude::{
    AlignItems, Dimension, Display, FlexDirection, Rect as TaffyRect, Size as TaffySize, Style,
};

use crate::{
    length, AccessibilityMeta, AccessibilityRole, ClipBehavior, ColorRgba, InputBehavior,
    LayoutStyle, TextStyle, UiDocument, UiNode, UiNodeId, UiNodeStyle, UiRect, UiVisual,
    WidgetActionBinding,
};

use super::menu::{
    button_like_with_input, first_navigable_index, length_percentage, menu_selection_at_path,
    next_navigable_index, set_active_descendant, AnchoredPopup, MenuItem, MenuSelection,
    NavigationDirection, PopupAlign, PopupPlacement, PopupSide,
};
use super::menu_list::{menu_list_popup, MenuListNodes, MenuListOptions};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MenuBarMenu {
    pub id: String,
    pub label: String,
    pub items: Vec<MenuItem>,
    pub enabled: bool,
}

impl MenuBarMenu {
    pub fn new(id: impl Into<String>, label: impl Into<String>, items: Vec<MenuItem>) -> Self {
        Self {
            id: id.into(),
            label: label.into(),
            items,
            enabled: true,
        }
    }

    pub fn disabled(mut self) -> Self {
        self.enabled = false;
        self
    }
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MenuBarState {
    pub open_menu: Option<usize>,
    pub active_item: Option<usize>,
}

impl MenuBarState {
    pub fn open(&mut self, menus: &[MenuBarMenu], index: usize) -> bool {
        let Some(menu) = menus.get(index) else {
            return false;
        };
        if !menu.enabled {
            return false;
        }
        self.open_menu = Some(index);
        self.active_item = first_navigable_index(&menu.items);
        true
    }

    pub fn close(&mut self) {
        self.open_menu = None;
        self.active_item = None;
    }

    pub fn move_menu(
        &mut self,
        menus: &[MenuBarMenu],
        direction: NavigationDirection,
    ) -> Option<usize> {
        let index = next_enabled_menu_bar_index(menus, self.open_menu, direction)?;
        self.open(menus, index);
        Some(index)
    }

    pub fn move_item(
        &mut self,
        menus: &[MenuBarMenu],
        direction: NavigationDirection,
    ) -> Option<usize> {
        let menu = self.open_menu.and_then(|index| menus.get(index))?;
        let active = next_navigable_index(&menu.items, self.active_item, direction);
        self.active_item = active;
        active
    }

    pub fn select_active(&self, menus: &[MenuBarMenu]) -> Option<MenuSelection> {
        let menu_index = self.open_menu?;
        let item_index = self.active_item?;
        let menu = menus.get(menu_index)?;
        let mut selection = menu_selection_at_path(&menu.items, &[item_index])?;
        selection.index_path.insert(0, menu_index);
        Some(selection)
    }

    pub fn set_active_item_by_id(&mut self, menus: &[MenuBarMenu], id: &str) -> Option<usize> {
        let menu_index = self.open_menu?;
        let menu = menus.get(menu_index)?;
        let item_index = menu
            .items
            .iter()
            .position(|item| item.id.as_deref() == Some(id) && item.enabled)?;
        self.active_item = Some(item_index);
        Some(item_index)
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct MenuBarAnchors {
    pub anchors: Vec<UiRect>,
    pub viewport: UiRect,
}

#[derive(Debug, Clone)]
pub struct MenuBarOptions {
    pub layout: LayoutStyle,
    pub visual: UiVisual,
    pub button_visual: UiVisual,
    pub active_button_visual: UiVisual,
    pub text_style: TextStyle,
    pub disabled_text_style: TextStyle,
    pub popup_placement: PopupPlacement,
    pub popup_menu: MenuListOptions,
    pub action_prefix: Option<String>,
}

impl Default for MenuBarOptions {
    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::percent(1.0),
                    height: length(30.0),
                },
                ..Default::default()
            }),
            visual: UiVisual::panel(ColorRgba::new(22, 27, 34, 255), None, 0.0),
            button_visual: UiVisual::TRANSPARENT,
            active_button_visual: UiVisual::panel(ColorRgba::new(45, 55, 68, 255), None, 2.0),
            text_style: TextStyle::default(),
            disabled_text_style: TextStyle {
                color: ColorRgba::new(138, 148, 164, 255),
                ..Default::default()
            },
            popup_placement: PopupPlacement::new(PopupSide::Bottom, PopupAlign::Start),
            popup_menu: MenuListOptions::default(),
            action_prefix: None,
        }
    }
}

impl MenuBarOptions {
    pub fn with_action_prefix(mut self, prefix: impl Into<String>) -> Self {
        self.action_prefix = Some(prefix.into());
        self
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MenuBarNodes {
    pub root: UiNodeId,
    pub buttons: Vec<UiNodeId>,
    pub popup: Option<MenuListNodes>,
}

pub fn menu_bar(
    document: &mut UiDocument,
    parent: UiNodeId,
    name: impl Into<String>,
    menus: &[MenuBarMenu],
    state: &MenuBarState,
    anchors: Option<&MenuBarAnchors>,
    options: MenuBarOptions,
) -> MenuBarNodes {
    let name = name.into();
    let root = document.add_child(
        parent,
        UiNode::container(
            name.clone(),
            UiNodeStyle {
                layout: options.layout.style,
                clip: ClipBehavior::Clip,
                ..Default::default()
            },
        )
        .with_visual(options.visual)
        .with_accessibility(AccessibilityMeta::new(AccessibilityRole::MenuBar).label(name.clone())),
    );
    let mut buttons = Vec::with_capacity(menus.len());
    for (index, menu) in menus.iter().enumerate() {
        let active = state.open_menu == Some(index);
        let visual = if active {
            options.active_button_visual
        } else {
            options.button_visual
        };
        let text_style = if menu.enabled {
            options.text_style.clone()
        } else {
            options.disabled_text_style.clone()
        };
        let button = button_like_with_input(
            document,
            root,
            format!("{name}.{}", menu.id),
            &menu.label,
            LayoutStyle::from_taffy_style(Style {
                display: Display::Flex,
                flex_direction: FlexDirection::Row,
                align_items: Some(AlignItems::Center),
                size: TaffySize {
                    width: Dimension::auto(),
                    height: Dimension::percent(1.0),
                },
                padding: TaffyRect {
                    left: length_percentage(10.0),
                    right: length_percentage(10.0),
                    top: length_percentage(0.0),
                    bottom: length_percentage(0.0),
                },
                ..Default::default()
            }),
            visual,
            text_style,
            if menu.enabled {
                InputBehavior::BUTTON
            } else {
                InputBehavior::NONE
            },
        );
        if menu.enabled {
            if let Some(prefix) = &options.action_prefix {
                document.node_mut(button).action =
                    Some(WidgetActionBinding::action(format!("{prefix}.{}", menu.id)));
            }
        }
        document.node_mut(button).accessibility = Some(menu_button_accessibility(menu, active));
        buttons.push(button);
    }
    set_active_descendant(
        document,
        root,
        state
            .open_menu
            .filter(|index| menus.get(*index).is_some_and(|menu| menu.enabled))
            .and_then(|index| buttons.get(index).copied()),
    );

    let popup = state
        .open_menu
        .and_then(|index| Some((index, menus.get(index)?)))
        .and_then(|(index, menu)| {
            let anchors = anchors?;
            let anchor = *anchors.anchors.get(index)?;
            Some(menu_list_popup(
                document,
                parent,
                format!("{name}.{}.popup", menu.id),
                AnchoredPopup::new(anchor, anchors.viewport, options.popup_placement),
                &menu.items,
                state.active_item,
                options.popup_menu,
            ))
        });

    MenuBarNodes {
        root,
        buttons,
        popup,
    }
}

fn next_enabled_menu_bar_index(
    menus: &[MenuBarMenu],
    current: Option<usize>,
    direction: NavigationDirection,
) -> Option<usize> {
    let len = menus.len();
    if len == 0 {
        return None;
    }
    let start = match (current.filter(|index| *index < len), direction) {
        (Some(index), NavigationDirection::Next) => (index + 1) % len,
        (Some(index), NavigationDirection::Previous) => (index + len - 1) % len,
        (None, NavigationDirection::Next) => 0,
        (None, NavigationDirection::Previous) => len - 1,
    };
    for offset in 0..len {
        let index = match direction {
            NavigationDirection::Next => (start + offset) % len,
            NavigationDirection::Previous => (start + len - offset) % len,
        };
        if menus[index].enabled {
            return Some(index);
        }
    }
    None
}

fn menu_button_accessibility(menu: &MenuBarMenu, active: bool) -> AccessibilityMeta {
    let accessibility = AccessibilityMeta::new(AccessibilityRole::MenuItem)
        .label(menu.label.clone())
        .value(if active { "open" } else { "closed" })
        .expanded(active);
    if menu.enabled {
        accessibility.focusable()
    } else {
        accessibility.disabled()
    }
}

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

    #[test]
    fn menu_bar_state_sets_active_item_by_command_id() {
        let menus = vec![MenuBarMenu::new(
            "file",
            "File",
            vec![
                MenuItem::command("new", "New"),
                MenuItem::command("disabled", "Disabled").disabled(),
                MenuItem::command("open", "Open"),
            ],
        )];
        let mut state = MenuBarState {
            open_menu: Some(0),
            active_item: Some(0),
        };

        assert_eq!(state.set_active_item_by_id(&menus, "open"), Some(2));
        assert_eq!(state.active_item, Some(2));
        assert_eq!(state.set_active_item_by_id(&menus, "disabled"), None);
        assert_eq!(state.active_item, Some(2));
    }
}