ticker-mac 0.0.7

macOS egui GUI for Ticker — a tick-based spreadsheet.
/// Native macOS menu bar built with muda.
///
/// Returns the [`muda::Menu`] (call `.init_for_nsapp()` after NSApp is
/// initialised) and a [`MenuIds`] lookup table that maps each item's
/// [`muda::MenuId`] back to a [`Command`].
use std::collections::HashMap;

use muda::{
    Menu, MenuItem, PredefinedMenuItem, Submenu,
    MenuEvent, MenuId,
    accelerator::{Accelerator, Code, Modifiers, CMD_OR_CTRL},
};

use crate::command::Command;

// ─── helpers ──────────────────────────────────────────────────────────────────

fn acc(mods: Modifiers, code: Code) -> Option<Accelerator> {
    Some(Accelerator::new(Some(mods), code))
}

fn cmd(code: Code) -> Option<Accelerator>               { acc(CMD_OR_CTRL, code) }
fn cmd_shift(code: Code) -> Option<Accelerator>         { acc(CMD_OR_CTRL | Modifiers::SHIFT, code) }
fn no_acc() -> Option<Accelerator>                      { None }

// ─── MenuIds ──────────────────────────────────────────────────────────────────

/// Maps menu item IDs back to `Command`s.
pub struct MenuIds {
    map: HashMap<MenuId, Command>,
}

impl MenuIds {
    fn new() -> Self { Self { map: HashMap::new() } }

    fn register(&mut self, item: &MenuItem, cmd: Command) {
        self.map.insert(item.id().clone(), cmd);
    }

    /// Return the command for a menu event, if any.
    pub fn command_for_event(&self, event: &MenuEvent) -> Option<Command> {
        self.map.get(&event.id).cloned()
    }
}

// ─── Public builder ───────────────────────────────────────────────────────────

