update kiro specs for mobile interface

This commit is contained in:
Emil Lerch 2026-10-03 12:13:36 -07:00
parent 42c7fe6fbc
commit e819a8d0bf
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 735 additions and 212 deletions

View file

@ -1165,65 +1165,67 @@ upward across many roundings.
## 6. C API (for Android/FFI)
**What exists today:** `engine/src/c_api.zig` is the root of the shared library
(`build.zig` builds `libtally.so` from it), and it declares three exports.
`tally_version` works. `tally_eval` and `tally_result_free` are signatures with
`// TODO: implement` bodies, so the library loads, exports the symbols, and answers
every evaluation with an empty `CalcResult`. No test builds it, so it appears in no
coverage report (open items 12 and 15).
**What exists today:** `engine/src/c_api.zig` is the root of the shared library and
implements the design below. Twelve exports, a hand-written `include/tally.h`, and two
test layers: Zig tests that build sessions with `std.testing.allocator` so the whole seam
is leak-checked, and `engine/test/c_abi_test.c`, which links against the real library
through the real header and is run by `zig build test`. `zig build android`
cross-compiles the library for arm64-v8a, x86_64 and armeabi-v7a, laid out for Gradle's
`jniLibs`, with the JNI layer (6.4) on top.
The rest of this section is the design, and the three decisions it does not yet make
are listed at the end.
What is not built: the financial and float-view payloads. Standard mode, programmer
mode, unit conversion, preview (6.5), whole-category conversion (6.6), save and load
(6.7) and the units
catalogue are across.
```zig
// c_api.zig - extern "C" exports
//
// SAFETY: All string inputs use pointer + length (not null-terminated).
// This avoids buffer overread vulnerabilities and allows embedded nulls.
// All returned strings include a length - caller must free with tally_result_free().
pub const Status = enum(c_int) { ok = 0, eval_error = 1, out_of_memory = 2, invalid_argument = 3 };
pub const Mode = enum(c_int) { standard = 0, programmer = 1 };
pub const CalcResult = extern struct {
/// JSON-encoded result. Null if error.
json_ptr: ?[*]u8,
json_len: usize,
/// Error message if json_ptr is null.
error_ptr: ?[*]u8,
error_len: usize,
/// Interpretation and display, set once on the session rather than per call. The
/// display fields are NFR-9.9: the engine holds no digit budget of its own.
pub const Config = extern struct {
bits: u16 = 64, // 8, 16, 32, 64, 128
is_signed: u8 = 1,
big_endian: u8 = 1,
separators: u8 = 1,
never_abbreviate: u8 = 0,
fraction_digits: u16 = 20,
max_integer_digits: u16 = 40,
significant_digits: u16 = 17,
scientific_below_exponent: u16 = 15,
};
/// Evaluate an expression, return JSON result.
/// Caller must free the result with tally_result_free().
export fn tally_eval(
expr_ptr: [*]const u8,
expr_len: usize,
mode: c_int, // 0=standard, 1=programmer, 2=financial
config_ptr: ?[*]const u8, // optional JSON config (null if none)
config_len: usize, // 0 if no config
) callconv(.c) CalcResult;
/// Convert a value between units, return JSON result.
export fn tally_convert(
value: f64,
from_ptr: [*]const u8,
from_len: usize,
to_ptr: [*]const u8,
to_len: usize,
) callconv(.c) CalcResult;
/// Free a CalcResult's buffers.
export fn tally_result_free(result: *CalcResult) callconv(.c) void;
/// Get version string (static lifetime, do not free).
export fn tally_version(out_len: *usize) callconv(.c) [*]const u8;
export fn tally_session_new() ?*Session;
export fn tally_session_free(?*Session) void;
export fn tally_session_configure(?*Session, ?*const Config) Status;
export fn tally_session_config(?*Session, ?*Config) Status;
export fn tally_eval(?*Session, ?[*]const u8, usize, c_int, ?*[*]const u8, ?*usize) Status;
export fn tally_preview(?*Session, ?[*]const u8, usize, c_int, ?*[*]const u8, ?*usize) Status;
export fn tally_convert(?*Session, ?[*]const u8, usize, ?[*]const u8, usize, ?*[*]const u8, ?*usize) Status;
export fn tally_session_save(?*Session, ?*[*]const u8, ?*usize) Status;
export fn tally_session_load(?*Session, ?[*]const u8, usize) Status;
export fn tally_unit_catalog(?*Session, ?*[*]const u8, ?*usize) Status;
export fn tally_version(?*usize) [*]const u8;
export fn tally_abi_version() u32;
```
A `tally_struct_layout` export was specified here too; it goes with section 3 and is
not part of the first Android surface.
Design notes:
- **No null-terminated strings.** All inputs take `(ptr, len)` pairs. This prevents buffer overread, allows binary data in expressions if ever needed, and is the idiomatic C pattern for length-known strings.
- **`CalcResult` struct** bundles success/error in a single return. Caller checks `json_ptr != null` for success.
- **JSON result format** keeps the Android/FFI boundary simple - Kotlin parses JSON natively. On the JNI side, the Kotlin bridge reads `(ptr, len)` into a `ByteArray` and decodes UTF-8.
- **A session, not a function.** `Environment` outlives an evaluation, so the handle does too: `x = 5` then `x * 2` works, and `Ans` has a value. This is what the one-shot `tally_eval` in the original sketch could not do.
- **`tally_result_free` is gone.** Results are borrowed from the session (6.2), so there is nothing for a caller to free and no way to free it wrongly.
- **JSON payloads**, written by `std.json` rather than by hand, so escaping is not a local invention. A standard result is
`{"ok":true,"display":"256","exact":true,"truncated":false,"bases":{"hex":"01 00",...}}`,
with `bases` present only when the expression used a non-decimal literal (FR-1.9) and
`conversion` present only when it was a unit conversion. Programmer mode returns every
row the screen shows. An error is `{"ok":false,"error":"division by zero"}`, worded by
`engine.phrase`, which is where the CLI and TUI get the same sentence.
- **Statuses distinguish three failures**: the expression was bad (`eval_error`, with the
reason in the JSON), the machine is out of memory (`out_of_memory`, with no JSON,
because there may be no memory to write it), or the call itself was malformed
(`invalid_argument`, which touches nothing).
- **A units catalogue**, so a picker is filled from `units.unitsIn` and the aliases table
rather than from a list retyped in Kotlin.
### 6.1 The decisions this design still owes
@ -1314,15 +1316,22 @@ and the returned value from the allocator it is handed. So:
| One call: parse tree, intermediate rationals, rendered text | nothing; the arena owns it | `ArenaAllocator` over the session allocator, `reset(.retain_capacity)` at the end of each call |
| The bytes handed back | the session's result buffer | session allocator, `clearRetainingCapacity` per call |
- **The session allocator is `std.heap.smp_allocator`** at the C boundary: thread-safe
by construction, aimed at release builds, and verified to compile for both Android
ABIs without libc. Not `page_allocator` directly - that rounds every allocation up to
a page, and a variables map plus big-integer limbs would burn one each. `page_allocator`
is simply where `smp_allocator` gets its memory.
- **The session allocator is `std.heap.page_allocator`** at the C boundary, and the
loader chose it, not taste. The Android library declares no dynamic dependencies (6.3),
so every symbol it references must be one it defines: `smp_allocator` keeps its
per-thread state in a thread-local, which emits a TLS segment and an undefined
`__tls_get_addr` that only Bionic's libc can satisfy. It links on a desktop and fails in
`dlopen` on a device, before any code runs. `std.heap.DebugAllocator` over
`page_allocator` is free of that symbol and is the upgrade if page granularity ever
costs anything measurable; what rules out page granularity being a problem today is the
arena below, which buys pages in chunks and hands out bytes. The allocations that do hit
`page_allocator` one for one are `Environment` variable clones and the geometric growth
of the result buffer, both bounded by what a person types.
- **The allocator is a parameter everywhere above the boundary.** The Zig-visible
session type takes an `Allocator`, so tests pass `std.testing.allocator` and get leak
detection; only the `export fn` picks a default. That is how the ABI gets leak-checked
without the library carrying a debug allocator into production.
without the library carrying a debug allocator into production. It is also what made the
fix above a one-line change to one function.
- **`retain_capacity` is what makes the steady state cheap.** The first evaluation buys
the arena's pages and every later one reuses them, so a calculator being typed into
stops calling the backing allocator almost entirely.
@ -1349,7 +1358,8 @@ Building the engine for `aarch64-linux-android` and `x86_64-linux-android` produ
shared object per ABI with **no dynamic dependencies at all** (zero `DT_NEEDED` entries),
containing the full exact tier: a probe that evaluated `2^100 + 1` and rendered it
through `Number.render` compiled and linked on both, at 230KB under `ReleaseSmall`. No
NDK was involved, and the three-lifetime design above compiles for both ABIs too.
NDK was involved, and the three-lifetime design above compiles for both ABIs too. The
shipped libraries, with the panic machinery removed below, are 170KB to 186KB.
Asking for `-lc` on those targets fails outright: Zig 0.16 bundles glibc and musl and no
Bionic, so it reports `unable to provide libc for target 'aarch64-linux...android.29'`
@ -1360,32 +1370,104 @@ that needs no NDK at all. The cost is that allocations do not pass through `mall
Android's malloc-based tooling cannot attribute them; for a calculator whose steady state
is an arena reset per keystroke, that is a fair trade.
Still assumed, and only testable with a device or emulator: that the library loads under
`System.loadLibrary`, that a JVM resolves the symbols, and that a page-backed allocator
behaves well under Android's memory pressure.
**No longer assumed.** The library loads under `System.loadLibrary`, the JVM resolves the
symbols, and the engine computes correctly on a device: on an Android 15 x86_64 image with
16 KiB pages, `2+3*4` gave `14`, `100 km to mi` gave
`62.13711922373339696174 mi`, `x = 7` then `x * 6` gave `42` - which is the
`Environment`-survives-the-arena-reset rule holding across calls - and `1/0` reported
`division by zero` rather than crashing. The 10 instrumented tests pass on the same image.
What remains untested is a physical device, arm64-v8a in particular, and behaviour under
real memory pressure.
**What a library with no `DT_NEEDED` actually costs, found on a device.** The zero above
is a constraint, not just a property, and the first build that ran on an emulator did not
load at all:
```
java.lang.UnsatisfiedLinkError: dlopen failed: cannot locate symbol "__tls_get_addr"
referenced by ".../lib/x86_64/libtally.so"
```
Nothing in the engine asked for a libc symbol on purpose. Three pieces of std did, all on
paths that had nothing to do with arithmetic:
| Symbol | Pulled in by | Removed by |
|---|---|---|
| `__tls_get_addr` | `smp_allocator`'s thread-local | `page_allocator` (6.2) |
| `__tls_get_addr`, `getauxval` | std's default panic handler: a thread-local recursion guard, and reading the library's own ELF headers to print a stack trace | a panic handler that writes the message to stderr and traps, letting Android's own crash handler produce the tombstone |
| `getauxval` (arm64-v8a only) | `std.heap.pageSize()`, a runtime auxv query wherever Zig's `page_size_min` and `page_size_max` differ - which is aarch64 alone, at 4 KiB to 64 KiB | `std.options.queryPageSize` returning 16 KiB, with only the *maximum* overridden |
Two things about that last row are worth keeping. It reproduced on **arm64-v8a only**,
so the x86_64 emulator that caught the first failure would have passed it while most real
phones failed - the symbol audit below is what catches that class, not testing. And
`page_size_min` was deliberately left alone: std documents it as the alignment a caller
may assume from a page allocation, and on a 4 KiB kernel `mmap` returns 4 KiB-aligned
memory, so raising it would promise an alignment the kernel does not give. Overriding the
ceiling and the query rounds lengths up, which is the safe direction.
The general lesson is that a freestanding shared library's dependencies are decided by
code that never runs. Two of the three came from a debugging aid on the panic path, and
with `BIND_NOW` set - it is, on all three ABIs - even an unreferenced PLT entry is
resolved at load, so "nothing calls it" is not a defence. The audit is therefore part of
the build's evidence rather than a one-time check: for each ABI, zero `DT_NEEDED`, zero
undefined dynamic symbols, no TLS segment, and `LOAD` aligned to 0x4000. Removing the
panic machinery also took the libraries from ~290KB to ~170-186KB.
### 6.4 How Kotlin reaches the C ABI
**Decided: a hand-written `engine/src/jni.zig`, compiled only for Android, with no NDK.**
A Kotlin `external fun` cannot call `tally_eval` directly; something has to translate
`jstring` and `JNIEnv*` into pointers and lengths (design 6.3). Three ways to get that
glue, in the order I would consider them:
glue were considered:
1. **`engine/src/jni.zig`, compiled only for Android.** The glue stays in one language,
is testable from Zig, and adds no dependency to the app. It needs the JNI type
definitions, which means either the NDK's `jni.h` at build time for that one file, or
hand-declaring the dozen function-table entries it uses. Hand-declaring removes the
NDK requirement entirely and puts a silent breakage risk in its place: a wrong vtable
index is a crash with no compiler to catch it. Prefer `jni.h`, and keep the C ABI
itself NDK-free so only this layer needs it.
1. **`engine/src/jni.zig`, compiled only for Android.** The glue stays in one language, is
testable from Zig, and adds no dependency to the app. It needs the JNI type
definitions, which means either the NDK's `jni.h` or hand-declaring what it uses.
2. **A small C shim compiled by Gradle** (`externalNativeBuild` with CMake). Standard
Android practice, and it moves native compilation into Gradle, which this design has
so far kept out.
3. **JNA**, which calls a plain C ABI with no per-project glue at all. Cheapest to start
and the only option that needs no NDK anywhere; it costs a dependency and per-call
marshalling overhead that a calculator will never notice.
kept out.
3. **JNA**, which calls a plain C ABI with no per-project glue at all. Cheapest to start,
and it costs a dependency and per-call marshalling.
This does not need deciding until there is something to load. The C ABI is identical
under all three.
The first, without `jni.h`, because the library already builds with no NDK and no libc
(6.2) and dragging in a sysroot for three functions would undo that.
**What that costs, and how it is held down.** `JNIEnv*` is a pointer to a table of
function pointers at fixed indices, so reaching `NewStringUTF`, `GetStringUTFChars` and
`ReleaseStringUTFChars` without the header means four integers have to be right, and a
wrong one is a jump into the wrong function rather than a compile error. Three things
guard it:
- The table is declared at its full published length (233 entries since JNI 1.6, with
`GetObjectRefType` last), and a comptime check rejects an index past the end. The
arithmetic that arrives at exactly that length is written out in the file, because that
count is what catches a dropped entry.
- `JNI_OnLoad` proves the indices against the JVM that is actually running, before
anything else: it checks the version, then round-trips a string through `NewStringUTF`
and `GetStringUTFChars` and compares the bytes. On failure it returns `JNI_ERR`, so
`System.loadLibrary` throws at startup rather than the app crashing later inside an
evaluation.
- The tests run every entry point against a synthetic function table, covering the
marshalling, the handle arithmetic and the self-check in both directions (including a
deliberately broken table). They cannot check the indices themselves, because they
supply the table.
- **A real JVM can, and not only Android's.** JNI's function table is fixed by the
specification rather than by the platform, so a desktop JVM resolves the same indices an
Android runtime does. `zig build jvm-test` builds the library for the host, compiles
`engine/test/java/dev/lerch/tally/TallyEngine.java`, and runs it: `System.loadLibrary`
executes `JNI_OnLoad`, and the test then drives every entry point, including a catalogue
string over 4KB. Moving one index by a single slot makes that step fail, which is how we
know the check has teeth. It is a separate step rather than part of `zig build test`
because it needs a JDK and nothing else in the suite does.
**The glue is also what makes borrowed results safe.** The bytes from `tally_eval` become
a Java string inside the same function that asked for them, so the app never holds a
native pointer and cannot outlive one. `c_api` leaves a NUL just past the reported length
for exactly this, so the copy the JVM makes is the only copy in the call.
The C ABI is unchanged by this choice: a Swift or C consumer links the same library and
calls it directly.
### 6.3 Who the ABI is for, and what is actually Android-specific
@ -1460,8 +1542,95 @@ retrofit:
product version and the ABI version are different facts and should be separately
readable.
### 6.5 Preview: an evaluation that stores nothing
**Decided and built, for the live result on the standard screen (9.2).** A screen that
answers as you type calls the engine on every keystroke, and `tally_eval` is the wrong
call for that: typing `x = 7` would assign `x` at `x =` ... `7`, and every intermediate
answer would overwrite `Ans`, so `Ans` would mean "whatever was last on screen" rather
than "what I last committed". Both are silent - the numbers are plausible - which is
the kind of wrong a calculator cannot afford.
```zig
export fn tally_preview(?*Session, ?[*]const u8, usize, c_int, ?*[*]const u8, ?*usize) Status;
```
Same arguments, same JSON, same borrowing rule as `tally_eval`; the only difference is
that nothing in the session changes. An assignment previews as the value it would store,
and a built-in target (`pi = 3`) previews as the error committing it would give.
- **A separate function, not a flag.** A `bool` on `tally_eval` changes a signature that
shipped; folding a bit into `mode` makes one integer mean two things. A new export is
additive, and `abi_version` moves to 2 so a prebuilt library without it is refused at
startup rather than failing on the first keystroke.
- **The guarantee is the type, not a branch.** Assignment is a statement (Task 5.19): the
parser only produces it at the root, never inside an expression. So `evalStringInfo`
now handles the root assignment itself, and the recursive evaluator below it takes a
`*const Environment` - it *cannot* store anything. The preview path,
`engine.previewStringInfo`, is the same walk without the two writes at the end, and it
takes a `*const Environment` too, so "preview mutated the session" is a compile error
rather than a test. The CLI and TUI get the same function; neither uses it yet.
- **Programmer mode is already pure** - it never touches the environment - so its preview
is its evaluation.
### 6.6 Convert: one value, every unit
**Decided and built, for the converter screen (9.6).**
```zig
export fn tally_convert(?*Session, value: ?[*]const u8, usize, unit: ?[*]const u8, usize, ?*[*]const u8, ?*usize) Status;
```
`{"ok":true,"input":"100","from":"C","category":"temperature","results":[{"unit":"C",
"display":"100","exact":true,"truncated":false},{"unit":"F","display":"212",...},...]}`
- **The whole category in one call,** because the screen shows all of it. The
alternative - one `tally_eval("100 C to F")` per row - is a dozen JNI round trips and
a dozen parses per keystroke, and puts the list of units in Kotlin.
- **The value is an expression, previewed** (6.5): `6*12` in inches works, and nothing
in the session changes.
- **A row that fails carries its own `error`** and leaves the rest of the table alone.
- **The rendering budget is the session's** (`Config`, 6.1 point 3), so the converter
sets fewer digits on its own session than the calculator does. Over JNI that is
`configureDisplay`, which reads the current config, changes the two digit counts and
writes it back, so the other fields keep their values.
- `abi_version` 3.
---
### 6.7 Save and load: a session that survives the process
**Decided and built, for the calculator reopening as it was left (9.2).** The engine
holds the variables and `Ans`, so the app saving only what its screen shows would bring
back `x * 2` without `x`.
```zig
export fn tally_session_save(?*Session, ?*[*]const u8, ?*usize) Status;
export fn tally_session_load(?*Session, ?[*]const u8, usize) Status;
```
`{"format":1,"ans":{"exact":"1/7"},"variables":[{"name":"r","value":{"bits":"3ff6a09e667f3bcd"}}]}`
- **Exact, both tiers.** An exact value is saved as `toFractionString` writes it, every
digit; it loads through `Rational.parseFraction`, which has no length cap, because
`parse`'s 512-digit limit is right for typing and wrong for `factorial(1000)`. An
inexact value is saved as its IEEE-754 bits, so it comes back as the same float and
not a decimal near it. A reloaded session saves to the same bytes it was loaded from.
- **Not a replay.** Re-evaluating the history on launch was the alternative and is
wrong twice: `Ans * 2` depends on every answer before it, so history cannot be trimmed
without changing values, and clearing it would silently drop the variables.
- **All or nothing.** The text is read and checked completely - format, a name an
assignment could have made, no duplicates, exactly one well-formed value form - and
only then swapped in. A refused load leaves the session untouched. Unknown fields are
ignored, so a later format can add to it.
- **Deterministic:** variables are listed by name.
- **Not included:** the configuration, which is the frontend's to set.
- Tested by round trips (2^100, 1/3, sqrt(2), factorial(500), `Ans`), a byte-identical
re-save, a load that replaces rather than merges, fourteen malformed states each
leaving the session as it was, and an allocation-failure sweep over save and load. ABI
version 5.
## 7. CLI Design
```
@ -2033,24 +2202,62 @@ need.
---
## 9. Android UI Design (DESIGN ONLY, NOT BUILT)
## 9. Android UI Design
Nothing in this section exists: there is no `android/` directory, no Gradle project, no
Kotlin, and the C ABI it depends on is two stubs (section 6). The screens below are the
plan. Read the struct layout screen (9.4) as doubly speculative, since the engine
feature behind it is also unbuilt.
The shell (9.1) and every screen but standard (9.3-9.7) are still DESIGN ONLY. 9.2 was
rewritten after the first build ran on a device, which is the input the rest of this
section was waiting for.
These designs are deliberately left unreconciled with what the TUI became during the
review, even where the two now disagree (9.5 has a Calculate button; the TUI dropped it
because results are live). Reconciling them on paper would be guessing twice: the useful
version of this section is the one written after there is a running app to react to.
Expect it to be rewritten from what the app teaches, not from what the TUI settled on.
That first build was a text field, the system keyboard, an `=` button and a monospace
scrollback: the TUI's prompt moved onto glass, the exact paradigm the paragraph below
rules out. It proved the boundary and was nearly unusable, and the specific ways it
failed are what 9.2 is now built against. Read the struct layout screen (9.4) as doubly
speculative, since the engine feature behind it is also unbuilt.
The remaining designs are deliberately left unreconciled with what the TUI became during
the review, even where the two now disagree (9.5 has a Calculate button; the TUI dropped
it because results are live). Expect each to be rewritten the way 9.2 was: from a
running screen, not from what the TUI settled on.
**The principle the built screens converged on,** written down because it was learned by
having proposals rejected, and the unbuilt screens are where it will be tested next:
- **Nothing moves unless the user moves it.** No auto-scroll to a chosen item, no
reordering under a finger, no list that jumps when a value changes. The value line
already says what was chosen; motion the user did not cause costs them their place.
(Rejected along the way: scrolling the result list to the chosen unit; recency order
within a category.)
- **State is remembered, not guessed.** Each screen reopens exactly as it was left, and
each part of it keeps its own memory - the converter's categories each keep their
value, unit and scroll position. When something is unknown, the honest default (the
top of a list, a blank field) beats a plausible reconstruction. (Rejected: carrying a
number across categories, so 98.6 km became 98.6 kg.)
- **Stable layouts are an interface.** Fixed order, things highlighted in place rather
than removed, so that where something is on screen is worth remembering.
- **Look before designing.** The first build was the TUI on glass; every good decision
since came from reacting to something running, usually against a concrete reference
(NCalc) with taps counted.
The Android app uses a fundamentally different interaction model from the TUI. Where the TUI is keyboard-driven with a prompt, Android is **touch-first with purpose-built input surfaces** for each mode. No text-cursor-in-a-prompt paradigm - instead, tappable buttons, interactive grids, and form fields.
### 9.1 Navigation & Shell
Bottom navigation bar with 4 destinations: **Standard**, **Programmer**, **Financial**, **Convert**.
**Built: a hamburger drawer, as in NCalc.** Calculator, Convert, and Settings below a
divider, under the app name and the engine version (which crossed JNI to be there).
Opened by the hamburger only; an edge swipe would fight the system back gesture and the
keys nearest the edge. Each screen also has a one-tap link to the other in its top bar,
because the drawer is two taps and switching between calculating and converting is the
common move. The app reopens on whichever was last open; Settings is never reopened.
**Settings** holds one thing so far: the theme - follow the device (the default),
light, or dark. The choice is saved, and the system bar icons follow the app's choice
rather than the device's. The palettes are Material's baseline light and dark schemes,
not the system's dynamic colours: those were tried and, on the test device, gave the
keypad's accent bands light containers in dark mode.
The original plan, kept for the record: a bottom navigation bar with 4 destinations,
**Standard**, **Programmer**, **Financial**, **Convert**. Programmer and Financial are
not built, and will be drawer entries when they are.
```
┌─────────────────────────────────────────┐
@ -2067,41 +2274,84 @@ Bottom navigation bar with 4 destinations: **Standard**, **Programmer**, **Finan
### 9.2 Standard Calculator Screen
Classic calculator layout. Expression builds at top, result previews live below it, button grid fills the bottom half.
**Decided after the first build, and built.** Touch-first: the keypad is the input, the
system keyboard is on request, and the answer is visible before `=` is pressed. The
layout follows NCalc (NCalcLibre on F-Droid), which is the reference for "what this
should feel like": a display card over a pad, a scientific block on an accent band above
the digits, and the rarer functions on a panel that slides up over the numbers.
```
┌─────────────────────────────────────────┐
│ Expression area (scrollable) │
│ ┌─────────────────────────────────┐ │
│ │ 2 × (3 + 4) │ │ ← expression in natural format
│ │ = 14 │ │ ← live result preview (dimmed)
│ └─────────────────────────────────┘ │
│ │
│ History peek: last 2-3 results │
│ ┌─────────────────────────────────┐ │
│ │ 5^2 = 25 sqrt(144) = 12│ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ sin │ cos │ tan │ ( │ ) │ ⌫ │ │ ← function row (swipeable for more)
│ ├─────┼─────┼─────┼─────┼─────┼───┤ │
│ │ 7 │ 8 │ 9 │ ÷ │ ^ │ │ │
│ ├─────┼─────┼─────┼─────┼─────┤ │ │
│ │ 4 │ 5 │ 6 │ × │ % │ │ │
│ ├─────┼─────┼─────┼─────┼─────┤ │ │
│ │ 1 │ 2 │ 3 │ - │ sqrt│ │ │
│ ├─────┼─────┼─────┼─────┼─────┤ │ │
│ │ 0 │ . │ Ans │ + │ = │ │ │
│ └─────┴─────┴─────┴─────┴─────┘ │ │
└─────────────────────────────────────────┘
+-----------------------------------------+
| Tally History | <- the only chrome
| |
| x / 3 + 1| | <- expression, large, with a cursor;
| = 4.2255e29 (dim) | operators drawn as glyphs
|-----------------------------------------|
| sin cos tan ln log sqrt | <- scientific block, accent band
| ( ) ^ x^2 n! e |
| x A B , mod abc |
|-----------------------------------------|
| 7 8 9 CLR <x | <- NCalc's numeric block, key for key
| 4 5 6 * / |
| 1 2 3 + - |
| 0 . pi Ans = |
|-----------------------------------------|
| more functions | <- slides a panel up over the digits:
+-----------------------------------------+ asin acos atan atan2 exp log2 log10
cbrt abs floor ceil round min max tau C
```
**Key interactions:**
- Tap buttons to build expression (no keyboard typing required)
- Long-press `=` to copy result
- Swipe function row for more functions (log, ln, abs, etc.)
- Tap history items to recall into expression
- Pull-down to expand full history view
- **No system keyboard by default.** Every key inserts plain engine syntax at the cursor
(`*`, `sqrt(`, `pi`), and the field still takes a tap to move the cursor. `abc` hides
the pad and raises the system keyboard for what a pad cannot offer - any other
variable name, functions not on it; Enter or Done means `=`, and dismissing the
keyboard brings the pad back. Both write the same text, so either can finish what the
other started.
- **Variables without a keyboard.** `x`, `A`, `B` (and `C` on the panel) insert their
name; a long-press stores into them - the expression if one is typed, `Ans` otherwise.
A calculator's STO, written as the `A = ...` assignment the engine already has.
- **Live result.** Every edit asks the engine for a *preview* (6.5): the same evaluation,
with nothing stored. Typing `x = 7` shows `= 7` without assigning `x`, and `Ans` stays
the last committed answer until `=`. A preview that fails - usually an unfinished
expression like `2 +` - shows nothing rather than an error, so the screen does not
flash red on every other keystroke.
- **`=` commits.** The entry goes into history, the answer becomes the display at full
size, and the expression that produced it sits above it, dimmed. A key that continues
an expression (an operator, `^`, `x^2`, `mod`) starts from `Ans`, so `= 14`, then `* 2`,
means `Ans * 2`. A digit starts fresh. Backspace brings the committed expression back
for editing. An error on `=` is shown in place and the expression is kept.
- **The answer is the largest thing on screen,** right-aligned, and shrinks to keep a
long exact answer on one line: `2^100` is 41 characters with its separators and fits.
Long-press copies it.
- **Backspace** deletes before the cursor; **long-press** or `CLR` clears.
- **`,` inserts `, `.** The engine reads `1,234` as a grouped number, so an argument
separator needs its space: `max(1,234)` is 1234, `max(1, 234)` is 234.
- **History is its own screen,** as in NCalc, so the calculator keeps its whole height
for the display and the pad. Newest first; tap an entry to put its expression back,
long-press to copy its answer. Results are not inserted as text: they carry separators
and may be abbreviated, which is display rather than the value. The engine version -
the first proof that a string crossed JNI - is on its empty state.
- **No unit conversion.** It is its own screen with pickers (9.6), not something typed
here. The engine still converts a typed `100 km to mi` (FR-4.1), so it works through
`abc`, but the screen offers it nowhere.
- **Evaluation is off the main thread.** A preview of `factorial(5000)` has to cost a
frame, not an ANR, so every engine call runs on one dedicated thread. One, because a
session takes no locks (6.1, rule 3): serialising the calls is the caller's job, and a
single-threaded executor does it - including at teardown, where the session is freed
by a task queued behind anything still running.
- **Colours are the system's** (Material You) on Android 12 and later, Material's
defaults before that; light and dark follow the system.
- **It reopens as it was left.** The expression and its cursor, the answer on display,
the history (newest 500), and - through the engine's own save (6.7) - every variable
and `Ans`, exactly. Saved on every edit, so even a kill with the app in the
foreground loses nothing. Clearing history keeps the variables: history is a record
of what was typed, not where values live. Verified on the emulator: store 7 in A,
`A x 6 =`, leave `2+` half typed, force-stop in the foreground, relaunch - `2+` is
there, `2+Ans` previews 44, `A` is 7, and all four entries are in history.
Not yet: landscape (9.7), a `sqrt` drawn as a radical in the expression, and any check
on a physical device.
### 9.3 Programmer Mode Screen
@ -2247,46 +2497,90 @@ Card-based interface with function selector at top.
- Result card shows formula breakdown (expandable)
- Long-press result to copy
### 9.6 Convert Mode Screen
### 9.6 Convert Screen
Designed for fast, one-handed unit conversion.
**Decided and built, against NCalc's.** NCalc makes a temperature conversion cost four
steps before a digit: Unit Converter, Temperature, the input unit, then the number - and
it starts at the converter list every time it opens. The goal here is the opposite: the
common case costs no navigation at all.
```
┌─────────────────────────────────────────┐
│ ┌─── Category ────────────────────┐ │
│ │ [Length] [Mass] [Temp] [Time] ▸ │ │ ← scrollable chip row
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ │ │
│ │ 100 │ │ ← large editable number
│ │ kilometers [▾] │ │ ← unit selector dropdown
│ │ │ │
│ │ ⇅ (swap) │ │ ← tap to swap from/to
│ │ │ │
│ │ 62.1371 │ │ ← live result (updates as you type)
│ │ miles [▾] │ │ ← unit selector dropdown
│ │ │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─── Keypad ─────────────────────┐ │
│ │ 7 │ 8 │ 9 │ │ │
│ ├─────┼─────┼─────┤ ⌫ │ │
│ │ 4 │ 5 │ 6 │ │ │
│ ├─────┼─────┼─────┼─────────────┤ │
│ │ 1 │ 2 │ 3 │ │ │
│ ├─────┼─────┼─────┤ C │ │
│ │ 0 │ . │ ± │ │ │
│ └─────┴─────┴─────┴─────────────┘ │
└─────────────────────────────────────────┘
+-----------------------------------------+
| = Convert Calculator |
| [Search] [Temperature] [Length] [Mass]..| <- search tile that never moves, then
| [ 37 C ] [180 mi] | EVERY category, most recent first
| [37] [C v] | <- the value (highlighted = the next
|-----------------------------------------| digit replaces it); the unit picker
| C Celsius 37 | <- highlighted in place
| F Fahrenheit 98.6 | <- every unit of the category, in a
| K Kelvin 310.15 | FIXED order: tap a row to view from
| R Rankine 558.27 | it, long-press to copy
|-----------------------------------------|
| 7 8 9 <x | <- digits only
| 4 5 6 CLR |
| 1 2 3 +/- |
| 0 . |
+-----------------------------------------+
```
**Key interactions:**
- Conversion updates live as user types (no "calculate" button needed)
- Swap button rotates from/to units with an animation
- Unit selectors open a searchable bottom sheet (for categories with many units)
- Category chips filter which units appear in the selectors
- Recent conversions remembered
- **It reopens where it was left,** on the category, value and unit, with the value
highlighted so the next digit starts a new number.
- **Each category remembers its own value and unit.** Leave Temperature at `98.6 F` and
Length at `180 mi` (Seattle to Portland, for telling foreigners), and a tap on either
lands exactly there. A category never visited starts at 1 of its base unit.
- **The top row is every category, most recently used first,** scrollable. A tap moves
that category to the front and the row scrolls back to show it there. A chip shows the
category and, once visited, the value it will land on - `Temperature` over `37 C` -
because a value reads as where you left off, where a bare unit would read as the only
unit on offer.
- **A search tile leads the row and never moves,** which is what keeps the LRU order
from feeling like a shuffle: search, or pick from what you used. It searches every unit
by name, alias or category, focused at once because it was asked for. Before anything
is typed it lists every category in the row's order: history for a returning user,
the first-run order (Temperature, Length, Mass, Volume, Speed, then the rest) as
suggestions for a new one. Picking a unit goes to its category in that unit.
- **Within a category the order is fixed** - the engine's table order - because with 4 to
15 units, knowing where a unit sits beats any reordering. The unit on display is
highlighted where it is, not removed, so a tap moves only the highlight. A recency
sort here was considered and set aside; it is a small change if fixed order turns out
to mean hunting.
- **Direction is one tap.** Tap the C row after typing 98.6 F and the screen views from
Celsius. Nothing is recomputed - the rows are already the typed value in every unit -
so viewing from mi and back to km is exact. Typing then edits the number on screen,
in the unit on screen.
- **The list moves only when you scroll it.** Each category keeps its own scroll
position, saved with its value and units and restored without animation, so coming
back to Length - after another category, or a relaunch - puts the list exactly where
it was. Choosing a unit, from a row, the dropdown or search, never scrolls: the value
line already says what was chosen, and someone reading down the column comparing
values would lose their place. The position is the unit in the top row plus the pixels
of it scrolled off, anchored by name rather than row number, so a unit the engine adds
above it does not shift the list onto a different unit; an anchor the engine has
dropped means the top. Saved when the category is left, the screen goes, or the app
goes to the background - not on every scroll.
- **The unit button is a dropdown** of the category's units, opening in place with the
value still visible: the list is 4 to 15 long, too short to earn a screen of its own,
which is what the first version gave it. Picking one is a result-row tap. Search stays
a full screen, because it needs the keyboard and spans every category.
- **Names come from the engine.** Every unit in `units.zig` carries a required display
`label` - US spelling, singular, "Meter per second" - and a unit without one does not
compile. It reaches the app through the catalogue (ABI 4), and the CLI and TUI can
list the same names. It replaced a Kotlin guess from the aliases, which gave
"Meterpersecond".
- **Inexact rows say so** with a leading approx sign: degrees to radians goes through pi.
- **Fewer digits than the calculator:** ten decimals and twelve significant digits, set
on the converter's own session.
- **The rules are plain Kotlin** (`Converter.kt`), unit tested on the JVM; the view model
only connects them to the engine, the disk and the screen.
Tap costs against the previous design: a used category is one tap and lands on its own
value (it used to be one tap that carried the wrong number across); a specific unit in
another category is two (category, then row); a never-used category is a scroll and a
tap, or search.
Not yet: currency (needs a network; the app has no permissions), compound units beyond
the tables, and expressions in the value - the calculator's `abc` route still converts
`100 km to mi` for that.
### 9.7 Tablet Layout (landscape / large screen)

View file

@ -182,12 +182,19 @@ still required (FR-7.7): the mouse never becomes the only way to do something.
- **FR-7.11.12**: Clickable regions are rebuilt every frame from what was actually drawn, so hit targets can never drift out of sync with the display.
- **FR-7.11.13**: In financial mode, clicking a calculation chip selects that calculation and clicking a field row focuses it for editing; the mouse wheel scrolls the amortization schedule. In the help overlay the wheel scrolls and a click returns.
### FR-8: Android Frontend - NOT BUILT
### FR-8: Android Frontend - WALKING SKELETON BUILT, UNVERIFIED ON A DEVICE
Nothing in FR-8 exists yet: no `android/` project, no Kotlin, and the C ABI it calls
through is two stub exports (design 6). Design 9 holds the screen designs, and design
6.1 holds the three decisions the ABI has to make before any of this can start. The
requirements below are unchanged; they are the target, not a description.
`android/` now holds a one-screen Compose app over the C ABI: an expression field, a
history, and the multi-base and conversion detail lines. The engine behind it is the
finished one, so exact arithmetic, units and the financial functions all work in that one
field already (FR-4.1, FR-5.7). Nothing in `android/` has been compiled: the machine it
was written on has no JDK, Gradle or SDK, by choice, so the version numbers and the
instrumented test are the first things to try.
What is built of the requirements below: FR-8.1 (Kotlin and Compose), FR-8.2 (JNI, via a
hand-written layer with no NDK - design 6.4), and the standard-mode half of FR-8.4.
FR-8.3's navigation, the programmer and financial and convert screens, and everything from
FR-8.9 down are not started. FR-8.6 needs FR-3, which is unbuilt.
- **FR-8.1**: Native Android app using Kotlin and Jetpack Compose.
- **FR-8.2**: Call into the Zig engine via JNI (C ABI shared library).
@ -215,7 +222,7 @@ requirements below are unchanged; they are the target, not a description.
### NFR-2: Portability
- Engine, CLI, and TUI must compile and run on: Linux (x86-64, aarch64), macOS (aarch64, x86-64), Windows (x86-64).
- Android engine .so must target: arm64-v8a, armeabi-v7a, x86_64. NOT WIRED UP: `build.zig` produces host static and shared libraries only, and no Android target has been added or tested.
- Android engine .so must target: arm64-v8a, armeabi-v7a, x86_64. BUILT: `zig build android` produces all three, ~300KB each, with zero dynamic dependencies and 16KB page alignment for Android 15's page size requirement. No NDK is involved. Not yet loaded by a JVM.
- Single `zig build` invocation to produce all desktop targets (cross-compilation).
### NFR-3: Correctness
@ -242,7 +249,7 @@ requirements below are unchanged; they are the target, not a description.
### NFR-6: Build & Distribution
- **Zig build system for everything.** A single `zig build` invocation at the workspace root handles all targets: engine, CLI, TUI, and Android .so cross-compilation. No Gradle required for the native library build - Gradle only wraps the prebuilt .so into the APK. (Today: engine libraries, the `tally` binary, `test` and `coverage`. The Android half is not wired up.)
- **Zig build system for everything.** A single `zig build` invocation at the workspace root handles all targets: engine, CLI, TUI, and Android .so cross-compilation. No Gradle required for the native library build - Gradle only wraps the prebuilt .so into the APK. (Today: engine libraries, the `tally` binary, `test`, `coverage`, and `android` for the three ABIs. Gradle packages what that last step writes and invokes no compiler of its own.)
- **Mise for toolchain management.** All required toolchains (Zig version, Android NDK if needed for validation) are declared in `.mise.toml` at the project root. A developer needs only `mise` installed - running `mise install` provisions everything else.
- **No other system-level dependencies.** Outside of mise, a developer should not need to manually install Zig, Android SDK/NDK, or any other toolchain. The `.mise.toml` is the single source of truth for tool versions.
- **libvaxis for TUI.** The TUI frontend uses libvaxis (pulled as a Zig build dependency). No other TUI framework.

View file

@ -1692,12 +1692,13 @@ the review would have spent its time on):
STILL OPEN, in the order I would take them:
As of the end of the file-by-file review: 18 items recorded, 5 struck as fixed, 13
As of the end of the file-by-file review: 18 items recorded, 7 struck as fixed, 11
live. Four of the live ones are engine correctness (1 through 5 below, minus the struck
one), four are display or interaction defects in the TUI, and three are the C ABI's
unfinished business (12, 15, and the session-handle question in design 6.1), which is
what the Android app will meet first. Item 18 is a list of its own: the findings from
`src/tui.zig`, recorded rather than fixed at the point the review reached that file.
one), four are display or interaction defects in the TUI, and the C ABI's unfinished
business is now only the JNI layer and the financial payloads (design 6.4 and Task 6.0),
since items 12 and 15 were closed when the boundary was written. Item 18 is a list of
its own: the findings from `src/tui.zig`, recorded rather than fixed at the point the
review reached that file.
1. ~~`>>` is logical in standard mode and arithmetic in programmer mode, shift
amounts wrap in one and clamp in the other, and `evaluator.zig` ignores the
@ -1768,9 +1769,10 @@ what the Android app will meet first. Item 18 is a list of its own: the findings
11. ~~Literals longer than 128 characters are rejected by a fixed tokenizer buffer,
and non-decimal literals are capped at 64 bits, both below what the exact tier
supports.~~ Fixed by Task 5.18.
12. `engine/src/c_api.zig` has no tests and appears in no coverage report, because
no test target builds the shared library. Deferred until Phase 6 gives it a
caller; recorded here so it is a known gap rather than an oversight.
12. ~~`engine/src/c_api.zig` has no tests and appears in no coverage report, because
no test target builds the shared library.~~ Fixed by Task 6.0: two test layers now
cover it (Zig tests against `std.testing.allocator`, and a C program linked through
`include/tally.h`), and it has its own coverage report at 98.4%.
13. NFR-9.9 ends with "the exact fraction available", and no frontend ever asks for
it. `Rational.toFractionString` works and is tested, but its only callers are
tests: nothing in the TUI or the CLI renders `1/3` as a fraction. Wiring it
@ -1782,11 +1784,12 @@ what the Android app will meet first. Item 18 is a list of its own: the findings
available" and requirements.md line 237's clipboard form are engine-side only.
`Number.render` reports the fact and takes clipboard options; the TUI has yet
to draw an indicator or copy anything.
15. `c_api.zig` renders nothing today, so Phase 6 has to decide how a
`Number.FormatOptions` crosses the FFI boundary. It must be parameters the
caller passes, not a default the C layer invents, or Android inherits a budget
chosen for an 80-column terminal (Task 5.17). Task 6.2 covers the bridge; this
is the display half of it.
15. ~~`c_api.zig` renders nothing today, so Phase 6 has to decide how a
`Number.FormatOptions` crosses the FFI boundary.~~ Decided and built in Task 6.0:
the display budget is five fields on `tally_config`, set on the session, and the
caller reads the defaults back rather than guessing them. A test asserts a
four-fraction-digit, separator-free configuration reaches the rendering, so the
budget provably belongs to the frontend (NFR-9.9) rather than to the C layer.
16. **The last answer should be a pseudovariable in its own right** (FR-1.6.1), and
the engine does not model it as one. Task 5.20 stopped `Ans = 5` from being
silently discarded, but it did so by folding `Ans` in with `pi`, `e` and `tau`
@ -1881,46 +1884,82 @@ what the Android app will meet first. Item 18 is a list of its own: the findings
`-9223372036854775808.0` and `18446744073709551616.0`; and `handleKey` is a
200-line if-chain mixing global keys, mode-specific keys and zone dispatch, which
reads fine today but is where a new binding will end up in the wrong order.
19. **Nothing enforces the freestanding-library rule that design 6.3 depends on.** The
Android libraries must have zero `DT_NEEDED`, zero undefined dynamic symbols and no
TLS segment, or they fail in `dlopen` before any code runs. That property was restored
by hand and is checked by hand, and three things make it likely to regress silently:
any `std` call can reach for a libc symbol on a path that never executes, `BIND_NOW`
is set so even an unreferenced PLT entry is resolved at load, and the one instance
found so far reproduced on **arm64-v8a only** - so an x86_64 emulator, which is what
CI would use, passes while phones fail. It wants a build step that parses the emitted
`.so` with `std.elf` and fails on a non-empty undefined-symbol set, per ABI, rather
than a `readelf` invocation in a shell. That step is also the natural home for the
16 KiB `LOAD` alignment check, which is currently only a Play submission rule nobody
verifies locally.
---
## Phase 6: Android App
### Task 6.0: Finish the C ABI (PREREQUISITE, not started)
### Task 6.0: Finish the C ABI [DONE for the C layer; JNI still open]
Nothing in Phase 6 can be verified until the library answers. `c_api.zig` is the root of
`libtally.so` and has three exports: `tally_version` works, `tally_eval` and
`tally_result_free` have `// TODO: implement` bodies, and no test builds the library, so
it appears in no coverage report (open items 12 and 15).
The boundary is written, tested and cross-compiling. Three of design 6.1's four questions
are answered in code: the session is a handle, the memory model is 6.2's (session-owned
buffer, borrowed results, no `tally_result_free`), and `FormatOptions` cross as fields on
`Config`. The fourth - what the JSON carries - is answered for standard mode, programmer
mode, conversion and the units catalogue, and still open for the financial and float
views.
- Toolchain first, and all of it through mise: pin the JDK, Gradle and the Android SDK
(plus the NDK if design 6.4 picks the `jni.zig` route) in `.mise.toml`, and check each
one resolves before anything depends on it. Nothing gets installed system-wide.
**Still to do before Kotlin:**
- Decide the four open questions in design 6.1, in this order: session lifetime
(an opaque handle, or a calculator with no variables and no `Ans`), memory ownership
(design 6.2 recommends a session-owned buffer with borrowed results and no
`tally_result_free`), who supplies `FormatOptions`, and what the JSON carries.
- Confirm the ground first: DONE for the compile half. Both Android ABIs build a
dependency-free ~230KB shared object containing the whole engine, with no NDK
(design 6.2). What remains is loading it: `System.loadLibrary`, symbol resolution
from a JVM, and the Kotlin binding choice in design 6.4.
- Implement `tally_eval` and `tally_result_free` against those decisions, including
which allocator the library owns and how a caller frees what it is handed.
- Add the units and financial enumerations Android needs for its pickers, so the
category list, the unit list per category and the form field specs come from the
engine rather than being retyped in Kotlin.
- Write `engine/src/jni.zig`: the `Java_*` entry points, compiled only for Android
behind a build option (design 6.3). Keep it thin - convert, call the C ABI, copy the
result into JVM memory - because that copy is what makes the borrowed-result rule in
6.2 safe without asking an app developer to remember it.
- Hand-write `include/tally.h` and have a test compile against the real exports, and add
`tally_abi_version()` beside `tally_version()` (design 6.3).
- Test the ABI from Zig, calling the exports the way JNI will: a test target that
builds the shared library and exercises each export, including the free path under
a failing allocator.
- Verify: `zig build test` covers `c_api.zig`, it appears in a coverage report, and a
round trip (`x = 5`, then `x * 2`) works across the boundary.
- The JNI translation layer, once design 6.4's three options are chosen between. Keep it
thin: convert, call the C ABI, copy the result into JVM memory. That copy is what makes
the borrowed-result rule safe without an app developer having to remember it.
- Financial and float-view payloads, which need form specs and a schedule shape. Better
decided against a screen than in advance.
- Toolchain through mise when the Kotlin side starts: JDK, Gradle, Android SDK, plus the
NDK if 6.4 picks the `jni.zig` route. Pinned in `.mise.toml`, each one checked to
resolve, nothing installed system-wide.
- Loading it for real: `System.loadLibrary`, symbol resolution from a JVM, and a
page-backed allocator under Android's memory pressure. The compile half is proven; none
of the runtime half is.
**What the ABI looks like.** Eight exports in `engine/src/c_api.zig`, implementing design
6.1 and 6.2: a session handle that owns the memory, results borrowed from the session's
buffer until its next call, and no `tally_result_free` at all. Standard mode, programmer
mode, unit conversion (the `to` keyword works from standard mode exactly as it does in
the CLI, FR-4.1) and a units catalogue for pickers. Results are JSON written by
`std.json`; errors carry `engine.phrase`'s wording, so a frontend that adds its own error
table is duplicating one that exists.
**Three lifetimes, as designed.** `Environment` on the session allocator, an arena per
call reset with `retain_capacity`, and a result buffer cleared rather than freed. A test
runs a hundred evaluations and asserts the result buffer's capacity never moves after the
first, which is the "steady state is near zero allocation" claim made concrete.
**Two test layers, because they catch different things.**
- Zig tests build sessions with `std.testing.allocator`, so the seam is leak-checked: a
session that forgets its arena fails here rather than on a phone. Thirteen tests cover
the session contract (`x = 2^100`, then `x + 1`, then `Ans / 2`, exact all the way
through), borrowed-result reuse, error wording, conversion, the multi-base rows,
programmer rows at a configured width and byte order, config round-tripping, the
catalogue, every malformed-call path, and the exported constructor that uses
`smp_allocator` rather than the test allocator.
- `engine/test/c_abi_test.c` links against the real library through the real
`include/tally.h` and is run by `zig build test`. This is the only layer that can catch
a header disagreeing with the library: struct field order and symbol names are
invisible from Zig. It is compiled `-std=c99 -Wall -Wextra -Werror`.
**`zig build android`** produces `zig-out/android/{arm64-v8a,x86_64,armeabi-v7a}/libtally.so`,
which is the layout Gradle's `jniLibs` expects, so packaging stays a copy and Gradle
never invokes Zig. ReleaseSmall always: ~300KB per ABI, and every one has zero
`DT_NEEDED` entries. A debug build is twenty times that and has no business in an APK.
- Verify: 956 tests pass plus the C program, fmt and zlint clean, ASCII clean.
Coverage adds a fourth report: engine 99.42%, CLI 98.87%, TUI 98.41%, ABI 98.41%, with
the ABI's five uncovered lines all out-of-memory paths. `zig build` still installs the
host library and now the header with it.
### Task 6.1: Set up Android project structure
- Create `android/` directory with Gradle project (Kotlin DSL)
@ -1954,6 +1993,133 @@ it appears in no coverage report (open items 12 and 15).
- Long-press `=` to copy result, tap history items to recall
- Verify: can perform calculations, see results, scroll history
### Task 6.1: Set up Android project structure [DONE, unverified on a device]
`android/` is a Gradle project with one module: Compose, Material 3, minSdk 26,
`namespace`/`applicationId` `dev.lerch.tally`. `jniLibs.srcDirs` points at
`zig-out/android`, so `zig build android` output is packaged with no copy step and Gradle
never invokes Zig. `abiFilters` lists exactly the three ABIs that step produces.
`zig build android` now roots at `jni.zig` rather than `c_api.zig`, so the Android library
carries both the `Java_*` entry points and the C ABI, and sets
`link_z_max_page_size = 16384`: Android 15's 16KB pages are a Play requirement, and the
default would load fine on a 4KB device and be rejected at submission.
The toolchain is pinned in `.mise.toml` (`java = "temurin-21"`, `gradle = "8.14.5"`,
`android-sdk = "23.0"`), and `mise install` is necessary but not sufficient: it provides
`sdkmanager` but no SDK to build against, and the packages `sdkmanager` downloads land
inside the mise install directory where mise does not track them. Four mise tasks close
that gap - `android-sdk`, `android-emulator`, `android-avd`, `android-run` - so every step
is one command and the versioned packages are pinned. `platform-tools` and `emulator` have
no version in their package ids and are the one thing that stays unpinned. No NDK, by
design. The whole procedure, including deploying over USB and over wireless adb, is
`android/SETUP.md`.
The emulator image worth using is `system-images;android-35;google_apis_ps16k;x86_64`,
because its 16 KiB pages are what `link_z_max_page_size` is for. The AVD lives in
`.tmp/avd` via `ANDROID_AVD_HOME` rather than `~/.android`, which keeps it out of the home
directory and also routes around a dangling `~/.android/avd` symlink on this machine that
makes `avdmanager create` fail with nothing but a path in the error.
- Verified: `gradle assembleDebug` builds a debug APK with all three `libtally.so` files
packaged (`lib/arm64-v8a`, `lib/x86_64`, `lib/armeabi-v7a`). AGP 8.7.3, Kotlin 2.0.21
with the Compose plugin, and Compose BOM 2024.10.01 all resolve against Gradle 8.14.5.
One compile error was found and fixed on the way: Material 3's `TopAppBar` is still an
experimental API and needs an opt-in.
- Verified on an emulator: the app installs, launches, and evaluates. The 10 instrumented
tests pass (`gradle connectedAndroidTest`). Getting there found a real loading bug that
no host test could have caught - see design 6.3 - because the first APK crashed in
`dlopen` on a missing `__tls_get_addr`.
- Not verified: the Gradle wrapper is not committed, and nothing has run on a physical
device. arm64-v8a is the gap that matters, since it is the ABI phones use and the one
where the page-size bug appeared.
### Task 6.2: Implement JNI bridge [DONE for the skeleton]
`engine/src/jni.zig`, the only Android-specific code in the tree, hand-written against a
function table declared by index because the NDK is not in the picture (design 6.4). Seven
entry points: session create and free, eval, unit catalogue, version, ABI version, and
`JNI_OnLoad`, which proves the table against the running JVM and fails
`System.loadLibrary` if it does not behave.
Kotlin side: `TallyEngine` (the `external fun`s and an ABI check), `TallySession` (an
`AutoCloseable` handle, so a use-after-close is a Kotlin exception rather than a native
crash), and `TallyResult`, which parses the JSON with `org.json` - Android ships it, and
the payload is small enough that a serialization dependency would buy nothing.
Kotlin does no arithmetic, formats no numbers and words no errors. `MultiBaseResult` and
`StructLayoutResult`, which this task originally told us to deserialize, do not exist: the
first was deliberately dropped (design 2.2) and the second belongs to unbuilt FR-3.
- Verify: 965 tests pass on the host, including seven JNI tests against a synthetic
function table, and `jni.zig` is at 100% line coverage in its own report.
- And the part that was supposed to need a device does not: `zig build jvm-test` proves
the four function-table indices against a real JVM on the desktop, because JNI's table
is fixed by the specification and not by the platform. `System.loadLibrary` runs
`JNI_OnLoad`, then every entry point is driven from Java. Shifting one index by a slot
makes the step fail, so the check is known to work rather than assumed to.
### Task 6.3: Implement standard calculator screen [DONE; redesigned after the first build]
**The first version** was an expression field, the system keyboard, an `=` button and a
monospace scrollback. It proved the boundary end to end on a device and was nearly
unusable: the TUI's prompt moved onto glass, which section 9 had ruled out in so many
words. It is recorded here because the redesign is a list of answers to its failures.
**What it is now** is design 9.2: an NCalc-style pad with no system keyboard by default,
a live result from the engine's new preview (design 6.5), `=` committing and continuing
from `Ans`, variable keys whose long-press stores into them, an `abc` key for the system
keyboard, a slide-up panel for the rarer functions, and history on its own screen. Kotlin
in `MainActivity.kt` (screen, display, history), `Keypad.kt` (keys and layout) and
`TallyViewModel.kt` (state, and every engine call on one dedicated thread).
The engine half, done first and test-driven:
- `engine.previewStringInfo`, taking a `*const Environment`. Assignment is a statement
(Task 5.19), so `evalStringInfo` now unwraps the root assignment itself and the
recursive evaluator takes the environment as `const` - it can no longer store
anything, and a preview that mutates the session is a compile error, not a test. Five
evaluator tests under `std.testing.allocator`, including a hand-built nested
assignment that is refused.
- `tally_preview` in the C ABI, same signature as `tally_eval`, sharing its argument
checks; `abi_version` 2, `TALLY_ABI_VERSION 2u`, `EXPECTED_ABI_VERSION = 2`. Three
`c_api` tests, a C ABI check through the real header, a JNI test against the synthetic
table, and `jvm-test` against a real JVM.
- A side effect worth having: a failed copy no longer leaves an assignment half-made,
because the variable is stored only after the value has been copied out.
- Verified on an emulator (Android 15, x86_64, 16 KiB pages), by driving the pad:
`2+3*4` previews `= 14` before `=`; `=` shows `14` with the expression above it; `*`
then `2` reads `Ans*2` and previews `= 28`; `abc` hides the pad and opens an input
connection, `r = 5` previews `= 5`, and Enter commits and brings the pad back; the
panel slides over the digits and back; long-press `A` after `77` stores it, and `A+2`
previews `= 79`; History lists entries newest first, tapping one recalls it, and
system back returns to the calculator; `2^100` shows all 31 digits on one line.
- 11 instrumented tests pass, including the new preview test. 974 host tests pass.
- One bug found by driving it and fixed: the field wraps, so a keyboard's Enter arrived
as a newline instead of as Done. A newline from the keyboard now means `=`.
- Not verified: a physical device; a real on-screen keyboard (the emulator's IME showed
its hardware-keyboard toolbar, so the inset-based "keyboard dismissed, bring the pad
back" path was not exercised - Enter was); TalkBack, though every symbol key carries a
spoken label; and dark mode, which follows the system but was not looked at.
**Persistence, after the converter showed what it should feel like:** the calculator
was the screen that forgot - expression, answer, history, variables and `Ans` all gone
when the process died. Now all of it comes back (design 9.2, 6.7).
- Engine: `tally_session_save` / `tally_session_load`, exact for both tiers (fraction
text with every digit, f64 bits), all-or-nothing, ABI 5. `Rational.parseFraction` is
the uncapped inverse of `toFractionString`. Nine Zig tests including two
allocation-failure sweeps; a C round trip through the header; JNI against the
synthetic table and a real JVM. 992 host tests.
- App: `CalcState.kt`, plain Kotlin with six JVM tests (22 in all) - history keeps the
engine's own JSON per entry, so a restored entry is parsed exactly as a fresh one;
the newest 500 are kept; damaged lines are dropped, not guessed at; the cursor is
clamped into the text. One more instrumented test (13).
- Verified on the emulator: store 7 in A, `A x 6 =`, leave `2+` typed, force-stop in
the foreground, relaunch: `2+` is back, `2+Ans` previews 44, `A` is 7, history holds
all four entries.
- Decided: clearing history keeps variables and `Ans`. Replaying history on launch was
the alternative, and rejected - see design 6.7.
### Task 6.4: Implement programmer mode screen
- Compose UI: segmented control for bit width (8/16/32/64)
- Value display cards: DEC, HEX, OCT, BIN, signed/unsigned (tappable to switch primary input base)
@ -1982,18 +2148,74 @@ it appears in no coverage report (open items 12 and 15).
- Long-press result to copy
- Verify: all financial calculations produce correct results, formula display works
### Task 6.7: Implement unit conversion screen
- Compose UI: scrollable category chip row at top
- Large editable number input (top), from-unit selector dropdown
- Swap button (animated rotation) between from/to
- Live result display (bottom), to-unit selector dropdown
- Numeric keypad (no expression input needed - just digits, decimal, sign)
- Unit selectors open searchable bottom sheets
- Conversion updates live as user types (no "calculate" button)
- Verify: live conversion works, category switching filters units, swap works
### Task 6.7: Implement unit conversion screen [DONE]
Design 9.6, built as a deliberate departure from NCalc, whose converter costs Unit
Converter, then Temperature, then the input unit, before a digit - and starts there on
every launch. Here the app reopens on the converter exactly as it was left, and the
common case is: open, type, read.
- Engine: `tally_convert` (design 6.6) returns a value in every unit of its category in
one call, previewed so nothing is stored; `abi_version` 3. Four `c_api` tests (every
temperature unit exact from `100 C`, an expression value that stores nothing, an
inexact angle row, errors and malformed calls), a C ABI check through the header, JNI
tests for `convert` and `configureDisplay` against the synthetic table, and `jvm-test`.
980 host tests; the three Android libraries still have zero undefined symbols.
- Kotlin: `ConvertScreen.kt`, `ConvertViewModel.kt` (state, its own session and
thread), `Units.kt` (the catalogue and table parsed), `AppPrefs.kt` (theme, last
screen, converter state, recents). One instrumented test added; 12 pass.
- Verified on the emulator by driving it: relaunch after a force-stop opens on Convert
with the saved value, unit and dark theme; tapping the F row then typing `98.6` gives
37 C, exact; the km chip carries the number across categories; 98.6 km, tap mi, tap
km is exactly 98.6 (it was 98.6000000000 and 98,599,999,999,998.1824 nm before the
source unit was kept separate from the shown one - a bug caught by looking); the
picker shows every unit grouped by category.
- Not verified: a physical device, TalkBack, and the light theme on the converter after
the palette change.
Also in this pass, from the same request: the calculator panel's labels are now
"functions" and "numbers"; a hamburger drawer (Calculator, Convert, Settings) as in
NCalc; a theme setting - follow the device by default, or light or dark - saved and
applied to the system bars too.
**Redesigned after first use (design 9.6):** the top row became every category in
most-recently-used order behind a fixed search tile; each category remembers its own
value and units; the result list is in fixed table order with the shown unit
highlighted in place; the unit button opens the category's units only. The rules moved
into `Converter.kt`, plain Kotlin with 12 JVM unit tests (`gradle testDebugUnitTest`, no
device) - the first Kotlin tests that do not need one. A mutation check (reinstating the
old backspace bug) fails the suite, so they test what they claim. That old bug: after
tapping a row, backspace edited the typed value read as the shown unit - 98.6 km, tap
mi, backspace gave `98.` miles.
- Verified on the emulator, driven by label: 98.6 F and 180 mi each restored by one tap
on their category; tapping the C row highlights it in place with nothing moving; the
Temperature chip then reads `37 C`; search for `knot` finds Speed > kn and lands on
1 kn; a force-stop restores all of it. 12 instrumented tests pass.
- Migrates the single value an older build saved (unit tested; not exercised on a
device, since the instrumented run uninstalls the app first).
- Follow-up from first use on the phone: the unit button became a dropdown (the
separate screen was the wrong weight for 4 to 15 units), and every unit gained a
required engine-side display name (`label` in `units.zig`, in the catalogue at ABI 4),
replacing the Kotlin guess that produced "Meterpersecond". A test in `units.zig` holds
every label to non-empty, capitalised, printable ASCII and unique within its
category; 983 host tests, 12 JVM, 12 instrumented.
- Then per-category scroll position (design 9.6): the unit in the top row plus pixel
offset, in the category's saved state, optional on load so older saves still work.
Nothing but the user's finger moves the list - auto-scrolling to the chosen unit was
proposed and rejected, because it fights reading down the column. Four more JVM tests
(16), with a mutation check: reinstating the old `edited`, which rebuilt the memory
from scratch, drops the position on the first keystroke and fails the suite. On the
emulator the first fully visible row came back at the same pixel after a dropdown
choice, typing, a category switch, and a background plus force-stop.
### Task 6.8: Android polish
- Material 3 theming (dynamic color on Android 12+)
- Material 3 theming: DONE with Task 6.7 as light, dark and follow-the-device, on
Material's baseline palettes. Dynamic colour (Android 12+) was tried and dropped: it
made the keypad's bands light in dark mode. Open item 17 (TUI theming) is separate.
- Launcher icon: DONE. Tally marks - four strokes and the closing diagonal - on the
baseline primary purple, as an adaptive vector icon (minSdk 26, so no bitmaps) with a
monochrome layer for Android 13+ themed icons. Artwork inside the 66dp safe circle.
Installed on a Galaxy S23+; seen on the launcher by the user, not by me.
- Tablet layout: two-pane (input left, results/visualization right)
- Landscape orientation: adapted layouts (wider bit grid, side-by-side panels)
- Haptic feedback on bit toggle and button presses