//! Help overlay for the TUI. //! //! The content is a static line list rather than a sequence of hand-gated draw //! calls. That change came from a real failure: each section used to guard itself //! with `if (row < height - 6)`, so as sections were added the later ones were //! silently dropped on a normal-sized terminal, with no indication anything was //! missing. A line list can be windowed and scrolled, and its length is knowable //! without drawing. const std = @import("std"); const vaxis = @import("vaxis"); const vxfw = vaxis.vxfw; const engine = @import("engine"); const draw = @import("draw.zig"); const C = draw.C; /// One rendered line of help. const Line = union(enum) { /// Section heading. header: []const u8, /// A binding: name and what it does. key: struct { name: []const u8, desc: []const u8 }, /// Free text, indented under a heading. text: []const u8, blank, }; const lines = bindings ++ functions ++ financial_functions ++ operators_and_units; /// The general function list, wrapped at comptime from the evaluator's own set of /// built-ins. The hand-written version named seventeen of the twenty-one and had been /// wrong since `log2`, `log10`, `cbrt` and `atan2` were added. const functions = blk: { var section: [function_lines.len + 2]Line = undefined; section[0] = .{ .header = "Functions" }; for (section[1 .. section.len - 1], function_lines) |*line, text| { line.* = .{ .text = text }; } section[section.len - 1] = .blank; break :blk section; }; /// Width a wrapped function line may reach. Text is drawn at `key_col`, and the 80th /// column is left clear so the help overlay never reaches the right edge. const wrap_width = 80 - key_col - 1; /// Function names packed into as few lines as `wrap_width` allows. const function_lines = blk: { // Enough rows for one name each, which no wrap can exceed. var packed_lines: [engine.evaluator.math_function_names.len][]const u8 = undefined; var count: usize = 0; var current: []const u8 = ""; for (engine.evaluator.math_function_names) |name| { if (current.len == 0) { current = name; } else if (current.len + 1 + name.len <= wrap_width) { current = current ++ " " ++ name; } else { packed_lines[count] = current; count += 1; current = name; } } if (current.len > 0) { packed_lines[count] = current; count += 1; } break :blk packed_lines[0..count].*; }; /// The financial section, rendered from the engine's function table: the list a /// frontend shows is not a frontend's to keep in step with the evaluator. const financial_functions = blk: { var section: [engine.financial.help_lines.len + 2]Line = undefined; section[0] = .{ .header = "Financial Functions" }; for (section[1 .. section.len - 1], engine.financial.help_lines) |*line, text| { line.* = .{ .text = text }; } section[section.len - 1] = .blank; break :blk section; }; const bindings = [_]Line{ .{ .header = "Keybindings" }, .{ .key = .{ .name = "Enter", .desc = "Evaluate expression" } }, .{ .key = .{ .name = "Tab", .desc = "Next mode (Standard/Programmer/Financial/Convert)" } }, .{ .key = .{ .name = "Shift-Tab", .desc = "Previous mode" } }, .{ .key = .{ .name = "Ctrl-C/D", .desc = "Quit" } }, .{ .key = .{ .name = "Ctrl-L", .desc = "Clear history" } }, .{ .key = .{ .name = "Up/Down", .desc = "Browse history" } }, .{ .key = .{ .name = "?", .desc = "Toggle this help" } }, .blank, .{ .header = "Mouse" }, .{ .key = .{ .name = "Tabs", .desc = "Click a tab to switch mode" } }, .{ .key = .{ .name = "Bit grid", .desc = "Click a bit to flip it" } }, .{ .key = .{ .name = "HEX/OCT", .desc = "Click a digit to put the cursor on it" } }, .{ .key = .{ .name = "BIN", .desc = "Click a digit to flip that bit" } }, .{ .key = .{ .name = "DEC rows", .desc = "Click to focus the field" } }, .{ .key = .{ .name = "Bits/Signed", .desc = "Click the label to toggle it" } }, .{ .key = .{ .name = "Convert", .desc = "Click a category or unit to select it" } }, .{ .key = .{ .name = "Financial", .desc = "Click a calculation or a field" } }, .{ .key = .{ .name = "Wheel", .desc = "Scroll the schedule, or this help" } }, .{ .key = .{ .name = "Input line", .desc = "Click to return focus to the prompt" } }, .blank, .{ .header = "Programmer Mode" }, .{ .key = .{ .name = "`", .desc = "Toggle input / value zone" } }, .{ .key = .{ .name = "Ctrl-W", .desc = "Cycle bit width (8/16/32/64/128)" } }, .{ .key = .{ .name = "Ctrl-E", .desc = "Toggle endianness (BE / LE)" } }, .{ .key = .{ .name = "Ctrl-F", .desc = "IEEE 754 float view (Ctrl-W: f32/f64)" } }, .{ .key = .{ .name = "Space", .desc = "Toggle bit (in grid)" } }, .{ .key = .{ .name = "Arrows", .desc = "Navigate fields and bits (value zone)" } }, .blank, .{ .header = "Financial Mode" }, .{ .key = .{ .name = "`", .desc = "Toggle input / form zone" } }, .{ .key = .{ .name = "Up/Down", .desc = "Move between fields" } }, .{ .key = .{ .name = "Left/Right", .desc = "Switch calculation" } }, .{ .key = .{ .name = "type", .desc = "Numbers or expressions: 12 * 30" } }, .{ .key = .{ .name = "Enter", .desc = "Evaluate the focused field in place" } }, .{ .key = .{ .name = "Space", .desc = "Toggle END / BGN on the payments row" } }, .{ .key = .{ .name = "Ctrl-U", .desc = "Clear the field (marks it to solve for)" } }, .{ .key = .{ .name = "PgUp/PgDn", .desc = "Scroll the amortization schedule" } }, .blank, .{ .header = "Convert Mode" }, .{ .key = .{ .name = "`", .desc = "Toggle input / selection zone" } }, .{ .key = .{ .name = "Arrows", .desc = "Left/Right: column, Up/Down: select" } }, .{ .key = .{ .name = "Ctrl-S", .desc = "Swap from and to units" } }, .{ .key = .{ .name = "Enter", .desc = "Set the value to convert" } }, .blank, }; const operators_and_units = [_]Line{ .{ .header = "Operators" }, .{ .text = "Standard: + - * / % ^ (power)" }, .{ .text = "Programmer: & | ~ << >> >>> ^/** (pow) and or xor not rol ror" }, .blank, .{ .header = "Units and Conversion" }, .{ .text = "100 km to mi, 32F in C, 2*3 kg to lb - works in any expression" }, }; /// Total help lines, so the caller can clamp its scroll offset without drawing. pub fn lineCount() usize { return lines.len; } /// Lines visible at a given terminal height: everything between the title and the /// footer. pub fn visibleLines(height: u16) usize { const first = content_start; const last = height -| 2; return if (last > first) last - first else 0; } const content_start: u16 = 3; const key_col: u16 = 4; const desc_col: u16 = 18; /// Draw the overlay, starting at line `scroll`. Out-of-range offsets are clamped /// here rather than trusted, so a resize cannot leave the view blank. /// /// The width comes off the surface: every row is drawn from a fixed column and /// `draw.fillRow` covers whatever the terminal is, so there was nothing for a width /// parameter to decide. pub fn drawHelp(surface: *vxfw.Surface, height: u16, scroll: usize) void { for (0..height) |r| { draw.fillRow(surface, @intCast(r), ' ', .{}); } draw.writeStr(surface, 1, 2, "Tally - Help", .{ .fg = C.cyan, .bold = true }); const visible = visibleLines(height); if (visible == 0) return; const max_scroll = if (lines.len > visible) lines.len - visible else 0; const first = @min(scroll, max_scroll); const last = @min(lines.len, first + visible); var row: u16 = content_start; for (lines[first..last]) |line| { switch (line) { .header => |text| draw.writeStr(surface, row, 2, text, .{ .fg = C.purple, .bold = true }), .key => |kv| { draw.writeStr(surface, row, key_col, kv.name, .{ .fg = C.yellow }); draw.writeStr(surface, row, desc_col, kv.desc, .{ .fg = C.fg }); }, .text => |text| draw.writeStr(surface, row, key_col, text, .{ .fg = C.fg }), .blank => {}, } row += 1; } // Footer: the position indicator only appears when something is off screen, // so a full view is not cluttered with it. draw.fillRow(surface, height -| 1, ' ', .{ .fg = C.muted }); if (lines.len > visible) { var buf: [96]u8 = undefined; const status = std.fmt.bufPrint(&buf, "lines {d}-{d} of {d} | Up/Down or wheel: scroll | any other key: return", .{ first + 1, last, lines.len, }) catch "Up/Down: scroll | any other key: return"; draw.hintRow(surface, height -| 1, 1, status, .{ .fg = C.muted }); } else { draw.hintRow(surface, height -| 1, 1, "Press any key to return", .{ .fg = C.muted }); } } // -- Tests -- const testing = std.testing; test "the function list shows every built-in the engine answers to" { // The wrap is comptime string building, so a dropped name would be invisible: the // list would simply be shorter. Checked against the engine's own set. for (engine.evaluator.math_function_names) |name| { var found = false; for (function_lines) |line| { if (std.mem.indexOf(u8, line, name) != null) found = true; } if (!found) { std.debug.print("function list omits '{s}'\n", .{name}); return error.FunctionMissing; } } // And the names are the engine's, not a copy: the four that the hand-written list // used to omit are the reason this is derived. for ([_][]const u8{ "log2", "log10", "cbrt", "atan2" }) |name| { var found = false; for (function_lines) |line| { if (std.mem.indexOf(u8, line, name) != null) found = true; } try testing.expect(found); } // `rand` returns 0 and is not advertised. for (function_lines) |line| { try testing.expect(std.mem.indexOf(u8, line, "rand") == null); } } test "every help line has content" { for (lines) |line| { switch (line) { .header => |text| try testing.expect(text.len > 0), .key => |kv| { try testing.expect(kv.name.len > 0); try testing.expect(kv.desc.len > 0); // Descriptions start at a fixed column, so a long name would // overwrite its own description. try testing.expect(kv.name.len < desc_col - key_col); }, .text => |text| try testing.expect(text.len > 0), .blank => {}, } } } test "help lines fit an 80-column terminal" { for (lines) |line| { const used: usize = switch (line) { .header => |text| 2 + text.len, .key => |kv| desc_col + kv.desc.len, .text => |text| key_col + text.len, .blank => 0, }; try testing.expect(used <= 80); } } test "visibleLines shrinks with the terminal and never underflows" { try testing.expect(visibleLines(40) > visibleLines(24)); try testing.expectEqual(@as(usize, 0), visibleLines(4)); try testing.expectEqual(@as(usize, 0), visibleLines(0)); } test "the help content is longer than a standard terminal, which is why it scrolls" { try testing.expect(lineCount() > visibleLines(24)); }