ticker-mac 0.0.7

macOS egui GUI for Ticker — a tick-based spreadsheet.
# ticker-mac — Design Document

## Library Analysis

### Candidates evaluated

#### egui / eframe (v0.33)
- **Backend:** wgpu → Metal on Apple Silicon. No translation layer.
- **Menus:** None built-in. Requires external `muda` crate (pattern is documented and production-proven with eframe 0.32+).
- **Keyboard:** Good. Translates all winit key events including modifiers, arrow keys, Home/End, IME.
- **Custom drawing:** First-class. The `Painter` API draws arbitrary shapes/text. `PaintCallback` / `Shape::Callback` injects raw wgpu draw calls into a specific region — useful for GPU-accelerated cell painting.
- **Partial repaint:** None at the panel level. egui is immediate-mode: the full widget tree is traversed every frame. However, egui only triggers a new frame on actual events (idle = no GPU submission), so total repaint cost is low in practice.
- **Ecosystem:** Largest Rust GUI ecosystem. Most third-party widgets, most examples, fastest iteration.

#### iced (v0.14)
- **Backend:** wgpu → Metal on Apple Silicon.
- **Menus:** None built-in. Same external `muda` hookup needed, but less documented.
- **Custom drawing:** `Canvas` widget with a `Geometry` cache. Only redraws when the program signals dirty. Architecturally cleaner for static grids.
- **Partial repaint:** iced 0.14 adds reactive rendering — only re-renders widgets whose state changed. A spreadsheet with mostly static cells benefits. Tradeoff: slower ecosystem, more verbosity, historically unstable API between majors.

#### cacao
- Safe AppKit wrappers (NSWindow, NSMenu, NSTableView). Effectively unmaintained since 2023. **Not recommended.**

#### objc2-app-kit
- Auto-generated bindings to the full AppKit header surface. Used internally by winit. Production-quality, but you write Objective-C–style Rust. Viable as a foundation layer if you need native AppKit controls, not as the primary UI toolkit.

#### muda (v0.17+, tauri-apps)
- The standard Rust crate for native OS menu bars. On macOS it calls `NSMenu` / `NSMenuItem` directly via objc2. Ships as the menu subsystem inside Tauri v2.
- Provides `PredefinedMenuItem` for standard macOS items (Services, Hide, Quit, Undo, Redo, Cut, Copy, Paste, SelectAll, Separator, Minimize, Zoom) all wired to their native AppKit selectors automatically.
- Keyboard accelerators (Cmd+S etc.) are registered per item and handled by the OS.
- Events delivered via `MenuEvent::receiver()` channel, polled in the event loop.
- Integration with eframe: use `NativeOptions::event_loop_builder` to init muda before the first window appears, then drain the channel in `App::update()`.

### Decision

**eframe (egui, wgpu backend) + muda.**

Rationale:
- The fastest path to a working, keyboard-centric spreadsheet grid on macOS.
- `PaintCallback` gives GPU-level grid rendering if needed in future.
- Native NSMenu via muda satisfies the menus requirement completely.
- egui's idle-repaint model (no GPU submission when idle) means partial-repaint is effectively handled: we only repaint when state changes, and each repaint is fast enough that sub-panel granularity is unnecessary in practice.
- Largest ecosystem, best documentation, best community support.

---

## Architecture

### Crate structure

```
ticker/
  ticker-core/       ← unchanged — pure engine
  ticker-cl/         ← unchanged — terminal UI
  ticker-mac/        ← NEW
    Cargo.toml
    src/
      main.rs        — eframe entry point, muda init
      app.rs         — App struct, state, update loop
      menu.rs        — muda menu construction + MenuEvent dispatch
      command.rs     — Command enum, unified dispatch
      input.rs       — keyboard → Command translation, mode FSM
      grid.rs        — custom egui grid widget (Painter-based)
      header.rs      — sheet tab bar + column header row
      sidebar.rs     — property panel widget
      footer.rs      — status bar widget
```

### Dependency tree

```
ticker-mac
  ├── ticker-core        (engine, formulas, eval, persistence)
  ├── eframe             (wgpu backend)
  ├── egui               (UI widgets + Painter)
  └── muda               (native macOS NSMenu)
```

No crossterm. No ANSI. No terminal assumptions.

---

## Layout

Five independent egui panels. Each panel repaints only when its own content changes.

```
┌─ TopPanel ─────────────────────────────────────────────┐
│  [Sheet1] [Sheet2] [+]                                  │  ← sheet tab bar
├─ TopPanel ─────────────────────────────────────────────┤
│  tick │ ColA        │ ColB        │ ColC       │ ...    │  ← column headers
├─ SidePanel(right) ──┬──────────────────────────────────┤
│  tickCount    100   │                                   │
│  tickRes      1     │  CentralPanel                     │
│  rate         0.065 │  (ScrollArea → grid rows)         │
│  ─────────────────  │                                   │
│  myProp    =sum(..) │                                   │
├─────────────────────┴──────────────────────────────────┤
└─ BottomPanel ──────────────────────────────────────────┘
   Normal  │  ColA[5]  │  997.32
```

