//! 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:")); } }