From 863111d80183a96180dece4ef090b9d54c9b159a Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 19 Mar 2026 09:57:54 -0700 Subject: [PATCH] introduce chart state to hold the 13 chart state fields --- src/tui.zig | 58 +++++++++++++++++++------------------ src/tui/quote_tab.zig | 66 +++++++++++++++++++++---------------------- 2 files changed, 64 insertions(+), 60 deletions(-) diff --git a/src/tui.zig b/src/tui.zig index bdc0229..fcd8f54 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -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(), .{}); diff --git a/src/tui/quote_tab.zig b/src/tui/quote_tab.zig index 1d27aaf..23bbbd7 100644 --- a/src/tui/quote_tab.zig +++ b/src/tui/quote_tab.zig @@ -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() });