add conversion functions

This commit is contained in:
Emil Lerch 2026-07-25 18:20:33 -07:00
parent 3262be27ba
commit 7e74278827
Signed by: lobo
GPG key ID: A7B62D657EF764F8
13 changed files with 2764 additions and 147 deletions

View file

@ -357,44 +357,90 @@ pub const UnitCategory = enum {
};
pub const UnitDef = struct {
name: []const u8, // canonical name (e.g., "km")
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.
/// 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,
from_base_factor: f64,
from_base_offset: 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
The parser recognizes the pattern `<expr> <unit> to <unit>`:
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.
NOT YET IMPLEMENTED: the expression-level syntax below (`<expr> <unit> to
<unit>` and unit-qualified arithmetic like `5 kg + 3 lb`) needs unit-aware
tokenizing and evaluation. Today conversion is reached through the CLI `convert`
subcommand and the TUI convert mode. The planned design was:
- Tokenizer identifies known unit names after a numeric expression
- `to` keyword triggers conversion mode
- Both units must be in the same category (error otherwise)
For expressions like `5 kg + 3 lb`, the parser:
1. Identifies `5 kg` as a unit-qualified value
2. Sees `+ 3 lb` - converts `3 lb` to `kg` (left-hand unit wins)
3. Evaluates as `5 + 1.36078 = 6.36078 kg`
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/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, ... },
// 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
@ -564,6 +610,60 @@ $ tally "5 kg + 3 lb"
## 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] ─┐
@ -733,6 +833,59 @@ IEEE 754 float interpretation:
└──── 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

View file

@ -67,23 +67,25 @@ A calculator application with three frontends (CLI, TUI, Android) sharing a comm
### FR-4: Unit Conversions
- **FR-4.1**: Support inline unit conversion syntax: `<value> <from_unit> to <to_unit>` (e.g., `100 km to miles`, `72 F to C`).
- **FR-4.2**: Support unit conversions within expressions: `5 kg + 3 lb` evaluates in the left-hand unit.
- **FR-4.3**: Length: mm, cm, m, km, in, ft, yd, mi, nm (nautical mile).
- **FR-4.4**: Mass/Weight: mg, g, kg, oz, lb, ton, tonne.
- **FR-4.5**: Temperature: C (Celsius), F (Fahrenheit), K (Kelvin).
- **FR-4.6**: Time: ms, s, min, hr, day, week, year.
- **FR-4.7**: Digital storage: bit, byte, KB, MB, GB, TB, PB (both SI and binary: KiB, MiB, GiB, TiB).
- **FR-4.8**: Speed: m/s, km/h, mph, knots.
- **FR-4.9**: Area: mm2, cm2, m2, km2, in2, ft2, acre, hectare.
- **FR-4.10**: Volume: ml, L, gal, qt, pt, fl_oz, cm3, m3.
- **FR-4.11**: Energy: J, kJ, cal, kcal, Wh, kWh, BTU.
- **FR-4.12**: Pressure: Pa, kPa, bar, atm, psi, mmHg.
- **FR-4.13**: Data rate: bps, Kbps, Mbps, Gbps, B/s, KB/s, MB/s, GB/s.
- **FR-4.14**: Angle: deg, rad, grad, turn.
- **FR-4.15**: In CLI, support `tally "100 km to miles"` and `tally convert 100 km miles`.
- **FR-4.16**: In TUI, dedicated conversion panel accessible from any mode.
- **FR-4.17**: Unit system extensible - adding a new category requires only a conversion table, no parser changes.
- **FR-4.1**: Support inline unit conversion syntax: `<value> <from_unit> to <to_unit>` (e.g., `100 km to miles`, `72 F to C`). NOT YET IMPLEMENTED as expression syntax; currently provided by the CLI `convert` subcommand (FR-4.15) and the TUI convert mode (FR-4.16).
- **FR-4.2**: Support unit conversions within expressions: `5 kg + 3 lb` evaluates in the left-hand unit. NOT YET IMPLEMENTED (requires unit-aware tokenizing and evaluation).
- **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).
- **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, support `tally convert <value> <from> <to>`, `tally convert <value> <from> to <to>`, and the glued form `tally convert 100km to mi`.
- **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 (`K` is Kelvin, `B` is byte, `kB` is not `KiB`); a case-insensitive pass runs only as a fallback, so forgiving input like `KM` or `Celsius` still works.
- **FR-4.19**: Converting between units of different categories is an error (`IncompatibleUnits`); an unrecognized unit name is an error (`UnknownUnit`).
### FR-5: Financial Mode
@ -114,11 +116,30 @@ A calculator application with three frontends (CLI, TUI, Android) sharing a comm
- **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.
- **FR-7.6**: Convert mode: select category, input value, select from/to units, live result.
- **FR-7.7**: Keyboard-driven navigation; no mouse required (mouse optional enhancement).
- **FR-7.7**: Every action must be reachable from the keyboard alone; the TUI is fully usable without a mouse.
- **FR-7.8**: Quick-switch keys for base display in programmer mode (e.g., `d`=dec, `h`=hex, `o`=oct, `b`=bin to highlight primary).
- **FR-7.9**: Support terminal resize gracefully.
- **FR-7.10**: Vi-style and Emacs-style keybinding options for expression input.
#### 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:`, or `Endian:` 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.
- **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-8: Android Frontend
- **FR-8.1**: Native Android app using Kotlin and Jetpack Compose.

View file

@ -134,14 +134,28 @@ function arg commas).
- Return step-by-step formula string alongside numeric result
- Verify: unit tests against known financial calculation results (textbook examples)
### Task 2.6: Implement unit conversion engine
- Create `engine/src/units.zig`
- Define `UnitCategory` enum and `UnitDef` struct (name, aliases, category, conversion factors)
- Implement conversion tables for all categories: length, mass, temperature, time, digital storage, speed, area, volume, energy, pressure, data rate, angle
- Implement `convert(value: f64, from: UnitDef, to: UnitDef) !f64`
- Temperature special case: formula-based conversion (C/F/K with offsets)
- Implement unit name resolution: given a string, find the matching `UnitDef` (supports aliases)
- Verify: unit tests for all conversion categories, round-trip accuracy, unknown unit error handling
### Task 2.6: Implement unit conversion engine [DONE]
- Created `engine/src/units.zig`
- `UnitCategory` enum (12 categories) with `label()` and `baseUnit()`
- `UnitDef` struct (name, aliases, category, to_base_factor, to_base_offset) with
`toBase()`, `fromBase()`, `isLinear()`
- Conversion tables for all 12 categories: length, mass, temperature, time,
digital storage, speed, area, volume, energy, pressure, data rate, angle
- `convert(value, from_name, to_name) -> ConvertResult` and
`convertUnits(value, from, to) -> f64`
- Temperature is the only affine category (F/K/R need offsets); base is Celsius
- `findUnit`: exact case-sensitive match first, case-insensitive fallback, so
`K`/`B`/`kB` keep their meaning while `KM` and `Celsius` still resolve
- DESIGN CHANGE from the original spec: only the to-base mapping is stored and
the inverse is derived in `fromBase()`, instead of storing a second
factor/offset pair. One source of truth per unit means a table entry cannot
disagree with its own reverse (see design.md 4.1)
- 77 unit tests: known-answer conversions per category, temperature offsets and
the -40 crossover, alias resolution, error paths, plus invariant tests (every
unit round-trips, every within-category pair round-trips, base units are
identity, names unique, no zero factors)
- Verify: `zig build test` passes; CLI spot-checks match reference values
(5280 ft/mi, 1024 MiB/GiB, 14.6959 psi/atm, 1 c = 1079252848.8 km/h)
---
@ -191,19 +205,25 @@ function arg commas).
### Task 4.1: Implement CLI argument parsing [DONE]
- Single binary at `src/main.zig` (not separate cli/)
- Parse args: positional expression (multi-arg joined with spaces), `-p`/`--programmer`, `--help`, `--version`
- Subcommands (`struct`, `cagr`, `tvm`, `convert`) NOT YET IMPLEMENTED
- Subcommand `convert` IMPLEMENTED: `tally convert <value> <from> [to] <to>`,
including the glued form `tally convert 100km to mi` (numeric prefix split from
the unit suffix, with `e`/`E` only treated as an exponent when followed by a
digit or sign so `5eV` parses as 5 eV)
- Subcommands (`struct`, `cagr`, `tvm`) NOT YET IMPLEMENTED
- Verify: unit tests for arg parsing; `--help` prints usage
NOTE: Multi-arg joining implemented (`tally 2 + 2` works same as `tally "2+2"`).
Subcommands deferred until Phase 2 engine work is done.
Remaining subcommands deferred until their engine modules exist.
### Task 4.2: Implement CLI output formatting [DONE]
- Standard mode: print result with comma formatting
- Programmer mode: print multi-base table (dec signed, dec unsigned, hex, bin)
- Convert: `formatConversion` prints `100 km = 62.13711922373339 mi`, resolving
aliases to canonical names
- Struct mode: NOT YET IMPLEMENTED
- Financial mode: NOT YET IMPLEMENTED
- `--json` flag: NOT YET IMPLEMENTED
- Verify: unit tests for evaluate function output
- Verify: unit tests for evaluate and formatConversion output
### Task 4.3: CLI integration and error handling [DONE]
- Wire arg parser -> engine calls -> output formatter
@ -334,20 +354,61 @@ Subcommands deferred until Phase 2 engine work is done.
- Result display with formula breakdown
- Verify: can input values, compute results, see formula steps
### Task 5.7: Implement unit conversion TUI view
- Create `tui/src/views/convert.zig`
- Category selector (navigable list or hotkeys)
- From-value input field, from-unit selector
- To-unit selector, live-updating result
- Quick swap (from ↔ to) key
- Show conversion factor used
- Verify: can select category, units, enter value, see live result update
### Task 5.7: Implement unit conversion TUI view [DONE]
- Created `src/tui/convert.zig`; convert is now a third top-level mode and Tab
cycles Standard -> Programmer -> Convert
- Category chips wrap to the terminal width, selected chip highlighted
- From/To unit columns list every unit in the category with the selection marked
- Live result plus a conversion-factor line (or an "affine conversion" note for
temperature, where no single factor applies)
- Value comes from the shared input line; a bare number is used directly and
anything else is evaluated as a standard expression first (so `2*3.5` works)
- Backtick toggles input/selection zone (same convention as programmer mode);
arrows navigate columns and selections with wrapping; Ctrl-S swaps units
- Changing category resets the unit pair to that category's defaults
- Graceful degradation: if the terminal is too short, shows "... N more"
- Fully mouse-clickable (see Task 5.9)
- Verify: category switching filters units, selection updates the live result,
swap works, conversions match the CLI
### Task 5.8: TUI polish and keybindings
- Implement `?` for help overlay showing all keybindings
- Implement vi-style input mode (optional: Esc for normal mode, i for insert) - configurable
- Mouse support: click on bit grid to toggle, click on tabs to switch modes
- Color theme (sensible defaults, respects terminal capabilities)
### Task 5.9: TUI mouse support [DONE]
- Requirement documented as FR-7.11 (mouse is first-class, with keyboard parity
still required by FR-7.7); architecture in design.md 8.0
- `RegionSet` in `src/tui.zig`: fixed 512-entry table of `{row, col, len, action}`
rebuilt every frame during drawing, so hit targets cannot drift out of sync
with what is displayed. Newest-registered region wins, letting views layer a
row-wide fallback under finer per-digit targets.
- `Action` union covers mode tabs, programmer field focus (with optional bit
cursor), bit toggling, input focus, help dismissal, float overlay + format,
width/endian/signedness toggles, and all convert-mode selections
- Only left button press is acted on, so one click fires exactly one action
- Wired into every view: mode tabs, standard mode input, programmer mode (bit
grid, HEX/OCT digit cursor placement, BIN bit flipping, DEC row focus,
Bits/Signed/Endian labels), float view (bits, format/exit hints), convert mode
(category chips, both unit columns), and the help overlay
- Click semantics follow the data: a bit grid cell and a BIN digit are one bit so
clicking flips them; a HEX nibble or OCT digit spans several bits with no single
meaningful toggle, so clicking places the cursor for typing
- Extracted shared state helpers (`setMode`, `cycleBitWidth`, `toggleEndian`,
`toggleSignedness`, `toggleFloatFormat`, `setConvCategory`, `swapConvUnits`) so
keyboard and mouse paths cannot diverge
- `build.zig`: added a `tui_tests` target rooted at `src/tui.zig`. The CLI test
target roots at `src/main.zig`, which only reaches `tui.zig` from `main()`, and
`main()` is never analyzed in test mode, so TUI tests were being silently
skipped
- 12 unit tests for the pure logic: region hit/miss boundaries, newest-wins
layering, clear, zero-length rejection, budget exhaustion, action payloads,
`wrapIndex` wrapping/clamping, and `defaultUnitIndices` invariants
- Verify: `zig build test` runs the TUI tests (366 total); interactive clicking
needs a manual pass since the overlay is terminal-only
### Task 5.8: TUI polish and keybindings [PARTIAL]
- DONE: `?` help overlay showing keybindings, mouse actions, functions, operators
- DONE: mouse support (see Task 5.9): clicking tabs, bits, digits, config labels,
convert categories/units, and the input line
- DONE: Molokai color theme
- NOT DONE: vi-style input mode (optional: Esc for normal mode, i for insert)
- NOT DONE: mouse wheel scrolling for history
- Verify: help overlay works, mouse interactions work, looks reasonable in 80x24 terminal
---

View file

