const std = @import("std"); const vaxis = @import("vaxis"); const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); const Money = @import("../Money.zig"); const theme = @import("theme.zig"); const tui = @import("../tui.zig"); const framework = @import("tab_framework.zig"); const App = tui.App; const StyledLine = tui.StyledLine; const OptionsRow = tui.OptionsRow; // ── Tab-local action enum ───────────────────────────────────── // // Options tab keybinds: // - Enter : expand/collapse the row at the cursor. // - `c` / `p` : collapse-or-expand all calls / puts. // - Ctrl-1 .. Ctrl-9 : set NTM filter to N strikes around ATM. pub const Action = enum { /// Toggle the row at the cursor: expand/collapse an expiration, /// or collapse/expand the calls/puts subsection at the cursor. /// Mouse single-click on a row dispatches the same action. expand_collapse, collapse_all_calls, collapse_all_puts, filter_1, filter_2, filter_3, filter_4, filter_5, filter_6, filter_7, filter_8, filter_9, }; // ── Tab-private state ───────────────────────────────────────── pub const State = struct { /// Loaded options chains for the active symbol. Owned by State; /// freed via `deinit` and `reload`. chains: ?[]zfin.OptionsChain = null, /// Whether `activate` has populated `chains` (or set `disabled`). /// The chains slice is null until the first successful fetch /// even if `loaded == true` (failed fetches still mark loaded). loaded: bool = false, /// Timestamp of the chains fetch — drives the "data Xs ago" /// header readout. timestamp: i64 = 0, /// Cursor position in the flattened options rows view. cursor: usize = 0, /// Per-expiration: is the expiration expanded (showing calls /// + puts subsections)? expanded: [64]bool = @splat(false), /// Per-expiration: when expanded, are the calls collapsed? calls_collapsed: [64]bool = @splat(false), /// Per-expiration: when expanded, are the puts collapsed? puts_collapsed: [64]bool = @splat(false), /// Number of strikes around ATM to show. Adjusted with Ctrl-1..9. near_the_money: usize = 8, /// Flattened display rows (expirations + headers + contracts). /// Rebuilt by `rebuildRows` whenever `expanded` or /// `near_the_money` changes. rows: std.ArrayList(OptionsRow) = .empty, /// Number of styled lines emitted before the first data row. /// Used by mouse-click handling to map screen rows to data rows. header_lines: usize = 0, }; // ── Tab framework contract ──────────────────────────────────── pub const tab = struct { pub const ActionT = Action; pub const StateT = State; pub const default_bindings: []const framework.TabBinding(Action) = &.{ .{ .action = .expand_collapse, .key = .{ .codepoint = vaxis.Key.enter } }, .{ .action = .collapse_all_calls, .key = .{ .codepoint = 'c' } }, .{ .action = .collapse_all_puts, .key = .{ .codepoint = 'p' } }, .{ .action = .filter_1, .key = .{ .codepoint = '1', .mods = .{ .ctrl = true } } }, .{ .action = .filter_2, .key = .{ .codepoint = '2', .mods = .{ .ctrl = true } } }, .{ .action = .filter_3, .key = .{ .codepoint = '3', .mods = .{ .ctrl = true } } }, .{ .action = .filter_4, .key = .{ .codepoint = '4', .mods = .{ .ctrl = true } } }, .{ .action = .filter_5, .key = .{ .codepoint = '5', .mods = .{ .ctrl = true } } }, .{ .action = .filter_6, .key = .{ .codepoint = '6', .mods = .{ .ctrl = true } } }, .{ .action = .filter_7, .key = .{ .codepoint = '7', .mods = .{ .ctrl = true } } }, .{ .action = .filter_8, .key = .{ .codepoint = '8', .mods = .{ .ctrl = true } } }, .{ .action = .filter_9, .key = .{ .codepoint = '9', .mods = .{ .ctrl = true } } }, }; pub const action_labels = std.enums.EnumArray(Action, []const u8).init(.{ .expand_collapse = "Expand/collapse row", .collapse_all_calls = "Toggle all calls", .collapse_all_puts = "Toggle all puts", .filter_1 = "Filter +/- 1 NTM", .filter_2 = "Filter +/- 2 NTM", .filter_3 = "Filter +/- 3 NTM", .filter_4 = "Filter +/- 4 NTM", .filter_5 = "Filter +/- 5 NTM", .filter_6 = "Filter +/- 6 NTM", .filter_7 = "Filter +/- 7 NTM", .filter_8 = "Filter +/- 8 NTM", .filter_9 = "Filter +/- 9 NTM", }); pub const status_hints: []const Action = &.{ .collapse_all_calls, .collapse_all_puts, }; pub fn init(state: *State, app: *App) !void { _ = app; state.* = .{}; } pub fn deinit(state: *State, app: *App) void { if (state.chains) |chains| { zfin.OptionsChain.freeSlice(app.allocator, chains); } state.rows.deinit(app.allocator); state.* = .{}; } pub fn activate(state: *State, app: *App) !void { if (app.symbol.len == 0) return; if (state.loaded) return; loadData(state, app); } pub const deactivate = framework.noopDeactivate(State); pub fn reload(state: *State, app: *App) !void { // Drop chains first so loadData starts clean. if (state.chains) |chains| { zfin.OptionsChain.freeSlice(app.allocator, chains); } // Preserve user UX choices across refresh: cursor, expanded // sections, near-the-money filter. Just clear the data. state.chains = null; state.loaded = false; state.timestamp = 0; loadData(state, app); } pub const tick = framework.noopTick(State); pub fn handleAction(state: *State, app: *App, action: Action) void { switch (action) { .collapse_all_calls => toggleAllCallsPuts(state, app, true), .collapse_all_puts => toggleAllCallsPuts(state, app, false), .expand_collapse => toggleExpandAtCursor(state, app), .filter_1, .filter_2, .filter_3, .filter_4, .filter_5, .filter_6, .filter_7, .filter_8, .filter_9 => { const n = @intFromEnum(action) - @intFromEnum(Action.filter_1) + 1; state.near_the_money = n; rebuildRows(state, app); var status_buf: [32]u8 = undefined; const msg = std.fmt.bufPrint(&status_buf, "+/- {d} NTM strikes", .{n}) catch "Filter changed"; app.setStatus(msg); }, } } /// Mouse handling: a single-click on a data row moves the /// cursor to that row and toggles expand/collapse — same effect /// as pressing Enter on the row. Returns `true` if the click /// landed on a data row (consumed); `false` otherwise (unhandled, /// e.g. clicks above the table or on blank lines). pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool { if (mouse.button != .left) return false; if (mouse.type != .press) return false; const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset; if (content_row < state.header_lines) return false; if (state.rows.items.len == 0) return false; // Walk rows tracking styled line position to find which row // was clicked. Each row = 1 styled line, except `puts_header` // which emits an extra blank line before it. const target_line = content_row - state.header_lines; var current_line: usize = 0; for (state.rows.items, 0..) |orow, oi| { if (orow.kind == .puts_header) current_line += 1; // extra blank if (current_line == target_line) { state.cursor = oi; toggleExpandAtCursor(state, app); return true; } current_line += 1; } return false; } pub fn isDisabled(app: *App) bool { _ = app; return false; } /// Symbol-change reset. Drops chains + display rows, clears /// cursor + per-expiration collapse flags. Preserves /// `near_the_money` because that's a persistent UX choice /// (not symbol-bound). pub fn onSymbolChange(state: *State, app: *App) void { if (state.chains) |chains| { zfin.OptionsChain.freeSlice(app.allocator, chains); } state.chains = null; state.loaded = false; state.timestamp = 0; state.cursor = 0; state.expanded = @splat(false); state.calls_collapsed = @splat(false); state.puts_collapsed = @splat(false); state.rows.clearRetainingCapacity(); state.header_lines = 0; // near_the_money preserved. } /// Sync the row cursor to the new scroll extreme. The framework /// updates `app.scroll_offset` itself; this hook just keeps the /// tab's own cursor consistent with what's now visible. pub fn onScroll(state: *State, app: *App, where: framework.ScrollEdge) void { _ = app; switch (where) { .top => state.cursor = 0, .bottom => { if (state.rows.items.len > 0) { state.cursor = state.rows.items.len - 1; } }, } } /// Step the row cursor by one row in `delta`'s direction. The /// magnitude of `delta` is ignored — keys and wheel events /// both move by a single row (matching legacy behavior). Returns /// `false` when there are no rows to navigate so the framework /// falls through to scrolling the viewport instead. pub fn onCursorMove(state: *State, app: *App, delta: isize) bool { if (state.rows.items.len == 0) return false; stepCursor(&state.cursor, state.rows.items.len, delta); ensureCursorVisible(state, &app.scroll_offset, app.visible_height); return true; } }; // ── Cursor movement / visibility (private; called from onCursorMove) ── fn stepCursor(cursor: *usize, row_count: usize, direction: isize) void { if (direction > 0) { if (row_count > 0 and cursor.* < row_count - 1) cursor.* += 1; } else { if (cursor.* > 0) cursor.* -= 1; } } fn ensureCursorVisible(state: *const State, scroll_offset: *usize, visible_height: usize) void { const cursor_row = state.cursor + state.header_lines; if (cursor_row < scroll_offset.*) { scroll_offset.* = cursor_row; } if (cursor_row >= scroll_offset.* + visible_height) { scroll_offset.* = cursor_row - visible_height + 1; } } // ── Data loading ────────────────────────────────────────────── fn loadData(state: *State, app: *App) void { state.loaded = true; if (state.chains) |chains| { zfin.OptionsChain.freeSlice(app.allocator, chains); } state.chains = null; const result = app.svc.getOptions(app.symbol) catch |err| { switch (err) { zfin.DataError.FetchFailed => app.setStatus("CBOE fetch failed (network error)"), else => app.setStatus("Error loading options"), } return; }; state.chains = result.data; state.timestamp = result.timestamp; state.cursor = 0; state.expanded = @splat(false); state.calls_collapsed = @splat(false); state.puts_collapsed = @splat(false); rebuildRows(state, app); app.setStatus(if (result.source == .cached) "Cached (1hr TTL) | r/F5 to refresh" else "Fetched | r/F5 to refresh"); } // ── Expand/collapse the row at the cursor ───────────────────── // // Called both from the keybind handler (`expand_collapse` action, // bound to Enter) and from the mouse handler (single-click on a // row). The behavior depends on the row kind: // - `expiration` row: toggle the expiration's expanded flag // (showing/hiding the calls + puts subsections). // - `calls_header`/`puts_header`: toggle that subsection's // collapsed flag. // - call/put contract rows: no-op (clicking a contract is // reserved for future per-contract actions). // // After any change, rebuilds the flat row list to reflect the new // layout. No-op if the cursor is out of range or rows are empty. fn toggleExpandAtCursor(state: *State, app: *App) void { if (state.rows.items.len == 0) return; if (state.cursor >= state.rows.items.len) return; const row = state.rows.items[state.cursor]; switch (row.kind) { .expiration => { if (row.exp_idx < state.expanded.len) { state.expanded[row.exp_idx] = !state.expanded[row.exp_idx]; rebuildRows(state, app); } }, .calls_header => { if (row.exp_idx < state.calls_collapsed.len) { state.calls_collapsed[row.exp_idx] = !state.calls_collapsed[row.exp_idx]; rebuildRows(state, app); } }, .puts_header => { if (row.exp_idx < state.puts_collapsed.len) { state.puts_collapsed[row.exp_idx] = !state.puts_collapsed[row.exp_idx]; rebuildRows(state, app); } }, // Clicking on a contract does nothing (yet). else => {}, } } // ── Row rebuilding (after expansion/collapse changes) ──────── pub fn rebuildRows(state: *State, app: *App) void { state.rows.clearRetainingCapacity(); const chains = state.chains orelse return; const atm_price = if (chains.len > 0) chains[0].underlying_price orelse 0 else @as(f64, 0); for (chains, 0..) |chain, ci| { state.rows.append(app.allocator, .{ .kind = .expiration, .exp_idx = ci, }) catch continue; if (ci < state.expanded.len and state.expanded[ci]) { // Calls header (always shown when expanded, acts as toggle) state.rows.append(app.allocator, .{ .kind = .calls_header, .exp_idx = ci, }) catch continue; // Calls contracts (only if not collapsed) if (!(ci < state.calls_collapsed.len and state.calls_collapsed[ci])) { const filtered_calls = fmt.filterNearMoney(chain.calls, atm_price, state.near_the_money); for (filtered_calls) |cc| { state.rows.append(app.allocator, .{ .kind = .call, .exp_idx = ci, .contract = cc, }) catch continue; } } // Puts header state.rows.append(app.allocator, .{ .kind = .puts_header, .exp_idx = ci, }) catch continue; // Puts contracts (only if not collapsed) if (!(ci < state.puts_collapsed.len and state.puts_collapsed[ci])) { const filtered_puts = fmt.filterNearMoney(chain.puts, atm_price, state.near_the_money); for (filtered_puts) |p| { state.rows.append(app.allocator, .{ .kind = .put, .exp_idx = ci, .contract = p, }) catch continue; } } } } } // ── All-calls / all-puts toggle ────────────────────────────── fn toggleAllCallsPuts(state: *State, app: *App, is_calls: bool) void { const chains = state.chains orelse return; // Determine whether to collapse or expand: if any expanded chain // has this section visible, collapse all; otherwise expand all. var any_visible = false; for (chains, 0..) |_, ci| { if (ci >= state.expanded.len) break; if (!state.expanded[ci]) continue; // only count expanded expirations if (is_calls) { if (ci < state.calls_collapsed.len and !state.calls_collapsed[ci]) { any_visible = true; break; } } else { if (ci < state.puts_collapsed.len and !state.puts_collapsed[ci]) { any_visible = true; break; } } } const new_state = any_visible; for (chains, 0..) |_, ci| { if (ci >= 64) break; if (is_calls) { state.calls_collapsed[ci] = new_state; } else { state.puts_collapsed[ci] = new_state; } } rebuildRows(state, app); if (is_calls) { app.setStatus(if (new_state) "All calls collapsed" else "All calls expanded"); } else { app.setStatus(if (new_state) "All puts collapsed" else "All puts expanded"); } } // ── Rendering ───────────────────────────────────────────────── pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { const state = &app.states.options; 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); } const chains = state.chains orelse { try lines.append(arena, .{ .text = " Loading options data...", .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); }; if (chains.len == 0) { try lines.append(arena, .{ .text = " No options data found.", .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); } var opt_ago_buf: [16]u8 = undefined; // wall-clock required: per-frame "now" for the "refreshed Xs ago" // readout. Captured here rather than on `app` so it refreshes every // time this tab renders. const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds(); const opt_ago = fmt.fmtTimeAgo(&opt_ago_buf, state.timestamp, now_s); if (opt_ago.len > 0) { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s} (data {s}, 15 min delay)", .{ app.symbol, opt_ago }), .style = th.headerStyle() }); } else { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s}", .{app.symbol}), .style = th.headerStyle() }); } if (chains[0].underlying_price) |price| { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Underlying: {f} {d} expiration(s) +/- {d} strikes NTM (Ctrl+1-9 to change)", .{ Money.from(price), chains.len, state.near_the_money }), .style = th.contentStyle() }); } try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); // Track header line count for mouse click mapping (after all non-data lines) state.header_lines = lines.items.len; // Flat list of options rows with inline expand/collapse for (state.rows.items, 0..) |row, ri| { const is_cursor = ri == state.cursor; switch (row.kind) { .expiration => { if (row.exp_idx < chains.len) { const chain = chains[row.exp_idx]; const is_expanded = row.exp_idx < state.expanded.len and state.expanded[row.exp_idx]; const is_monthly = fmt.isMonthlyExpiration(chain.expiration); const arrow: []const u8 = if (is_expanded) "v " else "> "; const text = try std.fmt.allocPrint(arena, " {s}{f} ({d} calls, {d} puts)", .{ arrow, chain.expiration, chain.calls.len, chain.puts.len, }); const style = if (is_cursor) th.selectStyle() else if (is_monthly) th.contentStyle() else th.mutedStyle(); try lines.append(arena, .{ .text = text, .style = style }); } }, .calls_header => { const calls_collapsed = row.exp_idx < state.calls_collapsed.len and state.calls_collapsed[row.exp_idx]; const arrow: []const u8 = if (calls_collapsed) " > " else " v "; const style = if (is_cursor) th.selectStyle() else th.headerStyle(); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}{s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8} Calls", .{ arrow, "Strike", "Last", "Bid", "Ask", "Volume", "OI", "IV", }), .style = style }); }, .puts_header => { const puts_collapsed = row.exp_idx < state.puts_collapsed.len and state.puts_collapsed[row.exp_idx]; const arrow: []const u8 = if (puts_collapsed) " > " else " v "; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); const style = if (is_cursor) th.selectStyle() else th.headerStyle(); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}{s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8} Puts", .{ arrow, "Strike", "Last", "Bid", "Ask", "Volume", "OI", "IV", }), .style = style }); }, .call => { if (row.contract) |cc| { const atm_price = chains[0].underlying_price orelse 0; const itm = cc.strike <= atm_price; const prefix: []const u8 = if (itm) " |" else " "; var contract_buf: [128]u8 = undefined; const text = try arena.dupe(u8, fmt.fmtContractLine(&contract_buf, prefix, cc)); const style = if (is_cursor) th.selectStyle() else th.contentStyle(); try lines.append(arena, .{ .text = text, .style = style }); } }, .put => { if (row.contract) |p| { const atm_price = chains[0].underlying_price orelse 0; const itm = p.strike >= atm_price; const prefix: []const u8 = if (itm) " |" else " "; var contract_buf: [128]u8 = undefined; const text = try arena.dupe(u8, fmt.fmtContractLine(&contract_buf, prefix, p)); const style = if (is_cursor) th.selectStyle() else th.contentStyle(); try lines.append(arena, .{ .text = text, .style = style }); } }, } } return lines.toOwnedSlice(arena); }