/// Build the full menu hierarchy and return it together with the ID table.
pub fn build_menu() -> (Menu, MenuIds) {
    let menu = Menu::new();
    let mut ids = MenuIds::new();

    // ── App menu (macOS auto-manages About / Services / Hide / Quit) ──────────
    let app_menu = Submenu::with_items("Ticker", true, &[
        &PredefinedMenuItem::about(None, None),
        &PredefinedMenuItem::separator(),
        &PredefinedMenuItem::services(None),
        &PredefinedMenuItem::separator(),
        &PredefinedMenuItem::hide(None),
        &PredefinedMenuItem::hide_others(None),
        &PredefinedMenuItem::show_all(None),
        &PredefinedMenuItem::separator(),
        &PredefinedMenuItem::quit(None),
    ]).unwrap();

    // ── File ─────────────────────────────────────────────────────────────────
    let open_i    = MenuItem::new("Open…",           true, cmd(Code::KeyO));
    let save_i    = MenuItem::new("Save",            true, cmd(Code::KeyS));
    let import_i  = MenuItem::new("Import…",         true, cmd(Code::KeyI));
    let rename_p  = MenuItem::new("Rename Project…", true, no_acc());

    ids.register(&open_i,   Command::OpenDialog);
    ids.register(&save_i,   Command::SaveDialog);
    ids.register(&import_i, Command::ImportDialog);
    ids.register(&rename_p, Command::RunCommand("rp".into()));

    let file_menu = Submenu::with_items("File", true, &[
        &open_i,
        &PredefinedMenuItem::separator(),
        &save_i,
        &PredefinedMenuItem::separator(),
        &import_i,
        &PredefinedMenuItem::separator(),
        &rename_p,
    ]).unwrap();

    // ── Edit ─────────────────────────────────────────────────────────────────
    let undo_i    = MenuItem::new("Undo", true, cmd(Code::KeyZ));
    let redo_i    = MenuItem::new("Redo", true, cmd_shift(Code::KeyZ));
    let conv_i    = MenuItem::new("Convert Column to Values", true, no_acc());
    let set_prop  = MenuItem::new("Set Property…",            true, no_acc());
    let del_prop  = MenuItem::new("Delete Property…",         true, no_acc());

    ids.register(&undo_i,   Command::Undo);
    ids.register(&redo_i,   Command::Redo);
    ids.register(&conv_i,   Command::ConvertToValues);
    ids.register(&set_prop, Command::RunCommand("sp".into()));
    ids.register(&del_prop, Command::RunCommand("dp".into()));

    let edit_menu = Submenu::with_items("Edit", true, &[
        &undo_i,
        &redo_i,
        &PredefinedMenuItem::separator(),
        &PredefinedMenuItem::cut(None),
        &PredefinedMenuItem::copy(None),
        &PredefinedMenuItem::paste(None),
        &PredefinedMenuItem::select_all(None),
        &PredefinedMenuItem::separator(),
        &conv_i,
        &PredefinedMenuItem::separator(),
        &set_prop,
        &del_prop,
    ]).unwrap();

    // ── Sheet ─────────────────────────────────────────────────────────────────
    let add_sheet    = MenuItem::new("Add Sheet…",      true, no_acc());
    let ren_sheet    = MenuItem::new("Rename Sheet…",   true, no_acc());
    let del_sheet    = MenuItem::new("Delete Sheet",    true, no_acc());
    let filter_i     = MenuItem::new("Create Filter…",  true, no_acc());
    let next_sheet   = MenuItem::new("Next Sheet",      true, acc(Modifiers::empty(), Code::BracketRight));
    let prev_sheet   = MenuItem::new("Previous Sheet",  true, acc(Modifiers::empty(), Code::BracketLeft));

    ids.register(&add_sheet,  Command::RunCommand("as".into()));
    ids.register(&ren_sheet,  Command::RunCommand("rs".into()));
    ids.register(&del_sheet,  Command::DeleteSheet);
    ids.register(&filter_i,   Command::RunCommand("filter".into()));
    ids.register(&next_sheet, Command::NextSheet);
    ids.register(&prev_sheet, Command::PrevSheet);

    // Sheet 1..9 shortcuts
    let sheet_shortcuts: Vec<MenuItem> = (1usize..=9).map(|n| {
        let code = match n {
            1 => Code::Digit1, 2 => Code::Digit2, 3 => Code::Digit3,
            4 => Code::Digit4, 5 => Code::Digit5, 6 => Code::Digit6,
            7 => Code::Digit7, 8 => Code::Digit8, _ => Code::Digit9,
        };
        MenuItem::new(format!("Go to Sheet {}", n), true, cmd(code))
    }).collect();
    let sheet_shortcut_refs: Vec<&dyn muda::IsMenuItem> =
        sheet_shortcuts.iter().map(|i| i as &dyn muda::IsMenuItem).collect();
    for (i, item) in sheet_shortcuts.iter().enumerate() {
        ids.register(item, Command::GoToSheet(i));
    }

    let sep1 = PredefinedMenuItem::separator();
    let sep2 = PredefinedMenuItem::separator();
    let sep3 = PredefinedMenuItem::separator();
    let mut sheet_items: Vec<&dyn muda::IsMenuItem> = vec![
        &add_sheet, &ren_sheet, &del_sheet,
        &sep1,
        &filter_i,
        &sep2,
        &next_sheet, &prev_sheet,
        &sep3,
    ];
    sheet_items.extend(sheet_shortcut_refs.iter().copied());
    let sheet_menu = Submenu::with_items("Sheet", true, &sheet_items).unwrap();

    // ── Column ────────────────────────────────────────────────────────────────
    let add_col   = MenuItem::new("Add Column…",              true, cmd_shift(Code::KeyA));
    let ren_col   = MenuItem::new("Rename Column…",           true, no_acc());
    let del_col   = MenuItem::new("Delete Column",            true, no_acc());
    let ml        = MenuItem::new("Move Left",                true, no_acc());
    let mr        = MenuItem::new("Move Right",               true, no_acc());
    let clr_col   = MenuItem::new("Clear Column",             true, no_acc());
    let hide_col  = MenuItem::new("Hide Column",              true, no_acc());
    let show_col  = MenuItem::new("Show Hidden Column…",      true, no_acc());
    let list_hid  = MenuItem::new("List Hidden Columns",      true, no_acc());
    let goto_i    = MenuItem::new("Go To…",                   true, cmd(Code::KeyG));

    ids.register(&add_col,   Command::RunCommand("ac".into()));
    ids.register(&ren_col,   Command::RunCommand("rc".into()));
    ids.register(&del_col,   Command::DeleteColumn);
    ids.register(&ml,        Command::MoveColumnLeft);
    ids.register(&mr,        Command::MoveColumnRight);
    ids.register(&clr_col,   Command::ClearColumn);
    ids.register(&hide_col,  Command::HideColumn);
    ids.register(&show_col,  Command::RunCommand("sh".into()));
    ids.register(&list_hid,  Command::ListHidden);
    ids.register(&goto_i,    Command::RunCommand("g".into()));

    let col_menu = Submenu::with_items("Column", true, &[
        &add_col, &ren_col, &del_col,
        &PredefinedMenuItem::separator(),
        &ml, &mr,
        &PredefinedMenuItem::separator(),
        &clr_col, &hide_col, &show_col, &list_hid,
        &PredefinedMenuItem::separator(),
        &goto_i,
    ]).unwrap();

    // ── View ──────────────────────────────────────────────────────────────────
    let toggle_props = MenuItem::new("Toggle Property Panel", true,
        acc(Modifiers::empty(), Code::KeyP));
    let zoom_in  = MenuItem::new("Zoom In",    true, cmd(Code::Equal));
    let zoom_out = MenuItem::new("Zoom Out",   true, cmd(Code::Minus));
    let zoom_rst = MenuItem::new("Reset Zoom", true, cmd(Code::Digit0));

    ids.register(&toggle_props, Command::TogglePropertyPanel);
    ids.register(&zoom_in,  Command::RunCommand("zoom_in".into()));
    ids.register(&zoom_out, Command::RunCommand("zoom_out".into()));
    ids.register(&zoom_rst, Command::RunCommand("zoom_reset".into()));

    let view_menu = Submenu::with_items("View", true, &[
        &toggle_props,
        &PredefinedMenuItem::separator(),
        &zoom_in, &zoom_out, &zoom_rst,
        &PredefinedMenuItem::separator(),
        &PredefinedMenuItem::fullscreen(None),
    ]).unwrap();

    // ── Window ────────────────────────────────────────────────────────────────
    let window_menu = Submenu::with_items("Window", true, &[
        &PredefinedMenuItem::minimize(None),
        &PredefinedMenuItem::maximize(None),
        &PredefinedMenuItem::separator(),
        &PredefinedMenuItem::bring_all_to_front(None),
    ]).unwrap();

    // ── Help ──────────────────────────────────────────────────────────────────
    let help_i = MenuItem::new("Ticker Help", true, cmd_shift(Code::Slash));
    ids.register(&help_i, Command::OpenHelp);

    let help_menu = Submenu::with_items("Help", true, &[
        &help_i,
    ]).unwrap();

    // ── Assemble root menu ────────────────────────────────────────────────────
    menu.append_items(&[
        &app_menu,
        &file_menu,
        &edit_menu,
        &sheet_menu,
        &col_menu,
        &view_menu,
        &window_menu,
        &help_menu,
    ]).unwrap();

    (menu, ids)
}