kiln-app 0.1.0

Native desktop apps from real HTML, CSS and TypeScript, rendered without Chromium or a WebView
use std::cell::RefCell;
use std::rc::Rc;

use anyhow::{Context, Result};

pub struct MenuNode {
    pub id: String,
    pub label: String,
    pub accelerator: Option<String>,
    pub enabled: bool,
    pub children: Vec<MenuNode>,
}

impl MenuNode {
    fn is_separator(&self) -> bool {
        self.label == "-"
    }
}

#[derive(Default)]
pub struct Native {
    menu: Rc<RefCell<Vec<MenuNode>>>,
    tray_menu: Rc<RefCell<Vec<MenuNode>>>,
    tray_tooltip: RefCell<String>,
    realised: RefCell<Option<muda::Menu>>,
    tray: RefCell<Option<tray_icon::TrayIcon>>,
}

impl Native {
    pub fn set_menu(&self, flat: &[String]) {
        *self.menu.borrow_mut() = parse_menu(flat);
    }

    pub fn menu_snapshot(&self) -> String {
        let mut out = String::new();
        write_menu(&self.menu.borrow(), 0, &mut out);
        out
    }

    pub fn set_tray(&self, tooltip: &str, flat: &[String]) {
        *self.tray_tooltip.borrow_mut() = tooltip.to_string();
        *self.tray_menu.borrow_mut() = parse_menu(flat);
    }

    pub fn tray_snapshot(&self) -> String {
        let mut out = format!("tooltip {:?}\n", self.tray_tooltip.borrow());
        write_menu(&self.tray_menu.borrow(), 0, &mut out);
        out
    }

    pub fn realise(&self) -> Result<()> {
        let menu = muda::Menu::new();
        for node in self.menu.borrow().iter() {
            menu.append(&*build_item(node)?)
                .context("append menu item")?;
        }

        #[cfg(target_os = "macos")]
        menu.init_for_nsapp();

        *self.realised.borrow_mut() = Some(menu);

        if !self.tray_menu.borrow().is_empty() {
            let tray_menu = muda::Menu::new();
            for node in self.tray_menu.borrow().iter() {
                tray_menu
                    .append(&*build_item(node)?)
                    .context("append tray item")?;
            }

            let tray = tray_icon::TrayIconBuilder::new()
                .with_tooltip(self.tray_tooltip.borrow().as_str())
                .with_menu(Box::new(tray_menu))
                .with_icon(kiln_tray_icon()?)
                .build()
                .context("build tray icon")?;
            *self.tray.borrow_mut() = Some(tray);
        }

        Ok(())
    }

    pub fn drain_menu_events(&self) -> Vec<String> {
        let mut ids = Vec::new();
        while let Ok(event) = muda::MenuEvent::receiver().try_recv() {
            ids.push(event.id().0.clone());
        }
        ids
    }

    pub fn read_text(&self) -> Option<String> {
        arboard::Clipboard::new().ok()?.get_text().ok()
    }

    pub fn write_text(&self, value: &str) -> Result<()> {
        arboard::Clipboard::new()
            .context("open clipboard")?
            .set_text(value.to_string())
            .context("write clipboard")
    }

    pub fn notify(&self, title: &str, body: &str) -> Result<()> {
        notify_rust::Notification::new()
            .summary(title)
            .body(body)
            .show()
            .map(|_| ())
            .context("show notification")
    }

    pub fn open_file(&self, multiple: bool) -> Vec<String> {
        let dialog = rfd::FileDialog::new();
        let paths = if multiple {
            dialog.pick_files().unwrap_or_default()
        } else {
            dialog.pick_file().into_iter().collect()
        };
        paths
            .into_iter()
            .map(|path| path.to_string_lossy().into_owned())
            .collect()
    }

    pub fn save_file(&self, suggested: &str) -> Option<String> {
        rfd::FileDialog::new()
            .set_file_name(suggested)
            .save_file()
            .map(|path| path.to_string_lossy().into_owned())
    }

    pub fn message(&self, title: &str, body: &str) {
        rfd::MessageDialog::new()
            .set_title(title)
            .set_description(body)
            .show();
    }
}

fn kiln_tray_icon() -> Result<tray_icon::Icon> {
    const SIZE: u32 = 32;
    let mut rgba = Vec::with_capacity((SIZE * SIZE * 4) as usize);
    let centre = (SIZE as f32 - 1.0) / 2.0;

    for y in 0..SIZE {
        for x in 0..SIZE {
            let dx = x as f32 - centre;
            let dy = y as f32 - centre;
            let radius = (dx * dx + dy * dy).sqrt();
            let alpha = if radius <= centre { 255 } else { 0 };
            rgba.extend_from_slice(&[0xF5, 0xA9, 0x3C, alpha]);
        }
    }

    tray_icon::Icon::from_rgba(rgba, SIZE, SIZE).context("build tray icon bitmap")
}

fn accelerator(node: &MenuNode) -> Option<muda::accelerator::Accelerator> {
    use std::str::FromStr;
    node.accelerator
        .as_ref()
        .and_then(|text| muda::accelerator::Accelerator::from_str(text).ok())
}

fn build_item(node: &MenuNode) -> Result<Box<dyn muda::IsMenuItem>> {
    if node.is_separator() {
        return Ok(Box::new(muda::PredefinedMenuItem::separator()));
    }

    if node.children.is_empty() {
        return Ok(Box::new(muda::MenuItem::with_id(
            node.id.clone(),
            &node.label,
            node.enabled,
            accelerator(node),
        )));
    }

    let submenu = muda::Submenu::with_id(node.id.clone(), &node.label, node.enabled);
    for child in &node.children {
        submenu
            .append(&*build_item(child)?)
            .context("append submenu item")?;
    }
    Ok(Box::new(submenu))
}

fn parse_menu(flat: &[String]) -> Vec<MenuNode> {
    let mut roots: Vec<MenuNode> = Vec::new();

    for entry in flat.chunks_exact(5) {
        let depth: usize = entry[0].parse().unwrap_or(0);
        let node = MenuNode {
            id: entry[1].clone(),
            label: entry[2].clone(),
            accelerator: (!entry[3].is_empty()).then(|| entry[3].clone()),
            enabled: entry[4] != "0",
            children: Vec::new(),
        };

        match parent_at(&mut roots, depth) {
            Some(parent) => parent.push(node),
            None => roots.push(node),
        }
    }

    roots
}

fn parent_at(roots: &mut Vec<MenuNode>, depth: usize) -> Option<&mut Vec<MenuNode>> {
    if depth == 0 {
        return None;
    }
    let mut level = roots;
    for _ in 0..depth {
        let last = level.last_mut()?;
        level = &mut last.children;
    }
    Some(level)
}

fn write_menu(nodes: &[MenuNode], depth: usize, out: &mut String) {
    use std::fmt::Write;

    for node in nodes {
        let indent = "  ".repeat(depth);
        if node.is_separator() {
            let _ = writeln!(out, "{indent}---");
            continue;
        }

        let _ = write!(out, "{indent}{:?}", node.label);
        if !node.id.is_empty() {
            let _ = write!(out, " #{}", node.id);
        }
        if let Some(accelerator) = &node.accelerator {
            let _ = write!(out, " [{accelerator}]");
        }
        if !node.enabled {
            let _ = write!(out, " disabled");
        }
        let _ = writeln!(out);

        write_menu(&node.children, depth + 1, out);
    }
}