From 7e74278827a5ca418876e7291963c1748909b089 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sat, 25 Jul 2026 18:20:33 -0700 Subject: [PATCH] add conversion functions --- .kiro/specs/calculator/design.md | 185 +++++- .kiro/specs/calculator/requirements.md | 57 +- .kiro/specs/calculator/tasks.md | 109 +++- build.zig | 18 + engine/src/engine.zig | 8 + engine/src/float_interp.zig | 344 +++++++++++ engine/src/units.zig | 777 +++++++++++++++++++++++++ src/main.zig | 273 ++++++++- src/tui.zig | 651 ++++++++++++++++++--- src/tui/convert.zig | 163 ++++++ src/tui/float_view.zig | 196 +++++++ src/tui/help.zig | 40 +- src/tui/programmer.zig | 90 ++- 13 files changed, 2764 insertions(+), 147 deletions(-) create mode 100644 engine/src/float_interp.zig create mode 100644 engine/src/units.zig create mode 100644 src/tui/convert.zig create mode 100644 src/tui/float_view.zig diff --git a/.kiro/specs/calculator/design.md b/.kiro/specs/calculator/design.md index 6db8b56..7910719 100644 --- a/.kiro/specs/calculator/design.md +++ b/.kiro/specs/calculator/design.md @@ -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 ` to `: +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 (` to +` 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 diff --git a/.kiro/specs/calculator/requirements.md b/.kiro/specs/calculator/requirements.md index e2b9ee7..ebd69dc 100644 --- a/.kiro/specs/calculator/requirements.md +++ b/.kiro/specs/calculator/requirements.md @@ -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: ` to ` (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: ` to ` (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 `, `tally convert 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. diff --git a/.kiro/specs/calculator/tasks.md b/.kiro/specs/calculator/tasks.md index bb43190..4b18ba5 100644 --- a/.kiro/specs/calculator/tasks.md +++ b/.kiro/specs/calculator/tasks.md @@ -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 [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 --- diff --git a/build.zig b/build.zig index 75b8499..632f900 100644 --- a/build.zig +++ b/build.zig @@ -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) -- { diff --git a/engine/src/engine.zig b/engine/src/engine.zig index 7abd2e5..c4eff07 100644 --- a/engine/src/engine.zig +++ b/engine/src/engine.zig @@ -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()); } diff --git a/engine/src/float_interp.zig b/engine/src/float_interp.zig new file mode 100644 index 0000000..f8ac9e0 --- /dev/null +++ b/engine/src/float_interp.zig @@ -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); + } +} diff --git a/engine/src/units.zig b/engine/src/units.zig new file mode 100644 index 0000000..db27395 --- /dev/null +++ b/engine/src/units.zig @@ -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()); +} diff --git a/src/main.zig b/src/main.zig index b6d0ff4..6b902f3 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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 + // Also accepts the natural form: tally convert 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 ` ` and ` to `, and also the +/// glued form ` 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 [to] + \\ + \\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] + \\ tally convert [to] \\ \\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); +} diff --git a/src/tui.zig b/src/tui.zig index d192aa3..8200104 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -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); +} diff --git a/src/tui/convert.zig b/src/tui/convert.zig new file mode 100644 index 0000000..fea5cf7 --- /dev/null +++ b/src/tui/convert.zig @@ -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); +} diff --git a/src/tui/float_view.zig b/src/tui/float_view.zig new file mode 100644 index 0000000..f39bb26 --- /dev/null +++ b/src/tui/float_view.zig @@ -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; +} diff --git a/src/tui/help.zig b/src/tui/help.zig index 560fcc3..501106f 100644 --- a/src/tui/help.zig +++ b/src/tui/help.zig @@ -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; diff --git a/src/tui/programmer.zig b/src/tui/programmer.zig index 75d832a..fda0716 100644 --- a/src/tui/programmer.zig +++ b/src/tui/programmer.zig @@ -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;