argui-platform 0.2.0

Native platform integrations for Argui applications
Documentation
use std::{
    collections::HashMap,
    sync::{Arc, RwLock},
};

use tray_icon::{
    MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent,
    menu::{CheckMenuItem, IsMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
};

use crate::{
    ApplicationId, IconSet, TrayAction, TrayConfig, TrayEvent, TrayItemId, TrayMenuItem,
    TrayPointerButton, native_tray::TrayEventHandler,
};

type Actions = Arc<RwLock<HashMap<String, (TrayItemId, TrayAction)>>>;

pub(super) struct PlatformTray {
    tray: TrayIcon,
    actions: Actions,
}

impl PlatformTray {
    pub(super) fn new(
        _application_id: &ApplicationId,
        config: TrayConfig,
        fallback_icons: &IconSet,
        handler: TrayEventHandler,
    ) -> Result<Self, String> {
        let actions = Arc::new(RwLock::new(HashMap::new()));
        install_event_handlers(Arc::clone(&actions), handler);
        let menu = build_menu(&config.menu, &actions)?;
        let mut builder = TrayIconBuilder::new()
            .with_menu(Box::new(menu))
            .with_menu_on_left_click(config.show_menu_on_left_click)
            .with_icon_as_template(config.icon_is_template);
        if let Some(tooltip) = &config.tooltip {
            builder = builder.with_tooltip(tooltip);
        }
        if let Some(title) = &config.title {
            builder = builder.with_title(title);
        }
        if let Some(icon) = native_icon(config.icon.as_ref().unwrap_or(fallback_icons))? {
            builder = builder.with_icon(icon);
        }
        let tray = builder.build().map_err(|error| error.to_string())?;
        tray.set_visible(config.visible)
            .map_err(|error| error.to_string())?;
        Ok(Self { tray, actions })
    }

    pub(super) fn sync(
        &mut self,
        config: TrayConfig,
        fallback_icons: &IconSet,
    ) -> Result<(), String> {
        let menu = build_menu(&config.menu, &self.actions)?;
        self.tray.set_menu(Some(Box::new(menu)));
        self.tray
            .set_tooltip(config.tooltip.as_deref())
            .map_err(|error| error.to_string())?;
        self.tray.set_title(config.title.as_deref());
        self.tray.set_icon_as_template(config.icon_is_template);
        self.tray
            .set_icon(native_icon(config.icon.as_ref().unwrap_or(fallback_icons))?)
            .map_err(|error| error.to_string())?;
        self.tray
            .set_visible(config.visible)
            .map_err(|error| error.to_string())?;
        self.tray
            .set_show_menu_on_left_click(config.show_menu_on_left_click);
        Ok(())
    }
}

fn install_event_handlers(actions: Actions, handler: TrayEventHandler) {
    let menu_handler = Arc::clone(&handler);
    MenuEvent::set_event_handler(Some(move |event: MenuEvent| {
        let action = actions
            .read()
            .ok()
            .and_then(|actions| actions.get(&event.id.0).cloned());
        if let Some((id, action)) = action {
            menu_handler(TrayEvent::Action { id, action });
        }
    }));
    TrayIconEvent::set_event_handler(Some(move |event: TrayIconEvent| match event {
        TrayIconEvent::Click {
            button,
            button_state: MouseButtonState::Up,
            ..
        } => handler(TrayEvent::Click {
            button: pointer_button(button),
            double: false,
        }),
        TrayIconEvent::DoubleClick { button, .. } => handler(TrayEvent::Click {
            button: pointer_button(button),
            double: true,
        }),
        _ => {}
    }));
}

fn pointer_button(button: MouseButton) -> TrayPointerButton {
    match button {
        MouseButton::Left => TrayPointerButton::Primary,
        MouseButton::Right => TrayPointerButton::Secondary,
        MouseButton::Middle => TrayPointerButton::Middle,
    }
}

fn native_icon(icons: &IconSet) -> Result<Option<tray_icon::Icon>, String> {
    icons
        .best_square(32)
        .map(|icon| {
            tray_icon::Icon::from_rgba(icon.rgba8.to_vec(), icon.width, icon.height)
                .map_err(|error| error.to_string())
        })
        .transpose()
}

fn build_menu(items: &[TrayMenuItem], actions: &Actions) -> Result<Menu, String> {
    let menu = Menu::new();
    let mut next_actions = HashMap::new();
    append_items(&menu, items, &mut next_actions)?;
    *actions
        .write()
        .map_err(|_| "tray action map is poisoned".to_owned())? = next_actions;
    Ok(menu)
}

trait AppendTarget {
    fn append_item(&self, item: &dyn IsMenuItem) -> tray_icon::menu::Result<()>;
}

impl AppendTarget for Menu {
    fn append_item(&self, item: &dyn IsMenuItem) -> tray_icon::menu::Result<()> {
        self.append(item)
    }
}

impl AppendTarget for Submenu {
    fn append_item(&self, item: &dyn IsMenuItem) -> tray_icon::menu::Result<()> {
        self.append(item)
    }
}

fn append_items(
    target: &impl AppendTarget,
    items: &[TrayMenuItem],
    actions: &mut HashMap<String, (TrayItemId, TrayAction)>,
) -> Result<(), String> {
    for item in items {
        match item {
            TrayMenuItem::Action {
                id,
                label,
                enabled,
                action,
            } => {
                register_action(actions, id, action)?;
                let item = MenuItem::with_id(id.as_str(), label, *enabled, None);
                target
                    .append_item(&item)
                    .map_err(|error| error.to_string())?;
            }
            TrayMenuItem::Check {
                id,
                label,
                enabled,
                checked,
                action,
            } => {
                register_action(actions, id, action)?;
                let item = CheckMenuItem::with_id(id.as_str(), label, *enabled, *checked, None);
                target
                    .append_item(&item)
                    .map_err(|error| error.to_string())?;
            }
            TrayMenuItem::Separator => {
                let item = PredefinedMenuItem::separator();
                target
                    .append_item(&item)
                    .map_err(|error| error.to_string())?;
            }
            TrayMenuItem::Submenu {
                id,
                label,
                enabled,
                items,
            } => {
                let submenu = Submenu::with_id(id.as_str(), label, *enabled);
                append_items(&submenu, items, actions)?;
                target
                    .append_item(&submenu)
                    .map_err(|error| error.to_string())?;
            }
        }
    }
    Ok(())
}

fn register_action(
    actions: &mut HashMap<String, (TrayItemId, TrayAction)>,
    id: &TrayItemId,
    action: &TrayAction,
) -> Result<(), String> {
    if actions
        .insert(id.as_str().to_owned(), (id.clone(), action.clone()))
        .is_some()
    {
        Err(format!("duplicate tray item id: {}", id.as_str()))
    } else {
        Ok(())
    }
}