tally/.kiro/specs/calculator/design.md

1594 lines
74 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# Design: Tally - Cross-Platform Calculator
## References
- #[[file:.kiro/specs/calculator/requirements.md]]
---
## 1. System Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Frontends │
├─────────────┬──────────────────┬────────────────────────────┤
│ CLI │ TUI │ Android │
│ (Zig) │ (Zig+libvaxis) │ (Kotlin/Compose) │
└──────┬──────┴────────┬─────────┴──────────────┬─────────────┘
│ │ │
│ Zig import │ Zig import │ JNI (C ABI)
│ │ │
┌──────▼───────────────▼────────────────────────▼─────────────┐
│ Engine (Zig Library) │
├─────────────────────────────────────────────────────────────┤
│ parser │ evaluator │ programmer │ struct_layout │
│ │ │ │ │
│ tokens │ standard │ bitwise ops │ abi_profiles │
│ ast │ financial │ base conv │ layout_compute │
└─────────────────────────────────────────────────────────────┘
```
The engine is a pure Zig library with no I/O. Frontends depend on the engine; the engine depends on nothing outside `std`.
### Build Graph
```
build.zig (workspace root)
|- engine/build.zig -> produces: static lib (.a), C header, shared lib (.so/.dylib/.dll)
|- cli/build.zig -> produces: statically-linked binary
|- tui/build.zig -> produces: statically-linked binary (links libvaxis)
|- android targets -> produces: libtally.so for arm64-v8a, x86_64
(consumed by android/ Gradle project for APK packaging)
```
**Build system philosophy:**
- `zig build` is the single entry point for all compilation. No Makefile, no CMake, no shell scripts.
- `zig build` (no args) -> builds engine + CLI + TUI for the host platform.
- `zig build -Dtarget=aarch64-linux-android` -> cross-compiles engine .so for Android arm64.
- `zig build test` -> runs all engine unit tests with coverage reporting.
- The `android/` Gradle project handles only APK packaging and Compose UI compilation - it does not invoke Zig. Prebuilt .so files are committed or produced by a prior `zig build` step.
**Toolchain management (mise):**
- `.mise.toml` at project root declares: Zig version, any other needed tools.
- `mise install` provisions the full dev environment. No other manual setup required.
- CI uses the same `.mise.toml` for reproducibility.
---
## 2. Engine Design
### 2.1 Module Breakdown
| Module | Responsibility |
|--------|---------------|
| `parser.zig` | Tokenizer + Pratt parser -> AST |
| `ast.zig` | AST node definitions |
| `evaluator.zig` | Walk AST, produce results |
| `programmer.zig` | Integer operations, base conversion, bit manipulation |
| `struct_layout.zig` | Struct DSL parser, layout computation, ABI profiles |
| `units.zig` | Unit conversion tables and resolver |
| `financial.zig` | CAGR, TVM, compound interest |
| `types.zig` | Shared types (Value, Error, etc.) |
| `engine.zig` | Public API surface (Zig-native) |
| `c_api.zig` | `extern "C"` wrappers for JNI/FFI consumers |
### 2.2 Core Data Types
```zig
/// Result of any calculation
pub const Value = union(enum) {
integer: Integer,
float: f64,
boolean: bool,
struct_layout: StructLayoutResult,
multi_base: MultiBaseResult, // programmer mode result
};
pub const Integer = struct {
/// Raw bits stored in u64, interpretation depends on context
raw: u64,
bit_width: BitWidth,
signedness: Signedness,
};
pub const BitWidth = enum { bits8, bits16, bits32, bits64 };
pub const Signedness = enum { signed, unsigned };
pub const MultiBaseResult = struct {
value: Integer,
decimal_signed: []const u8, // formatted string
decimal_unsigned: []const u8,
hex: []const u8,
octal: []const u8,
binary: []const u8,
bit_pattern: [64]u1, // individual bits, index 0 = LSB
};
```
### 2.3 AST Nodes
```zig
pub const Expr = union(enum) {
number_literal: NumberLiteral,
unary_op: UnaryOp,
binary_op: BinaryOp,
function_call: FunctionCall,
variable: []const u8,
assignment: Assignment,
};
pub const NumberLiteral = struct {
value: f64,
/// For programmer mode: preserve original base and integer value
int_value: ?u64,
base: Base,
};
pub const Base = enum { decimal, hex, octal, binary };
pub const BinaryOp = struct {
op: Operator,
left: *Expr,
right: *Expr,
};
pub const Operator = enum {
add, sub, mul, div, mod, pow,
bit_and, bit_or, bit_xor, shift_left,
shift_right_logical, shift_right_arithmetic,
rotate_left, rotate_right,
};
```
### 2.4 Parser Design
**Approach: Pratt parser (top-down operator precedence)**
Precedence table (low -> high):
| Level | Operators |
|-------|-----------|
| 1 | `\|` (OR), `or` |
| 2 | `xor` (XOR keyword) |
| 3 | `&` (AND), `and` |
| 4 | `<<`, `>>`, `>>>`, `rol`, `ror` |
| 5 | `+`, `-` |
| 6 | `*`, `/`, `%` |
| 7 | `^`, `**` (exponentiation, always) |
| 8 | Unary `-`, `~` (NOT), `not` |
| 9 | Function calls, parentheses |
**`^` is always exponentiation.** There is no mode-dependent operator
overloading. XOR is a keyword (`xor`), consistent with the existing
`rol`/`ror` keyword operators. Bitwise ops have both symbolic and
keyword forms: `&`/`and`, `|`/`or`, `~`/`not`. XOR is keyword-only
(`xor`) since `^` is reserved for power. Every operator means the same
thing in standard and programmer modes.
**No implicit multiplication**: adjacency (e.g. `2pi`) is an error;
use explicit `*`. Spaces within hex/oct/bin literals are separators
(e.g. `0xFF FF`).
### 2.5 Evaluation
The evaluator maintains an `Environment`:
```zig
pub const Environment = struct {
mode: Mode,
variables: std.StringHashMap(Value),
history: std.ArrayList(HistoryEntry),
programmer_config: ProgrammerConfig,
pub const Mode = enum { standard, programmer, financial };
pub const ProgrammerConfig = struct {
bit_width: BitWidth = .bits64,
signedness: Signedness = .signed,
display_endian: Endianness = .little,
};
};
```
Standard mode evaluates to `f64`. Programmer mode evaluates to `Integer` (exact, truncated to bit width). Financial functions return `f64` with optional step-by-step breakdown.
### 2.6 Number Display Formatting
The engine provides raw values; frontends apply display formatting. The engine includes a formatting module that produces display strings and raw (clipboard-friendly) strings separately.
#### Decimal Formatting
| Context | Display | Clipboard |
|---------|---------|-----------|
| Standard result | `4,294,967,295` | `4294967295` |
| Financial result | `$1,234,567.89` | `1234567.89` |
| Large decimals | `4,294,967,295` (never scientific unless > 15 digits of precision) | `4294967295` |
**Scientific notation threshold**: Only use scientific notation when the number exceeds what can be meaningfully displayed in decimal (> 15 significant digits, or absolute value > 10^15 or < 10^-15). Otherwise, always show full decimal with commas. This is a deliberate departure from calculators that jump to scientific notation at 10+ digits.
#### Programmer Mode Formatting
Values are displayed with visual grouping separators to aid readability, but clipboard copies the raw value without separators.
| Base | Display (value view) | Display (memory view) | Clipboard |
|------|---------------------|----------------------|-----------|
| Hex | `FF FF FF FF` | `FF FF FF FF` | `0xFFFFFFFF` |
| Bin | `1111 1111 1111 1111 1111 1111 1111 1111` | same | `0b11111111111111111111111111111111` |
| Oct | `0o37_777_777_777` | - | `0o37777777777` |
| Dec | `4,294,967,295` | - | `4294967295` |
Rationale for hex grouping:
- **Display uses space per byte** (`FF FF FF FF`): matches hex editors, debuggers, `xxd` output. Consistent with binary's nibble-space grouping. Universally readable.
- These are two different views of the same data: the *value* (what you'd write in source code) vs. the *memory* (how it sits in RAM). For this calculator, both use the same space-separated format.
Binary always groups by nibble (4 bits) with spaces - universally expected by programmers.
#### Formatting Data Structure
```zig
pub const FormattedValue = struct {
/// Human-readable display string (with commas, underscores, spaces)
display: []const u8,
/// Machine/clipboard string (raw, no separators, includes prefix)
raw: []const u8,
};
```
All frontends:
- Show `display` in the UI
- Copy `raw` to clipboard on long-press (Android) or yank (TUI)
---
### 2.7 Numeric Model: Exact and Inexact
#### 2.7.1 The problem
Standard mode currently evaluates everything as `f64`. That produces four
distinct classes of wrong answer, all reproducible today:
```
0.1 + 0.2 -> 0.30000000000000004 binary cannot represent 0.1
1.1 + 2.2 -> 3.3000000000000003 same
9007199254740993 -> 9.007199254740992e15 SILENTLY A DIFFERENT INTEGER
2^53 + 1 -> 9.007199254740992e15 same, past f64's integer limit
12 in in ft -> 0.9999999999999998 conversion factors are decimal
factorial(171) -> error: unknown function f64 overflow, wrong error too
```
The third case is the serious one. The user typed an exact integer and got a
different integer back. Meanwhile programmer mode, one Tab away, is exact to 128
bits. That is an internal inconsistency, not a rounding preference.
#### 2.7.2 The representation
A tagged number with an exact tier and an inexact tier:
```zig
/// Exact rational: p/q with arbitrary-size integers, always reduced, q > 0.
pub const Rational = struct {
num: std.math.big.int.Managed,
den: std.math.big.int.Managed,
};
pub const Number = union(enum) {
/// Exact value. Closed under + - * / and integer powers.
exact: Rational,
/// Inexact value, produced once a computation escapes the rationals.
inexact: f64,
};
```
**Contagion rule:** any operation with an `inexact` operand yields `inexact`.
A value never silently becomes exact again. This is what keeps the guarantee
honest: `exact` means "no rounding has occurred anywhere in this value's
history", not "happens to look clean right now".
#### 2.7.3 Why rational and not decimal128
Both fix `0.1 + 0.2`. They are not otherwise equivalent:
| | decimal128 | arbitrary rational |
|---|---|---|
| Form | +/- 34-digit significand x 10^exp, 128 bits fixed | p/q, arbitrary-size integers |
| `0.1`, `0.0254` | exact | exact (`1/10`, `127/5000`) |
| `1/3` | 34-digit approximation | **exact** |
| `(1/3)*3` | `0.999...9` | **exactly 1** |
| `9007199254740993` | exact | exact |
| `sqrt(2)`, `pi` | approximation | **cannot represent** |
| Rounds | every operation | never |
| Memory | fixed 16 bytes | grows with denominators |
| Trailing zeros (`2.50`) | **preserved** (scale is part of the value) | lost |
Two conclusions:
1. **Every decimal128 value is a rational.** Rationals strictly subsume them.
What decimal128 adds is fixed cost, standard rounding modes, and scale
semantics, not additional expressiveness.
2. **decimal128 does not deliver correctness, it delivers familiarity.**
`1/3 * 3` is still not 1. Since we are prioritizing correctness, rational
wins outright.
Decimal's genuine advantage is scale preservation, which matters for money
(`2.50`, not `2.5`). We recover that in financial mode with an explicit display
scale plus banker's rounding at the commit boundary, rather than a second
numeric type. See section 5.
#### 2.7.4 The exact/inexact boundary
The trigger is **whether the operation escapes the rationals**, evaluated per
call rather than per function. Notably it is *not* the input base: `0xdeadbeef`
is exactly 3735928559, and base is a lexical and display property only.
| Stays exact | Becomes inexact |
|---|---|
| `+ - * /` on exact operands | `sin cos tan asin acos atan` |
| `^` with integer exponent | `^` with non-integer exponent |
| `sqrt` of a perfect square | `sqrt` of a non-square |
| `%`, `abs`, `ceil`, `floor`, `round` | `log ln log2 log10 exp cbrt` |
| `factorial` (unbounded) | constants `pi`, `e`, `tau` |
| integer and decimal literals | any operand already inexact |
`sqrt(4)` is 2 exactly; `sqrt(2)` is not. We deliberately stop short of a CAS:
Qalculate simplifies `sqrt(8)` to `2*sqrt(2)` symbolically, we will not.
#### 2.7.5 Denominator growth and the demotion policy
Rationals grow. Chained division with coprime denominators multiplies them, so a
pathological input can consume unbounded memory. This must be designed in from
the start, not discovered later:
- Cap the denominator at a bit-width budget (starting proposal: 4096 bits).
- On exceeding it, **demote to `inexact`** rather than erroring or growing.
- Demotion is silent in the result value but visible in the tag, so a frontend
can indicate that exactness was lost.
Interactive single calculations will not approach this. The cap exists so the
failure mode is graceful degradation to today's behavior.
#### 2.7.6 Display
Exact values still have to be rendered at finite width, so display precision
remains a separate decision from compute precision:
- An exact value with a **terminating** decimal expansion prints exactly:
`1/10` -> `0.1`, and `12 in in ft` -> `1`.
- An exact value with a **non-terminating** expansion is rendered by long
division to a digit budget with correct rounding, and marked as approximate:
`100 km in mi` is exactly `781250/12573`, displayed as `62.137119224`.
- Because the exact form is retained, we can additionally offer the fraction
(`781250/12573`) as a display option. This is a feature, not a workaround.
This supersedes the unimplemented "exceeds 15 significant digits" clause of
NFR-7, which was never a precision decision and, read literally, would have
rendered `0.9999999999999998` as `9.999999999999998e-1`. NFR-7 needs a real
answer to a better-posed question: how many digits to show for a non-terminating
exact value.
#### 2.7.7 What is unaffected
- **Programmer mode** stays fixed-width `u128`. Wrapping and masking at a chosen
bit width is the entire point of that mode; exact arithmetic would break it.
- **The IEEE 754 float view** stays binary `f32`/`f64`. It exists to show binary
encodings.
- **The formatter's f64 paths** remain, since the inexact tier still needs them.
#### 2.7.8 Fallback precision: why f64 and not f128
The obvious objection to `f64` for the inexact tier is that the usual
justification (hardware instructions) does not apply to a calculator, which
evaluates one expression per human action. That objection is correct, and
performance is *not* the reason. The reason is that Zig 0.16's f128 support is
partial and, worse, silently inconsistent. Probed directly:
| Operation | f128 status |
|---|---|
| `@sqrt` | compiles, accurate to full f128 (34 digits verified) |
| `@sin`, `@cos` | compiles, accurate to full f128 |
| `@log`, `@log10`, `@exp` | compiles, but **only f64-accurate** |
| `std.math.pow` | **does not compile** ("pow not implemented for f128") |
| `std.math.cbrt` | **does not compile** |
| `std.math.atan`, `asin` | compiles |
| `parseFloat` / formatting | works, 112 mantissa bits (~34 decimal digits) |
The middle row is the disqualifying one. `@log(2.0)` as f128 returns
`0.6931471805599452862267639829951804`, which is the *f64* value widened: it
diverges from the true value at the 17th significant digit. So f128 storage
would hand us 34 digits of which roughly 18 are noise, with nothing to
distinguish them. For a calculator that then displays those digits, this is
actively worse than knowingly using f64.
The general principle: **the precision of the inexact tier is bounded by the
accuracy of the underlying transcendental implementations, not by the width of
the storage type.** Widening storage without widening the algorithms buys
nothing but false confidence.
##### Upstream status (checked, not assumed)
The f64-accuracy above is not a misconfiguration or a version quirk. It is
visible in Zig's own source, with its own TODO markers. `lib/compiler_rt/log.zig`:
```zig
pub fn logq(a: f128) callconv(.c) f128 {
// TODO: more correct implementation
return log(@floatCast(a));
}
```
A systematic scan of `lib/compiler_rt/` in Zig 0.16.0 splits the f128 routines
cleanly in two:
- **Downcast to f64, all five marked TODO:** `expq`, `exp2q`, `logq`, `log2q`,
`log10q`
- **Genuine f128 implementations:** `sinq`, `cosq`, `tanq`, `sqrtq`, `fabsq`,
`floorq`, `ceilq`, `roundq`, `truncq`
That is an exact match for the probe results: `@sin`/`@cos`/`@sqrt` accurate,
`@log`/`@log10`/`@exp` only f64-accurate.
Tracker status, as of this writing. Note that Zig has moved to Codeberg and
GitHub is effectively frozen (GitHub last pushed 2025-11-27; Codeberg shows ~742
open issues against GitHub's ~2876, so most GitHub issues were **not** migrated):
| Gap | Tracked? |
|---|---|
| `std.math.pow` f128 | [GH #23602](https://github.com/ziglang/zig/issues/23602) open, labeled "contributor friendly". [PR #23631](https://github.com/ziglang/zig/pull/23631) implemented it but was **closed unmerged** 2026-01-05 ("no CI on GitHub anymore... reopen on Codeberg"). Nobody has. No Codeberg issue exists. |
| `std.math.cbrt` f128 | **No issue on either tracker.** Unreported. `cbrt.zig` is ported from musl, which only ships `cbrtf`/`cbrt`. |
| `logq`/`expq` etc. f64 accuracy | **No issue on either tracker.** Only the in-source TODOs. |
The nearest historical artifacts are [GH #4026](https://github.com/ziglang/zig/issues/4026)
("implement all the math functions for all the floating point types", closed
2022-12-28 under milestone 0.11.0 with its f128 column still unchecked) and
[GH #15711](https://github.com/ziglang/zig/issues/15711) (a reference table
created and closed in the same second, which lists the f128 symbols as
*existing* - existence, not accuracy).
Adjacent open work suggesting f128 remains a soft spot generally:
[GH #23865](https://github.com/ziglang/zig/pull/23865) "Better @sqrt for f32, f64
and f128", [GH #23173](https://github.com/ziglang/zig/issues/23173) "introduce new
float types", and Codeberg #36093 "compiler_rt: min(f128) / max(f128) causes
integer overflow".
**Conclusion: "wait for Zig to fix f128" is not a plan.** Two of the three gaps
are not even reported, and the one with a working patch lost it to a tracker
migration. Obtaining true f128 accuracy would mean writing or porting the
routines ourselves, which is the same category and scale of work as going
arbitrary-precision - so it should be justified on its own terms, not smuggled in
as "just use a wider type".
(Optional, off the critical path: reviving the `pow` PR on Codeberg is a
well-scoped upstream contribution, and the issue is explicitly labeled
contributor friendly.)
So `f64` is chosen because ~15-16 digits is the precision we can actually
justify, and we display no more than that. The upgrade path is therefore *not*
"switch the storage type to f128"; it is "implement or bind correctly-rounded
arbitrary-precision transcendentals" (the problem MPFR exists to solve), which is
a separate project with its own justification.
Incidentally, the probe also confirms that width alone never fixes the decimal
problem: in f128, `0.1 + 0.2` is
`0.30000000000000000000000000000000004`. Only exactness fixes it.
#### 2.7.9 Implementation sequencing
The change must not land as one diff mixing structural churn (signature changes
rippling through hundreds of test assertions) with behavioral change (results
becoming correct). Reviewing that combination is impractical: the interesting
lines are buried in mechanical ones.
Two orderings were considered.
**Rejected: structure first.** Introduce `Number`, rewrite every test and the
display path to consume it, review, then change behavior. This fails because the
structural rewrite is not behavior-neutral: rewriting a test to consume `Number`
requires asserting whether each result is `exact` or `inexact`, and that
classification *is* the behavioral design. Every such assertion would be written
against the old f64 behavior and then flipped in the later step. Double churn,
and the intermediate commit is a large diff that improves nothing.
**Chosen: behavior first, behind the existing signature.**
1. **Add `rational.zig` and `number.zig`, wired to nothing.** Pure addition with
their own tests. Reviewable in isolation, zero risk to existing behavior.
2. **Switch the evaluator's internals to the exact tier, keeping `evalString`
returning `f64`.** All existing tests must still pass. Review and commit.
3. **Change `evalString` and the display path to return and consume `Number`**,
with the resulting mechanical churn in test signatures. Review and commit.
4. **Add the exactness tests** that only the `Number` API can express.
What makes step 2 worthwhile rather than invisible is that converting an exact
value to `f64` *once, at the boundary* already fixes most of the payoff. Today's
errors are accumulated: `0.1` and `0.2` are each rounded before being added. An
exact `3/10` rounded a single time to the nearest f64 prints as `0.3`.
Bug-by-bug, where the fix lands:
| Bug | Fixed by step 2 (f64 boundary) | Needs step 3 (`Number`) |
|---|---|---|
| `0.1 + 0.2` -> `0.3` | yes | - |
| `1.1 + 2.2` -> `3.3` | yes | - |
| `0.1 * 3` -> `0.3` | yes | - |
| `12 in in ft` -> `1` | yes | - |
| `factorial(171)` | partly (becomes `inf`, not an error) | yes, for the exact value |
| `9007199254740993` | **no** (f64 cannot hold it) | yes |
| `2^53 + 1` | **no** | yes |
| `1/3` retained exactly | no (only observable as exact) | yes |
So step 2 is independently verifiable through the current API, and the
large-integer cases become the motivating tests for step 3 rather than
speculative ones.
#### 2.7.10 Migration and test impact
The dominant risk is **compile-time** breakage, not behavioral. An audit of the
402-test suite found **no test that asserts the broken behavior** - nothing
pins `0.30000000000000004`, the 2^53 cliff, or the `factorial` limit. So the
target is "no test changes its expected value", not "no test file is touched".
Unaffected by construction: `programmer.zig` (47), `float_interp.zig` (21),
`formatter.zig` (40), `types.zig` and `tui.zig` (16).
Kept passing by making the change additive:
- `tokenizer.zig` (39): `NumberValue` **gains** an exact field, does not replace
`float`/`int_value`.
- `parser.zig` (32, 10 of which read `number.float_value`): the AST number node
likewise gains a field.
- `evaluator.zig` (62, 40 asserting `@as(f64, ...)`): keep `evalString`
returning `f64` as a wrapper over the exact core. Every one of those
assertions involves small values where exact-to-f64 is bit-identical.
- `units.zig` (106, 95 routed through the `expectConvert` helper): change the
helper once, leave the call sites alone.
Three existing items must change because we are invalidating their premise:
1. `tokenizer.zig` test `"parseNumber huge decimal falls back to float"` -
`99999999999999999999999999` becomes exactly representable, so the test still
passes but its intent is now false. Rewrite it.
2. `formatter.zig` `is_integer`, gated on `< 2^53` - that guard is the display
half of the `9007199254740993` bug.
3. `evaluator.zig` `factorial`'s `x > 170` rejection, which is also the source of
the misleading `unknown function` error.
**Compatibility wrappers are scaffolding with a deadline, not an end state.** If
the CLI and TUI keep calling the `f64` API, the exactness never reaches the user
and the work is invisible. The display path must move to `Number` (step 3 of
2.7.9), and that is where `main.zig`'s ~20 output assertions get reviewed.
Literal zero test churn across the whole sequence would be evidence we had not
delivered anything.
Windows Calculator draws the same exact/inexact line we are drawing: its
arbitrary-precision rational engine covers basic arithmetic only, not
transcendentals.
New tests required: rational arithmetic and reduction, exact/inexact contagion,
the demotion policy at the denominator cap, rational-to-decimal rendering and
rounding, and a regression test per bug in 2.7.1 - including one asserting
`9007199254740993` round-trips, which nothing currently guards.
---
## 3. Struct Layout Engine
### 3.1 Mini-DSL Grammar
```
struct_def := "struct" "{" field_list "}"
field_list := (field ";")*
field := type_name identifier
type_name := "u8" | "u16" | "u32" | "u64"
| "i8" | "i16" | "i32" | "i64"
| "f32" | "f64" | "bool" | "ptr"
| "[" number "]" type_name // arrays
identifier := [a-zA-Z_][a-zA-Z0-9_]*
```
### 3.2 ABI Profile Interface
```zig
pub const AbiProfile = struct {
name: []const u8,
pointer_size: u8, // bytes
max_alignment: u8, // maximum alignment enforced
/// Given a field type, return its (size, alignment) in bytes
field_layout: *const fn (field_type: FieldType) -> struct { size: u8, alignment: u8 },
/// Struct-level alignment (usually max of all field alignments)
struct_alignment: *const fn (fields: []const FieldInfo) -> u8,
/// Whether tail padding is added to round up to struct alignment
tail_padding: bool,
};
// Built-in profiles
pub const x86_64_sysv = AbiProfile{ ... }; // default
pub const x86_64_win64 = AbiProfile{ ... }; // future
pub const packed = AbiProfile{ ... }; // no padding
```
### 3.3 Layout Computation Algorithm
```
function compute_layout(fields, abi):
offset = 0
struct_align = 1
result_fields = []
for each field in fields:
(size, align) = abi.field_layout(field.type)
struct_align = max(struct_align, align)
# Insert padding for alignment
padding = (align - (offset % align)) % align
if padding > 0:
emit_padding(offset, padding)
offset += padding
# Place field
emit_field(field.name, field.type, offset, size)
offset += size
# Tail padding
if abi.tail_padding:
tail = (struct_align - (offset % struct_align)) % struct_align
if tail > 0:
emit_padding(offset, tail)
offset += tail
return StructLayout {
total_size: offset,
alignment: struct_align,
fields: result_fields,
}
```
### 3.4 Layout Result Structure
```zig
pub const StructLayoutResult = struct {
total_size: usize,
alignment: u8,
fields: []const LayoutField,
bytes: []const ByteInfo, // byte-by-byte map for visualization
abi_name: []const u8,
};
pub const LayoutField = struct {
name: []const u8,
type_name: []const u8,
offset: usize,
size: usize,
alignment: u8,
padding_before: usize,
};
pub const ByteInfo = union(enum) {
field: struct { field_index: usize, byte_within_field: usize },
padding: struct { before_field_index: usize },
tail_padding: void,
};
```
---
## 4. Unit Conversion Engine
### 4.1 Architecture
Units are organized by category. Each category has a canonical base unit. All conversions go through the base unit (from -> base -> to), except temperature which uses formula-based conversion.
```zig
pub const UnitCategory = enum {
length, mass, temperature, time, digital_storage,
speed, area, volume, energy, pressure, data_rate, angle,
};
pub const UnitDef = struct {
name: []const u8, // canonical name (e.g., "km")
aliases: []const []const u8, // alternatives (e.g., "kilometer", "kilometers")
category: UnitCategory,
/// Conversion to base unit: base = value * to_base_factor + to_base_offset
/// For most units offset = 0. Temperature is the only category that uses it.
to_base_factor: f64,
to_base_offset: f64 = 0,
pub fn toBase(self: UnitDef, value: f64) f64 {
return value * self.to_base_factor + self.to_base_offset;
}
/// The reverse direction is DERIVED, not stored.
pub fn fromBase(self: UnitDef, base: f64) f64 {
return (base - self.to_base_offset) / self.to_base_factor;
}
};
```
**Only the to-base mapping is stored; the reverse is derived.** An earlier draft
of this design gave each unit four fields (`to_base_factor`/`to_base_offset`
plus `from_base_factor`/`from_base_offset`). That was changed because it lets a
table entry describe a conversion that disagrees with its own inverse: nothing
stops a typo in `from_base_factor` from making `km -> m -> km` lossy. Deriving
`fromBase` by inverting `toBase` gives one source of truth per unit, halves the
numbers a contributor has to get right, and makes round-trip correctness
structural rather than something tests have to police. A unit test still
round-trips every unit and every within-category unit pair as a guard against
bad factors.
### 4.2 Conversion Resolution
Unit names resolve through `findUnit(name)`, which searches every category's
table by canonical name and alias:
1. An **exact, case-sensitive** match is tried first. This is what keeps
case-distinguished units meaningful: `K` is Kelvin (not a "kilo" something),
`B` is byte, and `kB` (1000) never silently becomes `KiB` (1024).
2. Only if nothing matches exactly does a **case-insensitive** pass run, which
is what makes forgiving input like `KM` or `Celsius` work.
Conversion goes through the category base unit: `to.fromBase(from.toBase(value))`.
Both units must be in the same category, or the result is `IncompatibleUnits`.
An unrecognized name is `UnknownUnit`.
A single multiplicative factor is reported alongside the result only when both
units are linear (zero offset). Temperature conversions are affine, so no single
factor describes them and the factor is reported as null.
**Free-form conversion requests** (`units.parseRequest`) let conversion be plain
expression input rather than a special subcommand, so `tally 32F in C` and typing
`100 km to mi` in the TUI both work:
- A standalone, whitespace-delimited, case-insensitive `to` or `in` is the
trigger. This needs no unit-aware tokenizer, because neither word is otherwise
an identifier in the grammar.
- No separator means `parseRequest` returns null and the caller falls through
to ordinary expression evaluation, so existing behavior is untouched.
- **`in` is both a separator and the name for inches.** Candidate separators are
therefore tried last-to-first, keeping the first reading that yields a valid
conversion. In `5 in in cm` the later `in` is the separator; in `100 mm in in`
the earlier one is, because only that reading resolves. `1 in in in` is a valid
identity. `min` is untouched because a separator must be whitespace-delimited.
- Backtracking also improves errors: if a candidate reading fails, the reason is
remembered, so a committed-but-invalid request reports
`UnknownUnit`/`IncompatibleUnits` instead of a confusing parse error.
- The trailing unit is split off by trying the **longest** suffix first, so
`100 km` yields `("100", km)` and not `("100 k", m)`, and no space is required
(`32F`). A candidate must start at a **token boundary** (the preceding
character may not be a letter), which is what stops the trailing `s` of
`100 smoots` from being read as seconds.
- The value portion is returned **unevaluated** as text, so `parseRequest` needs
no dependency on the evaluator and callers can evaluate it in whatever
environment they already have. This is why `2*3 kg to lb` works.
NOT YET IMPLEMENTED: unit-qualified arithmetic (`5 kg + 3 lb`), which genuinely
does need unit-aware tokenizing and evaluation. The planned approach:
For expressions like `5 kg + 3 lb`, the parser would:
1. Identify `5 kg` as a unit-qualified value
2. See `+ 3 lb` and convert `3 lb` to `kg` (left-hand unit wins)
3. Evaluate as `5 + 1.36078 = 6.36078 kg`
### 4.3 Adding New Units
New units require only a table entry - no parser changes:
```zig
// In units.zig
const length_units = [_]UnitDef{
.{ .name = "m", .aliases = &.{"meter", "meters"}, .category = .length, .to_base_factor = 1.0 },
.{ .name = "km", .aliases = &.{"kilometer", "kilometers"}, .category = .length, .to_base_factor = 1000.0 },
.{ .name = "mi", .aliases = &.{"mile", "miles"}, .category = .length, .to_base_factor = 1609.344 },
// ... add new entries here
};
```
Invariants enforced by tests rather than convention: every category's base unit
must have `to_base_factor == 1` and no offset, no unit may have a zero factor,
canonical names must be unique across all categories, every unit must be listed
in the table matching its own `category` field, and every unit and unit pair must
round-trip. A bad table entry fails the suite instead of silently producing wrong
answers.
---
## 5. Financial Module
### 5.1 CAGR
```
CAGR = (end_value / start_value) ^ (1 / periods) - 1
```
### 5.2 TVM (Time Value of Money)
The five TVM variables: N (periods), I/Y (interest rate per period), PV (present value), PMT (payment), FV (future value).
Relationship:
```
PV * (1 + r)^N + PMT * [((1 + r)^N - 1) / r] + FV = 0
```
where `r = I/Y / 100`.
Solving for the unknown variable:
- **FV, PV, PMT**: algebraic (closed-form)
- **N**: logarithmic solution
- **I/Y**: Newton-Raphson iterative solver (no closed form)
---
## 6. C API (for Android/FFI)
```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 CalcString = extern struct {
ptr: [*]const u8,
len: usize,
};
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,
};
/// 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;
/// Compute struct layout, return JSON result.
export fn tally_struct_layout(
def_ptr: [*]const u8,
def_len: usize,
abi_ptr: [*]const u8, // "sysv", "win64", "packed"
abi_len: usize,
endian: c_int, // 0=little, 1=big
) 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;
```
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.
---
## 7. CLI Design
```
tally [OPTIONS] <expression>
tally struct [OPTIONS] <definition | --file path>
tally convert <value> <from_unit> <to_unit>
tally cagr <start_value> <end_value> <periods>
tally tvm [--n N] [--rate R] [--pv PV] [--pmt PMT] [--fv FV]
Options:
-p, --programmer Programmer mode
-b, --bits <WIDTH> Bit width: 8, 16, 32, 64 (default: 64)
--base <BASE> Output base: dec, hex, oct, bin, all (default: all)
--json JSON output
--abi <ABI> Struct ABI: sysv (default), packed
--endian <E> Endianness: little (default), big
-h, --help Show help
--version Show version
```
### CLI Output Examples
Standard:
```
$ tally "2^32 - 1"
4,294,967,295
```
Programmer:
```
$ tally -p "0xFF & 0x0F"
dec(signed): 15
dec(unsigned): 15
hex: 0x0000_0000_0000_000F
oct: 0o17
bin: 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 1111
bits: [64]
```
Struct:
```
$ tally struct "struct { u32 x; u8 flags; u16 id; u64 ts; }"
ABI: x86-64 System V | Endian: little
Offset Size Field Type Align Padding
────── ──── ────────── ───── ───── ───────
0 4 x u32 4
4 1 flags u8 1
5 1 (padding) 1 byte
6 2 id u16 2
8 8 ts u64 8
Total: 16 bytes (alignment: 8, padding: 1 byte, tail: 0)
Memory map:
00: [xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx] x
04: [ffff ffff] [········] [iiii iiii iiii iiii] flags, pad, id
08: [tttt tttt tttt tttt tttt tttt tttt tttt ts
0C: tttt tttt tttt tttt tttt tttt tttt tttt]
```
Unit conversion:
```
$ tally convert 100 km miles
62.1371 miles
$ tally "72 F to C"
22.2222 °C
$ tally "5 kg + 3 lb"
6.36078 kg
```
---
## 8. TUI Layout
### 8.0 Mouse Input Architecture
The TUI is mouse-driven as well as keyboard-driven (FR-7.11). Rather than each
view doing its own coordinate arithmetic against hardcoded layout constants, hit
targets are registered as a side effect of drawing:
```zig
pub const Action = union(enum) {
mode: Mode, // tab bar
prog_field: struct { field: ProgField, bit: ?u7 }, // focus field, optional cursor
toggle_bit: u7, // flip one bit
focus_input, close_help, toggle_float,
cycle_width, toggle_endian, toggle_signedness, toggle_float_format,
conv_category: UnitCategory, conv_from: usize, conv_to: usize, conv_swap,
};
pub const RegionSet = struct {
items: [512]HitRegion, // { row, col, len, action }
count: usize,
pub fn clear(self: *RegionSet) void;
pub fn add(self: *RegionSet, row: u16, col: u16, len: u16, action: Action) void;
pub fn at(self: *const RegionSet, row: u16, col: u16) ?Action;
};
```
Key properties:
- **Regions are rebuilt every frame.** `clearRegions()` runs at the top of the
draw function, and each view calls `addRegion` right where it draws the thing.
Hit targets therefore cannot drift out of sync with what is on screen, which is
the usual failure mode for hand-maintained click maps.
- **Newest region wins.** `at()` searches backwards, so a view can register a
broad row-wide fallback first (click anywhere on the HEX row to focus it) and
then finer targets on top (click a specific nibble to put the cursor there).
- **Draw-time registration means one source of truth.** The same loop that
decides where a bit character goes also registers that cell, so the mapping
from cell to bit index is written once. `registerDigitRegions` mirrors
`drawFieldWithCursor`'s digit walk for the same reason.
- **Left press only.** Release, motion, and drag are ignored so one physical
click produces exactly one action.
- **A fixed-size buffer, not an allocation.** Drawing happens on every keystroke
and must not fail; an exhausted budget silently drops extra regions (a click
does nothing) instead of erroring. 512 comfortably covers the worst case, the
128-bit programmer view.
vxfw already puts the terminal in mouse mode and hit-tests surfaces, delivering
`.mouse` events to the root widget with local coordinates equal to screen
coordinates, so no terminal-level setup is needed here.
Click semantics are chosen per field to match what the data means: a bit grid
cell and a BIN digit each represent exactly one bit, so clicking flips it, while
a HEX nibble or OCT digit holds several bits with no single sensible "toggle", so
clicking places the cursor for typing instead.
### 8.1 Standard Mode
```
┌─ Tally ─────────────────────── [Standard] [Programmer] [Financial] ─┐
│ │
│ History: │
│ 2^10 = 1024 │
│ sqrt(144) = 12 │
│ Ans * 2 = 24 │
│ │
│ ───────────────────────────────────────────────────────────────── │
│ > 3 * pi + 1_ │
│ │
│ = 10.42477796... │
│ │
└───────────────────────────── [d]ec [h]ex [o]ct [b]in ── q:quit ── ?:help┘
```
### 8.2 Programmer Mode
```
┌─ Tally ─────────────────────── [Standard] [Programmer] [Financial] [Convert] ─┐
│ Bit Width: [64] Signed: [yes] Endian: [LE] │
│ │
│ ┌─ Bit Pattern (63 -> 0) ─────────────────────────────────────────────────────┐ │
│ │ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 │ │
│ │ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 │ │
│ └─────────────────────────────────────────────────────────────────────────────┘ │
│ cursor: bit 3 ▲ │
│ │
│ Signed: 255 │
│ Unsigned: 255 │
│ Hex: 0x0000_0000_0000_00FF │
│ Oct: 0o377 │
│ Bin: 0000 0000 0000 0000 0000 0000 1111 1111 │
│ │
│ > 0xFF & 0x0F_ │
│ = 15 │
│ │
└──── arrows:nav Space:toggle w:width s:sign e:endian y:yank -- Tab:mode ----┘
```
**NOTE: The wireframe above (8.2) is superseded by the editable-fields
design below. Keeping the old wireframe for reference during transition.**
#### 8.2.1 Programmer Mode - Editable Fields (REVISED DESIGN)
The programmer mode is an **editable multi-field form** where every
representation of the value is live-editable. A change in any field
instantly updates all others. Closer to integer.exposed than a
traditional calculator with a prompt.
Fields (navigable with Up/Down):
1. Bit grid - arrow keys move, Space toggles individual bits
2. DEC(s) - editable signed decimal
3. DEC(u) - editable unsigned decimal
4. HEX - editable hex (space-separated bytes: FF 00 AB CD)
5. ASCII - read-only (derived) view of the bytes, one glyph per byte,
MSB first, aligned column-for-column under the HEX row directly above
it. Printable bytes (0x20-0x7E) show as themselves; every other byte
shows as `.`, matching the `xxd` hex-dump convention (e.g. `.ELF`,
`...ascii`). Not part of the Up/Down focus cycle since it has nothing
to edit.
6. OCT - editable octal
7. BIN - editable binary (nibble-separated: 1111 0000)
8. Expression - full expression evaluator (& | ^ ~ << >> etc.)
Navigation:
- Up/Down moves focus between fields
- When a text field is focused, typing replaces its content;
value updates on Enter (or live as you type - TBD)
- When bit grid is focused: left/right/up/down navigate, Space toggles
- Tab cycles between Standard/Programmer mode
- Ctrl-W cycles bit width (8/16/32/64/128)
- Ctrl-E toggles endianness display (LE/BE)
Bit width is a non-destructive display lens:
- The stored value keeps full precision (u128). Cycling the width never
truncates it, so narrowing to a smaller width and then widening again
restores the hidden upper bits.
- The DEC/HEX/OCT/BIN/ASCII rows and the bit grid show the value masked to
the current width.
- While the stored value has bits set beyond the current width, a warning
line is shown ("value exceeds N bits - showing low bits (widen to
restore)"). It is a warning, not an error: the value is still usable.
- Explicit value edits (typing a decimal digit, toggling a bit, editing a
hex/oct/bin digit) commit to the current width, clearing any hidden upper
bits. Only width cycling is non-destructive; deliberately editing at a
narrow width means you are now working at that width.
Endianness:
- Toggle with Ctrl-E; header shows current setting (`Endian: LE` / `Endian: BE`)
- Default: big-endian. The HEX row's primary job is to show the value in hex,
so by default it reads MSB-first and matches DEC/OCT/BIN. Little-endian is
the opt-in "how it sits in x86-64 memory" view.
- Affects: HEX byte display order and ASCII byte order only. In LE the display
bytes are reversed (e.g. 0xDEADBEEF -> `EF BE AD DE`).
- Does NOT affect: the underlying value, DEC, OCT, or BIN display, nor the
clipboard `raw` form of HEX (which stays the canonical MSB-first number).
- Rationale for BE default (revised from an earlier LE default): defaulting to
LE would reverse the HEX row so it no longer matches the number shown in the
DEC/BIN rows directly beside it, which is surprising in a calculator context.
Text/ASCII literals:
- Single-quoted strings in expressions: `'ELF'`, `'RIFF'`, `'a'`
- Each character becomes one byte packed into the integer
- Packing order follows endianness setting:
- BE: first char -> most significant used byte (`'AB'` = 0x4142)
- LE: first char -> least significant byte (`'AB'` = 0x4241)
- Max chars = bit_width / 8 (1 for 8-bit, 2 for 16, 4 for 32, 8 for 64)
- Non-ASCII characters are an error
- Useful for: magic numbers (`'ELF'`), protocol tags, inspecting
how string bytes pack into integer registers
IEEE 754 float interpretation:
- A sub-view within programmer mode (toggle with a key, e.g. Ctrl-F)
- Reinterprets the current bit pattern as an IEEE 754 float
- Supported formats:
- float32 (1 sign + 8 exponent + 23 significand, bias 127)
- float64 (1 sign + 11 exponent + 52 significand, bias 1023)
- Bit grid color-codes the three fields:
- Sign bit: one color (e.g. pink)
- Exponent: another color (e.g. cyan)
- Significand: another color (e.g. green)
- Display below the bit grid shows:
- Decimal value (exact)
- Value and ULP use scientific notation for very small/large
magnitudes (e.g. a subnormal ULP shows as `1.4e-45`), fixed-point
otherwise, so each row stays on one line
- Formula: (-1)^s * 2^(e-bias) * 1.mantissa
- Classification: normal, denormal, zero, infinity, NaN
- ULP: delta to next representable value
- Raw fields: sign=0, exp=127 (unbiased: 0), frac=0x000000
- Input: can type a decimal float value (e.g. `3.14`) and it
shows the closest representable bit pattern, noting if rounding
occurred
- Auto-entry: switching into programmer mode (Tab) with a non-integer
value in `ans` opens this float view automatically on the exact f64
bit pattern, since the integer bit views cannot represent a fraction.
An integer `ans` uses the integer views as usual. Infinity/NaN also
open the float view (the only place they are representable).
- Toggling bits in the grid updates the float interpretation live
- Does NOT change bit_width setting - float32 uses 32-bit view,
float64 uses 64-bit view (auto-switches width when toggling
float format)
### 8.3 Struct Visualizer (sub-view of Programmer Mode)
```
┌─ Tally ────────────────── [Programmer > Struct Layout] ─────────────┐
│ ABI: x86-64 SysV Endian: LE │
│ │
│ Definition: │
│ struct { u32 x; u8 flags; u16 id; u64 ts; } │
│ │
│ ┌─ Memory Map ─────────────────────────────────────────────────────┐ │
│ │ 00 01 02 03 │ 04 │ 05 │ 06 07 │ 08 09 0A 0B 0C 0D 0E 0F │ │ │
│ │ ████████████ │ ▓▓ │ ░░ │ ▒▒▒▒▒ │ ░░░░░░░░░░░░░░░░░░░░░░░ │ │ │
│ │ x │flag│pad │ id │ ts │ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
│ Field Type Offset Size Align Pad Before │
│ x u32 0 4 4 0 │
│ flags u8 4 1 1 0 │
│ id u16 6 2 2 1 │
│ ts u64 8 8 8 0 │
│ │
│ Total: 16 bytes | Alignment: 8 | Padding: 1 byte (6.25%) │
│ Packed would be: 15 bytes │
└──── a:abi e:endian Enter:edit struct Esc:back ─────────────────────┘
```
### 8.4 Convert Mode
```
Tally [Standard] [Programmer] [Convert]
Category:
Length Mass Temperature Time Digital Storage Speed Area
Volume Energy Pressure Data Rate Angle
100 km
= 62.13711922373339 mi
1 km = 0.621371192237334 mi
From To
> nm nm
um um
mm mm
... > mi
------------------------------------------------------------------
> 100
Arrows:select | Ctrl-S:swap | `:input | Tab:mode | ?:help
```
Structure:
- **Category chips** wrap to as many lines as the terminal width needs. The
selected chip is highlighted; when the selection zone has focus on the category
column it is highlighted differently again.
- **The value** comes from the shared input line at the bottom, keeping input
consistent with the other modes. A bare number is used directly; anything else
is evaluated as a standard expression first, so `2*3.5` or `sqrt(2)` work as
input values.
- **The factor line** shows the single multiplicative factor between the two
units. Temperature is affine, so instead of a misleading number it says so.
- **Two unit columns** (From and To) list every unit in the category with the
selection marked. Both columns are clickable; the whole column slot is the
click target, not just the name text, so a one-character unit like `m` is not a
one-character target.
- If the terminal is too short to show every unit, a `... N more (resize to see
all)` line is drawn rather than silently truncating the list.
Interaction:
- Backtick toggles between the input zone and the selection zone, matching the
convention already used in programmer mode.
- In the selection zone, Left/Right move between the category, From, and To
columns; Up/Down move the selection within the focused column, wrapping.
- Ctrl-S swaps the two units, and works from either zone.
- Selecting a new category resets the unit pair to that category's defaults (its
base unit plus a distinct second unit), since the previous indices would refer
to unrelated units in the new table.
---
## 9. Android UI Design
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**.
```
┌─────────────────────────────────────────┐
│ ┌─────────────────────────────────┐ │
│ │ │ │
│ │ Mode Content Area │ │
│ │ │ │
│ └─────────────────────────────────┘ │
│ │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ 🔢 Standard 💻 Programmer 💰 Finance 🔄 Convert │
└─────────────────────────────────────────┘
```
### 9.2 Standard Calculator Screen
Classic calculator layout. Expression builds at top, result previews live below it, button grid fills the bottom half.
```
┌─────────────────────────────────────────┐
│ 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 │ + │ = │ │ │
│ └─────┴─────┴─────┴─────┴─────┘ │ │
└─────────────────────────────────────────┘
```
**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
### 9.3 Programmer Mode Screen
Split into three vertical zones: **value display**, **bit grid**, and **input buttons**.
```
┌─────────────────────────────────────────┐
│ ┌─── Bit Width ────────────────────┐ │
│ │ [ 8 ] [ 16 ] [ 32 ] [●64] │ │ ← segmented control
│ └──────────────────────────────────┘ │
│ │
│ ┌─── Value Display ────────────────┐ │
│ │ DEC 4,294,967,295 │ │ ← tappable to switch primary
│ │ HEX 0xFFFF_FFFF │ │
│ │ OCT 0o37_777_777_777 │ │
│ │ BIN 1111 1111 1111 1111 11.. │ │
│ │ SIGN -1 (signed) │ │
│ └──────────────────────────────────┘ │
│ │
│ ┌─── Bit Grid ─────────────────────┐ │
│ │ 31 30 29 28 │ 27 26 25 24 │ │ ← bit index labels
│ │ 1 1 1 1 │ 1 1 1 1 │ │ ← tappable cells
│ │ 23 22 21 20 │ 19 18 17 16 │ │
│ │ 1 1 1 1 │ 1 1 1 1 │ │
│ │ 15 14 13 12 │ 11 10 9 8 │ │
│ │ 1 1 1 1 │ 1 1 1 1 │ │
│ │ 7 6 5 4 │ 3 2 1 0 │ │
│ │ 1 1 1 1 │ 1 1 1 1 │ │
│ └──────────────────────────────────┘ │
│ │
│ ┌─── Operators ────────────────────┐ │
│ │ AND│ OR │XOR │NOT │ << │ >> │ROL │ │ ← bitwise op buttons
│ ├────┼────┼────┼────┼────┼────┼────┤ │
│ │ A │ B │ C │ D │ E │ F │ │ │ ← hex input
│ ├────┼────┼────┼────┼────┼────┤ │ │
│ │ 7 │ 8 │ 9 │ 0x│ 0b│CLR │ │ │ ← digits + base prefix
│ ├────┼────┼────┼────┼────┼────┤ │ │
│ │ 4 │ 5 │ 6 │ + │ - │ = │ │ │
│ ├────┼────┼────┼────┼────┼────┤ │ │
│ │ 1 │ 2 │ 3 │ × │ ÷ │ ⌫ │ │ │
│ └────┴────┴────┴────┴────┴────┘ │ │
└─────────────────────────────────────────┘
```
**Key interactions:**
- Tap any bit in the grid to toggle it - all displays update instantly
- Haptic pulse on bit toggle
- Tap DEC/HEX/OCT/BIN label to make that the primary input base
- Bit grid scrolls horizontally for 64-bit (or collapses to 4 rows of 16)
- FAB or toolbar button for "Struct Layout" sub-screen
### 9.4 Struct Layout Screen (Programmer sub-screen)
Accessed via a button within Programmer mode. Uses a **form-based struct builder** rather than raw text DSL (though raw text input is available via a toggle).
```
┌─────────────────────────────────────────┐
│ ← Back to Programmer │
│ │
│ ┌─── ABI / Settings ──────────────┐ │
│ │ ABI: [x86-64 SysV ▾] Endian: [LE ▾]│
│ └──────────────────────────────────┘ │
│ │
│ ┌─── Fields ───────────────────────┐ │
│ │ ┌────────┬──────────┬─────────┐ │ │
│ │ │ Type ▾ │ Name │ ⊖ │ │ │ ← each row is a field
│ │ ├────────┼──────────┼─────────┤ │ │
│ │ │ u32 │ x │ ⊖ │ │ │
│ │ │ u8 │ flags │ ⊖ │ │ │
│ │ │ u16 │ id │ ⊖ │ │ │
│ │ │ u64 │ ts │ ⊖ │ │ │
│ │ └────────┴──────────┴─────────┘ │ │
│ │ [ + Add Field ] │ │
│ │ [ ✏️ Raw DSL input toggle ] │ │
│ └──────────────────────────────────┘ │
│ │
│ ┌─── Memory Map ───────────────────┐ │
│ │ ██████████ ▓▓ ░░ ▒▒▒▒ ░░░░░░░░░ │ │ ← color-coded byte bar
│ │ x(4) fl p id ts(8) │ │ ← labels below
│ └──────────────────────────────────┘ │
│ │
│ ┌─── Summary ──────────────────────┐ │
│ │ Total: 16 bytes Align: 8 │ │
│ │ Padding: 1 byte (6.25%) │ │
│ │ Packed: 15 bytes │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
**Key interactions:**
- Type dropdown for each field (Material exposed dropdown menu)
- Swipe-to-delete or tap ⊖ to remove a field
- Drag handles to reorder fields (see padding change in real-time)
- Memory map is a custom Canvas composable with tappable segments (tap a segment -> highlights field info)
- Toggle for raw DSL text input (for copy-paste from code)
- Share button: export struct layout as text or image
### 9.5 Financial Mode Screen
Card-based interface with function selector at top.
```
┌─────────────────────────────────────────┐
│ ┌─── Function ────────────────────┐ │
│ │ [●CAGR] [TVM] [Compound] [PV] │ │ ← scrollable chip row
│ └─────────────────────────────────┘ │
│ │
│ ┌─── CAGR Inputs ────────────────┐ │
│ │ │ │
│ │ Start Value │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ 10,000 │ │ │ ← text field
│ │ └─────────────────────────┘ │ │
│ │ │ │
│ │ End Value │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ 25,000 │ │ │
│ │ └─────────────────────────┘ │ │
│ │ │ │
│ │ Periods (years) │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ 5 │ │ │
│ │ └─────────────────────────┘ │ │
│ │ │ │
│ │ [ Calculate ] │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─── Result ─────────────────────┐ │
│ │ CAGR = 20.11% │ │
│ │ │ │
│ │ Formula: │ │
│ │ (25000/10000)^(1/5) - 1 │ │
│ │ = 2.5^0.2 - 1 │ │
│ │ = 0.2011... │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
**Key interactions:**
- Chip row selects function type, input form adapts
- For TVM: 5 input fields, user leaves one blank -> that's the solve target
- Compute triggers engine call, result animates in
- Result card shows formula breakdown (expandable)
- Long-press result to copy
### 9.6 Convert Mode Screen
Designed for fast, one-handed unit conversion.
```
┌─────────────────────────────────────────┐
│ ┌─── 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 │ . │ ± │ │ │
│ └─────┴─────┴─────┴─────────────┘ │
└─────────────────────────────────────────┘
```
**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
### 9.7 Tablet Layout (landscape / large screen)
On tablets, modes use a two-pane layout:
```
┌─────────────────────┬──────────────────────────────┐
│ │ │
│ Input / Controls │ Results / Visualization │
│ │ │
│ (button grid, │ (history, bit grid, │
│ form fields, │ memory map, formula │
│ struct editor) │ breakdown) │
│ │ │
└─────────────────────┴──────────────────────────────┘
│ 🔢 Standard 💻 Programmer 💰 Finance 🔄 Convert │
└─────────────────────────────────────────────────────┘
```
### 9.8 Android Architecture (Technical)
```
┌──────────────────────────────────────┐
│ Compose UI Layer │
│ ├── StandardScreen │
│ ├── ProgrammerScreen │
│ │ ├── BitGridComposable │
│ │ └── StructVisualizerScreen │
│ ├── FinancialScreen │
│ └── ConvertScreen │
├──────────────────────────────────────┤
│ ViewModel Layer │
│ ├── CalculatorViewModel │
│ ├── ProgrammerViewModel │
│ ├── FinancialViewModel │
│ ├── ConvertViewModel │
│ └── manages state, calls engine │
├──────────────────────────────────────┤
│ JNI Bridge (TallyEngine.kt) │
│ ├── external fun evaluate(...) │
│ ├── external fun structLayout(...) │
│ ├── external fun convert(...) │
│ └── JSON serialization/deser │
├──────────────────────────────────────┤
│ Native .so (libtally.so) │
│ └── C ABI from engine/c_api.zig │
└──────────────────────────────────────┘
```
The JNI bridge sends expression strings down and receives JSON results back. This keeps the boundary thin and debuggable.
---
## 10. Error Handling Strategy
```zig
pub const CalcError = error{
// Parser errors
UnexpectedToken,
UnmatchedParen,
InvalidNumber,
UnknownFunction,
UnknownVariable,
// Evaluation errors
DivisionByZero,
Overflow,
InvalidOperandType,
DomainError, // e.g., sqrt(-1)
// Struct layout errors
InvalidType,
InvalidFieldName,
DuplicateFieldName,
StructTooLarge,
// Financial errors
InsufficientParameters,
ConvergenceFailure, // TVM Newton-Raphson didn't converge
// System
OutOfMemory,
};
pub const ErrorInfo = struct {
err: CalcError,
message: []const u8,
position: ?usize, // character position in input where error occurred
context: []const u8, // snippet of input around error
};
```
All engine functions return `CalcError!Result`. Frontends translate these into user-facing messages with position highlighting.
---
## 11. Key Design Decisions
| # | Decision | Rationale |
|---|----------|-----------|
| 1 | Pratt parser over recursive descent | Easier to extend precedence levels; cleaner for infix with mixed prefix operators |
| 2 | `u64` storage for all programmer integers | Simplest representation; bit_width used for masking/interpretation, not storage |
| 3 | JSON over C ABI boundary | Avoids complex struct marshaling; Kotlin/Swift handle JSON natively; small perf cost acceptable for calculator |
| 4 | Separate parser for struct DSL | Struct grammar is different enough from expressions; keeps both parsers simple |
| 5 | ABI as interface/vtable | Adding Windows x64 or ARM is implementing one struct; no changes to layout algorithm |
| 6 | libvaxis for TUI | Most mature Zig TUI; supports Windows/Mac/Linux; active development; used by Ghostty |
| 7 | `^` always means power; XOR is the `xor` keyword | Avoids mode-dependent operator overloading; every operator means the same thing in both modes; consistent with existing `rol`/`ror` keyword operators |
| 8 | Statically-linked desktop binaries | Zero dependencies for end user; Zig makes this trivial |
| 9 | Unit conversion via base-unit normalization | Simple to implement, easy to extend; only temperature needs special case (offset) |
| 10 | Android: button grids, not text prompts | Touch-first UX; no keyboard needed for basic use; purpose-built surfaces per mode beat a generic expression prompt |
| 11 | Android: form-based struct builder + raw toggle | Most users won't want to type DSL syntax on a phone; visual builder is more natural; power users can toggle to raw text |
| 12 | Android: live conversion without "Calculate" button | Conversions are instant; eliminating a tap makes the UX feel responsive and direct |