tally/src/tui.zig

684 lines
25 KiB
Zig

//! Tally TUI - interactive calculator interface.
//!
//! Split into sub-modules:
//! - draw.zig: shared drawing primitives and color palette
//! - tui/programmer.zig: programmer mode layout
//! - tui/help.zig: help overlay
const std = @import("std");
const vaxis = @import("vaxis");
const vxfw = vaxis.vxfw;
const engine = @import("engine");
const draw = @import("tui/draw.zig");
const programmer_view = @import("tui/programmer.zig");
const 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(), .{});
}