zsui 0.3.0-alpha.2

Rust-first feature-gated native UI framework for Win32, AppKit, and Linux.
Documentation
use std::cell::RefCell;
use std::fmt;
use std::rc::Rc;
use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};

use objc2::rc::Retained;
use objc2::{define_class, msg_send, sel, DefinedClass, MainThreadMarker, MainThreadOnly};
use objc2_app_kit::{
    NSApplication, NSControlStateValueOn, NSEventModifierFlags, NSMenu, NSMenuItem,
};
use objc2_foundation::{NSObject, NSObjectProtocol, NSString};

use crate::native_menu::{native_menu_command_event, NativeMenuModel, NativeMenuNode};
use crate::{DesktopEvent, MenuService, MenuSpec, WindowId, ZsAccelerator, ZsuiError, ZsuiResult};

struct AppKitMenuTargetIvars {
    sender: Sender<u32>,
    command_handler: Rc<RefCell<Option<Rc<dyn Fn(u32)>>>>,
}

impl fmt::Debug for AppKitMenuTargetIvars {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("AppKitMenuTargetIvars")
            .finish_non_exhaustive()
    }
}

define_class!(
    #[unsafe(super = NSObject)]
    #[thread_kind = MainThreadOnly]
    #[ivars = AppKitMenuTargetIvars]
    struct ZsuiAppKitMenuTarget;

    impl ZsuiAppKitMenuTarget {
        #[unsafe(method(zsuiMenuCommand:))]
        fn zsui_menu_command(&self, sender: &NSMenuItem) {
            let native_id = sender.tag();
            if let Ok(native_id) = u32::try_from(native_id) {
                if let Some(handler) = self.ivars().command_handler.borrow().as_ref().cloned() {
                    handler(native_id);
                } else {
                    let _ = self.ivars().sender.send(native_id);
                }
            }
        }
    }

    unsafe impl NSObjectProtocol for ZsuiAppKitMenuTarget {}
);

impl ZsuiAppKitMenuTarget {
    fn new(
        mtm: MainThreadMarker,
        sender: Sender<u32>,
        command_handler: Rc<RefCell<Option<Rc<dyn Fn(u32)>>>>,
    ) -> Retained<Self> {
        let this = Self::alloc(mtm).set_ivars(AppKitMenuTargetIvars {
            sender,
            command_handler,
        });
        unsafe { msg_send![super(this), init] }
    }
}

pub struct MacosAppKitMenuService {
    window: Option<WindowId>,
    model: Option<NativeMenuModel>,
    menu: Option<Retained<NSMenu>>,
    target: Retained<ZsuiAppKitMenuTarget>,
    command_handler: Rc<RefCell<Option<Rc<dyn Fn(u32)>>>>,
    receiver: Receiver<u32>,
    next_native_id: u32,
}

impl fmt::Debug for MacosAppKitMenuService {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("MacosAppKitMenuService")
            .field("window", &self.window)
            .field(
                "command_count",
                &self
                    .model
                    .as_ref()
                    .map(NativeMenuModel::command_count)
                    .unwrap_or(0),
            )
            .finish_non_exhaustive()
    }
}

impl MacosAppKitMenuService {
    pub fn new() -> ZsuiResult<Self> {
        let mtm = appkit_menu_main_thread_marker()?;
        let (sender, receiver) = mpsc::channel();
        let command_handler = Rc::new(RefCell::new(None));
        Ok(Self {
            window: None,
            model: None,
            menu: None,
            target: ZsuiAppKitMenuTarget::new(mtm, sender, Rc::clone(&command_handler)),
            command_handler,
            receiver,
            next_native_id: 1,
        })
    }

    pub fn poll_menu_command(&mut self) -> Option<DesktopEvent> {
        loop {
            let native_id = match self.receiver.try_recv() {
                Ok(native_id) => native_id,
                Err(TryRecvError::Empty | TryRecvError::Disconnected) => return None,
            };
            if let Some(event) =
                native_menu_command_event(self.model.as_ref(), self.window, native_id)
            {
                return Some(event);
            }
        }
    }

    pub fn native_command_count(&self) -> usize {
        self.model
            .as_ref()
            .map(NativeMenuModel::command_count)
            .unwrap_or(0)
    }

    pub(crate) fn set_event_handler(&mut self, handler: impl Fn(DesktopEvent) + 'static) {
        let model = self.model.clone();
        let window = self.window;
        *self.command_handler.borrow_mut() = Some(Rc::new(move |native_id| {
            if let Some(event) = native_menu_command_event(model.as_ref(), window, native_id) {
                handler(event);
            }
        }));
    }

    pub(crate) fn set_command_handler(&mut self, handler: impl Fn(crate::Command) + 'static) {
        let model = self.model.clone();
        *self.command_handler.borrow_mut() = Some(Rc::new(move |native_id| {
            if let Some(command) = model
                .as_ref()
                .and_then(|model| model.command_for_native_id(native_id))
            {
                handler(command);
            }
        }));
    }

    pub(crate) fn set_detached_menu(&mut self, menu: &MenuSpec) -> ZsuiResult<()> {
        self.set_menu(None, Some(menu), false)
    }

    pub(crate) fn native_menu(&self) -> Option<&NSMenu> {
        self.menu.as_deref()
    }

    pub(crate) fn invoke_first_enabled_command_for_proof(&self) -> bool {
        self.menu
            .as_deref()
            .is_some_and(perform_first_enabled_appkit_menu_action)
    }
}

fn perform_first_enabled_appkit_menu_action(menu: &NSMenu) -> bool {
    for index in 0..menu.numberOfItems() {
        let Some(item) = menu.itemAtIndex(index) else {
            continue;
        };
        if let Some(submenu) = item.submenu() {
            if perform_first_enabled_appkit_menu_action(&submenu) {
                return true;
            }
            continue;
        }
        if item.isEnabled() && item.tag() > 0 {
            menu.performActionForItemAtIndex(index);
            return true;
        }
    }
    false
}

