567 lines
25 KiB
Zig
567 lines
25 KiB
Zig
const std = @import("std");
|
|
const vaxis = @import("vaxis");
|
|
const zfin = @import("../root.zig");
|
|
const fmt = @import("../format.zig");
|
|
const theme_mod = @import("theme.zig");
|
|
const chart_mod = @import("chart.zig");
|
|
const tui = @import("../tui.zig");
|
|
|
|
const App = tui.App;
|
|
const StyledLine = tui.StyledLine;
|
|
const glyph = tui.glyph;
|
|
|
|
// ── Rendering ─────────────────────────────────────────────────
|
|
|
|
/// Draw the quote tab content. Uses Kitty graphics for the chart when available,
|
|
/// falling back to braille sparkline otherwise.
|
|
pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
|
const arena = ctx.arena;
|
|
|
|
// Determine whether to use Kitty graphics
|
|
const use_kitty = switch (app.chart.config.mode) {
|
|
.braille => false,
|
|
.kitty => true,
|
|
.auto => if (app.vx_app) |va| va.vx.caps.kitty_graphics else false,
|
|
};
|
|
|
|
if (use_kitty and app.candles != null and app.candles.?.len >= 40) {
|
|
drawWithKittyChart(app, ctx, buf, width, height) catch {
|
|
// On any failure, fall back to braille
|
|
try app.drawStyledContent(arena, buf, width, height, try buildStyledLines(app, arena));
|
|
};
|
|
} else {
|
|
// Fallback to styled lines with braille chart
|
|
try app.drawStyledContent(arena, buf, width, height, try buildStyledLines(app, arena));
|
|
}
|
|
}
|
|
|
|
/// Draw quote tab using Kitty graphics protocol for the chart.
|
|
fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
|
const arena = ctx.arena;
|
|
const th = app.theme;
|
|
const c = app.candles orelse return;
|
|
|
|
// Build text header (symbol, price, change) — first few lines
|
|
var lines: std.ArrayList(StyledLine) = .empty;
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
// Symbol + price header
|
|
if (app.quote) |q| {
|
|
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2}", .{ app.symbol, q.close });
|
|
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
|
|
if (q.previous_close > 0) {
|
|
const change = q.close - q.previous_close;
|
|
const pct = (change / q.previous_close) * 100.0;
|
|
var chg_buf: [64]u8 = undefined;
|
|
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), .style = change_style });
|
|
}
|
|
} else if (c.len > 0) {
|
|
const last = c[c.len - 1];
|
|
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2} (close)", .{ app.symbol, last.close });
|
|
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
|
|
if (c.len >= 2) {
|
|
const prev_close = c[c.len - 2].close;
|
|
const change = last.close - prev_close;
|
|
const pct = (change / prev_close) * 100.0;
|
|
var chg_buf: [64]u8 = undefined;
|
|
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), .style = change_style });
|
|
}
|
|
}
|
|
|
|
// Timeframe selector line
|
|
{
|
|
var tf_buf: [80]u8 = undefined;
|
|
var tf_pos: usize = 0;
|
|
const prefix = " Chart: ";
|
|
@memcpy(tf_buf[tf_pos..][0..prefix.len], prefix);
|
|
tf_pos += prefix.len;
|
|
const timeframes = [_]chart_mod.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
|
|
for (timeframes) |tf| {
|
|
const lbl = tf.label();
|
|
if (tf == app.chart.timeframe) {
|
|
tf_buf[tf_pos] = '[';
|
|
tf_pos += 1;
|
|
@memcpy(tf_buf[tf_pos..][0..lbl.len], lbl);
|
|
tf_pos += lbl.len;
|
|
tf_buf[tf_pos] = ']';
|
|
tf_pos += 1;
|
|
} else {
|
|
tf_buf[tf_pos] = ' ';
|
|
tf_pos += 1;
|
|
@memcpy(tf_buf[tf_pos..][0..lbl.len], lbl);
|
|
tf_pos += lbl.len;
|
|
tf_buf[tf_pos] = ' ';
|
|
tf_pos += 1;
|
|
}
|
|
tf_buf[tf_pos] = ' ';
|
|
tf_pos += 1;
|
|
}
|
|
const hint = " ([ ] to change)";
|
|
@memcpy(tf_buf[tf_pos..][0..hint.len], hint);
|
|
tf_pos += hint.len;
|
|
app.chart.timeframe_row = lines.items.len; // track which row the timeframe line is on
|
|
try lines.append(arena, .{ .text = try arena.dupe(u8, tf_buf[0..tf_pos]), .style = th.mutedStyle() });
|
|
}
|
|
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
// Draw the text header
|
|
const header_lines = try lines.toOwnedSlice(arena);
|
|
try app.drawStyledContent(arena, buf, width, height, header_lines);
|
|
|
|
// Calculate chart area (below the header, leaving room for details below)
|
|
const header_rows: u16 = @intCast(@min(header_lines.len, height));
|
|
const detail_rows: u16 = 10; // reserve rows for quote details below chart
|
|
const chart_rows = height -| header_rows -| detail_rows;
|
|
if (chart_rows < 8) return; // not enough space
|
|
|
|
// Compute pixel dimensions from cell size
|
|
// cell_size may be 0 if terminal hasn't reported pixel dimensions yet
|
|
const cell_w: u32 = if (ctx.cell_size.width > 0) ctx.cell_size.width else 8;
|
|
const cell_h: u32 = if (ctx.cell_size.height > 0) ctx.cell_size.height else 16;
|
|
const label_cols: u16 = 10; // columns reserved for axis labels on the right
|
|
const chart_cols = width -| 2 -| label_cols; // 1 col left margin + label area on right
|
|
if (chart_cols == 0) return;
|
|
const px_w: u32 = @as(u32, chart_cols) * cell_w;
|
|
const px_h: u32 = @as(u32, chart_rows) * cell_h;
|
|
|
|
if (px_w < 100 or px_h < 100) return;
|
|
// Apply resolution cap from chart config
|
|
const capped_w = @min(px_w, app.chart.config.max_width);
|
|
const capped_h = @min(px_h, app.chart.config.max_height);
|
|
|
|
// Check if we need to re-render the chart image
|
|
const symbol_changed = app.chart.symbol_len != app.symbol.len or
|
|
!std.mem.eql(u8, app.chart.symbol[0..app.chart.symbol_len], app.symbol);
|
|
const tf_changed = app.chart.timeframe_rendered == null or app.chart.timeframe_rendered.? != app.chart.timeframe;
|
|
|
|
if (app.chart.dirty or symbol_changed or tf_changed) {
|
|
// Free old image
|
|
if (app.chart.image_id) |old_id| {
|
|
if (app.vx_app) |va| {
|
|
va.vx.freeImage(va.tty.writer(), old_id);
|
|
}
|
|
app.chart.image_id = null;
|
|
}
|
|
|
|
// Render and transmit — use the app's main allocator, NOT the arena,
|
|
// because z2d allocates large pixel buffers that would bloat the arena.
|
|
if (app.vx_app) |va| {
|
|
const chart_result = chart_mod.renderChart(
|
|
app.allocator,
|
|
c,
|
|
app.chart.timeframe,
|
|
capped_w,
|
|
capped_h,
|
|
th,
|
|
) catch |err| {
|
|
app.chart.dirty = false;
|
|
var err_buf: [128]u8 = undefined;
|
|
const msg = std.fmt.bufPrint(&err_buf, "Chart render failed: {s}", .{@errorName(err)}) catch "Chart render failed";
|
|
app.setStatus(msg);
|
|
return;
|
|
};
|
|
defer app.allocator.free(chart_result.rgb_data);
|
|
|
|
// Base64-encode and transmit raw RGB data directly via Kitty protocol.
|
|
// This avoids the PNG encode → file write → file read → PNG decode roundtrip.
|
|
const base64_enc = std.base64.standard.Encoder;
|
|
const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch {
|
|
app.chart.dirty = false;
|
|
app.setStatus("Chart: base64 alloc failed");
|
|
return;
|
|
};
|
|
defer app.allocator.free(b64_buf);
|
|
const encoded = base64_enc.encode(b64_buf, chart_result.rgb_data);
|
|
|
|
const img = va.vx.transmitPreEncodedImage(
|
|
va.tty.writer(),
|
|
encoded,
|
|
chart_result.width,
|
|
chart_result.height,
|
|
.rgb,
|
|
) catch |err| {
|
|
app.chart.dirty = false;
|
|
var err_buf: [128]u8 = undefined;
|
|
const msg = std.fmt.bufPrint(&err_buf, "Image transmit failed: {s}", .{@errorName(err)}) catch "Image transmit failed";
|
|
app.setStatus(msg);
|
|
return;
|
|
};
|
|
|
|
app.chart.image_id = img.id;
|
|
app.chart.image_width = @intCast(chart_cols);
|
|
app.chart.image_height = chart_rows;
|
|
|
|
// Track what we rendered
|
|
const sym_len = @min(app.symbol.len, 16);
|
|
@memcpy(app.chart.symbol[0..sym_len], app.symbol[0..sym_len]);
|
|
app.chart.symbol_len = sym_len;
|
|
app.chart.timeframe_rendered = app.chart.timeframe;
|
|
app.chart.price_min = chart_result.price_min;
|
|
app.chart.price_max = chart_result.price_max;
|
|
app.chart.rsi_latest = chart_result.rsi_latest;
|
|
app.chart.dirty = false;
|
|
}
|
|
}
|
|
|
|
// Place the image in the cell buffer
|
|
if (app.chart.image_id) |img_id| {
|
|
// Place image at the first cell of the chart area
|
|
const chart_row_start: usize = header_rows;
|
|
const chart_col_start: usize = 1; // 1 col left margin
|
|
const buf_idx = chart_row_start * @as(usize, width) + chart_col_start;
|
|
if (buf_idx < buf.len) {
|
|
buf[buf_idx] = .{
|
|
.char = .{ .grapheme = " " },
|
|
.style = th.contentStyle(),
|
|
.image = .{
|
|
.img_id = img_id,
|
|
.options = .{
|
|
.size = .{
|
|
.rows = app.chart.image_height,
|
|
.cols = app.chart.image_width,
|
|
},
|
|
.scale = .contain,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
// ── Axis labels (terminal text in the right margin) ───────────
|
|
// The chart image uses layout fractions: price=72%, gap=8%, RSI=20%
|
|
// Map these to terminal rows to position labels.
|
|
const img_rows = app.chart.image_height;
|
|
const label_col: usize = @as(usize, chart_col_start) + @as(usize, app.chart.image_width) + 1;
|
|
const label_style = th.mutedStyle();
|
|
|
|
if (label_col + 8 <= width and img_rows >= 4 and app.chart.price_max > app.chart.price_min) {
|
|
// Price axis labels — evenly spaced across the price panel (top 72%)
|
|
const price_panel_rows = @as(f64, @floatFromInt(img_rows)) * 0.72;
|
|
const n_price_labels: usize = 5;
|
|
for (0..n_price_labels) |i| {
|
|
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_price_labels - 1));
|
|
const price_val = app.chart.price_max - frac * (app.chart.price_max - app.chart.price_min);
|
|
const row_f = @as(f64, @floatFromInt(chart_row_start)) + frac * price_panel_rows;
|
|
const row: usize = @intFromFloat(@round(row_f));
|
|
if (row >= height) continue;
|
|
|
|
var lbl_buf: [16]u8 = undefined;
|
|
const lbl = fmt.fmtMoneyAbs(&lbl_buf, price_val);
|
|
const start_idx = row * @as(usize, width) + label_col;
|
|
for (lbl, 0..) |ch, ci| {
|
|
const idx = start_idx + ci;
|
|
if (idx < buf.len and label_col + ci < width) {
|
|
buf[idx] = .{
|
|
.char = .{ .grapheme = glyph(ch) },
|
|
.style = label_style,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// RSI axis labels — positioned within the RSI panel (bottom 20%, after 80% offset)
|
|
const rsi_panel_start_f = @as(f64, @floatFromInt(img_rows)) * 0.80;
|
|
const rsi_panel_h = @as(f64, @floatFromInt(img_rows)) * 0.20;
|
|
const rsi_labels = [_]struct { val: f64, label: []const u8 }{
|
|
.{ .val = 70, .label = "70" },
|
|
.{ .val = 50, .label = "50" },
|
|
.{ .val = 30, .label = "30" },
|
|
};
|
|
for (rsi_labels) |rl| {
|
|
// RSI maps 0-100 top-to-bottom within the RSI panel
|
|
const rsi_frac = 1.0 - (rl.val / 100.0);
|
|
const row_f = @as(f64, @floatFromInt(chart_row_start)) + rsi_panel_start_f + rsi_frac * rsi_panel_h;
|
|
const row: usize = @intFromFloat(@round(row_f));
|
|
if (row >= height) continue;
|
|
|
|
const start_idx = row * @as(usize, width) + label_col;
|
|
for (rl.label, 0..) |ch, ci| {
|
|
const idx = start_idx + ci;
|
|
if (idx < buf.len and label_col + ci < width) {
|
|
buf[idx] = .{
|
|
.char = .{ .grapheme = glyph(ch) },
|
|
.style = label_style,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Render quote details below the chart image as styled text
|
|
const detail_start_row = header_rows + app.chart.image_height;
|
|
if (detail_start_row + 8 < height) {
|
|
var detail_lines: std.ArrayList(StyledLine) = .empty;
|
|
try detail_lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
const latest = c[c.len - 1];
|
|
const quote_data = app.quote;
|
|
const price = if (quote_data) |q| q.close else latest.close;
|
|
const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0);
|
|
|
|
try buildDetailColumns(app, arena, &detail_lines, latest, quote_data, price, prev_close);
|
|
|
|
// Write detail lines into the buffer below the image
|
|
const detail_buf_start = detail_start_row * @as(usize, width);
|
|
const remaining_height = height - @as(u16, @intCast(detail_start_row));
|
|
const detail_slice = try detail_lines.toOwnedSlice(arena);
|
|
if (detail_buf_start < buf.len) {
|
|
try app.drawStyledContent(arena, buf[detail_buf_start..], width, remaining_height, detail_slice);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
|
const th = app.theme;
|
|
var lines: std.ArrayList(StyledLine) = .empty;
|
|
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
if (app.symbol.len == 0) {
|
|
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
|
|
return lines.toOwnedSlice(arena);
|
|
}
|
|
|
|
var ago_buf: [16]u8 = undefined;
|
|
if (app.quote != null and app.quote_timestamp > 0) {
|
|
const ago_str = fmt.fmtTimeAgo(&ago_buf, app.quote_timestamp);
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (live, ~15 min delay, refreshed {s})", .{ app.symbol, ago_str }), .style = th.headerStyle() });
|
|
} else if (app.candle_last_date) |d| {
|
|
var cdate_buf: [10]u8 = undefined;
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (as of close on {s})", .{ app.symbol, d.format(&cdate_buf) }), .style = th.headerStyle() });
|
|
} else {
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s}", .{app.symbol}), .style = th.headerStyle() });
|
|
}
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
if (app.candles == null and !app.perf_loaded) app.loadPerfData();
|
|
|
|
// Use stored real-time quote if available (fetched on manual refresh)
|
|
const quote_data = app.quote;
|
|
|
|
const c = app.candles orelse {
|
|
if (quote_data) |q| {
|
|
// No candle data but have a quote - show it
|
|
var qclose_buf: [24]u8 = undefined;
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {s}", .{fmt.fmtMoneyAbs(&qclose_buf, q.close)}), .style = th.contentStyle() });
|
|
{
|
|
var chg_buf: [64]u8 = undefined;
|
|
const change_style = if (q.change >= 0) th.positiveStyle() else th.negativeStyle();
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, q.change, q.percent_change)}), .style = change_style });
|
|
}
|
|
return lines.toOwnedSlice(arena);
|
|
}
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin perf {s}", .{app.symbol}), .style = th.mutedStyle() });
|
|
return lines.toOwnedSlice(arena);
|
|
};
|
|
if (c.len == 0) {
|
|
try lines.append(arena, .{ .text = " No candle data.", .style = th.mutedStyle() });
|
|
return lines.toOwnedSlice(arena);
|
|
}
|
|
|
|
// Use real-time quote price if available, otherwise latest candle
|
|
const price = if (quote_data) |q| q.close else c[c.len - 1].close;
|
|
const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0);
|
|
const latest = c[c.len - 1];
|
|
|
|
try buildDetailColumns(app, arena, &lines, latest, quote_data, price, prev_close);
|
|
|
|
// Braille sparkline chart of recent 60 trading days
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
const chart_days: usize = @min(c.len, 60);
|
|
const chart_data = c[c.len - chart_days ..];
|
|
try tui.renderBrailleToStyledLines(arena, &lines, chart_data, th);
|
|
|
|
// Recent history table
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
try lines.append(arena, .{ .text = " Recent History:", .style = th.headerStyle() });
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {s:>10} {s:>10} {s:>10} {s:>10} {s:>12}", .{ "Date", "Open", "High", "Low", "Close", "Volume" }), .style = th.mutedStyle() });
|
|
|
|
const start_idx = if (c.len > 20) c.len - 20 else 0;
|
|
for (c[start_idx..]) |candle| {
|
|
var row_buf: [128]u8 = undefined;
|
|
const day_change = if (candle.close >= candle.open) th.positiveStyle() else th.negativeStyle();
|
|
try lines.append(arena, .{ .text = try arena.dupe(u8, fmt.fmtCandleRow(&row_buf, candle)), .style = day_change });
|
|
}
|
|
|
|
return lines.toOwnedSlice(arena);
|
|
}
|
|
|
|
// ── Quote detail columns (price/OHLCV | ETF stats | sectors | holdings) ──
|
|
|
|
const Column = struct {
|
|
texts: std.ArrayList([]const u8),
|
|
styles: std.ArrayList(vaxis.Style),
|
|
width: usize, // fixed column width for padding
|
|
|
|
fn init() Column {
|
|
return .{
|
|
.texts = .empty,
|
|
.styles = .empty,
|
|
.width = 0,
|
|
};
|
|
}
|
|
|
|
fn add(app: *Column, arena: std.mem.Allocator, text: []const u8, style: vaxis.Style) !void {
|
|
try app.texts.append(arena, text);
|
|
try app.styles.append(arena, style);
|
|
}
|
|
|
|
fn len(app: *const Column) usize {
|
|
return app.texts.items.len;
|
|
}
|
|
};
|
|
|
|
fn buildDetailColumns(
|
|
app: *App,
|
|
arena: std.mem.Allocator,
|
|
lines: *std.ArrayList(StyledLine),
|
|
latest: zfin.Candle,
|
|
quote_data: ?zfin.Quote,
|
|
price: f64,
|
|
prev_close: f64,
|
|
) !void {
|
|
const th = app.theme;
|
|
var date_buf: [10]u8 = undefined;
|
|
var close_buf: [24]u8 = undefined;
|
|
var vol_buf: [32]u8 = undefined;
|
|
|
|
// Column 1: Price/OHLCV
|
|
var col1 = Column.init();
|
|
col1.width = 30;
|
|
try col1.add(arena, try std.fmt.allocPrint(arena, " Date: {s}", .{latest.date.format(&date_buf)}), th.contentStyle());
|
|
try col1.add(arena, try std.fmt.allocPrint(arena, " Price: {s}", .{fmt.fmtMoneyAbs(&close_buf, price)}), th.contentStyle());
|
|
try col1.add(arena, try std.fmt.allocPrint(arena, " Open: ${d:.2}", .{if (quote_data) |q| q.open else latest.open}), th.mutedStyle());
|
|
try col1.add(arena, try std.fmt.allocPrint(arena, " High: ${d:.2}", .{if (quote_data) |q| q.high else latest.high}), th.mutedStyle());
|
|
try col1.add(arena, try std.fmt.allocPrint(arena, " Low: ${d:.2}", .{if (quote_data) |q| q.low else latest.low}), th.mutedStyle());
|
|
try col1.add(arena, try std.fmt.allocPrint(arena, " Volume: {s}", .{fmt.fmtIntCommas(&vol_buf, if (quote_data) |q| q.volume else latest.volume)}), th.mutedStyle());
|
|
if (prev_close > 0) {
|
|
const change = price - prev_close;
|
|
const pct = (change / prev_close) * 100.0;
|
|
var chg_buf: [64]u8 = undefined;
|
|
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
|
|
try col1.add(arena, try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), change_style);
|
|
}
|
|
|
|
// Columns 2-4: ETF profile (only for actual ETFs)
|
|
var col2 = Column.init(); // ETF stats
|
|
col2.width = 22;
|
|
var col3 = Column.init(); // Sectors
|
|
col3.width = 26;
|
|
var col4 = Column.init(); // Top holdings
|
|
col4.width = 30;
|
|
|
|
if (app.etf_profile) |profile| {
|
|
// Col 2: ETF key stats
|
|
try col2.add(arena, "ETF Profile", th.headerStyle());
|
|
if (profile.expense_ratio) |er| {
|
|
try col2.add(arena, try std.fmt.allocPrint(arena, " Expense: {d:.2}%", .{er * 100.0}), th.contentStyle());
|
|
}
|
|
if (profile.net_assets) |na| {
|
|
try col2.add(arena, try std.fmt.allocPrint(arena, " Assets: ${s}", .{std.mem.trimRight(u8, &fmt.fmtLargeNum(na), &.{' '})}), th.contentStyle());
|
|
}
|
|
if (profile.dividend_yield) |dy| {
|
|
try col2.add(arena, try std.fmt.allocPrint(arena, " Yield: {d:.2}%", .{dy * 100.0}), th.contentStyle());
|
|
}
|
|
if (profile.total_holdings) |th_val| {
|
|
try col2.add(arena, try std.fmt.allocPrint(arena, " Holdings: {d}", .{th_val}), th.mutedStyle());
|
|
}
|
|
|
|
// Col 3: Sector allocation
|
|
if (profile.sectors) |sectors| {
|
|
if (sectors.len > 0) {
|
|
try col3.add(arena, "Sectors", th.headerStyle());
|
|
const show = @min(sectors.len, 7);
|
|
for (sectors[0..show]) |sec| {
|
|
var title_buf: [64]u8 = undefined;
|
|
const title_name = fmt.toTitleCase(&title_buf, sec.name);
|
|
const name = if (title_name.len > 20) title_name[0..20] else title_name;
|
|
try col3.add(arena, try std.fmt.allocPrint(arena, " {d:>5.1}% {s}", .{ sec.weight * 100.0, name }), th.contentStyle());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Col 4: Top holdings
|
|
if (profile.holdings) |holdings| {
|
|
if (holdings.len > 0) {
|
|
try col4.add(arena, "Top Holdings", th.headerStyle());
|
|
const show = @min(holdings.len, 7);
|
|
for (holdings[0..show]) |h| {
|
|
const sym_str = h.symbol orelse "--";
|
|
try col4.add(arena, try std.fmt.allocPrint(arena, " {s:>6} {d:>5.1}%", .{ sym_str, h.weight * 100.0 }), th.contentStyle());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Merge all columns into grapheme-based StyledLines
|
|
const gap: usize = 3;
|
|
const bg_style = vaxis.Style{ .fg = theme_mod.Theme.vcolor(th.text), .bg = theme_mod.Theme.vcolor(th.bg) };
|
|
const cols = [_]*const Column{ &col1, &col2, &col3, &col4 };
|
|
var max_rows: usize = 0;
|
|
for (cols) |col| max_rows = @max(max_rows, col.len());
|
|
|
|
// Total max width for allocation
|
|
const max_width = col1.width + gap + col2.width + gap + col3.width + gap + col4.width + 4;
|
|
|
|
for (0..max_rows) |ri| {
|
|
const graphemes = try arena.alloc([]const u8, max_width);
|
|
const col_styles = try arena.alloc(vaxis.Style, max_width);
|
|
var pos: usize = 0;
|
|
|
|
for (cols, 0..) |col, ci| {
|
|
if (ci > 0 and col.len() == 0) continue; // skip empty columns entirely
|
|
if (ci > 0) {
|
|
// Gap between columns
|
|
for (0..gap) |_| {
|
|
if (pos < max_width) {
|
|
graphemes[pos] = " ";
|
|
col_styles[pos] = bg_style;
|
|
pos += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (ri < col.len()) {
|
|
const text = col.texts.items[ri];
|
|
const style = col.styles.items[ri];
|
|
// Write text characters
|
|
for (0..@min(text.len, col.width)) |ci2| {
|
|
if (pos < max_width) {
|
|
graphemes[pos] = glyph(text[ci2]);
|
|
col_styles[pos] = style;
|
|
pos += 1;
|
|
}
|
|
}
|
|
// Pad to column width
|
|
if (text.len < col.width) {
|
|
for (0..col.width - text.len) |_| {
|
|
if (pos < max_width) {
|
|
graphemes[pos] = " ";
|
|
col_styles[pos] = bg_style;
|
|
pos += 1;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Empty row in this column - pad full width
|
|
for (0..col.width) |_| {
|
|
if (pos < max_width) {
|
|
graphemes[pos] = " ";
|
|
col_styles[pos] = bg_style;
|
|
pos += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
try lines.append(arena, .{
|
|
.text = "",
|
|
.style = bg_style,
|
|
.graphemes = graphemes[0..pos],
|
|
.cell_styles = col_styles[0..pos],
|
|
});
|
|
}
|
|
}
|