ticker-mac 0.0.7

macOS egui GUI for Ticker — a tick-based spreadsheet.
mod command;
mod mode;
mod menu;
mod input;
mod help;
mod app;
mod dispatch;
mod render;

use mode::Mode;

use ticker_core::{
    AnyDatasheet,
    clear_eval_cache,
    FormulaTree, CellKind,
};

pub(crate) use app::App;

// ─── Constants ────────────────────────────────────────────────────────────────

pub(crate) const ROW_H: f32 = 22.0;
pub(crate) const TICK_COL_W: f32 = 52.0;
pub(crate) const PAGE_ROWS: usize = 20;

pub(crate) const COLOR_CURSOR_BG: egui::Color32 = egui::Color32::from_rgb(0, 120, 215);
pub(crate) const COLOR_CURSOR_FG: egui::Color32 = egui::Color32::WHITE;
pub(crate) const COLOR_FORMULA_CELL: egui::Color32 = egui::Color32::from_rgb(40, 180, 99);
pub(crate) const COLOR_PROPAGATED: egui::Color32 = egui::Color32::from_rgb(120, 120, 120);
pub(crate) const COLOR_ERROR: egui::Color32 = egui::Color32::from_rgb(220, 53, 69);
pub(crate) const COLOR_HEADER_BG: egui::Color32 = egui::Color32::from_rgb(40, 40, 40);
pub(crate) const COLOR_HEADER_FG: egui::Color32 = egui::Color32::from_rgb(180, 200, 220);
pub(crate) const COLOR_TICK_FG: egui::Color32 = egui::Color32::from_rgb(120, 120, 140);
pub(crate) const COLOR_SEPARATOR: egui::Color32 = egui::Color32::from_rgb(60, 60, 60);

// ─── Command categories ───────────────────────────────────────────────────────

pub(crate) struct ActionCategory {
    pub(crate) name: &'static str,
    pub(crate) key: char,
    pub(crate) cmds: &'static [&'static str],
}

pub(crate) const ACTION_CATEGORIES: &[ActionCategory] = &[
    ActionCategory { name: "Project",    key: 'P', cmds: &["rp", "w", "o", "q", "h"] },
    ActionCategory { name: "Sheets",     key: 'S', cmds: &["as", "rs", "ds", "2s", "filter", "hideds", "showds", "listds"] },
    ActionCategory { name: "Columns",    key: 'C', cmds: &["ac", "rc", "dc", "cv", "ml", "mr", "hc", "sh", "g", "f", "sf"] },
    ActionCategory { name: "Ticks",      key: 'T', cmds: &["it", "dt", "ct", "cat"] },
    ActionCategory { name: "Properties", key: 'R', cmds: &["sp", "dp"] },
];

pub(crate) fn action_category_for_key(key: char) -> Option<&'static ActionCategory> {
    ACTION_CATEGORIES.iter().find(|c| c.key == key.to_ascii_uppercase())
}

// ─── Formula tree helper ──────────────────────────────────────────────────────

pub(crate) fn formula_tree_cursor_text(tree: &FormulaTree) -> String {
    match tree.cursor.kind {
        CellKind::Wrap => String::new(),
        CellKind::Content => tree.node_at(&tree.cursor.path).content_text().to_owned(),
    }
}

// ─── eframe::App ─────────────────────────────────────────────────────────────

impl eframe::App for App {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        // Apply zoom (multiply on top of native DPI so Retina is preserved)
        ctx.set_pixels_per_point(self.base_dpi * self.zoom);

        // 0. Register native menu once the run loop is stable
        if !self.menu_initialized {
            #[cfg(target_os = "macos")]
            self.menu_bar.init_for_nsapp();
            self.menu_initialized = true;
        }

        // 1. Poll muda menu events
        while let Ok(event) = muda::MenuEvent::receiver().try_recv() {
            if let Some(cmd) = self.menu_ids.command_for_event(&event) {
                self.execute(cmd);
            }
        }

        // 2. Process keyboard input
        if let Some(cmd) =
            input::process(ctx, &self.mode, &self.edit_buffer, self.prop_focused)
        {
            self.execute(cmd);
        }

        // Initialise (or advance) the per-frame eval cache for the active sheet
        if let Some(ds) = self.active_ds() {
            clear_eval_cache(ds);
        } else if let Some(fd) = self.active_filtered_ds() {
            let source_id = fd.source.clone();
            if let Some(AnyDatasheet::Normal(src_ds)) = self.project.datasheets.get(&source_id) {
                clear_eval_cache(src_ds);
            }
        }

        // 3. Render panels
        self.render_footer(ctx);
        self.render_hints_bar(ctx);
        self.render_summary_bar(ctx);
        self.render_sheet_tabs(ctx);
        let col_ids = self.data_col_ids();
        self.render_col_headers(ctx, &col_ids.clone());
        self.render_aggr_row(ctx, &col_ids.clone());
        self.render_fmt_row(ctx, &col_ids.clone());
        self.render_property_panel(ctx);

        egui::CentralPanel::default().show(ctx, |ui| {
            if self.mode == Mode::Help {
                self.render_help(ui);
            } else {
                let col_ids_clone = col_ids.clone();
                if let Some(cmd) = self.render_grid(ui, &col_ids_clone) {
                    self.execute(cmd);
                }
            }
        });

        // Scroll into view after all commands processed
        let viewport_rows = 30;
        let viewport_cols = 8;
        self.scroll_into_view(viewport_rows, viewport_cols);

        ctx.request_repaint();
    }
}

// ─── main ─────────────────────────────────────────────────────────────────────

fn main() -> eframe::Result<()> {
    let (menu_bar, menu_ids) = menu::build_menu();

    let native_options = eframe::NativeOptions {
        viewport: egui::ViewportBuilder::default()
            .with_inner_size([1400.0, 900.0])
            .with_min_inner_size([600.0, 400.0])
            .with_title("Ticker"),
        ..Default::default()
    };

    eframe::run_native(
        "Ticker",
        native_options,
        Box::new(move |cc| Ok(Box::new(App::new(cc, menu_ids, menu_bar)))),
    )
}