@ -79,9 +79,27 @@ pub fn build(b: *std.Build) void {
const run_engine_tests = b.addRunArtifact(engine_tests);
const run_cli_tests = b.addRunArtifact(cli_tests);
// TUI tests need their own root: src/main.zig only reaches tui.zig from
// main(), which is never analyzed in test mode, so its tests would be
// silently skipped if we relied on the CLI test target.
const tui_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/tui.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "engine", .module = engine_mod },
.{ .name = "vaxis", .module = vaxis_dep.module("vaxis") },
},
}),
});
const run_tui_tests = b.addRunArtifact(tui_tests);
const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_engine_tests.step);
test_step.dependOn(&run_cli_tests.step);
test_step.dependOn(&run_tui_tests.step);
// -- Coverage step (uses kcov, Linux x86_64/aarch64 only) --
{

View file

@ -12,6 +12,7 @@ pub const evaluator = @import("evaluator.zig");
pub const programmer = @import("programmer.zig");
pub const formatter = @import("formatter.zig");
pub const float_interp = @import("float_interp.zig");
pub const units = @import("units.zig");
// Re-export primary types for convenience
pub const Value = types.Value;
@ -31,6 +32,13 @@ pub const FloatFormat = float_interp.FloatFormat;
pub const FloatClass = float_interp.FloatClass;
pub const FloatInfo = float_interp.FloatInfo;
// Unit conversion
pub const UnitCategory = units.UnitCategory;
pub const UnitDef = units.UnitDef;
pub const ConvertResult = units.ConvertResult;
pub const convert = units.convert;
pub const findUnit = units.findUnit;
test {
std.testing.refAllDecls(@This());
}

344
engine/src/float_interp.zig Normal file
View file

@ -0,0 +1,344 @@
//! IEEE 754 float interpretation for Tally programmer mode.
//!
//! Reinterprets a raw bit pattern as an IEEE 754 binary float (binary32 or
//! binary64), decomposing it into sign / exponent / significand, classifying
//! it (normal, denormal, zero, infinity, quiet/signaling NaN), and reporting
//! the value and the ULP (the size of one step in the last place). Also goes
//! the other way: given a decimal value, find the nearest representable bit
//! pattern and report whether rounding occurred.
//!
//! No allocation, no I/O. Everything is computed from the bit pattern.
const std = @import("std");
/// Supported IEEE 754 binary interchange formats.
pub const FloatFormat = enum {
f32,
f64,
/// Total width in bits (1 sign + exponent + significand).
pub fn totalBits(self: FloatFormat) u8 {
return switch (self) {
.f32 => 32,
.f64 => 64,
};
}
/// Number of exponent bits.
pub fn exponentBits(self: FloatFormat) u8 {
return switch (self) {
.f32 => 8,
.f64 => 11,
};
}
/// Number of significand (fraction) bits, excluding the implicit leading bit.
pub fn significandBits(self: FloatFormat) u8 {
return switch (self) {
.f32 => 23,
.f64 => 52,
};
}
/// Exponent bias.
pub fn bias(self: FloatFormat) i32 {
return switch (self) {
.f32 => 127,
.f64 => 1023,
};
}
};
/// IEEE 754 value classification.
pub const FloatClass = enum {
zero,
denormal,
normal,
infinity,
quiet_nan,
signaling_nan,
/// Human-readable label.
pub fn label(self: FloatClass) []const u8 {
return switch (self) {
.zero => "zero",
.denormal => "denormal (subnormal)",
.normal => "normal",
.infinity => "infinity",
.quiet_nan => "quiet NaN",
.signaling_nan => "signaling NaN",
};
}
};
/// Decomposition of a bit pattern interpreted as an IEEE 754 float.
pub const FloatInfo = struct {
format: FloatFormat,
/// Raw bit pattern, masked to the format width.
bits: u64,
/// Sign bit: 0 = positive, 1 = negative.
sign: u1,
/// Raw (biased) exponent field.
raw_exponent: u32,
/// Effective exponent used in the value formula. For normal numbers this is
/// raw_exponent - bias; for zero/denormal it is 1 - bias. Not meaningful for
/// infinity/NaN (set to raw_exponent - bias for reference).
unbiased_exponent: i32,
/// Raw significand (fraction) field, excluding the implicit leading bit.
significand: u64,
class: FloatClass,
/// The value as an f64 (exact for f32 inputs; identity for f64 inputs).
value: f64,
/// Size of one step in the last place at this value. NaN for inf/NaN inputs.
ulp: f64,
};
/// Reinterpret the low bits of `bits` as a value of the given float format.
pub fn reinterpretValue(format: FloatFormat, bits: u64) f64 {
return switch (format) {
.f32 => @floatCast(@as(f32, @bitCast(@as(u32, @truncate(bits))))),
.f64 => @bitCast(bits),
};
}
/// Decompose a raw bit pattern into its IEEE 754 fields and derived info.
/// Bits beyond the format width are ignored (masked off).
pub fn decompose(format: FloatFormat, raw_bits: u64) FloatInfo {
const total = format.totalBits();
const exp_bits = format.exponentBits();
const sig_bits = format.significandBits();
const bias = format.bias();
const width_mask: u64 = if (total >= 64) ~@as(u64, 0) else (@as(u64, 1) << @intCast(total)) - 1;
const bits = raw_bits & width_mask;
const sign: u1 = @intCast((bits >> @intCast(total - 1)) & 1);
const exp_field_mask: u64 = (@as(u64, 1) << @intCast(exp_bits)) - 1;
const raw_exp: u32 = @intCast((bits >> @intCast(sig_bits)) & exp_field_mask);
const sig_field_mask: u64 = (@as(u64, 1) << @intCast(sig_bits)) - 1;
const significand: u64 = bits & sig_field_mask;
const all_ones_exp: u32 = @intCast(exp_field_mask);
// SAFETY: class is assigned in every branch of the if/else below before use.
var class: FloatClass = undefined;
var unbiased: i32 = @as(i32, @intCast(raw_exp)) - bias;
if (raw_exp == 0) {
class = if (significand == 0) .zero else .denormal;
// Denormals (and zero) use a fixed exponent of 1 - bias.
unbiased = 1 - bias;
} else if (raw_exp == all_ones_exp) {
if (significand == 0) {
class = .infinity;
} else {
// The most significant significand bit is the quiet/signaling flag.
const quiet_bit = (significand >> @intCast(sig_bits - 1)) & 1;
class = if (quiet_bit == 1) .quiet_nan else .signaling_nan;
}
} else {
class = .normal;
}
return .{
.format = format,
.bits = bits,
.sign = sign,
.raw_exponent = raw_exp,
.unbiased_exponent = unbiased,
.significand = significand,
.class = class,
.value = reinterpretValue(format, bits),
.ulp = computeUlp(format, class, unbiased),
};
}
/// Result of finding the nearest representable bit pattern for a value.
pub const NearestResult = struct {
bits: u64,
/// True if the value could not be represented exactly in the format and was
/// rounded to the nearest representable value.
rounded: bool,
};
/// Find the bit pattern of the nearest representable value in the given format.
/// Reports whether rounding occurred (only detectable for f32; an f64 input is
/// already an f64 so no further rounding happens here).
pub fn nearestBits(format: FloatFormat, value: f64) NearestResult {
return switch (format) {
.f32 => blk: {
const as_f32: f32 = @floatCast(value);
const back: f64 = @floatCast(as_f32);
const raw32: u32 = @bitCast(as_f32);
// NaN never equals itself, so treat NaN->NaN as not rounded.
const both_nan = std.math.isNan(value) and std.math.isNan(back);
break :blk .{ .bits = raw32, .rounded = (back != value) and !both_nan };
},
.f64 => .{ .bits = @bitCast(value), .rounded = false },
};
}
fn computeUlp(format: FloatFormat, class: FloatClass, unbiased_exponent: i32) f64 {
const sig_bits: i32 = @intCast(format.significandBits());
return switch (class) {
.normal => std.math.ldexp(@as(f64, 1.0), unbiased_exponent - sig_bits),
// Every subnormal (and zero) steps by the smallest positive subnormal.
.zero, .denormal => std.math.ldexp(@as(f64, 1.0), (1 - format.bias()) - sig_bits),
.infinity, .quiet_nan, .signaling_nan => std.math.nan(f64),
};
}
// -- Tests --
const testing = std.testing;
test "f32: 1.0 is 0x3F800000, normal" {
const info = decompose(.f32, 0x3F800000);
try testing.expectEqual(FloatClass.normal, info.class);
try testing.expectEqual(@as(u1, 0), info.sign);
try testing.expectEqual(@as(u32, 127), info.raw_exponent);
try testing.expectEqual(@as(i32, 0), info.unbiased_exponent);
try testing.expectEqual(@as(u64, 0), info.significand);
try testing.expectEqual(@as(f64, 1.0), info.value);
// ULP of 1.0 in f32 is 2^-23
try testing.expectApproxEqAbs(std.math.ldexp(@as(f64, 1.0), -23), info.ulp, 0);
}
test "f32: -2.0 sign and exponent" {
// -2.0 = 0xC0000000: sign 1, exp 128 (unbiased 1), frac 0
const info = decompose(.f32, 0xC0000000);
try testing.expectEqual(@as(u1, 1), info.sign);
try testing.expectEqual(@as(i32, 1), info.unbiased_exponent);
try testing.expectEqual(@as(f64, -2.0), info.value);
try testing.expectEqual(FloatClass.normal, info.class);
}
test "f32: positive zero" {
const info = decompose(.f32, 0x00000000);
try testing.expectEqual(FloatClass.zero, info.class);
try testing.expectEqual(@as(f64, 0.0), info.value);
try testing.expectEqual(@as(u1, 0), info.sign);
}
test "f32: negative zero" {
const info = decompose(.f32, 0x80000000);
try testing.expectEqual(FloatClass.zero, info.class);
try testing.expectEqual(@as(u1, 1), info.sign);
}
test "f32: positive infinity" {
const info = decompose(.f32, 0x7F800000);
try testing.expectEqual(FloatClass.infinity, info.class);
try testing.expectEqual(@as(u1, 0), info.sign);
try testing.expect(std.math.isPositiveInf(info.value));
try testing.expect(std.math.isNan(info.ulp));
}
test "f32: negative infinity" {
const info = decompose(.f32, 0xFF800000);
try testing.expectEqual(FloatClass.infinity, info.class);
try testing.expectEqual(@as(u1, 1), info.sign);
try testing.expect(std.math.isNegativeInf(info.value));
}
test "f32: quiet NaN" {
// exponent all ones, top significand bit set
const info = decompose(.f32, 0x7FC00000);
try testing.expectEqual(FloatClass.quiet_nan, info.class);
try testing.expect(std.math.isNan(info.value));
}
test "f32: signaling NaN" {
// exponent all ones, top significand bit clear, some other bit set
const info = decompose(.f32, 0x7F800001);
try testing.expectEqual(FloatClass.signaling_nan, info.class);
try testing.expect(std.math.isNan(info.value));
}
test "f32: smallest positive denormal" {
// 0x00000001 = 2^-149
const info = decompose(.f32, 0x00000001);
try testing.expectEqual(FloatClass.denormal, info.class);
try testing.expectEqual(@as(u64, 1), info.significand);
try testing.expectApproxEqAbs(std.math.ldexp(@as(f64, 1.0), -149), info.value, 0);
// ULP for subnormals is the smallest positive subnormal itself
try testing.expectApproxEqAbs(std.math.ldexp(@as(f64, 1.0), -149), info.ulp, 0);
}
test "f32: largest denormal is just below smallest normal" {
const info = decompose(.f32, 0x007FFFFF);
try testing.expectEqual(FloatClass.denormal, info.class);
}
test "f64: 1.0 is 0x3FF0000000000000, normal" {
const info = decompose(.f64, 0x3FF0000000000000);
try testing.expectEqual(FloatClass.normal, info.class);
try testing.expectEqual(@as(u32, 1023), info.raw_exponent);
try testing.expectEqual(@as(i32, 0), info.unbiased_exponent);
try testing.expectEqual(@as(f64, 1.0), info.value);
try testing.expectApproxEqAbs(std.math.ldexp(@as(f64, 1.0), -52), info.ulp, 0);
}
test "f64: pi bit pattern" {
const info = decompose(.f64, 0x400921FB54442D18);
try testing.expectEqual(FloatClass.normal, info.class);
try testing.expectApproxEqAbs(std.math.pi, info.value, 1e-15);
}
test "f64: infinity and NaN" {
try testing.expectEqual(FloatClass.infinity, decompose(.f64, 0x7FF0000000000000).class);
try testing.expectEqual(FloatClass.quiet_nan, decompose(.f64, 0x7FF8000000000000).class);
try testing.expectEqual(FloatClass.signaling_nan, decompose(.f64, 0x7FF0000000000001).class);
}
test "decompose ignores bits above format width" {
// High garbage bits should be masked off for f32.
const info = decompose(.f32, 0xFFFF_FFFF_3F80_0000);
try testing.expectEqual(@as(f64, 1.0), info.value);
try testing.expectEqual(@as(u64, 0x3F800000), info.bits);
}
test "nearestBits: 1.0 exact in f32" {
const r = nearestBits(.f32, 1.0);
try testing.expectEqual(@as(u64, 0x3F800000), r.bits);
try testing.expect(!r.rounded);
}
test "nearestBits: 3.14 rounds in f32" {
const r = nearestBits(.f32, 3.14);
try testing.expect(r.rounded);
// Round-trips through decompose to the nearest f32 to 3.14
const info = decompose(.f32, r.bits);
try testing.expectApproxEqAbs(@as(f64, 3.14), info.value, 1e-6);
}
test "nearestBits: 0.5 exact in f32" {
const r = nearestBits(.f32, 0.5);
try testing.expect(!r.rounded);
try testing.expectEqual(@as(u64, 0x3F000000), r.bits);
}
test "nearestBits: f64 input never reports rounding" {
const r = nearestBits(.f64, 3.14);
try testing.expect(!r.rounded);
try testing.expectEqual(@as(u64, @bitCast(@as(f64, 3.14))), r.bits);
}
test "nearestBits: value too large for f32 overflows to infinity" {
const r = nearestBits(.f32, 1e40);
try testing.expect(r.rounded);
try testing.expectEqual(FloatClass.infinity, decompose(.f32, r.bits).class);
}
test "nearestBits: NaN is not reported as rounded" {
const r = nearestBits(.f32, std.math.nan(f64));
try testing.expect(!r.rounded);
try testing.expectEqual(FloatClass.quiet_nan, decompose(.f32, r.bits).class);
}
test "round trip: decompose then nearestBits for a range of f32 patterns" {
const patterns = [_]u32{ 0x3F800000, 0xC0490FDB, 0x00000001, 0x7F7FFFFF, 0x80000000 };
for (patterns) |p| {
const info = decompose(.f32, p);
const r = nearestBits(.f32, info.value);
try testing.expectEqual(@as(u64, p), r.bits);
}
}

777
engine/src/units.zig Normal file
View file

@ -0,0 +1,777 @@
//! Unit conversion engine for Tally.
//!
//! Units are grouped into categories, each with a canonical base unit. Every
//! unit declares an affine mapping to its category's base unit:
//!
//! base = value * to_base_factor + to_base_offset
//!
//! and the reverse direction is derived by inverting that mapping:
//!
//! value = (base - to_base_offset) / to_base_factor
//!
//! Deriving the inverse (rather than storing a second factor/offset pair) keeps
//! a single source of truth per unit, so a table entry cannot describe a
//! conversion that disagrees with its own reverse.
//!
//! Most units have a zero offset and so convert by pure scaling. Temperature is
//! the exception: Fahrenheit, Kelvin, and Rankine all need the offset term.
//!
//! No allocation, no I/O. Adding a unit means adding a table entry.
const std = @import("std");
const types = @import("types.zig");
const CalcError = types.CalcError;
pub const UnitCategory = enum {
length,
mass,
temperature,
time,
digital_storage,
speed,
area,
volume,
energy,
pressure,
data_rate,
angle,
/// Human-readable category name for menus and help text.
pub fn label(self: UnitCategory) []const u8 {
return switch (self) {
.length => "Length",
.mass => "Mass",
.temperature => "Temperature",
.time => "Time",
.digital_storage => "Digital Storage",
.speed => "Speed",
.area => "Area",
.volume => "Volume",
.energy => "Energy",
.pressure => "Pressure",
.data_rate => "Data Rate",
.angle => "Angle",
};
}
/// The canonical base unit name for this category.
pub fn baseUnit(self: UnitCategory) []const u8 {
return switch (self) {
.length => "m",
.mass => "kg",
.temperature => "C",
.time => "s",
.digital_storage => "B",
.speed => "m/s",
.area => "m2",
.volume => "L",
.energy => "J",
.pressure => "Pa",
.data_rate => "bps",
.angle => "rad",
};
}
};
pub const UnitDef = struct {
/// Canonical short name (e.g. "km").
name: []const u8,
/// Alternative spellings, including long forms and plurals.
aliases: []const []const u8 = &.{},
category: UnitCategory,
/// base = value * to_base_factor + to_base_offset
to_base_factor: f64,
to_base_offset: f64 = 0,
/// Convert a value in this unit to the category's base unit.
pub fn toBase(self: UnitDef, value: f64) f64 {
return value * self.to_base_factor + self.to_base_offset;
}
/// Convert a value in the category's base unit to this unit.
pub fn fromBase(self: UnitDef, base: f64) f64 {
return (base - self.to_base_offset) / self.to_base_factor;
}
/// True if this unit maps to its base by pure scaling (no offset).
pub fn isLinear(self: UnitDef) bool {
return self.to_base_offset == 0;
}
};
/// Result of a conversion, including metadata for display.
pub const ConvertResult = struct {
value: f64,
from: UnitDef,
to: UnitDef,
category: UnitCategory,
/// The single multiplicative factor relating the two units, when the
/// conversion is purely linear. Null for affine conversions (temperature),
/// where no single factor describes the relationship.
factor: ?f64,
};
// -- Unit tables --
//
// Each table's base unit has to_base_factor = 1 and no offset.
const length_units = [_]UnitDef{
.{ .name = "nm", .aliases = &.{ "nanometer", "nanometers", "nanometre", "nanometres" }, .category = .length, .to_base_factor = 1e-9 },
.{ .name = "um", .aliases = &.{ "micrometer", "micrometers", "micron", "microns" }, .category = .length, .to_base_factor = 1e-6 },
.{ .name = "mm", .aliases = &.{ "millimeter", "millimeters", "millimetre", "millimetres" }, .category = .length, .to_base_factor = 0.001 },
.{ .name = "cm", .aliases = &.{ "centimeter", "centimeters", "centimetre", "centimetres" }, .category = .length, .to_base_factor = 0.01 },
.{ .name = "m", .aliases = &.{ "meter", "meters", "metre", "metres" }, .category = .length, .to_base_factor = 1.0 },
.{ .name = "km", .aliases = &.{ "kilometer", "kilometers", "kilometre", "kilometres" }, .category = .length, .to_base_factor = 1000.0 },
.{ .name = "in", .aliases = &.{ "inch", "inches" }, .category = .length, .to_base_factor = 0.0254 },
.{ .name = "ft", .aliases = &.{ "foot", "feet" }, .category = .length, .to_base_factor = 0.3048 },
.{ .name = "yd", .aliases = &.{ "yard", "yards" }, .category = .length, .to_base_factor = 0.9144 },
.{ .name = "mi", .aliases = &.{ "mile", "miles" }, .category = .length, .to_base_factor = 1609.344 },
.{ .name = "nmi", .aliases = &.{ "nauticalmile", "nauticalmiles" }, .category = .length, .to_base_factor = 1852.0 },
.{ .name = "ly", .aliases = &.{ "lightyear", "lightyears" }, .category = .length, .to_base_factor = 9.4607304725808e15 },
.{ .name = "au", .aliases = &.{ "astronomicalunit", "astronomicalunits" }, .category = .length, .to_base_factor = 1.495978707e11 },
.{ .name = "pc", .aliases = &.{ "parsec", "parsecs" }, .category = .length, .to_base_factor = 3.0856775814913673e16 },
};
const mass_units = [_]UnitDef{
.{ .name = "mg", .aliases = &.{ "milligram", "milligrams" }, .category = .mass, .to_base_factor = 1e-6 },
.{ .name = "g", .aliases = &.{ "gram", "grams" }, .category = .mass, .to_base_factor = 0.001 },
.{ .name = "kg", .aliases = &.{ "kilogram", "kilograms" }, .category = .mass, .to_base_factor = 1.0 },
.{ .name = "t", .aliases = &.{ "tonne", "tonnes", "metricton", "metrictons" }, .category = .mass, .to_base_factor = 1000.0 },
.{ .name = "oz", .aliases = &.{ "ounce", "ounces" }, .category = .mass, .to_base_factor = 0.028349523125 },
.{ .name = "lb", .aliases = &.{ "lbs", "pound", "pounds" }, .category = .mass, .to_base_factor = 0.45359237 },
.{ .name = "st", .aliases = &.{ "stone", "stones" }, .category = .mass, .to_base_factor = 6.35029318 },
.{ .name = "ton", .aliases = &.{ "shortton", "shorttons" }, .category = .mass, .to_base_factor = 907.18474 },
.{ .name = "lt", .aliases = &.{ "longton", "longtons" }, .category = .mass, .to_base_factor = 1016.0469088 },
};
// Base: Celsius. These are the only units in the engine that need an offset.
const temperature_units = [_]UnitDef{
.{ .name = "C", .aliases = &.{ "celsius", "centigrade" }, .category = .temperature, .to_base_factor = 1.0 },
.{ .name = "F", .aliases = &.{"fahrenheit"}, .category = .temperature, .to_base_factor = 5.0 / 9.0, .to_base_offset = -160.0 / 9.0 },
.{ .name = "K", .aliases = &.{"kelvin"}, .category = .temperature, .to_base_factor = 1.0, .to_base_offset = -273.15 },
.{ .name = "R", .aliases = &.{ "rankine", "Ra" }, .category = .temperature, .to_base_factor = 5.0 / 9.0, .to_base_offset = -273.15 },
};
const time_units = [_]UnitDef{
.{ .name = "ns", .aliases = &.{ "nanosecond", "nanoseconds" }, .category = .time, .to_base_factor = 1e-9 },
.{ .name = "us", .aliases = &.{ "microsecond", "microseconds" }, .category = .time, .to_base_factor = 1e-6 },
.{ .name = "ms", .aliases = &.{ "millisecond", "milliseconds" }, .category = .time, .to_base_factor = 0.001 },
.{ .name = "s", .aliases = &.{ "sec", "secs", "second", "seconds" }, .category = .time, .to_base_factor = 1.0 },
.{ .name = "min", .aliases = &.{ "minute", "minutes" }, .category = .time, .to_base_factor = 60.0 },
.{ .name = "h", .aliases = &.{ "hr", "hrs", "hour", "hours" }, .category = .time, .to_base_factor = 3600.0 },
.{ .name = "d", .aliases = &.{ "day", "days" }, .category = .time, .to_base_factor = 86400.0 },
.{ .name = "wk", .aliases = &.{ "week", "weeks" }, .category = .time, .to_base_factor = 604800.0 },
// Julian year, the usual convention for "a year" in unit conversion.
.{ .name = "yr", .aliases = &.{ "year", "years" }, .category = .time, .to_base_factor = 31557600.0 },
};
// Base: byte. Decimal (kB) and binary (KiB) prefixes are both provided.
const digital_storage_units = [_]UnitDef{
.{ .name = "bit", .aliases = &.{"bits"}, .category = .digital_storage, .to_base_factor = 0.125 },
.{ .name = "B", .aliases = &.{ "byte", "bytes" }, .category = .digital_storage, .to_base_factor = 1.0 },
.{ .name = "kB", .aliases = &.{ "kilobyte", "kilobytes" }, .category = .digital_storage, .to_base_factor = 1e3 },
.{ .name = "MB", .aliases = &.{ "megabyte", "megabytes" }, .category = .digital_storage, .to_base_factor = 1e6 },
.{ .name = "GB", .aliases = &.{ "gigabyte", "gigabytes" }, .category = .digital_storage, .to_base_factor = 1e9 },
.{ .name = "TB", .aliases = &.{ "terabyte", "terabytes" }, .category = .digital_storage, .to_base_factor = 1e12 },
.{ .name = "PB", .aliases = &.{ "petabyte", "petabytes" }, .category = .digital_storage, .to_base_factor = 1e15 },
.{ .name = "KiB", .aliases = &.{ "kibibyte", "kibibytes" }, .category = .digital_storage, .to_base_factor = 1024.0 },
.{ .name = "MiB", .aliases = &.{ "mebibyte", "mebibytes" }, .category = .digital_storage, .to_base_factor = 1048576.0 },
.{ .name = "GiB", .aliases = &.{ "gibibyte", "gibibytes" }, .category = .digital_storage, .to_base_factor = 1073741824.0 },
.{ .name = "TiB", .aliases = &.{ "tebibyte", "tebibytes" }, .category = .digital_storage, .to_base_factor = 1099511627776.0 },
.{ .name = "PiB", .aliases = &.{ "pebibyte", "pebibytes" }, .category = .digital_storage, .to_base_factor = 1125899906842624.0 },
};
const speed_units = [_]UnitDef{
.{ .name = "m/s", .aliases = &.{ "mps", "meterpersecond", "meterspersecond" }, .category = .speed, .to_base_factor = 1.0 },
.{ .name = "km/h", .aliases = &.{ "kph", "kmh", "kilometerperhour", "kilometersperhour" }, .category = .speed, .to_base_factor = 1.0 / 3.6 },
.{ .name = "mph", .aliases = &.{ "mileperhour", "milesperhour" }, .category = .speed, .to_base_factor = 0.44704 },
.{ .name = "ft/s", .aliases = &.{ "fps", "footpersecond", "feetpersecond" }, .category = .speed, .to_base_factor = 0.3048 },
.{ .name = "kn", .aliases = &.{ "knot", "knots" }, .category = .speed, .to_base_factor = 1852.0 / 3600.0 },
.{ .name = "c", .aliases = &.{ "lightspeed", "speedoflight" }, .category = .speed, .to_base_factor = 299792458.0 },
};
const area_units = [_]UnitDef{
.{ .name = "mm2", .aliases = &.{ "mm^2", "squaremillimeter", "squaremillimeters" }, .category = .area, .to_base_factor = 1e-6 },
.{ .name = "cm2", .aliases = &.{ "cm^2", "squarecentimeter", "squarecentimeters" }, .category = .area, .to_base_factor = 1e-4 },
.{ .name = "m2", .aliases = &.{ "m^2", "squaremeter", "squaremeters" }, .category = .area, .to_base_factor = 1.0 },
.{ .name = "km2", .aliases = &.{ "km^2", "squarekilometer", "squarekilometers" }, .category = .area, .to_base_factor = 1e6 },
.{ .name = "in2", .aliases = &.{ "in^2", "squareinch", "squareinches" }, .category = .area, .to_base_factor = 0.00064516 },
.{ .name = "ft2", .aliases = &.{ "ft^2", "squarefoot", "squarefeet" }, .category = .area, .to_base_factor = 0.09290304 },
.{ .name = "yd2", .aliases = &.{ "yd^2", "squareyard", "squareyards" }, .category = .area, .to_base_factor = 0.83612736 },
.{ .name = "mi2", .aliases = &.{ "mi^2", "squaremile", "squaremiles" }, .category = .area, .to_base_factor = 2589988.110336 },
.{ .name = "ha", .aliases = &.{ "hectare", "hectares" }, .category = .area, .to_base_factor = 10000.0 },
.{ .name = "acre", .aliases = &.{"acres"}, .category = .area, .to_base_factor = 4046.8564224 },
};
// Base: liter.
const volume_units = [_]UnitDef{
.{ .name = "mL", .aliases = &.{ "ml", "milliliter", "milliliters" }, .category = .volume, .to_base_factor = 0.001 },
.{ .name = "L", .aliases = &.{ "l", "liter", "liters", "litre", "litres" }, .category = .volume, .to_base_factor = 1.0 },
.{ .name = "m3", .aliases = &.{ "m^3", "cubicmeter", "cubicmeters" }, .category = .volume, .to_base_factor = 1000.0 },
.{ .name = "cm3", .aliases = &.{ "cm^3", "cc", "cubiccentimeter", "cubiccentimeters" }, .category = .volume, .to_base_factor = 0.001 },
.{ .name = "in3", .aliases = &.{ "in^3", "cubicinch", "cubicinches" }, .category = .volume, .to_base_factor = 0.016387064 },
.{ .name = "ft3", .aliases = &.{ "ft^3", "cubicfoot", "cubicfeet" }, .category = .volume, .to_base_factor = 28.316846592 },
.{ .name = "gal", .aliases = &.{ "gallon", "gallons" }, .category = .volume, .to_base_factor = 3.785411784 },
.{ .name = "qt", .aliases = &.{ "quart", "quarts" }, .category = .volume, .to_base_factor = 0.946352946 },
.{ .name = "pt", .aliases = &.{ "pint", "pints" }, .category = .volume, .to_base_factor = 0.473176473 },
.{ .name = "cup", .aliases = &.{"cups"}, .category = .volume, .to_base_factor = 0.2365882365 },
.{ .name = "floz", .aliases = &.{ "fluidounce", "fluidounces" }, .category = .volume, .to_base_factor = 0.0295735295625 },
.{ .name = "tbsp", .aliases = &.{ "tablespoon", "tablespoons" }, .category = .volume, .to_base_factor = 0.01478676478125 },
.{ .name = "tsp", .aliases = &.{ "teaspoon", "teaspoons" }, .category = .volume, .to_base_factor = 0.00492892159375 },
.{ .name = "bbl", .aliases = &.{ "barrel", "barrels" }, .category = .volume, .to_base_factor = 158.987294928 },
};
const energy_units = [_]UnitDef{
.{ .name = "J", .aliases = &.{ "joule", "joules" }, .category = .energy, .to_base_factor = 1.0 },
.{ .name = "kJ", .aliases = &.{ "kilojoule", "kilojoules" }, .category = .energy, .to_base_factor = 1000.0 },
.{ .name = "cal", .aliases = &.{ "calorie", "calories" }, .category = .energy, .to_base_factor = 4.184 },
.{ .name = "kcal", .aliases = &.{ "kilocalorie", "kilocalories" }, .category = .energy, .to_base_factor = 4184.0 },
.{ .name = "Wh", .aliases = &.{ "watthour", "watthours" }, .category = .energy, .to_base_factor = 3600.0 },
.{ .name = "kWh", .aliases = &.{ "kilowatthour", "kilowatthours" }, .category = .energy, .to_base_factor = 3600000.0 },
.{ .name = "BTU", .aliases = &.{ "btu", "britishthermalunit" }, .category = .energy, .to_base_factor = 1055.05585262 },
.{ .name = "eV", .aliases = &.{ "electronvolt", "electronvolts" }, .category = .energy, .to_base_factor = 1.602176634e-19 },
.{ .name = "erg", .aliases = &.{"ergs"}, .category = .energy, .to_base_factor = 1e-7 },
};
const pressure_units = [_]UnitDef{
.{ .name = "Pa", .aliases = &.{ "pascal", "pascals" }, .category = .pressure, .to_base_factor = 1.0 },
.{ .name = "kPa", .aliases = &.{ "kilopascal", "kilopascals" }, .category = .pressure, .to_base_factor = 1000.0 },
.{ .name = "MPa", .aliases = &.{ "megapascal", "megapascals" }, .category = .pressure, .to_base_factor = 1e6 },
.{ .name = "bar", .aliases = &.{"bars"}, .category = .pressure, .to_base_factor = 100000.0 },
.{ .name = "mbar", .aliases = &.{ "millibar", "millibars" }, .category = .pressure, .to_base_factor = 100.0 },
.{ .name = "atm", .aliases = &.{ "atmosphere", "atmospheres" }, .category = .pressure, .to_base_factor = 101325.0 },
.{ .name = "psi", .aliases = &.{"poundpersquareinch"}, .category = .pressure, .to_base_factor = 6894.757293168361 },
.{ .name = "torr", .aliases = &.{"torrs"}, .category = .pressure, .to_base_factor = 101325.0 / 760.0 },
.{ .name = "mmHg", .aliases = &.{"mmhg"}, .category = .pressure, .to_base_factor = 133.322387415 },
.{ .name = "inHg", .aliases = &.{"inhg"}, .category = .pressure, .to_base_factor = 3386.388640341 },
};
// Base: bits per second.
const data_rate_units = [_]UnitDef{
.{ .name = "bps", .aliases = &.{ "bitpersecond", "bitspersecond" }, .category = .data_rate, .to_base_factor = 1.0 },
.{ .name = "kbps", .aliases = &.{"kilobitpersecond"}, .category = .data_rate, .to_base_factor = 1e3 },
.{ .name = "Mbps", .aliases = &.{"megabitpersecond"}, .category = .data_rate, .to_base_factor = 1e6 },
.{ .name = "Gbps", .aliases = &.{"gigabitpersecond"}, .category = .data_rate, .to_base_factor = 1e9 },
.{ .name = "Tbps", .aliases = &.{"terabitpersecond"}, .category = .data_rate, .to_base_factor = 1e12 },
.{ .name = "Bps", .aliases = &.{ "bytepersecond", "bytespersecond" }, .category = .data_rate, .to_base_factor = 8.0 },
.{ .name = "kBps", .aliases = &.{"kilobytepersecond"}, .category = .data_rate, .to_base_factor = 8e3 },
.{ .name = "MBps", .aliases = &.{"megabytepersecond"}, .category = .data_rate, .to_base_factor = 8e6 },
.{ .name = "GBps", .aliases = &.{"gigabytepersecond"}, .category = .data_rate, .to_base_factor = 8e9 },
};
const angle_units = [_]UnitDef{
.{ .name = "rad", .aliases = &.{ "radian", "radians" }, .category = .angle, .to_base_factor = 1.0 },
.{ .name = "mrad", .aliases = &.{ "milliradian", "milliradians" }, .category = .angle, .to_base_factor = 0.001 },
.{ .name = "deg", .aliases = &.{ "degree", "degrees" }, .category = .angle, .to_base_factor = std.math.pi / 180.0 },
.{ .name = "grad", .aliases = &.{ "gradian", "gradians", "gon" }, .category = .angle, .to_base_factor = std.math.pi / 200.0 },
.{ .name = "turn", .aliases = &.{ "turns", "rev", "revolution", "revolutions" }, .category = .angle, .to_base_factor = std.math.tau },
.{ .name = "arcmin", .aliases = &.{ "arcminute", "arcminutes" }, .category = .angle, .to_base_factor = std.math.pi / 10800.0 },
.{ .name = "arcsec", .aliases = &.{ "arcsecond", "arcseconds" }, .category = .angle, .to_base_factor = std.math.pi / 648000.0 },
};
/// All unit tables, indexed in the same order as `UnitCategory`.
pub const categories = [_][]const UnitDef{
&length_units,
&mass_units,
&temperature_units,
&time_units,
&digital_storage_units,
&speed_units,
&area_units,
&volume_units,
&energy_units,
&pressure_units,
&data_rate_units,
&angle_units,
};
/// Every unit belonging to a category, in table order.
pub fn unitsIn(category: UnitCategory) []const UnitDef {
return categories[@intFromEnum(category)];
}
/// The total number of categories.
pub const category_count = categories.len;
// -- Resolution --
fn eqlIgnoreCase(a: []const u8, b: []const u8) bool {
return std.ascii.eqlIgnoreCase(a, b);
}
fn matchesExact(unit: UnitDef, name: []const u8) bool {
if (std.mem.eql(u8, unit.name, name)) return true;
for (unit.aliases) |alias| {
if (std.mem.eql(u8, alias, name)) return true;
}
return false;
}
fn matchesIgnoreCase(unit: UnitDef, name: []const u8) bool {
if (eqlIgnoreCase(unit.name, name)) return true;
for (unit.aliases) |alias| {
if (eqlIgnoreCase(alias, name)) return true;
}
return false;
}
/// Look up a unit by canonical name or alias.
///
/// An exact (case-sensitive) match is tried first so that case-distinguished
/// units keep their meaning (e.g. "mB" does not silently become "MB", and "K"
/// stays Kelvin). Only if nothing matches exactly does a case-insensitive pass
/// run, which is what makes forgiving input like "KM" or "Celsius" work.
pub fn findUnit(name: []const u8) ?UnitDef {
if (name.len == 0) return null;
for (categories) |table| {
for (table) |unit| {
if (matchesExact(unit, name)) return unit;
}
}
for (categories) |table| {
for (table) |unit| {
if (matchesIgnoreCase(unit, name)) return unit;
}
}
return null;
}
// -- Conversion --
/// Convert a value between two already-resolved units.
/// Returns IncompatibleUnits if the units are in different categories.
pub fn convertUnits(value: f64, from: UnitDef, to: UnitDef) CalcError!f64 {
if (from.category != to.category) return CalcError.IncompatibleUnits;
if (std.mem.eql(u8, from.name, to.name)) return value;
return to.fromBase(from.toBase(value));
}
/// Convert a value between two units named by string (canonical name or alias).
/// Returns UnknownUnit if either name is unrecognized, or IncompatibleUnits if
/// the units belong to different categories.
pub fn convert(value: f64, from_name: []const u8, to_name: []const u8) CalcError!ConvertResult {
const from = findUnit(from_name) orelse return CalcError.UnknownUnit;
const to = findUnit(to_name) orelse return CalcError.UnknownUnit;
const result = try convertUnits(value, from, to);
// A single scaling factor only describes the relationship when neither
// unit carries an offset (i.e. everything except temperature).
const factor: ?f64 = if (from.isLinear() and to.isLinear())
from.to_base_factor / to.to_base_factor
else
null;
return .{
.value = result,
.from = from,
.to = to,
.category = from.category,
.factor = factor,
};
}
// -- Tests --
const testing = std.testing;
fn expectConvert(expected: f64, value: f64, from: []const u8, to: []const u8, tolerance: f64) !void {
const result = try convert(value, from, to);
try testing.expectApproxEqAbs(expected, result.value, tolerance);
}
test "length: km to mi" {
try expectConvert(62.1371192, 100, "km", "mi", 1e-6);
}
test "length: mi to km" {
try expectConvert(1.609344, 1, "mi", "km", 1e-9);
}
test "length: m to cm and mm" {
try expectConvert(100, 1, "m", "cm", 1e-12);
try expectConvert(1000, 1, "m", "mm", 1e-12);
}
test "length: inches to cm" {
try expectConvert(2.54, 1, "in", "cm", 1e-12);
}
test "length: ft to in" {
try expectConvert(12, 1, "ft", "in", 1e-12);
}
test "length: yd to ft" {
try expectConvert(3, 1, "yd", "ft", 1e-12);
}
test "length: mile is 5280 feet" {
try expectConvert(5280, 1, "mi", "ft", 1e-9);
}
test "length: nautical mile to m" {
try expectConvert(1852, 1, "nmi", "m", 1e-9);
}
test "mass: kg to lb" {
try expectConvert(2.2046226218, 1, "kg", "lb", 1e-9);
}
test "mass: lb to oz" {
try expectConvert(16, 1, "lb", "oz", 1e-12);
}
test "mass: stone to lb" {
try expectConvert(14, 1, "st", "lb", 1e-9);
}
test "mass: g to mg" {
try expectConvert(1000, 1, "g", "mg", 1e-9);
}
test "mass: tonne to kg" {
try expectConvert(1000, 1, "t", "kg", 1e-9);
}
test "mass: short ton to lb" {
try expectConvert(2000, 1, "ton", "lb", 1e-6);
}
test "temperature: C to F freezing and boiling" {
try expectConvert(32, 0, "C", "F", 1e-12);
try expectConvert(212, 100, "C", "F", 1e-12);
}
test "temperature: F to C" {
try expectConvert(0, 32, "F", "C", 1e-12);
try expectConvert(100, 212, "F", "C", 1e-12);
try expectConvert(37, 98.6, "F", "C", 1e-10);
}
test "temperature: the -40 crossover point" {
try expectConvert(-40, -40, "C", "F", 1e-12);
try expectConvert(-40, -40, "F", "C", 1e-12);
}
test "temperature: C to K" {
try expectConvert(273.15, 0, "C", "K", 1e-12);
try expectConvert(373.15, 100, "C", "K", 1e-12);
}
test "temperature: K to C absolute zero" {
try expectConvert(-273.15, 0, "K", "C", 1e-12);
}
test "temperature: F to K" {
try expectConvert(273.15, 32, "F", "K", 1e-10);
}
test "temperature: Rankine" {
// 0 R is absolute zero; 491.67 R is the freezing point of water
try expectConvert(-273.15, 0, "R", "C", 1e-10);
try expectConvert(0, 491.67, "R", "C", 1e-10);
try expectConvert(491.67, 32, "F", "R", 1e-9);
}
test "temperature: conversions report no single factor" {
const result = try convert(100, "C", "F");
try testing.expect(result.factor == null);
}
test "linear conversions report a factor" {
const result = try convert(1, "km", "m");
try testing.expect(result.factor != null);
try testing.expectApproxEqAbs(@as(f64, 1000.0), result.factor.?, 1e-9);
}
test "time: h to min to s" {
try expectConvert(60, 1, "h", "min", 1e-12);
try expectConvert(3600, 1, "h", "s", 1e-12);
}
test "time: day to h" {
try expectConvert(24, 1, "d", "h", 1e-12);
}
test "time: week to days" {
try expectConvert(7, 1, "wk", "d", 1e-12);
}
test "time: ms and ns" {
try expectConvert(1000, 1, "s", "ms", 1e-9);
try expectConvert(1e9, 1, "s", "ns", 1.0);
}
test "digital storage: byte to bits" {
try expectConvert(8, 1, "B", "bit", 1e-12);
}
test "digital storage: KiB vs kB are distinct" {
try expectConvert(1024, 1, "KiB", "B", 1e-9);
try expectConvert(1000, 1, "kB", "B", 1e-9);
}
test "digital storage: GiB to MiB" {
try expectConvert(1024, 1, "GiB", "MiB", 1e-9);
}
test "digital storage: TB to GB" {
try expectConvert(1000, 1, "TB", "GB", 1e-6);
}
test "speed: km/h to m/s" {
try expectConvert(1, 3.6, "km/h", "m/s", 1e-12);
}
test "speed: mph to km/h" {
try expectConvert(1.609344, 1, "mph", "km/h", 1e-9);
}
test "speed: knots to km/h" {
try expectConvert(1.852, 1, "kn", "km/h", 1e-9);
}
test "speed: speed of light in m/s" {
try expectConvert(299792458, 1, "c", "m/s", 1.0);
}
test "area: m2 to cm2" {
try expectConvert(10000, 1, "m2", "cm2", 1e-6);
}
test "area: hectare to m2" {
try expectConvert(10000, 1, "ha", "m2", 1e-6);
}
test "area: acre to ft2" {
try expectConvert(43560, 1, "acre", "ft2", 1e-6);
}
test "area: km2 to ha" {
try expectConvert(100, 1, "km2", "ha", 1e-9);
}
test "volume: L to mL" {
try expectConvert(1000, 1, "L", "mL", 1e-9);
}
test "volume: m3 to L" {
try expectConvert(1000, 1, "m3", "L", 1e-9);
}
test "volume: gallon to quarts and floz" {
try expectConvert(4, 1, "gal", "qt", 1e-9);
try expectConvert(128, 1, "gal", "floz", 1e-9);
}
test "volume: tbsp to tsp" {
try expectConvert(3, 1, "tbsp", "tsp", 1e-9);
}
test "volume: cc equals mL" {
try expectConvert(1, 1, "cc", "mL", 1e-12);
}
test "energy: kcal to cal and J" {
try expectConvert(1000, 1, "kcal", "cal", 1e-9);
try expectConvert(4184, 1, "kcal", "J", 1e-9);
}
test "energy: kWh to J" {
try expectConvert(3600000, 1, "kWh", "J", 1e-6);
}
test "energy: Wh to J" {
try expectConvert(3600, 1, "Wh", "J", 1e-9);
}
test "pressure: atm to Pa and psi" {
try expectConvert(101325, 1, "atm", "Pa", 1e-6);
try expectConvert(14.6959487755, 1, "atm", "psi", 1e-8);
}
test "pressure: bar to kPa" {
try expectConvert(100, 1, "bar", "kPa", 1e-9);
}
test "pressure: atm to torr is 760" {
try expectConvert(760, 1, "atm", "torr", 1e-9);
}
test "data rate: Mbps to bps" {
try expectConvert(1e6, 1, "Mbps", "bps", 1.0);
}
test "data rate: Bps to bps is 8x" {
try expectConvert(8, 1, "Bps", "bps", 1e-12);
}
test "data rate: MBps to Mbps" {
try expectConvert(8, 1, "MBps", "Mbps", 1e-9);
}
test "angle: deg to rad" {
try expectConvert(std.math.pi, 180, "deg", "rad", 1e-12);
}
test "angle: rad to deg" {
try expectConvert(180, std.math.pi, "rad", "deg", 1e-12);
}
test "angle: turn to deg" {
try expectConvert(360, 1, "turn", "deg", 1e-9);
}
test "angle: deg to arcmin and arcsec" {
try expectConvert(60, 1, "deg", "arcmin", 1e-9);
try expectConvert(3600, 1, "deg", "arcsec", 1e-9);
}
test "angle: gradian full turn is 400" {
try expectConvert(400, 1, "turn", "grad", 1e-9);
}
test "alias resolution: long names and plurals" {
try expectConvert(1000, 1, "kilometer", "meters", 1e-9);
try expectConvert(2.54, 1, "inch", "centimeters", 1e-12);
try expectConvert(32, 0, "celsius", "fahrenheit", 1e-12);
}
test "alias resolution is case-insensitive as a fallback" {
try expectConvert(1000, 1, "KM", "M", 1e-9);
try expectConvert(1000, 1, "KiloMeter", "meter", 1e-9);
}
test "exact match wins over case-insensitive match" {
// "K" exactly matches Kelvin, so it must not fall back to "k"-ish units.
const k = findUnit("K").?;
try testing.expectEqual(UnitCategory.temperature, k.category);
try testing.expectEqualStrings("K", k.name);
// Likewise "B" is byte, not "b"
const b = findUnit("B").?;
try testing.expectEqualStrings("B", b.name);
}
test "findUnit returns null for unknown names" {
try testing.expect(findUnit("smoots") == null);
try testing.expect(findUnit("") == null);
}
test "convert: unknown source unit errors" {
try testing.expectError(CalcError.UnknownUnit, convert(1, "bogus", "m"));
}
test "convert: unknown target unit errors" {
try testing.expectError(CalcError.UnknownUnit, convert(1, "m", "bogus"));
}
test "convert: incompatible categories error" {
try testing.expectError(CalcError.IncompatibleUnits, convert(1, "kg", "m"));
try testing.expectError(CalcError.IncompatibleUnits, convert(1, "C", "s"));
}
test "convert: same unit is identity" {
try expectConvert(42.5, 42.5, "m", "m", 0);
try expectConvert(-40, -40, "C", "C", 0);
}
test "convert: result carries metadata" {
const result = try convert(1, "km", "mi");
try testing.expectEqualStrings("km", result.from.name);
try testing.expectEqualStrings("mi", result.to.name);
try testing.expectEqual(UnitCategory.length, result.category);
}
test "convert: negative and zero values" {
try expectConvert(-1000, -1, "km", "m", 1e-9);
try expectConvert(0, 0, "km", "m", 0);
}
test "round trip: every unit converts to its base and back" {
const value: f64 = 7.25;
for (categories) |table| {
for (table) |unit| {
const base = unit.toBase(value);
const back = unit.fromBase(base);
try testing.expectApproxEqRel(value, back, 1e-12);
}
}
}
test "round trip: every unit pair within a category round-trips" {
const value: f64 = 3.5;
for (categories) |table| {
for (table) |a| {
for (table) |b| {
const forward = try convertUnits(value, a, b);
const back = try convertUnits(forward, b, a);
try testing.expectApproxEqRel(value, back, 1e-9);
}
}
}
}
test "every category has a table whose base unit resolves and is unit-factored" {
for (std.enums.values(UnitCategory)) |category| {
const table = unitsIn(category);
try testing.expect(table.len > 0);
const base_name = category.baseUnit();
const base = findUnit(base_name) orelse return error.BaseUnitNotFound;
try testing.expectEqual(category, base.category);
// The base unit must be the identity mapping for its category.
try testing.expectEqual(@as(f64, 1.0), base.to_base_factor);
try testing.expectEqual(@as(f64, 0.0), base.to_base_offset);
}
}
test "every unit belongs to the category of the table it is listed in" {
for (std.enums.values(UnitCategory)) |category| {
for (unitsIn(category)) |unit| {
try testing.expectEqual(category, unit.category);
}
}
}
test "no unit has a zero factor" {
for (categories) |table| {
for (table) |unit| {
try testing.expect(unit.to_base_factor != 0);
}
}
}
test "canonical unit names are unique across all categories" {
for (categories, 0..) |table_a, i| {
for (table_a, 0..) |a, ai| {
for (categories, 0..) |table_b, j| {
for (table_b, 0..) |b, bi| {
if (i == j and ai == bi) continue;
try testing.expect(!std.mem.eql(u8, a.name, b.name));
}
}
}
}
}
test "category labels and base units are non-empty" {
for (std.enums.values(UnitCategory)) |category| {
try testing.expect(category.label().len > 0);
try testing.expect(category.baseUnit().len > 0);
}
}
test "unitsIn matches category_count" {
try testing.expectEqual(@as(usize, category_count), std.enums.values(UnitCategory).len);
}
test "isLinear: only temperature units carry offsets" {
for (std.enums.values(UnitCategory)) |category| {
for (unitsIn(category)) |unit| {
if (category == .temperature) continue;
try testing.expect(unit.isLinear());
}
}
// Celsius is the temperature base, so it is linear; the rest are not.
try testing.expect(findUnit("C").?.isLinear());
try testing.expect(!findUnit("F").?.isLinear());
try testing.expect(!findUnit("K").?.isLinear());
}

View file

@ -15,6 +15,11 @@ pub const ParsedArgs = union(enum) {
text: []const u8,
mode: engine.Mode,
},
conversion: struct {
value: f64,
from: []const u8,
to: []const u8,
},
output: struct {
text: []const u8,
is_error: bool,
@ -26,6 +31,12 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const []const u8) ParsedA
var expr_parts = std.ArrayList([]const u8).empty;
defer expr_parts.deinit(allocator);
// "convert" subcommand: tally convert <value> <from> <to>
// Also accepts the natural form: tally convert <value> <from> to <to>
if (args.len > 0 and std.mem.eql(u8, args[0], "convert")) {
return parseConvertArgs(args[1..]);
}
for (args) |arg| {
if (std.mem.eql(u8, arg, "-p") or std.mem.eql(u8, arg, "--programmer")) {
mode = .programmer;
@ -54,6 +65,85 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const []const u8) ParsedA
return .{ .expression = .{ .text = expression, .mode = mode } };
}
/// Parse the arguments following the `convert` subcommand.
/// Accepts `<value> <from> <to>` and `<value> <from> to <to>`, and also the
/// glued form `<value><from> to <to>` (e.g. "100km to mi").
fn parseConvertArgs(args: []const []const u8) ParsedArgs {
// Drop a literal "to" separator so both call styles work.
var parts: [3][]const u8 = undefined;
var count: usize = 0;
for (args) |arg| {
if (std.mem.eql(u8, arg, "to")) continue;
if (count >= parts.len) {
return .{ .output = .{ .text = convert_usage, .is_error = true } };
}
parts[count] = arg;
count += 1;
}
// Glued form: "100km" "mi" -> split the leading number from the unit.
if (count == 2) {
const split = splitValueAndUnit(parts[0]) orelse {
return .{ .output = .{ .text = convert_usage, .is_error = true } };
};
const value = std.fmt.parseFloat(f64, split.number) catch {
return .{ .output = .{ .text = "error: invalid number\n", .is_error = true } };
};
return .{ .conversion = .{ .value = value, .from = split.unit, .to = parts[1] } };
}
if (count != 3) {
return .{ .output = .{ .text = convert_usage, .is_error = true } };
}
const value = std.fmt.parseFloat(f64, parts[0]) catch {
return .{ .output = .{ .text = "error: invalid number\n", .is_error = true } };
};
return .{ .conversion = .{ .value = value, .from = parts[1], .to = parts[2] } };
}
/// Split a token like "100km" into its numeric prefix and unit suffix.
fn splitValueAndUnit(token: []const u8) ?struct { number: []const u8, unit: []const u8 } {
var i: usize = 0;
while (i < token.len) : (i += 1) {
const c = token[i];
const is_numeric = (c >= '0' and c <= '9') or c == '.' or c == '-' or c == '+' or
c == 'e' or c == 'E';
// Stop at the first character that cannot continue a number. 'e'/'E'
// only continue a number when followed by a digit or sign (exponent),
// otherwise they begin the unit (e.g. the "eV" in "5eV").
if (c == 'e' or c == 'E') {
if (i + 1 >= token.len) break;
const n = token[i + 1];
const is_exponent = (n >= '0' and n <= '9') or n == '-' or n == '+';
if (!is_exponent) break;
continue;
}
if (!is_numeric) break;
}
if (i == 0 or i == token.len) return null;
return .{ .number = token[0..i], .unit = token[i..] };
}
/// Format a unit conversion result.
pub fn formatConversion(buf: []u8, value: f64, from_name: []const u8, to_name: []const u8) CliResult {
const result = engine.units.convert(value, from_name, to_name) catch |err| {
return .{ .output = errorMessage(err), .is_error = true };
};
var in_buf: [64]u8 = undefined;
var out_buf: [64]u8 = undefined;
const in_str = engine.formatter.formatCompactFloat(&in_buf, value);
const out_str = engine.formatter.formatCompactFloat(&out_buf, result.value);
const output = std.fmt.bufPrint(buf, "{s} {s} = {s} {s}", .{
in_str, result.from.name, out_str, result.to.name,
}) catch {
return .{ .output = "error: buffer overflow\n", .is_error = true };
};
return .{ .output = output, .is_error = false };
}
/// Evaluate an expression and format the result as a string.
pub fn evaluate(allocator: std.mem.Allocator, expression: []const u8, mode: engine.Mode, buf: []u8) CliResult {
if (mode == .programmer) {
@ -159,20 +249,41 @@ fn errorMessage(err: engine.CalcError) []const u8 {
engine.CalcError.InvalidExpression => "error: invalid expression\n",
engine.CalcError.DomainError => "error: domain error\n",
engine.CalcError.Overflow => "error: overflow\n",
engine.CalcError.UnknownUnit => "error: unknown unit\n",
engine.CalcError.IncompatibleUnits => "error: incompatible units (different categories)\n",
else => "error: evaluation error\n",
};
}
const convert_usage =
\\usage: tally convert <value> <from-unit> [to] <to-unit>
\\
\\examples:
\\ tally convert 100 km mi
\\ tally convert 100 km to mi
\\ tally convert 100km to mi
\\ tally convert 32 F to C
\\
;
const help_text =
\\tally - a cross-platform calculator
\\
\\Usage: tally [OPTIONS] <expression>
\\ tally convert <value> <from-unit> [to] <to-unit>
\\
\\Options:
\\ -p, --programmer Programmer mode (^ = XOR, ** = power)
\\ -p, --programmer Programmer mode (^ = power, xor = XOR)
\\ -h, --help Show this help
\\ --version Show version
\\
\\Examples:
\\ tally '2 + 3 * 4'
\\ tally -p '0xFF and 0x0F'
\\ tally convert 100 km to mi
\\
\\Run with no arguments to start the interactive TUI.
\\
;
// -- Entry point: thin I/O shell over pure logic --
@ -212,6 +323,14 @@ pub fn main(init: std.process.Init) u8 {
if (!result.is_error) write(io, std.Io.File.stdout(), "\n");
return if (result.is_error) @as(u8, 1) else 0;
},
.conversion => |conv| {
var buf: [4096]u8 = undefined;
const result = formatConversion(&buf, conv.value, conv.from, conv.to);
const file = if (result.is_error) std.Io.File.stderr() else std.Io.File.stdout();
write(io, file, result.output);
if (!result.is_error) write(io, std.Io.File.stdout(), "\n");
return if (result.is_error) @as(u8, 1) else 0;
},
}
}
@ -234,7 +353,7 @@ test "parseArgs: simple expression" {
try testing.expectEqual(engine.Mode.standard, e.mode);
testing.allocator.free(e.text);
},
.output => unreachable,
else => unreachable,
}
}
@ -245,7 +364,7 @@ test "parseArgs: multi-arg expression joins with spaces" {
try testing.expectEqualStrings("2 + 2", e.text);
testing.allocator.free(e.text);
},
.output => unreachable,
else => unreachable,
}
}
@ -257,7 +376,7 @@ test "parseArgs: programmer flag" {
try testing.expectEqualStrings("0xFF", e.text);
testing.allocator.free(e.text);
},
.output => unreachable,
else => unreachable,
}
}
@ -269,7 +388,7 @@ test "parseArgs: --programmer long flag" {
try testing.expectEqualStrings("0xF0 | 0x0F", e.text);
testing.allocator.free(e.text);
},
.output => unreachable,
else => unreachable,
}
}
@ -280,7 +399,7 @@ test "parseArgs: --help" {
try testing.expect(!out.is_error);
try testing.expect(std.mem.startsWith(u8, out.text, "tally"));
},
.expression => unreachable,
else => unreachable,
}
}
@ -291,7 +410,7 @@ test "parseArgs: --version" {
try testing.expect(!out.is_error);
try testing.expectEqualStrings("tally 0.1.0\n", out.text);
},
.expression => unreachable,
else => unreachable,
}
}
@ -302,7 +421,7 @@ test "parseArgs: no expression" {
try testing.expect(out.is_error);
try testing.expectEqualStrings("error: no expression provided\n", out.text);
},
.expression => unreachable,
else => unreachable,
}
}
@ -369,3 +488,141 @@ test "evaluate: unknown variable error" {
try testing.expect(result.is_error);
try testing.expectEqualStrings("error: unknown variable\n", result.output);
}
test "parseArgs: convert subcommand three-arg form" {
const parsed = parseArgs(testing.allocator, &.{ "convert", "100", "km", "mi" });
switch (parsed) {
.conversion => |c| {
try testing.expectEqual(@as(f64, 100), c.value);
try testing.expectEqualStrings("km", c.from);
try testing.expectEqualStrings("mi", c.to);
},
else => return error.ExpectedConversion,
}
}
test "parseArgs: convert subcommand with 'to' separator" {
const parsed = parseArgs(testing.allocator, &.{ "convert", "100", "km", "to", "mi" });
switch (parsed) {
.conversion => |c| {
try testing.expectEqual(@as(f64, 100), c.value);
try testing.expectEqualStrings("km", c.from);
try testing.expectEqualStrings("mi", c.to);
},
else => return error.ExpectedConversion,
}
}
test "parseArgs: convert glued value and unit" {
const parsed = parseArgs(testing.allocator, &.{ "convert", "100km", "to", "mi" });
switch (parsed) {
.conversion => |c| {
try testing.expectEqual(@as(f64, 100), c.value);
try testing.expectEqualStrings("km", c.from);
try testing.expectEqualStrings("mi", c.to);
},
else => return error.ExpectedConversion,
}
}
test "parseArgs: convert glued negative and decimal value" {
const parsed = parseArgs(testing.allocator, &.{ "convert", "-40.5C", "F" });
switch (parsed) {
.conversion => |c| {
try testing.expectApproxEqAbs(@as(f64, -40.5), c.value, 1e-12);
try testing.expectEqualStrings("C", c.from);
try testing.expectEqualStrings("F", c.to);
},
else => return error.ExpectedConversion,
}
}
test "parseArgs: convert with too few args is usage error" {
const parsed = parseArgs(testing.allocator, &.{ "convert", "100" });
switch (parsed) {
.output => |o| try testing.expect(o.is_error),
else => return error.ExpectedOutput,
}
}
test "parseArgs: convert with too many args is usage error" {
const parsed = parseArgs(testing.allocator, &.{ "convert", "1", "m", "ft", "yd" });
switch (parsed) {
.output => |o| try testing.expect(o.is_error),
else => return error.ExpectedOutput,
}
}
test "parseArgs: convert with non-numeric value errors" {
const parsed = parseArgs(testing.allocator, &.{ "convert", "abc", "km", "mi" });
switch (parsed) {
.output => |o| try testing.expect(o.is_error),
else => return error.ExpectedOutput,
}
}
test "splitValueAndUnit: basic" {
const r = splitValueAndUnit("100km").?;
try testing.expectEqualStrings("100", r.number);
try testing.expectEqualStrings("km", r.unit);
}
test "splitValueAndUnit: decimal and negative" {
const a = splitValueAndUnit("-40.5C").?;
try testing.expectEqualStrings("-40.5", a.number);
try testing.expectEqualStrings("C", a.unit);
}
test "splitValueAndUnit: exponent continues the number" {
const r = splitValueAndUnit("1e3m").?;
try testing.expectEqualStrings("1e3", r.number);
try testing.expectEqualStrings("m", r.unit);
}
test "splitValueAndUnit: unit starting with e is not eaten as exponent" {
const r = splitValueAndUnit("5eV").?;
try testing.expectEqualStrings("5", r.number);
try testing.expectEqualStrings("eV", r.unit);
}
test "splitValueAndUnit: rejects pure number or pure unit" {
try testing.expect(splitValueAndUnit("100") == null);
try testing.expect(splitValueAndUnit("km") == null);
}
test "formatConversion: km to mi" {
var buf: [256]u8 = undefined;
const result = formatConversion(&buf, 100, "km", "mi");
try testing.expect(!result.is_error);
try testing.expect(std.mem.indexOf(u8, result.output, "62.137") != null);
try testing.expect(std.mem.indexOf(u8, result.output, "km") != null);
try testing.expect(std.mem.indexOf(u8, result.output, "mi") != null);
}
test "formatConversion: temperature freezing point" {
var buf: [256]u8 = undefined;
const result = formatConversion(&buf, 0, "C", "F");
try testing.expect(!result.is_error);
try testing.expectEqualStrings("0 C = 32 F", result.output);
}
test "formatConversion: unknown unit is an error" {
var buf: [256]u8 = undefined;
const result = formatConversion(&buf, 1, "smoots", "m");
try testing.expect(result.is_error);
try testing.expect(std.mem.indexOf(u8, result.output, "unknown unit") != null);
}
test "formatConversion: incompatible units is an error" {
var buf: [256]u8 = undefined;
const result = formatConversion(&buf, 1, "kg", "m");
try testing.expect(result.is_error);
try testing.expect(std.mem.indexOf(u8, result.output, "incompatible") != null);
}
test "formatConversion: alias resolves to canonical name in output" {
var buf: [256]u8 = undefined;
const result = formatConversion(&buf, 1, "kilometer", "meters");
try testing.expect(!result.is_error);
try testing.expectEqualStrings("1 km = 1000 m", result.output);
}

