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.
23 KiB
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_stemreplaces anything outside[A-Za-z0-9._-], so../../etc/passwdcannot steer a write out of its directory. Leading dots are replaced too, and a fully-stripped name falls back tountitled. - 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:
- Copy
src/table.rs→/path/to/makepad/widgets/src/table.rs - Change the import at the top of
table.rsfromuse makepad_widgets::*;touse crate::{makepad_derive_widget::*, makepad_draw::*, widget::*}; - 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 - Run:
(cd /path/to/makepad cargo run -p makepad-example-table --releasemakepad-example-tableis the package inexamples/table_demohere; 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 ofmakepad-widgetsand registered bywidgets/src/lib.rs, so inside the makepad tree themath_cellfield resolves without extra work — but the registration order matters, becauseTable's DSL body namesmod.widgets.MathViewand 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 andcrate::math_view::script_mod(vm)is line 617, so following step 3's "put it next to chart" advice literally would registerTablesix lines too early. Putcrate::table::script_mod(vm)after themath_viewcall. -
DrawVector(Phase 6, 3D cells) frommakepad_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:
- 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'shead/hhea/OS/2tables. Most PDF viewers render correctly despite this; a pedantic one may warn. - 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
cmaptable, so copy-paste out of a generated PDF will not round-trip those characters. - 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_xand covered by tests, so replacing it will produce a visible diff rather than a silent shift. - 3D cells are a wireframe preview, not a render. No shading, no depth sorting, no camera. See the Phase 6 section above for why.
- The widget's drawing and focus behaviour is not unit-tested — only its
geometry and index arithmetic are. Anything needing a live
Cxneeds 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.