add conversion functions
This commit is contained in:
parent
3262be27ba
commit
7e74278827
13 changed files with 2764 additions and 147 deletions
|
|
@ -357,44 +357,90 @@ pub const UnitCategory = enum {
|
|||
};
|
||||
|
||||
pub const UnitDef = struct {
|
||||
name: []const u8, // canonical name (e.g., "km")
|
||||
name: []const u8, // canonical name (e.g., "km")
|
||||
aliases: []const []const u8, // alternatives (e.g., "kilometer", "kilometers")
|
||||
category: UnitCategory,
|
||||
/// Conversion to base unit: value_in_base = value * factor + offset
|
||||
/// For most units, offset = 0. Temperature uses both.
|
||||
/// Conversion to base unit: base = value * to_base_factor + to_base_offset
|
||||
/// For most units offset = 0. Temperature is the only category that uses it.
|
||||
to_base_factor: f64,
|
||||
to_base_offset: f64,
|
||||
from_base_factor: f64,
|
||||
from_base_offset: f64,
|
||||
to_base_offset: f64 = 0,
|
||||
|
||||
pub fn toBase(self: UnitDef, value: f64) f64 {
|
||||
return value * self.to_base_factor + self.to_base_offset;
|
||||
}
|
||||
|
||||
/// The reverse direction is DERIVED, not stored.
|
||||
pub fn fromBase(self: UnitDef, base: f64) f64 {
|
||||
return (base - self.to_base_offset) / self.to_base_factor;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Only the to-base mapping is stored; the reverse is derived.** An earlier draft
|
||||
of this design gave each unit four fields (`to_base_factor`/`to_base_offset`
|
||||
plus `from_base_factor`/`from_base_offset`). That was changed because it lets a
|
||||
table entry describe a conversion that disagrees with its own inverse: nothing
|
||||
stops a typo in `from_base_factor` from making `km -> m -> km` lossy. Deriving
|
||||
`fromBase` by inverting `toBase` gives one source of truth per unit, halves the
|
||||
numbers a contributor has to get right, and makes round-trip correctness
|
||||
structural rather than something tests have to police. A unit test still
|
||||
round-trips every unit and every within-category unit pair as a guard against
|
||||
bad factors.
|
||||
|
||||
### 4.2 Conversion Resolution
|
||||
|
||||
The parser recognizes the pattern `<expr> <unit> to <unit>`:
|
||||
Unit names resolve through `findUnit(name)`, which searches every category's
|
||||
table by canonical name and alias:
|
||||
|
||||
1. An **exact, case-sensitive** match is tried first. This is what keeps
|
||||
case-distinguished units meaningful: `K` is Kelvin (not a "kilo" something),
|
||||
`B` is byte, and `kB` (1000) never silently becomes `KiB` (1024).
|
||||
2. Only if nothing matches exactly does a **case-insensitive** pass run, which
|
||||
is what makes forgiving input like `KM` or `Celsius` work.
|
||||
|
||||
Conversion goes through the category base unit: `to.fromBase(from.toBase(value))`.
|
||||
Both units must be in the same category, or the result is `IncompatibleUnits`.
|
||||
An unrecognized name is `UnknownUnit`.
|
||||
|
||||
A single multiplicative factor is reported alongside the result only when both
|
||||
units are linear (zero offset). Temperature conversions are affine, so no single
|
||||
factor describes them and the factor is reported as null.
|
||||
|
||||
NOT YET IMPLEMENTED: the expression-level syntax below (`<expr> <unit> to
|
||||
<unit>` and unit-qualified arithmetic like `5 kg + 3 lb`) needs unit-aware
|
||||
tokenizing and evaluation. Today conversion is reached through the CLI `convert`
|
||||
subcommand and the TUI convert mode. The planned design was:
|
||||
|
||||
- Tokenizer identifies known unit names after a numeric expression
|
||||
- `to` keyword triggers conversion mode
|
||||
- Both units must be in the same category (error otherwise)
|
||||
|
||||
For expressions like `5 kg + 3 lb`, the parser:
|
||||
1. Identifies `5 kg` as a unit-qualified value
|
||||
2. Sees `+ 3 lb` - converts `3 lb` to `kg` (left-hand unit wins)
|
||||
3. Evaluates as `5 + 1.36078 = 6.36078 kg`
|
||||
For expressions like `5 kg + 3 lb`, the parser would:
|
||||
1. Identify `5 kg` as a unit-qualified value
|
||||
2. See `+ 3 lb` and convert `3 lb` to `kg` (left-hand unit wins)
|
||||
3. Evaluate as `5 + 1.36078 = 6.36078 kg`
|
||||
|
||||
### 4.3 Adding New Units
|
||||
|
||||
New units require only a table entry - no parser changes:
|
||||
|
||||
```zig
|
||||
// In units/length.zig
|
||||
pub const length_units = [_]UnitDef{
|
||||
.{ .name = "m", .aliases = &.{"meter", "meters"}, .to_base_factor = 1.0, ... },
|
||||
.{ .name = "km", .aliases = &.{"kilometer", "kilometers"}, .to_base_factor = 1000.0, ... },
|
||||
.{ .name = "mi", .aliases = &.{"mile", "miles"}, .to_base_factor = 1609.344, ... },
|
||||
// In units.zig
|
||||
const length_units = [_]UnitDef{
|
||||
.{ .name = "m", .aliases = &.{"meter", "meters"}, .category = .length, .to_base_factor = 1.0 },
|
||||
.{ .name = "km", .aliases = &.{"kilometer", "kilometers"}, .category = .length, .to_base_factor = 1000.0 },
|
||||
.{ .name = "mi", .aliases = &.{"mile", "miles"}, .category = .length, .to_base_factor = 1609.344 },
|
||||
// ... add new entries here
|
||||
};
|
||||
```
|
||||
|
||||
Invariants enforced by tests rather than convention: every category's base unit
|
||||
must have `to_base_factor == 1` and no offset, no unit may have a zero factor,
|
||||
canonical names must be unique across all categories, every unit must be listed
|
||||
in the table matching its own `category` field, and every unit and unit pair must
|
||||
round-trip. A bad table entry fails the suite instead of silently producing wrong
|
||||
answers.
|
||||
|
||||
---
|
||||
|
||||
## 5. Financial Module
|
||||
|
|
@ -564,6 +610,60 @@ $ tally "5 kg + 3 lb"
|
|||
|
||||
## 8. TUI Layout
|
||||
|
||||
### 8.0 Mouse Input Architecture
|
||||
|
||||
The TUI is mouse-driven as well as keyboard-driven (FR-7.11). Rather than each
|
||||
view doing its own coordinate arithmetic against hardcoded layout constants, hit
|
||||
targets are registered as a side effect of drawing:
|
||||
|
||||
```zig
|
||||
pub const Action = union(enum) {
|
||||
mode: Mode, // tab bar
|
||||
prog_field: struct { field: ProgField, bit: ?u7 }, // focus field, optional cursor
|
||||
toggle_bit: u7, // flip one bit
|
||||
focus_input, close_help, toggle_float,
|
||||
cycle_width, toggle_endian, toggle_signedness, toggle_float_format,
|
||||
conv_category: UnitCategory, conv_from: usize, conv_to: usize, conv_swap,
|
||||
};
|
||||
|
||||
pub const RegionSet = struct {
|
||||
items: [512]HitRegion, // { row, col, len, action }
|
||||
count: usize,
|
||||
pub fn clear(self: *RegionSet) void;
|
||||
pub fn add(self: *RegionSet, row: u16, col: u16, len: u16, action: Action) void;
|
||||
pub fn at(self: *const RegionSet, row: u16, col: u16) ?Action;
|
||||
};
|
||||
```
|
||||
|
||||
Key properties:
|
||||
|
||||
- **Regions are rebuilt every frame.** `clearRegions()` runs at the top of the
|
||||
draw function, and each view calls `addRegion` right where it draws the thing.
|
||||
Hit targets therefore cannot drift out of sync with what is on screen, which is
|
||||
the usual failure mode for hand-maintained click maps.
|
||||
- **Newest region wins.** `at()` searches backwards, so a view can register a
|
||||
broad row-wide fallback first (click anywhere on the HEX row to focus it) and
|
||||
then finer targets on top (click a specific nibble to put the cursor there).
|
||||
- **Draw-time registration means one source of truth.** The same loop that
|
||||
decides where a bit character goes also registers that cell, so the mapping
|
||||
from cell to bit index is written once. `registerDigitRegions` mirrors
|
||||
`drawFieldWithCursor`'s digit walk for the same reason.
|
||||
- **Left press only.** Release, motion, and drag are ignored so one physical
|
||||
click produces exactly one action.
|
||||
- **A fixed-size buffer, not an allocation.** Drawing happens on every keystroke
|
||||
and must not fail; an exhausted budget silently drops extra regions (a click
|
||||
does nothing) instead of erroring. 512 comfortably covers the worst case, the
|
||||
128-bit programmer view.
|
||||
|
||||
vxfw already puts the terminal in mouse mode and hit-tests surfaces, delivering
|
||||
`.mouse` events to the root widget with local coordinates equal to screen
|
||||
coordinates, so no terminal-level setup is needed here.
|
||||
|
||||
Click semantics are chosen per field to match what the data means: a bit grid
|
||||
cell and a BIN digit each represent exactly one bit, so clicking flips it, while
|
||||
a HEX nibble or OCT digit holds several bits with no single sensible "toggle", so
|
||||
clicking places the cursor for typing instead.
|
||||
|
||||
### 8.1 Standard Mode
|
||||
```
|
||||
┌─ Tally ─────────────────────── [Standard] [Programmer] [Financial] ─┐
|
||||
|
|
@ -733,6 +833,59 @@ IEEE 754 float interpretation:
|
|||
└──── a:abi e:endian Enter:edit struct Esc:back ─────────────────────┘
|
||||
```
|
||||
|
||||
### 8.4 Convert Mode
|
||||
|
||||
```
|
||||
Tally [Standard] [Programmer] [Convert]
|
||||
|
||||
Category:
|
||||
Length Mass Temperature Time Digital Storage Speed Area
|
||||
Volume Energy Pressure Data Rate Angle
|
||||
|
||||
100 km
|
||||
= 62.13711922373339 mi
|
||||
1 km = 0.621371192237334 mi
|
||||
|
||||
From To
|
||||
> nm nm
|
||||
um um
|
||||
mm mm
|
||||
... > mi
|
||||
|
||||
------------------------------------------------------------------
|
||||
> 100
|
||||
Arrows:select | Ctrl-S:swap | `:input | Tab:mode | ?:help
|
||||
```
|
||||
|
||||
Structure:
|
||||
|
||||
- **Category chips** wrap to as many lines as the terminal width needs. The
|
||||
selected chip is highlighted; when the selection zone has focus on the category
|
||||
column it is highlighted differently again.
|
||||
- **The value** comes from the shared input line at the bottom, keeping input
|
||||
consistent with the other modes. A bare number is used directly; anything else
|
||||
is evaluated as a standard expression first, so `2*3.5` or `sqrt(2)` work as
|
||||
input values.
|
||||
- **The factor line** shows the single multiplicative factor between the two
|
||||
units. Temperature is affine, so instead of a misleading number it says so.
|
||||
- **Two unit columns** (From and To) list every unit in the category with the
|
||||
selection marked. Both columns are clickable; the whole column slot is the
|
||||
click target, not just the name text, so a one-character unit like `m` is not a
|
||||
one-character target.
|
||||
- If the terminal is too short to show every unit, a `... N more (resize to see
|
||||
all)` line is drawn rather than silently truncating the list.
|
||||
|
||||
Interaction:
|
||||
|
||||
- Backtick toggles between the input zone and the selection zone, matching the
|
||||
convention already used in programmer mode.
|
||||
- In the selection zone, Left/Right move between the category, From, and To
|
||||
columns; Up/Down move the selection within the focused column, wrapping.
|
||||
- Ctrl-S swaps the two units, and works from either zone.
|
||||
- Selecting a new category resets the unit pair to that category's defaults (its
|
||||
base unit plus a distinct second unit), since the previous indices would refer
|
||||
to unrelated units in the new table.
|
||||
|
||||
---
|
||||
|
||||
## 9. Android UI Design
|
||||
|
|
|
|||
|
|
@ -67,23 +67,25 @@ A calculator application with three frontends (CLI, TUI, Android) sharing a comm
|
|||
|
||||
### FR-4: Unit Conversions
|
||||
|
||||
- **FR-4.1**: Support inline unit conversion syntax: `<value> <from_unit> to <to_unit>` (e.g., `100 km to miles`, `72 F to C`).
|
||||
- **FR-4.2**: Support unit conversions within expressions: `5 kg + 3 lb` evaluates in the left-hand unit.
|
||||
- **FR-4.3**: Length: mm, cm, m, km, in, ft, yd, mi, nm (nautical mile).
|
||||
- **FR-4.4**: Mass/Weight: mg, g, kg, oz, lb, ton, tonne.
|
||||
- **FR-4.5**: Temperature: C (Celsius), F (Fahrenheit), K (Kelvin).
|
||||
- **FR-4.6**: Time: ms, s, min, hr, day, week, year.
|
||||
- **FR-4.7**: Digital storage: bit, byte, KB, MB, GB, TB, PB (both SI and binary: KiB, MiB, GiB, TiB).
|
||||
- **FR-4.8**: Speed: m/s, km/h, mph, knots.
|
||||
- **FR-4.9**: Area: mm2, cm2, m2, km2, in2, ft2, acre, hectare.
|
||||
- **FR-4.10**: Volume: ml, L, gal, qt, pt, fl_oz, cm3, m3.
|
||||
- **FR-4.11**: Energy: J, kJ, cal, kcal, Wh, kWh, BTU.
|
||||
- **FR-4.12**: Pressure: Pa, kPa, bar, atm, psi, mmHg.
|
||||
- **FR-4.13**: Data rate: bps, Kbps, Mbps, Gbps, B/s, KB/s, MB/s, GB/s.
|
||||
- **FR-4.14**: Angle: deg, rad, grad, turn.
|
||||
- **FR-4.15**: In CLI, support `tally "100 km to miles"` and `tally convert 100 km miles`.
|
||||
- **FR-4.16**: In TUI, dedicated conversion panel accessible from any mode.
|
||||
- **FR-4.17**: Unit system extensible - adding a new category requires only a conversion table, no parser changes.
|
||||
- **FR-4.1**: Support inline unit conversion syntax: `<value> <from_unit> to <to_unit>` (e.g., `100 km to miles`, `72 F to C`). NOT YET IMPLEMENTED as expression syntax; currently provided by the CLI `convert` subcommand (FR-4.15) and the TUI convert mode (FR-4.16).
|
||||
- **FR-4.2**: Support unit conversions within expressions: `5 kg + 3 lb` evaluates in the left-hand unit. NOT YET IMPLEMENTED (requires unit-aware tokenizing and evaluation).
|
||||
- **FR-4.3**: Length: nm, um, mm, cm, m, km, in, ft, yd, mi, nmi (nautical mile), ly, au, pc. Base unit: m.
|
||||
- **FR-4.4**: Mass/Weight: mg, g, kg, t (tonne), oz, lb, st (stone), ton (US short), lt (long ton). Base unit: kg.
|
||||
- **FR-4.5**: Temperature: C (Celsius), F (Fahrenheit), K (Kelvin), R (Rankine). Base unit: C. These are the only affine conversions (they need an offset, not just a factor).
|
||||
- **FR-4.6**: Time: ns, us, ms, s, min, h, d, wk, yr. Base unit: s. A year is the Julian year (365.25 days), the usual unit-conversion convention.
|
||||
- **FR-4.7**: Digital storage: bit, B, and both decimal (kB, MB, GB, TB, PB) and binary (KiB, MiB, GiB, TiB, PiB) prefixes, kept distinct. Base unit: B.
|
||||
- **FR-4.8**: Speed: m/s, km/h, mph, ft/s, kn (knots), c (speed of light). Base unit: m/s.
|
||||
- **FR-4.9**: Area: mm2, cm2, m2, km2, in2, ft2, yd2, mi2, ha, acre. Base unit: m2.
|
||||
- **FR-4.10**: Volume: mL, L, m3, cm3, in3, ft3, gal, qt, pt, cup, floz, tbsp, tsp, bbl. Base unit: L.
|
||||
- **FR-4.11**: Energy: J, kJ, cal, kcal, Wh, kWh, BTU, eV, erg. Base unit: J.
|
||||
- **FR-4.12**: Pressure: Pa, kPa, MPa, bar, mbar, atm, psi, torr, mmHg, inHg. Base unit: Pa.
|
||||
- **FR-4.13**: Data rate: bps, kbps, Mbps, Gbps, Tbps, Bps, kBps, MBps, GBps. Base unit: bps.
|
||||
- **FR-4.14**: Angle: rad, mrad, deg, grad, turn, arcmin, arcsec. Base unit: rad.
|
||||
- **FR-4.15**: In CLI, support `tally convert <value> <from> <to>`, `tally convert <value> <from> to <to>`, and the glued form `tally convert 100km to mi`.
|
||||
- **FR-4.16**: In TUI, a dedicated convert mode reachable from the mode tabs.
|
||||
- **FR-4.17**: Unit system extensible - adding a unit or category requires only a table entry, no parser changes.
|
||||
- **FR-4.18**: Unit names resolve by canonical name or alias (long forms and plurals). An exact case-sensitive match is tried first so case-distinguished units keep their meaning (`K` is Kelvin, `B` is byte, `kB` is not `KiB`); a case-insensitive pass runs only as a fallback, so forgiving input like `KM` or `Celsius` still works.
|
||||
- **FR-4.19**: Converting between units of different categories is an error (`IncompatibleUnits`); an unrecognized unit name is an error (`UnknownUnit`).
|
||||
|
||||
### FR-5: Financial Mode
|
||||
|
||||
|
|
@ -114,11 +116,30 @@ A calculator application with three frontends (CLI, TUI, Android) sharing a comm
|
|||
- **FR-7.4**: Programmer mode struct sub-view: field list editor, live-updating memory map visualization.
|
||||
- **FR-7.5**: Financial mode: form-style input for parameters, result display with formula breakdown.
|
||||
- **FR-7.6**: Convert mode: select category, input value, select from/to units, live result.
|
||||
- **FR-7.7**: Keyboard-driven navigation; no mouse required (mouse optional enhancement).
|
||||
- **FR-7.7**: Every action must be reachable from the keyboard alone; the TUI is fully usable without a mouse.
|
||||
- **FR-7.8**: Quick-switch keys for base display in programmer mode (e.g., `d`=dec, `h`=hex, `o`=oct, `b`=bin to highlight primary).
|
||||
- **FR-7.9**: Support terminal resize gracefully.
|
||||
- **FR-7.10**: Vi-style and Emacs-style keybinding options for expression input.
|
||||
|
||||
#### FR-7.11: Mouse Support
|
||||
|
||||
Mouse input is a first-class way to drive the TUI, not an optional extra.
|
||||
Anything visibly interactive must respond to a left click. Keyboard parity is
|
||||
still required (FR-7.7): the mouse never becomes the only way to do something.
|
||||
|
||||
- **FR-7.11.1**: Clicking a mode tab switches to that mode.
|
||||
- **FR-7.11.2**: Clicking a bit in the programmer bit grid flips that bit and moves the cursor to it.
|
||||
- **FR-7.11.3**: Clicking a digit in the HEX or OCT row focuses that field and places the cursor on the exact digit clicked, ready for typing. A click does not alter the value, because a hex nibble or octal digit has no single unambiguous "toggle".
|
||||
- **FR-7.11.4**: Clicking a digit in the BIN row flips that bit, since one binary digit is exactly one bit.
|
||||
- **FR-7.11.5**: Clicking a DEC(s) or DEC(u) row focuses that field for editing.
|
||||
- **FR-7.11.6**: Clicking the `Bits:`, `Signed:`, or `Endian:` label in the programmer header toggles or cycles that setting.
|
||||
- **FR-7.11.7**: In the float view, clicking a bit flips it; clicking the format or exit hint in the header toggles f32/f64 or leaves the overlay.
|
||||
- **FR-7.11.8**: In convert mode, clicking a category chip selects that category (resetting the unit pair to that category's defaults); clicking a unit in the From or To column selects it.
|
||||
- **FR-7.11.9**: Clicking the input line returns keyboard focus to the expression prompt.
|
||||
- **FR-7.11.10**: Clicking anywhere dismisses the help overlay, matching its "any key dismisses" keyboard behavior.
|
||||
- **FR-7.11.11**: Only a left button press acts. Release, motion, and drag events are ignored so a single click cannot fire an action twice.
|
||||
- **FR-7.11.12**: Clickable regions are rebuilt every frame from what was actually drawn, so hit targets can never drift out of sync with the display.
|
||||
|
||||
### FR-8: Android Frontend
|
||||
|
||||
- **FR-8.1**: Native Android app using Kotlin and Jetpack Compose.
|
||||
|
|
|
|||
|
|
@ -134,14 +134,28 @@ function arg commas).
|
|||
- Return step-by-step formula string alongside numeric result
|
||||
- Verify: unit tests against known financial calculation results (textbook examples)
|
||||
|
||||
### Task 2.6: Implement unit conversion engine
|
||||
- Create `engine/src/units.zig`
|
||||
- Define `UnitCategory` enum and `UnitDef` struct (name, aliases, category, conversion factors)
|
||||
- Implement conversion tables for all categories: length, mass, temperature, time, digital storage, speed, area, volume, energy, pressure, data rate, angle
|
||||
- Implement `convert(value: f64, from: UnitDef, to: UnitDef) !f64`
|
||||
- Temperature special case: formula-based conversion (C/F/K with offsets)
|
||||
- Implement unit name resolution: given a string, find the matching `UnitDef` (supports aliases)
|
||||
- Verify: unit tests for all conversion categories, round-trip accuracy, unknown unit error handling
|
||||
### Task 2.6: Implement unit conversion engine [DONE]
|
||||
- Created `engine/src/units.zig`
|
||||
- `UnitCategory` enum (12 categories) with `label()` and `baseUnit()`
|
||||
- `UnitDef` struct (name, aliases, category, to_base_factor, to_base_offset) with
|
||||
`toBase()`, `fromBase()`, `isLinear()`
|
||||
- Conversion tables for all 12 categories: length, mass, temperature, time,
|
||||
digital storage, speed, area, volume, energy, pressure, data rate, angle
|
||||
- `convert(value, from_name, to_name) -> ConvertResult` and
|
||||
`convertUnits(value, from, to) -> f64`
|
||||
- Temperature is the only affine category (F/K/R need offsets); base is Celsius
|
||||
- `findUnit`: exact case-sensitive match first, case-insensitive fallback, so
|
||||
`K`/`B`/`kB` keep their meaning while `KM` and `Celsius` still resolve
|
||||
- DESIGN CHANGE from the original spec: only the to-base mapping is stored and
|
||||
the inverse is derived in `fromBase()`, instead of storing a second
|
||||
factor/offset pair. One source of truth per unit means a table entry cannot
|
||||
disagree with its own reverse (see design.md 4.1)
|
||||
- 77 unit tests: known-answer conversions per category, temperature offsets and
|
||||
the -40 crossover, alias resolution, error paths, plus invariant tests (every
|
||||
unit round-trips, every within-category pair round-trips, base units are
|
||||
identity, names unique, no zero factors)
|
||||
- Verify: `zig build test` passes; CLI spot-checks match reference values
|
||||
(5280 ft/mi, 1024 MiB/GiB, 14.6959 psi/atm, 1 c = 1079252848.8 km/h)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -191,19 +205,25 @@ function arg commas).
|
|||
### Task 4.1: Implement CLI argument parsing [DONE]
|
||||
- Single binary at `src/main.zig` (not separate cli/)
|
||||
- Parse args: positional expression (multi-arg joined with spaces), `-p`/`--programmer`, `--help`, `--version`
|
||||
- Subcommands (`struct`, `cagr`, `tvm`, `convert`) NOT YET IMPLEMENTED
|
||||
- Subcommand `convert` IMPLEMENTED: `tally convert <value> <from> [to] <to>`,
|
||||
including the glued form `tally convert 100km to mi` (numeric prefix split from
|
||||
the unit suffix, with `e`/`E` only treated as an exponent when followed by a
|
||||
digit or sign so `5eV` parses as 5 eV)
|
||||
- Subcommands (`struct`, `cagr`, `tvm`) NOT YET IMPLEMENTED
|
||||
- Verify: unit tests for arg parsing; `--help` prints usage
|
||||
|
||||
NOTE: Multi-arg joining implemented (`tally 2 + 2` works same as `tally "2+2"`).
|
||||
Subcommands deferred until Phase 2 engine work is done.
|
||||
Remaining subcommands deferred until their engine modules exist.
|
||||
|
||||
### Task 4.2: Implement CLI output formatting [DONE]
|
||||
- Standard mode: print result with comma formatting
|
||||
- Programmer mode: print multi-base table (dec signed, dec unsigned, hex, bin)
|
||||
- Convert: `formatConversion` prints `100 km = 62.13711922373339 mi`, resolving
|
||||
aliases to canonical names
|
||||
- Struct mode: NOT YET IMPLEMENTED
|
||||
- Financial mode: NOT YET IMPLEMENTED
|
||||
- `--json` flag: NOT YET IMPLEMENTED
|
||||
- Verify: unit tests for evaluate function output
|
||||
- Verify: unit tests for evaluate and formatConversion output
|
||||
|
||||
### Task 4.3: CLI integration and error handling [DONE]
|
||||
- Wire arg parser -> engine calls -> output formatter
|
||||
|
|
@ -334,20 +354,61 @@ Subcommands deferred until Phase 2 engine work is done.
|
|||
- Result display with formula breakdown
|
||||
- Verify: can input values, compute results, see formula steps
|
||||
|
||||
### Task 5.7: Implement unit conversion TUI view
|
||||
- Create `tui/src/views/convert.zig`
|
||||
- Category selector (navigable list or hotkeys)
|
||||
- From-value input field, from-unit selector
|
||||
- To-unit selector, live-updating result
|
||||
- Quick swap (from ↔ to) key
|
||||
- Show conversion factor used
|
||||
- Verify: can select category, units, enter value, see live result update
|
||||
### Task 5.7: Implement unit conversion TUI view [DONE]
|
||||
- Created `src/tui/convert.zig`; convert is now a third top-level mode and Tab
|
||||
cycles Standard -> Programmer -> Convert
|
||||
- Category chips wrap to the terminal width, selected chip highlighted
|
||||
- From/To unit columns list every unit in the category with the selection marked
|
||||
- Live result plus a conversion-factor line (or an "affine conversion" note for
|
||||
temperature, where no single factor applies)
|
||||
- Value comes from the shared input line; a bare number is used directly and
|
||||
anything else is evaluated as a standard expression first (so `2*3.5` works)
|
||||
- Backtick toggles input/selection zone (same convention as programmer mode);
|
||||
arrows navigate columns and selections with wrapping; Ctrl-S swaps units
|
||||
- Changing category resets the unit pair to that category's defaults
|
||||
- Graceful degradation: if the terminal is too short, shows "... N more"
|
||||
- Fully mouse-clickable (see Task 5.9)
|
||||
- Verify: category switching filters units, selection updates the live result,
|
||||
swap works, conversions match the CLI
|
||||
|
||||
### Task 5.8: TUI polish and keybindings
|
||||
- Implement `?` for help overlay showing all keybindings
|
||||
- Implement vi-style input mode (optional: Esc for normal mode, i for insert) - configurable
|
||||
- Mouse support: click on bit grid to toggle, click on tabs to switch modes
|
||||
- Color theme (sensible defaults, respects terminal capabilities)
|
||||
### Task 5.9: TUI mouse support [DONE]
|
||||
- Requirement documented as FR-7.11 (mouse is first-class, with keyboard parity
|
||||
still required by FR-7.7); architecture in design.md 8.0
|
||||
- `RegionSet` in `src/tui.zig`: fixed 512-entry table of `{row, col, len, action}`
|
||||
rebuilt every frame during drawing, so hit targets cannot drift out of sync
|
||||
with what is displayed. Newest-registered region wins, letting views layer a
|
||||
row-wide fallback under finer per-digit targets.
|
||||
- `Action` union covers mode tabs, programmer field focus (with optional bit
|
||||
cursor), bit toggling, input focus, help dismissal, float overlay + format,
|
||||
width/endian/signedness toggles, and all convert-mode selections
|
||||
- Only left button press is acted on, so one click fires exactly one action
|
||||
- Wired into every view: mode tabs, standard mode input, programmer mode (bit
|
||||
grid, HEX/OCT digit cursor placement, BIN bit flipping, DEC row focus,
|
||||
Bits/Signed/Endian labels), float view (bits, format/exit hints), convert mode
|
||||
(category chips, both unit columns), and the help overlay
|
||||
- Click semantics follow the data: a bit grid cell and a BIN digit are one bit so
|
||||
clicking flips them; a HEX nibble or OCT digit spans several bits with no single
|
||||
meaningful toggle, so clicking places the cursor for typing
|
||||
- Extracted shared state helpers (`setMode`, `cycleBitWidth`, `toggleEndian`,
|
||||
`toggleSignedness`, `toggleFloatFormat`, `setConvCategory`, `swapConvUnits`) so
|
||||
keyboard and mouse paths cannot diverge
|
||||
- `build.zig`: added a `tui_tests` target rooted at `src/tui.zig`. The CLI test
|
||||
target roots at `src/main.zig`, which only reaches `tui.zig` from `main()`, and
|
||||
`main()` is never analyzed in test mode, so TUI tests were being silently
|
||||
skipped
|
||||
- 12 unit tests for the pure logic: region hit/miss boundaries, newest-wins
|
||||
layering, clear, zero-length rejection, budget exhaustion, action payloads,
|
||||
`wrapIndex` wrapping/clamping, and `defaultUnitIndices` invariants
|
||||
- Verify: `zig build test` runs the TUI tests (366 total); interactive clicking
|
||||
needs a manual pass since the overlay is terminal-only
|
||||
|
||||
### Task 5.8: TUI polish and keybindings [PARTIAL]
|
||||
- DONE: `?` help overlay showing keybindings, mouse actions, functions, operators
|
||||
- DONE: mouse support (see Task 5.9): clicking tabs, bits, digits, config labels,
|
||||
convert categories/units, and the input line
|
||||
- DONE: Molokai color theme
|
||||
- NOT DONE: vi-style input mode (optional: Esc for normal mode, i for insert)
|
||||
- NOT DONE: mouse wheel scrolling for history
|
||||
- Verify: help overlay works, mouse interactions work, looks reasonable in 80x24 terminal
|
||||
|
||||
---
|
||||
|
|
|
|||
18
build.zig
18
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) --
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ pub const evaluator = @import("evaluator.zig");
|
|||
pub const programmer = @import("programmer.zig");
|
||||
pub const formatter = @import("formatter.zig");
|
||||
pub const float_interp = @import("float_interp.zig");
|
||||
pub const units = @import("units.zig");
|
||||
|
||||
// Re-export primary types for convenience
|
||||
pub const Value = types.Value;
|
||||
|
|
@ -31,6 +32,13 @@ pub const FloatFormat = float_interp.FloatFormat;
|
|||
pub const FloatClass = float_interp.FloatClass;
|
||||
pub const FloatInfo = float_interp.FloatInfo;
|
||||
|
||||
// Unit conversion
|
||||
pub const UnitCategory = units.UnitCategory;
|
||||
pub const UnitDef = units.UnitDef;
|
||||
pub const ConvertResult = units.ConvertResult;
|
||||
pub const convert = units.convert;
|
||||
pub const findUnit = units.findUnit;
|
||||
|
||||
test {
|
||||
std.testing.refAllDecls(@This());
|
||||
}
|
||||
|
|
|
|||
344
engine/src/float_interp.zig
Normal file
344
engine/src/float_interp.zig
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
//! IEEE 754 float interpretation for Tally programmer mode.
|
||||
//!
|
||||
//! Reinterprets a raw bit pattern as an IEEE 754 binary float (binary32 or
|
||||
//! binary64), decomposing it into sign / exponent / significand, classifying
|
||||
//! it (normal, denormal, zero, infinity, quiet/signaling NaN), and reporting
|
||||
//! the value and the ULP (the size of one step in the last place). Also goes
|
||||
//! the other way: given a decimal value, find the nearest representable bit
|
||||
//! pattern and report whether rounding occurred.
|
||||
//!
|
||||
//! No allocation, no I/O. Everything is computed from the bit pattern.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
/// Supported IEEE 754 binary interchange formats.
|
||||
pub const FloatFormat = enum {
|
||||
f32,
|
||||
f64,
|
||||
|
||||
/// Total width in bits (1 sign + exponent + significand).
|
||||
pub fn totalBits(self: FloatFormat) u8 {
|
||||
return switch (self) {
|
||||
.f32 => 32,
|
||||
.f64 => 64,
|
||||
};
|
||||
}
|
||||
|
||||
/// Number of exponent bits.
|
||||
pub fn exponentBits(self: FloatFormat) u8 {
|
||||
return switch (self) {
|
||||
.f32 => 8,
|
||||
.f64 => 11,
|
||||
};
|
||||
}
|
||||
|
||||
/// Number of significand (fraction) bits, excluding the implicit leading bit.
|
||||
pub fn significandBits(self: FloatFormat) u8 {
|
||||
return switch (self) {
|
||||
.f32 => 23,
|
||||
.f64 => 52,
|
||||
};
|
||||
}
|
||||
|
||||
/// Exponent bias.
|
||||
pub fn bias(self: FloatFormat) i32 {
|
||||
return switch (self) {
|
||||
.f32 => 127,
|
||||
.f64 => 1023,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// IEEE 754 value classification.
|
||||
pub const FloatClass = enum {
|
||||
zero,
|
||||
denormal,
|
||||
normal,
|
||||
infinity,
|
||||
quiet_nan,
|
||||
signaling_nan,
|
||||
|
||||
/// Human-readable label.
|
||||
pub fn label(self: FloatClass) []const u8 {
|
||||
return switch (self) {
|
||||
.zero => "zero",
|
||||
.denormal => "denormal (subnormal)",
|
||||
.normal => "normal",
|
||||
.infinity => "infinity",
|
||||
.quiet_nan => "quiet NaN",
|
||||
.signaling_nan => "signaling NaN",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Decomposition of a bit pattern interpreted as an IEEE 754 float.
|
||||
pub const FloatInfo = struct {
|
||||
format: FloatFormat,
|
||||
/// Raw bit pattern, masked to the format width.
|
||||
bits: u64,
|
||||
/// Sign bit: 0 = positive, 1 = negative.
|
||||
sign: u1,
|
||||
/// Raw (biased) exponent field.
|
||||
raw_exponent: u32,
|
||||
/// Effective exponent used in the value formula. For normal numbers this is
|
||||
/// raw_exponent - bias; for zero/denormal it is 1 - bias. Not meaningful for
|
||||
/// infinity/NaN (set to raw_exponent - bias for reference).
|
||||
unbiased_exponent: i32,
|
||||
/// Raw significand (fraction) field, excluding the implicit leading bit.
|
||||
significand: u64,
|
||||
class: FloatClass,
|
||||
/// The value as an f64 (exact for f32 inputs; identity for f64 inputs).
|
||||
value: f64,
|
||||
/// Size of one step in the last place at this value. NaN for inf/NaN inputs.
|
||||
ulp: f64,
|
||||
};
|
||||
|
||||
/// Reinterpret the low bits of `bits` as a value of the given float format.
|
||||
pub fn reinterpretValue(format: FloatFormat, bits: u64) f64 {
|
||||
return switch (format) {
|
||||
.f32 => @floatCast(@as(f32, @bitCast(@as(u32, @truncate(bits))))),
|
||||
.f64 => @bitCast(bits),
|
||||
};
|
||||
}
|
||||
|
||||
/// Decompose a raw bit pattern into its IEEE 754 fields and derived info.
|
||||
/// Bits beyond the format width are ignored (masked off).
|
||||
pub fn decompose(format: FloatFormat, raw_bits: u64) FloatInfo {
|
||||
const total = format.totalBits();
|
||||
const exp_bits = format.exponentBits();
|
||||
const sig_bits = format.significandBits();
|
||||
const bias = format.bias();
|
||||
|
||||
const width_mask: u64 = if (total >= 64) ~@as(u64, 0) else (@as(u64, 1) << @intCast(total)) - 1;
|
||||
const bits = raw_bits & width_mask;
|
||||
|
||||
const sign: u1 = @intCast((bits >> @intCast(total - 1)) & 1);
|
||||
const exp_field_mask: u64 = (@as(u64, 1) << @intCast(exp_bits)) - 1;
|
||||
const raw_exp: u32 = @intCast((bits >> @intCast(sig_bits)) & exp_field_mask);
|
||||
const sig_field_mask: u64 = (@as(u64, 1) << @intCast(sig_bits)) - 1;
|
||||
const significand: u64 = bits & sig_field_mask;
|
||||
const all_ones_exp: u32 = @intCast(exp_field_mask);
|
||||
|
||||
// SAFETY: class is assigned in every branch of the if/else below before use.
|
||||
var class: FloatClass = undefined;
|
||||
var unbiased: i32 = @as(i32, @intCast(raw_exp)) - bias;
|
||||
if (raw_exp == 0) {
|
||||
class = if (significand == 0) .zero else .denormal;
|
||||
// Denormals (and zero) use a fixed exponent of 1 - bias.
|
||||
unbiased = 1 - bias;
|
||||
} else if (raw_exp == all_ones_exp) {
|
||||
if (significand == 0) {
|
||||
class = .infinity;
|
||||
} else {
|
||||
// The most significant significand bit is the quiet/signaling flag.
|
||||
const quiet_bit = (significand >> @intCast(sig_bits - 1)) & 1;
|
||||
class = if (quiet_bit == 1) .quiet_nan else .signaling_nan;
|
||||
}
|
||||
} else {
|
||||
class = .normal;
|
||||
}
|
||||
|
||||
return .{
|
||||
.format = format,
|
||||
.bits = bits,
|
||||
.sign = sign,
|
||||
.raw_exponent = raw_exp,
|
||||
.unbiased_exponent = unbiased,
|
||||
.significand = significand,
|
||||
.class = class,
|
||||
.value = reinterpretValue(format, bits),
|
||||
.ulp = computeUlp(format, class, unbiased),
|
||||
};
|
||||
}
|
||||
|
||||
/// Result of finding the nearest representable bit pattern for a value.
|
||||
pub const NearestResult = struct {
|
||||
bits: u64,
|
||||
/// True if the value could not be represented exactly in the format and was
|
||||
/// rounded to the nearest representable value.
|
||||
rounded: bool,
|
||||
};
|
||||
|
||||
/// Find the bit pattern of the nearest representable value in the given format.
|
||||
/// Reports whether rounding occurred (only detectable for f32; an f64 input is
|
||||
/// already an f64 so no further rounding happens here).
|
||||
pub fn nearestBits(format: FloatFormat, value: f64) NearestResult {
|
||||
return switch (format) {
|
||||
.f32 => blk: {
|
||||
const as_f32: f32 = @floatCast(value);
|
||||
const back: f64 = @floatCast(as_f32);
|
||||
const raw32: u32 = @bitCast(as_f32);
|
||||
// NaN never equals itself, so treat NaN->NaN as not rounded.
|
||||
const both_nan = std.math.isNan(value) and std.math.isNan(back);
|
||||
break :blk .{ .bits = raw32, .rounded = (back != value) and !both_nan };
|
||||
},
|
||||
.f64 => .{ .bits = @bitCast(value), .rounded = false },
|
||||
};
|
||||
}
|
||||
|
||||
fn computeUlp(format: FloatFormat, class: FloatClass, unbiased_exponent: i32) f64 {
|
||||
const sig_bits: i32 = @intCast(format.significandBits());
|
||||
return switch (class) {
|
||||
.normal => std.math.ldexp(@as(f64, 1.0), unbiased_exponent - sig_bits),
|
||||
// Every subnormal (and zero) steps by the smallest positive subnormal.
|
||||
.zero, .denormal => std.math.ldexp(@as(f64, 1.0), (1 - format.bias()) - sig_bits),
|
||||
.infinity, .quiet_nan, .signaling_nan => std.math.nan(f64),
|
||||
};
|
||||
}
|
||||
|
||||
// -- Tests --
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "f32: 1.0 is 0x3F800000, normal" {
|
||||
const info = decompose(.f32, 0x3F800000);
|
||||
try testing.expectEqual(FloatClass.normal, info.class);
|
||||
try testing.expectEqual(@as(u1, 0), info.sign);
|
||||
try testing.expectEqual(@as(u32, 127), info.raw_exponent);
|
||||
try testing.expectEqual(@as(i32, 0), info.unbiased_exponent);
|
||||
try testing.expectEqual(@as(u64, 0), info.significand);
|
||||
try testing.expectEqual(@as(f64, 1.0), info.value);
|
||||
// ULP of 1.0 in f32 is 2^-23
|
||||
try testing.expectApproxEqAbs(std.math.ldexp(@as(f64, 1.0), -23), info.ulp, 0);
|
||||
}
|
||||
|
||||
test "f32: -2.0 sign and exponent" {
|
||||
// -2.0 = 0xC0000000: sign 1, exp 128 (unbiased 1), frac 0
|
||||
const info = decompose(.f32, 0xC0000000);
|
||||
try testing.expectEqual(@as(u1, 1), info.sign);
|
||||
try testing.expectEqual(@as(i32, 1), info.unbiased_exponent);
|
||||
try testing.expectEqual(@as(f64, -2.0), info.value);
|
||||
try testing.expectEqual(FloatClass.normal, info.class);
|
||||
}
|
||||
|
||||
test "f32: positive zero" {
|
||||
const info = decompose(.f32, 0x00000000);
|
||||
try testing.expectEqual(FloatClass.zero, info.class);
|
||||
try testing.expectEqual(@as(f64, 0.0), info.value);
|
||||
try testing.expectEqual(@as(u1, 0), info.sign);
|
||||
}
|
||||
|
||||
test "f32: negative zero" {
|
||||
const info = decompose(.f32, 0x80000000);
|
||||
try testing.expectEqual(FloatClass.zero, info.class);
|
||||
try testing.expectEqual(@as(u1, 1), info.sign);
|
||||
}
|
||||
|
||||
test "f32: positive infinity" {
|
||||
const info = decompose(.f32, 0x7F800000);
|
||||
try testing.expectEqual(FloatClass.infinity, info.class);
|
||||
try testing.expectEqual(@as(u1, 0), info.sign);
|
||||
try testing.expect(std.math.isPositiveInf(info.value));
|
||||
try testing.expect(std.math.isNan(info.ulp));
|
||||
}
|
||||
|
||||
test "f32: negative infinity" {
|
||||
const info = decompose(.f32, 0xFF800000);
|
||||
try testing.expectEqual(FloatClass.infinity, info.class);
|
||||
try testing.expectEqual(@as(u1, 1), info.sign);
|
||||
try testing.expect(std.math.isNegativeInf(info.value));
|
||||
}
|
||||
|
||||
test "f32: quiet NaN" {
|
||||
// exponent all ones, top significand bit set
|
||||
const info = decompose(.f32, 0x7FC00000);
|
||||
try testing.expectEqual(FloatClass.quiet_nan, info.class);
|
||||
try testing.expect(std.math.isNan(info.value));
|
||||
}
|
||||
|
||||
test "f32: signaling NaN" {
|
||||
// exponent all ones, top significand bit clear, some other bit set
|
||||
const info = decompose(.f32, 0x7F800001);
|
||||
try testing.expectEqual(FloatClass.signaling_nan, info.class);
|
||||
try testing.expect(std.math.isNan(info.value));
|
||||
}
|
||||
|
||||
test "f32: smallest positive denormal" {
|
||||
// 0x00000001 = 2^-149
|
||||
const info = decompose(.f32, 0x00000001);
|
||||
try testing.expectEqual(FloatClass.denormal, info.class);
|
||||
try testing.expectEqual(@as(u64, 1), info.significand);
|
||||
try testing.expectApproxEqAbs(std.math.ldexp(@as(f64, 1.0), -149), info.value, 0);
|
||||
// ULP for subnormals is the smallest positive subnormal itself
|
||||
try testing.expectApproxEqAbs(std.math.ldexp(@as(f64, 1.0), -149), info.ulp, 0);
|
||||
}
|
||||
|
||||
test "f32: largest denormal is just below smallest normal" {
|
||||
const info = decompose(.f32, 0x007FFFFF);
|
||||
try testing.expectEqual(FloatClass.denormal, info.class);
|
||||
}
|
||||
|
||||
test "f64: 1.0 is 0x3FF0000000000000, normal" {
|
||||
const info = decompose(.f64, 0x3FF0000000000000);
|
||||
try testing.expectEqual(FloatClass.normal, info.class);
|
||||
try testing.expectEqual(@as(u32, 1023), info.raw_exponent);
|
||||
try testing.expectEqual(@as(i32, 0), info.unbiased_exponent);
|
||||
try testing.expectEqual(@as(f64, 1.0), info.value);
|
||||
try testing.expectApproxEqAbs(std.math.ldexp(@as(f64, 1.0), -52), info.ulp, 0);
|
||||
}
|
||||
|
||||
test "f64: pi bit pattern" {
|
||||
const info = decompose(.f64, 0x400921FB54442D18);
|
||||
try testing.expectEqual(FloatClass.normal, info.class);
|
||||
try testing.expectApproxEqAbs(std.math.pi, info.value, 1e-15);
|
||||
}
|
||||
|
||||
test "f64: infinity and NaN" {
|
||||
try testing.expectEqual(FloatClass.infinity, decompose(.f64, 0x7FF0000000000000).class);
|
||||
try testing.expectEqual(FloatClass.quiet_nan, decompose(.f64, 0x7FF8000000000000).class);
|
||||
try testing.expectEqual(FloatClass.signaling_nan, decompose(.f64, 0x7FF0000000000001).class);
|
||||
}
|
||||
|
||||
test "decompose ignores bits above format width" {
|
||||
// High garbage bits should be masked off for f32.
|
||||
const info = decompose(.f32, 0xFFFF_FFFF_3F80_0000);
|
||||
try testing.expectEqual(@as(f64, 1.0), info.value);
|
||||
try testing.expectEqual(@as(u64, 0x3F800000), info.bits);
|
||||
}
|
||||
|
||||
test "nearestBits: 1.0 exact in f32" {
|
||||
const r = nearestBits(.f32, 1.0);
|
||||
try testing.expectEqual(@as(u64, 0x3F800000), r.bits);
|
||||
try testing.expect(!r.rounded);
|
||||
}
|
||||
|
||||
test "nearestBits: 3.14 rounds in f32" {
|
||||
const r = nearestBits(.f32, 3.14);
|
||||
try testing.expect(r.rounded);
|
||||
// Round-trips through decompose to the nearest f32 to 3.14
|
||||
const info = decompose(.f32, r.bits);
|
||||
try testing.expectApproxEqAbs(@as(f64, 3.14), info.value, 1e-6);
|
||||
}
|
||||
|
||||
test "nearestBits: 0.5 exact in f32" {
|
||||
const r = nearestBits(.f32, 0.5);
|
||||
try testing.expect(!r.rounded);
|
||||
try testing.expectEqual(@as(u64, 0x3F000000), r.bits);
|
||||
}
|
||||
|
||||
test "nearestBits: f64 input never reports rounding" {
|
||||
const r = nearestBits(.f64, 3.14);
|
||||
try testing.expect(!r.rounded);
|
||||
try testing.expectEqual(@as(u64, @bitCast(@as(f64, 3.14))), r.bits);
|
||||
}
|
||||
|
||||
test "nearestBits: value too large for f32 overflows to infinity" {
|
||||
const r = nearestBits(.f32, 1e40);
|
||||
try testing.expect(r.rounded);
|
||||
try testing.expectEqual(FloatClass.infinity, decompose(.f32, r.bits).class);
|
||||
}
|
||||
|
||||
test "nearestBits: NaN is not reported as rounded" {
|
||||
const r = nearestBits(.f32, std.math.nan(f64));
|
||||
try testing.expect(!r.rounded);
|
||||
try testing.expectEqual(FloatClass.quiet_nan, decompose(.f32, r.bits).class);
|
||||
}
|
||||
|
||||
test "round trip: decompose then nearestBits for a range of f32 patterns" {
|
||||
const patterns = [_]u32{ 0x3F800000, 0xC0490FDB, 0x00000001, 0x7F7FFFFF, 0x80000000 };
|
||||
for (patterns) |p| {
|
||||
const info = decompose(.f32, p);
|
||||
const r = nearestBits(.f32, info.value);
|
||||
try testing.expectEqual(@as(u64, p), r.bits);
|
||||
}
|
||||
}
|
||||
777
engine/src/units.zig
Normal file
777
engine/src/units.zig
Normal file
|
|
@ -0,0 +1,777 @@
|
|||
//! Unit conversion engine for Tally.
|
||||
//!
|
||||
//! Units are grouped into categories, each with a canonical base unit. Every
|
||||
//! unit declares an affine mapping to its category's base unit:
|
||||
//!
|
||||
//! base = value * to_base_factor + to_base_offset
|
||||
//!
|
||||
//! and the reverse direction is derived by inverting that mapping:
|
||||
//!
|
||||
//! value = (base - to_base_offset) / to_base_factor
|
||||
//!
|
||||
//! Deriving the inverse (rather than storing a second factor/offset pair) keeps
|
||||
//! a single source of truth per unit, so a table entry cannot describe a
|
||||
//! conversion that disagrees with its own reverse.
|
||||
//!
|
||||
//! Most units have a zero offset and so convert by pure scaling. Temperature is
|
||||
//! the exception: Fahrenheit, Kelvin, and Rankine all need the offset term.
|
||||
//!
|
||||
//! No allocation, no I/O. Adding a unit means adding a table entry.
|
||||
|
||||
const std = @import("std");
|
||||
const types = @import("types.zig");
|
||||
const CalcError = types.CalcError;
|
||||
|
||||
pub const UnitCategory = enum {
|
||||
length,
|
||||
mass,
|
||||
temperature,
|
||||
time,
|
||||
digital_storage,
|
||||
speed,
|
||||
area,
|
||||
volume,
|
||||
energy,
|
||||
pressure,
|
||||
data_rate,
|
||||
angle,
|
||||
|
||||
/// Human-readable category name for menus and help text.
|
||||
pub fn label(self: UnitCategory) []const u8 {
|
||||
return switch (self) {
|
||||
.length => "Length",
|
||||
.mass => "Mass",
|
||||
.temperature => "Temperature",
|
||||
.time => "Time",
|
||||
.digital_storage => "Digital Storage",
|
||||
.speed => "Speed",
|
||||
.area => "Area",
|
||||
.volume => "Volume",
|
||||
.energy => "Energy",
|
||||
.pressure => "Pressure",
|
||||
.data_rate => "Data Rate",
|
||||
.angle => "Angle",
|
||||
};
|
||||
}
|
||||
|
||||
/// The canonical base unit name for this category.
|
||||
pub fn baseUnit(self: UnitCategory) []const u8 {
|
||||
return switch (self) {
|
||||
.length => "m",
|
||||
.mass => "kg",
|
||||
.temperature => "C",
|
||||
.time => "s",
|
||||
.digital_storage => "B",
|
||||
.speed => "m/s",
|
||||
.area => "m2",
|
||||
.volume => "L",
|
||||
.energy => "J",
|
||||
.pressure => "Pa",
|
||||
.data_rate => "bps",
|
||||
.angle => "rad",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub const UnitDef = struct {
|
||||
/// Canonical short name (e.g. "km").
|
||||
name: []const u8,
|
||||
/// Alternative spellings, including long forms and plurals.
|
||||
aliases: []const []const u8 = &.{},
|
||||
category: UnitCategory,
|
||||
/// base = value * to_base_factor + to_base_offset
|
||||
to_base_factor: f64,
|
||||
to_base_offset: f64 = 0,
|
||||
|
||||
/// Convert a value in this unit to the category's base unit.
|
||||
pub fn toBase(self: UnitDef, value: f64) f64 {
|
||||
return value * self.to_base_factor + self.to_base_offset;
|
||||
}
|
||||
|
||||
/// Convert a value in the category's base unit to this unit.
|
||||
pub fn fromBase(self: UnitDef, base: f64) f64 {
|
||||
return (base - self.to_base_offset) / self.to_base_factor;
|
||||
}
|
||||
|
||||
/// True if this unit maps to its base by pure scaling (no offset).
|
||||
pub fn isLinear(self: UnitDef) bool {
|
||||
return self.to_base_offset == 0;
|
||||
}
|
||||
};
|
||||
|
||||
/// Result of a conversion, including metadata for display.
|
||||
pub const ConvertResult = struct {
|
||||
value: f64,
|
||||
from: UnitDef,
|
||||
to: UnitDef,
|
||||
category: UnitCategory,
|
||||
/// The single multiplicative factor relating the two units, when the
|
||||
/// conversion is purely linear. Null for affine conversions (temperature),
|
||||
/// where no single factor describes the relationship.
|
||||
factor: ?f64,
|
||||
};
|
||||
|
||||
// -- Unit tables --
|
||||
//
|
||||
// Each table's base unit has to_base_factor = 1 and no offset.
|
||||
|
||||
const length_units = [_]UnitDef{
|
||||
.{ .name = "nm", .aliases = &.{ "nanometer", "nanometers", "nanometre", "nanometres" }, .category = .length, .to_base_factor = 1e-9 },
|
||||
.{ .name = "um", .aliases = &.{ "micrometer", "micrometers", "micron", "microns" }, .category = .length, .to_base_factor = 1e-6 },
|
||||
.{ .name = "mm", .aliases = &.{ "millimeter", "millimeters", "millimetre", "millimetres" }, .category = .length, .to_base_factor = 0.001 },
|
||||
.{ .name = "cm", .aliases = &.{ "centimeter", "centimeters", "centimetre", "centimetres" }, .category = .length, .to_base_factor = 0.01 },
|
||||
.{ .name = "m", .aliases = &.{ "meter", "meters", "metre", "metres" }, .category = .length, .to_base_factor = 1.0 },
|
||||
.{ .name = "km", .aliases = &.{ "kilometer", "kilometers", "kilometre", "kilometres" }, .category = .length, .to_base_factor = 1000.0 },
|
||||
.{ .name = "in", .aliases = &.{ "inch", "inches" }, .category = .length, .to_base_factor = 0.0254 },
|
||||
.{ .name = "ft", .aliases = &.{ "foot", "feet" }, .category = .length, .to_base_factor = 0.3048 },
|
||||
.{ .name = "yd", .aliases = &.{ "yard", "yards" }, .category = .length, .to_base_factor = 0.9144 },
|
||||
.{ .name = "mi", .aliases = &.{ "mile", "miles" }, .category = .length, .to_base_factor = 1609.344 },
|
||||
.{ .name = "nmi", .aliases = &.{ "nauticalmile", "nauticalmiles" }, .category = .length, .to_base_factor = 1852.0 },
|
||||
.{ .name = "ly", .aliases = &.{ "lightyear", "lightyears" }, .category = .length, .to_base_factor = 9.4607304725808e15 },
|
||||
.{ .name = "au", .aliases = &.{ "astronomicalunit", "astronomicalunits" }, .category = .length, .to_base_factor = 1.495978707e11 },
|
||||
.{ .name = "pc", .aliases = &.{ "parsec", "parsecs" }, .category = .length, .to_base_factor = 3.0856775814913673e16 },
|
||||
};
|
||||
|
||||
const mass_units = [_]UnitDef{
|
||||
.{ .name = "mg", .aliases = &.{ "milligram", "milligrams" }, .category = .mass, .to_base_factor = 1e-6 },
|
||||
.{ .name = "g", .aliases = &.{ "gram", "grams" }, .category = .mass, .to_base_factor = 0.001 },
|
||||
.{ .name = "kg", .aliases = &.{ "kilogram", "kilograms" }, .category = .mass, .to_base_factor = 1.0 },
|
||||
.{ .name = "t", .aliases = &.{ "tonne", "tonnes", "metricton", "metrictons" }, .category = .mass, .to_base_factor = 1000.0 },
|
||||
.{ .name = "oz", .aliases = &.{ "ounce", "ounces" }, .category = .mass, .to_base_factor = 0.028349523125 },
|
||||
.{ .name = "lb", .aliases = &.{ "lbs", "pound", "pounds" }, .category = .mass, .to_base_factor = 0.45359237 },
|
||||
.{ .name = "st", .aliases = &.{ "stone", "stones" }, .category = .mass, .to_base_factor = 6.35029318 },
|
||||
.{ .name = "ton", .aliases = &.{ "shortton", "shorttons" }, .category = .mass, .to_base_factor = 907.18474 },
|
||||
.{ .name = "lt", .aliases = &.{ "longton", "longtons" }, .category = .mass, .to_base_factor = 1016.0469088 },
|
||||
};
|
||||
|
||||
// Base: Celsius. These are the only units in the engine that need an offset.
|
||||
const temperature_units = [_]UnitDef{
|
||||
.{ .name = "C", .aliases = &.{ "celsius", "centigrade" }, .category = .temperature, .to_base_factor = 1.0 },
|
||||
.{ .name = "F", .aliases = &.{"fahrenheit"}, .category = .temperature, .to_base_factor = 5.0 / 9.0, .to_base_offset = -160.0 / 9.0 },
|
||||
.{ .name = "K", .aliases = &.{"kelvin"}, .category = .temperature, .to_base_factor = 1.0, .to_base_offset = -273.15 },
|
||||
.{ .name = "R", .aliases = &.{ "rankine", "Ra" }, .category = .temperature, .to_base_factor = 5.0 / 9.0, .to_base_offset = -273.15 },
|
||||
};
|
||||
|
||||
const time_units = [_]UnitDef{
|
||||
.{ .name = "ns", .aliases = &.{ "nanosecond", "nanoseconds" }, .category = .time, .to_base_factor = 1e-9 },
|
||||
.{ .name = "us", .aliases = &.{ "microsecond", "microseconds" }, .category = .time, .to_base_factor = 1e-6 },
|
||||
.{ .name = "ms", .aliases = &.{ "millisecond", "milliseconds" }, .category = .time, .to_base_factor = 0.001 },
|
||||
.{ .name = "s", .aliases = &.{ "sec", "secs", "second", "seconds" }, .category = .time, .to_base_factor = 1.0 },
|
||||
.{ .name = "min", .aliases = &.{ "minute", "minutes" }, .category = .time, .to_base_factor = 60.0 },
|
||||
.{ .name = "h", .aliases = &.{ "hr", "hrs", "hour", "hours" }, .category = .time, .to_base_factor = 3600.0 },
|
||||
.{ .name = "d", .aliases = &.{ "day", "days" }, .category = .time, .to_base_factor = 86400.0 },
|
||||
.{ .name = "wk", .aliases = &.{ "week", "weeks" }, .category = .time, .to_base_factor = 604800.0 },
|
||||
// Julian year, the usual convention for "a year" in unit conversion.
|
||||
.{ .name = "yr", .aliases = &.{ "year", "years" }, .category = .time, .to_base_factor = 31557600.0 },
|
||||
};
|
||||
|
||||
// Base: byte. Decimal (kB) and binary (KiB) prefixes are both provided.
|
||||
const digital_storage_units = [_]UnitDef{
|
||||
.{ .name = "bit", .aliases = &.{"bits"}, .category = .digital_storage, .to_base_factor = 0.125 },
|
||||
.{ .name = "B", .aliases = &.{ "byte", "bytes" }, .category = .digital_storage, .to_base_factor = 1.0 },
|
||||
.{ .name = "kB", .aliases = &.{ "kilobyte", "kilobytes" }, .category = .digital_storage, .to_base_factor = 1e3 },
|
||||
.{ .name = "MB", .aliases = &.{ "megabyte", "megabytes" }, .category = .digital_storage, .to_base_factor = 1e6 },
|
||||
.{ .name = "GB", .aliases = &.{ "gigabyte", "gigabytes" }, .category = .digital_storage, .to_base_factor = 1e9 },
|
||||
.{ .name = "TB", .aliases = &.{ "terabyte", "terabytes" }, .category = .digital_storage, .to_base_factor = 1e12 },
|
||||
.{ .name = "PB", .aliases = &.{ "petabyte", "petabytes" }, .category = .digital_storage, .to_base_factor = 1e15 },
|
||||
.{ .name = "KiB", .aliases = &.{ "kibibyte", "kibibytes" }, .category = .digital_storage, .to_base_factor = 1024.0 },
|
||||
.{ .name = "MiB", .aliases = &.{ "mebibyte", "mebibytes" }, .category = .digital_storage, .to_base_factor = 1048576.0 },
|
||||
.{ .name = "GiB", .aliases = &.{ "gibibyte", "gibibytes" }, .category = .digital_storage, .to_base_factor = 1073741824.0 },
|
||||
.{ .name = "TiB", .aliases = &.{ "tebibyte", "tebibytes" }, .category = .digital_storage, .to_base_factor = 1099511627776.0 },
|
||||
.{ .name = "PiB", .aliases = &.{ "pebibyte", "pebibytes" }, .category = .digital_storage, .to_base_factor = 1125899906842624.0 },
|
||||
};
|
||||
|
||||
const speed_units = [_]UnitDef{
|
||||
.{ .name = "m/s", .aliases = &.{ "mps", "meterpersecond", "meterspersecond" }, .category = .speed, .to_base_factor = 1.0 },
|
||||
.{ .name = "km/h", .aliases = &.{ "kph", "kmh", "kilometerperhour", "kilometersperhour" }, .category = .speed, .to_base_factor = 1.0 / 3.6 },
|
||||
.{ .name = "mph", .aliases = &.{ "mileperhour", "milesperhour" }, .category = .speed, .to_base_factor = 0.44704 },
|
||||
.{ .name = "ft/s", .aliases = &.{ "fps", "footpersecond", "feetpersecond" }, .category = .speed, .to_base_factor = 0.3048 },
|
||||
.{ .name = "kn", .aliases = &.{ "knot", "knots" }, .category = .speed, .to_base_factor = 1852.0 / 3600.0 },
|
||||
.{ .name = "c", .aliases = &.{ "lightspeed", "speedoflight" }, .category = .speed, .to_base_factor = 299792458.0 },
|
||||
};
|
||||
|
||||
const area_units = [_]UnitDef{
|
||||
.{ .name = "mm2", .aliases = &.{ "mm^2", "squaremillimeter", "squaremillimeters" }, .category = .area, .to_base_factor = 1e-6 },
|
||||
.{ .name = "cm2", .aliases = &.{ "cm^2", "squarecentimeter", "squarecentimeters" }, .category = .area, .to_base_factor = 1e-4 },
|
||||
.{ .name = "m2", .aliases = &.{ "m^2", "squaremeter", "squaremeters" }, .category = .area, .to_base_factor = 1.0 },
|
||||
.{ .name = "km2", .aliases = &.{ "km^2", "squarekilometer", "squarekilometers" }, .category = .area, .to_base_factor = 1e6 },
|
||||
.{ .name = "in2", .aliases = &.{ "in^2", "squareinch", "squareinches" }, .category = .area, .to_base_factor = 0.00064516 },
|
||||
.{ .name = "ft2", .aliases = &.{ "ft^2", "squarefoot", "squarefeet" }, .category = .area, .to_base_factor = 0.09290304 },
|
||||
.{ .name = "yd2", .aliases = &.{ "yd^2", "squareyard", "squareyards" }, .category = .area, .to_base_factor = 0.83612736 },
|
||||
.{ .name = "mi2", .aliases = &.{ "mi^2", "squaremile", "squaremiles" }, .category = .area, .to_base_factor = 2589988.110336 },
|
||||
.{ .name = "ha", .aliases = &.{ "hectare", "hectares" }, .category = .area, .to_base_factor = 10000.0 },
|
||||
.{ .name = "acre", .aliases = &.{"acres"}, .category = .area, .to_base_factor = 4046.8564224 },
|
||||
};
|
||||
|
||||
// Base: liter.
|
||||
const volume_units = [_]UnitDef{
|
||||
.{ .name = "mL", .aliases = &.{ "ml", "milliliter", "milliliters" }, .category = .volume, .to_base_factor = 0.001 },
|
||||
.{ .name = "L", .aliases = &.{ "l", "liter", "liters", "litre", "litres" }, .category = .volume, .to_base_factor = 1.0 },
|
||||
.{ .name = "m3", .aliases = &.{ "m^3", "cubicmeter", "cubicmeters" }, .category = .volume, .to_base_factor = 1000.0 },
|
||||
.{ .name = "cm3", .aliases = &.{ "cm^3", "cc", "cubiccentimeter", "cubiccentimeters" }, .category = .volume, .to_base_factor = 0.001 },
|
||||
.{ .name = "in3", .aliases = &.{ "in^3", "cubicinch", "cubicinches" }, .category = .volume, .to_base_factor = 0.016387064 },
|
||||
.{ .name = "ft3", .aliases = &.{ "ft^3", "cubicfoot", "cubicfeet" }, .category = .volume, .to_base_factor = 28.316846592 },
|
||||
.{ .name = "gal", .aliases = &.{ "gallon", "gallons" }, .category = .volume, .to_base_factor = 3.785411784 },
|
||||
.{ .name = "qt", .aliases = &.{ "quart", "quarts" }, .category = .volume, .to_base_factor = 0.946352946 },
|
||||
.{ .name = "pt", .aliases = &.{ "pint", "pints" }, .category = .volume, .to_base_factor = 0.473176473 },
|
||||
.{ .name = "cup", .aliases = &.{"cups"}, .category = .volume, .to_base_factor = 0.2365882365 },
|
||||
.{ .name = "floz", .aliases = &.{ "fluidounce", "fluidounces" }, .category = .volume, .to_base_factor = 0.0295735295625 },
|
||||
.{ .name = "tbsp", .aliases = &.{ "tablespoon", "tablespoons" }, .category = .volume, .to_base_factor = 0.01478676478125 },
|
||||
.{ .name = "tsp", .aliases = &.{ "teaspoon", "teaspoons" }, .category = .volume, .to_base_factor = 0.00492892159375 },
|
||||
.{ .name = "bbl", .aliases = &.{ "barrel", "barrels" }, .category = .volume, .to_base_factor = 158.987294928 },
|
||||
};
|
||||
|
||||
const energy_units = [_]UnitDef{
|
||||
.{ .name = "J", .aliases = &.{ "joule", "joules" }, .category = .energy, .to_base_factor = 1.0 },
|
||||
.{ .name = "kJ", .aliases = &.{ "kilojoule", "kilojoules" }, .category = .energy, .to_base_factor = 1000.0 },
|
||||
.{ .name = "cal", .aliases = &.{ "calorie", "calories" }, .category = .energy, .to_base_factor = 4.184 },
|
||||
.{ .name = "kcal", .aliases = &.{ "kilocalorie", "kilocalories" }, .category = .energy, .to_base_factor = 4184.0 },
|
||||
.{ .name = "Wh", .aliases = &.{ "watthour", "watthours" }, .category = .energy, .to_base_factor = 3600.0 },
|
||||
.{ .name = "kWh", .aliases = &.{ "kilowatthour", "kilowatthours" }, .category = .energy, .to_base_factor = 3600000.0 },
|
||||
.{ .name = "BTU", .aliases = &.{ "btu", "britishthermalunit" }, .category = .energy, .to_base_factor = 1055.05585262 },
|
||||
.{ .name = "eV", .aliases = &.{ "electronvolt", "electronvolts" }, .category = .energy, .to_base_factor = 1.602176634e-19 },
|
||||
.{ .name = "erg", .aliases = &.{"ergs"}, .category = .energy, .to_base_factor = 1e-7 },
|
||||
};
|
||||
|
||||
const pressure_units = [_]UnitDef{
|
||||
.{ .name = "Pa", .aliases = &.{ "pascal", "pascals" }, .category = .pressure, .to_base_factor = 1.0 },
|
||||
.{ .name = "kPa", .aliases = &.{ "kilopascal", "kilopascals" }, .category = .pressure, .to_base_factor = 1000.0 },
|
||||
.{ .name = "MPa", .aliases = &.{ "megapascal", "megapascals" }, .category = .pressure, .to_base_factor = 1e6 },
|
||||
.{ .name = "bar", .aliases = &.{"bars"}, .category = .pressure, .to_base_factor = 100000.0 },
|
||||
.{ .name = "mbar", .aliases = &.{ "millibar", "millibars" }, .category = .pressure, .to_base_factor = 100.0 },
|
||||
.{ .name = "atm", .aliases = &.{ "atmosphere", "atmospheres" }, .category = .pressure, .to_base_factor = 101325.0 },
|
||||
.{ .name = "psi", .aliases = &.{"poundpersquareinch"}, .category = .pressure, .to_base_factor = 6894.757293168361 },
|
||||
.{ .name = "torr", .aliases = &.{"torrs"}, .category = .pressure, .to_base_factor = 101325.0 / 760.0 },
|
||||
.{ .name = "mmHg", .aliases = &.{"mmhg"}, .category = .pressure, .to_base_factor = 133.322387415 },
|
||||
.{ .name = "inHg", .aliases = &.{"inhg"}, .category = .pressure, .to_base_factor = 3386.388640341 },
|
||||
};
|
||||
|
||||
// Base: bits per second.
|
||||
const data_rate_units = [_]UnitDef{
|
||||
.{ .name = "bps", .aliases = &.{ "bitpersecond", "bitspersecond" }, .category = .data_rate, .to_base_factor = 1.0 },
|
||||
.{ .name = "kbps", .aliases = &.{"kilobitpersecond"}, .category = .data_rate, .to_base_factor = 1e3 },
|
||||
.{ .name = "Mbps", .aliases = &.{"megabitpersecond"}, .category = .data_rate, .to_base_factor = 1e6 },
|
||||
.{ .name = "Gbps", .aliases = &.{"gigabitpersecond"}, .category = .data_rate, .to_base_factor = 1e9 },
|
||||
.{ .name = "Tbps", .aliases = &.{"terabitpersecond"}, .category = .data_rate, .to_base_factor = 1e12 },
|
||||
.{ .name = "Bps", .aliases = &.{ "bytepersecond", "bytespersecond" }, .category = .data_rate, .to_base_factor = 8.0 },
|
||||
.{ .name = "kBps", .aliases = &.{"kilobytepersecond"}, .category = .data_rate, .to_base_factor = 8e3 },
|
||||
.{ .name = "MBps", .aliases = &.{"megabytepersecond"}, .category = .data_rate, .to_base_factor = 8e6 },
|
||||
.{ .name = "GBps", .aliases = &.{"gigabytepersecond"}, .category = .data_rate, .to_base_factor = 8e9 },
|
||||
};
|
||||
|
||||
const angle_units = [_]UnitDef{
|
||||
.{ .name = "rad", .aliases = &.{ "radian", "radians" }, .category = .angle, .to_base_factor = 1.0 },
|
||||
.{ .name = "mrad", .aliases = &.{ "milliradian", "milliradians" }, .category = .angle, .to_base_factor = 0.001 },
|
||||
.{ .name = "deg", .aliases = &.{ "degree", "degrees" }, .category = .angle, .to_base_factor = std.math.pi / 180.0 },
|
||||
.{ .name = "grad", .aliases = &.{ "gradian", "gradians", "gon" }, .category = .angle, .to_base_factor = std.math.pi / 200.0 },
|
||||
.{ .name = "turn", .aliases = &.{ "turns", "rev", "revolution", "revolutions" }, .category = .angle, .to_base_factor = std.math.tau },
|
||||
.{ .name = "arcmin", .aliases = &.{ "arcminute", "arcminutes" }, .category = .angle, .to_base_factor = std.math.pi / 10800.0 },
|
||||
.{ .name = "arcsec", .aliases = &.{ "arcsecond", "arcseconds" }, .category = .angle, .to_base_factor = std.math.pi / 648000.0 },
|
||||
};
|
||||
|
||||
/// All unit tables, indexed in the same order as `UnitCategory`.
|
||||
pub const categories = [_][]const UnitDef{
|
||||
&length_units,
|
||||
&mass_units,
|
||||
&temperature_units,
|
||||
&time_units,
|
||||
&digital_storage_units,
|
||||
&speed_units,
|
||||
&area_units,
|
||||
&volume_units,
|
||||
&energy_units,
|
||||
&pressure_units,
|
||||
&data_rate_units,
|
||||
&angle_units,
|
||||
};
|
||||
|
||||
/// Every unit belonging to a category, in table order.
|
||||
pub fn unitsIn(category: UnitCategory) []const UnitDef {
|
||||
return categories[@intFromEnum(category)];
|
||||
}
|
||||
|
||||
/// The total number of categories.
|
||||
pub const category_count = categories.len;
|
||||
|
||||
// -- Resolution --
|
||||
|
||||
fn eqlIgnoreCase(a: []const u8, b: []const u8) bool {
|
||||
return std.ascii.eqlIgnoreCase(a, b);
|
||||
}
|
||||
|
||||
fn matchesExact(unit: UnitDef, name: []const u8) bool {
|
||||
if (std.mem.eql(u8, unit.name, name)) return true;
|
||||
for (unit.aliases) |alias| {
|
||||
if (std.mem.eql(u8, alias, name)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn matchesIgnoreCase(unit: UnitDef, name: []const u8) bool {
|
||||
if (eqlIgnoreCase(unit.name, name)) return true;
|
||||
for (unit.aliases) |alias| {
|
||||
if (eqlIgnoreCase(alias, name)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Look up a unit by canonical name or alias.
|
||||
///
|
||||
/// An exact (case-sensitive) match is tried first so that case-distinguished
|
||||
/// units keep their meaning (e.g. "mB" does not silently become "MB", and "K"
|
||||
/// stays Kelvin). Only if nothing matches exactly does a case-insensitive pass
|
||||
/// run, which is what makes forgiving input like "KM" or "Celsius" work.
|
||||
pub fn findUnit(name: []const u8) ?UnitDef {
|
||||
if (name.len == 0) return null;
|
||||
for (categories) |table| {
|
||||
for (table) |unit| {
|
||||
if (matchesExact(unit, name)) return unit;
|
||||
}
|
||||
}
|
||||
for (categories) |table| {
|
||||
for (table) |unit| {
|
||||
if (matchesIgnoreCase(unit, name)) return unit;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -- Conversion --
|
||||
|
||||
/// Convert a value between two already-resolved units.
|
||||
/// Returns IncompatibleUnits if the units are in different categories.
|
||||
pub fn convertUnits(value: f64, from: UnitDef, to: UnitDef) CalcError!f64 {
|
||||
if (from.category != to.category) return CalcError.IncompatibleUnits;
|
||||
if (std.mem.eql(u8, from.name, to.name)) return value;
|
||||
return to.fromBase(from.toBase(value));
|
||||
}
|
||||
|
||||
/// Convert a value between two units named by string (canonical name or alias).
|
||||
/// Returns UnknownUnit if either name is unrecognized, or IncompatibleUnits if
|
||||
/// the units belong to different categories.
|
||||
pub fn convert(value: f64, from_name: []const u8, to_name: []const u8) CalcError!ConvertResult {
|
||||
const from = findUnit(from_name) orelse return CalcError.UnknownUnit;
|
||||
const to = findUnit(to_name) orelse return CalcError.UnknownUnit;
|
||||
const result = try convertUnits(value, from, to);
|
||||
|
||||
// A single scaling factor only describes the relationship when neither
|
||||
// unit carries an offset (i.e. everything except temperature).
|
||||
const factor: ?f64 = if (from.isLinear() and to.isLinear())
|
||||
from.to_base_factor / to.to_base_factor
|
||||
else
|
||||
null;
|
||||
|
||||
return .{
|
||||
.value = result,
|
||||
.from = from,
|
||||
.to = to,
|
||||
.category = from.category,
|
||||
.factor = factor,
|
||||
};
|
||||
}
|
||||
|
||||
// -- Tests --
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
fn expectConvert(expected: f64, value: f64, from: []const u8, to: []const u8, tolerance: f64) !void {
|
||||
const result = try convert(value, from, to);
|
||||
try testing.expectApproxEqAbs(expected, result.value, tolerance);
|
||||
}
|
||||
|
||||
test "length: km to mi" {
|
||||
try expectConvert(62.1371192, 100, "km", "mi", 1e-6);
|
||||
}
|
||||
|
||||
test "length: mi to km" {
|
||||
try expectConvert(1.609344, 1, "mi", "km", 1e-9);
|
||||
}
|
||||
|
||||
test "length: m to cm and mm" {
|
||||
try expectConvert(100, 1, "m", "cm", 1e-12);
|
||||
try expectConvert(1000, 1, "m", "mm", 1e-12);
|
||||
}
|
||||
|
||||
test "length: inches to cm" {
|
||||
try expectConvert(2.54, 1, "in", "cm", 1e-12);
|
||||
}
|
||||
|
||||
test "length: ft to in" {
|
||||
try expectConvert(12, 1, "ft", "in", 1e-12);
|
||||
}
|
||||
|
||||
test "length: yd to ft" {
|
||||
try expectConvert(3, 1, "yd", "ft", 1e-12);
|
||||
}
|
||||
|
||||
test "length: mile is 5280 feet" {
|
||||
try expectConvert(5280, 1, "mi", "ft", 1e-9);
|
||||
}
|
||||
|
||||
test "length: nautical mile to m" {
|
||||
try expectConvert(1852, 1, "nmi", "m", 1e-9);
|
||||
}
|
||||
|
||||
test "mass: kg to lb" {
|
||||
try expectConvert(2.2046226218, 1, "kg", "lb", 1e-9);
|
||||
}
|
||||
|
||||
test "mass: lb to oz" {
|
||||
try expectConvert(16, 1, "lb", "oz", 1e-12);
|
||||
}
|
||||
|
||||
test "mass: stone to lb" {
|
||||
try expectConvert(14, 1, "st", "lb", 1e-9);
|
||||
}
|
||||
|
||||
test "mass: g to mg" {
|
||||
try expectConvert(1000, 1, "g", "mg", 1e-9);
|
||||
}
|
||||
|
||||
test "mass: tonne to kg" {
|
||||
try expectConvert(1000, 1, "t", "kg", 1e-9);
|
||||
}
|
||||
|
||||
test "mass: short ton to lb" {
|
||||
try expectConvert(2000, 1, "ton", "lb", 1e-6);
|
||||
}
|
||||
|
||||
test "temperature: C to F freezing and boiling" {
|
||||
try expectConvert(32, 0, "C", "F", 1e-12);
|
||||
try expectConvert(212, 100, "C", "F", 1e-12);
|
||||
}
|
||||
|
||||
test "temperature: F to C" {
|
||||
try expectConvert(0, 32, "F", "C", 1e-12);
|
||||
try expectConvert(100, 212, "F", "C", 1e-12);
|
||||
try expectConvert(37, 98.6, "F", "C", 1e-10);
|
||||
}
|
||||
|
||||
test "temperature: the -40 crossover point" {
|
||||
try expectConvert(-40, -40, "C", "F", 1e-12);
|
||||
try expectConvert(-40, -40, "F", "C", 1e-12);
|
||||
}
|
||||
|
||||
test "temperature: C to K" {
|
||||
try expectConvert(273.15, 0, "C", "K", 1e-12);
|
||||
try expectConvert(373.15, 100, "C", "K", 1e-12);
|
||||
}
|
||||
|
||||
test "temperature: K to C absolute zero" {
|
||||
try expectConvert(-273.15, 0, "K", "C", 1e-12);
|
||||
}
|
||||
|
||||
test "temperature: F to K" {
|
||||
try expectConvert(273.15, 32, "F", "K", 1e-10);
|
||||
}
|
||||
|
||||
test "temperature: Rankine" {
|
||||
// 0 R is absolute zero; 491.67 R is the freezing point of water
|
||||
try expectConvert(-273.15, 0, "R", "C", 1e-10);
|
||||
try expectConvert(0, 491.67, "R", "C", 1e-10);
|
||||
try expectConvert(491.67, 32, "F", "R", 1e-9);
|
||||
}
|
||||
|
||||
test "temperature: conversions report no single factor" {
|
||||
const result = try convert(100, "C", "F");
|
||||
try testing.expect(result.factor == null);
|
||||
}
|
||||
|
||||
test "linear conversions report a factor" {
|
||||
const result = try convert(1, "km", "m");
|
||||
try testing.expect(result.factor != null);
|
||||
try testing.expectApproxEqAbs(@as(f64, 1000.0), result.factor.?, 1e-9);
|
||||
}
|
||||
|
||||
test "time: h to min to s" {
|
||||
try expectConvert(60, 1, "h", "min", 1e-12);
|
||||
try expectConvert(3600, 1, "h", "s", 1e-12);
|
||||
}
|
||||
|
||||
test "time: day to h" {
|
||||
try expectConvert(24, 1, "d", "h", 1e-12);
|
||||
}
|
||||
|
||||
test "time: week to days" {
|
||||
try expectConvert(7, 1, "wk", "d", 1e-12);
|
||||
}
|
||||
|
||||
test "time: ms and ns" {
|
||||
try expectConvert(1000, 1, "s", "ms", 1e-9);
|
||||
try expectConvert(1e9, 1, "s", "ns", 1.0);
|
||||
}
|
||||
|
||||
test "digital storage: byte to bits" {
|
||||
try expectConvert(8, 1, "B", "bit", 1e-12);
|
||||
}
|
||||
|
||||
test "digital storage: KiB vs kB are distinct" {
|
||||
try expectConvert(1024, 1, "KiB", "B", 1e-9);
|
||||
try expectConvert(1000, 1, "kB", "B", 1e-9);
|
||||
}
|
||||
|
||||
test "digital storage: GiB to MiB" {
|
||||
try expectConvert(1024, 1, "GiB", "MiB", 1e-9);
|
||||
}
|
||||
|
||||
test "digital storage: TB to GB" {
|
||||
try expectConvert(1000, 1, "TB", "GB", 1e-6);
|
||||
}
|
||||
|
||||
test "speed: km/h to m/s" {
|
||||
try expectConvert(1, 3.6, "km/h", "m/s", 1e-12);
|
||||
}
|
||||
|
||||
test "speed: mph to km/h" {
|
||||
try expectConvert(1.609344, 1, "mph", "km/h", 1e-9);
|
||||
}
|
||||
|
||||
test "speed: knots to km/h" {
|
||||
try expectConvert(1.852, 1, "kn", "km/h", 1e-9);
|
||||
}
|
||||
|
||||
test "speed: speed of light in m/s" {
|
||||
try expectConvert(299792458, 1, "c", "m/s", 1.0);
|
||||
}
|
||||
|
||||
test "area: m2 to cm2" {
|
||||
try expectConvert(10000, 1, "m2", "cm2", 1e-6);
|
||||
}
|
||||
|
||||
test "area: hectare to m2" {
|
||||
try expectConvert(10000, 1, "ha", "m2", 1e-6);
|
||||
}
|
||||
|
||||
test "area: acre to ft2" {
|
||||
try expectConvert(43560, 1, "acre", "ft2", 1e-6);
|
||||
}
|
||||
|
||||
test "area: km2 to ha" {
|
||||
try expectConvert(100, 1, "km2", "ha", 1e-9);
|
||||
}
|
||||
|
||||
test "volume: L to mL" {
|
||||
try expectConvert(1000, 1, "L", "mL", 1e-9);
|
||||
}
|
||||
|
||||
test "volume: m3 to L" {
|
||||
try expectConvert(1000, 1, "m3", "L", 1e-9);
|
||||
}
|
||||
|
||||
test "volume: gallon to quarts and floz" {
|
||||
try expectConvert(4, 1, "gal", "qt", 1e-9);
|
||||
try expectConvert(128, 1, "gal", "floz", 1e-9);
|
||||
}
|
||||
|
||||
test "volume: tbsp to tsp" {
|
||||
try expectConvert(3, 1, "tbsp", "tsp", 1e-9);
|
||||
}
|
||||
|
||||
test "volume: cc equals mL" {
|
||||
try expectConvert(1, 1, "cc", "mL", 1e-12);
|
||||
}
|
||||
|
||||
test "energy: kcal to cal and J" {
|
||||
try expectConvert(1000, 1, "kcal", "cal", 1e-9);
|
||||
try expectConvert(4184, 1, "kcal", "J", 1e-9);
|
||||
}
|
||||
|
||||
test "energy: kWh to J" {
|
||||
try expectConvert(3600000, 1, "kWh", "J", 1e-6);
|
||||
}
|
||||
|
||||
test "energy: Wh to J" {
|
||||
try expectConvert(3600, 1, "Wh", "J", 1e-9);
|
||||
}
|
||||
|
||||
test "pressure: atm to Pa and psi" {
|
||||
try expectConvert(101325, 1, "atm", "Pa", 1e-6);
|
||||
try expectConvert(14.6959487755, 1, "atm", "psi", 1e-8);
|
||||
}
|
||||
|
||||
test "pressure: bar to kPa" {
|
||||
try expectConvert(100, 1, "bar", "kPa", 1e-9);
|
||||
}
|
||||
|
||||
test "pressure: atm to torr is 760" {
|
||||
try expectConvert(760, 1, "atm", "torr", 1e-9);
|
||||
}
|
||||
|
||||
test "data rate: Mbps to bps" {
|
||||
try expectConvert(1e6, 1, "Mbps", "bps", 1.0);
|
||||
}
|
||||
|
||||
test "data rate: Bps to bps is 8x" {
|
||||
try expectConvert(8, 1, "Bps", "bps", 1e-12);
|
||||
}
|
||||
|
||||
test "data rate: MBps to Mbps" {
|
||||
try expectConvert(8, 1, "MBps", "Mbps", 1e-9);
|
||||
}
|
||||
|
||||
test "angle: deg to rad" {
|
||||
try expectConvert(std.math.pi, 180, "deg", "rad", 1e-12);
|
||||
}
|
||||
|
||||
test "angle: rad to deg" {
|
||||
try expectConvert(180, std.math.pi, "rad", "deg", 1e-12);
|
||||
}
|
||||
|
||||
test "angle: turn to deg" {
|
||||
try expectConvert(360, 1, "turn", "deg", 1e-9);
|
||||
}
|
||||
|
||||
test "angle: deg to arcmin and arcsec" {
|
||||
try expectConvert(60, 1, "deg", "arcmin", 1e-9);
|
||||
try expectConvert(3600, 1, "deg", "arcsec", 1e-9);
|
||||
}
|
||||
|
||||
test "angle: gradian full turn is 400" {
|
||||
try expectConvert(400, 1, "turn", "grad", 1e-9);
|
||||
}
|
||||
|
||||
test "alias resolution: long names and plurals" {
|
||||
try expectConvert(1000, 1, "kilometer", "meters", 1e-9);
|
||||
try expectConvert(2.54, 1, "inch", "centimeters", 1e-12);
|
||||
try expectConvert(32, 0, "celsius", "fahrenheit", 1e-12);
|
||||
}
|
||||
|
||||
test "alias resolution is case-insensitive as a fallback" {
|
||||
try expectConvert(1000, 1, "KM", "M", 1e-9);
|
||||
try expectConvert(1000, 1, "KiloMeter", "meter", 1e-9);
|
||||
}
|
||||
|
||||
test "exact match wins over case-insensitive match" {
|
||||
// "K" exactly matches Kelvin, so it must not fall back to "k"-ish units.
|
||||
const k = findUnit("K").?;
|
||||
try testing.expectEqual(UnitCategory.temperature, k.category);
|
||||
try testing.expectEqualStrings("K", k.name);
|
||||
// Likewise "B" is byte, not "b"
|
||||
const b = findUnit("B").?;
|
||||
try testing.expectEqualStrings("B", b.name);
|
||||
}
|
||||
|
||||
test "findUnit returns null for unknown names" {
|
||||
try testing.expect(findUnit("smoots") == null);
|
||||
try testing.expect(findUnit("") == null);
|
||||
}
|
||||
|
||||
test "convert: unknown source unit errors" {
|
||||
try testing.expectError(CalcError.UnknownUnit, convert(1, "bogus", "m"));
|
||||
}
|
||||
|
||||
test "convert: unknown target unit errors" {
|
||||
try testing.expectError(CalcError.UnknownUnit, convert(1, "m", "bogus"));
|
||||
}
|
||||
|
||||
test "convert: incompatible categories error" {
|
||||
try testing.expectError(CalcError.IncompatibleUnits, convert(1, "kg", "m"));
|
||||
try testing.expectError(CalcError.IncompatibleUnits, convert(1, "C", "s"));
|
||||
}
|
||||
|
||||
test "convert: same unit is identity" {
|
||||
try expectConvert(42.5, 42.5, "m", "m", 0);
|
||||
try expectConvert(-40, -40, "C", "C", 0);
|
||||
}
|
||||
|
||||
test "convert: result carries metadata" {
|
||||
const result = try convert(1, "km", "mi");
|
||||
try testing.expectEqualStrings("km", result.from.name);
|
||||
try testing.expectEqualStrings("mi", result.to.name);
|
||||
try testing.expectEqual(UnitCategory.length, result.category);
|
||||
}
|
||||
|
||||
test "convert: negative and zero values" {
|
||||
try expectConvert(-1000, -1, "km", "m", 1e-9);
|
||||
try expectConvert(0, 0, "km", "m", 0);
|
||||
}
|
||||
|
||||
test "round trip: every unit converts to its base and back" {
|
||||
const value: f64 = 7.25;
|
||||
for (categories) |table| {
|
||||
for (table) |unit| {
|
||||
const base = unit.toBase(value);
|
||||
const back = unit.fromBase(base);
|
||||
try testing.expectApproxEqRel(value, back, 1e-12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "round trip: every unit pair within a category round-trips" {
|
||||
const value: f64 = 3.5;
|
||||
for (categories) |table| {
|
||||
for (table) |a| {
|
||||
for (table) |b| {
|
||||
const forward = try convertUnits(value, a, b);
|
||||
const back = try convertUnits(forward, b, a);
|
||||
try testing.expectApproxEqRel(value, back, 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "every category has a table whose base unit resolves and is unit-factored" {
|
||||
for (std.enums.values(UnitCategory)) |category| {
|
||||
const table = unitsIn(category);
|
||||
try testing.expect(table.len > 0);
|
||||
const base_name = category.baseUnit();
|
||||
const base = findUnit(base_name) orelse return error.BaseUnitNotFound;
|
||||
try testing.expectEqual(category, base.category);
|
||||
// The base unit must be the identity mapping for its category.
|
||||
try testing.expectEqual(@as(f64, 1.0), base.to_base_factor);
|
||||
try testing.expectEqual(@as(f64, 0.0), base.to_base_offset);
|
||||
}
|
||||
}
|
||||
|
||||
test "every unit belongs to the category of the table it is listed in" {
|
||||
for (std.enums.values(UnitCategory)) |category| {
|
||||
for (unitsIn(category)) |unit| {
|
||||
try testing.expectEqual(category, unit.category);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "no unit has a zero factor" {
|
||||
for (categories) |table| {
|
||||
for (table) |unit| {
|
||||
try testing.expect(unit.to_base_factor != 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "canonical unit names are unique across all categories" {
|
||||
for (categories, 0..) |table_a, i| {
|
||||
for (table_a, 0..) |a, ai| {
|
||||
for (categories, 0..) |table_b, j| {
|
||||
for (table_b, 0..) |b, bi| {
|
||||
if (i == j and ai == bi) continue;
|
||||
try testing.expect(!std.mem.eql(u8, a.name, b.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "category labels and base units are non-empty" {
|
||||
for (std.enums.values(UnitCategory)) |category| {
|
||||
try testing.expect(category.label().len > 0);
|
||||
try testing.expect(category.baseUnit().len > 0);
|
||||
}
|
||||
}
|
||||
|
||||
test "unitsIn matches category_count" {
|
||||
try testing.expectEqual(@as(usize, category_count), std.enums.values(UnitCategory).len);
|
||||
}
|
||||
|
||||
test "isLinear: only temperature units carry offsets" {
|
||||
for (std.enums.values(UnitCategory)) |category| {
|
||||
for (unitsIn(category)) |unit| {
|
||||
if (category == .temperature) continue;
|
||||
try testing.expect(unit.isLinear());
|
||||
}
|
||||
}
|
||||
// Celsius is the temperature base, so it is linear; the rest are not.
|
||||
try testing.expect(findUnit("C").?.isLinear());
|
||||
try testing.expect(!findUnit("F").?.isLinear());
|
||||
try testing.expect(!findUnit("K").?.isLinear());
|
||||
}
|
||||
273
src/main.zig
273
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 <value> <from> <to>
|
||||
// Also accepts the natural form: tally convert <value> <from> to <to>
|
||||
if (args.len > 0 and std.mem.eql(u8, args[0], "convert")) {
|
||||
return parseConvertArgs(args[1..]);
|
||||
}
|
||||
|
||||
for (args) |arg| {
|
||||
if (std.mem.eql(u8, arg, "-p") or std.mem.eql(u8, arg, "--programmer")) {
|
||||
mode = .programmer;
|
||||
|
|
@ -54,6 +65,85 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const []const u8) ParsedA
|
|||
return .{ .expression = .{ .text = expression, .mode = mode } };
|
||||
}
|
||||
|
||||
/// Parse the arguments following the `convert` subcommand.
|
||||
/// Accepts `<value> <from> <to>` and `<value> <from> to <to>`, and also the
|
||||
/// glued form `<value><from> to <to>` (e.g. "100km to mi").
|
||||
fn parseConvertArgs(args: []const []const u8) ParsedArgs {
|
||||
// Drop a literal "to" separator so both call styles work.
|
||||
var parts: [3][]const u8 = undefined;
|
||||
var count: usize = 0;
|
||||
for (args) |arg| {
|
||||
if (std.mem.eql(u8, arg, "to")) continue;
|
||||
if (count >= parts.len) {
|
||||
return .{ .output = .{ .text = convert_usage, .is_error = true } };
|
||||
}
|
||||
parts[count] = arg;
|
||||
count += 1;
|
||||
}
|
||||
|
||||
// Glued form: "100km" "mi" -> split the leading number from the unit.
|
||||
if (count == 2) {
|
||||
const split = splitValueAndUnit(parts[0]) orelse {
|
||||
return .{ .output = .{ .text = convert_usage, .is_error = true } };
|
||||
};
|
||||
const value = std.fmt.parseFloat(f64, split.number) catch {
|
||||
return .{ .output = .{ .text = "error: invalid number\n", .is_error = true } };
|
||||
};
|
||||
return .{ .conversion = .{ .value = value, .from = split.unit, .to = parts[1] } };
|
||||
}
|
||||
|
||||
if (count != 3) {
|
||||
return .{ .output = .{ .text = convert_usage, .is_error = true } };
|
||||
}
|
||||
|
||||
const value = std.fmt.parseFloat(f64, parts[0]) catch {
|
||||
return .{ .output = .{ .text = "error: invalid number\n", .is_error = true } };
|
||||
};
|
||||
return .{ .conversion = .{ .value = value, .from = parts[1], .to = parts[2] } };
|
||||
}
|
||||
|
||||
/// Split a token like "100km" into its numeric prefix and unit suffix.
|
||||
fn splitValueAndUnit(token: []const u8) ?struct { number: []const u8, unit: []const u8 } {
|
||||
var i: usize = 0;
|
||||
while (i < token.len) : (i += 1) {
|
||||
const c = token[i];
|
||||
const is_numeric = (c >= '0' and c <= '9') or c == '.' or c == '-' or c == '+' or
|
||||
c == 'e' or c == 'E';
|
||||
// Stop at the first character that cannot continue a number. 'e'/'E'
|
||||
// only continue a number when followed by a digit or sign (exponent),
|
||||
// otherwise they begin the unit (e.g. the "eV" in "5eV").
|
||||
if (c == 'e' or c == 'E') {
|
||||
if (i + 1 >= token.len) break;
|
||||
const n = token[i + 1];
|
||||
const is_exponent = (n >= '0' and n <= '9') or n == '-' or n == '+';
|
||||
if (!is_exponent) break;
|
||||
continue;
|
||||
}
|
||||
if (!is_numeric) break;
|
||||
}
|
||||
if (i == 0 or i == token.len) return null;
|
||||
return .{ .number = token[0..i], .unit = token[i..] };
|
||||
}
|
||||
|
||||
/// Format a unit conversion result.
|
||||
pub fn formatConversion(buf: []u8, value: f64, from_name: []const u8, to_name: []const u8) CliResult {
|
||||
const result = engine.units.convert(value, from_name, to_name) catch |err| {
|
||||
return .{ .output = errorMessage(err), .is_error = true };
|
||||
};
|
||||
|
||||
var in_buf: [64]u8 = undefined;
|
||||
var out_buf: [64]u8 = undefined;
|
||||
const in_str = engine.formatter.formatCompactFloat(&in_buf, value);
|
||||
const out_str = engine.formatter.formatCompactFloat(&out_buf, result.value);
|
||||
|
||||
const output = std.fmt.bufPrint(buf, "{s} {s} = {s} {s}", .{
|
||||
in_str, result.from.name, out_str, result.to.name,
|
||||
}) catch {
|
||||
return .{ .output = "error: buffer overflow\n", .is_error = true };
|
||||
};
|
||||
return .{ .output = output, .is_error = false };
|
||||
}
|
||||
|
||||
/// Evaluate an expression and format the result as a string.
|
||||
pub fn evaluate(allocator: std.mem.Allocator, expression: []const u8, mode: engine.Mode, buf: []u8) CliResult {
|
||||
if (mode == .programmer) {
|
||||
|
|
@ -159,20 +249,41 @@ fn errorMessage(err: engine.CalcError) []const u8 {
|
|||
engine.CalcError.InvalidExpression => "error: invalid expression\n",
|
||||
engine.CalcError.DomainError => "error: domain error\n",
|
||||
engine.CalcError.Overflow => "error: overflow\n",
|
||||
engine.CalcError.UnknownUnit => "error: unknown unit\n",
|
||||
engine.CalcError.IncompatibleUnits => "error: incompatible units (different categories)\n",
|
||||
else => "error: evaluation error\n",
|
||||
};
|
||||
}
|
||||
|
||||
const convert_usage =
|
||||
\\usage: tally convert <value> <from-unit> [to] <to-unit>
|
||||
\\
|
||||
\\examples:
|
||||
\\ tally convert 100 km mi
|
||||
\\ tally convert 100 km to mi
|
||||
\\ tally convert 100km to mi
|
||||
\\ tally convert 32 F to C
|
||||
\\
|
||||
;
|
||||
|
||||
const help_text =
|
||||
\\tally - a cross-platform calculator
|
||||
\\
|
||||
\\Usage: tally [OPTIONS] <expression>
|
||||
\\ tally convert <value> <from-unit> [to] <to-unit>
|
||||
\\
|
||||
\\Options:
|
||||
\\ -p, --programmer Programmer mode (^ = XOR, ** = power)
|
||||
\\ -p, --programmer Programmer mode (^ = power, xor = XOR)
|
||||
\\ -h, --help Show this help
|
||||
\\ --version Show version
|
||||
\\
|
||||
\\Examples:
|
||||
\\ tally '2 + 3 * 4'
|
||||
\\ tally -p '0xFF and 0x0F'
|
||||
\\ tally convert 100 km to mi
|
||||
\\
|
||||
\\Run with no arguments to start the interactive TUI.
|
||||
\\
|
||||
;
|
||||
|
||||
// -- Entry point: thin I/O shell over pure logic --
|
||||
|
|
@ -212,6 +323,14 @@ pub fn main(init: std.process.Init) u8 {
|
|||
if (!result.is_error) write(io, std.Io.File.stdout(), "\n");
|
||||
return if (result.is_error) @as(u8, 1) else 0;
|
||||
},
|
||||
.conversion => |conv| {
|
||||
var buf: [4096]u8 = undefined;
|
||||
const result = formatConversion(&buf, conv.value, conv.from, conv.to);
|
||||
const file = if (result.is_error) std.Io.File.stderr() else std.Io.File.stdout();
|
||||
write(io, file, result.output);
|
||||
if (!result.is_error) write(io, std.Io.File.stdout(), "\n");
|
||||
return if (result.is_error) @as(u8, 1) else 0;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -234,7 +353,7 @@ test "parseArgs: simple expression" {
|
|||
try testing.expectEqual(engine.Mode.standard, e.mode);
|
||||
testing.allocator.free(e.text);
|
||||
},
|
||||
.output => unreachable,
|
||||
else => unreachable,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -245,7 +364,7 @@ test "parseArgs: multi-arg expression joins with spaces" {
|
|||
try testing.expectEqualStrings("2 + 2", e.text);
|
||||
testing.allocator.free(e.text);
|
||||
},
|
||||
.output => unreachable,
|
||||
else => unreachable,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -257,7 +376,7 @@ test "parseArgs: programmer flag" {
|
|||
try testing.expectEqualStrings("0xFF", e.text);
|
||||
testing.allocator.free(e.text);
|
||||
},
|
||||
.output => unreachable,
|
||||
else => unreachable,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -269,7 +388,7 @@ test "parseArgs: --programmer long flag" {
|
|||
try testing.expectEqualStrings("0xF0 | 0x0F", e.text);
|
||||
testing.allocator.free(e.text);
|
||||
},
|
||||
.output => unreachable,
|
||||
else => unreachable,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -280,7 +399,7 @@ test "parseArgs: --help" {
|
|||
try testing.expect(!out.is_error);
|
||||
try testing.expect(std.mem.startsWith(u8, out.text, "tally"));
|
||||
},
|
||||
.expression => unreachable,
|
||||
else => unreachable,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -291,7 +410,7 @@ test "parseArgs: --version" {
|
|||
try testing.expect(!out.is_error);
|
||||
try testing.expectEqualStrings("tally 0.1.0\n", out.text);
|
||||
},
|
||||
.expression => unreachable,
|
||||
else => unreachable,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -302,7 +421,7 @@ test "parseArgs: no expression" {
|
|||
try testing.expect(out.is_error);
|
||||
try testing.expectEqualStrings("error: no expression provided\n", out.text);
|
||||
},
|
||||
.expression => unreachable,
|
||||
else => unreachable,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -369,3 +488,141 @@ test "evaluate: unknown variable error" {
|
|||
try testing.expect(result.is_error);
|
||||
try testing.expectEqualStrings("error: unknown variable\n", result.output);
|
||||
}
|
||||
|
||||
test "parseArgs: convert subcommand three-arg form" {
|
||||
const parsed = parseArgs(testing.allocator, &.{ "convert", "100", "km", "mi" });
|
||||
switch (parsed) {
|
||||
.conversion => |c| {
|
||||
try testing.expectEqual(@as(f64, 100), c.value);
|
||||
try testing.expectEqualStrings("km", c.from);
|
||||
try testing.expectEqualStrings("mi", c.to);
|
||||
},
|
||||
else => return error.ExpectedConversion,
|
||||
}
|
||||
}
|
||||
|
||||
test "parseArgs: convert subcommand with 'to' separator" {
|
||||
const parsed = parseArgs(testing.allocator, &.{ "convert", "100", "km", "to", "mi" });
|
||||
switch (parsed) {
|
||||
.conversion => |c| {
|
||||
try testing.expectEqual(@as(f64, 100), c.value);
|
||||
try testing.expectEqualStrings("km", c.from);
|
||||
try testing.expectEqualStrings("mi", c.to);
|
||||
},
|
||||
else => return error.ExpectedConversion,
|
||||
}
|
||||
}
|
||||
|
||||
test "parseArgs: convert glued value and unit" {
|
||||
const parsed = parseArgs(testing.allocator, &.{ "convert", "100km", "to", "mi" });
|
||||
switch (parsed) {
|
||||
.conversion => |c| {
|
||||
try testing.expectEqual(@as(f64, 100), c.value);
|
||||
try testing.expectEqualStrings("km", c.from);
|
||||
try testing.expectEqualStrings("mi", c.to);
|
||||
},
|
||||
else => return error.ExpectedConversion,
|
||||
}
|
||||
}
|
||||
|
||||
test "parseArgs: convert glued negative and decimal value" {
|
||||
const parsed = parseArgs(testing.allocator, &.{ "convert", "-40.5C", "F" });
|
||||
switch (parsed) {
|
||||
.conversion => |c| {
|
||||
try testing.expectApproxEqAbs(@as(f64, -40.5), c.value, 1e-12);
|
||||
try testing.expectEqualStrings("C", c.from);
|
||||
try testing.expectEqualStrings("F", c.to);
|
||||
},
|
||||
else => return error.ExpectedConversion,
|
||||
}
|
||||
}
|
||||
|
||||
test "parseArgs: convert with too few args is usage error" {
|
||||
const parsed = parseArgs(testing.allocator, &.{ "convert", "100" });
|
||||
switch (parsed) {
|
||||
.output => |o| try testing.expect(o.is_error),
|
||||
else => return error.ExpectedOutput,
|
||||
}
|
||||
}
|
||||
|
||||
test "parseArgs: convert with too many args is usage error" {
|
||||
const parsed = parseArgs(testing.allocator, &.{ "convert", "1", "m", "ft", "yd" });
|
||||
switch (parsed) {
|
||||
.output => |o| try testing.expect(o.is_error),
|
||||
else => return error.ExpectedOutput,
|
||||
}
|
||||
}
|
||||
|
||||
test "parseArgs: convert with non-numeric value errors" {
|
||||
const parsed = parseArgs(testing.allocator, &.{ "convert", "abc", "km", "mi" });
|
||||
switch (parsed) {
|
||||
.output => |o| try testing.expect(o.is_error),
|
||||
else => return error.ExpectedOutput,
|
||||
}
|
||||
}
|
||||
|
||||
test "splitValueAndUnit: basic" {
|
||||
const r = splitValueAndUnit("100km").?;
|
||||
try testing.expectEqualStrings("100", r.number);
|
||||
try testing.expectEqualStrings("km", r.unit);
|
||||
}
|
||||
|
||||
test "splitValueAndUnit: decimal and negative" {
|
||||
const a = splitValueAndUnit("-40.5C").?;
|
||||
try testing.expectEqualStrings("-40.5", a.number);
|
||||
try testing.expectEqualStrings("C", a.unit);
|
||||
}
|
||||
|
||||
test "splitValueAndUnit: exponent continues the number" {
|
||||
const r = splitValueAndUnit("1e3m").?;
|
||||
try testing.expectEqualStrings("1e3", r.number);
|
||||
try testing.expectEqualStrings("m", r.unit);
|
||||
}
|
||||
|
||||
test "splitValueAndUnit: unit starting with e is not eaten as exponent" {
|
||||
const r = splitValueAndUnit("5eV").?;
|
||||
try testing.expectEqualStrings("5", r.number);
|
||||
try testing.expectEqualStrings("eV", r.unit);
|
||||
}
|
||||
|
||||
test "splitValueAndUnit: rejects pure number or pure unit" {
|
||||
try testing.expect(splitValueAndUnit("100") == null);
|
||||
try testing.expect(splitValueAndUnit("km") == null);
|
||||
}
|
||||
|
||||
test "formatConversion: km to mi" {
|
||||
var buf: [256]u8 = undefined;
|
||||
const result = formatConversion(&buf, 100, "km", "mi");
|
||||
try testing.expect(!result.is_error);
|
||||
try testing.expect(std.mem.indexOf(u8, result.output, "62.137") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, result.output, "km") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, result.output, "mi") != null);
|
||||
}
|
||||
|
||||
test "formatConversion: temperature freezing point" {
|
||||
var buf: [256]u8 = undefined;
|
||||
const result = formatConversion(&buf, 0, "C", "F");
|
||||
try testing.expect(!result.is_error);
|
||||
try testing.expectEqualStrings("0 C = 32 F", result.output);
|
||||
}
|
||||
|
||||
test "formatConversion: unknown unit is an error" {
|
||||
var buf: [256]u8 = undefined;
|
||||
const result = formatConversion(&buf, 1, "smoots", "m");
|
||||
try testing.expect(result.is_error);
|
||||
try testing.expect(std.mem.indexOf(u8, result.output, "unknown unit") != null);
|
||||
}
|
||||
|
||||
test "formatConversion: incompatible units is an error" {
|
||||
var buf: [256]u8 = undefined;
|
||||
const result = formatConversion(&buf, 1, "kg", "m");
|
||||
try testing.expect(result.is_error);
|
||||
try testing.expect(std.mem.indexOf(u8, result.output, "incompatible") != null);
|
||||
}
|
||||
|
||||
test "formatConversion: alias resolves to canonical name in output" {
|
||||
var buf: [256]u8 = undefined;
|
||||
const result = formatConversion(&buf, 1, "kilometer", "meters");
|
||||
try testing.expect(!result.is_error);
|
||||
try testing.expectEqualStrings("1 km = 1000 m", result.output);
|
||||
}
|
||||
|
|
|
|||
651
src/tui.zig
651
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);
|
||||
}
|
||||
|
|
|
|||
163
src/tui/convert.zig
Normal file
163
src/tui/convert.zig
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
//! Unit conversion mode drawing for the TUI.
|
||||
//!
|
||||
//! Layout: a wrapping row of category chips, the live conversion result, the
|
||||
//! conversion factor, and two side-by-side unit columns (from / to). Every
|
||||
//! chip and unit name is registered as a clickable region, so the whole view is
|
||||
//! usable with the mouse as well as the keyboard.
|
||||
|
||||
const std = @import("std");
|
||||
const vaxis = @import("vaxis");
|
||||
const vxfw = vaxis.vxfw;
|
||||
const engine = @import("engine");
|
||||
const draw = @import("draw.zig");
|
||||
const tui = @import("../tui.zig");
|
||||
const C = draw.C;
|
||||
|
||||
const units = engine.units;
|
||||
|
||||
/// Column layout constants.
|
||||
const from_col: u16 = 4;
|
||||
const to_col_offset: u16 = 26;
|
||||
|
||||
pub fn drawConvertMode(app: *tui.App, surface: *vxfw.Surface, width: u16, height: u16) void {
|
||||
const table = units.unitsIn(app.conv_category);
|
||||
const pair = app.convUnits();
|
||||
const zone_active = app.value_zone_active;
|
||||
|
||||
// -- Category chips (wrapping) --
|
||||
draw.writeStr(surface, 2, 2, "Category:", .{ .fg = C.muted });
|
||||
var row: u16 = 3;
|
||||
var col: u16 = 4;
|
||||
for (std.enums.values(engine.UnitCategory)) |category| {
|
||||
const label = category.label();
|
||||
const chip_len: u16 = @intCast(label.len + 2);
|
||||
// Wrap when the chip would run off the right edge.
|
||||
if (col + chip_len >= width -| 2) {
|
||||
row += 1;
|
||||
col = 4;
|
||||
}
|
||||
const selected = category == app.conv_category;
|
||||
const focused = selected and zone_active and app.conv_zone == .category;
|
||||
const style: vaxis.Style = if (focused)
|
||||
.{ .fg = C.bg, .bg = C.cyan, .bold = true }
|
||||
else if (selected)
|
||||
.{ .fg = C.bg, .bg = C.purple, .bold = true }
|
||||
else
|
||||
.{ .fg = C.muted };
|
||||
draw.writeChar(surface, row, col, ' ', style);
|
||||
draw.writeStr(surface, row, col + 1, label, style);
|
||||
draw.writeChar(surface, row, col + 1 + @as(u16, @intCast(label.len)), ' ', style);
|
||||
app.addRegion(row, col, chip_len, .{ .conv_category = category });
|
||||
col += chip_len + 1;
|
||||
}
|
||||
|
||||
// -- Result --
|
||||
row += 2;
|
||||
const converted = units.convertUnits(app.conv_value, pair.from, pair.to) catch app.conv_value;
|
||||
|
||||
var in_buf: [64]u8 = undefined;
|
||||
var out_buf: [64]u8 = undefined;
|
||||
const in_str = engine.formatter.formatCompactFloat(&in_buf, app.conv_value);
|
||||
const out_str = engine.formatter.formatCompactFloat(&out_buf, converted);
|
||||
|
||||
var line_buf: [192]u8 = undefined;
|
||||
const input_line = std.fmt.bufPrint(&line_buf, "{s} {s}", .{ in_str, pair.from.name }) catch "?";
|
||||
draw.writeStr(surface, row, 2, input_line, .{ .fg = C.fg });
|
||||
row += 1;
|
||||
|
||||
var result_buf: [192]u8 = undefined;
|
||||
const result_line = std.fmt.bufPrint(&result_buf, "= {s} {s}", .{ out_str, pair.to.name }) catch "?";
|
||||
draw.writeStr(surface, row, 2, result_line, .{ .fg = C.green, .bold = true });
|
||||
row += 1;
|
||||
|
||||
// -- Conversion factor (linear conversions only) --
|
||||
if (pair.from.isLinear() and pair.to.isLinear()) {
|
||||
const factor = pair.from.to_base_factor / pair.to.to_base_factor;
|
||||
var factor_val_buf: [64]u8 = undefined;
|
||||
const factor_str = engine.formatter.formatCompactFloat(&factor_val_buf, factor);
|
||||
var factor_buf: [160]u8 = undefined;
|
||||
const factor_line = std.fmt.bufPrint(&factor_buf, "1 {s} = {s} {s}", .{
|
||||
pair.from.name, factor_str, pair.to.name,
|
||||
}) catch "?";
|
||||
draw.writeStr(surface, row, 2, factor_line, .{ .fg = C.muted });
|
||||
} else {
|
||||
// Temperature is affine, so no single factor describes it.
|
||||
draw.writeStr(surface, row, 2, "affine conversion (offset), no single factor", .{ .fg = C.muted });
|
||||
}
|
||||
row += 2;
|
||||
|
||||
// -- Unit columns --
|
||||
const to_col = from_col + to_col_offset;
|
||||
const from_focused = zone_active and app.conv_zone == .from;
|
||||
const to_focused = zone_active and app.conv_zone == .to;
|
||||
|
||||
draw.writeStr(surface, row, 2, "From", if (from_focused)
|
||||
.{ .fg = C.cyan, .bold = true }
|
||||
else
|
||||
.{ .fg = C.muted });
|
||||
draw.writeStr(surface, row, to_col -| 2, "To", if (to_focused)
|
||||
.{ .fg = C.cyan, .bold = true }
|
||||
else
|
||||
.{ .fg = C.muted });
|
||||
row += 1;
|
||||
|
||||
const list_start = row;
|
||||
const list_end = height -| 4;
|
||||
const visible: usize = if (list_end > list_start) list_end - list_start else 0;
|
||||
|
||||
for (table, 0..) |unit, i| {
|
||||
if (i >= visible) break;
|
||||
const list_row: u16 = list_start + @as(u16, @intCast(i));
|
||||
|
||||
drawUnitCell(app, surface, list_row, from_col, unit, i == app.conv_from_idx, from_focused, .{ .conv_from = i });
|
||||
drawUnitCell(app, surface, list_row, to_col, unit, i == app.conv_to_idx, to_focused, .{ .conv_to = i });
|
||||
}
|
||||
|
||||
// If the terminal is too short for the whole list, say so rather than
|
||||
// silently truncating.
|
||||
if (table.len > visible and visible > 0) {
|
||||
var more_buf: [64]u8 = undefined;
|
||||
const more = std.fmt.bufPrint(&more_buf, "... {d} more (resize to see all)", .{table.len - visible}) catch "...";
|
||||
draw.writeStr(surface, list_end -| 1, from_col, more, .{ .fg = C.dim });
|
||||
}
|
||||
|
||||
// -- Separator, input, status --
|
||||
draw.fillRow(surface, height -| 3, '-', .{ .fg = C.dim });
|
||||
app.drawInput(surface, height -| 2);
|
||||
app.addRegion(height -| 2, 0, width, .focus_input);
|
||||
|
||||
draw.fillRow(surface, height -| 1, ' ', .{ .fg = C.muted, .bg = C.bg });
|
||||
const status = if (zone_active)
|
||||
"Arrows:select | Ctrl-S:swap | `:input | Tab:mode | ?:help"
|
||||
else
|
||||
"Type a value + Enter | `:select units | Ctrl-S:swap | Tab:mode | ?:help";
|
||||
draw.writeStr(surface, height -| 1, 1, status, .{ .fg = C.muted, .bg = C.bg });
|
||||
}
|
||||
|
||||
/// Draw one unit name in a column, highlighting it when selected, and register
|
||||
/// it as clickable.
|
||||
fn drawUnitCell(
|
||||
app: *tui.App,
|
||||
surface: *vxfw.Surface,
|
||||
row: u16,
|
||||
col: u16,
|
||||
unit: engine.UnitDef,
|
||||
selected: bool,
|
||||
column_focused: bool,
|
||||
action: tui.Action,
|
||||
) void {
|
||||
const style: vaxis.Style = if (selected and column_focused)
|
||||
.{ .fg = C.bg, .bg = C.cyan, .bold = true }
|
||||
else if (selected)
|
||||
.{ .fg = C.green, .bold = true }
|
||||
else
|
||||
.{ .fg = C.fg };
|
||||
|
||||
const marker: u8 = if (selected) '>' else ' ';
|
||||
draw.writeChar(surface, row, col -| 2, marker, .{ .fg = C.cyan, .bold = true });
|
||||
draw.writeStr(surface, row, col, unit.name, style);
|
||||
|
||||
// Make the whole column slot clickable, not just the name text, so short
|
||||
// names like "m" are not a one-character target.
|
||||
app.addRegion(row, col -| 2, to_col_offset -| 2, action);
|
||||
}
|
||||
196
src/tui/float_view.zig
Normal file
196
src/tui/float_view.zig
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
//! IEEE 754 float interpretation overlay for the TUI programmer mode.
|
||||
//!
|
||||
//! Reinterprets the current programmer value as an IEEE 754 float (f32 or f64),
|
||||
//! drawing a color-coded bit grid (sign / exponent / significand) plus the
|
||||
//! decoded value, formula, classification, ULP, and raw fields.
|
||||
|
||||
const std = @import("std");
|
||||
const vaxis = @import("vaxis");
|
||||
const vxfw = vaxis.vxfw;
|
||||
const engine = @import("engine");
|
||||
const draw = @import("draw.zig");
|
||||
const tui = @import("../tui.zig");
|
||||
const C = draw.C;
|
||||
|
||||
const fi = engine.float_interp;
|
||||
|
||||
/// Which IEEE 754 field a given bit belongs to.
|
||||
const Field = enum { sign, exponent, significand };
|
||||
|
||||
fn fieldOf(bit_idx: u8, format: engine.FloatFormat) Field {
|
||||
const total = format.totalBits();
|
||||
const sig_bits = format.significandBits();
|
||||
if (bit_idx == total - 1) return .sign;
|
||||
if (bit_idx >= sig_bits) return .exponent;
|
||||
return .significand;
|
||||
}
|
||||
|
||||
fn fieldColor(field: Field) vaxis.Cell.Color {
|
||||
return switch (field) {
|
||||
.sign => C.pink,
|
||||
.exponent => C.cyan,
|
||||
.significand => C.green,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn drawFloatView(app: *tui.App, surface: *vxfw.Surface, width: u16, height: u16) void {
|
||||
_ = width;
|
||||
const format = app.float_format;
|
||||
const total = format.totalBits();
|
||||
const bw = app.prog_config.bit_width;
|
||||
const bits: u64 = @truncate(app.prog_value & bw.mask());
|
||||
const info = fi.decompose(format, bits);
|
||||
|
||||
// Header line
|
||||
var header_buf: [96]u8 = undefined;
|
||||
const header = std.fmt.bufPrint(&header_buf, "IEEE 754 {s} ({d}-bit) Ctrl-W:f32/f64 Ctrl-F:exit", .{
|
||||
switch (format) {
|
||||
.f32 => "float32",
|
||||
.f64 => "float64",
|
||||
},
|
||||
total,
|
||||
}) catch "IEEE 754";
|
||||
draw.writeStr(surface, 2, 2, header, .{ .fg = C.muted });
|
||||
// Clicking the header row exits the overlay; clicking the format label
|
||||
// toggles f32/f64.
|
||||
if (std.mem.indexOf(u8, header, "Ctrl-W")) |idx| {
|
||||
app.addRegion(2, 2 + @as(u16, @intCast(idx)), 12, .toggle_float_format);
|
||||
}
|
||||
if (std.mem.indexOf(u8, header, "Ctrl-F")) |idx| {
|
||||
app.addRegion(2, 2 + @as(u16, @intCast(idx)), 11, .toggle_float);
|
||||
}
|
||||
|
||||
// Color legend
|
||||
draw.writeStr(surface, 3, 2, "sign", .{ .fg = C.pink, .bold = true });
|
||||
draw.writeStr(surface, 3, 7, "exponent", .{ .fg = C.cyan, .bold = true });
|
||||
draw.writeStr(surface, 3, 16, "significand", .{ .fg = C.green, .bold = true });
|
||||
|
||||
// Bit grid (color-coded by field)
|
||||
const grid_start: u16 = 5;
|
||||
const grid_rows = drawFloatBitGrid(app, surface, grid_start, bits, format);
|
||||
|
||||
// Info panel below the grid
|
||||
var row = grid_start + grid_rows + 1;
|
||||
const bias = format.bias();
|
||||
const sig_bits = format.significandBits();
|
||||
|
||||
// Value
|
||||
var val_buf: [64]u8 = undefined;
|
||||
const val_str = engine.formatter.formatCompactFloat(&val_buf, info.value);
|
||||
draw.writeStr(surface, row, 2, "Value:", .{ .fg = C.cyan });
|
||||
draw.writeStr(surface, row, 12, val_str, .{ .fg = C.fg, .bold = true });
|
||||
row += 1;
|
||||
|
||||
// Classification
|
||||
draw.writeStr(surface, row, 2, "Class:", .{ .fg = C.cyan });
|
||||
draw.writeStr(surface, row, 12, info.class.label(), .{ .fg = C.fg });
|
||||
row += 1;
|
||||
|
||||
// Formula (normal / denormal only)
|
||||
draw.writeStr(surface, row, 2, "Formula:", .{ .fg = C.cyan });
|
||||
var formula_buf: [128]u8 = undefined;
|
||||
const formula: []const u8 = switch (info.class) {
|
||||
.normal, .denormal => blk: {
|
||||
const sign_char: u8 = if (info.sign == 1) '-' else '+';
|
||||
const lead: f64 = if (info.class == .normal) 1.0 else 0.0;
|
||||
const frac: f64 = @as(f64, @floatFromInt(info.significand)) /
|
||||
std.math.ldexp(@as(f64, 1.0), @intCast(sig_bits));
|
||||
const mantissa = lead + frac;
|
||||
break :blk std.fmt.bufPrint(&formula_buf, "{c}2^{d} x {d}", .{
|
||||
sign_char, info.unbiased_exponent, mantissa,
|
||||
}) catch "?";
|
||||
},
|
||||
else => "n/a",
|
||||
};
|
||||
draw.writeStr(surface, row, 12, formula, .{ .fg = C.fg });
|
||||
row += 1;
|
||||
|
||||
// ULP
|
||||
draw.writeStr(surface, row, 2, "ULP:", .{ .fg = C.cyan });
|
||||
var ulp_buf: [64]u8 = undefined;
|
||||
const ulp_str: []const u8 = if (std.math.isNan(info.ulp))
|
||||
"n/a"
|
||||
else
|
||||
engine.formatter.formatCompactFloat(&ulp_buf, info.ulp);
|
||||
draw.writeStr(surface, row, 12, ulp_str, .{ .fg = C.fg });
|
||||
row += 1;
|
||||
row += 1;
|
||||
|
||||
// Raw fields
|
||||
draw.writeStr(surface, row, 2, "Sign:", .{ .fg = C.pink });
|
||||
var sign_buf: [32]u8 = undefined;
|
||||
const sign_str = std.fmt.bufPrint(&sign_buf, "{d} ({s})", .{
|
||||
info.sign,
|
||||
if (info.sign == 1) "negative" else "positive",
|
||||
}) catch "?";
|
||||
draw.writeStr(surface, row, 12, sign_str, .{ .fg = C.fg });
|
||||
row += 1;
|
||||
|
||||
draw.writeStr(surface, row, 2, "Exp:", .{ .fg = C.cyan });
|
||||
var exp_buf: [80]u8 = undefined;
|
||||
const exp_str = std.fmt.bufPrint(&exp_buf, "{d} biased -> {d} unbiased (bias {d})", .{
|
||||
info.raw_exponent, info.unbiased_exponent, bias,
|
||||
}) catch "?";
|
||||
draw.writeStr(surface, row, 12, exp_str, .{ .fg = C.fg });
|
||||
row += 1;
|
||||
|
||||
draw.writeStr(surface, row, 2, "Frac:", .{ .fg = C.green });
|
||||
var frac_buf: [64]u8 = undefined;
|
||||
const frac_str = std.fmt.bufPrint(&frac_buf, "0x{X} ({d} bits)", .{
|
||||
info.significand, sig_bits,
|
||||
}) catch "?";
|
||||
draw.writeStr(surface, row, 12, frac_str, .{ .fg = C.fg });
|
||||
row += 1;
|
||||
|
||||
// History + input + status
|
||||
const hist_start = row + 1;
|
||||
const hist_end = height -| 4;
|
||||
if (hist_start < hist_end) {
|
||||
tui.drawHistory(app.history.items, surface, hist_start, hist_end);
|
||||
}
|
||||
|
||||
draw.fillRow(surface, height -| 3, '-', .{ .fg = C.dim });
|
||||
app.drawInput(surface, height -| 2);
|
||||
app.addRegion(height -| 2, 0, surface.size.width, .focus_input);
|
||||
draw.fillRow(surface, height -| 1, ' ', .{ .fg = C.muted, .bg = C.bg });
|
||||
const status = "Type a float | Arrows:nav bits | Space:toggle | Ctrl-W:f32/f64 | Ctrl-F:exit | ?:help";
|
||||
draw.writeStr(surface, height -| 1, 1, status, .{ .fg = C.muted, .bg = C.bg });
|
||||
}
|
||||
|
||||
/// Draw the color-coded bit grid. Returns the number of rows drawn.
|
||||
fn drawFloatBitGrid(app: *tui.App, surface: *vxfw.Surface, start_row: u16, bits: u64, format: engine.FloatFormat) u16 {
|
||||
const total = format.totalBits();
|
||||
const bits_per_row: u8 = if (total > 32) 32 else total;
|
||||
const num_rows: u16 = (@as(u16, total) + bits_per_row - 1) / bits_per_row;
|
||||
const cursor_active = app.value_zone_active and app.prog_field == .bits;
|
||||
|
||||
var row = start_row;
|
||||
var bit_row: u16 = 0;
|
||||
while (bit_row < num_rows) : (bit_row += 1) {
|
||||
const row_start_bit: u8 = @intCast(total - 1 - bit_row * bits_per_row);
|
||||
var col: u16 = 2;
|
||||
var b: u8 = 0;
|
||||
while (b < bits_per_row) : (b += 1) {
|
||||
const bit_idx = row_start_bit -| b;
|
||||
if (bit_idx >= total) break;
|
||||
if (b > 0 and b % 4 == 0) col += 1;
|
||||
|
||||
const bit_val: u8 = @intCast((bits >> @intCast(bit_idx)) & 1);
|
||||
const field = fieldOf(bit_idx, format);
|
||||
const color = fieldColor(field);
|
||||
const is_cursor = cursor_active and (bit_idx == app.bit_cursor);
|
||||
|
||||
const style: vaxis.Style = if (is_cursor)
|
||||
.{ .fg = C.bg, .bg = C.orange, .bold = true }
|
||||
else
|
||||
.{ .fg = color, .bold = (bit_val == 1) };
|
||||
|
||||
draw.writeChar(surface, row, col, '0' + bit_val, style);
|
||||
// bit_idx < total <= 64, so it fits in a u7.
|
||||
app.addRegion(row, col, 1, .{ .toggle_bit = @intCast(bit_idx) });
|
||||
col += 1;
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
return num_rows;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue