//! 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 help_view = @import("tui/help.zig"); const C = draw.C; const Allocator = std.mem.Allocator; const Mode = enum { standard, programmer }; pub const App = struct { allocator: Allocator, io: std.Io, env: engine.evaluator.Environment, input: vxfw.TextField, history: std.ArrayList(HistoryEntry), show_help: bool, history_browse_idx: ?usize, saved_input: ?[]const u8, mode: Mode, // Programmer mode state prog_value: u128, prog_config: engine.types.ProgrammerConfig, bit_cursor: u7, prog_field: ProgField, value_zone_active: bool, // true = cursor in value display, false = in input 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 }; return .{ .allocator = allocator, .io = io, .env = engine.evaluator.Environment.init(allocator, .standard), .input = text_field, .history = .empty, .show_help = false, .history_browse_idx = null, .saved_input = null, .mode = .standard, .prog_value = 0, .prog_config = .{}, .bit_cursor = 0, .prog_field = .bits, .value_zone_active = false, }; } 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); } pub fn widget(self: *App) vxfw.Widget { return .{ .userdata = self, .eventHandler = typeErasedEventHandler, .drawFn = typeErasedDrawFn, }; } 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), .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) { self.show_help = false; ctx.redraw = true; return; } if (key.matches('?', .{}) and !self.value_zone_active) { self.show_help = true; ctx.redraw = true; return; } // Tab: switch between Standard and Programmer mode if (key.matches(vaxis.Key.tab, .{})) { self.mode = if (self.mode == .standard) .programmer else .standard; self.env.mode = if (self.mode == .standard) .standard else .programmer; if (self.mode == .programmer) { const ans = self.env.ans; if (ans >= 0 and ans == @trunc(ans) and ans < 18446744073709551616.0) { self.prog_value = @intFromFloat(ans); } else if (ans < 0 and ans == @trunc(ans) and ans >= -9223372036854775808.0) { const signed: i128 = @intFromFloat(ans); self.prog_value = @bitCast(signed); } self.prog_value &= self.prog_config.bit_width.mask(); } self.value_zone_active = false; ctx.redraw = true; return; } // Backtick: toggle between input zone and value zone (programmer mode only) if (self.mode == .programmer and key.matches('`', .{})) { self.value_zone_active = !self.value_zone_active; ctx.redraw = true; return; } // Ctrl-W: cycle bit width (programmer mode) if (self.mode == .programmer and key.matches('w', .{ .ctrl = true })) { self.prog_config.bit_width = switch (self.prog_config.bit_width) { .bits8 => .bits16, .bits16 => .bits32, .bits32 => .bits64, .bits64 => .bits128, .bits128 => .bits8, }; // NOTE: prog_value is intentionally NOT masked here. Width is a // display lens over the full value, so narrowing then widening // restores the hidden upper bits. The display masks to width and // a warning is shown while the value does not fit (see // drawProgrammerMode). Explicit value edits still commit to width. if (self.bit_cursor >= self.prog_config.bit_width.bits()) { self.bit_cursor = @intCast(self.prog_config.bit_width.bits() - 1); } ctx.redraw = true; return; } // Ctrl-E: toggle display endianness (programmer mode) if (self.mode == .programmer and key.matches('e', .{ .ctrl = true })) { self.prog_config.display_endian = switch (self.prog_config.display_endian) { .little => .big, .big => .little, }; 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; } 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.bit_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.bit_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.bit_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.bit_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.bit_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.bit_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.bit_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.bit_width.mask(); self.prog_value = (((self.prog_value & m) *% 10) +% (char - '0')) & m; } }, .expression => {}, } } /// 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) { try self.submitProgrammer(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 { const 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; }; var fmt_buf: [4096]u8 = undefined; const formatted = engine.formatter.formatFloat(&fmt_buf, info.value); const result_copy = try self.allocator.dupe(u8, formatted.display); var details: ?[3][]const u8 = null; if (info.has_nondecimal_literal and info.value >= 0 and info.value == @trunc(info.value) and info.value < 340282366920938463463374607431768211456.0) { const int_val: u128 = @intFromFloat(info.value); const bw = engine.types.BitWidth.smallestFor(int_val); var hex_buf: [256]u8 = undefined; var oct_buf: [256]u8 = undefined; var bin_buf: [512]u8 = undefined; const hex = engine.formatter.formatHex(&hex_buf, int_val, bw, .big); const oct = engine.formatter.formatOctal(&oct_buf, int_val, bw); const bin = engine.formatter.formatBinary(&bin_buf, int_val, bw); details = .{ try std.fmt.allocPrint(self.allocator, "hex: {s}", .{hex.display}), try std.fmt.allocPrint(self.allocator, "oct: {s}", .{oct.display}), try std.fmt.allocPrint(self.allocator, "bin: {s}", .{bin.display}), }; } try self.history.append(self.allocator, .{ .expr = expr_text, .result = result_copy, .is_error = false, .details = details, }); } 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(); var buf: [256]u8 = undefined; const dec = engine.formatter.formatDecimalUnsigned(&buf, int.unsignedValue()); break :blk .{ false, try self.allocator.dupe(u8, dec.display) }; } 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 }); if (self.show_help) { help_view.drawHelp(&surface, width, height); 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 const std_style: vaxis.Style = if (self.mode == .standard) .{ .fg = C.bg, .bg = C.green, .bold = true } else .{ .fg = C.muted, .bg = C.bg }; const prog_style: vaxis.Style = if (self.mode == .programmer) .{ .fg = C.bg, .bg = C.orange, .bold = true } else .{ .fg = C.muted, .bg = C.bg }; const tab_col = width -| 26; draw.writeStr(&surface, 0, tab_col, " Standard ", std_style); draw.writeStr(&surface, 0, tab_col + 10, " Programmer ", prog_style); if (self.mode == .programmer) { programmer_view.drawProgrammerMode(self, &surface, width, height); } else { self.drawStandardMode(&surface, width, height); } return surface; } fn drawStandardMode(self: *App, surface: *vxfw.Surface, width: u16, height: u16) void { _ = width; drawHistory(self.history.items, surface, 2, height -| 4); draw.fillRow(surface, height -| 3, '-', .{ .fg = C.dim }); self.drawInput(surface, height -| 2); 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 }; } } }; 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; } } fn errorStr(err: engine.CalcError) []const u8 { return switch (err) { engine.CalcError.DivisionByZero => "error: division by zero", engine.CalcError.UnknownFunction => "error: unknown function", engine.CalcError.UnknownVariable => "error: unknown variable", engine.CalcError.UnmatchedParen => "error: unmatched parenthesis", engine.CalcError.UnexpectedToken => "error: unexpected token", engine.CalcError.UnexpectedEnd => "error: unexpected end of expression", engine.CalcError.InvalidNumber => "error: invalid number", engine.CalcError.DomainError => "error: domain error", engine.CalcError.Overflow => "error: overflow", else => "error: evaluation error", }; } 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(), .{}); }