update kiro specs for mobile interface
This commit is contained in:
parent
42c7fe6fbc
commit
e819a8d0bf
3 changed files with 735 additions and 212 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue