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,
};
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`
/// interface via `widget()`, which wires `typeErasedEventHandler` and
/// `typeErasedDrawFn` as callbacks. Passed to `vxfw.App.run()` as the
@ -293,20 +309,8 @@ pub const App = struct {
account_map: ?zfin.analysis.AccountMap = null,
// Chart state (Kitty graphics)
chart_config: chart_mod.ChartConfig = .{},
chart: ChartState = .{},
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 {
return .{
@ -420,7 +424,7 @@ pub const App = struct {
}
// Quote tab: click on timeframe selector to switch timeframes
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;
if (content_row == tf_row) {
// " Chart: [6M] YTD 1Y 3Y 5Y ([ ] to change)"
@ -434,8 +438,8 @@ pub const App = struct {
const lbl_len = tf.label().len;
const slot_width = lbl_len + 2 + 1; // [XX] + space or XX + space
if (col >= x and col < x + slot_width) {
if (tf != self.chart_timeframe) {
self.chart_timeframe = tf;
if (tf != self.chart.timeframe) {
self.chart.timeframe = tf;
self.setStatus(tf.label());
return ctx.consumeAndRedraw();
}
@ -686,17 +690,17 @@ pub const App = struct {
},
.chart_timeframe_next => {
if (self.active_tab == .quote) {
self.chart_timeframe = self.chart_timeframe.next();
self.chart_dirty = true;
self.setStatus(self.chart_timeframe.label());
self.chart.timeframe = self.chart.timeframe.next();
self.chart.dirty = true;
self.setStatus(self.chart.timeframe.label());
return ctx.consumeAndRedraw();
}
},
.chart_timeframe_prev => {
if (self.active_tab == .quote) {
self.chart_timeframe = self.chart_timeframe.prev();
self.chart_dirty = true;
self.setStatus(self.chart_timeframe.label());
self.chart.timeframe = self.chart.timeframe.prev();
self.chart.dirty = true;
self.setStatus(self.chart.timeframe.label());
return ctx.consumeAndRedraw();
}
},
@ -939,7 +943,7 @@ pub const App = struct {
self.trailing_me_total = null;
self.risk_metrics = null;
self.scroll_offset = 0;
self.chart_dirty = true;
self.chart.dirty = true;
}
fn refreshCurrentTab(self: *App) void {
@ -968,7 +972,7 @@ pub const App = struct {
self.perf_loaded = false;
self.freeCandles();
self.freeDividends();
self.chart_dirty = true;
self.chart.dirty = true;
},
.earnings => {
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,
.symbol = symbol,
.has_explicit_symbol = has_explicit_symbol,
.chart_config = chart_config,
.chart = .{ .config = chart_config },
};
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 {
// 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);
app_inst.chart_image_id = null;
app_inst.chart.image_id = null;
}
}
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;
// Determine whether to use Kitty graphics
const use_kitty = switch (self.chart_config.mode) {
const use_kitty = switch (self.chart.config.mode) {
.braille => false,
.kitty => true,
.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" };
for (timeframes) |tf| {
const lbl = tf.label();
if (tf == self.chart_timeframe) {
if (tf == self.chart.timeframe) {
tf_buf[tf_pos] = '[';
tf_pos += 1;
@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)";
@memcpy(tf_buf[tf_pos..][0..hint.len], hint);
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() });
}
@ -129,21 +129,21 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell
if (px_w < 100 or px_h < 100) return;
// Apply resolution cap from chart config
const capped_w = @min(px_w, self.chart_config.max_width);
const capped_h = @min(px_h, self.chart_config.max_height);
const capped_w = @min(px_w, self.chart.config.max_width);
const capped_h = @min(px_h, self.chart.config.max_height);
// Check if we need to re-render the chart image
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);
const tf_changed = self.chart_timeframe_rendered == null or self.chart_timeframe_rendered.? != self.chart_timeframe;
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);
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
if (self.chart_image_id) |old_id| {
if (self.chart.image_id) |old_id| {
if (self.vx_app) |va| {
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,
@ -152,12 +152,12 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell
const chart_result = chart_mod.renderChart(
self.allocator,
c,
self.chart_timeframe,
self.chart.timeframe,
capped_w,
capped_h,
th,
) catch |err| {
self.chart_dirty = false;
self.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";
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.
const base64_enc = std.base64.standard.Encoder;
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");
return;
};
@ -183,31 +183,31 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell
chart_result.height,
.rgb,
) catch |err| {
self.chart_dirty = false;
self.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";
self.setStatus(msg);
return;
};
self.chart_image_id = img.id;
self.chart_image_width = @intCast(chart_cols);
self.chart_image_height = chart_rows;
self.chart.image_id = img.id;
self.chart.image_width = @intCast(chart_cols);
self.chart.image_height = chart_rows;
// Track what we rendered
const sym_len = @min(self.symbol.len, 16);
@memcpy(self.chart_symbol[0..sym_len], self.symbol[0..sym_len]);
self.chart_symbol_len = sym_len;
self.chart_timeframe_rendered = self.chart_timeframe;
self.chart_price_min = chart_result.price_min;
self.chart_price_max = chart_result.price_max;
self.chart_rsi_latest = chart_result.rsi_latest;
self.chart_dirty = false;
@memcpy(self.chart.symbol[0..sym_len], self.symbol[0..sym_len]);
self.chart.symbol_len = sym_len;
self.chart.timeframe_rendered = self.chart.timeframe;
self.chart.price_min = chart_result.price_min;
self.chart.price_max = chart_result.price_max;
self.chart.rsi_latest = chart_result.rsi_latest;
self.chart.dirty = false;
}
}
// 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
const chart_row_start: usize = header_rows;
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,
.options = .{
.size = .{
.rows = self.chart_image_height,
.cols = self.chart_image_width,
.rows = self.chart.image_height,
.cols = self.chart.image_width,
},
.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)
// The chart image uses layout fractions: price=72%, gap=8%, RSI=20%
// Map these to terminal rows to position labels.
const img_rows = self.chart_image_height;
const label_col: usize = @as(usize, chart_col_start) + @as(usize, self.chart_image_width) + 1;
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_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%)
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 = 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: usize = @intFromFloat(@round(row_f));
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
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) {
var detail_lines: std.ArrayList(StyledLine) = .empty;
try detail_lines.append(arena, .{ .text = "", .style = th.contentStyle() });