zsui 0.3.0-alpha.2

Rust-first feature-gated native UI framework for Win32, AppKit, and Linux.
Documentation
#[cfg(any(
    test,
    target_os = "macos",
    all(target_os = "linux", not(target_env = "ohos"))
))]
use std::collections::BTreeMap;

#[cfg(any(
    test,
    target_os = "macos",
    all(target_os = "linux", not(target_env = "ohos"))
))]
use crate::{Command, DesktopEvent, MenuItemSpec, MenuSpec, WindowId, ZsAccelerator};
#[cfg(any(
    test,
    target_os = "macos",
    all(target_os = "linux", not(target_env = "ohos"))
))]
use crate::{ZsuiError, ZsuiResult};

#[cfg(any(
    test,
    target_os = "macos",
    all(target_os = "linux", not(target_env = "ohos"))
))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct NativeMenuModel {
    pub title: Option<String>,
    pub items: Vec<NativeMenuNode>,
    commands: BTreeMap<u32, NativeMenuCommandBinding>,
    next_native_id: u32,
}

#[cfg(any(
    test,
    target_os = "macos",
    all(target_os = "linux", not(target_env = "ohos"))
))]
impl NativeMenuModel {
    pub fn lower(spec: &MenuSpec, first_native_id: u32) -> ZsuiResult<Self> {
        if first_native_id == 0 {
            return Err(ZsuiError::invalid_spec(
                "menu.native_id",
                "native menu command ids must start above zero",
            ));
        }

        let mut lowerer = NativeMenuLowerer {
            next_native_id: first_native_id,
            commands: BTreeMap::new(),
        };
        let items = lowerer.lower_items(&spec.items, true)?;
        Ok(Self {
            title: spec.title.clone(),
            items,
            commands: lowerer.commands,
            next_native_id: lowerer.next_native_id,
        })
    }

    pub fn command_count(&self) -> usize {
        self.commands.len()
    }

    pub fn next_native_id(&self) -> u32 {
        self.next_native_id
    }

    pub fn command_for_native_id(&self, native_id: u32) -> Option<Command> {
        self.commands
            .get(&native_id)
            .filter(|binding| binding.enabled)
            .map(|binding| binding.command.clone())
    }
}

#[cfg(any(
    test,
    target_os = "macos",
    all(target_os = "linux", not(target_env = "ohos"))
))]
pub(crate) fn native_menu_command_event(
    model: Option<&NativeMenuModel>,
    window: Option<WindowId>,
    native_id: u32,
) -> Option<DesktopEvent> {
    Some(DesktopEvent::MenuCommand {
        window: window?,
        command: model?.command_for_native_id(native_id)?,
    })
}

#[cfg(any(
    test,
    target_os = "macos",
    all(target_os = "linux", not(target_env = "ohos"))
))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum NativeMenuNode {
    Command {
        native_id: u32,
        label: String,
        enabled: bool,
        checked: bool,
        accelerator: Option<ZsAccelerator>,
    },
    Separator,
    Submenu {
        label: String,
        enabled: bool,
        items: Vec<NativeMenuNode>,
    },
}

#[cfg(any(
    test,
    target_os = "macos",
    all(target_os = "linux", not(target_env = "ohos"))
))]
#[derive(Debug, Clone, PartialEq, Eq)]
struct NativeMenuCommandBinding {
    command: Command,
    enabled: bool,
}

#[cfg(any(
    test,
    target_os = "macos",
    all(target_os = "linux", not(target_env = "ohos"))
))]
struct NativeMenuLowerer {
    next_native_id: u32,
    commands: BTreeMap<u32, NativeMenuCommandBinding>,
}

