tally/src/tui.zig

3055 lines
117 KiB
Zig

//! Tally TUI - interactive calculator interface.
//!
//! Split into sub-modules:
//! - draw.zig: shared drawing primitives and color palette
//! - tui/programmer.zig: programmer mode layout
//! - tui/help.zig: help overlay
const std = @import("std");
const vaxis = @import("vaxis");
const vxfw = vaxis.vxfw;
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 financial_view = @import("tui/financial.zig");
const help_view = @import("tui/help.zig");
const test_render = @import("tui/test_render.zig");
const C = draw.C;
const Allocator = std.mem.Allocator;
const Mode = enum { standard, programmer, financial, 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,
/// Select a financial calculation form.
fin_form: financial_view.Form,
/// Focus a field in the financial form.
fin_field: usize,
};
/// 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,
io: std.Io,
env: engine.evaluator.Environment,
input: vxfw.TextField,
history: std.ArrayList(HistoryEntry),
show_help: bool,
/// First help line shown. The overlay is taller than a normal terminal, so it
/// scrolls rather than dropping its later sections.
help_scroll: usize,
history_browse_idx: ?usize,
saved_input: ?[]const u8,
mode: Mode,
// Programmer mode state
prog_value: u128,
prog_config: engine.programmer.Config,
bit_cursor: u7,
prog_field: ProgField,
value_zone_active: bool, // true = cursor in value display, false = in input
// 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,
/// The value being converted. A `Number` so exact input stays exact through
/// the conversion, matching the CLI.
conv_value: engine.Number,
conv_zone: ConvZone,
/// Financial mode state (forms, focused field, schedule scroll).
fin: financial_view.State,
/// Terminal height from the last draw, so key handling can compute a page
/// without guessing at the layout.
last_height: u16,
// Mouse hit regions, rebuilt every frame during drawing
regions: RegionSet,
pub const ProgField = enum {
bits,
dec_signed,
dec_unsigned,
hex,
oct,
bin,
expression,
pub fn next(self: ProgField) ProgField {
return switch (self) {
.bits => .dec_signed,
.dec_signed => .dec_unsigned,
.dec_unsigned => .hex,
.hex => .oct,
.oct => .bin,
.bin => .expression,
.expression => .bits,
};
}
pub fn prev(self: ProgField) ProgField {
return switch (self) {
.bits => .expression,
.dec_signed => .bits,
.dec_unsigned => .dec_signed,
.hex => .dec_unsigned,
.oct => .hex,
.bin => .oct,
.expression => .bin,
};
}
pub fn label(self: ProgField) []const u8 {
return switch (self) {
.bits => "BITS",
.dec_signed => "DEC(s)",
.dec_unsigned => "DEC(u)",
.hex => "HEX",
.oct => "OCT",
.bin => "BIN",
.expression => "EXPR",
};
}
};
pub const HistoryEntry = struct {
expr: []const u8,
result: []const u8,
is_error: bool,
/// Optional pre-labeled detail lines (e.g. "hex: 01 F0") for
/// standard-mode results that used non-decimal literals.
details: ?[3][]const u8 = null,
};
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,
.env = engine.evaluator.Environment.init(allocator),
.input = text_field,
.history = .empty,
.show_help = false,
.help_scroll = 0,
.history_browse_idx = null,
.saved_input = null,
.mode = .standard,
.prog_value = 0,
.prog_config = .{},
.bit_cursor = 0,
.prog_field = .bits,
.value_zone_active = false,
.float_view_active = false,
.float_format = .f32,
.conv_category = .length,
.conv_from_idx = default_from,
.conv_to_idx = default_to,
// The value being converted starts at exactly one. It used to be
// Number.fromFloat(1), which made the opening screen read
// "1 m = 999,999,999.9999999 nm" while the CLI printed the exact
// 1,000,000,000 for the same conversion.
.conv_value = engine.Number.parse(allocator, "1") catch engine.Number.fromFloat(1),
.conv_zone = .from,
.fin = .{},
.last_height = 24,
.regions = .empty,
};
}
pub fn deinit(self: *App) void {
self.input.deinit();
self.env.deinit();
for (self.history.items) |entry| {
self.allocator.free(entry.expr);
self.allocator.free(entry.result);
if (entry.details) |details| {
for (details) |d| self.allocator.free(d);
}
}
self.history.deinit(self.allocator);
if (self.saved_input) |s| self.allocator.free(s);
self.conv_value.deinit();
}
pub fn widget(self: *App) vxfw.Widget {
return .{
.userdata = self,
.eventHandler = typeErasedEventHandler,
.drawFn = typeErasedDrawFn,
};
}
// -- 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);
}
/// Move the help overlay by a line delta, clamped to its content. `visible`
/// comes from the last drawn height so a page is a real page.
fn scrollHelpBy(self: *App, delta: i32) void {
const total = help_view.lineCount();
const visible = help_view.visibleLines(self.last_height);
const max_scroll = if (total > visible) total - visible else 0;
const next = @as(i64, @intCast(self.help_scroll)) + delta;
if (next < 0) {
self.help_scroll = 0;
} else if (@as(usize, @intCast(next)) > max_scroll) {
self.help_scroll = max_scroll;
} else {
self.help_scroll = @intCast(next);
}
}
/// Handle a scrolling key while the help overlay is open. Returns true when
/// the key was a scroll rather than a dismiss.
fn scrollHelp(self: *App, key: vaxis.Key) bool {
const page: i32 = @intCast(@max(1, help_view.visibleLines(self.last_height)));
if (key.matches(vaxis.Key.down, .{})) {
self.scrollHelpBy(1);
return true;
}
if (key.matches(vaxis.Key.up, .{})) {
self.scrollHelpBy(-1);
return true;
}
if (key.matches(vaxis.Key.page_down, .{})) {
self.scrollHelpBy(page);
return true;
}
if (key.matches(vaxis.Key.page_up, .{})) {
self.scrollHelpBy(-page);
return true;
}
return false;
}
fn handleMouse(self: *App, ctx: *vxfw.EventContext, mouse: vaxis.Mouse) !void {
if (mouse.row < 0 or mouse.col < 0) return;
// The help overlay takes the wheel to scroll and swallows clicks to
// dismiss, matching its keyboard behavior.
if (self.show_help) {
if (mouse.button == .wheel_up or mouse.button == .wheel_down) {
self.scrollHelpBy(if (mouse.button == .wheel_up) -3 else 3);
ctx.redraw = true;
return;
}
if (mouse.type != .press or mouse.button != .left) return;
self.show_help = false;
self.help_scroll = 0;
ctx.redraw = true;
return;
}
// The wheel scrolls the amortization schedule. Handled before the
// press filter below, since a wheel event is not a left press.
if (self.mode == .financial and (mouse.button == .wheel_up or mouse.button == .wheel_down)) {
const delta: i32 = if (mouse.button == .wheel_up) -3 else 3;
// The visible row count is a drawing concern, so scroll optimistically
// and let the draw pass clamp against the real schedule length.
self.fin.scrollBy(delta, 0, std.math.maxInt(u32));
ctx.redraw = true;
return;
}
// Only act on a left press. Release/motion/drag would double-fire.
if (mouse.type != .press or mouse.button != .left) return;
const row: u16 = @intCast(mouse.row);
const col: u16 = @intCast(mouse.col);
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.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.width.bits()) {
self.bit_cursor = bit;
self.prog_value ^= @as(u128, 1) << bit;
self.prog_value &= self.prog_config.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(),
.fin_form => |form| {
self.fin.setForm(form);
self.value_zone_active = true;
},
.fin_field => |index| {
self.fin.focusField(index);
self.value_zone_active = true;
},
}
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;
// The environment has no mode to set: which evaluator runs is decided at
// the call, not by state. This used to write `self.env.mode`, a field the
// engine never read.
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.ansFloat();
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.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.width.bits()) {
self.bit_cursor = @intCast(self.prog_config.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 => {},
}
}
fn handleKey(self: *App, ctx: *vxfw.EventContext, key: vaxis.Key) !void {
if (key.matches('c', .{ .ctrl = true }) or key.matches('d', .{ .ctrl = true })) {
ctx.quit = true;
return;
}
if (self.show_help) {
// Scrolling keys navigate the overlay; anything else dismisses it, so
// the "press a key to get out" behavior survives while the sections
// past the first screen become reachable.
if (self.scrollHelp(key)) {
ctx.redraw = true;
return;
}
self.show_help = false;
self.help_scroll = 0;
ctx.redraw = true;
return;
}
if (key.matches('?', .{}) and !self.value_zone_active) {
self.show_help = true;
ctx.redraw = true;
return;
}
// Tab: cycle Standard -> Programmer -> Financial -> Convert.
// Shift-Tab goes back, so a mis-hit does not mean cycling all the way
// around.
if (key.matches(vaxis.Key.tab, .{})) {
self.setMode(nextMode(self.mode));
ctx.redraw = true;
return;
}
if (key.matches(vaxis.Key.tab, .{ .shift = true })) {
self.setMode(prevMode(self.mode));
ctx.redraw = true;
return;
}
// Backtick: toggle between input zone and value/selection zone
if ((self.mode == .programmer or self.mode == .convert or self.mode == .financial) and
key.matches('`', .{}))
{
self.value_zone_active = !self.value_zone_active;
ctx.redraw = true;
return;
}
// Financial mode: the schedule scrolls from either zone, so PgUp/PgDn
// work while editing fields too.
if (self.mode == .financial) {
if (key.matches(vaxis.Key.page_up, .{})) {
self.fin.scrollBy(-10, 0, std.math.maxInt(u32));
ctx.redraw = true;
return;
}
if (key.matches(vaxis.Key.page_down, .{})) {
self.fin.scrollBy(10, 0, std.math.maxInt(u32));
ctx.redraw = true;
return;
}
if (self.value_zone_active) {
self.handleFinancialZoneKey(key);
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.toggleFloatFormat();
} else {
self.cycleBitWidth();
}
ctx.redraw = true;
return;
}
// Ctrl-E: toggle display endianness (programmer mode)
if (self.mode == .programmer and key.matches('e', .{ .ctrl = true })) {
self.toggleEndian();
ctx.redraw = true;
return;
}
// Ctrl-F: toggle the IEEE 754 float interpretation overlay (programmer mode)
if (self.mode == .programmer and key.matches('f', .{ .ctrl = true })) {
self.float_view_active = !self.float_view_active;
if (self.float_view_active) {
self.syncFloatWidth();
// Keep the bit grid focused so arrows/space edit bits directly.
self.prog_field = .bits;
if (self.bit_cursor >= self.prog_config.width.bits()) {
self.bit_cursor = @intCast(self.prog_config.width.bits() - 1);
}
}
ctx.redraw = true;
return;
}
// VALUE ZONE key handling
if (self.value_zone_active) {
self.handleValueZoneKey(key);
ctx.redraw = true;
return;
}
// INPUT ZONE below
if (key.matches(vaxis.Key.enter, .{})) {
try self.submitExpression();
ctx.redraw = true;
return;
}
if (key.matches('l', .{ .ctrl = true })) {
for (self.history.items) |entry| {
self.allocator.free(entry.expr);
self.allocator.free(entry.result);
if (entry.details) |details| {
for (details) |d| self.allocator.free(d);
}
}
self.history.clearRetainingCapacity();
self.history_browse_idx = null;
if (self.saved_input) |s| {
self.allocator.free(s);
self.saved_input = null;
}
ctx.redraw = true;
return;
}
// Up/Down: history browsing
if (key.matches(vaxis.Key.up, .{})) {
if (self.history.items.len == 0) return;
if (self.history_browse_idx) |idx| {
if (idx + 1 < self.history.items.len) {
self.history_browse_idx = idx + 1;
self.setInputFromHistory(idx + 1);
}
} else {
try self.saveCurrentInput();
self.history_browse_idx = 0;
self.setInputFromHistory(0);
}
ctx.redraw = true;
return;
}
if (key.matches(vaxis.Key.down, .{})) {
if (self.history_browse_idx) |idx| {
if (idx == 0) {
self.history_browse_idx = null;
self.restoreSavedInput();
} else {
self.history_browse_idx = idx - 1;
self.setInputFromHistory(idx - 1);
}
ctx.redraw = true;
}
return;
}
// Pass to text field
try self.input.handleEvent(ctx, .{ .key_press = key });
self.history_browse_idx = null;
ctx.redraw = true;
}
/// Financial form editing: arrows move between fields, printable characters
/// edit the focused one. Kept separate from `handleValueZoneKey`, which is
/// specific to the programmer view's bit and base fields.
fn handleFinancialZoneKey(self: *App, key: vaxis.Key) void {
if (key.matches(vaxis.Key.down, .{})) {
self.fin.nextField();
return;
}
if (key.matches(vaxis.Key.up, .{})) {
self.fin.prevField();
return;
}
// Left/Right cycle the calculation, so every form is reachable without
// the mouse (FR-7.7).
if (key.matches(vaxis.Key.right, .{})) {
self.fin.nextForm();
return;
}
if (key.matches(vaxis.Key.left, .{})) {
self.fin.prevForm();
return;
}
if (key.matches(vaxis.Key.backspace, .{})) {
self.fin.backspace();
return;
}
// Enter evaluates the field in place: type "12 * 30" in a period count and
// it becomes 360. Fields hold expressions, so this is the commit step.
if (key.matches(vaxis.Key.enter, .{})) {
self.commitFinancialField();
return;
}
// Ctrl-U empties a field, which is also how a TVM variable is marked as
// the one to solve for.
if (key.matches('u', .{ .ctrl = true }) or key.matches(vaxis.Key.delete, .{})) {
self.fin.clearFocused();
return;
}
const cp = key.codepoint;
if (cp >= 0x20 and cp < 0x7F) {
_ = self.fin.typeChar(@intCast(cp));
}
}
/// Evaluate the focused financial field in place, replacing an expression with
/// its value. A field that does not evaluate is left exactly as typed, so a
/// half-finished expression is never silently discarded.
fn commitFinancialField(self: *App) void {
if (self.fin.focusedIsToggle()) return;
const field = self.fin.focused();
if (field.isEmpty()) return;
var value = engine.evalString(&self.env, self.allocator, field.text()) catch return;
defer value.deinit();
self.fin.setFocusedValue(value.toFloat(self.allocator));
}
fn handleValueZoneKey(self: *App, key: vaxis.Key) void {
// Up/Down: move between fields
if (key.matches(vaxis.Key.up, .{})) {
if (self.prog_field == .bits) {
const width = self.prog_config.width.bits();
const bits_per_row: u8 = if (width > 32) 32 else width;
if (@as(u8, self.bit_cursor) + bits_per_row < width) {
self.bit_cursor += @intCast(bits_per_row);
return;
}
}
self.prog_field = self.prog_field.prev();
self.alignCursorToField();
return;
}
if (key.matches(vaxis.Key.down, .{})) {
if (self.prog_field == .bits) {
const width = self.prog_config.width.bits();
const bits_per_row: u8 = if (width > 32) 32 else width;
if (self.bit_cursor >= bits_per_row) {
self.bit_cursor -= @intCast(bits_per_row);
return;
}
}
self.prog_field = self.prog_field.next();
self.alignCursorToField();
return;
}
// Left/Right
if (key.matches(vaxis.Key.left, .{})) {
const step = self.fieldBitStep();
if (step > 0) {
const width = self.prog_config.width.bits();
if (@as(u16, self.bit_cursor) + step < width) {
self.bit_cursor += @intCast(step);
}
}
return;
}
if (key.matches(vaxis.Key.right, .{})) {
const step = self.fieldBitStep();
if (step > 0) {
if (self.bit_cursor >= step) {
self.bit_cursor -= @intCast(step);
}
}
return;
}
// Space: toggle bit
if (key.matches(' ', .{})) {
if (self.prog_field == .bits) {
self.prog_value ^= @as(u128, 1) << self.bit_cursor;
self.prog_value &= self.prog_config.width.mask();
}
return;
}
// Typing digits to edit the focused field's value
const cp = key.codepoint;
if (cp >= 0x20 and cp < 0x7F) {
self.handleValueInput(@intCast(cp));
}
}
fn handleValueInput(self: *App, char: u8) void {
switch (self.prog_field) {
.hex => {
const nibble: ?u4 = if (char >= '0' and char <= '9')
@intCast(char - '0')
else if (char >= 'a' and char <= 'f')
@intCast(char - 'a' + 10)
else if (char >= 'A' and char <= 'F')
@intCast(char - 'A' + 10)
else
null;
if (nibble) |n| {
// Replace the nibble at bit_cursor position
const shift: u7 = self.bit_cursor & 0x7C; // round down to nibble boundary
const mask = ~(@as(u128, 0xF) << shift);
self.prog_value = (self.prog_value & mask) | (@as(u128, n) << shift);
self.prog_value &= self.prog_config.width.mask();
// Move cursor right (toward LSB)
if (shift >= 4) self.bit_cursor -= 4;
}
},
.oct => {
if (char >= '0' and char <= '7') {
const digit: u3 = @intCast(char - '0');
// Replace the octal digit at bit_cursor position
const shift: u7 = (self.bit_cursor / 3) * 3; // round down to octal boundary
const mask = ~(@as(u128, 0x7) << shift);
self.prog_value = (self.prog_value & mask) | (@as(u128, digit) << shift);
self.prog_value &= self.prog_config.width.mask();
if (shift >= 3) self.bit_cursor -= 3;
}
},
.bin, .bits => {
if (char == '0') {
self.prog_value &= ~(@as(u128, 1) << self.bit_cursor);
if (self.bit_cursor > 0) self.bit_cursor -= 1;
} else if (char == '1') {
self.prog_value |= @as(u128, 1) << self.bit_cursor;
self.prog_value &= self.prog_config.width.mask();
if (self.bit_cursor > 0) self.bit_cursor -= 1;
}
},
.dec_unsigned, .dec_signed => {
if (char >= '0' and char <= '9') {
// Operate on the in-width portion so hidden upper bits do
// not corrupt the arithmetic; the edit commits to width.
const m = self.prog_config.width.mask();
self.prog_value = (((self.prog_value & m) *% 10) +% (char - '0')) & m;
}
},
.expression => {},
}
}
/// 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.width = switch (self.prog_config.width) {
.bits8 => .bits16,
.bits16 => .bits32,
.bits32 => .bits64,
.bits64 => .bits128,
.bits128 => .bits8,
};
if (self.bit_cursor >= self.prog_config.width.bits()) {
self.bit_cursor = @intCast(self.prog_config.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.width = switch (self.float_format) {
.f32 => .bits32,
.f64 => .bits64,
};
if (self.bit_cursor >= self.prog_config.width.bits()) {
self.bit_cursor = @intCast(self.prog_config.width.bits() - 1);
}
}
/// How many bits the cursor moves per left/right step in the current field.
fn fieldBitStep(self: *App) u8 {
return switch (self.prog_field) {
.bits, .bin => 1,
.hex => 4,
.oct => 3,
else => 0, // dec fields don't have positional cursor
};
}
/// Snap bit_cursor to the nearest boundary for the current field.
fn alignCursorToField(self: *App) void {
const step = self.fieldBitStep();
if (step > 1) {
// Round down to nearest boundary
self.bit_cursor = @intCast((@as(u8, self.bit_cursor) / step) * step);
}
}
fn submitExpression(self: *App) !void {
const first = self.input.buf.firstHalf();
const second = self.input.buf.secondHalf();
const expr_text = try self.allocator.alloc(u8, first.len + second.len);
@memcpy(expr_text[0..first.len], first);
@memcpy(expr_text[first.len..], second);
if (expr_text.len == 0) {
self.allocator.free(expr_text);
return;
}
if (self.mode == .programmer) {
if (self.float_view_active) {
try self.submitFloat(expr_text);
} else {
try self.submitProgrammer(expr_text);
}
} else if (self.mode == .convert) {
try self.submitConvert(expr_text);
} else if (self.mode == .financial) {
try self.submitFinancial(expr_text);
} else {
try self.submitStandard(expr_text);
}
self.input.clearRetainingCapacity();
self.history_browse_idx = null;
if (self.saved_input) |s| {
self.allocator.free(s);
self.saved_input = null;
}
}
fn submitStandard(self: *App, expr_text: []const u8) !void {
// A standalone "to" keyword makes this a unit conversion, e.g.
// "100 km to mi". Anything else falls through to normal evaluation.
if (engine.units.parseRequest(expr_text)) |maybe_request| {
if (maybe_request) |request| {
try self.submitStandardConversion(expr_text, request);
return;
}
} else |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 info = engine.evalStringInfo(&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;
};
defer info.value.deinit();
// Exact results render in full, so an exact integer past f64's 2^53
// limit reaches the user intact instead of collapsing to scientific
// notation.
const shown = try engine.formatter.formatNumber(self.allocator, info.value);
defer shown.deinit(self.allocator);
const result_copy = try self.allocator.dupe(u8, shown.display);
var details: ?[3][]const u8 = null;
const as_float = info.value.toFloat(self.allocator);
if (info.has_nondecimal_literal and as_float >= 0 and
as_float == @trunc(as_float) and
as_float < 340282366920938463463374607431768211456.0)
{
const int_val: u128 = @intFromFloat(as_float);
const int: engine.Integer = .{
.raw = int_val,
.width = engine.formatter.displayWidthFor(int_val),
.signedness = .unsigned,
};
details = .{
try std.fmt.allocPrint(self.allocator, "hex: {f}", .{int.fmt(.hex, .{})}),
try std.fmt.allocPrint(self.allocator, "oct: {f}", .{int.fmt(.octal, .{})}),
try std.fmt.allocPrint(self.allocator, "bin: {f}", .{int.fmt(.binary, .{})}),
};
}
try self.history.append(self.allocator, .{
.expr = expr_text,
.result = result_copy,
.is_error = false,
.details = details,
});
}
/// In the float overlay, interpret typed input as a decimal float value and
/// store its nearest representable bit pattern. Falls back to the integer
/// expression engine for input that is not a plain float literal (e.g.
/// "0xFF" or "1 << 3").
fn submitFloat(self: *App, expr_text: []const u8) !void {
const val = std.fmt.parseFloat(f64, expr_text) catch {
try self.submitProgrammer(expr_text);
return;
};
const nearest = engine.float_interp.nearestBits(self.float_format, val);
self.prog_value = nearest.bits;
const note: []const u8 = if (nearest.rounded) " (rounded to nearest)" else "";
const result = try std.fmt.allocPrint(self.allocator, "{d}{s}", .{ val, note });
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 parsed exactly; 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 {
var value = engine.Number.parse(self.allocator, 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;
};
errdefer value.deinit();
const pair = self.convUnits();
var converted = engine.units.convertExactUnits(self.allocator, value, pair.from, pair.to) catch |err| {
value.deinit();
const msg = try self.allocator.dupe(u8, errorStr(err));
try self.history.append(self.allocator, .{ .expr = expr_text, .result = msg, .is_error = true });
return;
};
defer converted.deinit();
// Adopt the new value only once the conversion has succeeded.
self.conv_value.deinit();
self.conv_value = value;
const shown_in = try engine.formatter.formatNumber(self.allocator, self.conv_value);
defer shown_in.deinit(self.allocator);
const shown_out = try engine.formatter.formatNumber(self.allocator, converted);
defer shown_out.deinit(self.allocator);
const result = try std.fmt.allocPrint(self.allocator, "{s} {s} = {s} {s}", .{
shown_in.display, pair.from.name, shown_out.display, pair.to.name,
});
try self.history.append(self.allocator, .{ .expr = expr_text, .result = result, .is_error = false });
}
/// Evaluate and record a standard-mode unit conversion ("100 km to mi").
fn submitStandardConversion(self: *App, expr_text: []const u8, request: engine.units.ConversionRequest) !void {
var evaluated = engine.evalString(&self.env, self.allocator, request.value_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;
};
defer evaluated.deinit();
var converted = engine.units.convertExactUnits(self.allocator, evaluated, request.from, request.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;
};
defer converted.deinit();
const shown = try engine.formatter.formatNumber(self.allocator, converted);
defer shown.deinit(self.allocator);
const result = try std.fmt.allocPrint(self.allocator, "{s} {s}", .{ shown.display, request.to.name });
try self.history.append(self.allocator, .{ .expr = expr_text, .result = result, .is_error = false });
}
/// In financial mode the input line fills the focused field, evaluating
/// whatever was typed first. That is how "1200*12" becomes a periods entry
/// without leaving the form, and it keeps the full expression language
/// available inside a form field.
fn submitFinancial(self: *App, expr_text: []const u8) !void {
defer self.allocator.free(expr_text);
var value = engine.evalString(&self.env, self.allocator, expr_text) catch {
// Nothing is recorded on a bad expression: the form is live, so the
// field simply keeps its previous contents.
return;
};
defer value.deinit();
self.fin.setFocusedValue(value.toFloat(self.allocator));
// Move focus into the form so the next field is a keystroke away.
self.value_zone_active = true;
}
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();
const text = try std.fmt.allocPrint(self.allocator, "{f}", .{int.fmt(.decimal_unsigned, .{})});
break :blk .{ false, text };
} else |err| blk: {
break :blk .{ true, try self.allocator.dupe(u8, errorStr(err)) };
};
try self.history.append(self.allocator, .{ .expr = expr_text, .result = display_text, .is_error = is_error });
}
fn saveCurrentInput(self: *App) !void {
if (self.saved_input) |s| self.allocator.free(s);
const first = self.input.buf.firstHalf();
const second = self.input.buf.secondHalf();
const text = try self.allocator.alloc(u8, first.len + second.len);
@memcpy(text[0..first.len], first);
@memcpy(text[first.len..], second);
self.saved_input = text;
}
fn setInputFromHistory(self: *App, browse_idx: usize) void {
const actual_idx = self.history.items.len - 1 - browse_idx;
const entry = self.history.items[actual_idx];
self.input.clearRetainingCapacity();
self.input.insertSliceAtCursor(entry.expr) catch return;
}
fn restoreSavedInput(self: *App) void {
self.input.clearRetainingCapacity();
if (self.saved_input) |s| {
self.input.insertSliceAtCursor(s) catch return;
self.allocator.free(s);
self.saved_input = null;
}
}
// -- Drawing --
fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
const self: *App = @ptrCast(@alignCast(ptr));
const width = ctx.max.width orelse 80;
const height = ctx.max.height orelse 24;
var surface = try vxfw.Surface.init(ctx.arena, self.widget(), .{ .width = width, .height = height });
self.last_height = height;
// Hit regions describe the frame being drawn, so start fresh.
self.clearRegions();
if (self.show_help) {
help_view.drawHelp(&surface, width, height, self.help_scroll);
return surface;
}
// Title bar
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. Each is registered as a clickable region.
// Mode tabs, drawn from the same table the Tab order comes from. Each is
// registered as a clickable region.
var total_tab_width: u16 = 0;
for (mode_tabs) |tab| total_tab_width += @intCast(tab.text.len);
var tab_col = width -| (total_tab_width + 2);
for (mode_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;
}
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),
.financial => financial_view.drawFinancialMode(self, &surface, width, height),
.standard => self.drawStandardMode(&surface, width, height),
}
return surface;
}
fn drawStandardMode(self: *App, surface: *vxfw.Surface, width: u16, height: u16) void {
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 });
}
pub fn drawInput(self: *App, surface: *vxfw.Surface, row: u16) void {
const input_focused = !self.value_zone_active;
const prompt_style: vaxis.Style = if (input_focused)
.{ .fg = C.pink, .bold = true }
else
.{ .fg = C.dim };
draw.writeStr(surface, row, 1, "> ", prompt_style);
const first = self.input.buf.firstHalf();
const second = self.input.buf.secondHalf();
var col: u16 = 3;
for (first) |byte| {
draw.writeChar(surface, row, col, byte, .{ .fg = C.fg });
col += 1;
}
for (second) |byte| {
draw.writeChar(surface, row, col, byte, .{ .fg = C.fg });
col += 1;
}
if (input_focused) {
const cursor_col: u16 = 3 + @as(u16, @intCast(self.input.buf.cursor));
surface.cursor = .{ .col = cursor_col, .row = row, .shape = .block };
}
}
};
/// Mode order for Tab and Shift-Tab, matching the tab bar left to right.
/// The mode bar: order, label and colour, in one place.
///
/// Tab order, Shift-Tab order and the drawn tab bar all come from this. They used
/// to be three separate encodings of the same sequence: the array below plus a
/// hand-written switch in each direction, which is three places to update and two
/// chances to disagree.
///
/// The table is indexed by the `Mode` tag, which the comptime block below enforces,
/// so a mode added to the enum without a tab here is a compile error and looking up
/// a mode's position needs no search and no unreachable branch.
const mode_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 = .financial, .text = " Financial ", .color = C.yellow },
.{ .mode = .convert, .text = " Convert ", .color = C.purple },
};
comptime {
const modes = std.enums.values(Mode);
if (mode_tabs.len != modes.len) @compileError("every Mode needs a tab in mode_tabs");
for (mode_tabs, 0..) |tab, i| {
if (@intFromEnum(tab.mode) != i) @compileError("mode_tabs must be in Mode declaration order");
}
}
/// Position of a mode in the bar.
fn modeIndex(mode: Mode) usize {
return @intFromEnum(mode);
}
pub fn nextMode(mode: Mode) Mode {
return mode_tabs[wrapIndex(modeIndex(mode), 1, mode_tabs.len)].mode;
}
pub fn prevMode(mode: Mode) Mode {
return mode_tabs[wrapIndex(modeIndex(mode), -1, mode_tabs.len)].mode;
}
/// 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;
const capacity: usize = end - start;
const LineKind = enum { expr, result, result_err, detail };
const Line = struct { text: []const u8, kind: LineKind };
// Flatten all entries into a line list (oldest first).
var lines: [512]Line = undefined;
var n: usize = 0;
for (items) |entry| {
if (n < lines.len) {
lines[n] = .{ .text = entry.expr, .kind = .expr };
n += 1;
}
if (n < lines.len) {
lines[n] = .{ .text = entry.result, .kind = if (entry.is_error) .result_err else .result };
n += 1;
}
if (entry.details) |details| {
for (details) |d| {
if (n < lines.len) {
lines[n] = .{ .text = d, .kind = .detail };
n += 1;
}
}
}
}
// Render only the last `capacity` lines (newest anchored at bottom).
const first_visible = if (n > capacity) n - capacity else 0;
var row: u16 = start;
for (lines[first_visible..n]) |line| {
if (row >= end) break;
switch (line.kind) {
.expr => draw.writeStr(surface, row, 2, line.text, .{ .fg = C.muted }),
.result => {
draw.writeStr(surface, row, 4, "= ", .{ .fg = C.green, .bold = true });
draw.writeStr(surface, row, 6, line.text, .{ .fg = C.green, .bold = true });
},
.result_err => {
draw.writeStr(surface, row, 4, "= ", .{ .fg = C.pink });
draw.writeStr(surface, row, 6, line.text, .{ .fg = C.pink });
},
.detail => draw.writeStr(surface, row, 6, line.text, .{ .fg = C.muted }),
}
row += 1;
}
}
/// Turn an engine error into a status-line string.
///
/// The phrases live once, in `engine.phrase`; the prefix is added at
/// comptime. This switch used to be a second full copy of the CLI's, and had fallen
/// behind: `InsufficientParameters`, `ConvergenceFailure` and `InvalidExpression`
/// all came out as "evaluation error".
fn errorStr(err: engine.Error) []const u8 {
return switch (err) {
inline else => |e| comptime "error: " ++ engine.phrase(e),
};
}
pub fn run(allocator: Allocator, io: std.Io, environ_map: *std.process.Environ.Map) !void {
var app = App.init(allocator, io);
defer app.deinit();
var vx_buf: [4096]u8 = undefined;
var vx_app = try vxfw.App.init(io, allocator, environ_map, &vx_buf);
defer vx_app.deinit();
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);
}
// -- Financial mode wiring --
//
// financial.zig tests the form logic in isolation. These go through the actual
// key and mouse handlers, which is what catches a binding that was never routed
// or one mode swallowing another's keys.
fn testApp() App {
// SAFETY: `io` is only used by the event loop and command handling, neither
// of which these tests reach.
return App.init(testing.allocator, undefined);
}
fn testCtx() vxfw.EventContext {
// SAFETY: same as above; no test here issues a command that would use `io`.
return .{ .io = undefined, .alloc = testing.allocator, .cmds = .empty };
}
fn press(app: *App, ctx: *vxfw.EventContext, key: vaxis.Key) !void {
try app.handleKey(ctx, key);
}
test "tab cycles through all four modes" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
const tab: vaxis.Key = .{ .codepoint = vaxis.Key.tab };
try testing.expectEqual(Mode.standard, app.mode);
try press(&app, &ctx, tab);
try testing.expectEqual(Mode.programmer, app.mode);
try press(&app, &ctx, tab);
try testing.expectEqual(Mode.financial, app.mode);
try press(&app, &ctx, tab);
try testing.expectEqual(Mode.convert, app.mode);
try press(&app, &ctx, tab);
try testing.expectEqual(Mode.standard, app.mode);
}
test "financial mode: backtick toggles the form zone and digits reach the field" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
app.setMode(.financial);
try testing.expect(!app.value_zone_active);
try press(&app, &ctx, .{ .codepoint = '`' });
try testing.expect(app.value_zone_active);
try press(&app, &ctx, .{ .codepoint = '2' });
try press(&app, &ctx, .{ .codepoint = '5' });
try press(&app, &ctx, .{ .codepoint = '0' });
try testing.expectEqualStrings("250", app.fin.focused().text());
// Backspace and Ctrl-U both edit, and neither leaks into the text field.
try press(&app, &ctx, .{ .codepoint = vaxis.Key.backspace });
try testing.expectEqualStrings("25", app.fin.focused().text());
try press(&app, &ctx, .{ .codepoint = 'u', .mods = .{ .ctrl = true } });
try testing.expect(app.fin.focused().isEmpty());
}
test "financial mode: up and down move between fields" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
app.setMode(.financial);
app.value_zone_active = true;
try press(&app, &ctx, .{ .codepoint = vaxis.Key.down });
try testing.expectEqual(@as(usize, 1), app.fin.field);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.up });
try testing.expectEqual(@as(usize, 0), app.fin.field);
// Wraps rather than sticking at the top.
try press(&app, &ctx, .{ .codepoint = vaxis.Key.up });
try testing.expectEqual(@as(usize, 2), app.fin.field);
}
test "financial mode: left and right switch calculation without the mouse" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
app.setMode(.financial);
app.value_zone_active = true;
try testing.expectEqual(financial_view.Form.cagr, app.fin.form);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.right });
try testing.expectEqual(financial_view.Form.compound, app.fin.form);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.left });
try testing.expectEqual(financial_view.Form.cagr, app.fin.form);
// Wraps backwards to the last calculation.
try press(&app, &ctx, .{ .codepoint = vaxis.Key.left });
try testing.expectEqual(financial_view.Form.amortization, app.fin.form);
// And forwards off the end back to the first. This direction was never
// exercised, and it panicked with an integer overflow: the Form tag is a u2,
// so the handler's own `@intFromEnum(form) + 1` overflowed before the modulo
// could wrap it.
try press(&app, &ctx, .{ .codepoint = vaxis.Key.right });
try testing.expectEqual(financial_view.Form.cagr, app.fin.form);
}
test "financial mode: the digits that edit a field do not switch modes" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
app.setMode(.financial);
app.value_zone_active = true;
// '?' opens help only from the input zone; in the form zone it is ignored
// rather than stealing a keystroke mid-entry.
try press(&app, &ctx, .{ .codepoint = '?' });
try testing.expect(!app.show_help);
try testing.expect(app.fin.focused().isEmpty());
}
test "financial mode: page keys scroll the schedule from either zone" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
app.setMode(.financial);
app.fin.setForm(.amortization);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.page_down });
try testing.expectEqual(@as(usize, 10), app.fin.scroll);
app.value_zone_active = true;
try press(&app, &ctx, .{ .codepoint = vaxis.Key.page_down });
try testing.expectEqual(@as(usize, 20), app.fin.scroll);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.page_up });
try testing.expectEqual(@as(usize, 10), app.fin.scroll);
}
test "financial mode: clicking a calculation or a field focuses it" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
app.setMode(.financial);
try app.applyAction(&ctx, .{ .fin_form = .tvm });
try testing.expectEqual(financial_view.Form.tvm, app.fin.form);
try testing.expect(app.value_zone_active);
try app.applyAction(&ctx, .{ .fin_field = 3 });
try testing.expectEqual(@as(usize, 3), app.fin.field);
// An out-of-range index is ignored rather than moving focus off the form.
try app.applyAction(&ctx, .{ .fin_field = 99 });
try testing.expectEqual(@as(usize, 3), app.fin.field);
}
test "financial mode: the wheel scrolls the schedule" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
app.setMode(.financial);
app.fin.setForm(.amortization);
try app.handleMouse(&ctx, .{
.col = 10,
.row = 14,
.button = .wheel_down,
.mods = .{},
.type = .press,
});
try testing.expectEqual(@as(usize, 3), app.fin.scroll);
try app.handleMouse(&ctx, .{
.col = 10,
.row = 14,
.button = .wheel_up,
.mods = .{},
.type = .press,
});
try testing.expectEqual(@as(usize, 0), app.fin.scroll);
}
test "financial mode: the input line evaluates an expression into the field" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
app.setMode(.financial);
app.fin.setForm(.amortization);
app.fin.focusField(2); // Periods
try app.input.insertSliceAtCursor("30 * 12");
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
try testing.expectEqualStrings("360", app.fin.fieldAt(2).text());
// The form takes focus so the next field is one keystroke away, and the
// input line is left empty.
try testing.expect(app.value_zone_active);
try testing.expectEqual(@as(usize, 0), app.input.buf.realLength());
// A form result is live, so nothing is appended to history.
try testing.expectEqual(@as(usize, 0), app.history.items.len);
}
test "financial mode: a bad expression leaves the field untouched" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
app.setMode(.financial);
app.fin.fieldAt(0).set("100");
try app.input.insertSliceAtCursor("2 +");
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
try testing.expectEqualStrings("100", app.fin.fieldAt(0).text());
}
test "other modes still get their own keys after financial mode was added" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
// Convert mode's Ctrl-S swap must not be shadowed by the financial branch.
app.setMode(.convert);
const before = app.convUnits();
try press(&app, &ctx, .{ .codepoint = 's', .mods = .{ .ctrl = true } });
const after = app.convUnits();
try testing.expectEqualStrings(before.from.name, after.to.name);
// Programmer mode's bit-width cycle still works.
app.setMode(.programmer);
const width_before = app.prog_config.width;
try press(&app, &ctx, .{ .codepoint = 'w', .mods = .{ .ctrl = true } });
try testing.expect(app.prog_config.width != width_before);
}
test "shift-tab walks back through the modes" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
const back_tab: vaxis.Key = .{ .codepoint = vaxis.Key.tab, .mods = .{ .shift = true } };
try testing.expectEqual(Mode.standard, app.mode);
try press(&app, &ctx, back_tab);
try testing.expectEqual(Mode.convert, app.mode);
try press(&app, &ctx, back_tab);
try testing.expectEqual(Mode.financial, app.mode);
try press(&app, &ctx, back_tab);
try testing.expectEqual(Mode.programmer, app.mode);
try press(&app, &ctx, back_tab);
try testing.expectEqual(Mode.standard, app.mode);
}
test "nextMode and prevMode are inverses in both directions" {
for ([_]Mode{ .standard, .programmer, .financial, .convert }) |mode| {
try testing.expectEqual(mode, prevMode(nextMode(mode)));
try testing.expectEqual(mode, nextMode(prevMode(mode)));
}
}
test "nextMode visits every mode once before repeating" {
// Cycling through as many steps as there are tabs must return to the start
// having seen each mode, which is what makes every mode reachable by Tab.
var seen = [_]bool{false} ** mode_tabs.len;
var mode: Mode = .standard;
for (0..mode_tabs.len) |_| {
const i = modeIndex(mode);
try testing.expect(!seen[i]);
seen[i] = true;
mode = nextMode(mode);
}
try testing.expectEqual(Mode.standard, mode);
for (seen) |visited| try testing.expect(visited);
}
test "the drawn tab order is the Tab key order" {
// The bar is drawn left to right from mode_tabs, so pressing Tab must move to
// the tab drawn to the right of the current one.
for (mode_tabs, 0..) |tab, i| {
const expected = mode_tabs[(i + 1) % mode_tabs.len].mode;
try testing.expectEqual(expected, nextMode(tab.mode));
}
}
test "plain tab is not shift-tab" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
// Forward once, back once, and we are where we started.
try press(&app, &ctx, .{ .codepoint = vaxis.Key.tab });
try press(&app, &ctx, .{ .codepoint = vaxis.Key.tab, .mods = .{ .shift = true } });
try testing.expectEqual(Mode.standard, app.mode);
}
test "financial mode: Enter evaluates the focused field in place" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
app.setMode(.financial);
app.fin.setForm(.amortization);
app.value_zone_active = true;
app.fin.focusField(2); // Periods
for ("12 * 30") |char| _ = app.fin.typeChar(char);
try testing.expect(app.fin.focused().isExpression());
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
try testing.expectEqualStrings("360", app.fin.focused().text());
try testing.expect(!app.fin.focused().isExpression());
}
test "financial mode: Enter on a field that does not evaluate keeps the text" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
app.setMode(.financial);
app.value_zone_active = true;
for ("12 *") |char| _ = app.fin.typeChar(char);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
try testing.expectEqualStrings("12 *", app.fin.focused().text());
}
test "financial mode: Enter on a toggle or empty field does nothing" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
app.setMode(.financial);
app.fin.setForm(.tvm);
app.value_zone_active = true;
app.fin.focusField(5); // the END/BGN row
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
try testing.expect(!app.fin.due);
try testing.expect(app.fin.fieldAt(5).isEmpty());
app.fin.focusField(0);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
try testing.expect(app.fin.focused().isEmpty());
}
test "financial mode: an expression typed into a field survives field changes" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
app.setMode(.financial);
app.value_zone_active = true;
for ("2 * 5000") |char| _ = app.fin.typeChar(char);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.down });
try press(&app, &ctx, .{ .codepoint = vaxis.Key.up });
try testing.expectEqualStrings("2 * 5000", app.fin.focused().text());
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
try testing.expectEqualStrings("10000", app.fin.focused().text());
}
// -- Rendered frames for every mode --
//
// Drawing code was previously untested: the view modules had zero instrumented
// coverage, so a layout regression would only show up by eye. These draw real
// frames through the actual widget draw path and read the cells back. They also
// assert the frame stays rectangular, since the drawing helpers clip silently
// rather than erroring when a column is miscomputed.
fn renderApp(arena: std.mem.Allocator, app: *App, width: u16, height: u16) ![][]u8 {
return test_render.frame(arena, app, width, height);
}
test "render: the tab bar shows every mode and marks the active one" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var app = testApp();
defer app.deinit();
for ([_]Mode{ .standard, .programmer, .financial, .convert }) |mode| {
app.setMode(mode);
const rows = try renderApp(arena, &app, 100, 24);
try testing.expect(test_render.furniture(rows).intact());
try testing.expect(test_render.contains(rows, "Tally"));
try testing.expect(test_render.contains(rows, "Standard"));
try testing.expect(test_render.contains(rows, "Programmer"));
try testing.expect(test_render.contains(rows, "Financial"));
try testing.expect(test_render.contains(rows, "Convert"));
}
}
test "render: standard mode shows history, results and details" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
try app.input.insertSliceAtCursor("2 + 3 * 4");
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
// A non-decimal literal adds the hex/oct/bin detail lines.
try app.input.insertSliceAtCursor("0xFF + 1");
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
// And an error entry, which renders in the error style.
try app.input.insertSliceAtCursor("1 / 0");
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
const rows = try renderApp(arena, &app, 80, 24);
try testing.expect(test_render.furniture(rows).intact());
try testing.expect(test_render.contains(rows, "2 + 3 * 4"));
try testing.expect(test_render.contains(rows, "= 14"));
try testing.expect(test_render.contains(rows, "= 256"));
try testing.expect(test_render.contains(rows, "hex:"));
try testing.expect(test_render.contains(rows, "oct:"));
try testing.expect(test_render.contains(rows, "bin:"));
try testing.expect(test_render.contains(rows, "division by zero"));
try testing.expect(test_render.contains(rows, "?:help"));
}
test "render: standard mode with more history than fits keeps the newest" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
var i: usize = 1;
while (i <= 30) : (i += 1) {
var buf: [32]u8 = undefined;
const expr = try std.fmt.bufPrint(&buf, "{d} * 1000", .{i});
try app.input.insertSliceAtCursor(expr);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
}
const rows = try renderApp(arena, &app, 80, 24);
// Newest entry visible, oldest scrolled off.
try testing.expect(test_render.contains(rows, "30 * 1000"));
try testing.expect(!test_render.contains(rows, "1 * 1000"));
}
test "render: programmer mode draws the bit grid and all base rows" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var app = testApp();
defer app.deinit();
app.setMode(.programmer);
app.prog_value = 0xDEADBEEF;
const rows = try renderApp(arena, &app, 100, 30);
try testing.expect(test_render.furniture(rows).intact());
try testing.expect(test_render.contains(rows, "Bits: 64"));
try testing.expect(test_render.contains(rows, "Signed: yes"));
try testing.expect(test_render.contains(rows, "DEC(s):"));
try testing.expect(test_render.contains(rows, "DEC(u):"));
try testing.expect(test_render.contains(rows, "HEX:"));
try testing.expect(test_render.contains(rows, "OCT:"));
try testing.expect(test_render.contains(rows, "BIN:"));
try testing.expect(test_render.contains(rows, "[float: Ctrl-F]"));
// 0xDEADBEEF in the decimal row and the hex row. Pinned exactly: an `or` of
// two spellings would not have caught a formatting regression in either.
try testing.expect(test_render.contains(rows, "3,735,928,559"));
try testing.expect(test_render.contains(rows, "DE AD BE EF"));
}
test "render: programmer mode warns when the value exceeds the display width" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var app = testApp();
defer app.deinit();
app.setMode(.programmer);
app.prog_value = 0xDEADBEEF;
app.prog_config.width = .bits8;
const rows = try renderApp(arena, &app, 100, 30);
try testing.expect(test_render.contains(rows, "value exceeds 8 bits"));
}
test "render: programmer mode is well formed at every width and setting" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var app = testApp();
defer app.deinit();
app.setMode(.programmer);
app.prog_value = 0xFEDCBA9876543210;
for ([_]engine.BitWidth{ .bits8, .bits16, .bits32, .bits64, .bits128 }) |width| {
app.prog_config.width = width;
for ([_]std.builtin.Endian{ .little, .big }) |endian| {
app.prog_config.display_endian = endian;
for ([_]engine.Integer.Signedness{ .signed, .unsigned }) |signedness| {
app.prog_config.signedness = signedness;
const rows = try renderApp(arena, &app, 100, 40);
try testing.expect(test_render.furniture(rows).intact());
var buf: [24]u8 = undefined;
const expected = try std.fmt.bufPrint(&buf, "Bits: {d}", .{width.bits()});
try testing.expect(test_render.contains(rows, expected));
}
}
}
}
test "render: the float view decodes a known bit pattern" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var app = testApp();
defer app.deinit();
app.setMode(.programmer);
app.float_view_active = true;
app.float_format = .f32;
app.prog_config.width = .bits32;
app.prog_value = @as(u32, @bitCast(@as(f32, 1.0)));
const rows = try renderApp(arena, &app, 100, 30);
try testing.expect(test_render.furniture(rows).intact());
try testing.expect(test_render.contains(rows, "sign"));
try testing.expect(test_render.contains(rows, "exponent"));
try testing.expect(test_render.contains(rows, "significand"));
try testing.expect(test_render.contains(rows, "Value:"));
try testing.expect(test_render.contains(rows, "Class:"));
try testing.expect(test_render.contains(rows, "Formula:"));
try testing.expect(test_render.contains(rows, "ULP:"));
// 1.0 is a normal number whose value renders as exactly 1.
try testing.expect(test_render.contains(rows, "normal"));
}
test "render: the float view decodes every classification by name" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var app = testApp();
defer app.deinit();
app.setMode(.programmer);
app.float_view_active = true;
// Asserting the classification text, not merely that the word "Class:" is on
// screen. The previous version of this test could not tell a NaN from a normal
// number.
const cases = [_]struct { format: engine.FloatFormat, bits: u128, class: []const u8 }{
.{ .format = .f32, .bits = 0, .class = "zero" },
.{ .format = .f32, .bits = 1, .class = "denormal" },
.{ .format = .f32, .bits = 0x3F800000, .class = "normal" },
.{ .format = .f32, .bits = 0x7F800000, .class = "infinity" },
.{ .format = .f32, .bits = 0x7FC00000, .class = "NaN" },
.{ .format = .f64, .bits = @as(u64, @bitCast(@as(f64, -3.14))), .class = "normal" },
.{ .format = .f64, .bits = 0, .class = "zero" },
.{ .format = .f64, .bits = 0x7FF0000000000000, .class = "infinity" },
};
for (cases) |case| {
app.float_format = case.format;
app.prog_config.width = if (case.format == .f32) .bits32 else .bits64;
app.prog_value = case.bits;
const rows = try renderApp(arena, &app, 100, 34);
try testing.expect(test_render.furniture(rows).intact());
if (!test_render.contains(rows, case.class)) {
std.debug.print("float view did not report \"{s}\" for bits 0x{X}\n", .{ case.class, case.bits });
return error.TestUnexpectedResult;
}
}
}
test "render: convert mode draws chips, both unit columns and the exact result" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var app = testApp();
defer app.deinit();
app.setMode(.convert);
const rows = try renderApp(arena, &app, 100, 34);
try testing.expect(test_render.furniture(rows).intact());
try testing.expect(test_render.contains(rows, "Category:"));
try testing.expect(test_render.contains(rows, "Length"));
try testing.expect(test_render.contains(rows, "From"));
try testing.expect(test_render.contains(rows, "To"));
// The full result line, not just a prefix: asserting "1 m =" passed equally for
// the correct 1,000,000,000 and for the 999,999,999.9999999 the inexact default
// value used to produce.
try testing.expect(test_render.contains(rows, "1 m"));
try testing.expect(test_render.contains(rows, "= 1,000,000,000 nm"));
try testing.expect(test_render.contains(rows, "Ctrl-S:swap"));
}
test "render: convert mode says when a conversion is affine" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var app = testApp();
defer app.deinit();
app.setMode(.convert);
var ctx = testCtx();
try app.applyAction(&ctx, .{ .conv_category = .temperature });
const rows = try renderApp(arena, &app, 100, 34);
// No single factor describes C to F, so it must not print one.
try testing.expect(test_render.contains(rows, "affine conversion"));
}
test "render: convert mode is well formed for every category" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var app = testApp();
defer app.deinit();
app.setMode(.convert);
for (std.enums.values(engine.UnitCategory)) |category| {
var ctx = testCtx();
try app.applyAction(&ctx, .{ .conv_category = category });
const rows = try renderApp(arena, &app, 100, 34);
try testing.expect(test_render.furniture(rows).intact());
try testing.expect(test_render.contains(rows, category.label()));
}
}
test "render: convert mode reports exactly how many units are hidden" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var app = testApp();
defer app.deinit();
app.setMode(.convert);
// Length has 14 units. At 100x19 only a few fit, and the notice must account
// for every one it does not show. The old code wrote the notice over the last
// unit it had just drawn and undercounted by one; the old test only checked
// that the words "more (resize to see all)" appeared somewhere.
const table_len = engine.units.unitsIn(.length).len;
const rows = try renderApp(arena, &app, 100, 19);
const header_row = test_render.rowOf(rows, "From") orelse return error.NoHeader;
const notice_row = test_render.rowOf(rows, "more (resize to see all)") orelse
return error.NoOverflowNotice;
try testing.expect(notice_row > header_row);
// Unit rows are the ones between the header and the notice.
const shown = notice_row - header_row - 1;
try testing.expect(shown > 0);
var expected_buf: [48]u8 = undefined;
const expected = try std.fmt.bufPrint(&expected_buf, "... {d} more", .{table_len - shown});
if (!test_render.contains(rows, expected)) {
std.debug.print("expected \"{s}\", got \"{s}\"\n", .{ expected, rows[notice_row] });
return error.TestUnexpectedResult;
}
// The row above the notice still holds a unit: the notice has its own row and
// does not erase the last one drawn.
const last_unit_row = rows[notice_row - 1];
try testing.expect(std.mem.trim(u8, last_unit_row, " ").len > 0);
}
test "render: a unit list that fits shows no notice and every unit" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var app = testApp();
defer app.deinit();
app.setMode(.convert);
const rows = try renderApp(arena, &app, 100, 40);
try testing.expect(!test_render.contains(rows, "more (resize to see all)"));
for (engine.units.unitsIn(.length)) |unit| {
if (!test_render.contains(rows, unit.name)) {
std.debug.print("unit \"{s}\" was not drawn\n", .{unit.name});
return error.TestUnexpectedResult;
}
}
}
test "render: the help overlay lists every mode's bindings across its pages" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var app = testApp();
defer app.deinit();
app.show_help = true;
// The content is longer than any normal terminal, so walk it a page at a time
// and require that every section appears somewhere.
const wanted = [_][]const u8{
"Tally - Help",
"Keybindings",
"Shift-Tab",
"Mouse",
"Programmer Mode",
"Financial Mode",
"Convert Mode",
"Functions",
"Financial Functions",
"Operators",
"Units and Conversion",
};
var found: [wanted.len]bool = @splat(false);
var page: usize = 0;
while (page < 12) : (page += 1) {
const rows = try renderApp(arena, &app, 80, 24);
// The help overlay has no prompt, so its structural invariant is its own:
// the title on row 1 and a footer on the last row.
try testing.expectEqual(@as(?usize, 1), test_render.rowOf(rows, "Tally - Help"));
try testing.expect(test_render.rowOf(rows, "scroll") == rows.len - 1);
for (wanted, 0..) |needle, i| {
if (test_render.contains(rows, needle)) found[i] = true;
}
app.scrollHelpBy(10);
}
for (wanted, found) |needle, ok| {
if (!ok) {
std.debug.print("help never showed \"{s}\"\n", .{needle});
return error.TestUnexpectedResult;
}
}
}
test "render: the help overlay shows its scroll position when content is off screen" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var app = testApp();
defer app.deinit();
app.show_help = true;
const short = try renderApp(arena, &app, 80, 24);
try testing.expect(test_render.contains(short, "lines 1-"));
try testing.expect(test_render.contains(short, "scroll"));
// Tall enough for everything: the position indicator is not needed.
const tall = try renderApp(arena, &app, 80, 90);
try testing.expect(test_render.contains(tall, "Press any key to return"));
try testing.expect(!test_render.contains(tall, "lines 1-"));
}
test "render: the help overlay survives a terminal too short for any content" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var app = testApp();
defer app.deinit();
app.show_help = true;
const rows = try renderApp(arena, &app, 80, 4);
try testing.expectEqual(@as(?usize, 1), test_render.rowOf(rows, "Tally - Help"));
try testing.expect(test_render.contains(rows, "Tally - Help"));
}
test "render: every mode keeps its input line at every terminal size" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var app = testApp();
defer app.deinit();
// The point of this test is the furniture check: content that overflows its
// region lands on the separator or the prompt, which is exactly what happened
// in programmer mode at 100x16 and financial mode at 20x5. The old assertion
// here could not fail, so both went unnoticed.
for ([_]Mode{ .standard, .programmer, .financial, .convert }) |mode| {
app.setMode(mode);
for ([_][2]u16{ .{ 20, 6 }, .{ 40, 10 }, .{ 60, 15 }, .{ 100, 16 }, .{ 200, 60 } }) |size| {
const rows = try renderApp(arena, &app, size[0], size[1]);
const bottom = test_render.furniture(rows);
if (!bottom.intact()) {
std.debug.print(
"{s} at {d}x{d}: separator={} prompt={} status={}\n",
.{ @tagName(mode), size[0], size[1], bottom.separator, bottom.prompt, bottom.status },
);
return error.TestUnexpectedResult;
}
}
}
}
test "render: 128-bit programmer mode keeps its input line on a short terminal" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var app = testApp();
defer app.deinit();
app.setMode(.programmer);
app.prog_config.width = .bits128;
// Four grid rows plus six base rows do not fit in 16 rows. The view used to
// draw them anyway, putting the BIN row on the prompt.
const rows = try renderApp(arena, &app, 100, 16);
try testing.expect(test_render.furniture(rows).intact());
try testing.expect(test_render.contains(rows, "terminal too short"));
// And it says what it needs, rather than showing a half-drawn layout.
try testing.expect(test_render.contains(rows, "128-bit"));
try testing.expect(!test_render.contains(rows, "BIN:"));
// With room, the full view is back.
const tall = try renderApp(arena, &app, 100, 30);
try testing.expect(test_render.furniture(tall).intact());
try testing.expect(test_render.contains(tall, "BIN:"));
try testing.expect(!test_render.contains(tall, "terminal too short"));
}
test "help overlay: arrows scroll, other keys dismiss" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
try press(&app, &ctx, .{ .codepoint = '?' });
try testing.expect(app.show_help);
// Scrolling keys navigate rather than closing.
try press(&app, &ctx, .{ .codepoint = vaxis.Key.down });
try testing.expect(app.show_help);
try testing.expectEqual(@as(usize, 1), app.help_scroll);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.page_down });
try testing.expect(app.show_help);
try testing.expect(app.help_scroll > 1);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.up });
try testing.expect(app.show_help);
// Anything else closes it and resets the position.
try press(&app, &ctx, .{ .codepoint = 'q' });
try testing.expect(!app.show_help);
try testing.expectEqual(@as(usize, 0), app.help_scroll);
}
test "help overlay: scrolling is clamped to the content" {
var app = testApp();
defer app.deinit();
app.show_help = true;
app.scrollHelpBy(-5);
try testing.expectEqual(@as(usize, 0), app.help_scroll);
app.scrollHelpBy(10_000);
const visible = help_view.visibleLines(app.last_height);
try testing.expectEqual(help_view.lineCount() - visible, app.help_scroll);
// The last line is always reachable, and never scrolled past.
try testing.expect(app.help_scroll + visible == help_view.lineCount());
}
test "help overlay: the wheel scrolls it and a click dismisses it" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
app.show_help = true;
try app.handleMouse(&ctx, .{ .col = 10, .row = 5, .button = .wheel_down, .mods = .{}, .type = .press });
try testing.expect(app.show_help);
try testing.expectEqual(@as(usize, 3), app.help_scroll);
try app.handleMouse(&ctx, .{ .col = 10, .row = 5, .button = .wheel_up, .mods = .{}, .type = .press });
try testing.expectEqual(@as(usize, 0), app.help_scroll);
try app.handleMouse(&ctx, .{ .col = 10, .row = 5, .button = .left, .mods = .{}, .type = .press });
try testing.expect(!app.show_help);
}
test "help overlay: a click does not fall through to the view underneath" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
// Draw a frame so the tab-bar regions exist, then open help and click one.
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
_ = try renderApp(arena_state.allocator(), &app, 80, 24);
app.show_help = true;
try app.handleMouse(&ctx, .{ .col = 72, .row = 0, .button = .left, .mods = .{}, .type = .press });
try testing.expect(!app.show_help);
// Still in standard mode: the click closed help instead of switching mode.
try testing.expectEqual(Mode.standard, app.mode);
}
// -- Interaction paths --
//
// Event handling was the other half of the untested TUI: the key and mouse
// routing for programmer and convert mode had no coverage, so a binding could be
// broken by a refactor without any test noticing.
/// Send an event through the widget interface, the way the runtime does.
fn dispatch(app: *App, ctx: *vxfw.EventContext, event: vxfw.Event) !void {
const widget = app.widget();
try widget.eventHandler.?(widget.userdata, ctx, event);
}
test "events arrive through the widget interface" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
try dispatch(&app, &ctx, .init);
try testing.expect(ctx.redraw);
try dispatch(&app, &ctx, .{ .key_press = .{ .codepoint = vaxis.Key.tab } });
try testing.expectEqual(Mode.programmer, app.mode);
try dispatch(&app, &ctx, .{ .mouse = .{
.col = 0,
.row = 0,
.button = .left,
.mods = .{},
.type = .press,
} });
// An event kind the app does not handle is ignored rather than crashing.
try dispatch(&app, &ctx, .focus_in);
}
test "ProgField next and prev walk the full cycle" {
const fields = [_]App.ProgField{ .bits, .dec_signed, .dec_unsigned, .hex, .oct, .bin, .expression };
for (fields) |field| {
try testing.expectEqual(field, field.next().prev());
try testing.expectEqual(field, field.prev().next());
try testing.expect(field.label().len > 0);
}
// A full lap returns to the start.
var field: App.ProgField = .bits;
for (fields) |_| field = field.next();
try testing.expectEqual(App.ProgField.bits, field);
}
test "clicking a tab switches mode through the region table" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
// Draw first so the tab regions exist, then click each tab where it was drawn.
_ = try renderApp(arena_state.allocator(), &app, 100, 24);
for ([_]Mode{ .convert, .financial, .programmer, .standard }) |mode| {
const region = for (app.regions.items[0..app.regions.count]) |r| {
switch (r.action) {
.mode => |m| if (m == mode) break r,
else => {},
}
} else return error.NoRegionForMode;
try app.handleMouse(&ctx, .{
.col = @intCast(region.col),
.row = @intCast(region.row),
.button = .left,
.mods = .{},
.type = .press,
});
try testing.expectEqual(mode, app.mode);
}
}
test "clicking empty space does nothing" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
_ = try renderApp(arena_state.allocator(), &app, 100, 24);
const before = app.mode;
try app.handleMouse(&ctx, .{ .col = 3, .row = 1, .button = .left, .mods = .{}, .type = .press });
try testing.expectEqual(before, app.mode);
// Non-press events are ignored so one click cannot fire twice.
try app.handleMouse(&ctx, .{ .col = 0, .row = 0, .button = .left, .mods = .{}, .type = .release });
try app.handleMouse(&ctx, .{ .col = 0, .row = 0, .button = .middle, .mods = .{}, .type = .press });
try testing.expectEqual(before, app.mode);
}
test "programmer mode: every clickable control acts" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
app.setMode(.programmer);
// Focus a field, with and without a bit position.
try app.applyAction(&ctx, .{ .prog_field = .{ .field = .hex, .bit = 8 } });
try testing.expectEqual(App.ProgField.hex, app.prog_field);
try testing.expectEqual(@as(u7, 8), app.bit_cursor);
try app.applyAction(&ctx, .{ .prog_field = .{ .field = .oct, .bit = null } });
try testing.expectEqual(App.ProgField.oct, app.prog_field);
// An out-of-width bit is ignored rather than moving the cursor off the value.
app.prog_config.width = .bits8;
try app.applyAction(&ctx, .{ .prog_field = .{ .field = .bits, .bit = 100 } });
try testing.expect(app.bit_cursor < 8);
// Toggling bits.
app.prog_config.width = .bits32;
app.prog_value = 0;
try app.applyAction(&ctx, .{ .toggle_bit = 3 });
try testing.expectEqual(@as(u128, 8), app.prog_value);
try app.applyAction(&ctx, .{ .toggle_bit = 3 });
try testing.expectEqual(@as(u128, 0), app.prog_value);
try app.applyAction(&ctx, .{ .toggle_bit = 99 }); // out of width: no-op
try testing.expectEqual(@as(u128, 0), app.prog_value);
// Settings.
const width_before = app.prog_config.width;
try app.applyAction(&ctx, .cycle_width);
try testing.expect(app.prog_config.width != width_before);
const endian_before = app.prog_config.display_endian;
try app.applyAction(&ctx, .toggle_endian);
try testing.expect(app.prog_config.display_endian != endian_before);
const signed_before = app.prog_config.signedness;
try app.applyAction(&ctx, .toggle_signedness);
try testing.expect(app.prog_config.signedness != signed_before);
// Float overlay and its format toggle.
try app.applyAction(&ctx, .toggle_float);
try testing.expect(app.float_view_active);
const format_before = app.float_format;
try app.applyAction(&ctx, .toggle_float_format);
try testing.expect(app.float_format != format_before);
try app.applyAction(&ctx, .toggle_float);
try testing.expect(!app.float_view_active);
// Focus and help.
try app.applyAction(&ctx, .focus_input);
try testing.expect(!app.value_zone_active);
app.show_help = true;
try app.applyAction(&ctx, .close_help);
try testing.expect(!app.show_help);
}
test "programmer mode: bit width cycles through every size and keeps the cursor in range" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
app.setMode(.programmer);
app.value_zone_active = true;
app.bit_cursor = 100;
app.prog_config.width = .bits8;
var seen: usize = 0;
while (seen < 6) : (seen += 1) {
try press(&app, &ctx, .{ .codepoint = 'w', .mods = .{ .ctrl = true } });
try testing.expect(app.bit_cursor < app.prog_config.width.bits());
}
// Six steps through five widths lands one past the start: 8, 16, 32, 64, 128, 8, 16.
try testing.expectEqual(engine.BitWidth.bits16, app.prog_config.width);
}
test "programmer mode: typing edits the focused field in its own base" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
app.setMode(.programmer);
app.value_zone_active = true;
app.prog_config.width = .bits32;
// Hex nibble entry.
app.prog_field = .hex;
app.bit_cursor = 28;
app.prog_value = 0;
for ("dead") |char| try press(&app, &ctx, .{ .codepoint = char });
try testing.expectEqual(@as(u128, 0xDEAD0000), app.prog_value);
// Octal digit entry.
app.prog_field = .oct;
app.bit_cursor = 6;
app.prog_value = 0;
try press(&app, &ctx, .{ .codepoint = '7' });
try testing.expectEqual(@as(u128, 0o700), app.prog_value);
// Binary digits.
app.prog_field = .bin;
app.bit_cursor = 3;
app.prog_value = 0;
try press(&app, &ctx, .{ .codepoint = '1' });
try press(&app, &ctx, .{ .codepoint = '1' });
try testing.expectEqual(@as(u128, 0b1100), app.prog_value);
try press(&app, &ctx, .{ .codepoint = '0' });
// Decimal digits accumulate.
app.prog_field = .dec_unsigned;
app.prog_value = 0;
for ("123") |char| try press(&app, &ctx, .{ .codepoint = char });
try testing.expectEqual(@as(u128, 123), app.prog_value);
// The expression row ignores typed digits.
app.prog_field = .expression;
const before = app.prog_value;
try press(&app, &ctx, .{ .codepoint = '9' });
try testing.expectEqual(before, app.prog_value);
}
test "programmer mode: arrows and space navigate the bit grid" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
app.setMode(.programmer);
app.value_zone_active = true;
app.prog_config.width = .bits64;
app.prog_field = .bits;
app.bit_cursor = 0;
// Up moves a row of bits at a time inside the grid, then to the previous field.
try press(&app, &ctx, .{ .codepoint = vaxis.Key.up });
try testing.expectEqual(@as(u7, 32), app.bit_cursor);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.down });
try testing.expectEqual(@as(u7, 0), app.bit_cursor);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.up });
try press(&app, &ctx, .{ .codepoint = vaxis.Key.up });
try testing.expectEqual(App.ProgField.expression, app.prog_field);
// Left/Right step by the field's digit size.
app.prog_field = .hex;
app.bit_cursor = 0;
try press(&app, &ctx, .{ .codepoint = vaxis.Key.left });
try testing.expectEqual(@as(u7, 4), app.bit_cursor);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.right });
try testing.expectEqual(@as(u7, 0), app.bit_cursor);
// At the edges the cursor stays put.
try press(&app, &ctx, .{ .codepoint = vaxis.Key.right });
try testing.expectEqual(@as(u7, 0), app.bit_cursor);
// Space flips the bit under the cursor, but only in the grid.
app.prog_field = .bits;
app.prog_value = 0;
app.bit_cursor = 5;
try press(&app, &ctx, .{ .codepoint = ' ' });
try testing.expectEqual(@as(u128, 32), app.prog_value);
app.prog_field = .hex;
try press(&app, &ctx, .{ .codepoint = ' ' });
try testing.expectEqual(@as(u128, 32), app.prog_value);
// Down from the last field wraps to the first.
app.prog_field = .expression;
try press(&app, &ctx, .{ .codepoint = vaxis.Key.down });
try testing.expectEqual(App.ProgField.bits, app.prog_field);
}
test "programmer mode: an expression submitted from the prompt sets the value" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
app.setMode(.programmer);
try app.input.insertSliceAtCursor("0xFF and 0x0F");
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
try testing.expectEqual(@as(u128, 0x0F), app.prog_value);
try testing.expectEqual(@as(usize, 1), app.history.items.len);
try testing.expect(!app.history.items[0].is_error);
// A bad expression records an error and leaves the value alone.
try app.input.insertSliceAtCursor("0xFF and");
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
try testing.expectEqual(@as(u128, 0x0F), app.prog_value);
try testing.expect(app.history.items[1].is_error);
}
test "float view: typing a decimal stores the nearest bit pattern" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
app.setMode(.programmer);
app.float_view_active = true;
app.float_format = .f64;
app.prog_config.width = .bits64;
try app.input.insertSliceAtCursor("3.14");
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
try testing.expectEqual(@as(u128, @as(u64, @bitCast(@as(f64, 3.14)))), app.prog_value);
try testing.expect(app.history.items.len == 1);
// Input that is not a float literal falls through to the integer engine.
try app.input.insertSliceAtCursor("1 << 3");
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
try testing.expectEqual(@as(u128, 8), app.prog_value);
// In the float overlay Ctrl-W swaps format instead of cycling width.
try press(&app, &ctx, .{ .codepoint = 'w', .mods = .{ .ctrl = true } });
try testing.expectEqual(engine.FloatFormat.f32, app.float_format);
try testing.expectEqual(engine.BitWidth.bits32, app.prog_config.width);
// Ctrl-F leaves the overlay.
try press(&app, &ctx, .{ .codepoint = 'f', .mods = .{ .ctrl = true } });
try testing.expect(!app.float_view_active);
}
test "switching into programmer mode picks a representation that fits the answer" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
// An integer answer uses the integer views.
try app.input.insertSliceAtCursor("6 * 7");
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
app.setMode(.programmer);
try testing.expect(!app.float_view_active);
try testing.expectEqual(@as(u128, 42), app.prog_value);
// A negative integer arrives as its two's complement.
app.setMode(.standard);
try app.input.insertSliceAtCursor("0 - 42");
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
app.setMode(.programmer);
try testing.expect(!app.float_view_active);
try testing.expectEqual(@as(u128, @as(u64, @bitCast(@as(i64, -42)))), app.prog_value & 0xFFFF_FFFF_FFFF_FFFF);
// A fraction cannot be shown in the integer views, so the float view opens.
app.setMode(.standard);
try app.input.insertSliceAtCursor("1 / 3");
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
app.setMode(.programmer);
try testing.expect(app.float_view_active);
try testing.expectEqual(engine.FloatFormat.f64, app.float_format);
}
test "convert mode: selection keys move between columns and wrap within them" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
app.setMode(.convert);
app.value_zone_active = true;
// Left and right cycle category -> from -> to.
app.conv_zone = .from;
try press(&app, &ctx, .{ .codepoint = vaxis.Key.left });
try testing.expectEqual(ConvZone.category, app.conv_zone);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.left });
try testing.expectEqual(ConvZone.to, app.conv_zone);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.right });
try testing.expectEqual(ConvZone.category, app.conv_zone);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.right });
try testing.expectEqual(ConvZone.from, app.conv_zone);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.right });
try testing.expectEqual(ConvZone.to, app.conv_zone);
// Up/Down move the selection in the focused column.
app.conv_zone = .from;
const from_before = app.conv_from_idx;
try press(&app, &ctx, .{ .codepoint = vaxis.Key.down });
try testing.expect(app.conv_from_idx != from_before);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.up });
try testing.expectEqual(from_before, app.conv_from_idx);
app.conv_zone = .to;
const to_before = app.conv_to_idx;
try press(&app, &ctx, .{ .codepoint = vaxis.Key.down });
try testing.expect(app.conv_to_idx != to_before);
// In the category column, Up/Down change category and reset the unit pair.
app.conv_zone = .category;
const category_before = app.conv_category;
try press(&app, &ctx, .{ .codepoint = vaxis.Key.down });
try testing.expect(app.conv_category != category_before);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.up });
try testing.expectEqual(category_before, app.conv_category);
}
test "convert mode: swap works from either zone and clicks select units" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
app.setMode(.convert);
const before = app.convUnits();
try press(&app, &ctx, .{ .codepoint = 's', .mods = .{ .ctrl = true } });
const after = app.convUnits();
try testing.expectEqualStrings(before.from.name, after.to.name);
try testing.expectEqualStrings(before.to.name, after.from.name);
try app.applyAction(&ctx, .{ .conv_from = 2 });
try testing.expectEqual(@as(usize, 2), app.conv_from_idx);
try testing.expectEqual(ConvZone.from, app.conv_zone);
try app.applyAction(&ctx, .{ .conv_to = 5 });
try testing.expectEqual(@as(usize, 5), app.conv_to_idx);
try testing.expectEqual(ConvZone.to, app.conv_zone);
try app.applyAction(&ctx, .conv_swap);
try testing.expectEqual(@as(usize, 5), app.conv_from_idx);
// Selecting a category resets the pair to that category's defaults.
try app.applyAction(&ctx, .{ .conv_category = .mass });
const defaults = defaultUnitIndices(.mass);
try testing.expectEqual(defaults.from, app.conv_from_idx);
try testing.expectEqual(defaults.to, app.conv_to_idx);
}
test "convert mode: the input line sets the value, an expression or a plain number" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
app.setMode(.convert);
try app.applyAction(&ctx, .{ .conv_category = .length });
try app.input.insertSliceAtCursor("100");
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
try testing.expectEqual(@as(usize, 1), app.history.items.len);
try testing.expect(!app.history.items[0].is_error);
try app.input.insertSliceAtCursor("2 * 3.5");
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
try testing.expect(!app.history.items[1].is_error);
// A bad value records an error and leaves the previous value in place.
try app.input.insertSliceAtCursor("2 +");
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
try testing.expect(app.history.items[2].is_error);
}
test "standard mode: a bare conversion and its failure modes are recorded" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
try app.input.insertSliceAtCursor("100 km to mi");
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
try testing.expect(!app.history.items[0].is_error);
try testing.expect(std.mem.indexOf(u8, app.history.items[0].result, "mi") != null);
// Incompatible units.
try app.input.insertSliceAtCursor("100 km to kg");
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
try testing.expect(app.history.items[1].is_error);
// A value that does not evaluate.
try app.input.insertSliceAtCursor("2 + to mi");
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
try testing.expect(app.history.items[2].is_error);
}
test "history browsing walks back and restores what was being typed" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
for ([_][]const u8{ "1 + 1", "2 + 2", "3 + 3" }) |expr| {
try app.input.insertSliceAtCursor(expr);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
}
// A partially typed expression is preserved while browsing.
try app.input.insertSliceAtCursor("9 * ");
try press(&app, &ctx, .{ .codepoint = vaxis.Key.up });
try testing.expectEqual(@as(?usize, 0), app.history_browse_idx);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.up });
try testing.expectEqual(@as(?usize, 1), app.history_browse_idx);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.down });
try testing.expectEqual(@as(?usize, 0), app.history_browse_idx);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.down });
try testing.expectEqual(@as(?usize, null), app.history_browse_idx);
try testing.expectEqual(@as(usize, 4), app.input.buf.realLength());
// Down with nothing to restore is a no-op, and Up past the oldest entry stops.
try press(&app, &ctx, .{ .codepoint = vaxis.Key.down });
var i: usize = 0;
while (i < 10) : (i += 1) try press(&app, &ctx, .{ .codepoint = vaxis.Key.up });
try testing.expectEqual(@as(?usize, 2), app.history_browse_idx);
}
test "history browsing with no history does nothing" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.up });
try testing.expectEqual(@as(?usize, null), app.history_browse_idx);
}
test "Ctrl-L clears history and any saved input" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
try app.input.insertSliceAtCursor("0xFF + 1");
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
try app.input.insertSliceAtCursor("in progress");
try press(&app, &ctx, .{ .codepoint = vaxis.Key.up });
try testing.expect(app.saved_input != null);
try press(&app, &ctx, .{ .codepoint = 'l', .mods = .{ .ctrl = true } });
try testing.expectEqual(@as(usize, 0), app.history.items.len);
try testing.expectEqual(@as(?usize, null), app.history_browse_idx);
try testing.expect(app.saved_input == null);
}
test "an empty prompt submits nothing" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
try testing.expectEqual(@as(usize, 0), app.history.items.len);
}
test "typed characters reach the prompt and Ctrl-C quits" {
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
for ("42") |char| try press(&app, &ctx, .{ .codepoint = char, .text = "x" });
try testing.expect(app.input.buf.realLength() > 0);
try press(&app, &ctx, .{ .codepoint = 'c', .mods = .{ .ctrl = true } });
try testing.expect(ctx.quit);
}
test "errorStr covers the errors the TUI can surface" {
const errors = [_]engine.Error{
engine.Error.DivisionByZero,
engine.Error.UnknownFunction,
engine.Error.UnknownVariable,
engine.Error.UnmatchedParen,
engine.Error.UnexpectedToken,
engine.Error.UnexpectedEnd,
engine.Error.InvalidNumber,
engine.Error.DomainError,
engine.Error.Overflow,
engine.Error.UnknownUnit,
engine.Error.IncompatibleUnits,
engine.Error.ConvergenceFailure,
};
for (errors) |err| {
const text = errorStr(err);
try testing.expect(std.mem.startsWith(u8, text, "error: "));
}
}
test "render: programmer mode draws the cursor in whichever field is focused" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var app = testApp();
defer app.deinit();
app.setMode(.programmer);
app.value_zone_active = true;
app.prog_value = 0x0FEDCBA987654321;
// Each base row highlights differently and draws a digit cursor, so every
// field has to be drawn focused at least once.
const fields = [_]App.ProgField{ .bits, .dec_signed, .dec_unsigned, .hex, .oct, .bin, .expression };
for (fields) |field| {
app.prog_field = field;
for ([_]engine.BitWidth{ .bits8, .bits64, .bits128 }) |width| {
app.prog_config.width = width;
app.bit_cursor = @intCast(@min(5, width.bits() - 1));
const rows = try renderApp(arena, &app, 110, 40);
try testing.expect(test_render.furniture(rows).intact());
// The bit grid has one row per 32 bits, and the base rows follow it, so
// this pins the layout rather than just the presence of a string.
const grid_rows = (@as(usize, width.bits()) + 31) / 32;
try testing.expectEqual(@as(?usize, 4 + grid_rows + 1), test_render.rowOf(rows, "DEC(s):"));
try testing.expectEqual(@as(?usize, 4 + grid_rows + 6), test_render.rowOf(rows, "BIN:"));
// The config line is always present, whichever field has focus.
try testing.expect(test_render.contains(rows, "Bits:"));
}
}
}
test "render: convert mode draws the focused column and category" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var app = testApp();
defer app.deinit();
app.setMode(.convert);
app.value_zone_active = true;
for ([_]ConvZone{ .category, .from, .to }) |zone| {
app.conv_zone = zone;
const rows = try renderApp(arena, &app, 100, 34);
try testing.expect(test_render.furniture(rows).intact());
try testing.expect(test_render.contains(rows, "Category:"));
}
}