diff --git a/.gitignore b/.gitignore index 073a8f3..fe35b4b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ zig-out/ .DS_Store Thumbs.db coverage/ +zig-pkg/ diff --git a/build.zig b/build.zig index 8f1764e..75b8499 100644 --- a/build.zig +++ b/build.zig @@ -12,6 +12,12 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); + // -- Vaxis dependency (TUI library) -- + const vaxis_dep = b.dependency("vaxis", .{ + .target = target, + .optimize = optimize, + }); + // -- Engine static library (for linking into the main binary) -- const engine_lib = b.addLibrary(.{ .linkage = .static, @@ -44,6 +50,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .imports = &.{ .{ .name = "engine", .module = engine_mod }, + .{ .name = "vaxis", .module = vaxis_dep.module("vaxis") }, }, }), }); @@ -65,6 +72,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .imports = &.{ .{ .name = "engine", .module = engine_mod }, + .{ .name = "vaxis", .module = vaxis_dep.module("vaxis") }, }, }), }); diff --git a/build.zig.zon b/build.zig.zon index 16f9fc4..029ad4c 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -10,5 +10,10 @@ "engine", "src", }, - .dependencies = .{}, + .dependencies = .{ + .vaxis = .{ + .url = "git+https://github.com/rockorager/libvaxis.git?ref=main#1dbbe575dff4586fe51e3217aa5c3fecdcbb6089", + .hash = "vaxis-0.6.0-BWNV_CrbCQCscGpzsAlR402rYQ_tV3aAl081c2iRRkka", + }, + }, } diff --git a/src/main.zig b/src/main.zig index af4fb2b..8d99ca1 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,6 @@ const std = @import("std"); const engine = @import("engine"); +const tui = @import("tui.zig"); /// Result of CLI evaluation - pure data, no I/O. pub const CliResult = struct { @@ -131,21 +132,6 @@ const help_text = \\ ; -const usage_text = - \\tally - a cross-platform calculator - \\ - \\Usage: - \\ tally "" Evaluate expression - \\ tally -p "" Programmer mode - \\ tally --help Show help - \\ - \\Examples: - \\ tally "2^32 - 1" - \\ tally -p "0xFF & 0x0F" - \\ tally "sin(pi/2) + 1" - \\ -; - // -- Entry point: thin I/O shell over pure logic -- pub fn main(init: std.process.Init) u8 { @@ -158,7 +144,13 @@ pub fn main(init: std.process.Init) u8 { }; if (args.len <= 1) { - write(io, std.Io.File.stdout(), usage_text); + tui.run(allocator, io, init.environ_map) catch |err| { + write(io, std.Io.File.stderr(), "error: failed to start TUI: "); + const err_name = @errorName(err); + write(io, std.Io.File.stderr(), err_name); + write(io, std.Io.File.stderr(), "\n"); + return 1; + }; return 0; } diff --git a/src/tui.zig b/src/tui.zig new file mode 100644 index 0000000..047403b --- /dev/null +++ b/src/tui.zig @@ -0,0 +1,511 @@ +const std = @import("std"); +const vaxis = @import("vaxis"); +const vxfw = vaxis.vxfw; +const engine = @import("engine"); + +const Allocator = std.mem.Allocator; + +/// TUI application state. +pub const App = struct { + allocator: Allocator, + io: std.Io, + env: engine.evaluator.Environment, + input: vxfw.TextField, + history: std.ArrayList(HistoryEntry), + show_help: bool, + /// Index into history for up/down navigation. + /// null means we're on a fresh input (not browsing history). + /// 0 = most recent entry, 1 = second most recent, etc. + history_browse_idx: ?usize, + /// Saved current input when user starts browsing history + saved_input: ?[]const u8, + + const HistoryEntry = struct { + expr: []const u8, + result: []const u8, + is_error: bool, + }; + + pub fn init(allocator: Allocator, io: std.Io) App { + var text_field = vxfw.TextField.init(allocator); + text_field.style = .{ .fg = .{ .index = 15 } }; + 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, + }; + } + + 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); + } + 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 })) { + ctx.quit = true; + return; + } + if (key.matches('d', .{ .ctrl = true })) { + ctx.quit = true; + return; + } + + // Help toggle + if (self.show_help) { + // Any key dismisses help + self.show_help = false; + ctx.redraw = true; + return; + } + + if (key.matches('?', .{})) { + self.show_help = true; + ctx.redraw = true; + return; + } + + if (key.matches(vaxis.Key.enter, .{})) { + try self.submitExpression(); + ctx.redraw = true; + return; + } + + // Ctrl-L: clear history + if (key.matches('l', .{ .ctrl = true })) { + for (self.history.items) |entry| { + self.allocator.free(entry.expr); + self.allocator.free(entry.result); + } + 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 arrow: browse history (older) + if (key.matches(vaxis.Key.up, .{})) { + if (self.history.items.len == 0) return; + if (self.history_browse_idx) |idx| { + // Already browsing, go further back + if (idx + 1 < self.history.items.len) { + self.history_browse_idx = idx + 1; + self.setInputFromHistory(idx + 1); + } + } else { + // Start browsing: save current input, show most recent + try self.saveCurrentInput(); + self.history_browse_idx = 0; + self.setInputFromHistory(0); + } + ctx.redraw = true; + return; + } + + // Down arrow: browse history (newer) + if (key.matches(vaxis.Key.down, .{})) { + if (self.history_browse_idx) |idx| { + if (idx == 0) { + // Back to the saved input + self.history_browse_idx = null; + self.restoreSavedInput(); + } else { + self.history_browse_idx = idx - 1; + self.setInputFromHistory(idx - 1); + } + ctx.redraw = true; + } + return; + } + + // Pass all other keys to the text field for editing + try self.input.handleEvent(ctx, .{ .key_press = key }); + // Any edit resets history browsing + self.history_browse_idx = null; + ctx.redraw = true; + } + + 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; + } + + // Evaluate directly through our persistent environment + const is_error, const display_text = if (engine.evalString(&self.env, self.allocator, expr_text)) |value| blk: { + var fmt_buf: [4096]u8 = undefined; + const formatted = engine.formatter.formatFloat(&fmt_buf, value); + break :blk .{ false, try self.allocator.dupe(u8, formatted.display) }; + } else |err| blk: { + break :blk .{ true, @as([]const u8, 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", + }) }; + }; + + const result_copy = if (is_error) + try self.allocator.dupe(u8, display_text) + else + display_text; + + try self.history.append(self.allocator, .{ + .expr = expr_text, + .result = result_copy, + .is_error = is_error, + }); + + self.input.clearRetainingCapacity(); + self.history_browse_idx = null; + if (self.saved_input) |s| { + self.allocator.free(s); + self.saved_input = null; + } + } + + 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 { + // browse_idx 0 = most recent (last item), 1 = second most recent, etc. + 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; + } + } + + 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) { + self.drawHelp(&surface, width, height); + return surface; + } + + // -- Title bar (row 0) -- Molokai cyan on dark + const title_style: vaxis.Style = .{ + .fg = .{ .rgb = .{ 0x66, 0xD9, 0xEF } }, // molokai cyan + .bg = .{ .rgb = .{ 0x1B, 0x1D, 0x1E } }, // molokai bg + .bold = true, + }; + const title_mode_style: vaxis.Style = .{ + .fg = .{ .rgb = .{ 0xA6, 0xE2, 0x2E } }, // molokai green + .bg = .{ .rgb = .{ 0x1B, 0x1D, 0x1E } }, + }; + self.fillRow(&surface, 0, ' ', title_style); + self.writeStr(&surface, 0, 1, "Tally", title_style); + const mode_str = "[Standard]"; + const mode_col = width -| @as(u16, @intCast(mode_str.len + 1)); + self.writeStr(&surface, 0, mode_col, mode_str, title_mode_style); + + // -- History (rows 2 to height-4) -- + const hist_start: u16 = 2; + const hist_end: u16 = height -| 4; + + if (self.history.items.len > 0) { + const avail_rows = hist_end -| hist_start; + const max_entries = avail_rows / 2; + const start_idx = if (self.history.items.len > max_entries) + self.history.items.len - max_entries + else + 0; + + var row: u16 = hist_start; + for (self.history.items[start_idx..]) |entry| { + if (row + 1 >= hist_end) break; + // Expression in muted text + self.writeStr(&surface, row, 2, entry.expr, .{ + .fg = .{ .rgb = .{ 0x75, 0x71, 0x5E } }, // molokai muted + }); + row += 1; + // Result: green for success, pink for error + const res_style: vaxis.Style = if (entry.is_error) + .{ .fg = .{ .rgb = .{ 0xF9, 0x26, 0x72 } } } // molokai pink + else + .{ .fg = .{ .rgb = .{ 0xA6, 0xE2, 0x2E } }, .bold = true }; // molokai green + self.writeStr(&surface, row, 4, "= ", res_style); + self.writeStr(&surface, row, 6, entry.result, res_style); + row += 1; + } + } + + // -- Separator (row height-3) -- + const sep_row = height -| 3; + self.fillRow(&surface, sep_row, '-', .{ .fg = .{ .rgb = .{ 0x46, 0x48, 0x3E } } }); // molokai comment-ish + + // -- Input line (row height-2) -- + const input_row = height -| 2; + self.writeStr(&surface, input_row, 1, "> ", .{ + .fg = .{ .rgb = .{ 0xF9, 0x26, 0x72 } }, // molokai pink prompt + .bold = true, + }); + + const first = self.input.buf.firstHalf(); + const second = self.input.buf.secondHalf(); + const input_style: vaxis.Style = .{ .fg = .{ .rgb = .{ 0xF8, 0xF8, 0xF2 } } }; // molokai fg + var col: u16 = 3; + for (first) |byte| { + self.writeChar(&surface, input_row, col, byte, input_style); + col += 1; + } + for (second) |byte| { + self.writeChar(&surface, input_row, col, byte, input_style); + col += 1; + } + + // Cursor + const cursor_col: u16 = 3 + @as(u16, @intCast(self.input.buf.cursor)); + surface.cursor = .{ .col = cursor_col, .row = input_row, .shape = .block }; + + // -- Status bar (row height-1) -- + const status_row = height -| 1; + const status_style: vaxis.Style = .{ + .fg = .{ .rgb = .{ 0x75, 0x71, 0x5E } }, // molokai muted + .bg = .{ .rgb = .{ 0x1B, 0x1D, 0x1E } }, // molokai bg + }; + self.fillRow(&surface, status_row, ' ', status_style); + self.writeStr(&surface, status_row, 1, "?:help | Enter:eval | Ctrl-L:clear | Ctrl-C:quit", status_style); + + return surface; + } + + fn drawHelp(self: *App, surface: *vxfw.Surface, width: u16, height: u16) void { + const header_style: vaxis.Style = .{ .fg = .{ .rgb = .{ 0x66, 0xD9, 0xEF } }, .bold = true }; // cyan + const key_style: vaxis.Style = .{ .fg = .{ .rgb = .{ 0xE6, 0xDB, 0x74 } } }; // yellow + const text_style: vaxis.Style = .{ .fg = .{ .rgb = .{ 0xF8, 0xF8, 0xF2 } } }; // fg + const section_style: vaxis.Style = .{ .fg = .{ .rgb = .{ 0xAE, 0x81, 0xFF } }, .bold = true }; // purple + + // Background + for (0..height) |r| { + self.fillRow(surface, @intCast(r), ' ', .{}); + } + + var row: u16 = 1; + self.writeStr(surface, row, 2, "Tally - Help", header_style); + row += 2; + + // Keybindings + self.writeStr(surface, row, 2, "Keybindings", section_style); + row += 1; + self.writeStr(surface, row, 4, "Enter", key_style); + self.writeStr(surface, row, 14, "Evaluate expression", text_style); + row += 1; + self.writeStr(surface, row, 4, "Ctrl-C", key_style); + self.writeStr(surface, row, 14, "Quit", text_style); + row += 1; + self.writeStr(surface, row, 4, "Ctrl-D", key_style); + self.writeStr(surface, row, 14, "Quit", text_style); + row += 1; + self.writeStr(surface, row, 4, "Ctrl-L", key_style); + self.writeStr(surface, row, 14, "Clear history", text_style); + row += 1; + self.writeStr(surface, row, 4, "?", key_style); + self.writeStr(surface, row, 14, "Toggle this help", text_style); + row += 2; + + // Variables + self.writeStr(surface, row, 2, "Variables", section_style); + row += 1; + self.writeStr(surface, row, 4, "Ans", key_style); + self.writeStr(surface, row, 14, "Last result", text_style); + row += 1; + self.writeStr(surface, row, 4, "X = expr", key_style); + self.writeStr(surface, row, 14, "Assign to variable (A-Z, any name)", text_style); + row += 2; + + // Constants + self.writeStr(surface, row, 2, "Constants", section_style); + row += 1; + self.writeStr(surface, row, 4, "pi", key_style); + self.writeStr(surface, row, 14, "3.14159...", text_style); + self.writeStr(surface, row, 30, "e", key_style); + self.writeStr(surface, row, 36, "2.71828...", text_style); + self.writeStr(surface, row, 52, "tau", key_style); + self.writeStr(surface, row, 60, "6.28318...", text_style); + row += 2; + + // Functions + self.writeStr(surface, row, 2, "Functions", section_style); + row += 1; + const func_lines = [_][]const u8{ + "sin cos tan asin acos atan atan2", + "log (base-10) ln (natural) log2 exp", + "sqrt cbrt abs ceil floor round", + "factorial max(a,b) min(a,b)", + }; + for (func_lines) |line| { + if (row >= height -| 2) break; + self.writeStr(surface, row, 4, line, text_style); + row += 1; + } + row += 1; + + // Operators + if (row < height -| 4) { + self.writeStr(surface, row, 2, "Operators", section_style); + row += 1; + self.writeStr(surface, row, 4, "+ - * / % ^ (power)", text_style); + row += 1; + self.writeStr(surface, row, 4, "Implicit mul: 2pi 3(4+5) (2)(3)", text_style); + row += 2; + } + + // Syntax + if (row < height -| 3) { + self.writeStr(surface, row, 2, "Number formats", section_style); + row += 1; + self.writeStr(surface, row, 4, "Decimal: 1_000_000 Hex: 0xFF Bin: 0b1010 Oct: 0o77", text_style); + row += 1; + } + + // Footer + const footer_row = height -| 1; + const footer_style: vaxis.Style = .{ .fg = .{ .rgb = .{ 0x75, 0x71, 0x5E } } }; // molokai muted + self.fillRow(surface, footer_row, ' ', footer_style); + self.writeStr(surface, footer_row, 1, "Press any key to return", footer_style); + + _ = width; + } + + // -- Drawing helpers -- + + /// Static single-byte grapheme strings. Each entry is a pointer + /// to a static [1]u8 array stored in .rodata. + const ascii_graphemes: [128]*const [1]u8 = blk: { + @setEvalBranchQuota(200); + var table: [128]*const [1]u8 = undefined; + for (0..128) |i| { + table[i] = &[1]u8{@as(u8, @intCast(i))}; + } + break :blk table; + }; + + fn charGrapheme(byte: u8) []const u8 { + if (byte < 128) return ascii_graphemes[byte]; + return " "; + } + + fn fillRow(self: *App, surface: *vxfw.Surface, row: u16, char: u8, style: vaxis.Style) void { + _ = self; + const width = surface.size.width; + const grapheme: []const u8 = charGrapheme(char); + for (0..width) |col| { + const idx = @as(usize, row) * @as(usize, width) + col; + if (idx < surface.buffer.len) { + surface.buffer[idx] = .{ + .char = .{ .grapheme = grapheme, .width = 1 }, + .style = style, + }; + } + } + } + + fn writeStr(self: *App, surface: *vxfw.Surface, row: u16, col: u16, text: []const u8, style: vaxis.Style) void { + _ = self; + const width = surface.size.width; + for (0..text.len) |i| { + const c = col + @as(u16, @intCast(i)); + if (c >= width) break; + const idx = @as(usize, row) * @as(usize, width) + @as(usize, c); + if (idx < surface.buffer.len) { + surface.buffer[idx] = .{ + .char = .{ .grapheme = charGrapheme(text[i]), .width = 1 }, + .style = style, + }; + } + } + } + + fn writeChar(self: *App, surface: *vxfw.Surface, row: u16, col: u16, byte: u8, style: vaxis.Style) void { + _ = self; + const width = surface.size.width; + if (col >= width) return; + const idx = @as(usize, row) * @as(usize, width) + @as(usize, col); + if (idx < surface.buffer.len) { + surface.buffer[idx] = .{ + .char = .{ .grapheme = charGrapheme(byte), .width = 1 }, + .style = style, + }; + } + } +}; + +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(), .{}); +}