View file

@ -12,12 +12,104 @@ const engine = @import("engine");
const draw = @import("tui/draw.zig");
const programmer_view = @import("tui/programmer.zig");
const float_view = @import("tui/float_view.zig");
const convert_view = @import("tui/convert.zig");
const help_view = @import("tui/help.zig");
const C = draw.C;
const Allocator = std.mem.Allocator;
const Mode = enum { standard, programmer };
const Mode = enum { standard, programmer, convert };
/// Which column of the convert view has keyboard focus.
pub const ConvZone = enum { category, from, to };
/// A clickable screen region registered during drawing and consulted when a
/// mouse press arrives. Regions are rebuilt every frame, so they always match
/// what is currently on screen.
pub const HitRegion = struct {
row: u16,
col: u16,
len: u16,
action: Action,
fn contains(self: HitRegion, row: u16, col: u16) bool {
return row == self.row and col >= self.col and col < self.col + self.len;
}
};
/// What clicking a region does.
pub const Action = union(enum) {
/// Switch to a top-level mode (the tab bar).
mode: Mode,
/// Focus a programmer-mode field, optionally placing the bit cursor.
prog_field: struct { field: App.ProgField, bit: ?u7 },
/// Toggle a single bit (bit grid and BIN digits).
toggle_bit: u7,
/// Put keyboard focus back on the expression input.
focus_input,
/// Dismiss the help overlay.
close_help,
/// Toggle the IEEE 754 float overlay.
toggle_float,
/// Cycle the programmer bit width.
cycle_width,
/// Toggle display endianness.
toggle_endian,
/// Toggle signed/unsigned interpretation.
toggle_signedness,
/// Toggle the float format (f32 <-> f64) in the float overlay.
toggle_float_format,
/// Select a unit category in convert mode.
conv_category: engine.UnitCategory,
/// Select the source unit (index into the category's unit table).
conv_from: usize,
/// Select the target unit.
conv_to: usize,
/// Swap source and target units.
conv_swap,
};
/// Upper bound on clickable regions in a single frame. The worst case is the
/// 128-bit programmer view (128 grid bits + 128 BIN digits + 32 hex + 43 oct
/// + labels), so this leaves comfortable headroom.
const max_hit_regions = 512;
/// A frame's worth of clickable regions. Rebuilt from scratch each draw, so it
/// always reflects what is currently on screen. Kept as a standalone struct so
/// the hit-testing logic can be tested without constructing a terminal app.
pub const RegionSet = struct {
items: [max_hit_regions]HitRegion,
count: usize,
/// An empty set.
// SAFETY: items is uninitialized because count is 0; no slot is ever read
// before `add` writes it.
pub const empty: RegionSet = .{ .items = undefined, .count = 0 };
pub fn clear(self: *RegionSet) void {
self.count = 0;
}
/// Add a region. Silently ignored when the frame budget is exhausted or the
/// region is empty, so an overfull frame loses clicks rather than
/// corrupting state.
pub fn add(self: *RegionSet, row: u16, col: u16, len: u16, action: Action) void {
if (self.count >= self.items.len) return;
if (len == 0) return;
self.items[self.count] = .{ .row = row, .col = col, .len = len, .action = action };
self.count += 1;
}
/// Find the action for a cell. Searched newest-first, so a view may register
/// a broad background region and then finer controls on top of it.
pub fn at(self: *const RegionSet, row: u16, col: u16) ?Action {
var i: usize = self.count;
while (i > 0) {
i -= 1;
if (self.items[i].contains(row, col)) return self.items[i].action;
}
return null;
}
};
pub const App = struct {
allocator: Allocator,
@ -38,6 +130,14 @@ pub const App = struct {
// Float interpretation overlay (programmer mode)
float_view_active: bool,
float_format: engine.FloatFormat,
// Convert mode state
conv_category: engine.UnitCategory,
conv_from_idx: usize,
conv_to_idx: usize,
conv_value: f64,
conv_zone: ConvZone,
// Mouse hit regions, rebuilt every frame during drawing
regions: RegionSet,
pub const ProgField = enum {
bits,
@ -97,6 +197,9 @@ pub const App = struct {
pub fn init(allocator: Allocator, io: std.Io) App {
var text_field = vxfw.TextField.init(allocator);
text_field.style = .{ .fg = C.fg };
const defaults = defaultUnitIndices(.length);
const default_from = defaults.from;
const default_to = defaults.to;
return .{
.allocator = allocator,
.io = io,
@ -114,6 +217,12 @@ pub const App = struct {
.value_zone_active = false,
.float_view_active = false,
.float_format = .f32,
.conv_category = .length,
.conv_from_idx = default_from,
.conv_to_idx = default_to,
.conv_value = 1,
.conv_zone = .from,
.regions = .empty,
};
}
@ -139,10 +248,207 @@ pub const App = struct {
};
}
// -- Mouse hit regions --
/// Drop all regions from the previous frame. Called at the start of drawing.
pub fn clearRegions(self: *App) void {
self.regions.clear();
}
/// Register a clickable region for this frame.
pub fn addRegion(self: *App, row: u16, col: u16, len: u16, action: Action) void {
self.regions.add(row, col, len, action);
}
/// Find the action for a click at the given cell, if any.
pub fn regionAt(self: *App, row: u16, col: u16) ?Action {
return self.regions.at(row, col);
}
fn handleMouse(self: *App, ctx: *vxfw.EventContext, mouse: vaxis.Mouse) !void {
// Only act on a left press. Release/motion/drag would double-fire.
if (mouse.type != .press or mouse.button != .left) return;
if (mouse.row < 0 or mouse.col < 0) return;
const row: u16 = @intCast(mouse.row);
const col: u16 = @intCast(mouse.col);
// The help overlay swallows clicks, matching its "any key dismisses"
// keyboard behavior.
if (self.show_help) {
self.show_help = false;
ctx.redraw = true;
return;
}
const action = self.regionAt(row, col) orelse return;
try self.applyAction(ctx, action);
}
fn applyAction(self: *App, ctx: *vxfw.EventContext, action: Action) !void {
switch (action) {
.mode => |m| self.setMode(m),
.prog_field => |target| {
self.value_zone_active = true;
self.prog_field = target.field;
if (target.bit) |bit| {
if (bit < self.prog_config.bit_width.bits()) self.bit_cursor = bit;
} else {
self.alignCursorToField();
}
},
.toggle_bit => |bit| {
self.value_zone_active = true;
self.prog_field = if (self.prog_field == .bin) .bin else .bits;
if (bit < self.prog_config.bit_width.bits()) {
self.bit_cursor = bit;
self.prog_value ^= @as(u128, 1) << bit;
self.prog_value &= self.prog_config.bit_width.mask();
}
},
.focus_input => self.value_zone_active = false,
.close_help => self.show_help = false,
.toggle_float => {
self.float_view_active = !self.float_view_active;
if (self.float_view_active) {
self.syncFloatWidth();
self.prog_field = .bits;
}
},
.cycle_width => self.cycleBitWidth(),
.toggle_endian => self.toggleEndian(),
.toggle_signedness => self.toggleSignedness(),
.toggle_float_format => self.toggleFloatFormat(),
.conv_category => |category| self.setConvCategory(category),
.conv_from => |idx| {
self.conv_from_idx = idx;
self.conv_zone = .from;
self.value_zone_active = true;
},
.conv_to => |idx| {
self.conv_to_idx = idx;
self.conv_zone = .to;
self.value_zone_active = true;
},
.conv_swap => self.swapConvUnits(),
}
ctx.redraw = true;
}
// -- Mode and convert-mode state helpers --
/// Switch modes, carrying the last answer into programmer mode.
fn setMode(self: *App, new_mode: Mode) void {
self.mode = new_mode;
self.env.mode = switch (new_mode) {
.programmer => .programmer,
else => .standard,
};
if (new_mode == .programmer) self.loadAnsIntoProgrammer();
self.value_zone_active = false;
}
/// Bring `env.ans` into programmer mode, choosing the representation that
/// can actually hold it: integer views for integers, and the IEEE 754 float
/// overlay for fractions, infinities, NaN, and out-of-range magnitudes.
fn loadAnsIntoProgrammer(self: *App) void {
const ans = self.env.ans;
if (ans == @trunc(ans) and ans >= -9223372036854775808.0 and ans < 18446744073709551616.0) {
self.float_view_active = false;
if (ans >= 0) {
self.prog_value = @intFromFloat(ans);
} else {
const signed: i128 = @intFromFloat(ans);
self.prog_value = @bitCast(signed);
}
self.prog_value &= self.prog_config.bit_width.mask();
} else {
self.float_format = .f64;
self.float_view_active = true;
self.syncFloatWidth();
self.prog_value = @as(u64, @bitCast(ans));
self.prog_field = .bits;
if (self.bit_cursor >= self.prog_config.bit_width.bits()) {
self.bit_cursor = @intCast(self.prog_config.bit_width.bits() - 1);
}
}
}
/// Select a unit category, resetting the unit selection to that category's
/// defaults (its base unit, plus a distinct second unit).
fn setConvCategory(self: *App, category: engine.UnitCategory) void {
self.conv_category = category;
const defaults = defaultUnitIndices(category);
self.conv_from_idx = defaults.from;
self.conv_to_idx = defaults.to;
}
fn swapConvUnits(self: *App) void {
const tmp = self.conv_from_idx;
self.conv_from_idx = self.conv_to_idx;
self.conv_to_idx = tmp;
}
/// The currently selected source and target unit definitions.
pub fn convUnits(self: *const App) struct { from: engine.UnitDef, to: engine.UnitDef } {
const table = engine.units.unitsIn(self.conv_category);
const from_idx = @min(self.conv_from_idx, table.len - 1);
const to_idx = @min(self.conv_to_idx, table.len - 1);
return .{ .from = table[from_idx], .to = table[to_idx] };
}
/// Handle arrow navigation inside the convert view's selection zone.
/// Returns true if the key was consumed.
fn handleConvertSelectionKey(self: *App, ctx: *vxfw.EventContext, key: vaxis.Key) !bool {
const table_len = engine.units.unitsIn(self.conv_category).len;
// Left/Right move between the category, from, and to columns.
if (key.matches(vaxis.Key.left, .{})) {
self.conv_zone = switch (self.conv_zone) {
.category => .to,
.from => .category,
.to => .from,
};
ctx.redraw = true;
return true;
}
if (key.matches(vaxis.Key.right, .{})) {
self.conv_zone = switch (self.conv_zone) {
.category => .from,
.from => .to,
.to => .category,
};
ctx.redraw = true;
return true;
}
// Up/Down move the selection within the focused column, wrapping.
const delta: i32 = if (key.matches(vaxis.Key.down, .{}))
1
else if (key.matches(vaxis.Key.up, .{}))
-1
else
0;
if (delta == 0) return false;
switch (self.conv_zone) {
.category => {
const count: i32 = @intCast(engine.units.category_count);
const current: i32 = @intCast(@intFromEnum(self.conv_category));
const next = @mod(current + delta + count, count);
self.setConvCategory(@enumFromInt(@as(usize, @intCast(next))));
},
.from => self.conv_from_idx = wrapIndex(self.conv_from_idx, delta, table_len),
.to => self.conv_to_idx = wrapIndex(self.conv_to_idx, delta, table_len),
}
ctx.redraw = true;
return true;
}
fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
const self: *App = @ptrCast(@alignCast(ptr));
switch (event) {
.key_press => |key| try self.handleKey(ctx, key),
.mouse => |mouse| try self.handleMouse(ctx, mouse),
.init => ctx.redraw = true,
else => {},
}
@ -166,73 +472,44 @@ pub const App = struct {
return;
}
// Tab: switch between Standard and Programmer mode
// Tab: cycle Standard -> Programmer -> Convert
if (key.matches(vaxis.Key.tab, .{})) {
self.mode = if (self.mode == .standard) .programmer else .standard;
self.env.mode = if (self.mode == .standard) .standard else .programmer;
if (self.mode == .programmer) {
const ans = self.env.ans;
if (ans == @trunc(ans) and ans >= -9223372036854775808.0 and ans < 18446744073709551616.0) {
// Integer value: show the integer bit views.
self.float_view_active = false;
if (ans >= 0) {
self.prog_value = @intFromFloat(ans);
} else {
const signed: i128 = @intFromFloat(ans);
self.prog_value = @bitCast(signed);
}
self.prog_value &= self.prog_config.bit_width.mask();
} else {
// Non-integer (or out-of-range / inf / NaN): the integer
// views cannot represent it, so open the float overlay on
// the exact f64 bit pattern of the value.
self.float_format = .f64;
self.float_view_active = true;
self.syncFloatWidth();
self.prog_value = @as(u64, @bitCast(ans));
self.prog_field = .bits;
if (self.bit_cursor >= self.prog_config.bit_width.bits()) {
self.bit_cursor = @intCast(self.prog_config.bit_width.bits() - 1);
}
}
}
self.value_zone_active = false;
self.setMode(switch (self.mode) {
.standard => .programmer,
.programmer => .convert,
.convert => .standard,
});
ctx.redraw = true;
return;
}
// Backtick: toggle between input zone and value zone (programmer mode only)
if (self.mode == .programmer and key.matches('`', .{})) {
// Backtick: toggle between input zone and value/selection zone
if ((self.mode == .programmer or self.mode == .convert) and key.matches('`', .{})) {
self.value_zone_active = !self.value_zone_active;
ctx.redraw = true;
return;
}
// Convert mode keys
if (self.mode == .convert) {
// Ctrl-S swaps the two units from either zone.
if (key.matches('s', .{ .ctrl = true })) {
self.swapConvUnits();
ctx.redraw = true;
return;
}
if (self.value_zone_active) {
if (try self.handleConvertSelectionKey(ctx, key)) return;
}
}
// Ctrl-W: cycle bit width (programmer mode). In the float overlay it
// instead toggles the float format (f32 <-> f64) and snaps the width.
if (self.mode == .programmer and key.matches('w', .{ .ctrl = true })) {
if (self.float_view_active) {
self.float_format = switch (self.float_format) {
.f32 => .f64,
.f64 => .f32,
};
self.syncFloatWidth();
self.toggleFloatFormat();
} else {
self.prog_config.bit_width = switch (self.prog_config.bit_width) {
.bits8 => .bits16,
.bits16 => .bits32,
.bits32 => .bits64,
.bits64 => .bits128,
.bits128 => .bits8,
};
// NOTE: prog_value is intentionally NOT masked here. Width is a
// display lens over the full value, so narrowing then widening
// restores the hidden upper bits. The display masks to width and
// a warning is shown while the value does not fit (see
// drawProgrammerMode). Explicit value edits still commit to width.
if (self.bit_cursor >= self.prog_config.bit_width.bits()) {
self.bit_cursor = @intCast(self.prog_config.bit_width.bits() - 1);
}
self.cycleBitWidth();
}
ctx.redraw = true;
return;
@ -240,10 +517,7 @@ pub const App = struct {
// Ctrl-E: toggle display endianness (programmer mode)
if (self.mode == .programmer and key.matches('e', .{ .ctrl = true })) {
self.prog_config.display_endian = switch (self.prog_config.display_endian) {
.little => .big,
.big => .little,
};
self.toggleEndian();
ctx.redraw = true;
return;
}
@ -452,6 +726,47 @@ pub const App = struct {
}
}
/// Advance the programmer-mode bit width to the next size, wrapping.
///
/// NOTE: prog_value is intentionally NOT masked here. Width is a display
/// lens over the full value, so narrowing then widening restores the hidden
/// upper bits. The display masks to width and shows a warning while the
/// value does not fit. Explicit value edits still commit to width.
fn cycleBitWidth(self: *App) void {
self.prog_config.bit_width = switch (self.prog_config.bit_width) {
.bits8 => .bits16,
.bits16 => .bits32,
.bits32 => .bits64,
.bits64 => .bits128,
.bits128 => .bits8,
};
if (self.bit_cursor >= self.prog_config.bit_width.bits()) {
self.bit_cursor = @intCast(self.prog_config.bit_width.bits() - 1);
}
}
fn toggleEndian(self: *App) void {
self.prog_config.display_endian = switch (self.prog_config.display_endian) {
.little => .big,
.big => .little,
};
}
fn toggleSignedness(self: *App) void {
self.prog_config.signedness = switch (self.prog_config.signedness) {
.signed => .unsigned,
.unsigned => .signed,
};
}
fn toggleFloatFormat(self: *App) void {
self.float_format = switch (self.float_format) {
.f32 => .f64,
.f64 => .f32,
};
self.syncFloatWidth();
}
/// Snap the bit width to match the active float format (f32 -> 32, f64 -> 64).
fn syncFloatWidth(self: *App) void {
self.prog_config.bit_width = switch (self.float_format) {
@ -500,6 +815,8 @@ pub const App = struct {
} else {
try self.submitProgrammer(expr_text);
}
} else if (self.mode == .convert) {
try self.submitConvert(expr_text);
} else {
try self.submitStandard(expr_text);
}
@ -567,6 +884,35 @@ pub const App = struct {
try self.history.append(self.allocator, .{ .expr = expr_text, .result = result, .is_error = false });
}
/// In convert mode, the input line sets the value to convert. A bare number
/// is taken directly; anything else is evaluated as a standard expression so
/// things like "2*3.5" or "sqrt(2)" work as the input value.
fn submitConvert(self: *App, expr_text: []const u8) !void {
const value: f64 = std.fmt.parseFloat(f64, expr_text) catch
engine.evalString(&self.env, self.allocator, expr_text) catch |err| {
const msg = try self.allocator.dupe(u8, errorStr(err));
try self.history.append(self.allocator, .{ .expr = expr_text, .result = msg, .is_error = true });
return;
};
self.conv_value = value;
const pair = self.convUnits();
const converted = engine.units.convertUnits(value, pair.from, pair.to) catch |err| {
const msg = try self.allocator.dupe(u8, errorStr(err));
try self.history.append(self.allocator, .{ .expr = expr_text, .result = msg, .is_error = true });
return;
};
var in_buf: [64]u8 = undefined;
var out_buf: [64]u8 = undefined;
const in_str = engine.formatter.formatCompactFloat(&in_buf, value);
const out_str = engine.formatter.formatCompactFloat(&out_buf, converted);
const result = try std.fmt.allocPrint(self.allocator, "{s} {s} = {s} {s}", .{
in_str, pair.from.name, out_str, pair.to.name,
});
try self.history.append(self.allocator, .{ .expr = expr_text, .result = result, .is_error = false });
}
fn submitProgrammer(self: *App, expr_text: []const u8) !void {
const is_error, const display_text = if (engine.evalProgrammerString(self.allocator, expr_text, self.prog_config)) |int| blk: {
self.prog_value = int.unsignedValue();
@ -615,6 +961,9 @@ pub const App = struct {
var surface = try vxfw.Surface.init(ctx.arena, self.widget(), .{ .width = width, .height = height });
// Hit regions describe the frame being drawn, so start fresh.
self.clearRegions();
if (self.show_help) {
help_view.drawHelp(&surface, width, height);
return surface;
@ -624,37 +973,46 @@ pub const App = struct {
draw.fillRow(&surface, 0, ' ', .{ .fg = C.cyan, .bg = C.bg, .bold = true });
draw.writeStr(&surface, 0, 1, "Tally", .{ .fg = C.cyan, .bg = C.bg, .bold = true });
// Mode tabs
const std_style: vaxis.Style = if (self.mode == .standard)
.{ .fg = C.bg, .bg = C.green, .bold = true }
else
.{ .fg = C.muted, .bg = C.bg };
const prog_style: vaxis.Style = if (self.mode == .programmer)
.{ .fg = C.bg, .bg = C.orange, .bold = true }
else
.{ .fg = C.muted, .bg = C.bg };
const tab_col = width -| 26;
draw.writeStr(&surface, 0, tab_col, " Standard ", std_style);
draw.writeStr(&surface, 0, tab_col + 10, " Programmer ", prog_style);
// Mode tabs. Each is registered as a clickable region.
const tabs = [_]struct { mode: Mode, text: []const u8, color: vaxis.Cell.Color }{
.{ .mode = .standard, .text = " Standard ", .color = C.green },
.{ .mode = .programmer, .text = " Programmer ", .color = C.orange },
.{ .mode = .convert, .text = " Convert ", .color = C.purple },
};
var total_tab_width: u16 = 0;
for (tabs) |tab| total_tab_width += @intCast(tab.text.len);
var tab_col = width -| (total_tab_width + 2);
for (tabs) |tab| {
const len: u16 = @intCast(tab.text.len);
const style: vaxis.Style = if (self.mode == tab.mode)
.{ .fg = C.bg, .bg = tab.color, .bold = true }
else
.{ .fg = C.muted, .bg = C.bg };
draw.writeStr(&surface, 0, tab_col, tab.text, style);
self.addRegion(0, tab_col, len, .{ .mode = tab.mode });
tab_col += len;
}
if (self.mode == .programmer) {
if (self.float_view_active) {
float_view.drawFloatView(self, &surface, width, height);
} else {
programmer_view.drawProgrammerMode(self, &surface, width, height);
}
} else {
self.drawStandardMode(&surface, width, height);
switch (self.mode) {
.programmer => {
if (self.float_view_active) {
float_view.drawFloatView(self, &surface, width, height);
} else {
programmer_view.drawProgrammerMode(self, &surface, width, height);
}
},
.convert => convert_view.drawConvertMode(self, &surface, width, height),
.standard => self.drawStandardMode(&surface, width, height),
}
return surface;
}
fn drawStandardMode(self: *App, surface: *vxfw.Surface, width: u16, height: u16) void {
_ = width;
drawHistory(self.history.items, surface, 2, height -| 4);
draw.fillRow(surface, height -| 3, '-', .{ .fg = C.dim });
self.drawInput(surface, height -| 2);
self.addRegion(height -| 2, 0, width, .focus_input);
draw.fillRow(surface, height -| 1, ' ', .{ .fg = C.muted, .bg = C.bg });
draw.writeStr(surface, height -| 1, 1, "?:help | Tab:mode | Enter:eval | Ctrl-L:clear | Ctrl-C:quit", .{ .fg = C.muted, .bg = C.bg });
}
@ -687,6 +1045,30 @@ pub const App = struct {
}
};
/// Move an index by delta within [0, len), wrapping at both ends.
pub fn wrapIndex(current: usize, delta: i32, len: usize) usize {
if (len == 0) return 0;
const n: i32 = @intCast(len);
const cur: i32 = @intCast(@min(current, len - 1));
return @intCast(@mod(cur + delta + n, n));
}
/// Default source and target unit indices for a category: the base unit paired
/// with the first unit that differs from it.
pub fn defaultUnitIndices(category: engine.UnitCategory) struct { from: usize, to: usize } {
const table = engine.units.unitsIn(category);
const base_name = category.baseUnit();
var from: usize = 0;
for (table, 0..) |unit, i| {
if (std.mem.eql(u8, unit.name, base_name)) {
from = i;
break;
}
}
const to: usize = if (table.len == 1) from else if (from == 0) 1 else 0;
return .{ .from = from, .to = to };
}
pub fn drawHistory(items: []const App.HistoryEntry, surface: *vxfw.Surface, start: u16, end: u16) void {
if (items.len == 0) return;
if (end <= start) return;
@ -749,6 +1131,8 @@ fn errorStr(err: engine.CalcError) []const u8 {
engine.CalcError.InvalidNumber => "error: invalid number",
engine.CalcError.DomainError => "error: domain error",
engine.CalcError.Overflow => "error: overflow",
engine.CalcError.UnknownUnit => "error: unknown unit",
engine.CalcError.IncompatibleUnits => "error: incompatible units",
else => "error: evaluation error",
};
}
@ -763,3 +1147,114 @@ pub fn run(allocator: Allocator, io: std.Io, environ_map: *std.process.Environ.M
try vx_app.run(app.widget(), .{});
}
// -- Tests --
const testing = std.testing;
test "RegionSet: hit inside and outside a region" {
var set: RegionSet = .empty;
set.add(3, 10, 5, .conv_swap);
// Inside: columns 10..14 on row 3
try testing.expect(set.at(3, 10) != null);
try testing.expect(set.at(3, 14) != null);
// Just outside on either side
try testing.expect(set.at(3, 9) == null);
try testing.expect(set.at(3, 15) == null);
// Wrong row
try testing.expect(set.at(2, 12) == null);
try testing.expect(set.at(4, 12) == null);
}
test "RegionSet: later regions win over earlier ones" {
var set: RegionSet = .empty;
// Broad background region, then a specific control on top of it.
set.add(5, 0, 80, .{ .mode = .standard });
set.add(5, 10, 1, .{ .toggle_bit = 7 });
const on_top = set.at(5, 10) orelse return error.NoRegion;
try testing.expectEqual(@as(u7, 7), on_top.toggle_bit);
// Elsewhere on the row the background still applies.
const background = set.at(5, 20) orelse return error.NoRegion;
try testing.expectEqual(Mode.standard, background.mode);
}
test "RegionSet: clear removes all regions" {
var set: RegionSet = .empty;
set.add(1, 1, 4, .focus_input);
try testing.expect(set.at(1, 2) != null);
set.clear();
try testing.expect(set.at(1, 2) == null);
try testing.expectEqual(@as(usize, 0), set.count);
}
test "RegionSet: zero-length regions are ignored" {
var set: RegionSet = .empty;
set.add(1, 5, 0, .focus_input);
try testing.expectEqual(@as(usize, 0), set.count);
try testing.expect(set.at(1, 5) == null);
}
test "RegionSet: exceeding the budget drops extras without corrupting state" {
var set: RegionSet = .empty;
var i: usize = 0;
while (i < max_hit_regions + 50) : (i += 1) {
set.add(0, @intCast(i % 200), 1, .focus_input);
}
try testing.expectEqual(@as(usize, max_hit_regions), set.count);
// Still queryable, no panic
_ = set.at(0, 5);
}
test "RegionSet: distinguishes actions by payload" {
var set: RegionSet = .empty;
set.add(0, 0, 2, .{ .conv_from = 3 });
set.add(1, 0, 2, .{ .conv_to = 9 });
const from = set.at(0, 1) orelse return error.NoRegion;
const to = set.at(1, 1) orelse return error.NoRegion;
try testing.expectEqual(@as(usize, 3), from.conv_from);
try testing.expectEqual(@as(usize, 9), to.conv_to);
}
test "wrapIndex: moves forward and backward" {
try testing.expectEqual(@as(usize, 1), wrapIndex(0, 1, 5));
try testing.expectEqual(@as(usize, 3), wrapIndex(4, -1, 5));
}
test "wrapIndex: wraps at both ends" {
try testing.expectEqual(@as(usize, 0), wrapIndex(4, 1, 5));
try testing.expectEqual(@as(usize, 4), wrapIndex(0, -1, 5));
}
test "wrapIndex: handles zero and single-element ranges" {
try testing.expectEqual(@as(usize, 0), wrapIndex(0, 1, 0));
try testing.expectEqual(@as(usize, 0), wrapIndex(0, 1, 1));
try testing.expectEqual(@as(usize, 0), wrapIndex(0, -1, 1));
}
test "wrapIndex: clamps an out-of-range starting index" {
// Starting past the end should still land in range.
const result = wrapIndex(99, 1, 5);
try testing.expect(result < 5);
}
test "defaultUnitIndices: from is the category base unit and differs from to" {
for (std.enums.values(engine.UnitCategory)) |category| {
const table = engine.units.unitsIn(category);
const defaults = defaultUnitIndices(category);
try testing.expect(defaults.from < table.len);
try testing.expect(defaults.to < table.len);
try testing.expectEqualStrings(category.baseUnit(), table[defaults.from].name);
// Every category has more than one unit, so the pair must differ.
try testing.expect(defaults.from != defaults.to);
}
}
test "defaultUnitIndices: length defaults to meters" {
const defaults = defaultUnitIndices(.length);
const table = engine.units.unitsIn(.length);
try testing.expectEqualStrings("m", table[defaults.from].name);
}

163
src/tui/convert.zig Normal file
View file

@ -0,0 +1,163 @@
//! Unit conversion mode drawing for the TUI.
//!
//! Layout: a wrapping row of category chips, the live conversion result, the
//! conversion factor, and two side-by-side unit columns (from / to). Every
//! chip and unit name is registered as a clickable region, so the whole view is
//! usable with the mouse as well as the keyboard.
const std = @import("std");
const vaxis = @import("vaxis");
const vxfw = vaxis.vxfw;
const engine = @import("engine");
const draw = @import("draw.zig");
const tui = @import("../tui.zig");
const C = draw.C;
const units = engine.units;
/// Column layout constants.
const from_col: u16 = 4;
const to_col_offset: u16 = 26;
pub fn drawConvertMode(app: *tui.App, surface: *vxfw.Surface, width: u16, height: u16) void {
const table = units.unitsIn(app.conv_category);
const pair = app.convUnits();
const zone_active = app.value_zone_active;
// -- Category chips (wrapping) --
draw.writeStr(surface, 2, 2, "Category:", .{ .fg = C.muted });
var row: u16 = 3;
var col: u16 = 4;
for (std.enums.values(engine.UnitCategory)) |category| {
const label = category.label();
const chip_len: u16 = @intCast(label.len + 2);
// Wrap when the chip would run off the right edge.
if (col + chip_len >= width -| 2) {
row += 1;
col = 4;
}
const selected = category == app.conv_category;
const focused = selected and zone_active and app.conv_zone == .category;
const style: vaxis.Style = if (focused)
.{ .fg = C.bg, .bg = C.cyan, .bold = true }
else if (selected)
.{ .fg = C.bg, .bg = C.purple, .bold = true }
else
.{ .fg = C.muted };
draw.writeChar(surface, row, col, ' ', style);
draw.writeStr(surface, row, col + 1, label, style);
draw.writeChar(surface, row, col + 1 + @as(u16, @intCast(label.len)), ' ', style);
app.addRegion(row, col, chip_len, .{ .conv_category = category });
col += chip_len + 1;
}
// -- Result --
row += 2;
const converted = units.convertUnits(app.conv_value, pair.from, pair.to) catch app.conv_value;
var in_buf: [64]u8 = undefined;
var out_buf: [64]u8 = undefined;
const in_str = engine.formatter.formatCompactFloat(&in_buf, app.conv_value);
const out_str = engine.formatter.formatCompactFloat(&out_buf, converted);
var line_buf: [192]u8 = undefined;
const input_line = std.fmt.bufPrint(&line_buf, "{s} {s}", .{ in_str, pair.from.name }) catch "?";
draw.writeStr(surface, row, 2, input_line, .{ .fg = C.fg });
row += 1;
var result_buf: [192]u8 = undefined;
const result_line = std.fmt.bufPrint(&result_buf, "= {s} {s}", .{ out_str, pair.to.name }) catch "?";
draw.writeStr(surface, row, 2, result_line, .{ .fg = C.green, .bold = true });
row += 1;
// -- Conversion factor (linear conversions only) --
if (pair.from.isLinear() and pair.to.isLinear()) {
const factor = pair.from.to_base_factor / pair.to.to_base_factor;
var factor_val_buf: [64]u8 = undefined;
const factor_str = engine.formatter.formatCompactFloat(&factor_val_buf, factor);
var factor_buf: [160]u8 = undefined;
const factor_line = std.fmt.bufPrint(&factor_buf, "1 {s} = {s} {s}", .{
pair.from.name, factor_str, pair.to.name,
}) catch "?";
draw.writeStr(surface, row, 2, factor_line, .{ .fg = C.muted });
} else {
// Temperature is affine, so no single factor describes it.
draw.writeStr(surface, row, 2, "affine conversion (offset), no single factor", .{ .fg = C.muted });
}
row += 2;
// -- Unit columns --
const to_col = from_col + to_col_offset;
const from_focused = zone_active and app.conv_zone == .from;
const to_focused = zone_active and app.conv_zone == .to;
draw.writeStr(surface, row, 2, "From", if (from_focused)
.{ .fg = C.cyan, .bold = true }
else
.{ .fg = C.muted });
draw.writeStr(surface, row, to_col -| 2, "To", if (to_focused)
.{ .fg = C.cyan, .bold = true }
else
.{ .fg = C.muted });
row += 1;
const list_start = row;
const list_end = height -| 4;
const visible: usize = if (list_end > list_start) list_end - list_start else 0;
for (table, 0..) |unit, i| {
if (i >= visible) break;
const list_row: u16 = list_start + @as(u16, @intCast(i));
drawUnitCell(app, surface, list_row, from_col, unit, i == app.conv_from_idx, from_focused, .{ .conv_from = i });
drawUnitCell(app, surface, list_row, to_col, unit, i == app.conv_to_idx, to_focused, .{ .conv_to = i });
}
// If the terminal is too short for the whole list, say so rather than
// silently truncating.
if (table.len > visible and visible > 0) {
var more_buf: [64]u8 = undefined;
const more = std.fmt.bufPrint(&more_buf, "... {d} more (resize to see all)", .{table.len - visible}) catch "...";
draw.writeStr(surface, list_end -| 1, from_col, more, .{ .fg = C.dim });
}
// -- Separator, input, status --
draw.fillRow(surface, height -| 3, '-', .{ .fg = C.dim });
app.drawInput(surface, height -| 2);
app.addRegion(height -| 2, 0, width, .focus_input);
draw.fillRow(surface, height -| 1, ' ', .{ .fg = C.muted, .bg = C.bg });
const status = if (zone_active)
"Arrows:select | Ctrl-S:swap | `:input | Tab:mode | ?:help"
else
"Type a value + Enter | `:select units | Ctrl-S:swap | Tab:mode | ?:help";
draw.writeStr(surface, height -| 1, 1, status, .{ .fg = C.muted, .bg = C.bg });
}
/// Draw one unit name in a column, highlighting it when selected, and register
/// it as clickable.
fn drawUnitCell(
app: *tui.App,
surface: *vxfw.Surface,
row: u16,
col: u16,
unit: engine.UnitDef,
selected: bool,
column_focused: bool,
action: tui.Action,
) void {
const style: vaxis.Style = if (selected and column_focused)
.{ .fg = C.bg, .bg = C.cyan, .bold = true }
else if (selected)
.{ .fg = C.green, .bold = true }
else
.{ .fg = C.fg };
const marker: u8 = if (selected) '>' else ' ';
draw.writeChar(surface, row, col -| 2, marker, .{ .fg = C.cyan, .bold = true });
draw.writeStr(surface, row, col, unit.name, style);
// Make the whole column slot clickable, not just the name text, so short
// names like "m" are not a one-character target.
app.addRegion(row, col -| 2, to_col_offset -| 2, action);
}

196
src/tui/float_view.zig Normal file
View file

@ -0,0 +1,196 @@
//! IEEE 754 float interpretation overlay for the TUI programmer mode.
//!
//! Reinterprets the current programmer value as an IEEE 754 float (f32 or f64),
//! drawing a color-coded bit grid (sign / exponent / significand) plus the
//! decoded value, formula, classification, ULP, and raw fields.
const std = @import("std");
const vaxis = @import("vaxis");
const vxfw = vaxis.vxfw;
const engine = @import("engine");
const draw = @import("draw.zig");
const tui = @import("../tui.zig");
const C = draw.C;
const fi = engine.float_interp;
/// Which IEEE 754 field a given bit belongs to.
const Field = enum { sign, exponent, significand };
fn fieldOf(bit_idx: u8, format: engine.FloatFormat) Field {
const total = format.totalBits();
const sig_bits = format.significandBits();
if (bit_idx == total - 1) return .sign;
if (bit_idx >= sig_bits) return .exponent;
return .significand;
}
fn fieldColor(field: Field) vaxis.Cell.Color {
return switch (field) {
.sign => C.pink,
.exponent => C.cyan,
.significand => C.green,
};
}
pub fn drawFloatView(app: *tui.App, surface: *vxfw.Surface, width: u16, height: u16) void {
_ = width;
const format = app.float_format;
const total = format.totalBits();
const bw = app.prog_config.bit_width;
const bits: u64 = @truncate(app.prog_value & bw.mask());
const info = fi.decompose(format, bits);
// Header line
var header_buf: [96]u8 = undefined;
const header = std.fmt.bufPrint(&header_buf, "IEEE 754 {s} ({d}-bit) Ctrl-W:f32/f64 Ctrl-F:exit", .{
switch (format) {
.f32 => "float32",
.f64 => "float64",
},
total,
}) catch "IEEE 754";
draw.writeStr(surface, 2, 2, header, .{ .fg = C.muted });
// Clicking the header row exits the overlay; clicking the format label
// toggles f32/f64.
if (std.mem.indexOf(u8, header, "Ctrl-W")) |idx| {
app.addRegion(2, 2 + @as(u16, @intCast(idx)), 12, .toggle_float_format);
}
if (std.mem.indexOf(u8, header, "Ctrl-F")) |idx| {
app.addRegion(2, 2 + @as(u16, @intCast(idx)), 11, .toggle_float);
}
// Color legend
draw.writeStr(surface, 3, 2, "sign", .{ .fg = C.pink, .bold = true });
draw.writeStr(surface, 3, 7, "exponent", .{ .fg = C.cyan, .bold = true });
draw.writeStr(surface, 3, 16, "significand", .{ .fg = C.green, .bold = true });
// Bit grid (color-coded by field)
const grid_start: u16 = 5;
const grid_rows = drawFloatBitGrid(app, surface, grid_start, bits, format);
// Info panel below the grid
var row = grid_start + grid_rows + 1;
const bias = format.bias();
const sig_bits = format.significandBits();
// Value
var val_buf: [64]u8 = undefined;
const val_str = engine.formatter.formatCompactFloat(&val_buf, info.value);
draw.writeStr(surface, row, 2, "Value:", .{ .fg = C.cyan });
draw.writeStr(surface, row, 12, val_str, .{ .fg = C.fg, .bold = true });
row += 1;
// Classification
draw.writeStr(surface, row, 2, "Class:", .{ .fg = C.cyan });
draw.writeStr(surface, row, 12, info.class.label(), .{ .fg = C.fg });
row += 1;
// Formula (normal / denormal only)
draw.writeStr(surface, row, 2, "Formula:", .{ .fg = C.cyan });
var formula_buf: [128]u8 = undefined;
const formula: []const u8 = switch (info.class) {
.normal, .denormal => blk: {
const sign_char: u8 = if (info.sign == 1) '-' else '+';
const lead: f64 = if (info.class == .normal) 1.0 else 0.0;
const frac: f64 = @as(f64, @floatFromInt(info.significand)) /
std.math.ldexp(@as(f64, 1.0), @intCast(sig_bits));
const mantissa = lead + frac;
break :blk std.fmt.bufPrint(&formula_buf, "{c}2^{d} x {d}", .{
sign_char, info.unbiased_exponent, mantissa,
}) catch "?";
},
else => "n/a",
};
draw.writeStr(surface, row, 12, formula, .{ .fg = C.fg });
row += 1;
// ULP
draw.writeStr(surface, row, 2, "ULP:", .{ .fg = C.cyan });
var ulp_buf: [64]u8 = undefined;
const ulp_str: []const u8 = if (std.math.isNan(info.ulp))
"n/a"
else
engine.formatter.formatCompactFloat(&ulp_buf, info.ulp);
draw.writeStr(surface, row, 12, ulp_str, .{ .fg = C.fg });
row += 1;
row += 1;
// Raw fields
draw.writeStr(surface, row, 2, "Sign:", .{ .fg = C.pink });
var sign_buf: [32]u8 = undefined;
const sign_str = std.fmt.bufPrint(&sign_buf, "{d} ({s})", .{
info.sign,
if (info.sign == 1) "negative" else "positive",
}) catch "?";
draw.writeStr(surface, row, 12, sign_str, .{ .fg = C.fg });
row += 1;
draw.writeStr(surface, row, 2, "Exp:", .{ .fg = C.cyan });
var exp_buf: [80]u8 = undefined;
const exp_str = std.fmt.bufPrint(&exp_buf, "{d} biased -> {d} unbiased (bias {d})", .{
info.raw_exponent, info.unbiased_exponent, bias,
}) catch "?";
draw.writeStr(surface, row, 12, exp_str, .{ .fg = C.fg });
row += 1;
draw.writeStr(surface, row, 2, "Frac:", .{ .fg = C.green });
var frac_buf: [64]u8 = undefined;
const frac_str = std.fmt.bufPrint(&frac_buf, "0x{X} ({d} bits)", .{
info.significand, sig_bits,
}) catch "?";
draw.writeStr(surface, row, 12, frac_str, .{ .fg = C.fg });
row += 1;
// History + input + status
const hist_start = row + 1;
const hist_end = height -| 4;
if (hist_start < hist_end) {
tui.drawHistory(app.history.items, surface, hist_start, hist_end);
}
draw.fillRow(surface, height -| 3, '-', .{ .fg = C.dim });
app.drawInput(surface, height -| 2);
app.addRegion(height -| 2, 0, surface.size.width, .focus_input);
draw.fillRow(surface, height -| 1, ' ', .{ .fg = C.muted, .bg = C.bg });
const status = "Type a float | Arrows:nav bits | Space:toggle | Ctrl-W:f32/f64 | Ctrl-F:exit | ?:help";
draw.writeStr(surface, height -| 1, 1, status, .{ .fg = C.muted, .bg = C.bg });
}
/// Draw the color-coded bit grid. Returns the number of rows drawn.
fn drawFloatBitGrid(app: *tui.App, surface: *vxfw.Surface, start_row: u16, bits: u64, format: engine.FloatFormat) u16 {
const total = format.totalBits();
const bits_per_row: u8 = if (total > 32) 32 else total;
const num_rows: u16 = (@as(u16, total) + bits_per_row - 1) / bits_per_row;
const cursor_active = app.value_zone_active and app.prog_field == .bits;
var row = start_row;
var bit_row: u16 = 0;
while (bit_row < num_rows) : (bit_row += 1) {
const row_start_bit: u8 = @intCast(total - 1 - bit_row * bits_per_row);
var col: u16 = 2;
var b: u8 = 0;
while (b < bits_per_row) : (b += 1) {
const bit_idx = row_start_bit -| b;
if (bit_idx >= total) break;
if (b > 0 and b % 4 == 0) col += 1;
const bit_val: u8 = @intCast((bits >> @intCast(bit_idx)) & 1);
const field = fieldOf(bit_idx, format);
const color = fieldColor(field);
const is_cursor = cursor_active and (bit_idx == app.bit_cursor);
const style: vaxis.Style = if (is_cursor)
.{ .fg = C.bg, .bg = C.orange, .bold = true }
else
.{ .fg = color, .bold = (bit_val == 1) };
draw.writeChar(surface, row, col, '0' + bit_val, style);
// bit_idx < total <= 64, so it fits in a u7.
app.addRegion(row, col, 1, .{ .toggle_bit = @intCast(bit_idx) });
col += 1;
}
row += 1;
}
return num_rows;
}

View file

@ -18,7 +18,7 @@ pub fn drawHelp(surface: *vxfw.Surface, width: u16, height: u16) void {
row += 1;
const keys = [_][2][]const u8{
.{ "Enter", "Evaluate expression" },
.{ "Tab", "Switch mode (Standard/Programmer)" },
.{ "Tab", "Cycle mode (Standard/Programmer/Convert)" },
.{ "Ctrl-C/D", "Quit" },
.{ "Ctrl-L", "Clear history" },
.{ "Up/Down", "Browse history" },
@ -31,6 +31,26 @@ pub fn drawHelp(surface: *vxfw.Surface, width: u16, height: u16) void {
}
row += 1;
draw.writeStr(surface, row, 2, "Mouse", .{ .fg = C.purple, .bold = true });
row += 1;
const mouse_keys = [_][2][]const u8{
.{ "Tabs", "Click a tab to switch mode" },
.{ "Bit grid", "Click a bit to flip it" },
.{ "HEX/OCT", "Click a digit to put the cursor on it" },
.{ "BIN", "Click a digit to flip that bit" },
.{ "DEC rows", "Click to focus the field" },
.{ "Bits/Signed/Endian", "Click the label to toggle it" },
.{ "Convert", "Click a category or unit to select it" },
.{ "Input line", "Click to return focus to the prompt" },
};
for (mouse_keys) |kv| {
if (row >= height -| 4) break;
draw.writeStr(surface, row, 4, kv[0], .{ .fg = C.yellow });
draw.writeStr(surface, row, 24, kv[1], .{ .fg = C.fg });
row += 1;
}
row += 1;
draw.writeStr(surface, row, 2, "Programmer Mode", .{ .fg = C.purple, .bold = true });
row += 1;
const prog_keys = [_][2][]const u8{
@ -49,6 +69,24 @@ pub fn drawHelp(surface: *vxfw.Surface, width: u16, height: u16) void {
}
row += 1;
if (row < height -| 6) {
draw.writeStr(surface, row, 2, "Convert Mode", .{ .fg = C.purple, .bold = true });
row += 1;
const conv_keys = [_][2][]const u8{
.{ "`", "Toggle input / selection zone" },
.{ "Arrows", "Left/Right: column, Up/Down: select" },
.{ "Ctrl-S", "Swap from and to units" },
.{ "Enter", "Set the value to convert" },
};
for (conv_keys) |kv| {
if (row >= height -| 4) break;
draw.writeStr(surface, row, 4, kv[0], .{ .fg = C.yellow });
draw.writeStr(surface, row, 18, kv[1], .{ .fg = C.fg });
row += 1;
}
row += 1;
}
if (row < height -| 6) {
draw.writeStr(surface, row, 2, "Functions", .{ .fg = C.purple, .bold = true });
row += 1;

View file

@ -9,12 +9,11 @@ const tui = @import("../tui.zig");
const C = draw.C;
pub fn drawProgrammerMode(app: *tui.App, surface: *vxfw.Surface, width: u16, height: u16) void {
_ = width;
const bw = app.prog_config.bit_width;
const val = app.prog_value & bw.mask();
const focused = app.prog_field;
// Config display
// Config display. Each segment is clickable and toggles what it names.
var config_buf: [80]u8 = undefined;
const config_str = std.fmt.bufPrint(&config_buf, "Bits: {d} Signed: {s} Endian: {s}", .{
bw.bits(),
@ -22,6 +21,13 @@ pub fn drawProgrammerMode(app: *tui.App, surface: *vxfw.Surface, width: u16, hei
if (app.prog_config.display_endian == .little) "LE" else "BE",
}) catch "Bits: ??";
draw.writeStr(surface, 2, 2, config_str, .{ .fg = C.muted });
registerConfigRegions(app, 2, 2, config_str);
// "float view" affordance on the same line, at the right.
const float_hint = "[float: Ctrl-F]";
const float_col = width -| @as(u16, @intCast(float_hint.len + 2));
draw.writeStr(surface, 2, float_col, float_hint, .{ .fg = C.dim });
app.addRegion(2, float_col, @intCast(float_hint.len), .toggle_float);
// Truncation warning: the stored value has bits beyond the current width,
// so the display below shows only the low bits. Widening restores them.
@ -49,6 +55,7 @@ pub fn drawProgrammerMode(app: *tui.App, surface: *vxfw.Surface, width: u16, hei
.{ .fg = C.cyan };
draw.writeStr(surface, base_start, 2, "DEC(s):", sdec_style);
draw.writeStr(surface, base_start, 11, sdec.display, if (focused == .dec_signed) .{ .fg = C.fg, .bold = true } else .{ .fg = C.fg });
app.addRegion(base_start, 0, width, .{ .prog_field = .{ .field = .dec_signed, .bit = null } });
// DEC(u)
var udec_buf: [256]u8 = undefined;
@ -59,6 +66,7 @@ pub fn drawProgrammerMode(app: *tui.App, surface: *vxfw.Surface, width: u16, hei
.{ .fg = C.cyan };
draw.writeStr(surface, base_start + 1, 2, "DEC(u):", udec_style);
draw.writeStr(surface, base_start + 1, 11, udec.display, if (focused == .dec_unsigned) .{ .fg = C.fg, .bold = true } else .{ .fg = C.fg });
app.addRegion(base_start + 1, 0, width, .{ .prog_field = .{ .field = .dec_unsigned, .bit = null } });
// HEX
var hex_buf: [256]u8 = undefined;
@ -73,6 +81,10 @@ pub fn drawProgrammerMode(app: *tui.App, surface: *vxfw.Surface, width: u16, hei
} else {
draw.writeStr(surface, base_start + 2, 11, hex.display, .{ .fg = C.green });
}
// Row-wide fallback focuses the field; per-digit regions (added next) place
// the cursor on the exact nibble that was clicked.
app.addRegion(base_start + 2, 0, width, .{ .prog_field = .{ .field = .hex, .bit = null } });
registerDigitRegions(app, base_start + 2, 11, hex.display, 4, .hex, false);
// ASCII (derived, read-only): one glyph per byte, aligned under HEX.
var ascii_buf: [128]u8 = undefined;
@ -93,6 +105,8 @@ pub fn drawProgrammerMode(app: *tui.App, surface: *vxfw.Surface, width: u16, hei
} else {
draw.writeStr(surface, base_start + 4, 11, oct.display, .{ .fg = C.purple });
}
app.addRegion(base_start + 4, 0, width, .{ .prog_field = .{ .field = .oct, .bit = null } });
registerDigitRegions(app, base_start + 4, 11, oct.display, 3, .oct, false);
// BIN
var bin_buf: [512]u8 = undefined;
@ -107,6 +121,9 @@ pub fn drawProgrammerMode(app: *tui.App, surface: *vxfw.Surface, width: u16, hei
} else {
draw.writeStr(surface, base_start + 5, 11, bin.display, .{ .fg = C.yellow });
}
app.addRegion(base_start + 5, 0, width, .{ .prog_field = .{ .field = .bin, .bit = null } });
// A binary digit IS a single bit, so clicking one flips it directly.
registerDigitRegions(app, base_start + 5, 11, bin.display, 1, .bin, true);
// History
const hist_start = base_start + 7;
@ -118,6 +135,7 @@ pub fn drawProgrammerMode(app: *tui.App, surface: *vxfw.Surface, width: u16, hei
// Separator + input + status
draw.fillRow(surface, height -| 3, '-', .{ .fg = C.dim });
app.drawInput(surface, height -| 2);
app.addRegion(height -| 2, 0, width, .focus_input);
draw.fillRow(surface, height -| 1, ' ', .{ .fg = C.muted, .bg = C.bg });
const status = if (focused == .bits)
"Arrows:nav | Space:toggle | Up/Down:field | Ctrl-W:width | Ctrl-E:endian | Ctrl-F:float | Tab:mode"
@ -126,6 +144,72 @@ pub fn drawProgrammerMode(app: *tui.App, surface: *vxfw.Surface, width: u16, hei
draw.writeStr(surface, height -| 1, 1, status, .{ .fg = C.muted, .bg = C.bg });
}
/// Register clickable regions over the "Bits: / Signed: / Endian:" config line
/// so each label toggles the setting it names.
fn registerConfigRegions(app: *tui.App, row: u16, col: u16, text: []const u8) void {
const segments = [_]struct { needle: []const u8, action: tui.Action }{
.{ .needle = "Bits:", .action = .cycle_width },
.{ .needle = "Signed:", .action = .toggle_signedness },
.{ .needle = "Endian:", .action = .toggle_endian },
};
for (segments) |segment| {
const idx = std.mem.indexOf(u8, text, segment.needle) orelse continue;
// Cover the label plus its value (label length + space + up to 3 chars).
const start: u16 = col + @as(u16, @intCast(idx));
const len: u16 = @intCast(segment.needle.len + 4);
app.addRegion(row, start, len, segment.action);
}
}
/// Register one clickable region per displayed digit of a base field, mapping
/// each digit back to the bit position it represents. Mirrors the layout logic
/// in `drawFieldWithCursor` so clicks land on the same digit the cursor would.
///
/// When `toggles` is true a click flips the bit (used for BIN, where one digit
/// is exactly one bit). Otherwise a click just moves the cursor to that digit,
/// since "toggling" a multi-bit nibble or octal digit has no single meaning.
fn registerDigitRegions(
app: *tui.App,
row: u16,
col: u16,
text: []const u8,
bits_per_digit: u8,
field: tui.App.ProgField,
toggles: bool,
) void {
// Skip a base prefix if one is present (display strings normally omit it).
var start: usize = 0;
if (text.len >= 2 and text[0] == '0' and (text[1] == 'o' or text[1] == 'x' or text[1] == 'b')) {
start = 2;
}
var displayed_digits: u16 = 0;
for (text[start..]) |ch| {
if (ch != ' ') displayed_digits += 1;
}
if (displayed_digits == 0) return;
var text_col: u16 = col + @as(u16, @intCast(start));
var digit_idx: u16 = 0;
for (text[start..]) |ch| {
if (ch != ' ') {
// Digits are drawn MSB-first; convert to a bit offset from the LSB.
const from_lsb: u16 = displayed_digits - 1 - digit_idx;
const bit_pos: u32 = @as(u32, from_lsb) * bits_per_digit;
if (bit_pos < 128) {
const bit: u7 = @intCast(bit_pos);
const action: tui.Action = if (toggles)
.{ .toggle_bit = bit }
else
.{ .prog_field = .{ .field = field, .bit = bit } };
app.addRegion(row, text_col, 1, action);
}
digit_idx += 1;
}
text_col += 1;
}
}
/// Draw a field's display string with a cursor highlighting the digit at bit_cursor position.
/// `bits_per_digit` is 4 for hex, 3 for oct, 1 for bin.
fn drawFieldWithCursor(surface: *vxfw.Surface, row: u16, col: u16, text: []const u8, bit_cursor: u7, bits_per_digit: u8, total_bits: u8, color: vaxis.Cell.Color) void {
@ -208,6 +292,8 @@ fn drawBitGrid(app: *tui.App, surface: *vxfw.Surface, start_row: u16, val: u128,
};
draw.writeChar(surface, row, col, '0' + bit_val, style);
// bit_idx < width_bits <= 128, so it always fits in a u7.
app.addRegion(row, col, 1, .{ .toggle_bit = @intCast(bit_idx) });
col += 1;
}
row += 1;