From acf5f723f8f2a2089c17255aade64c05c2864f41 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Fri, 26 Jun 2026 11:53:29 -0700 Subject: [PATCH] move braille charts to its own space --- TODO.md | 14 +- src/charts/braille.zig | 513 +++++++++++++++++++++++++++++++++ src/commands/history.zig | 5 +- src/commands/projections.zig | 5 +- src/commands/quote.zig | 5 +- src/format.zig | 529 +---------------------------------- src/tui.zig | 12 +- src/tui/projections_tab.zig | 9 +- 8 files changed, 543 insertions(+), 549 deletions(-) create mode 100644 src/charts/braille.zig diff --git a/TODO.md b/TODO.md index 4f6b54c..caa6aa0 100644 --- a/TODO.md +++ b/TODO.md @@ -103,18 +103,18 @@ still don't have PNG export and were deferred: ## Refactor: trim `src/format.zig` once Money / Date have absorbed their helpers - priority LOW -`src/format.zig` is still a ~1700-line grab-bag, but the money- and +`src/format.zig` is still a ~1600-line grab-bag, but the money- and date-shaped helpers that used to live there have been moved out: money formatting now lives in `src/Money.zig` (with `{f}` / `whole()` / `trim()` / `signed()` / `padRight(N)` / `padLeft(N)`), -and date formatting lives in `src/Date.zig` (with `{f}` / -`padRight(N)` / `padLeft(N)`). What's left in `format.zig` is the -genuinely-format-domain stuff: braille charts, return formatters, -allocation notes, signed-percent rendering. +date formatting lives in `src/Date.zig` (with `{f}` / +`padRight(N)` / `padLeft(N)`), and the braille sparkline chart now +lives in `src/charts/braille.zig`. What's left in `format.zig` is +the genuinely-format-domain stuff: return formatters, allocation +notes, signed-percent rendering. If the file ever grows enough to be annoying again, consider -renaming to `src/render.zig` to better describe what's left, or -splitting the braille chart out (it's ~600 lines on its own). +renaming to `src/render.zig` to better describe what's left. Not blocking - file it as cleanup if and when it bites. ## Investigate: detailed 401(k) contributions data source diff --git a/src/charts/braille.zig b/src/charts/braille.zig new file mode 100644 index 0000000..ab9223d --- /dev/null +++ b/src/charts/braille.zig @@ -0,0 +1,513 @@ +//! Braille sparkline chart engine. +//! +//! Renders compact price/value sparklines using Unicode braille +//! characters (U+2800..U+28FF) for a 2-wide x 4-tall dot matrix per +//! terminal cell. `computeBrailleChart` produces a renderer-agnostic +//! `BrailleChart` (pattern grid + per-column colors); `writeBrailleAnsi` +//! renders it to a writer with ANSI color for the CLI, while the TUI +//! consumes the `BrailleChart` directly to emit vaxis cells. +//! +//! Extracted from `format.zig`; CLI and TUI both reach for it via +//! `@import("charts/braille.zig")`. + +const std = @import("std"); +const Date = @import("../Date.zig"); +const Money = @import("../Money.zig"); +const Candle = @import("../models/candle.zig").Candle; + +/// Interpolate color between two RGB values. t in [0.0, 1.0]. +pub fn lerpColor(a: [3]u8, b: [3]u8, t: f64) [3]u8 { + return .{ + @intFromFloat(@as(f64, @floatFromInt(a[0])) * (1.0 - t) + @as(f64, @floatFromInt(b[0])) * t), + @intFromFloat(@as(f64, @floatFromInt(a[1])) * (1.0 - t) + @as(f64, @floatFromInt(b[1])) * t), + @intFromFloat(@as(f64, @floatFromInt(a[2])) * (1.0 - t) + @as(f64, @floatFromInt(b[2])) * t), + }; +} + +// ── Braille chart ──────────────────────────────────────────── + +/// Braille dot patterns for the 2x4 matrix within each character cell. +/// Layout: [0][3] Bit mapping: dot0=0x01, dot3=0x08 +/// [1][4] dot1=0x02, dot4=0x10 +/// [2][5] dot2=0x04, dot5=0x20 +/// [6][7] dot6=0x40, dot7=0x80 +pub const braille_dots = [4][2]u8{ + .{ 0x01, 0x08 }, // row 0 (top) + .{ 0x02, 0x10 }, // row 1 + .{ 0x04, 0x20 }, // row 2 + .{ 0x40, 0x80 }, // row 3 (bottom) +}; + +/// Comptime table of braille character UTF-8 encodings (U+2800..U+28FF). +/// Each braille codepoint is 3 bytes in UTF-8: 0xE2 0xA0+hi 0x80+lo. +pub const braille_utf8 = blk: { + var table: [256][3]u8 = undefined; + for (0..256) |i| { + const cp: u21 = 0x2800 + @as(u21, @intCast(i)); + table[i] = .{ + @as(u8, 0xE0 | @as(u8, @truncate(cp >> 12))), + @as(u8, 0x80 | @as(u8, @truncate((cp >> 6) & 0x3F))), + @as(u8, 0x80 | @as(u8, @truncate(cp & 0x3F))), + }; + } + break :blk table; +}; + +/// Return a static-lifetime grapheme slice for a braille pattern byte. +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] + patterns: []u8, + /// RGB color per data column + col_colors: [][3]u8, + n_cols: usize, + chart_height: usize, + /// 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: [money_label_max_bytes]u8, + min_label_len: usize, + /// Date of first candle in the chart data + start_date: Date, + /// Date of last candle in the chart data + end_date: Date, + + pub fn maxLabel(self: *const BrailleChart) []const u8 { + return self.max_label[0..self.max_label_len]; + } + + pub fn minLabel(self: *const BrailleChart) []const u8 { + return self.min_label[0..self.min_label_len]; + } + + pub fn pattern(self: *const BrailleChart, row: usize, col: usize) u8 { + return self.patterns[row * self.n_cols + col]; + } + + /// Format a date for the chart's x-axis at a granularity + /// appropriate for that individual date's recency. Two tiers, + /// per-date (driven by how far back the date is from the + /// chart's `end_date` reference, not by the chart's overall + /// span): + /// - within 720 days of `end_date` -> "DD MMM" (e.g., "08 May") + /// - older than 720 days -> "MMM YYYY" (e.g., "Jul 2014") + /// + /// On a 12-year chart, this typically yields a long-format + /// start label (`Jul 2014`) paired with a short-format end + /// label (`08 May`) - the start is far enough back that + /// year context is what matters; the end is recent enough + /// that day-of-month resolution is useful. + /// + /// The day-first ordering for the short form is intentional: + /// when a chart pairs `"08 May"` with `"Jul 2014"`, the first + /// character of each label cleanly disambiguates the format + /// at a glance - digit-first is a recent date, letter-first is + /// a distant date. Saves the eye from re-parsing every label. + /// + /// `buf` must be at least 8 bytes; the returned slice borrows + /// from it. + pub fn fmtAxisDate(self: *const BrailleChart, date: Date, buf: *[8]u8) []const u8 { + const age_days = self.end_date.days - date.days; + const mon = Date.monthShort(date.month()); + + if (age_days <= 720) { + // "DD MMM" - day-first so the leading character is a + // digit (visually distinct from the letter-first + // long form below). + return std.fmt.bufPrint(buf, "{d:0>2} {s}", .{ date.day(), mon }) catch buf[0..0]; + } + // "MMM YYYY" - for dates more than ~2 years before + // `end_date`. Day-of-month resolution stops being useful + // at this scale; full 4-digit year keeps the label + // unambiguous regardless of how far back the chart goes. + return std.fmt.bufPrint(buf, "{s} {d:0>4}", .{ mon, @as(u16, @intCast(date.year())) }) catch buf[0..0]; + } + + pub fn deinit(self: *BrailleChart, alloc: std.mem.Allocator) void { + alloc.free(self.patterns); + alloc.free(self.col_colors); + } +}; + +/// Compute braille sparkline chart data from candle close prices. +/// Uses Unicode braille characters (U+2800..U+28FF) for 2-wide x 4-tall dot matrix per cell. +/// Each terminal row provides 4 sub-rows of resolution; each column maps to one data point. +/// +/// Returns a BrailleChart with the pattern grid and per-column colors. +/// Caller must call deinit() when done (unless using an arena allocator). +pub fn computeBrailleChart( + alloc: std.mem.Allocator, + data: []const Candle, + chart_width: usize, + chart_height: usize, + positive_color: [3]u8, + negative_color: [3]u8, +) !BrailleChart { + if (data.len < 2) return error.InsufficientData; + + const dot_rows: usize = chart_height * 4; // vertical dot resolution + + // Find min/max chart-close prices (split-adjusted when available). + // See `Candle.chartClose` - using raw `close` here would render + // false cliffs at split dates. + var min_price: f64 = data[0].chartClose(); + var max_price: f64 = data[0].chartClose(); + for (data) |d| { + const cc = d.chartClose(); + if (cc < min_price) min_price = cc; + if (cc > max_price) max_price = cc; + } + if (max_price == min_price) max_price = min_price + 1.0; + const price_range = max_price - min_price; + + // Price labels + // SAFETY: every field of `result` is initialized below before + // it is read or returned. Treating it as `undefined` here is + // a deliberate "stack-allocate, then write each field" + // pattern - Zig requires the variable to exist before + // bufPrint can take a slice of one of its fields. + var result: BrailleChart = undefined; + const max_str = std.fmt.bufPrint(&result.max_label, "{f}", .{Money.from(max_price)}) catch ""; + result.max_label_len = max_str.len; + const min_str = std.fmt.bufPrint(&result.min_label, "{f}", .{Money.from(min_price)}) catch ""; + result.min_label_len = min_str.len; + + const n_cols = @min(data.len, chart_width); + result.n_cols = n_cols; + result.chart_height = chart_height; + result.start_date = data[0].date; + result.end_date = data[data.len - 1].date; + + // Map each data column to a dot-row position and color + const dot_y = try alloc.alloc(usize, n_cols); + defer alloc.free(dot_y); + + result.col_colors = try alloc.alloc([3]u8, n_cols); + errdefer alloc.free(result.col_colors); + + for (0..n_cols) |col| { + const data_idx_f: f64 = @as(f64, @floatFromInt(col)) * @as(f64, @floatFromInt(data.len - 1)) / @as(f64, @floatFromInt(n_cols - 1)); + const data_idx: usize = @min(@as(usize, @intFromFloat(data_idx_f)), data.len - 1); + const close = data[data_idx].chartClose(); + const norm = (close - min_price) / price_range; // 0 = min, 1 = max + // Inverted: 0 = top dot row, dot_rows-1 = bottom + const y_f = (1.0 - norm) * @as(f64, @floatFromInt(dot_rows - 1)); + dot_y[col] = @min(@as(usize, @intFromFloat(y_f)), dot_rows - 1); + // Color: gradient from negative (bottom) to positive (top) + result.col_colors[col] = lerpColor(negative_color, positive_color, norm); + } + + // Build the braille pattern grid + result.patterns = try alloc.alloc(u8, chart_height * n_cols); + @memset(result.patterns, 0); + + for (0..n_cols) |col| { + const target_y = dot_y[col]; + // Fill from target_y down to the bottom + for (target_y..dot_rows) |dy| { + const term_row = dy / 4; + const sub_row = dy % 4; + result.patterns[term_row * n_cols + col] |= braille_dots[sub_row][0]; + } + + // Interpolate between this point and the next for smooth contour + if (col + 1 < n_cols) { + const y0 = dot_y[col]; + const y1 = dot_y[col + 1]; + const min_y = @min(y0, y1); + const max_y = @max(y0, y1); + for (min_y..max_y + 1) |dy| { + const term_row = dy / 4; + const sub_row = dy % 4; + result.patterns[term_row * n_cols + col] |= braille_dots[sub_row][0]; + } + } + } + + return result; +} + +/// Write a braille chart to a writer with ANSI color escapes. +/// Used by the CLI for terminal output. Set `skip_date_axis` to +/// provide a custom x-axis (e.g. year labels instead of dates). +pub fn writeBrailleAnsi( + out: *std.Io.Writer, + chart: *const BrailleChart, + use_color: bool, + muted_color: [3]u8, + skip_date_axis: bool, +) !void { + var last_r: u8 = 0; + var last_g: u8 = 0; + var last_b: u8 = 0; + var color_active = false; + + for (0..chart.chart_height) |row| { + try out.writeAll(" "); // 2 leading spaces + + for (0..chart.n_cols) |col| { + const pat = chart.pattern(row, col); + if (use_color and pat != 0) { + const c = chart.col_colors[col]; + // Only emit color escape if color changed + if (!color_active or c[0] != last_r or c[1] != last_g or c[2] != last_b) { + try out.print("\x1b[38;2;{d};{d};{d}m", .{ c[0], c[1], c[2] }); + last_r = c[0]; + last_g = c[1]; + last_b = c[2]; + color_active = true; + } + } else if (color_active and pat == 0) { + try out.writeAll("\x1b[0m"); + color_active = false; + } + try out.writeAll(brailleGlyph(pat)); + } + + if (color_active) { + try out.writeAll("\x1b[0m"); + color_active = false; + } + + // Price label on first/last row + if (row == 0) { + if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] }); + try out.print(" {s}", .{chart.maxLabel()}); + if (use_color) try out.writeAll("\x1b[0m"); + } else if (row == chart.chart_height - 1) { + if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] }); + try out.print(" {s}", .{chart.minLabel()}); + if (use_color) try out.writeAll("\x1b[0m"); + } + try out.writeAll("\n"); + } + + // Date axis below chart + if (!skip_date_axis) { + var start_buf: [8]u8 = undefined; + var end_buf: [8]u8 = undefined; + const start_label = chart.fmtAxisDate(chart.start_date, &start_buf); + const end_label = chart.fmtAxisDate(chart.end_date, &end_buf); + + if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] }); + try out.writeAll(" "); // match leading indent + try out.writeAll(start_label); + const total_width = chart.n_cols; + if (total_width > start_label.len + end_label.len) { + const gap = total_width - start_label.len - end_label.len; + for (0..gap) |_| try out.writeAll(" "); + } + try out.writeAll(end_label); + if (use_color) try out.writeAll("\x1b[0m"); + try out.writeAll("\n"); + } +} + +// ── Tests ──────────────────────────────────────────────────── + +test "lerpColor" { + // t=0 returns first color + const c0 = lerpColor(.{ 0, 0, 0 }, .{ 255, 255, 255 }, 0.0); + try std.testing.expectEqual(@as(u8, 0), c0[0]); + try std.testing.expectEqual(@as(u8, 0), c0[1]); + // t=1 returns second color + const c1 = lerpColor(.{ 0, 0, 0 }, .{ 255, 255, 255 }, 1.0); + try std.testing.expectEqual(@as(u8, 255), c1[0]); + // t=0.5 returns midpoint + const c_mid = lerpColor(.{ 0, 0, 0 }, .{ 200, 100, 50 }, 0.5); + try std.testing.expectEqual(@as(u8, 100), c_mid[0]); + try std.testing.expectEqual(@as(u8, 50), c_mid[1]); + try std.testing.expectEqual(@as(u8, 25), c_mid[2]); +} + +test "brailleGlyph" { + // Pattern 0 = U+2800 (blank braille) + const blank = brailleGlyph(0); + try std.testing.expectEqual(@as(usize, 3), blank.len); + try std.testing.expectEqual(@as(u8, 0xE2), blank[0]); + try std.testing.expectEqual(@as(u8, 0xA0), blank[1]); + try std.testing.expectEqual(@as(u8, 0x80), blank[2]); + // Pattern 0xFF = U+28FF (full braille) + const full = brailleGlyph(0xFF); + try std.testing.expectEqual(@as(usize, 3), full.len); + try std.testing.expectEqual(@as(u8, 0xE2), full[0]); + try std.testing.expectEqual(@as(u8, 0xA3), full[1]); + try std.testing.expectEqual(@as(u8, 0xBF), full[2]); +} + +test "computeBrailleChart" { + const alloc = std.testing.allocator; + // Build synthetic candle data: 20 candles, prices rising from 100 to 119 + var candles: [20]Candle = undefined; + for (0..20) |i| { + const price: f64 = 100.0 + @as(f64, @floatFromInt(i)); + 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); + try std.testing.expectEqual(@as(usize, 20), chart.n_cols); + try std.testing.expectEqual(@as(usize, 4), chart.chart_height); + try std.testing.expectEqual(@as(usize, 80), chart.patterns.len); // 4 * 20 + try std.testing.expectEqual(@as(usize, 20), chart.col_colors.len); + // Max/min labels should contain price info + try std.testing.expect(chart.maxLabel().len > 0); + try std.testing.expect(chart.minLabel().len > 0); +} + +test "computeBrailleChart insufficient data" { + const alloc = std.testing.allocator; + const candles = [_]Candle{ + .{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 }, + }; + const result = computeBrailleChart(alloc, &candles, 10, 4, .{ 0, 0, 0 }, .{ 255, 255, 255 }); + 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`. + // Build a synthetic 4-candle slice that mimics a 3:1 split: raw + // close drops 300 -> 100, but adj_close is constant at 100. The + // chart should see a flat line, not a cliff. + const alloc = std.testing.allocator; + const candles = [_]Candle{ + .{ .date = Date.fromYmd(2024, 3, 5), .open = 300, .high = 300, .low = 300, .close = 300, .adj_close = 100, .volume = 1000 }, + .{ .date = Date.fromYmd(2024, 3, 6), .open = 300, .high = 300, .low = 300, .close = 300, .adj_close = 100, .volume = 1000 }, + .{ .date = Date.fromYmd(2024, 3, 7), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 }, + .{ .date = Date.fromYmd(2024, 3, 8), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 }, + }; + var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 }); + defer chart.deinit(alloc); + // Min and max labels should reflect the adjusted price (~$100), + // not the raw close range (300 -> 100). The exact values vary + // because computeBrailleChart bumps max by $1 internally when + // min == max, but neither label should mention $300. + try std.testing.expect(std.mem.indexOf(u8, chart.maxLabel(), "300") == null); + try std.testing.expect(std.mem.indexOf(u8, chart.minLabel(), "300") == null); +} + +test "fmtAxisDate: span <=720d produces DD MMM" { + var br: BrailleChart = undefined; + br.start_date = Date.fromYmd(2026, 1, 1); + br.end_date = Date.fromYmd(2026, 5, 11); + var buf: [8]u8 = undefined; + const lbl = br.fmtAxisDate(Date.fromYmd(2026, 4, 27), &buf); + try std.testing.expectEqualStrings("27 Apr", lbl); +} + +test "fmtAxisDate: ~2y span (around the threshold) produces DD MMM" { + // 700 days from 2024-01-01 - still inside the threshold. + var br: BrailleChart = undefined; + br.start_date = Date.fromYmd(2024, 1, 1); + br.end_date = Date.fromYmd(2025, 12, 1); // 700 days + var buf: [8]u8 = undefined; + const lbl = br.fmtAxisDate(Date.fromYmd(2024, 1, 1), &buf); + try std.testing.expectEqualStrings("01 Jan", lbl); +} + +test "fmtAxisDate: long-history chart shows MMM YYYY for old start, DD MMM for recent end" { + // 12-year chart: start is way more than 720 days from end, + // so the start gets MMM YYYY. End is `end_date` itself + // (age 0), so it gets DD MMM. + var br: BrailleChart = undefined; + br.start_date = Date.fromYmd(2014, 7, 3); + br.end_date = Date.fromYmd(2026, 5, 11); + var buf: [8]u8 = undefined; + const start_lbl = br.fmtAxisDate(br.start_date, &buf); + try std.testing.expectEqualStrings("Jul 2014", start_lbl); + var buf2: [8]u8 = undefined; + const end_lbl = br.fmtAxisDate(br.end_date, &buf2); + try std.testing.expectEqualStrings("11 May", end_lbl); +} + +test "fmtAxisDate: boundary at exactly 720 days uses DD MMM" { + var br: BrailleChart = undefined; + br.start_date = Date.fromYmd(2025, 1, 1); + // 720 days later = 2026-12-22. + br.end_date = Date.fromYmd(2026, 12, 22); + var buf: [8]u8 = undefined; + // Date 720 days before end_date: still boundary-inclusive -> DD MMM. + const lbl = br.fmtAxisDate(Date.fromYmd(2025, 1, 1), &buf); + try std.testing.expectEqualStrings("01 Jan", lbl); +} + +test "fmtAxisDate: 721 days before end flips to MMM YYYY" { + var br: BrailleChart = undefined; + br.start_date = Date.fromYmd(2024, 12, 31); + // 721 days after 2024-12-31 = 2026-12-22. + br.end_date = Date.fromYmd(2026, 12, 22); + var buf: [8]u8 = undefined; + // Format the start (which is 721 days before end_date). + const lbl = br.fmtAxisDate(br.start_date, &buf); + try std.testing.expectEqualStrings("Dec 2024", lbl); +} diff --git a/src/commands/history.zig b/src/commands/history.zig index dfb51f8..0b95e8b 100644 --- a/src/commands/history.zig +++ b/src/commands/history.zig @@ -45,6 +45,7 @@ const view = @import("../views/history.zig"); const chart_export = @import("../chart_export.zig"); const line_chart = @import("../charts/line_chart.zig"); const chart = @import("../charts/chart.zig"); +const braille = @import("../charts/braille.zig"); const term_graphics = @import("../term_graphics.zig"); const term_query = @import("../term_query.zig"); const theme = @import("../tui/theme.zig"); @@ -655,9 +656,9 @@ fn renderBraille( } const candles = candles_list.items; - var braille_chart = fmt.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return; + var braille_chart = braille.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return; defer braille_chart.deinit(allocator); - try fmt.writeBrailleAnsi(out, &braille_chart, color, cli.CLR_MUTED, false); + try braille.writeBrailleAnsi(out, &braille_chart, color, cli.CLR_MUTED, false); } fn renderTable( diff --git a/src/commands/projections.zig b/src/commands/projections.zig index 3eac249..680bacb 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -23,6 +23,7 @@ const milestones = @import("../analytics/milestones.zig"); const shiller = @import("../data/shiller.zig"); const chart_export = @import("../chart_export.zig"); const projection_chart = @import("../charts/projection_chart.zig"); +const braille = @import("../charts/braille.zig"); const term_graphics = @import("../term_graphics.zig"); const term_query = @import("../term_query.zig"); const theme = @import("../tui/theme.zig"); @@ -895,9 +896,9 @@ pub fn runBands( }; } - var br = fmt.computeBrailleChart(va, candles, 80, 12, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null; + var br = braille.computeBrailleChart(va, candles, 80, 12, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null; if (br) |*chart| { - try fmt.writeBrailleAnsi(out, chart, color, cli.CLR_MUTED, true); + try braille.writeBrailleAnsi(out, chart, color, cli.CLR_MUTED, true); // Year axis instead of date axis try cli.setFg(out, color, cli.CLR_MUTED); try out.print(" Now", .{}); diff --git a/src/commands/quote.zig b/src/commands/quote.zig index d6ec55b..bc4ec03 100644 --- a/src/commands/quote.zig +++ b/src/commands/quote.zig @@ -6,6 +6,7 @@ const fmt = cli.fmt; const Money = @import("../Money.zig"); const chart_export = @import("../chart_export.zig"); const tui_chart = @import("../charts/chart.zig"); +const braille = @import("../charts/braille.zig"); const term_graphics = @import("../term_graphics.zig"); const term_query = @import("../term_query.zig"); const theme = @import("../tui/theme.zig"); @@ -288,9 +289,9 @@ const KittyChart = struct { fn renderBrailleCandles(allocator: std.mem.Allocator, out: *std.Io.Writer, color: bool, candles: []const zfin.Candle, display_count: usize) !void { const n = @min(candles.len, display_count); const data = candles[candles.len - n ..]; - var ch = fmt.computeBrailleChart(allocator, data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return; + var ch = braille.computeBrailleChart(allocator, data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return; defer ch.deinit(allocator); - try fmt.writeBrailleAnsi(out, &ch, color, cli.CLR_MUTED, false); + try braille.writeBrailleAnsi(out, &ch, color, cli.CLR_MUTED, false); } /// Render the price+Bollinger+volume+RSI chart for the most recent diff --git a/src/format.zig b/src/format.zig index b49ced0..30b7984 100644 --- a/src/format.zig +++ b/src/format.zig @@ -1,7 +1,8 @@ //! Shared formatting utilities used by both CLI and TUI. //! -//! Number formatting (fmtIntCommas, etc.), financial helpers -//! (capitalGainsIndicator, filterNearMoney), and braille chart computation. +//! Number formatting (fmtIntCommas, etc.) and financial helpers +//! (capitalGainsIndicator, filterNearMoney). The braille sparkline +//! chart lives in `charts/braille.zig`. const std = @import("std"); const Date = @import("Date.zig"); @@ -888,329 +889,6 @@ pub fn fmtPriceChange(buf: []u8, change: f64, pct: f64) []const u8 { } } -/// Interpolate color between two RGB values. t in [0.0, 1.0]. -pub fn lerpColor(a: [3]u8, b: [3]u8, t: f64) [3]u8 { - return .{ - @intFromFloat(@as(f64, @floatFromInt(a[0])) * (1.0 - t) + @as(f64, @floatFromInt(b[0])) * t), - @intFromFloat(@as(f64, @floatFromInt(a[1])) * (1.0 - t) + @as(f64, @floatFromInt(b[1])) * t), - @intFromFloat(@as(f64, @floatFromInt(a[2])) * (1.0 - t) + @as(f64, @floatFromInt(b[2])) * t), - }; -} - -// ── Braille chart ──────────────────────────────────────────── - -/// Braille dot patterns for the 2x4 matrix within each character cell. -/// Layout: [0][3] Bit mapping: dot0=0x01, dot3=0x08 -/// [1][4] dot1=0x02, dot4=0x10 -/// [2][5] dot2=0x04, dot5=0x20 -/// [6][7] dot6=0x40, dot7=0x80 -pub const braille_dots = [4][2]u8{ - .{ 0x01, 0x08 }, // row 0 (top) - .{ 0x02, 0x10 }, // row 1 - .{ 0x04, 0x20 }, // row 2 - .{ 0x40, 0x80 }, // row 3 (bottom) -}; - -/// Comptime table of braille character UTF-8 encodings (U+2800..U+28FF). -/// Each braille codepoint is 3 bytes in UTF-8: 0xE2 0xA0+hi 0x80+lo. -pub const braille_utf8 = blk: { - var table: [256][3]u8 = undefined; - for (0..256) |i| { - const cp: u21 = 0x2800 + @as(u21, @intCast(i)); - table[i] = .{ - @as(u8, 0xE0 | @as(u8, @truncate(cp >> 12))), - @as(u8, 0x80 | @as(u8, @truncate((cp >> 6) & 0x3F))), - @as(u8, 0x80 | @as(u8, @truncate(cp & 0x3F))), - }; - } - break :blk table; -}; - -/// Return a static-lifetime grapheme slice for a braille pattern byte. -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] - patterns: []u8, - /// RGB color per data column - col_colors: [][3]u8, - n_cols: usize, - chart_height: usize, - /// 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: [money_label_max_bytes]u8, - min_label_len: usize, - /// Date of first candle in the chart data - start_date: Date, - /// Date of last candle in the chart data - end_date: Date, - - pub fn maxLabel(self: *const BrailleChart) []const u8 { - return self.max_label[0..self.max_label_len]; - } - - pub fn minLabel(self: *const BrailleChart) []const u8 { - return self.min_label[0..self.min_label_len]; - } - - pub fn pattern(self: *const BrailleChart, row: usize, col: usize) u8 { - return self.patterns[row * self.n_cols + col]; - } - - /// Format a date as "MMM DD" for the braille chart x-axis. - /// The year context is already visible in the surrounding CLI/TUI interface. - /// Returns the number of bytes written. - pub fn fmtShortDate(date: Date, buf: *[7]u8) []const u8 { - const mon = Date.monthShort(date.month()); - const d = date.day(); - buf[0] = mon[0]; - buf[1] = mon[1]; - buf[2] = mon[2]; - buf[3] = ' '; - if (d >= 10) { - buf[4] = '0' + d / 10; - } else { - buf[4] = '0'; - } - buf[5] = '0' + d % 10; - return buf[0..6]; - } - - /// Format a date for the chart's x-axis at a granularity - /// appropriate for that individual date's recency. Two tiers, - /// per-date (driven by how far back the date is from the - /// chart's `end_date` reference, not by the chart's overall - /// span): - /// - within 720 days of `end_date` -> "DD MMM" (e.g., "08 May") - /// - older than 720 days -> "MMM YYYY" (e.g., "Jul 2014") - /// - /// On a 12-year chart, this typically yields a long-format - /// start label (`Jul 2014`) paired with a short-format end - /// label (`08 May`) - the start is far enough back that - /// year context is what matters; the end is recent enough - /// that day-of-month resolution is useful. - /// - /// The day-first ordering for the short form is intentional: - /// when a chart pairs `"08 May"` with `"Jul 2014"`, the first - /// character of each label cleanly disambiguates the format - /// at a glance - digit-first is a recent date, letter-first is - /// a distant date. Saves the eye from re-parsing every label. - /// - /// `buf` must be at least 8 bytes; the returned slice borrows - /// from it. - pub fn fmtAxisDate(self: *const BrailleChart, date: Date, buf: *[8]u8) []const u8 { - const age_days = self.end_date.days - date.days; - const mon = Date.monthShort(date.month()); - - if (age_days <= 720) { - // "DD MMM" - day-first so the leading character is a - // digit (visually distinct from the letter-first - // long form below). - return std.fmt.bufPrint(buf, "{d:0>2} {s}", .{ date.day(), mon }) catch buf[0..0]; - } - // "MMM YYYY" - for dates more than ~2 years before - // `end_date`. Day-of-month resolution stops being useful - // at this scale; full 4-digit year keeps the label - // unambiguous regardless of how far back the chart goes. - return std.fmt.bufPrint(buf, "{s} {d:0>4}", .{ mon, @as(u16, @intCast(date.year())) }) catch buf[0..0]; - } - - pub fn deinit(self: *BrailleChart, alloc: std.mem.Allocator) void { - alloc.free(self.patterns); - alloc.free(self.col_colors); - } -}; - -/// Compute braille sparkline chart data from candle close prices. -/// Uses Unicode braille characters (U+2800..U+28FF) for 2-wide x 4-tall dot matrix per cell. -/// Each terminal row provides 4 sub-rows of resolution; each column maps to one data point. -/// -/// Returns a BrailleChart with the pattern grid and per-column colors. -/// Caller must call deinit() when done (unless using an arena allocator). -pub fn computeBrailleChart( - alloc: std.mem.Allocator, - data: []const Candle, - chart_width: usize, - chart_height: usize, - positive_color: [3]u8, - negative_color: [3]u8, -) !BrailleChart { - if (data.len < 2) return error.InsufficientData; - - const dot_rows: usize = chart_height * 4; // vertical dot resolution - - // Find min/max chart-close prices (split-adjusted when available). - // See `Candle.chartClose` - using raw `close` here would render - // false cliffs at split dates. - var min_price: f64 = data[0].chartClose(); - var max_price: f64 = data[0].chartClose(); - for (data) |d| { - const cc = d.chartClose(); - if (cc < min_price) min_price = cc; - if (cc > max_price) max_price = cc; - } - if (max_price == min_price) max_price = min_price + 1.0; - const price_range = max_price - min_price; - - // Price labels - // SAFETY: every field of `result` is initialized below before - // it is read or returned. Treating it as `undefined` here is - // a deliberate "stack-allocate, then write each field" - // pattern - Zig requires the variable to exist before - // bufPrint can take a slice of one of its fields. - var result: BrailleChart = undefined; - const max_str = std.fmt.bufPrint(&result.max_label, "{f}", .{Money.from(max_price)}) catch ""; - result.max_label_len = max_str.len; - const min_str = std.fmt.bufPrint(&result.min_label, "{f}", .{Money.from(min_price)}) catch ""; - result.min_label_len = min_str.len; - - const n_cols = @min(data.len, chart_width); - result.n_cols = n_cols; - result.chart_height = chart_height; - result.start_date = data[0].date; - result.end_date = data[data.len - 1].date; - - // Map each data column to a dot-row position and color - const dot_y = try alloc.alloc(usize, n_cols); - defer alloc.free(dot_y); - - result.col_colors = try alloc.alloc([3]u8, n_cols); - errdefer alloc.free(result.col_colors); - - for (0..n_cols) |col| { - const data_idx_f: f64 = @as(f64, @floatFromInt(col)) * @as(f64, @floatFromInt(data.len - 1)) / @as(f64, @floatFromInt(n_cols - 1)); - const data_idx: usize = @min(@as(usize, @intFromFloat(data_idx_f)), data.len - 1); - const close = data[data_idx].chartClose(); - const norm = (close - min_price) / price_range; // 0 = min, 1 = max - // Inverted: 0 = top dot row, dot_rows-1 = bottom - const y_f = (1.0 - norm) * @as(f64, @floatFromInt(dot_rows - 1)); - dot_y[col] = @min(@as(usize, @intFromFloat(y_f)), dot_rows - 1); - // Color: gradient from negative (bottom) to positive (top) - result.col_colors[col] = lerpColor(negative_color, positive_color, norm); - } - - // Build the braille pattern grid - result.patterns = try alloc.alloc(u8, chart_height * n_cols); - @memset(result.patterns, 0); - - for (0..n_cols) |col| { - const target_y = dot_y[col]; - // Fill from target_y down to the bottom - for (target_y..dot_rows) |dy| { - const term_row = dy / 4; - const sub_row = dy % 4; - result.patterns[term_row * n_cols + col] |= braille_dots[sub_row][0]; - } - - // Interpolate between this point and the next for smooth contour - if (col + 1 < n_cols) { - const y0 = dot_y[col]; - const y1 = dot_y[col + 1]; - const min_y = @min(y0, y1); - const max_y = @max(y0, y1); - for (min_y..max_y + 1) |dy| { - const term_row = dy / 4; - const sub_row = dy % 4; - result.patterns[term_row * n_cols + col] |= braille_dots[sub_row][0]; - } - } - } - - return result; -} - -/// Write a braille chart to a writer with ANSI color escapes. -/// Used by the CLI for terminal output. Set `skip_date_axis` to -/// provide a custom x-axis (e.g. year labels instead of dates). -pub fn writeBrailleAnsi( - out: *std.Io.Writer, - chart: *const BrailleChart, - use_color: bool, - muted_color: [3]u8, - skip_date_axis: bool, -) !void { - var last_r: u8 = 0; - var last_g: u8 = 0; - var last_b: u8 = 0; - var color_active = false; - - for (0..chart.chart_height) |row| { - try out.writeAll(" "); // 2 leading spaces - - for (0..chart.n_cols) |col| { - const pat = chart.pattern(row, col); - if (use_color and pat != 0) { - const c = chart.col_colors[col]; - // Only emit color escape if color changed - if (!color_active or c[0] != last_r or c[1] != last_g or c[2] != last_b) { - try out.print("\x1b[38;2;{d};{d};{d}m", .{ c[0], c[1], c[2] }); - last_r = c[0]; - last_g = c[1]; - last_b = c[2]; - color_active = true; - } - } else if (color_active and pat == 0) { - try out.writeAll("\x1b[0m"); - color_active = false; - } - try out.writeAll(brailleGlyph(pat)); - } - - if (color_active) { - try out.writeAll("\x1b[0m"); - color_active = false; - } - - // Price label on first/last row - if (row == 0) { - if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] }); - try out.print(" {s}", .{chart.maxLabel()}); - if (use_color) try out.writeAll("\x1b[0m"); - } else if (row == chart.chart_height - 1) { - if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] }); - try out.print(" {s}", .{chart.minLabel()}); - if (use_color) try out.writeAll("\x1b[0m"); - } - try out.writeAll("\n"); - } - - // Date axis below chart - if (!skip_date_axis) { - var start_buf: [8]u8 = undefined; - var end_buf: [8]u8 = undefined; - const start_label = chart.fmtAxisDate(chart.start_date, &start_buf); - const end_label = chart.fmtAxisDate(chart.end_date, &end_buf); - - if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] }); - try out.writeAll(" "); // match leading indent - try out.writeAll(start_label); - const total_width = chart.n_cols; - if (total_width > start_label.len + end_label.len) { - const gap = total_width - start_label.len - end_label.len; - for (0..gap) |_| try out.writeAll(" "); - } - try out.writeAll(end_label); - if (use_color) try out.writeAll("\x1b[0m"); - try out.writeAll("\n"); - } -} - // ── ANSI color helpers (for CLI) ───────────────────────────── /// Determine whether to use ANSI color output. @@ -1531,151 +1209,6 @@ test "aggregateDripLots empty" { try std.testing.expect(agg.lt.isEmpty()); } -test "lerpColor" { - // t=0 returns first color - const c0 = lerpColor(.{ 0, 0, 0 }, .{ 255, 255, 255 }, 0.0); - try std.testing.expectEqual(@as(u8, 0), c0[0]); - try std.testing.expectEqual(@as(u8, 0), c0[1]); - // t=1 returns second color - const c1 = lerpColor(.{ 0, 0, 0 }, .{ 255, 255, 255 }, 1.0); - try std.testing.expectEqual(@as(u8, 255), c1[0]); - // t=0.5 returns midpoint - const c_mid = lerpColor(.{ 0, 0, 0 }, .{ 200, 100, 50 }, 0.5); - try std.testing.expectEqual(@as(u8, 100), c_mid[0]); - try std.testing.expectEqual(@as(u8, 50), c_mid[1]); - try std.testing.expectEqual(@as(u8, 25), c_mid[2]); -} - -test "brailleGlyph" { - // Pattern 0 = U+2800 (blank braille) - const blank = brailleGlyph(0); - try std.testing.expectEqual(@as(usize, 3), blank.len); - try std.testing.expectEqual(@as(u8, 0xE2), blank[0]); - try std.testing.expectEqual(@as(u8, 0xA0), blank[1]); - try std.testing.expectEqual(@as(u8, 0x80), blank[2]); - // Pattern 0xFF = U+28FF (full braille) - const full = brailleGlyph(0xFF); - try std.testing.expectEqual(@as(usize, 3), full.len); - try std.testing.expectEqual(@as(u8, 0xE2), full[0]); - try std.testing.expectEqual(@as(u8, 0xA3), full[1]); - try std.testing.expectEqual(@as(u8, 0xBF), full[2]); -} - -test "fmtShortDate" { - var buf: [7]u8 = undefined; - const jan15 = BrailleChart.fmtShortDate(Date.fromYmd(2024, 1, 15), &buf); - try std.testing.expectEqualStrings("Jan 15", jan15); - const dec01 = BrailleChart.fmtShortDate(Date.fromYmd(2024, 12, 1), &buf); - try std.testing.expectEqualStrings("Dec 01", dec01); - const jun09 = BrailleChart.fmtShortDate(Date.fromYmd(2026, 6, 9), &buf); - try std.testing.expectEqualStrings("Jun 09", jun09); -} - -test "computeBrailleChart" { - const alloc = std.testing.allocator; - // Build synthetic candle data: 20 candles, prices rising from 100 to 119 - var candles: [20]Candle = undefined; - for (0..20) |i| { - const price: f64 = 100.0 + @as(f64, @floatFromInt(i)); - 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); - try std.testing.expectEqual(@as(usize, 20), chart.n_cols); - try std.testing.expectEqual(@as(usize, 4), chart.chart_height); - try std.testing.expectEqual(@as(usize, 80), chart.patterns.len); // 4 * 20 - try std.testing.expectEqual(@as(usize, 20), chart.col_colors.len); - // Max/min labels should contain price info - try std.testing.expect(chart.maxLabel().len > 0); - try std.testing.expect(chart.minLabel().len > 0); -} - -test "computeBrailleChart insufficient data" { - const alloc = std.testing.allocator; - const candles = [_]Candle{ - .{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 }, - }; - const result = computeBrailleChart(alloc, &candles, 10, 4, .{ 0, 0, 0 }, .{ 255, 255, 255 }); - 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`. - // Build a synthetic 4-candle slice that mimics a 3:1 split: raw - // close drops 300 -> 100, but adj_close is constant at 100. The - // chart should see a flat line, not a cliff. - const alloc = std.testing.allocator; - const candles = [_]Candle{ - .{ .date = Date.fromYmd(2024, 3, 5), .open = 300, .high = 300, .low = 300, .close = 300, .adj_close = 100, .volume = 1000 }, - .{ .date = Date.fromYmd(2024, 3, 6), .open = 300, .high = 300, .low = 300, .close = 300, .adj_close = 100, .volume = 1000 }, - .{ .date = Date.fromYmd(2024, 3, 7), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 }, - .{ .date = Date.fromYmd(2024, 3, 8), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 }, - }; - var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 }); - defer chart.deinit(alloc); - // Min and max labels should reflect the adjusted price (~$100), - // not the raw close range (300 -> 100). The exact values vary - // because computeBrailleChart bumps max by $1 internally when - // min == max, but neither label should mention $300. - try std.testing.expect(std.mem.indexOf(u8, chart.maxLabel(), "300") == null); - try std.testing.expect(std.mem.indexOf(u8, chart.minLabel(), "300") == null); -} - test "fmtContractLine" { var buf: [128]u8 = undefined; const contract = OptionContract{ @@ -1868,62 +1401,6 @@ test "fmtTimeAgo: days" { try std.testing.expectEqualStrings("7d ago", fmtTimeAgo(&buf, 1_700_000_000, 1_700_000_000 + 7 * 86_400)); } -test "fmtAxisDate: span <=720d produces DD MMM" { - var br: BrailleChart = undefined; - br.start_date = Date.fromYmd(2026, 1, 1); - br.end_date = Date.fromYmd(2026, 5, 11); - var buf: [8]u8 = undefined; - const lbl = br.fmtAxisDate(Date.fromYmd(2026, 4, 27), &buf); - try std.testing.expectEqualStrings("27 Apr", lbl); -} - -test "fmtAxisDate: ~2y span (around the threshold) produces DD MMM" { - // 700 days from 2024-01-01 - still inside the threshold. - var br: BrailleChart = undefined; - br.start_date = Date.fromYmd(2024, 1, 1); - br.end_date = Date.fromYmd(2025, 12, 1); // 700 days - var buf: [8]u8 = undefined; - const lbl = br.fmtAxisDate(Date.fromYmd(2024, 1, 1), &buf); - try std.testing.expectEqualStrings("01 Jan", lbl); -} - -test "fmtAxisDate: long-history chart shows MMM YYYY for old start, DD MMM for recent end" { - // 12-year chart: start is way more than 720 days from end, - // so the start gets MMM YYYY. End is `end_date` itself - // (age 0), so it gets DD MMM. - var br: BrailleChart = undefined; - br.start_date = Date.fromYmd(2014, 7, 3); - br.end_date = Date.fromYmd(2026, 5, 11); - var buf: [8]u8 = undefined; - const start_lbl = br.fmtAxisDate(br.start_date, &buf); - try std.testing.expectEqualStrings("Jul 2014", start_lbl); - var buf2: [8]u8 = undefined; - const end_lbl = br.fmtAxisDate(br.end_date, &buf2); - try std.testing.expectEqualStrings("11 May", end_lbl); -} - -test "fmtAxisDate: boundary at exactly 720 days uses DD MMM" { - var br: BrailleChart = undefined; - br.start_date = Date.fromYmd(2025, 1, 1); - // 720 days later = 2026-12-22. - br.end_date = Date.fromYmd(2026, 12, 22); - var buf: [8]u8 = undefined; - // Date 720 days before end_date: still boundary-inclusive -> DD MMM. - const lbl = br.fmtAxisDate(Date.fromYmd(2025, 1, 1), &buf); - try std.testing.expectEqualStrings("01 Jan", lbl); -} - -test "fmtAxisDate: 721 days before end flips to MMM YYYY" { - var br: BrailleChart = undefined; - br.start_date = Date.fromYmd(2024, 12, 31); - // 721 days after 2024-12-31 = 2026-12-22. - br.end_date = Date.fromYmd(2026, 12, 22); - var buf: [8]u8 = undefined; - // Format the start (which is 721 days before end_date). - const lbl = br.fmtAxisDate(br.start_date, &buf); - try std.testing.expectEqualStrings("Dec 2024", lbl); -} - test "displayCols: ASCII bytes count as 1 col each" { try std.testing.expectEqual(@as(usize, 0), displayCols("")); try std.testing.expectEqual(@as(usize, 5), displayCols("hello")); diff --git a/src/tui.zig b/src/tui.zig index d3e0313..d03aa9d 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -1,7 +1,6 @@ const std = @import("std"); const vaxis = @import("vaxis"); const zfin = @import("root.zig"); -const fmt = @import("format.zig"); const Money = @import("Money.zig"); const cli = @import("commands/common.zig"); const stderr = @import("stderr.zig"); @@ -10,6 +9,7 @@ const tab_framework = @import("tui/tab_framework.zig"); const framework = @import("commands/framework.zig"); const theme = @import("tui/theme.zig"); const chart = @import("charts/chart.zig"); +const braille = @import("charts/braille.zig"); const input_buffer = @import("tui/input_buffer.zig"); pub const PortfolioData = @import("PortfolioData.zig"); @@ -2143,14 +2143,14 @@ pub const App = struct { pub fn renderBrailleToStyledLines(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine), data: []const zfin.Candle, th: theme.Theme) !void { // Local shadows the `chart` module import; use a shorter name for // the local BrailleChart handle. - var br = fmt.computeBrailleChart(arena, data, 60, 10, th.positive, th.negative) catch return; + var br = braille.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; + // for portfolios over $1M; see `braille.money_label_max_bytes`. + const label_cells: usize = 1 + braille.money_label_max_bytes; const row_cells: usize = 2 + br.n_cols + label_cells; const bg = th.bg; @@ -2170,7 +2170,7 @@ pub fn renderBrailleToStyledLines(arena: std.mem.Allocator, lines: *std.ArrayLis // Chart columns for (0..br.n_cols) |col| { const pattern = br.pattern(row, col); - graphemes[gpos] = fmt.brailleGlyph(pattern); + graphemes[gpos] = braille.brailleGlyph(pattern); if (pattern != 0) { styles[gpos] = .{ .fg = theme.Theme.vcolor(br.col_colors[col]), .bg = theme.Theme.vcolor(bg) }; } else { @@ -3088,7 +3088,7 @@ test "renderBrailleToStyledLines: full price label renders for portfolios over $ // 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`. + // budget. Buffer is now sized via `braille.money_label_max_bytes`. var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index 5eec477..f6237b1 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -28,6 +28,7 @@ const theme = @import("theme.zig"); const tui = @import("../tui.zig"); const projection_chart = @import("../charts/projection_chart.zig"); const forecast_chart = @import("../charts/forecast_chart.zig"); +const braille = @import("../charts/braille.zig"); const forecast = @import("../analytics/forecast_evaluation.zig"); const imported = @import("../data/imported_values.zig"); const milestones = @import("../analytics/milestones.zig"); @@ -1981,7 +1982,7 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style // Compute braille chart with wider dimensions const chart_width: usize = 80; const chart_height: usize = 12; - var br = fmt.computeBrailleChart(arena, candles, chart_width, chart_height, th.positive, th.negative) catch null; + var br = braille.computeBrailleChart(arena, candles, chart_width, chart_height, th.positive, th.negative) catch null; if (br) |*br_chart| { const bg = th.bg; @@ -1994,8 +1995,8 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style // 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; + // `braille.money_label_max_bytes`. + const proj_label_cells: usize = 1 + braille.money_label_max_bytes; const proj_row_cells: usize = 2 + br_chart.n_cols + proj_label_cells; for (0..br_chart.chart_height) |row| { @@ -2014,7 +2015,7 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style // Chart columns for (0..br_chart.n_cols) |col| { const pat = br_chart.pattern(row, col); - graphemes[gpos] = fmt.brailleGlyph(pat); + graphemes[gpos] = braille.brailleGlyph(pat); if (pat != 0) { styles[gpos] = .{ .fg = theme.Theme.vcolor(br_chart.col_colors[col]), .bg = bg_v }; } else {