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], }); } }