Key points:
- **TopPanel (tabs):** only repaints on sheet add/remove/rename/switch.
- **TopPanel (headers):** only repaints on column add/remove/rename/resize/scroll.
- **SidePanel (properties):** only repaints on property changes or panel show/hide toggle.
- **CentralPanel (grid):** repaints on cursor move, data change, scroll. Uses virtual scrolling — only paints the visible window of rows × columns.
- **BottomPanel (footer):** only repaints on mode changes and cursor moves. Fast — renders three short strings.

Because egui only submits a new Metal frame when repaint is requested, and `request_repaint()` is cheap to call selectively, the footer is never redrawn unless its own data changes.

---

## Rendering: the grid widget

The grid is a custom egui widget using the `Painter` API. It does not use `egui::Grid` or `egui::Table` (too inflexible for custom cell types, selection highlighting, formula display).

### Virtual scrolling

Only the visible rows and columns are painted:

```
visible_ticks = first_visible_tick .. first_visible_tick + viewport_rows
visible_cols  = first_visible_col  .. first_visible_col  + viewport_cols
```

The scroll position is stored in `App` state. Cursor movement clamps the scroll window (same logic as the terminal).

### Cell rendering pass

For each visible `(tick, col)`:

1. Compute `cell_rect` from column widths and row height.
2. Fill background: cursor cell = accent colour, selected range = light tint, alternate rows = subtle stripe.
3. Clip text to cell rect.
4. Align number cells right, text cells left.
5. Formula cells that show computed values prefix with nothing; propagated cells show the value dimmed.

### Column widths

Unlike the terminal (fixed 12-char columns), `ticker-mac` stores per-column pixel widths. Default = 100px. Drag the column header separator to resize (mouse complement to keyboard).

---

## Mode FSM

Identical modes to the terminal, same transitions:

| Mode | Meaning |
|------|---------|
| `Normal` | Grid navigation |
| `Editing` | Typing a plain value into a cell |
| `FormulaName` | Typing a formula name after `=` |
| `FormulaArgs` | Entering formula arguments |
| `Command` | Typing a `:` command (shown in footer) |
| `Help` | Help overlay |

The mode determines what happens to keyboard input. In `Normal`, arrow keys move the cursor. In `Editing`, arrow keys commit the cell and move. In `FormulaArgs`, Enter moves to the next argument.

The mode FSM lives in `input.rs`. It converts `egui::Event::Key` + `egui::Modifiers` into a `Command`.

---

## Command dispatch

All actions — whether triggered by keyboard, menu event, or context menu — go through a single `Command` enum:

```rust
pub enum Command {
    // Navigation
    MoveCursor(Direction),
    JumpFirstCol, JumpLastCol, JumpFirstTick, JumpLastTick,
    PageUp, PageDown, GoTo { col: String, tick: u64 },
    NextSheet, PrevSheet, GoToSheet(usize),
    // Editing
    StartEdit, StartEditKeepValue, StartFormula, DeleteCell, ClearCell,
    ConfirmEdit, CancelEdit,
    // Formula args
    SubmitArg(String), FinishVariadic, StartNestedFormula, CancelFormula,
    // Commands (colon-mode)
    RunCommand(String),
    // File
    Open(PathBuf), Save, SaveAs(PathBuf), Import, Quit,
    // Sheet management
    AddSheet(Option<String>), RenameSheet(String), DeleteSheet,
    CreateFilter { source: Option<String>, condition: String },
    // Column management
    AddColumn(Option<String>), RenameColumn(String), DeleteColumn,
    ConvertToValues, MoveColLeft, MoveColRight,
    ClearColumn, HideColumn, ShowColumn(String), ListHidden,
    // Properties
    SetProperty { key: String, val: String }, DeleteProperty(String),
    // View
    TogglePropertyPanel,
    // Help
    OpenHelp, CloseHelp,
    // Undo/Redo
    Undo, Redo,
}
```

In `App::update()`:

```rust
let cmd = self.input.process(&ctx)         // keyboard
    .or_else(|| self.menu.poll_events());   // native menu

if let Some(cmd) = cmd {
    self.execute(cmd, &ctx);
}
```

This replaces the giant nested `match` in `ticker-cl/src/main.rs` with a clean two-step pipeline: event → command → execute.

---

## Menu structure (muda / NSMenu)

Mirrors the `:` command organisation from the terminal, adding macOS-native groups.

