drawing fixes
This commit is contained in:
parent
2f769edee3
commit
64c80e5797
8 changed files with 398 additions and 70 deletions
|
|
@ -131,6 +131,7 @@ A calculator application with three frontends (CLI, TUI, Android) sharing a comm
|
|||
- **FR-7.3**: Programmer mode: bit grid (navigable with arrow keys, toggled with Space), simultaneous base displays, expression input. NOTE: Enter does not toggle a bit. It is consumed by the input line, and the value-zone status hint that advertised `Enter:set` is a known defect. Space is the only toggle.
|
||||
- **FR-7.3.1**: When the terminal is too short for the bit grid plus all six base rows, the view says so and how many rows it needs, rather than drawing rows over the separator and the input line.
|
||||
- **FR-7.3.2**: When the terminal is too narrow for the whole binary row, it continues onto further rows rather than being cut off. 64 bits grouped in fours is 79 characters and starts at column 11, so the row needs a 90-column terminal; on the default 80 it was silently clipped, and what went missing was the right-hand end, which is the low bits - the value read as wrong rather than as incomplete. The split halves at a time (64 bits becomes two rows of 32), matching the grouping the bit grid above already uses, and a wider terminal gets the single row back. The label sits on the first row; the rest are the same field, with the same click targets and the same cursor.
|
||||
- **FR-7.3.3**: The IEEE 754 float overlay reports a terminal too short for it, the way FR-7.3.1's integer view does, rather than drawing a panel the furniture then covers. It used to draw its eight rows regardless: the separator, prompt and status were drawn afterwards and overwrote them, except for the two columns the prompt occupies, which left the tail of the "Exp:" row sitting on the input line as `> xp: 0 biased ...` at 100x16. Content that does not fit is absent and accounted for, never relocated.
|
||||
- **FR-7.4**: Programmer mode struct sub-view: field list editor, live-updating memory map visualization.
|
||||
- **FR-7.5**: Financial mode: form-style input for parameters, result display with formula breakdown, and a scrollable amortization schedule. Four calculations behind one selector: CAGR, compound interest, TVM, and amortization. TVM and compound interest solve for whichever field is left blank; an unanswerable form reports what it is waiting for rather than doing nothing. Results are live, so financial mode records nothing in history.
|
||||
- **FR-7.5.1**: Form fields group digits as they are typed: entering `200` then `0` displays `2,000`. The stored text stays ungrouped so it still parses, and a typed comma is accepted and discarded rather than corrupting the value.
|
||||
|
|
@ -138,7 +139,11 @@ A calculator application with three frontends (CLI, TUI, Android) sharing a comm
|
|||
- **FR-7.5.3**: Rate fields display a trailing `%` after the value, and accept a typed `%` as a no-op, so `6%` and `6` mean the same thing. The label alone is not sufficient affordance once the eye is on the value column.
|
||||
- **FR-7.5.4**: Where a default is near-universal, the form prefills it as a visible value rather than showing an empty field with a placeholder. Compounds/year starts at `1`. This keeps "blank means solve for this" true for every field the user sees, instead of blank meaning a default in one place and a solve slot in another. Clearing a prefilled non-solvable field asks for a value rather than for an answer.
|
||||
- **FR-7.6**: Convert mode: select category, input value, select from/to units, live result.
|
||||
- The conversion factor line ("1 in = 2.54 cm") is computed by converting one of the source unit, the same exact path as the result above it. It used to be an f64 division of the two base factors, which disagreed with the line it sat under: centimetres to inches read 0.39370078740157477 as a factor and 0.393700787401574803150 as a result.
|
||||
- Every row is bounded by the region the view owns. The result, factor and column headers used to be drawn unconditionally, so on a short terminal they landed on the furniture and left fragments beside the prompt.
|
||||
- A conversion that cannot be computed says so. The previous fallback drew the unconverted input in the result slot, which reads as a conversion that did nothing rather than one that failed.
|
||||
- **FR-7.6.1**: The help overlay scrolls (Up/Down, PgUp/PgDn, or the mouse wheel) and shows its position when content is off screen. Any other key returns. Sections are a single line list rather than individually height-gated draw calls, which is what previously caused later sections to be dropped silently on a normal-sized terminal.
|
||||
- **FR-7.6.2**: The help overlay's function lists come from the engine, not from the overlay. The financial half renders `financial.expression_functions` (FR-5.7); the general half renders `evaluator.math_function_names`, which is every built-in that is not a financial function, wrapped to the width at comptime. The hand-written list named seventeen of the twenty-one: `log2`, `log10`, `cbrt` and `atan2` had been dispatchable and undocumented for some time. `rand` is deliberately excluded while it remains a placeholder that returns 0, and an engine test asserts that every built-in is in exactly one of the two lists or is that one exclusion, so a function added without a decision about how it is presented fails the build rather than going unmentioned.
|
||||
- **FR-7.7**: Every action must be reachable from the keyboard alone; the TUI is fully usable without a mouse.
|
||||
- **FR-7.8**: REMOVED. Quick-switch keys (`d`=dec, `h`=hex, `o`=oct, `b`=bin) to highlight the primary base cannot coexist with editable value fields: in the value zone those letters are hex and binary digits. The requirement predates editable fields, and the fields are the more useful feature. Base selection stays on the arrow keys and the mouse.
|
||||
- **FR-7.9**: Support terminal resize gracefully.
|
||||
|
|
|
|||
|
|
@ -914,6 +914,65 @@ plans a `--raw` flag, but today it is exercised only by tests.
|
|||
100% line coverage, engine 99.44%. CLI output byte-identical across all five rows,
|
||||
both byte orders, ASCII packing and the multi-base standard-mode view.
|
||||
|
||||
### Task 5.28: The views stop drawing what they cannot fit
|
||||
|
||||
Entry 16 covered the five view files. The common fault was the same one Task 5.27
|
||||
found in the binary row, in its other form: content drawn without asking whether there
|
||||
was room for it.
|
||||
|
||||
**The float overlay put the exponent row on the input line.** Its panel is eight rows
|
||||
under a two-row grid, and it drew them whatever the height. The furniture is drawn
|
||||
last, so the separator and status covered their rows entirely, but the prompt occupies
|
||||
only two columns: at 100x16 the input line read
|
||||
|
||||
```
|
||||
> xp: 0 biased -> -1022 unbiased (bias 1023)
|
||||
```
|
||||
|
||||
The overlay now reports what it needs, as the integer view already did (FR-7.3.1), and
|
||||
this one is FR-7.3.3. `drawInput` also clears its row before drawing the prompt, so no
|
||||
view can leave a fragment beside it again. The furniture check could not see this: it
|
||||
tests the prompt marker, and the marker was exactly where it belonged.
|
||||
|
||||
**Convert mode did the same, more quietly.** The result, the factor and the column
|
||||
headers were drawn unconditionally; at 100x12 "From"/"To" ended up next to the prompt
|
||||
as `> rom ... To`, and at 60x10 the result line did. Every row is now bounded by the
|
||||
content region, and the out-of-memory path draws a notice and the furniture instead of
|
||||
returning with no prompt.
|
||||
|
||||
**The factor line disagreed with the result above it.** It divided the two base
|
||||
factors in f64 while the result used the exact path, so centimetres to inches read
|
||||
`0.39370078740157477` as a factor and `0.393700787401574803150` as a result, three
|
||||
lines apart. It now converts one of the source unit through the same code as
|
||||
everything else. The old fallback on a failed conversion was worse than the error it
|
||||
hid: it drew the unconverted input as the answer.
|
||||
|
||||
**The help overlay's function list was wrong and is now derived.** It named seventeen
|
||||
functions; the engine has answered to twenty-one since `log2`, `log10`, `cbrt` and
|
||||
`atan2` were added, so the only way to find them was to read `evaluator.zig`. The
|
||||
overlay renders `evaluator.math_function_names`, which is every built-in that is not
|
||||
one of `financial.expression_functions`, wrapped to the width at comptime. `rand` is
|
||||
excluded while it returns 0 rather than a random number, and an evaluator test asserts
|
||||
every built-in is in exactly one of the two lists or is that single exclusion, so the
|
||||
next function added cannot be silently undocumented (FR-7.6.2). This is the same
|
||||
treatment Task 5.25 gave the financial list, finishing the job.
|
||||
|
||||
**Financial mode asked for the same answer three times.** `state.outcome()` was called
|
||||
by the draw entry point, by `drawOutcome` and by `drawSchedule`, and for a 360-period
|
||||
loan each call runs `amortizationTotals`, which walks the whole schedule. The outcome
|
||||
is now computed once and passed down, which also removed `drawOutcome`'s unused `app`
|
||||
and `width` parameters and `drawSchedule`'s unused `width`. `drawHelp` lost a `width`
|
||||
it ignored, and `errorText` stopped being public: nothing outside the file called it.
|
||||
|
||||
- Verify: 942 tests pass, 4 of them new, fmt and zlint clean. float_view.zig and
|
||||
programmer.zig at 100% line coverage, financial.zig 99.6%, help.zig 97.3%,
|
||||
convert.zig 97.2% (the uncovered rows are the out-of-memory notice and two failure
|
||||
diagnostics). TUI total 98.41%, engine 99.29%. Checked by hand: the help overlay's
|
||||
Functions section now reads `abs floor ceil round sqrt factorial max min sin cos tan
|
||||
asin acos atan cbrt` / `exp ln log2 log10 log atan2`, and the float overlay at
|
||||
100x16 shows `too short for the 64-bit float view: needs 19 rows, has 16` with a
|
||||
clean prompt row.
|
||||
|
||||
### Task 5.27: The check that could fail, and the two faults it found
|
||||
|
||||
`test_render.zig` opens by explaining that an earlier `wellFormed(rows, width)` was a
|
||||
|
|
|
|||
|
|
@ -382,6 +382,48 @@ const Arity = struct { min: u8, max: u8 };
|
|||
/// buffer is sized for.
|
||||
const max_args = 4;
|
||||
|
||||
/// Every built-in that is not a financial function, in declaration order: the general
|
||||
/// maths surface, for the help screens.
|
||||
///
|
||||
/// Derived rather than listed, because a hand-written copy went stale. The TUI's help
|
||||
/// overlay named seventeen functions and the engine had answered to twenty-one for
|
||||
/// some time: `log2`, `log10`, `cbrt` and `atan2` were missing, so the only way to
|
||||
/// discover them was to read this file.
|
||||
///
|
||||
/// The financial half is `financial.expression_functions`, which carries signatures
|
||||
/// and summaries because those functions need them. These take a number and return a
|
||||
/// number, so a name is the whole story.
|
||||
///
|
||||
/// `rand` is deliberately absent: it is a placeholder that returns 0, and a help
|
||||
/// screen should not advertise a function that does not do what its name says.
|
||||
pub const math_function_names = blk: {
|
||||
// 38 built-ins crossed with 17 financial names, each comparison a loop over bytes.
|
||||
@setEvalBranchQuota(4000);
|
||||
var count: usize = 0;
|
||||
for (@typeInfo(Builtin).@"enum".fields) |field| {
|
||||
if (isMathBuiltin(field.name)) count += 1;
|
||||
}
|
||||
|
||||
var names: [count][]const u8 = undefined;
|
||||
var i: usize = 0;
|
||||
for (@typeInfo(Builtin).@"enum".fields) |field| {
|
||||
if (isMathBuiltin(field.name)) {
|
||||
names[i] = field.name;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
break :blk names;
|
||||
};
|
||||
|
||||
/// Whether a built-in belongs in the general list rather than the financial one.
|
||||
fn isMathBuiltin(comptime name: []const u8) bool {
|
||||
if (std.mem.eql(u8, name, "rand")) return false;
|
||||
for (financial.expression_functions) |doc| {
|
||||
if (std.mem.eql(u8, name, doc.name)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
fn arityOf(builtin: Builtin) Arity {
|
||||
return switch (builtin) {
|
||||
.rand => .{ .min = 0, .max = 0 },
|
||||
|
|
@ -1637,8 +1679,36 @@ test "every built-in is reachable by name at its own arity" {
|
|||
}
|
||||
}
|
||||
|
||||
test "the help table's financial functions dispatch at the arity they advertise" {
|
||||
// `financial.expression_functions` is what both frontends' help screens render.
|
||||
test "every name in the help lists is a built-in, and every built-in is in one of them" {
|
||||
// The two lists the help screens render, plus the one deliberate exclusion, have
|
||||
// to add up to the dispatcher. A built-in added without a decision about which
|
||||
// list it belongs to fails here rather than being invisible to the user.
|
||||
inline for (@typeInfo(Builtin).@"enum".fields) |field| {
|
||||
const in_math = for (math_function_names) |name| {
|
||||
if (std.mem.eql(u8, name, field.name)) break true;
|
||||
} else false;
|
||||
const in_financial = for (financial.expression_functions) |doc| {
|
||||
if (std.mem.eql(u8, doc.name, field.name)) break true;
|
||||
} else false;
|
||||
const excluded = comptime std.mem.eql(u8, field.name, "rand");
|
||||
|
||||
if (@intFromBool(in_math) + @intFromBool(in_financial) + @intFromBool(excluded) != 1) {
|
||||
std.debug.print("'{s}': maths={} financial={} excluded={}\n", .{
|
||||
field.name, in_math, in_financial, excluded,
|
||||
});
|
||||
return error.BuiltinUnclassified;
|
||||
}
|
||||
}
|
||||
|
||||
for (math_function_names) |name| {
|
||||
if (std.meta.stringToEnum(Builtin, name) == null) {
|
||||
std.debug.print("help lists '{s}', which no built-in answers\n", .{name});
|
||||
return error.UnknownFunction;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "the help table's financial functions dispatch at the arity they advertise" { // `financial.expression_functions` is what both frontends' help screens render.
|
||||
// The names and signatures there are only correct if the evaluator agrees, and
|
||||
// nothing else checks it: `Builtin` is private, so this is the one place the two
|
||||
// can be compared.
|
||||
|
|
|
|||
66
src/tui.zig
66
src/tui.zig
|
|
@ -1252,7 +1252,7 @@ pub const App = struct {
|
|||
self.clearRegions();
|
||||
|
||||
if (self.show_help) {
|
||||
help_view.drawHelp(&surface, width, height, self.help_scroll);
|
||||
help_view.drawHelp(&surface, height, self.help_scroll);
|
||||
return surface;
|
||||
}
|
||||
|
||||
|
|
@ -1303,6 +1303,13 @@ pub const App = struct {
|
|||
}
|
||||
|
||||
pub fn drawInput(self: *App, surface: *vxfw.Surface, row: u16) void {
|
||||
// Clear the row first. A view whose content runs past its region is drawn
|
||||
// before this, and only the two prompt columns would otherwise be overwritten:
|
||||
// the float overlay's "Exp:" row used to appear beside the prompt as
|
||||
// "> xp: 0 biased ...". Content that does not fit should be absent, not
|
||||
// relocated onto the input line.
|
||||
draw.fillRow(surface, row, ' ', .{ .bg = C.bg });
|
||||
|
||||
const input_focused = !self.value_zone_active;
|
||||
const prompt_style: vaxis.Style = if (input_focused)
|
||||
.{ .fg = C.pink, .bold = true }
|
||||
|
|
@ -2099,6 +2106,63 @@ test "render: programmer mode is well formed at every width and setting" {
|
|||
}
|
||||
}
|
||||
|
||||
test "render: the float view says so when the terminal is too short, and keeps its prompt clean" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
var app = testApp();
|
||||
defer app.deinit();
|
||||
app.setMode(.programmer);
|
||||
app.float_view_active = true;
|
||||
app.float_format = .f64;
|
||||
app.prog_config.width = .bits64;
|
||||
|
||||
// The panel is eight rows under a two-row grid, which does not fit in 16 rows.
|
||||
// It used to be drawn anyway: the furniture went over the top of it, and the
|
||||
// "Exp:" row survived beside the prompt as "> xp: 0 biased ...".
|
||||
const rows = try renderApp(arena, &app, 100, 16);
|
||||
try test_render.expectSound(rows, 1);
|
||||
try testing.expect(test_render.contains(rows, "too short for the"));
|
||||
try testing.expect(test_render.contains(rows, "64-bit float view"));
|
||||
try testing.expect(!test_render.contains(rows, "Exp:"));
|
||||
// Nothing but the prompt on the prompt row.
|
||||
const prompt_row = rows[rows.len - 2];
|
||||
try testing.expectEqualStrings(" > ", prompt_row[0..3]);
|
||||
for (prompt_row[3..]) |ch| try testing.expectEqual(@as(u8, ' '), ch);
|
||||
|
||||
// With room, the panel is back.
|
||||
const tall = try renderApp(arena, &app, 100, 30);
|
||||
try test_render.expectSound(tall, 1);
|
||||
try testing.expect(test_render.contains(tall, "Exp:"));
|
||||
try testing.expect(!test_render.contains(tall, "too short for the"));
|
||||
}
|
||||
|
||||
test "render: convert mode keeps its content out of the furniture on a short terminal" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
var app = testApp();
|
||||
defer app.deinit();
|
||||
app.setMode(.convert);
|
||||
|
||||
// At 12 rows the unit columns have nowhere to go. The column headers used to be
|
||||
// drawn regardless, leaving "rom" and "To" beside the prompt.
|
||||
for ([_][2]u16{ .{ 100, 12 }, .{ 60, 10 }, .{ 80, 8 } }) |size| {
|
||||
const rows = try renderApp(arena, &app, size[0], size[1]);
|
||||
try test_render.expectSound(rows, 1);
|
||||
const prompt_row = rows[rows.len - 2];
|
||||
try testing.expectEqualStrings(" > ", prompt_row[0..3]);
|
||||
for (prompt_row[3..]) |ch| {
|
||||
if (ch != ' ') {
|
||||
std.debug.print("{d}x{d}: prompt row holds [{s}]\n", .{ size[0], size[1], prompt_row });
|
||||
return error.ContentOnPromptRow;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "render: the float view decodes a known bit pattern" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ pub fn drawConvertMode(app: *tui.App, surface: *vxfw.Surface, width: u16, height
|
|||
const table = units.unitsIn(app.conv_category);
|
||||
const pair = app.convUnits();
|
||||
const zone_active = app.value_zone_active;
|
||||
// Content rows: everything below this belongs to the separator, the prompt and
|
||||
// the status line, which are drawn last and would otherwise be overwritten by a
|
||||
// view that ran past them.
|
||||
const content_end = height -| 3;
|
||||
|
||||
// -- Category chips (wrapping) --
|
||||
draw.writeStr(surface, 2, 2, "Category:", .{ .fg = C.muted });
|
||||
|
|
@ -38,7 +42,7 @@ pub fn drawConvertMode(app: *tui.App, surface: *vxfw.Surface, width: u16, height
|
|||
}
|
||||
// And stop before the furniture rows, so a narrow terminal does not draw
|
||||
// chips over the separator and the prompt.
|
||||
if (row >= height -| 3) break;
|
||||
if (row >= content_end) break;
|
||||
const selected = category == app.conv_category;
|
||||
const focused = selected and zone_active and app.conv_zone == .category;
|
||||
const style: vaxis.Style = if (focused)
|
||||
|
|
@ -58,8 +62,16 @@ pub fn drawConvertMode(app: *tui.App, surface: *vxfw.Surface, width: u16, height
|
|||
row += 2;
|
||||
// Exact conversion, so a terminating case like 12 in -> ft shows 1 rather
|
||||
// than 0.9999999999999998.
|
||||
var converted = units.convertExactUnits(app.allocator, app.conv_value, pair.from, pair.to) catch
|
||||
app.conv_value.clone() catch return;
|
||||
var converted = units.convertExactUnits(app.allocator, app.conv_value, pair.from, pair.to) catch {
|
||||
// Both units come from the same category, so this is out of memory rather
|
||||
// than a mismatch. Saying so beats the previous fallback, which drew the
|
||||
// unconverted input as though it were the answer.
|
||||
if (row < content_end) {
|
||||
draw.writeStr(surface, row, 2, "conversion unavailable (out of memory)", .{ .fg = C.pink });
|
||||
}
|
||||
drawFurniture(app, surface, width, height, zone_active);
|
||||
return;
|
||||
};
|
||||
defer converted.deinit();
|
||||
|
||||
const shown_in = app.conv_value.render(app.allocator, tui.display_format) catch return;
|
||||
|
|
@ -69,27 +81,17 @@ pub fn drawConvertMode(app: *tui.App, surface: *vxfw.Surface, width: u16, height
|
|||
|
||||
var line_buf: [192]u8 = undefined;
|
||||
const input_line = std.fmt.bufPrint(&line_buf, "{s} {s}", .{ shown_in.text, pair.from.name }) catch "?";
|
||||
draw.writeStr(surface, row, 2, input_line, .{ .fg = C.fg });
|
||||
if (row < content_end) draw.writeStr(surface, row, 2, input_line, .{ .fg = C.fg });
|
||||
row += 1;
|
||||
|
||||
var result_buf: [192]u8 = undefined;
|
||||
const result_line = std.fmt.bufPrint(&result_buf, "= {s} {s}", .{ shown_out.text, pair.to.name }) catch "?";
|
||||
draw.writeStr(surface, row, 2, result_line, .{ .fg = C.green, .bold = true });
|
||||
if (row < content_end) draw.writeStr(surface, row, 2, result_line, .{ .fg = C.green, .bold = true });
|
||||
row += 1;
|
||||
|
||||
// -- Conversion factor (linear conversions only) --
|
||||
if (pair.from.isLinear() and pair.to.isLinear()) {
|
||||
const factor = pair.from.to_base_factor / pair.to.to_base_factor;
|
||||
const factor_shown = engine.Number.fromFloat(factor).render(app.allocator, tui.compact_format) catch return;
|
||||
defer factor_shown.deinit(app.allocator);
|
||||
var factor_buf: [160]u8 = undefined;
|
||||
const factor_line = std.fmt.bufPrint(&factor_buf, "1 {s} = {s} {s}", .{
|
||||
pair.from.name, factor_shown.text, pair.to.name,
|
||||
}) catch "?";
|
||||
draw.writeStr(surface, row, 2, factor_line, .{ .fg = C.muted });
|
||||
} else {
|
||||
// Temperature is affine, so no single factor describes it.
|
||||
draw.writeStr(surface, row, 2, "affine conversion (offset), no single factor", .{ .fg = C.muted });
|
||||
if (row < content_end) {
|
||||
drawFactorLine(app, surface, row, pair.from, pair.to);
|
||||
}
|
||||
row += 2;
|
||||
|
||||
|
|
@ -98,14 +100,16 @@ pub fn drawConvertMode(app: *tui.App, surface: *vxfw.Surface, width: u16, height
|
|||
const from_focused = zone_active and app.conv_zone == .from;
|
||||
const to_focused = zone_active and app.conv_zone == .to;
|
||||
|
||||
draw.writeStr(surface, row, 2, "From", if (from_focused)
|
||||
.{ .fg = C.cyan, .bold = true }
|
||||
else
|
||||
.{ .fg = C.muted });
|
||||
draw.writeStr(surface, row, to_col -| 2, "To", if (to_focused)
|
||||
.{ .fg = C.cyan, .bold = true }
|
||||
else
|
||||
.{ .fg = C.muted });
|
||||
if (row < content_end) {
|
||||
draw.writeStr(surface, row, 2, "From", if (from_focused)
|
||||
.{ .fg = C.cyan, .bold = true }
|
||||
else
|
||||
.{ .fg = C.muted });
|
||||
draw.writeStr(surface, row, to_col -| 2, "To", if (to_focused)
|
||||
.{ .fg = C.cyan, .bold = true }
|
||||
else
|
||||
.{ .fg = C.muted });
|
||||
}
|
||||
row += 1;
|
||||
|
||||
const list_start = row;
|
||||
|
|
@ -127,8 +131,8 @@ pub fn drawConvertMode(app: *tui.App, surface: *vxfw.Surface, width: u16, height
|
|||
}
|
||||
|
||||
// If the terminal is too short for the whole list, say so rather than silently
|
||||
// truncating. Drawn even when nothing fits at all, which is the case that used
|
||||
// to show two empty columns and no explanation.
|
||||
// truncating. A terminal with no room for even the notice gets nothing: there is
|
||||
// no row left to put it on.
|
||||
if (overflows) {
|
||||
var more_buf: [64]u8 = undefined;
|
||||
const more = std.fmt.bufPrint(&more_buf, "... {d} more (resize to see all)", .{table.len - visible}) catch "...";
|
||||
|
|
@ -138,7 +142,12 @@ pub fn drawConvertMode(app: *tui.App, surface: *vxfw.Surface, width: u16, height
|
|||
}
|
||||
}
|
||||
|
||||
// -- Separator, input, status --
|
||||
drawFurniture(app, surface, width, height, zone_active);
|
||||
}
|
||||
|
||||
/// The separator, input line and status row. Factored out so the out-of-memory path
|
||||
/// above draws them too, rather than returning early and leaving no prompt at all.
|
||||
fn drawFurniture(app: *tui.App, surface: *vxfw.Surface, width: u16, height: u16, zone_active: bool) void {
|
||||
draw.fillRow(surface, height -| 3, '-', .{ .fg = C.dim });
|
||||
app.drawInput(surface, height -| 2);
|
||||
app.addRegion(height -| 2, 0, width, .focus_input);
|
||||
|
|
@ -151,6 +160,39 @@ pub fn drawConvertMode(app: *tui.App, surface: *vxfw.Surface, width: u16, height
|
|||
draw.hintRow(surface, height -| 1, 1, status, .{ .fg = C.muted, .bg = C.bg });
|
||||
}
|
||||
|
||||
/// "1 in = 2.54 cm", or a note that the pair has no single factor.
|
||||
///
|
||||
/// The factor is converted rather than divided. `from.to_base_factor /
|
||||
/// to.to_base_factor` is an f64 division, which disagreed with the exact result drawn
|
||||
/// directly above it: centimetres to inches came out 0.39370078740157477 here and
|
||||
/// 0.393700787401574803150 there, for the same pair of units.
|
||||
fn drawFactorLine(
|
||||
app: *tui.App,
|
||||
surface: *vxfw.Surface,
|
||||
row: u16,
|
||||
from: engine.UnitDef,
|
||||
to: engine.UnitDef,
|
||||
) void {
|
||||
if (!from.isLinear() or !to.isLinear()) {
|
||||
// Temperature is affine, so no single factor describes it.
|
||||
draw.writeStr(surface, row, 2, "affine conversion (offset), no single factor", .{ .fg = C.muted });
|
||||
return;
|
||||
}
|
||||
|
||||
var one = engine.Number.parse(app.allocator, "1") catch return;
|
||||
defer one.deinit();
|
||||
var factor = units.convertExactUnits(app.allocator, one, from, to) catch return;
|
||||
defer factor.deinit();
|
||||
const factor_shown = factor.render(app.allocator, tui.compact_format) catch return;
|
||||
defer factor_shown.deinit(app.allocator);
|
||||
|
||||
var factor_buf: [160]u8 = undefined;
|
||||
const factor_line = std.fmt.bufPrint(&factor_buf, "1 {s} = {s} {s}", .{
|
||||
from.name, factor_shown.text, to.name,
|
||||
}) catch "?";
|
||||
draw.writeStr(surface, row, 2, factor_line, .{ .fg = C.muted });
|
||||
}
|
||||
|
||||
/// Draw one unit name in a column, highlighting it when selected, and register
|
||||
/// it as clickable.
|
||||
fn drawUnitCell(
|
||||
|
|
|
|||
|
|
@ -591,14 +591,17 @@ pub fn drawFinancialMode(app: *tui.App, surface: *vxfw.Surface, width: u16, heig
|
|||
}
|
||||
|
||||
// -- Result --
|
||||
//
|
||||
// Computed once and handed down. Each call rebuilds the answer, and for a
|
||||
// 360-period loan that means `amortizationTotals` walking the whole schedule:
|
||||
// the draw used to ask three times, and `drawSchedule` then built the rows again.
|
||||
const result = state.outcome();
|
||||
row += 1;
|
||||
row = drawOutcome(app, state, surface, row, width, height);
|
||||
row = drawOutcome(state, result, surface, row, height);
|
||||
|
||||
// -- Amortization schedule --
|
||||
if (state.form == .amortization) {
|
||||
if (state.outcome() == .schedule) {
|
||||
drawSchedule(app, state, surface, row, width, height);
|
||||
}
|
||||
if (result == .schedule) {
|
||||
drawSchedule(app, state, result.schedule, surface, row, height);
|
||||
}
|
||||
|
||||
// -- Separator, input line, status --
|
||||
|
|
@ -695,18 +698,16 @@ fn drawCursor(surface: *vxfw.Surface, row: u16, col: u16) void {
|
|||
|
||||
/// Draw the result block. Returns the next free row.
|
||||
fn drawOutcome(
|
||||
app: *tui.App,
|
||||
state: *State,
|
||||
result: Outcome,
|
||||
surface: *vxfw.Surface,
|
||||
start_row: u16,
|
||||
width: u16,
|
||||
height: u16,
|
||||
) u16 {
|
||||
_ = width;
|
||||
var row = start_row;
|
||||
if (row >= height -| 4) return row;
|
||||
|
||||
switch (state.outcome()) {
|
||||
switch (result) {
|
||||
.hint => |text| {
|
||||
draw.writeStr(surface, row, 2, text, .{ .fg = C.muted });
|
||||
row += 1;
|
||||
|
|
@ -756,7 +757,6 @@ fn drawOutcome(
|
|||
}
|
||||
},
|
||||
}
|
||||
_ = app;
|
||||
return row + 1;
|
||||
}
|
||||
|
||||
|
|
@ -764,22 +764,15 @@ fn drawOutcome(
|
|||
fn drawSchedule(
|
||||
app: *tui.App,
|
||||
state: *State,
|
||||
schedule_info: Outcome.Schedule,
|
||||
surface: *vxfw.Surface,
|
||||
start_row: u16,
|
||||
width: u16,
|
||||
height: u16,
|
||||
) void {
|
||||
_ = width;
|
||||
var row = start_row;
|
||||
const list_end = height -| 4;
|
||||
if (row + 1 >= list_end) return;
|
||||
|
||||
const outcome = state.outcome();
|
||||
const schedule_info = switch (outcome) {
|
||||
.schedule => |s| s,
|
||||
else => return,
|
||||
};
|
||||
|
||||
const rows = financial.amortizationSchedule(app.allocator, schedule_info.params) catch return;
|
||||
defer app.allocator.free(rows);
|
||||
|
||||
|
|
@ -884,7 +877,7 @@ fn money(buf: []u8, value: f64) []const u8 {
|
|||
/// rest defer to `engine.phrase`, so this is no longer a third copy of
|
||||
/// the whole table. A generic "domain error" is useless in a form, where the cause
|
||||
/// is always one of a few bad entries, but "division by zero" needs no improving.
|
||||
pub fn errorText(err: engine.Error) []const u8 {
|
||||
fn errorText(err: engine.Error) []const u8 {
|
||||
return switch (err) {
|
||||
engine.Error.DomainError => "check the entries: values must be positive and a payment must cover the interest",
|
||||
engine.Error.ConvergenceFailure => "no rate solves these cash flows",
|
||||
|
|
|
|||
|
|
@ -47,13 +47,32 @@ fn fieldColor(field: Field) vaxis.Cell.Color {
|
|||
}
|
||||
|
||||
pub fn drawFloatView(app: *tui.App, surface: *vxfw.Surface, width: u16, height: u16) void {
|
||||
_ = width;
|
||||
const format = app.float_format;
|
||||
const total = format.totalBits();
|
||||
const bw = app.prog_config.width;
|
||||
const bits: u64 = @truncate(app.prog_value & bw.mask());
|
||||
const info = fi.decompose(format, bits);
|
||||
|
||||
// Rows this view needs: two header lines, the bit grid, a blank, and the eight
|
||||
// rows of the panel below it. Without this check the panel was drawn regardless
|
||||
// and the furniture went over the top of it, which left the tail of the "Exp:"
|
||||
// row sitting on the input line next to the prompt at 100x16.
|
||||
const grid_start: u16 = 5;
|
||||
const grid_rows: u16 = (@as(u16, total) + 31) / 32;
|
||||
const panel_rows: u16 = 9;
|
||||
const content_end = height -| 3;
|
||||
if (grid_start + grid_rows + panel_rows > content_end) {
|
||||
var need_buf: [96]u8 = undefined;
|
||||
const need = std.fmt.bufPrint(
|
||||
&need_buf,
|
||||
"too short for the {d}-bit float view: needs {d} rows, has {d}",
|
||||
.{ total, grid_start + grid_rows + panel_rows + 3, height },
|
||||
) catch "too short for the float view";
|
||||
draw.writeStr(surface, 2, 2, need, .{ .fg = C.pink });
|
||||
drawFurniture(app, surface, width, height);
|
||||
return;
|
||||
}
|
||||
|
||||
// Header line
|
||||
var header_buf: [96]u8 = undefined;
|
||||
const header = std.fmt.bufPrint(&header_buf, "IEEE 754 {s} ({d}-bit) Ctrl-W:f32/f64 Ctrl-F:exit", .{
|
||||
|
|
@ -64,13 +83,17 @@ pub fn drawFloatView(app: *tui.App, surface: *vxfw.Surface, width: u16, height:
|
|||
total,
|
||||
}) catch "IEEE 754";
|
||||
draw.writeStr(surface, 2, 2, header, .{ .fg = C.muted });
|
||||
// Clicking the header row exits the overlay; clicking the format label
|
||||
// toggles f32/f64.
|
||||
if (std.mem.indexOf(u8, header, "Ctrl-W")) |idx| {
|
||||
app.addRegion(2, 2 + @as(u16, @intCast(idx)), 12, .toggle_float_format);
|
||||
// Clicking the format label toggles f32/f64; clicking the exit label leaves the
|
||||
// overlay. Region lengths come from the labels rather than being counted by hand:
|
||||
// the f32/f64 region was two characters short, so clicking the end of the label
|
||||
// did nothing.
|
||||
const format_label = "Ctrl-W:f32/f64";
|
||||
const exit_label = "Ctrl-F:exit";
|
||||
if (std.mem.indexOf(u8, header, format_label)) |idx| {
|
||||
app.addRegion(2, 2 + @as(u16, @intCast(idx)), format_label.len, .toggle_float_format);
|
||||
}
|
||||
if (std.mem.indexOf(u8, header, "Ctrl-F")) |idx| {
|
||||
app.addRegion(2, 2 + @as(u16, @intCast(idx)), 11, .toggle_float);
|
||||
if (std.mem.indexOf(u8, header, exit_label)) |idx| {
|
||||
app.addRegion(2, 2 + @as(u16, @intCast(idx)), exit_label.len, .toggle_float);
|
||||
}
|
||||
|
||||
// Color legend
|
||||
|
|
@ -79,11 +102,10 @@ pub fn drawFloatView(app: *tui.App, surface: *vxfw.Surface, width: u16, height:
|
|||
draw.writeStr(surface, 3, 16, "significand", .{ .fg = C.green, .bold = true });
|
||||
|
||||
// Bit grid (color-coded by field)
|
||||
const grid_start: u16 = 5;
|
||||
const grid_rows = drawFloatBitGrid(app, surface, grid_start, bits, format);
|
||||
const drawn_rows = drawFloatBitGrid(app, surface, grid_start, bits, format);
|
||||
|
||||
// Info panel below the grid
|
||||
var row = grid_start + grid_rows + 1;
|
||||
var row = grid_start + drawn_rows + 1;
|
||||
const bias = format.bias();
|
||||
const sig_bits = format.significandBits();
|
||||
|
||||
|
|
@ -162,9 +184,15 @@ pub fn drawFloatView(app: *tui.App, surface: *vxfw.Surface, width: u16, height:
|
|||
tui.drawHistory(app.history.items, surface, hist_start, hist_end);
|
||||
}
|
||||
|
||||
drawFurniture(app, surface, width, height);
|
||||
}
|
||||
|
||||
/// The separator, input line and status row. Factored out so the short-terminal path
|
||||
/// draws them too, rather than returning early and leaving no prompt at all.
|
||||
fn drawFurniture(app: *tui.App, surface: *vxfw.Surface, width: u16, height: u16) void {
|
||||
draw.fillRow(surface, height -| 3, '-', .{ .fg = C.dim });
|
||||
app.drawInput(surface, height -| 2);
|
||||
app.addRegion(height -| 2, 0, surface.size.width, .focus_input);
|
||||
app.addRegion(height -| 2, 0, width, .focus_input);
|
||||
draw.fillRow(surface, height -| 1, ' ', .{ .fg = C.muted, .bg = C.bg });
|
||||
const status = "?:help | Ctrl-F:exit | Type a float | Arrows:nav bits | Space:toggle | Ctrl-W:f32/f64";
|
||||
draw.hintRow(surface, height -| 1, 1, status, .{ .fg = C.muted, .bg = C.bg });
|
||||
|
|
|
|||
|
|
@ -25,7 +25,48 @@ const Line = union(enum) {
|
|||
blank,
|
||||
};
|
||||
|
||||
const lines = bindings_and_functions ++ financial_functions ++ operators_and_units;
|
||||
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.
|
||||
|
|
@ -39,7 +80,7 @@ const financial_functions = blk: {
|
|||
break :blk section;
|
||||
};
|
||||
|
||||
const bindings_and_functions = [_]Line{
|
||||
const bindings = [_]Line{
|
||||
.{ .header = "Keybindings" },
|
||||
.{ .key = .{ .name = "Enter", .desc = "Evaluate expression" } },
|
||||
.{ .key = .{ .name = "Tab", .desc = "Next mode (Standard/Programmer/Financial/Convert)" } },
|
||||
|
|
@ -89,11 +130,6 @@ const bindings_and_functions = [_]Line{
|
|||
.{ .key = .{ .name = "Ctrl-S", .desc = "Swap from and to units" } },
|
||||
.{ .key = .{ .name = "Enter", .desc = "Set the value to convert" } },
|
||||
.blank,
|
||||
|
||||
.{ .header = "Functions" },
|
||||
.{ .text = "sin cos tan asin acos atan log ln sqrt abs" },
|
||||
.{ .text = "ceil floor round factorial max min exp" },
|
||||
.blank,
|
||||
};
|
||||
|
||||
const operators_and_units = [_]Line{
|
||||
|
|
@ -125,7 +161,11 @@ 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.
|
||||
pub fn drawHelp(surface: *vxfw.Surface, width: u16, height: u16, scroll: usize) void {
|
||||
///
|
||||
/// 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), ' ', .{});
|
||||
}
|
||||
|
|
@ -164,13 +204,40 @@ pub fn drawHelp(surface: *vxfw.Surface, width: u16, height: u16, scroll: usize)
|
|||
} else {
|
||||
draw.hintRow(surface, height -| 1, 1, "Press any key to return", .{ .fg = C.muted });
|
||||
}
|
||||
_ = width;
|
||||
}
|
||||
|
||||
// -- 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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue