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

23 KiB
Raw Permalink Blame History

makepad-table + invoicer platform

A Notion-style manipulable table widget for 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

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

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

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:

{ "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.

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:

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:

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:

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.

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:

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:

cargo run -p makepad-invoicer --example table_demo --release

Or from anywhere in the workspace:

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:
    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:
    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.