# 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:
| `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)
| 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.