48 KiB
Design: Tally - Cross-Platform Calculator
References
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 buildis 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 priorzig buildstep.
Toolchain management (mise):
.mise.tomlat project root declares: Zig version, any other needed tools.mise installprovisions the full dev environment. No other manual setup required.- CI uses the same
.mise.tomlfor 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
/// 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
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 | | (bitwise OR) |
| 2 | ^ (bitwise XOR) - context-dependent, see below |
| 3 | & (bitwise AND) |
| 4 | <<, >>, >>>, rol, ror |
| 5 | +, - |
| 6 | *, /, % |
| 7 | ** or ^ (exponentiation - standard mode) |
| 8 | Unary -, ~ (bitwise NOT) |
| 9 | Function calls, parentheses |
Context-dependent ^: In standard mode, ^ means exponentiation. In programmer mode, ^ means XOR. The parser accepts a mode parameter to resolve this. Programmer mode uses ** for exponentiation if needed.
Implicit multiplication: The tokenizer detects adjacency patterns (number-identifier, number-paren, paren-paren) and inserts a synthetic * token.
2.5 Evaluation
The evaluator maintains an Environment:
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,xxdoutput. 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
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
displayin the UI - Copy
rawto clipboard on long-press (Android) or yank (TUI)
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
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
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.
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: value_in_base = value * factor + offset
/// For most units, offset = 0. Temperature uses both.
to_base_factor: f64,
to_base_offset: f64,
from_base_factor: f64,
from_base_offset: f64,
};
4.2 Conversion Resolution
The parser recognizes the pattern <expr> <unit> to <unit>:
- Tokenizer identifies known unit names after a numeric expression
tokeyword triggers conversion mode- Both units must be in the same category (error otherwise)
For expressions like 5 kg + 3 lb, the parser:
- Identifies
5 kgas a unit-qualified value - Sees
+ 3 lb- converts3 lbtokg(left-hand unit wins) - Evaluates as
5 + 1.36078 = 6.36078 kg
4.3 Adding New Units
New units require only a table entry - no parser changes:
// In units/length.zig
pub const length_units = [_]UnitDef{
.{ .name = "m", .aliases = &.{"meter", "meters"}, .to_base_factor = 1.0, ... },
.{ .name = "km", .aliases = &.{"kilometer", "kilometers"}, .to_base_factor = 1000.0, ... },
.{ .name = "mi", .aliases = &.{"mile", "miles"}, .to_base_factor = 1609.344, ... },
// ... add new entries here
};
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)
// 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. CalcResultstruct bundles success/error in a single return. Caller checksjson_ptr != nullfor 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 aByteArrayand 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.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):
- Bit grid - arrow keys move, Space toggles individual bits
- DEC(s) - editable signed decimal
- DEC(u) - editable unsigned decimal
- HEX - editable hex (space-separated bytes: FF 00 AB CD)
- OCT - editable octal
- BIN - editable binary (nibble-separated: 1111 0000)
- ASCII - shows printable ASCII interpretation of the bytes
(e.g.,
.ELForhell- dots for non-printable) - 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)
- Ctrl-E toggles endianness display (LE/BE)
Endianness:
- Default: little-endian (matches x86-64)
- Toggle with Ctrl-E
- Affects: HEX byte display order, ASCII interpretation order
- Does NOT affect: the underlying u64 value, DEC display, BIN display
- Header shows current setting:
Endian: LEorEndian: BE
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)
- BE: first char -> most significant used byte (
- 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)
- 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 - 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 ─────────────────────┘
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
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 | Mode as parser parameter | Resolves ^ ambiguity (power vs XOR) at parse time rather than eval time |
| 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 |