# makepad-table + invoicer platform A Notion-style manipulable table widget for [Makepad](https://github.com/makepad/makepad) **plus** a receipt/quote/invoice editing platform that exports to PDF. The project follows a **parallel data model architecture**: ``` ┌──→ Makepad UI (apps/invoicer) — editing + live preview Document (struct) ──┤ └──→ PDF Exporter (crates/pdf-export) — vector PDF bytes ``` The Makepad UI and PDF exporter never talk to each other directly. They both speak the language of `makepad-doc-model`. This means: - You can edit a document in Makepad and the same struct renders identically to PDF. - You can write a CLI tool that converts JSON → PDF without ever launching the GUI. - You can swap out the Makepad UI for a web UI later without touching the model or exporter. ## Workspace layout ``` makepad_table/ ├── Cargo.toml ← workspace manifest ├── src/ ← the Table widget itself (Phase 1+2) │ ├── lib.rs │ └── table.rs ← the Table widget (3260 lines, all 6 phases) ├── crates/ │ ├── doc-model/ ← shared data types │ │ ├── Cargo.toml │ │ └── src/lib.rs ← Document, Invoice, Quote, Receipt, Party, LineItem, Money, TaxRate, Currency + JSON helpers + tests │ └── pdf-export/ ← pdf-writer based renderer │ ├── Cargo.toml │ ├── src/lib.rs ← render_pdf(doc) → Vec │ └── resources/ │ ├── Inter-Regular.ttf ← embedded as F1 (body) │ └── JetBrainsMono-Regular.ttf ← embedded as F2 (numbers) ├── apps/ │ └── invoicer/ ← makepad UI app │ ├── Cargo.toml │ ├── src/main.rs ← sidebar + editor + Table + Export button │ ├── examples/ │ │ └── table_demo.rs ← standalone Table widget demo (Cargo example) │ └── resources/ │ ├── invoice.json ← sample data │ ├── quote.json │ └── receipt.json └── examples/ └── table_demo/ ← standalone demo; the template ├── Cargo.toml the "drop into makepad" section └── src/main.rs below refers to ``` All five packages belong to the nested workspace declared in the top-level `Cargo.toml` here. `examples/table_demo` was previously a member of no workspace at all, which meant `cargo` refused to operate in that directory and nothing ever compiled it — it had drifted to a removed makepad API. ## Quick start ### Option A — Run the invoicer app ```bash cd /path/to/makepad_table cargo run -p makepad-invoicer --release ``` On startup it loads 3 sample documents (invoice, quote, receipt). Use the sidebar buttons to switch between them, edit the header fields or any line-item cell directly, and click "Export PDF" to write `~/invoicer/.pdf`. ### Option B — Use the model + PDF exporter as a library ```rust use makepad_doc_model::{Document, sample_invoice}; use makepad_pdf_export::render_pdf_to_file; use std::path::Path; let doc = sample_invoice(); render_pdf_to_file(&doc, Path::new("invoice.pdf")).unwrap(); ``` ### Option C — Run the doc-model unit tests ```bash cd /path/to/makepad_table cargo test -p makepad-doc-model ``` Tests cover: invoice arithmetic (subtotal/discount/tax/total), currency formatting including negatives and `i64` extremes, thousands separators at every digit width, JSON round-trips for all 3 doc types, tax override precedence, payment-method labels, the Phase 2 setters and their trimming, currency lookup, percentage validation, and the Phase 3 document library (filename sanitising, search, recents, save/reopen round-trip). ## Data model highlights **Money** is stored as integer minor units (cents/pence) — never f64 — to avoid rounding errors in totals. `i64` is used because amounts can exceed `i32` range (e.g. `i32::MAX` cents = ~$21M). **Tax rates** are basis points (8.5% = 850 bps). Same fixed-point arithmetic reasoning. **Multi-tax** is supported per-line-item: each `LineItem` has an optional `tax_override: Option`. When `None`, the document's `default_tax` applies. This handles VAT/GST/sales tax mixes where some items are zero-rated (e.g. resale of stock) or taxed differently (e.g. services vs goods). **Multi-currency** via the `Currency` enum: 30+ preset currencies with correct symbols and decimal counts (`JPY`/`KRW`/`VND` have 0 decimals, everything else 2). User-defined currencies via `Currency::Other { code, symbol, decimals }`. **JSON** round-trips work via `#[serde(tag = "kind", rename_all = "lowercase")]` on the `Document` enum, so: ```json { "kind": "invoice", "number": "INV-2024-001", ... } ``` deserializes to `Document::Invoice(Invoice { ... })` and re-serializes identically. ## PDF output (Minimal B&W) - A4 portrait (595 × 842 pt), 50pt margins - Black text on white, thin 0.5pt rules, faint zebra striping on alternating line items - Inter for body text (12pt headers, 9-10pt body, 28pt title) - JetBrains Mono for doc numbers, dates, qty, prices, totals - Three document types share most layout; differences: - **Invoice**: title "Invoice", shows issue + due dates, status label - **Quote**: title "Quote", shows issue + valid-until dates, status label - **Receipt**: title "Receipt", shows date-paid + payment method, "PAID" badge - Per-line tax rate displayed as a percentage - Totals block: Subtotal → Discount (if > 0) → Tax → bold rule → Total - Footer: transaction ID (receipts only), notes, terms (invoice/quote only) ## Phase status | Phase | Status | What | |---|---|---| | Table Phase 1 | ✅ Done | Static rendering: bg, grid, header, cells | | Table Phase 2 | ✅ Done | Hover + cell editing via TextInput overlay (mouse + touch) | | Table Phase 3 | ✅ Done | Row/column context menus — own overlay, hover, Escape, 15 actions | | Table Phase 4 | ✅ Done | Drag-reorder columns with ghost + insert marker | | Table Phase 5 | ✅ Done | LaTeX cells — set `TableColumn::kind = CellKind::Latex` | | Table Phase 6 | ✅ Done | 3D cells — isometric wireframe preview from a text spec | | **Invoicer doc-model** | ✅ Done | Document enum, all 3 doc types, multi-tax, multi-currency, JSON | | **Invoicer PDF exporter** | ✅ Done | Minimal B&W, embedded Inter + JetBrains Mono, all 3 doc types | | **Invoicer Makepad UI** | ✅ Phase 1 | Sidebar + header + totals + Table for line items + Export PDF | | **Invoicer UI Phase 2** | ✅ Done | Editable header fields, currency + tax entry, doc switcher in sidebar | | **Invoicer UI Phase 3** | ✅ Done | Search box, filtered document list, recents, platform file picker, New/Delete | ## Interaction model | Gesture | Target | Result | |---|---|---| | Single click / tap | cell | selects it | | Double click / double tap | cell | edits it | | Single click / tap | row or column header | selects it and shows the resize handle | | Long press (500ms) | row or column header | opens its context menu | | Long press (500ms) | cell | opens the cell menu: copy, paste, clear, attach | | Drag the handle | selected header | resizes that column or row | | Drag the corner anchor | attached image | resizes the image; the row follows | | Drag a column header | column | reorders it | Mouse and touch run the **same** state machine — one `PressTracker`, one long-press timer, one click handler. Only gesture recognition differs, because it must: a finger reports Start/Move/Stop with no tap count and no hover, so double-tap is derived from the interval between taps. They had previously drifted, with long press working under a finger and not under a mouse. A held press schedules its own frame. Neither a mouse nor a finger emits anything while held still, so without that the long press would only fire if the user happened to jiggle the pointer. ### The row gutter A leading gutter numbers the rows 1..n and acts as the row header. It is not a data column: it has no entry in `columns`, and cannot be reordered, renamed or deleted. Numbers are derived from the row index every frame rather than stored, so an insert or a reorder cannot leave them stale. Every grid position is drawn whether or not it holds text — a row shorter than the column list used to simply stop, leaving the remaining columns with no background and nothing to click. ## Cell attachments A long press on a cell offers copy, paste, clear, and three attachment sources. A cell holding an image grows to fit it, capped so one tall photo cannot make a row taller than the viewport. Cells stay `String`. Attachments are a side-table on `TableData` keyed by `(row, col)`, so a table without them costs nothing and still round-trips as text. The keys are positional, so inserting a row or reordering a column shifts them too — otherwise the data moves and the image stays behind. ```rust use makepad_table::{CellAttachment, CellAttachmentProvider}; // The host supplies whatever the widget cannot reach on its own. struct MyProvider; impl CellAttachmentProvider for MyProvider { fn capture_photo(&mut self, cx: &mut Cx, row: usize, col: usize) -> bool { // open the camera; later call table.set_attachment(..) true } } table.set_attachment_provider(Box::new(MyProvider)); ``` ### Images An attached PNG or JPEG is decoded and drawn for real, not shown as a placeholder. Decoding reuses `pageflipnav/src/utils.rs::load_png_or_jpg` — the Robrix-derived app in this repo — rather than a reinvention: sniff the header with `imghdr` and call the matching loader, falling back to trying both when the sniff says something unexpected. The sniff avoids a decode-and-fail on the common path; the fallback means a mislabelled file is still decoded rather than refused. `imghdr` has no transitive dependencies. The result is cached per cell — `draw_walk` runs every frame and re-reading a photo from disk at 60Hz would dominate the widget. The row grows to whatever the image will actually draw at, so the two can never disagree and clip. Drag the corner anchor to resize: that pins an explicit height which the aspect-ratio default no longer overrules, and the row follows it. The floor is checked at compile time against the anchor size, because an image dragged smaller than its own grab handle could not be grabbed again. Row heights are per row. `TableRow::height` holds a dragged override and `RowGeometry` accumulates the boundaries, so resizing one row shifts the rows below it rather than resizing all of them — which is what a single shared `row_height` did. PDFs and locations stay as labelled chips. A PDF page needs a rasteriser this widget deliberately does not carry. ### The `capabilities` feature Camera and location are available through an opt-in feature: ```toml makepad-table = { path = "...", features = ["capabilities"] } ``` It supplies `UikitAttachmentProvider`, which drives the `nigig-uikit` camera and location widgets the host already has in its tree. **Measured cost:** 89 crates by default, 275 with the feature on. That is real, and it is inherent rather than packaging waste — `camera_widget` imports `send_geocode_request` and `request_map_tile` from `nigig-core`, both of which call `spawn_async` (the shared Tokio runtime), and the first makes an HTTPS call to Nominatim. A camera that geocodes needs an async runtime and an HTTP client. It is affordable because it is opt-in and because any app enabling it already depends on `nigig-core`, so that app's own tree grows by nothing. CI checks both halves: that the opt-in build still compiles, and that the default build pulls none of `tokio`, `reqwest`, `hyper`, `clap`, `csv`, `image`, `nigig-uikit` or `nigig-core`. File picking is deliberately *not* part of it. `robius-file-picker` already ships unconditionally and costs nothing; two paths for one job is one too many. **Why a trait underneath.** Camera and location live in `nigig-uikit`, which pulls in `nigig-core` and with it tokio, reqwest, matrix-sdk and clap. That is the wrong dependency for a table widget, so the widget names what it needs and a host that already has those crates supplies them. A provider returning `false` — including the default, which declines everything — makes the table emit `AttachmentUnavailable` rather than leaving a menu entry that appears to do nothing. File picking is the exception and ships here: `robius-file-picker` is already used by three crates in this repo, works through `rfd` on desktop and the platform picker on Android, and brings none of that weight. ## LaTeX cells (Phase 5) Set a column's `kind` and its cells render as maths instead of text: ```rust use makepad_table::{CellKind, TableColumn}; let col = TableColumn { id: "formula".into(), title: "Formula".into(), width: 200.0, kind: CellKind::Latex, ..Default::default() }; ``` Cells stay `String`; the kind lives on the column, so tables written before this existed are unaffected and a table still round-trips through plain text. Rendering goes through makepad's `MathView`, which is already registered with the script VM, rather than a second LaTeX layout path. An expression that fails to parse is **not** blanked. `MathView` draws its own `[reason]` marker, so a typo shows up in the cell rather than silently erasing the content. ## 3D cells (Phase 6) Set `kind: CellKind::Solid3d` and the cell text describes a solid, drawn as an isometric wireframe: ```text cube 10 20 30 a box with those extents cube 10 a uniform cube sphere 5 radius cylinder 3 12 radius, height ``` Separators may be spaces or commas, shape names are case-insensitive, and `box` and `cyl` are accepted as aliases. Every dimension must be finite and greater than zero. The cell stays a string, so a 3D column saves, loads and round-trips like any other. A spec that cannot be parsed draws the reason — `[unknown shape: torus]`, `[cube wants 3 args, got 2]` — rather than an empty cell, so a typo is visible instead of looking like a rendering fault. **Why a wireframe.** Shading needs a 3D pass with its own camera, depth buffer and lighting shader; the CAD viewport elsewhere in this repo spends about 2,500 lines on exactly that. A cell forty pixels tall gains nothing from it. Edges are projected isometrically and stroked with `DrawVector`, which needs no pass of its own, and the drawing is scaled to fit and centred so a 1-unit and a 1000-unit cube look identical in the cell. Curved solids are drawn as rings rather than their full triangulation — a 40px cell cannot resolve hundreds of triangles. ## Document library and file handling (Invoicer UI Phase 3) The sidebar has a search box, a filtered document list, a recents list, and New / Open / Save As / Delete in the toolbar. `DocumentLibrary` in `makepad-doc-model` is the layer underneath it — browse, save, load, search — kept in the model so it is testable without a window. ```rust use makepad_doc_model::DocumentLibrary; let mut lib = DocumentLibrary::new(8); // keep 8 recent paths lib.push(makepad_doc_model::sample_invoice()); let path = lib.save_entry_to_dir(0, &dir)?; // writes .json let index = lib.open_path(&path)?; // and records it as recent let hits = lib.search("globex"); // indices, in library order ``` ### The file picker File opening and saving go through **`robius-file-picker`**, the same crate `nigig-build`, `nigig-pay-ui` and `nigig-sms` already use, rather than makepad's own `FileDialog`. That is not a preference. Makepad's `open_system_openfile_dialog` is implemented **on macOS only** — the Linux and Android backends never handle `CxOsOp::SelectFileDialog`, so the op is queued and dropped. The button compiles, runs, and silently does nothing on the platform this repo targets. CI gates against it regressing. The picker's callback runs off the UI thread with no `Cx`, so it parks its result and signals; `drain_file_picker` applies it on the next `Event::Signal`. Same shape as the SMS bulk CSV import. Two deliberate choices worth knowing: - **Save says "Choose where to save…", not "Saved".** The dialog being open is not the file being written, and claiming success before the write is a lie the user discovers later. - **Delete removes the row, not the file.** Removing an entry from a list is not consent to delete a document off disk, and there is no undo here. The status line says so. ### The document list A fixed pool of 12 button slots rather than a `PortalList`, because this app opens documents one at a time and the count stays small. The pool is honest about its limit: anything past it shows as `+n more — narrow the search to reach them` rather than being silently dropped. Three things the library gets right that are easy to get wrong: - **A document number becomes a filename**, and it is user-controlled text. `safe_file_stem` replaces anything outside `[A-Za-z0-9._-]`, so `../../etc/passwd` cannot steer a write out of its directory. Leading dots are replaced too, and a fully-stripped name falls back to `untitled`. - **An empty search matches everything**, so clearing the box restores the list instead of emptying it. All terms must match, so each word narrows. The haystack is type, number, both party names and every line description. - **Recents de-duplicate and move to the front.** Without that, re-opening one file fills the list with it and pushes everything else out. ## Building and testing This is a **nested workspace**: it is named in the root manifest's `workspace.exclude`, so `cargo build` at the repo root does not touch it. Build and test it on its own: ```bash cd crates/apps/makepad_table cargo test # 145 tests: 99 widget, 36 doc-model, 6 invoicer, 4 pdf-export cargo test --features capabilities -p makepad-table # 105: +6 provider tests cargo clippy --all-targets -- -D warnings cargo fmt -- --check ``` `makepad-widgets` is pinned to the same fork revision the rest of the repo uses. It previously tracked upstream branch `dev`, which meant the same commit of this repo could build against a different makepad from one day to the next. ### What the tests cover The widget's geometry and index arithmetic are unit-tested without a GPU. `Table` derives `Script` and `Widget` and cannot be constructed outside a live `Cx`, so the logic worth testing lives in plain types alongside it — `ColumnGeometry` for boundary and drop-position maths, `reorder_columns` for the move itself. The widget forwards to both. Focus handling, action emission and drawing are **not** covered: they need the makepad test runtime and a display. ## Run the standalone Table widget demo The Table widget has its own demo as a Cargo *example* of the makepad-invoicer package. It inherits invoicer's dependencies, so no separate Cargo.toml is needed — just run: ```bash cargo run -p makepad-invoicer --example table_demo --release ``` Or from anywhere in the workspace: ```bash cargo run --example table_demo --release ``` This loads a single Table widget seeded with the "Random table" data from the reference video (Role / Current Task / Deadline, 6 rows). Click any cell to edit, hover a row for the green handle, tap a row handle or column header to open its context menu, and drag a column header sideways to reorder it. ## Drop into makepad itself To move the Table widget from this crate into the makepad repo: 1. **Copy** `src/table.rs` → `/path/to/makepad/widgets/src/table.rs` 2. **Change the import** at the top of `table.rs` from `use makepad_widgets::*;` to `use crate::{makepad_derive_widget::*, makepad_draw::*, widget::*};` 3. **Register** the module in `widgets/src/lib.rs`: ```rust pub mod table; // near line 121 (chart module) pub use crate::table::*; // near line 238 crate::table::script_mod(vm); // AFTER math_view, see below ``` 4. **Run**: ```bash cd /path/to/makepad cargo run -p makepad-example-table --release ``` (`makepad-example-table` is the package in `examples/table_demo` here; copy it across alongside the widget.) Two things the widget now depends on from inside `widgets`, which matter if you move it: - **`MathView`** (Phase 5, LaTeX cells). It is already part of `makepad-widgets` and registered by `widgets/src/lib.rs`, so inside the makepad tree the `math_cell` field resolves without extra work — but the registration order matters, because `Table`'s DSL body names `mod.widgets.MathView` and the VM has to know that type first. This is a real trap rather than a theoretical one: at the pinned revision `crate::chart::script_mod(vm)` is line 611 and `crate::math_view::script_mod(vm)` is line 617, so following step 3's "put it next to chart" advice literally would register `Table` six lines too early. Put `crate::table::script_mod(vm)` *after* the `math_view` call. - **`DrawVector`** (Phase 6, 3D cells) from `makepad_draw`, which step 2's import line already brings in. Neither needs a Cargo feature: both are unconditional parts of `makepad-widgets` at the pinned revision. ## Known limitations This section used to list five things to watch for on "the first compile on your machine", because the code had been written without a toolchain and had never been built. It has been built since, and it is now in CI, so the three predictions are settled: `KeyCode::Tab` is the correct spelling (not `TabKey`), `#[live] cell_editor: TextInput` works as written without needing `ComponentRef`, and the pdf-writer 0.15 calls compile as they stand. What that first compile *did* find were three defects in the money code, all fixed: a `usize` underflow in the thousands separator that panicked on most numbers, a sign placed inside the currency symbol with a mismatched fractional part, and test expectations that were a factor of ten low. See the git history for the detail. The genuine limitations that remain: 1. **Font metrics in the embedded PDF font descriptor** — the ascent, descent and bbox values (`950.0`, `-250.0`, and so on) are rough. Real ones would come from parsing the TTF's `head`/`hhea`/`OS/2` tables. Most PDF viewers render correctly despite this; a pedantic one may warn. 2. **ToUnicode CMap** — the embedded CMap is a minimal identity mapping for BMP codepoints. Full Unicode (emoji, supplementary planes) needs a complete CMap generated from the font's `cmap` table, so copy-paste out of a generated PDF will not round-trip those characters. 3. **Cell text width is approximated** at 7px per character for centre and right alignment. It is wrong for anything but a monospace-ish Latin string. Fixing it needs a real text measurer rather than a better guess; the approximation is isolated in `align_text_x` and covered by tests, so replacing it will produce a visible diff rather than a silent shift. 4. **3D cells are a wireframe preview, not a render.** No shading, no depth sorting, no camera. See the Phase 6 section above for why. 5. **The widget's drawing and focus behaviour is not unit-tested** — only its geometry and index arithmetic are. Anything needing a live `Cx` needs the makepad test runtime and a display. ## License MIT OR Apache-2.0. The bundled Inter and JetBrains Mono TTFs are under the SIL Open Font License 1.1.