42 KiB
Requirements: Tally - Cross-Platform Calculator
Overview
A calculator application with three frontends (CLI, TUI, Android) sharing a common Zig engine. Supports standard arithmetic, a programmer mode with struct layout visualization, and financial calculations.
Functional Requirements
FR-1: Standard Calculation Mode
- FR-1.1: Parse and evaluate infix mathematical expressions with correct operator precedence (PEMDAS).
- FR-1.2: Support operators:
+,-,*,/,%(modulo),^(power), unary-. - FR-1.3: Support parentheses for grouping.
- FR-1.4: Support built-in functions:
sin,cos,tan,asin,acos,atan,log(base-10),ln(natural),sqrt,cbrt,abs,ceil,floor,round,factorial. An argument outside a function's domain is reported as such, distinct from an unknown name:asin(2),ln(0),log2(0)andfactorial(-1)report a domain error,sqrt(-1)reports "square root of a negative number", and only an unrecognized name reports an unknown function. - FR-1.5: Support constants:
pi,e,tau. These are fixed values, readable and not assignable. - FR-1.6: Support variable storage: any identifier as a named variable. (The original wording restricted this to
A-F, X, Y, Z; the implementation accepts any name, which is a superset and the better behaviour, so the requirement follows the code.) A constant is not an assignment target:pi = 3reports an error rather than accepting the assignment and ignoring it. - FR-1.6.1: The last answer is a pseudovariable, not a constant:
Ans(alsoans) reads the result of the previous evaluation, the engine updates it on every successful evaluation, and the user cannot assign to it. It is a distinct concept from FR-1.5's constants, which never change, and from FR-1.6's variables, which only the user writes. The set is exactly one name: the immediately preceding answer. Earlier results are FR-1.7's history, reachable by replay rather than by name. See tasks.md open item 16: the engine currently foldsAnsin with the constants and reportsAssignmentToConstant("cannot assign to a built-in constant") forAns = 5, which misnames it. - FR-1.7: Maintain calculation history with replay capability.
- FR-1.8: Commas accepted as digit separators in input, but only in thousands groups: a comma must be followed by exactly three digits (
1,000 * 2is 2000,1,234,567is one number). A comma followed by any other number of digits is an argument separator, which is what makeslog(100,10)two arguments rather than the number 10010. The ambiguous casemax(1,234)resolves in favour of the grouping and reads asmax(1234); write a space to mean two arguments. A malformed group such as1,00is an error rather than a silently merged number. Spaces and underscores group hex/octal/binary literals (0xFF FF,0xFF_FF); commas do not. - FR-1.9: When a standard-mode expression contains any non-decimal literal (hex
0x, octal0o, or binary0b) and the result is a non-negative integer, enrich the result display with hex/octal/binary representations inline (without leaving standard mode). Uses the smallest standard bit width (8/16/32/64/128) that holds the value. This does not change the evaluation semantics (still f64 arithmetic,^is still power); it only augments the display. Fractional or negative results show decimal only.
FR-2: Programmer Mode
- FR-2.1: Accept input in decimal, hexadecimal (
0x), octal (0o), and binary (0b) formats. - FR-2.2: Simultaneously display results in all four bases (dec, hex, oct, bin).
- FR-2.3: Support configurable bit widths: 8, 16, 32, 64, 128-bit. Standard mode is fixed at 64-bit signed; a width other than 64 is what programmer mode is for.
- FR-2.4: Support bitwise operators: AND (
&orand), OR (|oror), XOR (xorkeyword), NOT (~ornot), left shift (<<), right shift (logical>>>), arithmetic right shift (>>), rotate left (rol), rotate right (ror). Note:^is always exponentiation (never XOR) - see FR-2.12.>>fills the vacated high bits with copies of the sign bit, so-8 >> 1is -4;>>>fills them with zeros. In an unsigned width there is no sign to extend, so>>fills with zeros there too. Bits shifted past the width are discarded rather than wrapped (0b1000 << 1is 16);rolandrorare the operators that wrap. - FR-2.13: A shift distance at or beyond the bit width shifts every original bit out:
1 << 64is 0 in 64-bit, and 8-bit0xFF >>> 20is 0. For>>the result is the sign fill, so a negative value shifted that far is -1 and a non-negative one is 0. Rotation instead reduces the distance modulo the width, because a rotation is cyclic:1 rol 65is1 rol 1. A negative shift or rotate distance is a domain error rather than a very large one. - FR-2.12: The
^operator means exponentiation in all modes (never XOR). This avoids mode-dependent operator overloading. XOR is available only via thexorkeyword. Power is also available via**. This keeps every operator's meaning identical across standard and programmer modes. - FR-2.5: Display both signed (two's complement) and unsigned interpretations of the current value.
- FR-2.6: Visualize the bit pattern as a grid (integer.exposed style) - bits individually addressable/toggleable in TUI and Android.
- FR-2.7: SUPERSEDED by FR-7.8's removal. Single-letter keys for switching the primary base cannot coexist with editable value fields, where those letters are hex and binary digits. Base selection is by arrow key and mouse, and all four bases are on screen at once anyway (FR-2.2), which is most of what the shortcut was for.
- FR-2.8: Configurable endianness display (big-endian default, little-endian available). Affects byte order in HEX and ASCII rows and byte-level visualizations. Does not change the underlying value - purely a display/interpretation toggle. Big-endian is the default so the HEX row reads as the number itself, matching the DEC, OCT and BIN rows; the little-endian view (x86 memory order) is one keystroke away. (This requirement previously specified a little-endian default, which the implementation deliberately did not follow.)
- FR-2.9: ASCII/text literal input via single-quoted strings:
'hello'packs ASCII bytes into the integer value. First character occupies the most significant used byte (big-endian packing when display is BE) or least significant byte (when display is LE). Useful for examining magic numbers, file signatures, protocol headers. - FR-2.10: ASCII interpretation display - when the current value contains printable ASCII bytes, show the text representation alongside the numeric bases (e.g.,
ASCII: "ELF."orASCII: ..lfwith dots for non-printable bytes). - FR-2.11: IEEE 754 floating-point interpretation (float.exposed style):
- Support float32 (single) and float64 (double) formats, selectable
- Decompose bit pattern into sign bit, exponent field, and significand/mantissa field with visual color-coding in the bit grid
- Display the evaluation formula: (-1)^sign * 2^(exponent - bias) * 1.mantissa
- Display the exact decimal value represented by the bit pattern
- Display delta to next and previous representable float (ULP - unit in last place)
- Handle special values: +/-infinity, NaN, denormals/subnormals, +/-zero
- Allow toggling individual bits and seeing the float value update in real-time
- Allow entering a decimal float value and seeing the closest IEEE 754 representation
- Show when a value is not exactly representable (rounding occurred)
FR-3: Struct Layout Visualizer (Programmer Mode Extension) - NOT BUILT, WANTED, SEQUENCED AFTER ANDROID
None of FR-3 exists: no DSL parser, no ABI profiles, no layout computation, no CLI subcommand and no TUI sub-view. The four errors it would need were removed from the engine because nothing could produce them, and design.md section 3 (the grammar and algorithm) is marked DESIGN ONLY for the same reason.
The feature stays in the specification: it is wanted, and it is the reason programmer mode exists at all for some of its users. It is simply behind the Android app in priority, so expect the DESIGN ONLY markers to stand for a while rather than to be a sign of abandonment.
- FR-3.1: Accept struct definitions in a mini-DSL:
struct { u32 x; u8 flags; u16 id; u64 timestamp; } - FR-3.2: Compute and display total struct size in bytes.
- FR-3.3: Display per-field information: offset, size, alignment requirement.
- FR-3.4: Identify and highlight padding bytes between fields and at the end (tail padding).
- FR-3.5: Render a byte-level memory map showing which bytes belong to which field and which are padding.
- FR-3.6: Default to x86-64 System V ABI alignment rules.
- FR-3.7: Support switching ABI/alignment profiles (extensible design - future: Windows x64, ARM AAPCS, packed).
- FR-3.8: Support switching endianness display (little-endian default, big-endian available).
- FR-3.9: Supported field types (initial set):
u8,u16,u32,u64,i8,i16,i32,i64,f32,f64,bool,ptr(pointer-sized, arch-dependent). - FR-3.10: Show a "packed" comparison - display what the struct would look like with no padding vs. natural alignment.
FR-4: Unit Conversions
- FR-4.1: Support inline unit conversion syntax:
<value> <from_unit> to|in <to_unit>(e.g.,100 km to mi,32F in C). IMPLEMENTED as ordinary expression input, so no subcommand is needed:tally 32F in Cworks, as does typing it in the TUI standard mode. The value may itself be an expression (2*3 kg to lb). - FR-4.2: Support unit conversions within expressions:
5 kg + 3 lbevaluates in the left-hand unit. NOT YET IMPLEMENTED (needs unit-aware tokenizing and evaluation, unlike FR-4.1 which only needs thetokeyword as a trigger). - FR-4.3: Length: nm, um, mm, cm, m, km, in, ft, yd, mi, nmi (nautical mile), ly, au, pc. Base unit: m.
- FR-4.4: Mass/Weight: mg, g, kg, t (tonne), oz, lb, st (stone), ton (US short), lt (long ton). Base unit: kg.
- FR-4.5: Temperature: C (Celsius), F (Fahrenheit), K (Kelvin), R (Rankine). Base unit: C. These are the only affine conversions (they need an offset, not just a factor). Fahrenheit and Rankine scale by exactly 5/9, which has no terminating decimal form.
- FR-4.6: Time: ns, us, ms, s, min, h, d, wk, yr. Base unit: s. A year is the Julian year (365.25 days), the usual unit-conversion convention.
- FR-4.7: Digital storage: bit, B, and both decimal (kB, MB, GB, TB, PB) and binary (KiB, MiB, GiB, TiB, PiB) prefixes, kept distinct. Base unit: B.
- FR-4.8: Speed: m/s, km/h, mph, ft/s, kn (knots), c (speed of light). Base unit: m/s.
- FR-4.9: Area: mm2, cm2, m2, km2, in2, ft2, yd2, mi2, ha, acre. Base unit: m2.
- FR-4.10: Volume: mL, L, m3, cm3, in3, ft3, gal, qt, pt, cup, floz, tbsp, tsp, bbl. Base unit: L.
- FR-4.11: Energy: J, kJ, cal, kcal, Wh, kWh, BTU, eV, erg. Base unit: J.
- FR-4.12: Pressure: Pa, kPa, MPa, bar, mbar, atm, psi, torr, mmHg, inHg. Base unit: Pa.
- FR-4.13: Data rate: bps, kbps, Mbps, Gbps, Tbps, Bps, kBps, MBps, GBps. Base unit: bps.
- FR-4.14: Angle: rad, mrad, deg, grad, turn, arcmin, arcsec. Base unit: rad.
- FR-4.15: In CLI, unit conversion is plain expression input (FR-4.1):
tally 100 km to mi,tally 32F to C. Aconvertsubcommand is also accepted for explicitness and scripting (tally convert 100 km mi, with or without theto), but it is a convenience, not the only route. - FR-4.16: In TUI, a dedicated convert mode reachable from the mode tabs.
- FR-4.17: Unit system extensible - adding a unit or category requires only a table entry, no parser changes.
- FR-4.18: Unit names resolve by canonical name or alias (long forms and plurals). An exact case-sensitive match is tried first so case-distinguished units keep their meaning (
Kis Kelvin,Bis byte,kBis notKiB); a case-insensitive pass runs only as a fallback, so forgiving input likeKMorCelsiusstill works. - FR-4.19: Converting between units of different categories is an error (
IncompatibleUnits); an unrecognized unit name is an error (UnknownUnit). - FR-4.20: Conversion detection must not change the meaning of ordinary expressions. Only a standalone whitespace-delimited
toorintriggers it, and a unit name is only recognized at a token boundary (so the trailingsofsmootsis not read as seconds, and theininsideminis not read as a separator). Without a separator,eremains Euler's number rather than becoming a unit. - FR-4.21: Because
inis both a separator and the canonical name for inches, candidate separators are tried last-to-first and the first reading that yields a valid conversion wins. This must resolve all of:5 in in cm(5 inches to cm),100 mm in in(mm to inches),1 in in in(identity), and60 min in h(minutes to hours). Theconvertsubcommand must accept the same separators as bare expressions. - FR-4.22: Conversions must be exact wherever the units allow it. Conversion factors are declared as exact text (decimal or
p/q) and the f64 forms derived from them, so12 in to ftis exactly1,98.6 Fis exactly37 C, and3.6 km/his exactly1 m/s. Units defined in terms of pi (the angle units, and the parsec) have no exact rational factor and fall back to the inexact tier; a result reports whether its conversion was lossless.
FR-5: Financial Mode
- FR-5.1: Compute CAGR given start value, end value, and number of periods.
- FR-5.2: Compute compound interest (future value given principal, rate, periods, compounding frequency).
- FR-5.2.1: The compound-interest relationship is solvable for any of its four variables: present value, future value, nominal annual rate, or years. The compounding frequency is an input rather than a variable, because a solution for it only exists in the narrow band between annual and continuous compounding and a fractional answer would be meaningless.
- FR-5.2.2: A solved rate is a NOMINAL annual rate. When the compounding frequency exceeds 1 the effective annual rate (APY) must be reported alongside it, since a nominal rate compared against a quoted effective rate is a wrong comparison. At annual compounding the two coincide and only one figure is shown.
- FR-5.3: Compute present value (discount future cash flow).
- FR-5.4: Support TVM (Time Value of Money) solver - given any 4 of: N, I/Y, PV, PMT, FV, solve for the 5th.
- FR-5.5: Display intermediate calculation steps/formula used (transparency).
- FR-5.6: Produce an amortization schedule for a loan: given principal, rate per period, and number of periods (with an optional payment override), report each period's payment, interest, principal, and remaining balance, plus totals. Figures round to whole cents by default, with the final period absorbing the rounding residue so the balance reaches exactly zero. A payment larger than the level payment retires the loan early; a payment that covers the interest but not the full term ends in a balloon payment; a payment that does not cover the first period's interest is an error rather than an infinite schedule.
- FR-5.7: Financial calculations must be reachable from a standard-mode expression as functions, not only from a dedicated mode, the same way unit conversion is reachable from a bare expression. Results compose with ordinary arithmetic, so
cagr(10000, 25000, 5) * 100is a valid expression. The functions are:cagr(start, end, periods),fv(pv, rate, years[, per_year]),pv(fv, rate, years[, per_year]),compound_rate(pv, fv, years[, per_year]),compound_years(pv, fv, rate[, per_year]),apy(nominal_rate, per_year),tvm_pmt/tvm_fv/tvm_pv/tvm_n/tvm_rate(each taking the other four variables),amort_payment(principal, rate, periods),amort_interest/amort_principal/amort_balance(..., period), andamort_total_interest/amort_total_paid. Anything the financial forms can solve must also be reachable this way, so the two surfaces do not drift apart.- The list a user sees is the engine's, not each frontend's: the signature and one-line summary of every function above live in
financial.expression_functions, and the CLI help and the TUI help overlay render it. A frontend must not carry its own copy. Both did, and both were wrong in different ways: the CLI named nine of the seventeen and the overlay eleven, with wording that had diverged. An evaluator test asserts every documented name dispatches at the arity its signature advertises, which is what keeps this requirement's list and the code in step.
- The list a user sees is the engine's, not each frontend's: the signature and one-line summary of every function above live in
- FR-5.8: Financial results are in the inexact tier (FR-4.22's exactness model does not apply): every formula needs a non-integer power or a logarithm, so there is no exact rational to preserve. Money is handled by explicit rounding at the point a figure becomes an amount, banker's rounding (half-even) by default so repeated rounding does not bias totals upward.
FR-6: CLI Frontend
- FR-6.1: Invoke as
tally "<expression>"for standard mode evaluation. - FR-6.2: Flag
--programmeror-pfor programmer mode:tally -p "0xFF & 0x0F". - FR-6.3: Flag
--bits <8|16|32|64|128>to set bit width in programmer mode (default: 64). Accepted as--bits 8or--bits=8. It implies-p, because standard mode is fixed at 64-bit signed and the flag would mean nothing there. - FR-6.4: REMOVED. Flag
--base <dec|hex|oct|bin>to control primary output format. Programmer mode prints all four rows and standard mode adds them when the expression used a non-decimal literal (FR-1.9), so there is nothing for a selector to select; "primary" has no meaning in a view that shows every base at once. Anyone wanting one base can pipe through a line filter. - FR-6.5: NOT IMPLEMENTED (FR-3 is unbuilt, and sequenced after the Android app). Subcommand
tally struct <definition_or_file>for struct layout - accepts inline DSL string or path to a file containing the definition. - FR-6.6: REMOVED. Subcommand
tally cagr <start> <end> <periods>. Superseded by FR-5.7:tally 'cagr(1000, 2000, 5)'is the same calculation and composes with arithmetic, which a subcommand cannot. A second spelling would need its own argument checking and its own help, both of which would drift from the function's. - FR-6.7: REMOVED. Subcommand
tally tvmwith named flags. Superseded by FR-5.7'stvm_pmt,tvm_fv,tvm_pv,tvm_nandtvm_rate: naming the variable in the function is clearer than omitting one of five flags, and it needs no rule about which omission means "solve". - FR-6.8: Subcommand
tally convert <value> <from_unit> <to_unit>for unit conversion. - FR-6.9: REMOVED as specified here. Machine-readable output is still wanted, but not as a CLI flag invented separately from the C ABI's encoding (design 6): two JSON shapes for the same results is the duplication this project keeps finding. When the ABI settles its encoding, the CLI can render the same one, and that will be a new requirement rather than this one.
- FR-6.10: Exit code 0 on success, non-zero on parse/evaluation errors with stderr message.
- FR-6.11: Subcommand
tally amort <principal> <rate> <periods> [payment]prints an amortization schedule as a table. Flags:--monthly(the rate given is an annual nominal rate charged over monthly periods, so rate/12 applies per period),--summary(totals only, no per-period rows),--exact(skip cent rounding). A table is the one financial output that cannot be an expression function, which is why it is a subcommand. - FR-6.12: Flags
--signed(default) and--unsignedset how programmer-mode values are interpreted, which decides both the displayed rows and whether>>extends the sign. Both imply-p. - FR-6.13: Flag
--endian <big|little>(alsobe/le, case-insensitive) sets the byte order of the hex and ASCII rows, matching the TUI's Ctrl-E toggle. Default big-endian, so the hex row reads as the number itself (FR-2.8). Implies-p. - FR-6.14: Every subcommand answers
-h/--helpwith its own usage on stdout and exit code 0. Getting a subcommand's arguments wrong prints the same usage on stderr with a non-zero exit code. - FR-6.15: An unrecognized flag is an error naming
tally --help, not part of the expression.tally --bit 8 '1'used to be evaluated as the expression--bit 8 1and reported as an unexpected token.
FR-7: TUI Frontend
- FR-7.1: Full-screen terminal UI with mode tabs (Standard, Programmer, Financial, Convert). Tab moves to the next mode and Shift-Tab to the previous one, so a mis-hit costs one keystroke rather than a full lap.
- FR-7.2: Standard mode: expression input line, result display, scrollable history.
- FR-7.3: Programmer mode: bit grid (navigable with arrow keys, toggled with Space), simultaneous base displays, expression input. NOTE: Enter does not toggle a bit. It is consumed by the input line, and the value-zone status hint that advertised
Enter:setis a known defect. Space is the only toggle. - FR-7.3.1: When the terminal is too short for the bit grid plus all six base rows, the view says so and how many rows it needs, rather than drawing rows over the separator and the input line.
- FR-7.3.2: When the terminal is too narrow for the whole binary row, it continues onto further rows rather than being cut off. 64 bits grouped in fours is 79 characters and starts at column 11, so the row needs a 90-column terminal; on the default 80 it was silently clipped, and what went missing was the right-hand end, which is the low bits - the value read as wrong rather than as incomplete. The split halves at a time (64 bits becomes two rows of 32), matching the grouping the bit grid above already uses, and a wider terminal gets the single row back. The label sits on the first row; the rest are the same field, with the same click targets and the same cursor.
- FR-7.3.3: The IEEE 754 float overlay reports a terminal too short for it, the way FR-7.3.1's integer view does, rather than drawing a panel the furniture then covers. It used to draw its eight rows regardless: the separator, prompt and status were drawn afterwards and overwrote them, except for the two columns the prompt occupies, which left the tail of the "Exp:" row sitting on the input line as
> xp: 0 biased ...at 100x16. Content that does not fit is absent and accounted for, never relocated. - FR-7.4: Programmer mode struct sub-view: field list editor, live-updating memory map visualization.
- FR-7.5: Financial mode: form-style input for parameters, result display with formula breakdown, and a scrollable amortization schedule. Four calculations behind one selector: CAGR, compound interest, TVM, and amortization. TVM and compound interest solve for whichever field is left blank; an unanswerable form reports what it is waiting for rather than doing nothing. Results are live, so financial mode records nothing in history.
- FR-7.5.1: Form fields group digits as they are typed: entering
200then0displays2,000. The stored text stays ungrouped so it still parses, and a typed comma is accepted and discarded rather than corrupting the value. - FR-7.5.2: Form fields accept expressions, not only numbers.
12 * 30in a period count is valid input, shown verbatim while it is an expression and evaluated in place by Enter. A field holding an unevaluated expression is reported as such, so it is never mistaken for a blank field the form is waiting to solve for. A field that does not evaluate keeps its text rather than being silently cleared. - FR-7.5.3: Rate fields display a trailing
%after the value, and accept a typed%as a no-op, so6%and6mean the same thing. The label alone is not sufficient affordance once the eye is on the value column. - FR-7.5.4: Where a default is near-universal, the form prefills it as a visible value rather than showing an empty field with a placeholder. Compounds/year starts at
1. This keeps "blank means solve for this" true for every field the user sees, instead of blank meaning a default in one place and a solve slot in another. Clearing a prefilled non-solvable field asks for a value rather than for an answer. - FR-7.6: Convert mode: select category, input value, select from/to units, live result.
- The conversion factor line ("1 in = 2.54 cm") is computed by converting one of the source unit, the same exact path as the result above it. It used to be an f64 division of the two base factors, which disagreed with the line it sat under: centimetres to inches read 0.39370078740157477 as a factor and 0.393700787401574803150 as a result.
- Every row is bounded by the region the view owns. The result, factor and column headers used to be drawn unconditionally, so on a short terminal they landed on the furniture and left fragments beside the prompt.
- A conversion that cannot be computed says so. The previous fallback drew the unconverted input in the result slot, which reads as a conversion that did nothing rather than one that failed.
- FR-7.6.1: The help overlay scrolls (Up/Down, PgUp/PgDn, or the mouse wheel) and shows its position when content is off screen. Any other key returns. Sections are a single line list rather than individually height-gated draw calls, which is what previously caused later sections to be dropped silently on a normal-sized terminal.
- FR-7.6.2: The help overlay's function lists come from the engine, not from the overlay. The financial half renders
financial.expression_functions(FR-5.7); the general half rendersevaluator.math_function_names, which is every built-in that is not a financial function, wrapped to the width at comptime. The hand-written list named seventeen of the twenty-one:log2,log10,cbrtandatan2had been dispatchable and undocumented for some time.randis deliberately excluded while it remains a placeholder that returns 0, and an engine test asserts that every built-in is in exactly one of the two lists or is that one exclusion, so a function added without a decision about how it is presented fails the build rather than going unmentioned. - FR-7.7: Every action must be reachable from the keyboard alone; the TUI is fully usable without a mouse.
- FR-7.8: REMOVED. Quick-switch keys (
d=dec,h=hex,o=oct,b=bin) to highlight the primary base cannot coexist with editable value fields: in the value zone those letters are hex and binary digits. The requirement predates editable fields, and the fields are the more useful feature. Base selection stays on the arrow keys and the mouse. - FR-7.9: Support terminal resize gracefully.
- FR-7.9.3: NOT IMPLEMENTED. The colour scheme is selectable rather than fixed. Molokai stays the default, and the requirement is that it is a default: a light theme, a terminal's own 16 colours, and a user's choice are all reachable without editing the source. Today
draw.Cis eleven literal RGB triples read directly by every view, which makes the palette a compile-time fact. Prerequisite is naming the roles the views use (error, focus, selection, accent) instead of the colours they happen to be; tasks.md open item 17 holds the detail. - FR-7.9.1: Nothing in the TUI's own chrome is cut off mid-word at 60 columns or more. Status lines are lists of key hints, and a line too long for the terminal drops whole hints from the end rather than losing the last one's tail: each mode orders its hints so the keys that leave the current zone (
?:help,Tab:mode, the zone toggle) come first and are the last to go. Status lines also leave the final column clear, which is what lets a rendered frame be checked for clipping at all: any row but the separator reaching the right edge is then a fault, not a maybe. Below 60 columns the notices themselves cannot fit, and shortening them further would leave them saying nothing. - FR-7.9.2: Input is ASCII. A key event carrying non-ASCII text is dropped rather than stored: every column in the TUI is one byte, so an accented character would occupy a cell per UTF-8 byte, drawn as blanks, and then fail to tokenize. Refusing it keeps what is on screen equal to what is in the buffer. A wider fix is a different piece of work: the whole layout counts columns in bytes.
- FR-7.10: Vi-style and Emacs-style keybinding options for expression input. NOT IMPLEMENTED: the prompt is a plain text field. Deferred, not dropped.
FR-7.11: Mouse Support
Mouse input is a first-class way to drive the TUI, not an optional extra. Anything visibly interactive must respond to a left click. Keyboard parity is still required (FR-7.7): the mouse never becomes the only way to do something.
- FR-7.11.1: Clicking a mode tab switches to that mode.
- FR-7.11.2: Clicking a bit in the programmer bit grid flips that bit and moves the cursor to it.
- FR-7.11.3: Clicking a digit in the HEX or OCT row focuses that field and places the cursor on the exact digit clicked, ready for typing. A click does not alter the value, because a hex nibble or octal digit has no single unambiguous "toggle".
- FR-7.11.4: Clicking a digit in the BIN row flips that bit, since one binary digit is exactly one bit.
- FR-7.11.5: Clicking a DEC(s) or DEC(u) row focuses that field for editing.
- FR-7.11.6: Clicking the
Bits:,Signed:, orEndian:label in the programmer header toggles or cycles that setting. - FR-7.11.7: In the float view, clicking a bit flips it; clicking the format or exit hint in the header toggles f32/f64 or leaves the overlay.
- FR-7.11.8: In convert mode, clicking a category chip selects that category (resetting the unit pair to that category's defaults); clicking a unit in the From or To column selects it.
- FR-7.11.9: Clicking the input line returns keyboard focus to the expression prompt.
- FR-7.11.10: Clicking anywhere dismisses the help overlay, matching its "any key dismisses" keyboard behavior.
- FR-7.11.11: Only a left button press acts. Release, motion, and drag events are ignored so a single click cannot fire an action twice. The wheel is the one exception: in financial mode it scrolls the amortization schedule (FR-7.11.13).
- 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 - WALKING SKELETON BUILT, UNVERIFIED ON A DEVICE
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).
- FR-8.3: Bottom navigation bar for mode switching (Standard, Programmer, Financial, Convert).
- FR-8.4: Standard mode: traditional calculator layout with button grid, expression display at top, result below.
- FR-8.5: Programmer mode: tappable bit grid, segmented control for bit width, base display cards, operator button row for bitwise ops.
- FR-8.6: Programmer mode struct sub-screen: text field for struct input, scrollable field table, color-coded memory map rendered as a custom Canvas composable.
- FR-8.7: Financial mode: card-based UI with input fields, dropdown for function selection (CAGR, TVM, etc.), compute button, result card with formula.
- FR-8.8: Convert mode: category picker (horizontal chips), from/to unit selectors, live conversion as user types.
- FR-8.9: Responsive layout for phone and tablet (multi-pane on tablet).
- FR-8.10: Follow Material Design 3 guidelines with dynamic color (Android 12+).
- FR-8.11: Haptic feedback on bit toggle and button presses.
- FR-8.12: Support landscape orientation with adapted layouts (wider bit grid, side-by-side panels).
Non-Functional Requirements
NFR-1: Performance
- Expression parsing and evaluation must complete in < 1ms for typical expressions.
- TUI must render at >= 30fps during interaction (bit toggling, navigation).
- Struct layout computation must handle structs with up to 256 fields without perceptible delay.
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. BUILT:
zig build androidproduces 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 buildinvocation to produce all desktop targets (cross-compilation).
NFR-3: Correctness
- Floating-point arithmetic uses IEEE 754 double precision (64-bit).
- Programmer mode integer operations must be exact (no floating-point).
- Struct layout computation must match the actual layout produced by a C compiler under the specified ABI.
NFR-4: Extensibility
- Adding a new ABI profile for struct layout requires implementing a single interface (alignment rules + pointer size).
- Adding new financial functions requires no changes to the parser - use a function-call syntax.
- Adding new unit categories/units requires only table entries, no parser changes.
- Engine exposes a stable C ABI for future frontends (e.g., WASM, iOS).
NFR-5: Testability
- Engine must be testable in isolation (no I/O dependencies).
- Struct layout results must be verifiable against
paholeor compiler output for reference structs. - Engine test coverage target: >= 80% line coverage. The engine is pure computation with no I/O, so this is achievable and expected.
- Frontend coverage is measured, not waived. The CLI and TUI each get their own instrumented coverage report alongside the engine's, with include paths kept disjoint so no file is accounted for twice. The TUI is testable further than "smoke tests" suggests: views are drawn into a surface and the cells read back, and key and mouse handling goes through the real handlers, so layout and binding regressions are caught rather than eyeballed. Target is the same >= 80% as the engine.
- What genuinely cannot be covered is the terminal itself: the event loop,
vaxissetup, and the process entry point. Those are the only frontend paths exempt. - All public engine API functions must have corresponding unit tests covering happy path, error cases, and edge cases.
NFR-6: Build & Distribution
- Zig build system for everything. A single
zig buildinvocation 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, thetallybinary,test,coverage, andandroidfor 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.tomlat the project root. A developer needs onlymiseinstalled - runningmise installprovisions 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.tomlis 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.
- Produce statically-linked binaries for CLI and TUI (no shared library dependencies on end-user system).
- Android: produce .so files for arm64-v8a, x86_64 via
zig build -Dtarget=aarch64-linux-android(or similar). The Gradle project inandroid/consumes these prebuilt artifacts. - Minimal runtime dependencies: Zig stdlib + libvaxis (TUI only). No other native deps.
NFR-7: Number Display & Formatting
- Decimal numbers must use comma grouping for display (e.g.,
4,294,967,295). - Inexact (f64) values: scientific notation only when absolute value > 10^15 / < 10^-15. Never jump to scientific notation for values that fit in a readable decimal. The upper bound is not a readability preference: 10^15 is where f64 stops distinguishing consecutive integers (2^53 ~ 9.007 x 10^15), so printing a plain integer past it would assert precision the value does not have. The lower bound is a readability choice, and it is therefore a frontend's to make: it is
Number.FormatOptions.scientific_below_exponent, which both shipped frontends set to 15.- (An earlier draft also triggered scientific notation past 15 significant digits. That clause was never implemented and was wrong: read literally it renders
0.9999999999999998as9.999999999999998e-1, which is worse.)
- (An earlier draft also triggered scientific notation past 15 significant digits. That clause was never implemented and was wrong: read literally it renders
- Exact values: the 10^15 upper bound above must NOT apply. It exists because of f64's precision cliff, and an exact value has no such cliff, so applying it would contradict NFR-9.1 -
9007199254740993is ~9.007 x 10^15 and would render as9.007199254740993e15, which is precisely the bug NFR-9.1 forbids. Exact values instead have a readability cap on integer digits, above which the display abbreviates to scientific notation while the clipboard form retains every digit. The cap is not the engine's to choose: it is a field ofNumber.FormatOptions, which every frontend supplies (max_integer_digits, withnullmeaning never abbreviate, which is what the clipboard form asks for). - The 10^-15 lower bound does apply to exact values, and for the opposite reason to the upper one: at that magnitude a fixed rendering has spent its whole fractional budget on leading zeros and retains a handful of significant digits, where the scientific form retains seventeen. Abbreviating there loses less, not more. Both tiers therefore share one threshold, so the same magnitude reads the same way whichever tier produced it.
- The cap must exceed the values the exact tier exists to serve:
9007199254740993(16 digits) and2^128(39 digits). It exists at all because without any cap,factorial(171)renders 310 digits and1.5e300 * 10renders 301: accurate but unreadable. - PROVISIONALLY 40 digits, pending review. Chosen as the smallest round number above
2^128. Not derived from any measured preference.
- The cap must exceed the values the exact tier exists to serve:
- Programmer mode hex values display with space-separated bytes (e.g.,
FF FF FF FF). - Programmer mode binary values display grouped by nibble with spaces (e.g.,
1111 1111). - All frontends must distinguish between "display format" (with separators) and "clipboard format" (raw, no separators).
- Android: long-press any result to copy the raw (no-separator) value to clipboard.
- TUI: yank command copies raw value to system clipboard.
- CLI: stdout output uses display format by default;
--rawflag or--jsongives unformatted values.
NFR-8: Security
- C API must not use null-terminated strings. All string inputs take
(pointer, length)pairs to prevent buffer overread. - Engine must validate all input lengths before processing.
- No unbounded allocations from untrusted input (enforce maximum expression length, maximum struct field count).
NFR-9: Numeric Correctness (Exact Arithmetic)
Correctness is prioritized over evaluation speed. A calculator evaluates one expression at a time in response to a human, so there is no hot-loop or real-time constraint that justifies accepting wrong answers.
- NFR-9.1: Integer arithmetic must be exact and unbounded in standard mode.
9007199254740993must round-trip, and2^53 + 1must not collapse to2^53. Silently returning a different integer than the user typed is a correctness bug, not a display artifact. - NFR-9.2: Arithmetic on terminating decimal literals must be exact:
0.1 + 0.2is0.3, and1.1 + 2.2is3.3. - NFR-9.3: Division must be exact where the result is rational:
1/3retains its exact value and(1/3) * 3is exactly1. - NFR-9.4: Unit conversions between units whose factors are exact decimals must be exact:
12 in to ftis exactly1. - NFR-9.5: Exactness is tracked, not assumed. A value is exact only if no rounding occurred anywhere in its computation. Any operation involving an inexact operand yields an inexact result, and inexact values never silently become exact again.
- NFR-9.6: Operations that cannot produce a rational result (trigonometric, logarithmic, exponential, non-integer powers, roots of non-perfect-powers, and the constants
pi/e/tau) fall back to floating point. This is a per-evaluation property:sqrt(4)stays exact,sqrt(2)does not. - NFR-9.7: Input base must not affect the numeric path.
0xdeadbeef + 10is exact integer arithmetic; base is a lexical and display concern only. - NFR-9.8: Exact arithmetic must degrade gracefully rather than exhaust memory. Denominator size is capped, and exceeding the cap demotes the value to inexact rather than erroring.
- NFR-9.9: Display precision is a separate decision from compute precision. Exact values with terminating decimal expansions print exactly; non-terminating ones are rendered to a digit budget with correct rounding and marked approximate, with the exact fraction available.
- NFR-9.10: Programmer mode retains fixed-width
u128wrapping semantics, and the IEEE 754 float view retains binaryf32/f64semantics. Neither is affected by exact arithmetic. - NFR-9.11: The inexact tier must not claim more precision than its operations can deliver. Displayed digits must be digits we can justify, so the storage width is chosen to match the accuracy of the available transcendental implementations rather than exceeding it (see design.md 2.7.8).