#[cfg(any(
    test,
    target_os = "macos",
    all(target_os = "linux", not(target_env = "ohos"))
))]
impl NativeMenuLowerer {
    fn lower_items(
        &mut self,
        items: &[MenuItemSpec],
        ancestors_enabled: bool,
    ) -> ZsuiResult<Vec<NativeMenuNode>> {
        items
            .iter()
            .map(|item| match item {
                MenuItemSpec::Command {
                    label,
                    command,
                    enabled,
                    checked,
                    accelerator,
                    ..
                } => {
                    let enabled = ancestors_enabled && *enabled;
                    let native_id = self.allocate_native_id()?;
                    self.commands.insert(
                        native_id,
                        NativeMenuCommandBinding {
                            command: command.clone(),
                            enabled,
                        },
                    );
                    Ok(NativeMenuNode::Command {
                        native_id,
                        label: label.clone(),
                        enabled,
                        checked: *checked,
                        accelerator: accelerator
                            .map(|accelerator| {
                                accelerator.validate()?;
                                Ok(accelerator)
                            })
                            .transpose()?,
                    })
                }
                MenuItemSpec::Separator => Ok(NativeMenuNode::Separator),
                MenuItemSpec::Submenu {
                    label,
                    enabled,
                    menu,
                    ..
                } => {
                    let enabled = ancestors_enabled && *enabled;
                    Ok(NativeMenuNode::Submenu {
                        label: label.clone(),
                        enabled,
                        items: self.lower_items(&menu.items, enabled)?,
                    })
                }
            })
            .collect()
    }

    fn allocate_native_id(&mut self) -> ZsuiResult<u32> {
        let native_id = self.next_native_id;
        self.next_native_id = self.next_native_id.checked_add(1).ok_or_else(|| {
            ZsuiError::invalid_spec(
                "menu.native_id",
                "native menu command id range is exhausted",
            )
        })?;
        Ok(native_id)
    }
}

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

    #[test]
    fn lowering_preserves_nested_state_and_routes_only_enabled_commands() {
        let mut file_menu = MenuSpec::new()
            .item("Open", Command::custom("file.open"))
            .separator()
            .submenu(
                "Recent",
                MenuSpec::new().item("One", Command::custom("file.recent.one")),
            );
        file_menu
            .items
            .push(MenuItemSpec::command("Disabled", Command::custom("file.disabled")).disabled());
        let menu = MenuSpec::new()
            .title("Application")
            .submenu("File", file_menu);

        let model = NativeMenuModel::lower(&menu, 41).expect("menu should lower");

        assert_eq!(model.title.as_deref(), Some("Application"));
        assert_eq!(model.command_count(), 3);
        assert_eq!(model.next_native_id(), 44);
        assert_eq!(
            model.command_for_native_id(41),
            Some(Command::custom("file.open"))
        );
        assert_eq!(
            model.command_for_native_id(42),
            Some(Command::custom("file.recent.one"))
        );
        assert_eq!(model.command_for_native_id(43), None);
        assert!(matches!(
            &model.items[0],
            NativeMenuNode::Submenu { items, .. }
                if matches!(items[1], NativeMenuNode::Separator)
        ));
    }

    #[test]
    fn invalid_typed_accelerators_fail_before_reaching_a_backend() {
        let menu = MenuSpec {
            id: None,
            title: None,
            items: vec![MenuItemSpec::command("Invalid", Command::Quit)
                .accelerator(ZsAccelerator::new(crate::ZsAcceleratorKey::Function(25)))],
        };

        assert!(NativeMenuModel::lower(&menu, 1).is_err());
        assert!(NativeMenuModel::lower(&MenuSpec::new(), 0).is_err());
    }

    #[test]
    fn command_event_requires_current_window_model_and_enabled_binding() {
        let mut menu = MenuSpec::new().item("Open", Command::custom("file.open"));
        menu.items
            .push(MenuItemSpec::command("Disabled", Command::Quit).disabled());
        let model = NativeMenuModel::lower(&menu, 90).expect("menu model");

        assert_eq!(
            native_menu_command_event(Some(&model), Some(WindowId(7)), 90),
            Some(DesktopEvent::MenuCommand {
                window: WindowId(7),
                command: Command::custom("file.open"),
            })
        );
        assert_eq!(
            native_menu_command_event(Some(&model), Some(WindowId(7)), 91),
            None
        );
        assert_eq!(native_menu_command_event(Some(&model), None, 90), None);
        assert_eq!(native_menu_command_event(None, Some(WindowId(7)), 90), None);
    }
}