nigig-org/crates/apps/makepad_table/README.md
andodeki a2b05c56c9
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
makepad-table / model (push) Has been cancelled
makepad-table / widget (push) Has been cancelled
makepad-table / hygiene (push) Has been cancelled
feat(makepad-table): opt-in capabilities feature, and raise the matrix_client defect
The two caveats from the dependency investigation.

## The capabilities feature

Camera and location attachments are now available behind
`features = ["capabilities"]`, which pulls `nigig-uikit` and supplies
`UikitAttachmentProvider`.

Measured: 89 crates by default, 275 with the feature on. That cost is real
and it is inherent, not 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; there is no lighter honest version.

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.

Everything touching `nigig-uikit` is in one module, so the boundary is a file
rather than `#[cfg]` scattered through the widget. The provider holds no
widgets of its own: the host owns the `CameraWidget` already in its tree and
this asks it to open, because a provider that instantiated a second camera
would fight the first for the device.

A second request while one is outstanding is refused rather than overwriting.
The table turns that refusal into `AttachmentUnavailable`, so the user is
told the camera is busy instead of watching their first request vanish.

File picking is deliberately declined here — `robius-file-picker` already
ships unconditionally and costs nothing, and two paths for one job is one too
many.

Two CI gates, both verified to fail when they should: the opt-in build must
keep compiling, and the default build must pull none of `tokio`, `reqwest`,
`hyper`, `clap`, `csv`, `image`, `nigig-uikit` or `nigig-core`. The second
checks the resolved `cargo tree` rather than the manifest, because feature
unification can switch an optional dependency on from a sibling crate.

Tests 99 default, 105 with the feature. Both clippy-clean.

## The matrix_client defect

Raised in REVIEWS/MATRIX_CLIENT_FEATURE_GATE.md rather than fixed. It is not
my crate, nothing depends on the broken combination, and a blind fix could
change behaviour someone relies on.

`matrix_client` declares `native = ["dep:tokio", "dep:reqwest",
"dep:rusqlite"]` but its source gates on `#[cfg(not(target_arch =
"wasm32"))]`. Two switches for the same modules, so on a native target with
the feature off the modules compile and their dependencies do not — 19
errors, 26 ungated uses across 7 files. There is no CI job for the crate,
which is why it rotted unnoticed.

The note corrects an overstatement I made while arguing for the trait hook.
I said fixing this would unblock wasm. It would not: `matrix_client` already
builds clean for wasm32 with `--no-default-features`, and `nigig-core` has 8
wasm errors of its own (`crate::platform::spawn` missing) that have nothing
to do with it. The only broken combination is native-target-with-feature-off,
which nothing builds.

I also said earlier that `matrix_client` was heavy — it is a 7-dependency
local crate, not matrix-sdk. That was wrong and it inflated the case for the
trait hook; the note records the measured numbers instead.
2026-08-18 18:03:26 +00:00

511 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<u8>
│ └── 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/<doc_number>.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<TaxRate>`. 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 <number>.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.