bump size of chart numbers on history tui tab
All checks were successful
Generic zig build / build (push) Successful in 10m28s
Generic zig build / publish-macos (push) Successful in 12s
Generic zig build / deploy (push) Successful in 18s

This commit is contained in:
Emil Lerch 2026-06-03 07:09:20 -07:00
parent 7f204747ad
commit 79ffbeb078
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 145 additions and 10 deletions

View file

@ -755,6 +755,14 @@ pub fn brailleGlyph(pattern: u8) []const u8 {
return &braille_utf8[pattern];
}
/// Maximum byte length for a `Money.from(v).{f}` rendering used as a
/// chart axis label. Sized to fit `$999,999,999,999.99` (19 chars,
/// up to a trillion-plus) with slack. Renderers that pre-allocate
/// buffer cells for these labels should use this constant rather
/// than hard-coding a smaller width and silently truncating
/// portfolios over $1M.
pub const money_label_max_bytes: usize = 24;
/// Computed braille chart data, ready for rendering by CLI (ANSI) or TUI (vaxis).
pub const BrailleChart = struct {
/// Braille pattern bytes: patterns[row * n_cols + col]
@ -763,9 +771,14 @@ pub const BrailleChart = struct {
col_colors: [][3]u8,
n_cols: usize,
chart_height: usize,
max_label: [16]u8,
/// Money labels formatted via `Money.from(v).{f}`. Sized to fit
/// up to `$999,999,999,999.99` (19 chars) with slack so we don't
/// silently drop the label when portfolios cross into ten figures.
/// Renderers that need to budget cells for the label should use
/// `money_label_max_bytes` rather than guessing.
max_label: [money_label_max_bytes]u8,
max_label_len: usize,
min_label: [16]u8,
min_label: [money_label_max_bytes]u8,
min_label_len: usize,
/// Date of first candle in the chart data
start_date: Date,
@ -1360,6 +1373,52 @@ test "computeBrailleChart insufficient data" {
try std.testing.expectError(error.InsufficientData, result);
}
test "computeBrailleChart preserves full label for prices over $1M" {
// Regression test for a bug where the max/min label buffers were
// sized at 16 bytes - too small for `Money.from(v).{f}` of values
// with 13+ chars (anything $1,000,000+). Result was a silently
// empty label string in the BrailleChart, then the TUI renderer
// truncated even further to 10 cells, dropping the label entirely
// for portfolios over $1M. See `money_label_max_bytes`.
const alloc = std.testing.allocator;
var candles: [20]Candle = undefined;
for (0..20) |i| {
// Arbitrary placeholder range starting at $1,234,567.89 with
// a $500,000 step. The point of this test is the rendered
// shape - 13+ char `$X,XXX,XXX.XX` strings that would have
// overflowed the old 16-byte label buffer or the renderer's
// 10-cell budget. The exact numbers are irrelevant.
const price: f64 = 1_234_567.89 + @as(f64, @floatFromInt(i)) * 500_000.0;
candles[i] = .{
.date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
.open = price,
.high = price,
.low = price,
.close = price,
.adj_close = price,
.volume = 1000,
};
}
var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 });
defer chart.deinit(alloc);
// Both labels must contain the dollar sign and a comma (the
// thousands separator) - that confirms `Money.from` produced
// a multi-million-dollar string and didn't fall through to the
// empty-string fallback when bufPrint hit NoSpaceLeft.
const max_lbl = chart.maxLabel();
const min_lbl = chart.minLabel();
try std.testing.expect(std.mem.indexOfScalar(u8, max_lbl, '$') != null);
try std.testing.expect(std.mem.indexOfScalar(u8, max_lbl, ',') != null);
try std.testing.expect(std.mem.indexOfScalar(u8, min_lbl, '$') != null);
try std.testing.expect(std.mem.indexOfScalar(u8, min_lbl, ',') != null);
// Both labels should match the `$X,XXX,XXX.XX` shape (at
// least 13 chars). Any silent-truncation bug would leave them
// empty or much shorter.
try std.testing.expect(max_lbl.len >= 13);
try std.testing.expect(min_lbl.len >= 13);
}
test "computeBrailleChart uses adj_close to avoid split cliff" {
// Regression: SOXX 3:1 on 2024-03-07 used to render a sharp drop
// because the chart consumed raw `close` instead of `adj_close`.

View file

@ -1905,10 +1905,17 @@ pub fn renderBrailleToStyledLines(arena: std.mem.Allocator, lines: *std.ArrayLis
var br = fmt.computeBrailleChart(arena, data, 60, 10, th.positive, th.negative) catch return;
// No deinit needed: arena handles cleanup
// Cell budget per row: 2 leading spaces + n_cols chart cells +
// 1 separator space + up to `money_label_max_bytes` for the
// price label. Sizing this too small silently truncates labels
// for portfolios over $1M; see `fmt.money_label_max_bytes`.
const label_cells: usize = 1 + fmt.money_label_max_bytes;
const row_cells: usize = 2 + br.n_cols + label_cells;
const bg = th.bg;
for (0..br.chart_height) |row| {
const graphemes = try arena.alloc([]const u8, br.n_cols + 12); // chart + padding + label
const styles = try arena.alloc(vaxis.Style, br.n_cols + 12);
const graphemes = try arena.alloc([]const u8, row_cells);
const styles = try arena.alloc(vaxis.Style, row_cells);
var gpos: usize = 0;
// 2 leading spaces
@ -1968,8 +1975,8 @@ pub fn renderBrailleToStyledLines(arena: std.mem.Allocator, lines: *std.ArrayLis
const end_label = br.fmtAxisDate(br.end_date, &end_buf);
const muted_style = vaxis.Style{ .fg = theme.Theme.vcolor(th.text_muted), .bg = theme.Theme.vcolor(bg) };
const date_graphemes = try arena.alloc([]const u8, br.n_cols + 12);
const date_styles = try arena.alloc(vaxis.Style, br.n_cols + 12);
const date_graphemes = try arena.alloc([]const u8, row_cells);
const date_styles = try arena.alloc(vaxis.Style, row_cells);
var dpos: usize = 0;
// 2 leading spaces
@ -2615,3 +2622,62 @@ test "writeDefaultKeys: tab sections appear in tab_modules declaration order" {
prev_pos = pos;
}
}
test "renderBrailleToStyledLines: full price label renders for portfolios over $1M" {
// Regression test for a TUI rendering bug where the per-row
// grapheme buffer was sized at `n_cols + 12`, leaving only 10
// cells for the right-side price label (after 2 leading spaces
// and `n_cols` chart cells). For 13+ char money strings like
// `$X,XXX,XXX.XX` (14 chars including the leading separator
// space), the label got silently truncated to the 10 cell
// budget. Buffer is now sized via `fmt.money_label_max_bytes`.
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const a = arena.allocator();
// Synthetic candles in an arbitrary multi-million-dollar range.
// Specific numbers don't matter; what matters is that the
// rendered `Money.from(v).{f}` strings are wide enough to
// exercise the label buffer (13+ chars).
var candles: [20]zfin.Candle = undefined;
for (0..20) |i| {
const price: f64 = 1_234_567.89 + @as(f64, @floatFromInt(i)) * 500_000.0;
candles[i] = .{
.date = zfin.Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
.open = price,
.high = price,
.low = price,
.close = price,
.adj_close = price,
.volume = 1000,
};
}
var lines: std.ArrayList(StyledLine) = .empty;
try renderBrailleToStyledLines(a, &lines, &candles, theme.default_theme);
// Grab the first row (which carries the max label) and the
// last chart row (which carries the min label). Walk the
// grapheme cells looking for the `$` and the closing digit
// pattern of the money string.
try testing.expect(lines.items.len >= 2);
const first_row = lines.items[0];
const graphemes = first_row.graphemes orelse return error.MissingGraphemes;
// Reconstruct the rendered string and assert the full money
// label is present (dollar sign, comma separator, two-digit
// cents). A truncated label would lack the trailing digits.
var rendered: std.ArrayList(u8) = .empty;
defer rendered.deinit(a);
for (graphemes) |g| try rendered.appendSlice(a, g);
// The max value in the candle set is the last index's price:
// 1_234_567.89 + 19 * 500_000.0 = 10_734_567.89.
// `Money.from(10_734_567.89).{f}` produces `$10,734,567.89`.
// Confirm the label as a whole substring is present (no
// truncation).
try testing.expect(std.mem.indexOf(u8, rendered.items, "$") != null);
try testing.expect(std.mem.indexOf(u8, rendered.items, ".89") != null);
try testing.expect(std.mem.indexOf(u8, rendered.items, ",") != null);
}

View file

@ -1919,9 +1919,19 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
const muted_fg = theme.Theme.vcolor(th.text_muted);
const bg_v = theme.Theme.vcolor(bg);
// Cell budget per row: 2 leading spaces + n_cols
// chart cells + 1 separator space + up to
// `money_label_max_bytes` for the price label.
// Sized via a named constant so the projection
// chart doesn't silently truncate labels for
// portfolios that grow past $1M; see
// `fmt.money_label_max_bytes`.
const proj_label_cells: usize = 1 + fmt.money_label_max_bytes;
const proj_row_cells: usize = 2 + br_chart.n_cols + proj_label_cells;
for (0..br_chart.chart_height) |row| {
const graphemes = try arena.alloc([]const u8, br_chart.n_cols + 20);
const styles = try arena.alloc(vaxis.Style, br_chart.n_cols + 20);
const graphemes = try arena.alloc([]const u8, proj_row_cells);
const styles = try arena.alloc(vaxis.Style, proj_row_cells);
var gpos: usize = 0;
// 2 leading spaces
@ -1967,8 +1977,8 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
// Year axis: "Now" on left, "{horizon}yr" on right
{
const axis_graphemes = try arena.alloc([]const u8, br_chart.n_cols + 20);
const axis_styles = try arena.alloc(vaxis.Style, br_chart.n_cols + 20);
const axis_graphemes = try arena.alloc([]const u8, proj_row_cells);
const axis_styles = try arena.alloc(vaxis.Style, proj_row_cells);
const muted_style = vaxis.Style{ .fg = muted_fg, .bg = bg_v };
var apos: usize = 0;