impl MenuService for MacosAppKitMenuService {
    fn set_window_menu(&mut self, window: WindowId, menu: Option<&MenuSpec>) -> ZsuiResult<()> {
        self.set_menu(Some(window), menu, true)
    }
}

impl MacosAppKitMenuService {
    fn set_menu(
        &mut self,
        window: Option<WindowId>,
        menu: Option<&MenuSpec>,
        install_as_main_menu: bool,
    ) -> ZsuiResult<()> {
        let mtm = appkit_menu_main_thread_marker()?;
        let application = NSApplication::sharedApplication(mtm);
        self.command_handler.borrow_mut().take();
        detach_owned_appkit_menu(&application, self.menu.as_ref());
        let Some(menu_spec) = menu else {
            self.window = None;
            self.model = None;
            self.menu = None;
            return Ok(());
        };

        let model = NativeMenuModel::lower(menu_spec, self.next_native_id)?;
        let native_menu = build_appkit_menu(&model, &self.target, mtm);
        if install_as_main_menu {
            application.setMainMenu(Some(&native_menu));
        }
        self.next_native_id = model.next_native_id();
        self.window = window;
        self.model = Some(model);
        self.menu = Some(native_menu);
        Ok(())
    }
}

impl Drop for MacosAppKitMenuService {
    fn drop(&mut self) {
        let Some(mtm) = MainThreadMarker::new() else {
            return;
        };
        let application = NSApplication::sharedApplication(mtm);
        detach_owned_appkit_menu(&application, self.menu.as_ref());
    }
}

fn appkit_menu_main_thread_marker() -> ZsuiResult<MainThreadMarker> {
    MainThreadMarker::new().ok_or_else(|| {
        ZsuiError::host(
            "macos_appkit_menu",
            "AppKit menus must be installed and polled from the macOS main thread",
        )
    })
}

fn detach_owned_appkit_menu(application: &NSApplication, owned_menu: Option<&Retained<NSMenu>>) {
    let Some(owned_menu) = owned_menu else {
        return;
    };
    let Some(current_menu) = application.mainMenu() else {
        return;
    };
    let current_menu: &NSMenu = current_menu.as_ref();
    let owned_menu: &NSMenu = owned_menu.as_ref();
    if std::ptr::eq(current_menu, owned_menu) {
        application.setMainMenu(None);
    }
}

fn build_appkit_menu(
    model: &NativeMenuModel,
    target: &ZsuiAppKitMenuTarget,
    mtm: MainThreadMarker,
) -> Retained<NSMenu> {
    let title = NSString::from_str(model.title.as_deref().unwrap_or(""));
    let menu = NSMenu::initWithTitle(NSMenu::alloc(mtm), &title);
    append_appkit_menu_nodes(&menu, &model.items, target, mtm);
    menu
}

fn append_appkit_menu_nodes(
    menu: &NSMenu,
    nodes: &[NativeMenuNode],
    target: &ZsuiAppKitMenuTarget,
    mtm: MainThreadMarker,
) {
    for node in nodes {
        match node {
            NativeMenuNode::Command {
                native_id,
                label,
                enabled,
                checked,
                accelerator,
            } => {
                let title = NSString::from_str(label);
                let key_equivalent = accelerator
                    .as_ref()
                    .and_then(crate::platform_menu_accelerator::appkit_key_equivalent)
                    .unwrap_or_default();
                let key_equivalent = NSString::from_str(&key_equivalent);
                let item = unsafe {
                    NSMenuItem::initWithTitle_action_keyEquivalent(
                        NSMenuItem::alloc(mtm),
                        &title,
                        Some(sel!(zsuiMenuCommand:)),
                        &key_equivalent,
                    )
                };
                unsafe { item.setTarget(Some(target)) };
                item.setTag(*native_id as isize);
                item.setEnabled(*enabled);
                if *checked {
                    item.setState(NSControlStateValueOn);
                }
                if let Some(accelerator) = accelerator {
                    item.setKeyEquivalentModifierMask(appkit_modifier_flags(accelerator));
                }
                menu.addItem(&item);
            }
            NativeMenuNode::Separator => menu.addItem(&NSMenuItem::separatorItem(mtm)),
            NativeMenuNode::Submenu {
                label,
                enabled,
                items,
            } => {
                let title = NSString::from_str(label);
                let item = unsafe {
                    NSMenuItem::initWithTitle_action_keyEquivalent(
                        NSMenuItem::alloc(mtm),
                        &title,
                        None,
                        &NSString::new(),
                    )
                };
                item.setEnabled(*enabled);
                let submenu = NSMenu::initWithTitle(NSMenu::alloc(mtm), &title);
                append_appkit_menu_nodes(&submenu, items, target, mtm);
                item.setSubmenu(Some(&submenu));
                menu.addItem(&item);
            }
        }
    }
}

fn appkit_modifier_flags(accelerator: &ZsAccelerator) -> NSEventModifierFlags {
    let mut flags = NSEventModifierFlags::empty();
    if accelerator.uses_primary() || accelerator.uses_super() {
        flags |= NSEventModifierFlags::Command;
    }
    if accelerator.uses_alt() {
        flags |= NSEventModifierFlags::Option;
    }
    if accelerator.uses_shift() {
        flags |= NSEventModifierFlags::Shift;
    }
    flags
}

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

    #[test]
    fn appkit_menu_service_implements_safe_public_contract() {
        fn assert_service<T: MenuService>() {}
        assert_service::<MacosAppKitMenuService>();
    }
}