```
Ticker            (app menu — auto-managed by NSApplication)
  About Ticker
  ──────────────
  Services  ▶
  ──────────────
  Hide Ticker     Cmd+H
  Hide Others     Cmd+Option+H
  Show All
  ──────────────
  Quit Ticker     Cmd+Q

File
  Open…           Cmd+O
  ──────────────
  Save            Cmd+S
  Save As…        Cmd+Shift+S
  ──────────────
  Import…         Cmd+I
  ──────────────
  Rename Project…

Edit
  Undo            Cmd+Z
  Redo            Cmd+Shift+Z
  ──────────────
  Cut             Cmd+X        (copy cell value to clipboard + clear)
  Copy            Cmd+C
  Paste           Cmd+V
  Select All      Cmd+A
  ──────────────
  Clear Cell      Delete
  Convert Column to Values
  ──────────────
  Set Property…
  Delete Property…

Sheet
  Add Sheet…
  Rename Sheet…
  Delete Sheet
  ──────────────
  Create Filter…
  ──────────────
  Next Sheet      ]
  Previous Sheet  [
  Go to Sheet 1…9 Cmd+1…9

Column
  Add Column…     Cmd+Shift+A
  Rename Column…
  Delete Column
  ──────────────
  Move Left
  Move Right
  ──────────────
  Clear Column
  Hide Column
  Show Column…
  List Hidden…
  ──────────────
  Go To…          Cmd+G

View
  Toggle Property Panel    P
  ──────────────
  Zoom In         Cmd++
  Zoom Out        Cmd+-
  Reset Zoom      Cmd+0

Help
  Ticker Help     Cmd+?
```

All `PredefinedMenuItem` items (Undo, Redo, Cut, Copy, Paste, SelectAll, Services, Hide, Quit) are wired to native AppKit selectors automatically. Standard Cmd+Z / Cmd+C / Cmd+Q work without any application code.

---

## Rendering optimisation notes

1. **Idle suppression.** egui with eframe does not submit a Metal frame when idle. The app only repaints when an event occurs (key press, mouse move, menu action, data change). The footer and header are not rendered between events.

2. **Virtual grid.** Only visible cells are painted. A 10,000-tick × 20-column sheet with a 40-row viewport paints 800 cells, not 200,000.

3. **Column header isolation.** The column header row is a separate `TopPanel`. It is only invalidated when columns change or the horizontal scroll position changes. Tick navigation does not touch it.

4. **Footer isolation.** The `BottomPanel` is rendered last, after all other panels. Its content (mode + cell address + cell value/formula) is cheap to compute. In practice the footer repaint cost is immeasurable.

5. **Property panel isolation.** The `SidePanel` is rendered independently. Cursor movement in the grid does not trigger a property panel repaint (they share no state unless a property is focused).

6. **Future: `PaintCallback`.** If profiling reveals the Painter-based cell rendering is a bottleneck at very large column counts or with complex cell content (sparklines, mini charts), a `Shape::Callback` can inject a raw wgpu render pass for just the cell area, keeping the rest of the UI managed by egui.

---

## Differences from ticker-cl (terminal)

| | ticker-cl | ticker-mac |
|---|---|---|
| Rendering | Crossterm ANSI characters | egui Painter → Metal |
| Menu | `:` command mode only | `:` commands + native NSMenu |
| Column widths | Fixed (12 chars) | Variable (px, mouse-resizable) |
| Font | Terminal monospace | Configurable (default: system monospace) |
| Copy/paste | Not implemented | Native NSPasteboard via Cmd+C/V |
| Undo | Custom history (50 snapshots) | Same history module |
| Mouse | Not used | Column resize, click to position cursor, scroll |
| Shared code || ticker-core (all engine, formula, persistence) |

---

## Migration order (implementation steps)

1. **Scaffold `ticker-mac` crate.** Cargo.toml with eframe + muda deps. Blank window that opens and closes.
2. **Add muda menu skeleton.** All menu items wired, `Command` enum defined, dispatch loop in place. Nothing executes yet.
3. **Implement `App` state.** Holds `Project`, `cursor_tick`, `cursor_col`, `mode`, `edit_buffer` — mirrors `ticker-cl`'s `App`.
4. **Implement grid widget.** Static render of cells, no interaction. Virtual scroll. Correct column widths.
5. **Implement keyboard input → Command.** Normal mode navigation, editing mode, confirm/cancel.
6. **Wire commands to state mutations.** Navigation, editing plain values, mode transitions.
7. **Implement formula entry.** FormulaName + FormulaArgs modes, using `FormulaKind`/`build_formula`/`parse_arg` from core.
8. **Implement property panel.** Sidebar, key/value list, editing, formula entry for properties.
9. **Implement file operations.** Open, Save, SaveAs — using `load_text`/`save_text` from core.
10. **Implement sheet/column management commands.** Add, rename, delete, filter, hide.
11. **Implement colon-command mode.** Footer text input, same command set as terminal.
12. **Implement help overlay.** Using the same help content as `ticker-cl`.
13. **Polish.** Native clipboard, zoom, drag column resize, window state persistence.