introduce chart state to hold the 13 chart state fields

This commit is contained in:
Emil Lerch 2026-03-19 09:57:54 -07:00
parent 21a45d5309
commit 863111d801
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 64 additions and 60 deletions

View file

@ -192,6 +192,22 @@ pub const OptionsRow = struct {
contract: ?zfin.OptionContract = null, contract: ?zfin.OptionContract = null,
}; };
pub const ChartState = struct {
config: chart_mod.ChartConfig = .{},
timeframe: chart_mod.Timeframe = .@"1Y",
image_id: ?u32 = null, // currently transmitted Kitty image ID
image_width: u16 = 0, // image width in cells
image_height: u16 = 0, // image height in cells
symbol: [16]u8 = undefined, // symbol the chart was rendered for
symbol_len: usize = 0,
timeframe_rendered: ?chart_mod.Timeframe = null, // timeframe the chart was rendered for
timeframe_row: ?usize = null, // screen row of the timeframe selector (for mouse clicks)
dirty: bool = true, // needs re-render
price_min: f64 = 0,
price_max: f64 = 0,
rsi_latest: ?f64 = null,
};
/// Root widget for the interactive TUI. Implements the vaxis `vxfw.Widget` /// Root widget for the interactive TUI. Implements the vaxis `vxfw.Widget`
/// interface via `widget()`, which wires `typeErasedEventHandler` and /// interface via `widget()`, which wires `typeErasedEventHandler` and
/// `typeErasedDrawFn` as callbacks. Passed to `vxfw.App.run()` as the /// `typeErasedDrawFn` as callbacks. Passed to `vxfw.App.run()` as the
@ -293,20 +309,8 @@ pub const App = struct {
account_map: ?zfin.analysis.AccountMap = null, account_map: ?zfin.analysis.AccountMap = null,
// Chart state (Kitty graphics) // Chart state (Kitty graphics)
chart_config: chart_mod.ChartConfig = .{}, chart: ChartState = .{},
vx_app: ?*vaxis.vxfw.App = null, // set during run(), for Kitty graphics access vx_app: ?*vaxis.vxfw.App = null, // set during run(), for Kitty graphics access
chart_timeframe: chart_mod.Timeframe = .@"1Y",
chart_image_id: ?u32 = null, // currently transmitted Kitty image ID
chart_image_width: u16 = 0, // image width in cells
chart_image_height: u16 = 0, // image height in cells
chart_symbol: [16]u8 = undefined, // symbol the chart was rendered for
chart_symbol_len: usize = 0,
chart_timeframe_rendered: ?chart_mod.Timeframe = null, // timeframe the chart was rendered for
chart_timeframe_row: ?usize = null, // screen row of the timeframe selector (for mouse clicks)
chart_dirty: bool = true, // needs re-render
chart_price_min: f64 = 0,
chart_price_max: f64 = 0,
chart_rsi_latest: ?f64 = null,
pub fn widget(self: *App) vaxis.vxfw.Widget { pub fn widget(self: *App) vaxis.vxfw.Widget {
return .{ return .{
@ -420,7 +424,7 @@ pub const App = struct {
} }
// Quote tab: click on timeframe selector to switch timeframes // Quote tab: click on timeframe selector to switch timeframes
if (self.active_tab == .quote and mouse.row > 0) { if (self.active_tab == .quote and mouse.row > 0) {
if (self.chart_timeframe_row) |tf_row| { if (self.chart.timeframe_row) |tf_row| {
const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset; const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset;
if (content_row == tf_row) { if (content_row == tf_row) {
// " Chart: [6M] YTD 1Y 3Y 5Y ([ ] to change)" // " Chart: [6M] YTD 1Y 3Y 5Y ([ ] to change)"
@ -434,8 +438,8 @@ pub const App = struct {
const lbl_len = tf.label().len; const lbl_len = tf.label().len;
const slot_width = lbl_len + 2 + 1; // [XX] + space or XX + space const slot_width = lbl_len + 2 + 1; // [XX] + space or XX + space
if (col >= x and col < x + slot_width) { if (col >= x and col < x + slot_width) {
if (tf != self.chart_timeframe) { if (tf != self.chart.timeframe) {
self.chart_timeframe = tf; self.chart.timeframe = tf;
self.setStatus(tf.label()); self.setStatus(tf.label());
return ctx.consumeAndRedraw(); return ctx.consumeAndRedraw();
} }
@ -686,17 +690,17 @@ pub const App = struct {
}, },
.chart_timeframe_next => { .chart_timeframe_next => {
if (self.active_tab == .quote) { if (self.active_tab == .quote) {
self.chart_timeframe = self.chart_timeframe.next(); self.chart.timeframe = self.chart.timeframe.next();
self.chart_dirty = true; self.chart.dirty = true;
self.setStatus(self.chart_timeframe.label()); self.setStatus(self.chart.timeframe.label());
return ctx.consumeAndRedraw(); return ctx.consumeAndRedraw();
} }
}, },
.chart_timeframe_prev => { .chart_timeframe_prev => {
if (self.active_tab == .quote) { if (self.active_tab == .quote) {
self.chart_timeframe = self.chart_timeframe.prev(); self.chart.timeframe = self.chart.timeframe.prev();
self.chart_dirty = true; self.chart.dirty = true;
self.setStatus(self.chart_timeframe.label()); self.setStatus(self.chart.timeframe.label());
return ctx.consumeAndRedraw(); return ctx.consumeAndRedraw();
} }
}, },
@ -939,7 +943,7 @@ pub const App = struct {
self.trailing_me_total = null; self.trailing_me_total = null;
self.risk_metrics = null; self.risk_metrics = null;
self.scroll_offset = 0; self.scroll_offset = 0;
self.chart_dirty = true; self.chart.dirty = true;
} }
fn refreshCurrentTab(self: *App) void { fn refreshCurrentTab(self: *App) void {
@ -968,7 +972,7 @@ pub const App = struct {
self.perf_loaded = false; self.perf_loaded = false;
self.freeCandles(); self.freeCandles();
self.freeDividends(); self.freeDividends();
self.chart_dirty = true; self.chart.dirty = true;
}, },
.earnings => { .earnings => {
self.earnings_loaded = false; self.earnings_loaded = false;
@ -1827,7 +1831,7 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []co
.portfolio_path = portfolio_path, .portfolio_path = portfolio_path,
.symbol = symbol, .symbol = symbol,
.has_explicit_symbol = has_explicit_symbol, .has_explicit_symbol = has_explicit_symbol,
.chart_config = chart_config, .chart = .{ .config = chart_config },
}; };
if (portfolio_path) |path| { if (portfolio_path) |path| {
@ -1929,9 +1933,9 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []co
defer app_inst.vx_app = null; defer app_inst.vx_app = null;
defer { defer {
// Free any chart image before vaxis is torn down // Free any chart image before vaxis is torn down
if (app_inst.chart_image_id) |id| { if (app_inst.chart.image_id) |id| {
vx_app.vx.freeImage(vx_app.tty.writer(), id); vx_app.vx.freeImage(vx_app.tty.writer(), id);
app_inst.chart_image_id = null; app_inst.chart.image_id = null;
} }
} }
try vx_app.run(app_inst.widget(), .{}); try vx_app.run(app_inst.widget(), .{});

View file

@ -18,7 +18,7 @@ pub fn drawContent(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, w
const arena = ctx.arena; const arena = ctx.arena;
// Determine whether to use Kitty graphics // Determine whether to use Kitty graphics
const use_kitty = switch (self.chart_config.mode) { const use_kitty = switch (self.chart.config.mode) {
.braille => false, .braille => false,
.kitty => true, .kitty => true,
.auto => if (self.vx_app) |va| va.vx.caps.kitty_graphics else false, .auto => if (self.vx_app) |va| va.vx.caps.kitty_graphics else false,
@ -80,7 +80,7 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell
const timeframes = [_]chart_mod.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" }; const timeframes = [_]chart_mod.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
for (timeframes) |tf| { for (timeframes) |tf| {
const lbl = tf.label(); const lbl = tf.label();
if (tf == self.chart_timeframe) { if (tf == self.chart.timeframe) {
tf_buf[tf_pos] = '['; tf_buf[tf_pos] = '[';
tf_pos += 1; tf_pos += 1;
@memcpy(tf_buf[tf_pos..][0..lbl.len], lbl); @memcpy(tf_buf[tf_pos..][0..lbl.len], lbl);
@ -101,7 +101,7 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell
const hint = " ([ ] to change)"; const hint = " ([ ] to change)";
@memcpy(tf_buf[tf_pos..][0..hint.len], hint); @memcpy(tf_buf[tf_pos..][0..hint.len], hint);
tf_pos += hint.len; tf_pos += hint.len;
self.chart_timeframe_row = lines.items.len; // track which row the timeframe line is on self.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 = try arena.dupe(u8, tf_buf[0..tf_pos]), .style = th.mutedStyle() });
} }
@ -129,21 +129,21 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell
if (px_w < 100 or px_h < 100) return; if (px_w < 100 or px_h < 100) return;
// Apply resolution cap from chart config // Apply resolution cap from chart config
const capped_w = @min(px_w, self.chart_config.max_width); const capped_w = @min(px_w, self.chart.config.max_width);
const capped_h = @min(px_h, self.chart_config.max_height); const capped_h = @min(px_h, self.chart.config.max_height);
// Check if we need to re-render the chart image // Check if we need to re-render the chart image
const symbol_changed = self.chart_symbol_len != self.symbol.len or const symbol_changed = self.chart.symbol_len != self.symbol.len or
!std.mem.eql(u8, self.chart_symbol[0..self.chart_symbol_len], self.symbol); !std.mem.eql(u8, self.chart.symbol[0..self.chart.symbol_len], self.symbol);
const tf_changed = self.chart_timeframe_rendered == null or self.chart_timeframe_rendered.? != self.chart_timeframe; const tf_changed = self.chart.timeframe_rendered == null or self.chart.timeframe_rendered.? != self.chart.timeframe;
if (self.chart_dirty or symbol_changed or tf_changed) { if (self.chart.dirty or symbol_changed or tf_changed) {
// Free old image // Free old image
if (self.chart_image_id) |old_id| { if (self.chart.image_id) |old_id| {
if (self.vx_app) |va| { if (self.vx_app) |va| {
va.vx.freeImage(va.tty.writer(), old_id); va.vx.freeImage(va.tty.writer(), old_id);
} }
self.chart_image_id = null; self.chart.image_id = null;
} }
// Render and transmit use the app's main allocator, NOT the arena, // Render and transmit use the app's main allocator, NOT the arena,
@ -152,12 +152,12 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell
const chart_result = chart_mod.renderChart( const chart_result = chart_mod.renderChart(
self.allocator, self.allocator,
c, c,
self.chart_timeframe, self.chart.timeframe,
capped_w, capped_w,
capped_h, capped_h,
th, th,
) catch |err| { ) catch |err| {
self.chart_dirty = false; self.chart.dirty = false;
var err_buf: [128]u8 = undefined; var err_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&err_buf, "Chart render failed: {s}", .{@errorName(err)}) catch "Chart render failed"; const msg = std.fmt.bufPrint(&err_buf, "Chart render failed: {s}", .{@errorName(err)}) catch "Chart render failed";
self.setStatus(msg); self.setStatus(msg);
@ -169,7 +169,7 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell
// This avoids the PNG encode file write file read PNG decode roundtrip. // This avoids the PNG encode file write file read PNG decode roundtrip.
const base64_enc = std.base64.standard.Encoder; const base64_enc = std.base64.standard.Encoder;
const b64_buf = self.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch { const b64_buf = self.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch {
self.chart_dirty = false; self.chart.dirty = false;
self.setStatus("Chart: base64 alloc failed"); self.setStatus("Chart: base64 alloc failed");
return; return;
}; };
@ -183,31 +183,31 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell
chart_result.height, chart_result.height,
.rgb, .rgb,
) catch |err| { ) catch |err| {
self.chart_dirty = false; self.chart.dirty = false;
var err_buf: [128]u8 = undefined; var err_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&err_buf, "Image transmit failed: {s}", .{@errorName(err)}) catch "Image transmit failed"; const msg = std.fmt.bufPrint(&err_buf, "Image transmit failed: {s}", .{@errorName(err)}) catch "Image transmit failed";
self.setStatus(msg); self.setStatus(msg);
return; return;
}; };
self.chart_image_id = img.id; self.chart.image_id = img.id;
self.chart_image_width = @intCast(chart_cols); self.chart.image_width = @intCast(chart_cols);
self.chart_image_height = chart_rows; self.chart.image_height = chart_rows;
// Track what we rendered // Track what we rendered
const sym_len = @min(self.symbol.len, 16); const sym_len = @min(self.symbol.len, 16);
@memcpy(self.chart_symbol[0..sym_len], self.symbol[0..sym_len]); @memcpy(self.chart.symbol[0..sym_len], self.symbol[0..sym_len]);
self.chart_symbol_len = sym_len; self.chart.symbol_len = sym_len;
self.chart_timeframe_rendered = self.chart_timeframe; self.chart.timeframe_rendered = self.chart.timeframe;
self.chart_price_min = chart_result.price_min; self.chart.price_min = chart_result.price_min;
self.chart_price_max = chart_result.price_max; self.chart.price_max = chart_result.price_max;
self.chart_rsi_latest = chart_result.rsi_latest; self.chart.rsi_latest = chart_result.rsi_latest;
self.chart_dirty = false; self.chart.dirty = false;
} }
} }
// Place the image in the cell buffer // Place the image in the cell buffer
if (self.chart_image_id) |img_id| { if (self.chart.image_id) |img_id| {
// Place image at the first cell of the chart area // Place image at the first cell of the chart area
const chart_row_start: usize = header_rows; const chart_row_start: usize = header_rows;
const chart_col_start: usize = 1; // 1 col left margin const chart_col_start: usize = 1; // 1 col left margin
@ -220,8 +220,8 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell
.img_id = img_id, .img_id = img_id,
.options = .{ .options = .{
.size = .{ .size = .{
.rows = self.chart_image_height, .rows = self.chart.image_height,
.cols = self.chart_image_width, .cols = self.chart.image_width,
}, },
.scale = .contain, .scale = .contain,
}, },
@ -232,17 +232,17 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell
// Axis labels (terminal text in the right margin) // Axis labels (terminal text in the right margin)
// The chart image uses layout fractions: price=72%, gap=8%, RSI=20% // The chart image uses layout fractions: price=72%, gap=8%, RSI=20%
// Map these to terminal rows to position labels. // Map these to terminal rows to position labels.
const img_rows = self.chart_image_height; const img_rows = self.chart.image_height;
const label_col: usize = @as(usize, chart_col_start) + @as(usize, self.chart_image_width) + 1; const label_col: usize = @as(usize, chart_col_start) + @as(usize, self.chart.image_width) + 1;
const label_style = th.mutedStyle(); const label_style = th.mutedStyle();
if (label_col + 8 <= width and img_rows >= 4 and self.chart_price_max > self.chart_price_min) { if (label_col + 8 <= width and img_rows >= 4 and self.chart.price_max > self.chart.price_min) {
// Price axis labels evenly spaced across the price panel (top 72%) // Price axis labels evenly spaced across the price panel (top 72%)
const price_panel_rows = @as(f64, @floatFromInt(img_rows)) * 0.72; const price_panel_rows = @as(f64, @floatFromInt(img_rows)) * 0.72;
const n_price_labels: usize = 5; const n_price_labels: usize = 5;
for (0..n_price_labels) |i| { for (0..n_price_labels) |i| {
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_price_labels - 1)); const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_price_labels - 1));
const price_val = self.chart_price_max - frac * (self.chart_price_max - self.chart_price_min); const price_val = self.chart.price_max - frac * (self.chart.price_max - self.chart.price_min);
const row_f = @as(f64, @floatFromInt(chart_row_start)) + frac * price_panel_rows; const row_f = @as(f64, @floatFromInt(chart_row_start)) + frac * price_panel_rows;
const row: usize = @intFromFloat(@round(row_f)); const row: usize = @intFromFloat(@round(row_f));
if (row >= height) continue; if (row >= height) continue;
@ -290,7 +290,7 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell
} }
// Render quote details below the chart image as styled text // Render quote details below the chart image as styled text
const detail_start_row = header_rows + self.chart_image_height; const detail_start_row = header_rows + self.chart.image_height;
if (detail_start_row + 8 < height) { if (detail_start_row + 8 < height) {
var detail_lines: std.ArrayList(StyledLine) = .empty; var detail_lines: std.ArrayList(StyledLine) = .empty;
try detail_lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try detail_lines.append(arena, .{ .text = "", .style = th.contentStyle() });