278 lines
11 KiB
Zig
278 lines
11 KiB
Zig
//! 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));
|
|
}
|