move tab-modals into tabs from global

This commit is contained in:
Emil Lerch 2026-05-15 17:47:23 -07:00
parent 15ae2cbf20
commit e301757311
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 810 additions and 441 deletions

View file

@ -8,6 +8,7 @@ const keybinds = @import("tui/keybinds.zig");
const tab_framework = @import("tui/tab_framework.zig");
const theme = @import("tui/theme.zig");
const chart = @import("tui/chart.zig");
const input_buffer = @import("tui/input_buffer.zig");
/// Single source of truth for tab modules. Each entry is the
/// imported tab module; the field name is the tab's tag (must match
@ -119,16 +120,18 @@ fn tabLabel(t: Tab) []const u8 {
/// `std.enums.values(Tab)`; aliased for brevity at call sites.
const tabs: []const Tab = std.enums.values(Tab);
/// Truly global UI modes layered over any tab. Tab-internal
/// modal sub-states (e.g. portfolio's account picker,
/// projections' as-of date input) live in the tab's own
/// `State.modal`, not here.
pub const InputMode = enum {
/// No global mode active.
normal,
/// Symbol-input prompt (any tab can trigger via the
/// `symbol_input` action).
symbol_input,
/// Help overlay.
help,
account_picker,
account_search,
/// Mini popup on the projections tab for entering an as-of date.
/// Same input scaffolding as `symbol_input` (shared `input_buf`),
/// committed via `parseAsOfDate`.
date_input,
};
pub const StyledLine = struct {
@ -655,16 +658,13 @@ pub const App = struct {
// Portfolio tab state lives in `self.states.portfolio` (see TabStates).
// Account picker / search modal state. The portfolio tab opens
// the picker via the `account_filter` action, but the picker
// itself is a global UI mode (mode = .account_picker) that
// operates on portfolio state via `self.states.portfolio`.
// Search-mode is mode = .account_search.
account_picker_cursor: usize = 0, // cursor position in picker (0 = "All accounts")
account_search_buf: [64]u8 = undefined,
account_search_len: usize = 0,
account_search_matches: std.ArrayList(usize) = .empty, // indices into states.portfolio.account_list matching search
account_search_cursor: usize = 0, // cursor within search_matches
// Account picker / search state lives in
// `self.states.portfolio` see portfolio_tab.zig. The picker
// is fully tab-internal: opened/closed via
// `state.modal` and routed through portfolio's own
// `handleKey` / `handleMouse` / `drawContent` /
// `statusOverride` hooks. App.Mode does NOT carry picker
// variants.
// History tab state lives in `self.states.history` (see TabStates).
// Projections tab state lives in `self.states.projections`.
@ -692,18 +692,26 @@ pub const App = struct {
const self: *App = @ptrCast(@alignCast(ptr));
switch (event) {
.key_press => |key| {
// Tab-level pre-empt. The active tab gets first
// crack at every key (when it declares the hook).
// Used for tab-internal modals (account picker on
// portfolio, date input on projections, etc) that
// need to swallow keys before global keymap
// matching runs otherwise typing `r` while a
// modal is open would refresh, which would be
// surprising.
//
// Tabs that don't declare `handleKey` skip this.
// Tabs that declare it but aren't currently in a
// modal sub-state should return `false` so
// dispatch falls through to the normal path.
if (self.dispatchBool("handleKey", .{key})) {
return ctx.consumeAndRedraw();
}
if (self.mode == .symbol_input) {
return self.handleInputKey(ctx, key);
}
if (self.mode == .date_input) {
return self.handleDateInputKey(ctx, key);
}
if (self.mode == .account_picker) {
return self.handleAccountPickerKey(ctx, key);
}
if (self.mode == .account_search) {
return self.handleAccountSearchKey(ctx, key);
}
if (self.mode == .help) {
self.mode = .normal;
return ctx.consumeAndRedraw();
@ -721,40 +729,35 @@ pub const App = struct {
}
fn handleMouse(self: *App, ctx: *vaxis.vxfw.EventContext, mouse: vaxis.Mouse) void {
// Account picker mouse handling
if (self.mode == .account_picker) {
const total_items = self.states.portfolio.account_list.items.len + 1;
switch (mouse.button) {
.wheel_up => {
if (self.shouldDebounceWheel()) return;
if (self.account_picker_cursor > 0)
self.account_picker_cursor -= 1;
return ctx.consumeAndRedraw();
},
.wheel_down => {
if (self.shouldDebounceWheel()) return;
if (total_items > 0 and self.account_picker_cursor < total_items - 1)
self.account_picker_cursor += 1;
return ctx.consumeAndRedraw();
},
.left => {
if (mouse.type != .press) return;
// Map click row to picker item index.
// mouse.row maps directly to content line index
// (same convention as portfolio click handling).
const content_row = @as(usize, @intCast(mouse.row));
if (content_row >= tab_modules.portfolio.account_picker_header_lines) {
const item_idx = content_row - tab_modules.portfolio.account_picker_header_lines;
if (item_idx < total_items) {
self.account_picker_cursor = item_idx;
self.applyAccountPickerSelection();
return ctx.consumeAndRedraw();
}
}
},
else => {},
// Active-tab dispatch first. Tabs return `true` to
// consume; on `false` we fall through to App-level
// handling (tab-bar clicks, viewport scroll). This gives
// tab-internal modals (e.g. portfolio's account picker)
// first crack at every mouse event so they can swallow
// tab-bar clicks and prevent tab switching while modal.
//
// We probe `statusOverride` first to detect "tab is in a
// modal sub-state" — when it returns non-null, the
// tab gets the FULL mouse stream (including row 0).
// Otherwise (normal mode), we apply chrome ownership:
// row 0 is the tab bar, so non-modal tabs only see
// content-region events. This prevents
// `mouse.row + scroll_offset` ambiguity when a tab's
// handleMouse derives a content row from the raw mouse
// row row 0 would otherwise look like
// `scroll_offset` rows of header, which can mis-read as
// a header/sort click.
const tab_is_modal = self.activeTabStatusOverride() != null;
if (tab_is_modal or mouse.row > 0) {
if (self.dispatchBool("handleMouse", .{mouse})) {
return ctx.consumeAndRedraw();
}
return;
}
if (tab_is_modal) {
// Modal didn't consume (it always should see
// contract above), but be defensive: still swallow
// the event so tab-bar clicks etc don't fire.
return ctx.consumeAndRedraw();
}
switch (mouse.button) {
@ -784,25 +787,10 @@ pub const App = struct {
col += lbl_len;
}
}
// Framework dispatch: ask the active tab's `handleMouse`
// (when defined) if it wants to consume this click.
//
// Chrome ownership: row 0 is the tab bar clicks
// that hit a tab label were already consumed above;
// misses are dropped here so tab handlers only see
// content-region events. (Future: a tab might want
// to opt in to chrome regions for per-tab indicators
// would require a framework hook to claim chrome
// ranges, not just a row-0 bypass.)
//
// The bottom status row (`max_size.height - 1`) is
// also chrome but isn't filtered yet none of the
// current tabs have content there, so clicks land
// harmlessly. Filter here when a tab grows
// bottom-edge content that needs disambiguation.
if (mouse.row > 0 and self.dispatchBool("handleMouse", .{mouse})) {
return ctx.consumeAndRedraw();
}
// Content-region clicks already went through
// `dispatchBool("handleMouse")` above when
// `mouse.row > 0`. Reaching here means the tab
// declined to consume nothing left to do.
},
else => {},
}
@ -1069,73 +1057,12 @@ pub const App = struct {
return &.{};
}
/// Outcome of a single keypress in an input-mode buffer (symbol
/// input, date input, etc.). Returned by `handleInputBuffer` so
/// the per-mode caller only needs to wire up the `committed`
/// branch with its own semantics; the shared scaffolding (Esc to
/// cancel, Backspace/Ctrl+U to edit, printable to append) is
/// handled once.
const InputBufferResult = enum {
/// Esc pressed. Caller should exit input mode; the shared
/// helper has already reset `input_len` and set mode back to
/// `.normal`.
cancelled,
/// Enter pressed. Caller reads `self.input_buf[0..self.input_len]`
/// to commit, then resets mode + length.
committed,
/// Character appended / removed / cleared. Caller should just
/// redraw; no further action.
edited,
/// Key didn't match any input-buffer semantic (e.g., a
/// function key). Caller may ignore or layer on its own
/// handling; the helper didn't consume the event.
ignored,
};
/// Shared input-buffer state machine. Handles Esc (cancel),
/// Backspace/Ctrl+U (edit), and printable-ASCII append. Returns
/// the outcome so the caller can wire up Enter and Esc/edit
/// side-effects on its own.
///
/// Behavior on `cancelled`: resets `self.mode = .normal` and
/// `self.input_len = 0`. Caller typically sets a status message
/// and calls `ctx.consumeAndRedraw()`.
///
/// Does not touch state on `committed` caller owns the commit
/// (reading the buffer, dispatching to downstream, resetting
/// mode/length when done).
fn handleInputBuffer(self: *App, key: vaxis.Key) InputBufferResult {
if (key.codepoint == vaxis.Key.escape) {
self.mode = .normal;
self.input_len = 0;
return .cancelled;
}
if (key.codepoint == vaxis.Key.enter) {
return .committed;
}
if (key.codepoint == vaxis.Key.backspace) {
if (self.input_len > 0) self.input_len -= 1;
return .edited;
}
// Ctrl+U: clear entire input (readline convention)
if (key.matches('u', .{ .ctrl = true })) {
self.input_len = 0;
return .edited;
}
// Accept printable ASCII (letters, digits, common punctuation).
if (key.codepoint < std.math.maxInt(u7) and std.ascii.isPrint(@intCast(key.codepoint)) and self.input_len < self.input_buf.len) {
self.input_buf[self.input_len] = @intCast(key.codepoint);
self.input_len += 1;
return .edited;
}
return .ignored;
}
/// Handles keypresses in symbol_input mode (activated by `/`).
/// Mini text input for typing a ticker symbol (e.g. AAPL, BRK.B, ^GSPC).
fn handleInputKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
switch (self.handleInputBuffer(key)) {
switch (input_buffer.handleKey(&self.input_buf, &self.input_len, key)) {
.cancelled => {
self.mode = .normal;
self.setStatus("Cancelled");
return ctx.consumeAndRedraw();
},
@ -1161,259 +1088,6 @@ pub const App = struct {
}
}
/// Handles keypresses in date_input mode (activated by `d` on the
/// projections tab).
///
/// Accepts the same input as the CLI `--as-of` flag `YYYY-MM-DD`,
/// relative shortcuts (`1W`, `1M`, `3M`, `1Q`, `1Y`, `3Y`, `5Y`),
/// or `live` / empty for live state. Commit via Enter, cancel via
/// Esc.
fn handleDateInputKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
switch (self.handleInputBuffer(key)) {
.cancelled => {
self.setStatus("Cancelled");
return ctx.consumeAndRedraw();
},
.edited => return ctx.consumeAndRedraw(),
.ignored => {},
.committed => {
const input = self.input_buf[0..self.input_len];
const parsed = cli.parseAsOfDate(input, self.today) catch |err| {
var buf: [256]u8 = undefined;
const msg = cli.fmtAsOfParseError(&buf, input, err);
self.setStatus(msg);
self.mode = .normal;
self.input_len = 0;
return ctx.consumeAndRedraw();
};
if (parsed) |d| {
// Guard against future dates.
if (d.days > self.today.days) {
self.setStatus("As-of date is in the future");
self.mode = .normal;
self.input_len = 0;
return ctx.consumeAndRedraw();
}
self.states.projections.as_of = d;
self.states.projections.as_of_requested = null;
var status_buf: [64]u8 = undefined;
const msg = std.fmt.bufPrint(&status_buf, "As-of: {f}", .{d}) catch "As-of set";
self.setStatus(msg);
} else {
// `null` parse result = live.
self.states.projections.as_of = null;
self.states.projections.as_of_requested = null;
self.setStatus("As-of cleared — showing live");
}
tab_modules.projections.tab.reload(&self.states.projections, self) catch {};
self.mode = .normal;
self.input_len = 0;
ctx.queueRefresh() catch {};
return ctx.consumeAndRedraw();
},
}
}
/// Handles keypresses in account_picker mode.
fn handleAccountPickerKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
const total_items = self.states.portfolio.account_list.items.len + 1; // +1 for "All accounts"
if (key.codepoint == vaxis.Key.escape or key.codepoint == 'q') {
self.mode = .normal;
return ctx.consumeAndRedraw();
}
if (key.codepoint == vaxis.Key.enter) {
self.applyAccountPickerSelection();
return ctx.consumeAndRedraw();
}
// '/' enters search mode
if (key.matches('/', .{})) {
self.mode = .account_search;
self.account_search_len = 0;
self.updateAccountSearchMatches();
return ctx.consumeAndRedraw();
}
// 'A' selects "All accounts" instantly
if (key.matches('A', .{})) {
self.account_picker_cursor = 0;
self.applyAccountPickerSelection();
return ctx.consumeAndRedraw();
}
// Check shortcut keys for instant selection
if (key.codepoint < std.math.maxInt(u7) and key.matches(key.codepoint, .{})) {
const ch: u8 = @intCast(key.codepoint);
for (self.states.portfolio.account_shortcut_keys.items, 0..) |shortcut, i| {
if (shortcut == ch) {
self.account_picker_cursor = i + 1; // +1 for "All accounts" at 0
self.applyAccountPickerSelection();
return ctx.consumeAndRedraw();
}
}
}
// Navigation via keymap
const action = self.keymap.matchAction(key) orelse return;
switch (action) {
.select_next => {
if (total_items > 0 and self.account_picker_cursor < total_items - 1)
self.account_picker_cursor += 1;
return ctx.consumeAndRedraw();
},
.select_prev => {
if (self.account_picker_cursor > 0)
self.account_picker_cursor -= 1;
return ctx.consumeAndRedraw();
},
.scroll_top => {
self.account_picker_cursor = 0;
return ctx.consumeAndRedraw();
},
.scroll_bottom => {
if (total_items > 0)
self.account_picker_cursor = total_items - 1;
return ctx.consumeAndRedraw();
},
else => {},
}
}
/// Handles keypresses in account_search mode (/ search within picker).
fn handleAccountSearchKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
// Escape: cancel search, return to picker
if (key.codepoint == vaxis.Key.escape) {
self.mode = .account_picker;
self.account_search_len = 0;
return ctx.consumeAndRedraw();
}
// Enter: select the first match (or current search cursor)
if (key.codepoint == vaxis.Key.enter) {
if (self.account_search_matches.items.len > 0) {
const match_idx = self.account_search_matches.items[self.account_search_cursor];
self.account_picker_cursor = match_idx + 1; // +1 for "All accounts"
}
self.account_search_len = 0;
self.applyAccountPickerSelection();
return ctx.consumeAndRedraw();
}
// Ctrl+N / Ctrl+P or arrow keys to cycle through matches
if (key.matches('n', .{ .ctrl = true }) or key.codepoint == vaxis.Key.down) {
if (self.account_search_matches.items.len > 0 and
self.account_search_cursor < self.account_search_matches.items.len - 1)
self.account_search_cursor += 1;
return ctx.consumeAndRedraw();
}
if (key.matches('p', .{ .ctrl = true }) or key.codepoint == vaxis.Key.up) {
if (self.account_search_cursor > 0)
self.account_search_cursor -= 1;
return ctx.consumeAndRedraw();
}
// Backspace
if (key.codepoint == vaxis.Key.backspace) {
if (self.account_search_len > 0) {
self.account_search_len -= 1;
self.updateAccountSearchMatches();
}
return ctx.consumeAndRedraw();
}
// Ctrl+U: clear search
if (key.matches('u', .{ .ctrl = true })) {
self.account_search_len = 0;
self.updateAccountSearchMatches();
return ctx.consumeAndRedraw();
}
// Printable ASCII
if (key.codepoint < std.math.maxInt(u7) and std.ascii.isPrint(@intCast(key.codepoint)) and self.account_search_len < self.account_search_buf.len) {
self.account_search_buf[self.account_search_len] = @intCast(key.codepoint);
self.account_search_len += 1;
self.updateAccountSearchMatches();
return ctx.consumeAndRedraw();
}
}
/// Update search match indices based on current search string.
fn updateAccountSearchMatches(self: *App) void {
self.account_search_matches.clearRetainingCapacity();
const query = self.account_search_buf[0..self.account_search_len];
if (query.len == 0) return;
var lower_query: [64]u8 = undefined;
for (query, 0..) |c, i| lower_query[i] = std.ascii.toLower(c);
const lq = lower_query[0..query.len];
for (self.states.portfolio.account_list.items, 0..) |acct, i| {
if (containsLower(acct, lq)) {
self.account_search_matches.append(self.allocator, i) catch continue;
} else if (i < self.states.portfolio.account_numbers.items.len) {
if (self.states.portfolio.account_numbers.items[i]) |num| {
if (containsLower(num, lq)) {
self.account_search_matches.append(self.allocator, i) catch continue;
}
}
}
}
if (self.account_search_cursor >= self.account_search_matches.items.len) {
self.account_search_cursor = if (self.account_search_matches.items.len > 0)
self.account_search_matches.items.len - 1
else
0;
}
}
fn containsLower(haystack: []const u8, needle_lower: []const u8) bool {
if (needle_lower.len == 0) return true;
if (haystack.len < needle_lower.len) return false;
const end = haystack.len - needle_lower.len + 1;
for (0..end) |start| {
var matched = true;
for (0..needle_lower.len) |j| {
if (std.ascii.toLower(haystack[start + j]) != needle_lower[j]) {
matched = false;
break;
}
}
if (matched) return true;
}
return false;
}
/// Apply the current account picker selection and return to normal mode.
fn applyAccountPickerSelection(self: *App) void {
if (self.account_picker_cursor == 0) {
// "All accounts" clear filter
self.setAccountFilter(null);
} else {
const idx = self.account_picker_cursor - 1;
if (idx < self.states.portfolio.account_list.items.len) {
self.setAccountFilter(self.states.portfolio.account_list.items[idx]);
}
}
self.mode = .normal;
self.states.portfolio.cursor = 0;
self.scroll_offset = 0;
tab_modules.portfolio.rebuildPortfolioRows(&self.states.portfolio, self);
if (self.states.portfolio.account_filter) |af| {
var tmp_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&tmp_buf, "Filtered: {s}", .{af}) catch "Filtered";
self.setStatus(msg);
} else {
self.setStatus("Filter cleared: showing all accounts");
}
}
/// Load accounts.srf if not already loaded. Derives path from portfolio_path.
pub fn ensureAccountMap(self: *App) void {
if (self.portfolio.account_map != null) return;
@ -1543,8 +1217,31 @@ pub const App = struct {
}
}
/// Returns true if this wheel event should be suppressed (too close to the last one).
fn shouldDebounceWheel(self: *App) bool {
/// Returns true if this wheel event should be suppressed (too
/// close in time to the last one).
///
/// Terminals batch ~3-5 wheel events per physical detent.
/// Whether you want to absorb them depends on the semantics
/// of what wheel-up/down does in the current view:
///
/// - **Cursor-move semantics** ("one detent = one row"):
/// call this and bail on `true`. This is what cursor-bearing
/// tabs use via `moveBy` `onCursorMove`, and what the
/// account-picker modal uses. Without debounce, one detent
/// jumps 5 rows.
///
/// - **Viewport-scroll semantics** ("one detent = N rows of
/// scroll"): do NOT debounce. Burst delivery at, say, 3 rows
/// per event × 5 events = 15 rows feels like fast scroll
/// rather than a glitch, and matches what users expect from
/// non-cursor views (quote chart, perf table, etc).
///
/// The state lives on App because terminals don't distinguish
/// "wheel events on the picker" from "wheel events on the
/// portfolio rows" — there's one ratcheting clock for the
/// whole app, even when the active surface changes between
/// events.
pub fn shouldDebounceWheel(self: *App) bool {
// wall-clock required: input-event debounce needs the actual
// monotonic moment this wheel event arrived, not a frame-captured
// approximation. `.awake` (monotonic) resists system clock jumps.
@ -1726,7 +1423,6 @@ pub const App = struct {
tab_modules.earnings.tab.deinit(&self.states.earnings, self);
tab_modules.options.tab.deinit(&self.states.options, self);
tab_modules.portfolio.tab.deinit(&self.states.portfolio, self);
self.account_search_matches.deinit(self.allocator);
tab_modules.analysis.tab.deinit(&self.states.analysis, self);
self.portfolio.deinit(self.allocator);
tab_modules.history.tab.deinit(&self.states.history, self);
@ -1832,8 +1528,6 @@ pub const App = struct {
if (self.mode == .help) {
try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildHelpStyledLines(ctx.arena));
} else if (self.mode == .account_picker or self.mode == .account_search) {
try tab_modules.portfolio.drawAccountPicker(&self.states.portfolio, self, ctx.arena, buf, width, height);
} else {
switch (self.active_tab) {
.portfolio => try self.drawPortfolioContent(ctx.arena, buf, width, height),
@ -1942,39 +1636,65 @@ pub const App = struct {
const t = self.theme;
const buf = try ctx.arena.alloc(vaxis.Cell, width);
// Truly global modes: symbol input is App-owned.
if (self.mode == .symbol_input) {
self.renderInputPrompt(buf, width, "Symbol: ", " Enter=confirm Esc=cancel ");
} else if (self.mode == .date_input) {
self.renderInputPrompt(buf, width, "As-of: ", " YYYY-MM-DD | 1M | live Enter=confirm ");
} else if (self.mode == .account_picker) {
const prompt_style = t.inputStyle();
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = prompt_style });
const hint = " j/k=navigate Enter=select Esc=cancel Click=select ";
for (0..@min(hint.len, width)) |i| {
buf[i] = .{ .char = .{ .grapheme = glyph(hint[i]) }, .style = prompt_style };
return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
}
// Tab-internal modal? Active tab declares `statusOverride`
// and is in a modal sub-state.
if (self.activeTabStatusOverride()) |override| {
switch (override) {
.hint => |text| {
const prompt_style = t.inputStyle();
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = prompt_style });
for (0..@min(text.len, width)) |i| {
buf[i] = .{ .char = .{ .grapheme = glyph(text[i]) }, .style = prompt_style };
}
},
.input_prompt => |ip| self.renderInputPrompt(buf, width, ip.prompt, ip.hint),
}
return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
}
// Default status bar: getStatus() + optional account-filter
// suffix on the portfolio tab.
const status_style = t.statusStyle();
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = status_style });
if (self.states.portfolio.account_filter != null and self.active_tab == .portfolio) {
const af = self.states.portfolio.account_filter.?;
const msg = self.getStatus(ctx.arena);
const filter_text = std.fmt.allocPrint(ctx.arena, "{s} [Account: {s}]", .{ msg, af }) catch msg;
for (0..@min(filter_text.len, width)) |i| {
buf[i] = .{ .char = .{ .grapheme = glyph(filter_text[i]) }, .style = status_style };
}
} else {
const status_style = t.statusStyle();
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = status_style });
// Show account filter indicator when active, appended to status message
if (self.states.portfolio.account_filter != null and self.active_tab == .portfolio) {
const af = self.states.portfolio.account_filter.?;
const msg = self.getStatus(ctx.arena);
const filter_text = std.fmt.allocPrint(ctx.arena, "{s} [Account: {s}]", .{ msg, af }) catch msg;
for (0..@min(filter_text.len, width)) |i| {
buf[i] = .{ .char = .{ .grapheme = glyph(filter_text[i]) }, .style = status_style };
}
} else {
const msg = self.getStatus(ctx.arena);
for (0..@min(msg.len, width)) |i| {
buf[i] = .{ .char = .{ .grapheme = glyph(msg[i]) }, .style = status_style };
}
const msg = self.getStatus(ctx.arena);
for (0..@min(msg.len, width)) |i| {
buf[i] = .{ .char = .{ .grapheme = glyph(msg[i]) }, .style = status_style };
}
}
return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
}
/// Call the active tab's `statusOverride` hook (when declared)
/// and return its result. Comptime-walks `tab_modules` to find
/// the matching scope. Used by `drawStatusBar` to let tabs
/// take over the status row during tab-internal modals.
fn activeTabStatusOverride(self: *App) ?tab_framework.StatusOverride {
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
const Module = @field(tab_modules, field.name);
if (!@hasDecl(Module.tab, "statusOverride")) return null;
const state_ptr = &@field(self.states, field.name);
return Module.tab.statusOverride(state_ptr, self);
}
}
return null;
}
// Portfolio content
fn drawPortfolioContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {

139
src/tui/input_buffer.zig Normal file
View file

@ -0,0 +1,139 @@
//! Shared input-buffer state machine for the TUI's modal text
//! prompts (symbol input, projections' as-of date input, etc).
//!
//! Pure free function over `(buf, len_ptr, key)` no App or
//! tab-state coupling. Callers own:
//!
//! - The byte buffer (typically a fixed-size `[16]u8`).
//! - The `len: *usize` cursor into it.
//! - Mode/modal cleanup on `cancelled` and `committed` results.
//! - Side effects (status messages, downstream dispatch, etc).
//!
//! The state machine handles only:
//! - Esc reset `len` to 0, return `.cancelled`.
//! - Enter return `.committed` (caller reads `buf[0..len]`).
//! - Backspace decrement `len`.
//! - Ctrl+U reset `len` to 0 (readline-style clear).
//! - Printable ASCII append byte, increment `len` (capped at
//! buffer length).
const std = @import("std");
const vaxis = @import("vaxis");
/// Outcome of one `handleKey` call. Caller dispatches on the
/// variant.
pub const Result = enum {
/// Esc pressed. `len.*` has been reset to 0. Caller should
/// clear its modal flag and any related UI state.
cancelled,
/// Enter pressed. Caller reads `buf[0..len.*]` to commit,
/// then resets `len.*` and clears the modal flag itself.
committed,
/// Character appended / removed / cleared. Caller should
/// just redraw; no further action.
edited,
/// Key didn't match any input-buffer semantic (e.g. a
/// function key). Caller may layer on its own handling.
ignored,
};
/// Apply a key event to the shared input buffer state machine.
/// See module-level docs for the contract.
pub fn handleKey(buf: []u8, len: *usize, key: vaxis.Key) Result {
if (key.codepoint == vaxis.Key.escape) {
len.* = 0;
return .cancelled;
}
if (key.codepoint == vaxis.Key.enter) {
return .committed;
}
if (key.codepoint == vaxis.Key.backspace) {
if (len.* > 0) len.* -= 1;
return .edited;
}
// Ctrl+U: clear entire input (readline convention)
if (key.matches('u', .{ .ctrl = true })) {
len.* = 0;
return .edited;
}
// Accept printable ASCII (letters, digits, common punctuation).
if (key.codepoint < std.math.maxInt(u7) and std.ascii.isPrint(@intCast(key.codepoint)) and len.* < buf.len) {
buf[len.*] = @intCast(key.codepoint);
len.* += 1;
return .edited;
}
return .ignored;
}
// Tests
const testing = std.testing;
test "handleKey: escape resets len and returns cancelled" {
var buf: [16]u8 = undefined;
var len: usize = 5;
const result = handleKey(&buf, &len, .{ .codepoint = vaxis.Key.escape });
try testing.expectEqual(Result.cancelled, result);
try testing.expectEqual(@as(usize, 0), len);
}
test "handleKey: enter returns committed without touching len" {
var buf: [16]u8 = undefined;
@memcpy(buf[0..3], "abc");
var len: usize = 3;
const result = handleKey(&buf, &len, .{ .codepoint = vaxis.Key.enter });
try testing.expectEqual(Result.committed, result);
try testing.expectEqual(@as(usize, 3), len);
try testing.expectEqualStrings("abc", buf[0..len]);
}
test "handleKey: printable ASCII appends and increments len" {
var buf: [16]u8 = undefined;
var len: usize = 0;
const result = handleKey(&buf, &len, .{ .codepoint = 'x' });
try testing.expectEqual(Result.edited, result);
try testing.expectEqual(@as(usize, 1), len);
try testing.expectEqual(@as(u8, 'x'), buf[0]);
}
test "handleKey: backspace decrements len" {
var buf: [16]u8 = undefined;
var len: usize = 3;
const result = handleKey(&buf, &len, .{ .codepoint = vaxis.Key.backspace });
try testing.expectEqual(Result.edited, result);
try testing.expectEqual(@as(usize, 2), len);
}
test "handleKey: backspace at len=0 stays at 0" {
var buf: [16]u8 = undefined;
var len: usize = 0;
const result = handleKey(&buf, &len, .{ .codepoint = vaxis.Key.backspace });
try testing.expectEqual(Result.edited, result);
try testing.expectEqual(@as(usize, 0), len);
}
test "handleKey: ctrl+U clears buffer" {
var buf: [16]u8 = undefined;
var len: usize = 5;
const result = handleKey(&buf, &len, .{ .codepoint = 'u', .mods = .{ .ctrl = true } });
try testing.expectEqual(Result.edited, result);
try testing.expectEqual(@as(usize, 0), len);
}
test "handleKey: append capped at buffer length" {
var buf: [3]u8 = undefined;
var len: usize = 3;
const result = handleKey(&buf, &len, .{ .codepoint = 'x' });
// Returns .ignored (or whatever the cap path produces) len should not advance past buf.len
try testing.expectEqual(Result.ignored, result);
try testing.expectEqual(@as(usize, 3), len);
}
test "handleKey: unrecognized key returns ignored" {
var buf: [16]u8 = undefined;
var len: usize = 0;
// F1 is a non-printable, non-special-handled key
const result = handleKey(&buf, &len, .{ .codepoint = vaxis.Key.f1 });
try testing.expectEqual(Result.ignored, result);
try testing.expectEqual(@as(usize, 0), len);
}

View file

@ -45,7 +45,7 @@ pub const Action = enum {
sort_col_prev,
/// Flip the current sort direction (asc desc).
sort_reverse,
/// Open the account picker modal (mode = .account_picker).
/// Open the account picker modal (state.modal = .account_picker).
/// No-op if no portfolio is loaded.
open_account_picker,
/// Clear the active account filter (return to "all accounts").
@ -58,6 +58,24 @@ pub const Action = enum {
// Tab-private state
/// Tab-internal modal sub-state. The picker is "modal" only from
/// the portfolio tab's perspective App treats the tab the same
/// as any other; the tab itself swallows input and re-routes
/// drawing while a modal is active. App.Mode does NOT carry
/// these variants.
pub const Modal = enum {
/// No modal active. Normal portfolio behavior (table view +
/// keymap actions).
none,
/// Account-picker overlay open. Picker keys (j/k/Enter/Esc/...)
/// are routed to `handleAccountPickerKey`; everything else is
/// swallowed so global actions don't fire underneath.
account_picker,
/// Search-within-picker active (entered from picker via `/`).
/// Routed to `handleAccountSearchKey`.
account_search,
};
pub const State = struct {
/// Selected row in the portfolio view.
cursor: usize = 0,
@ -117,6 +135,39 @@ pub const State = struct {
/// call to skip redundant network round-trips. Owned by State;
/// freed after first consumption.
prefetched_prices: ?std.StringHashMap(f64) = null,
// Account picker / search modal
//
// The portfolio tab owns picker state in full: the cursor,
// search buffer, and the modal sub-state itself
// (`state.modal`). The picker is "modal" only from
// portfolio's perspective the framework treats the tab the
// same as any other; portfolio's own `handleKey` /
// `handleMouse` / `drawContent` / `drawStatusBar` check
// `state.modal` and route accordingly. App.Mode does NOT
// carry picker variants.
/// Cursor position in the picker (0 = "All accounts", N = nth
/// account in `account_list` shifted by 1).
account_picker_cursor: usize = 0,
/// Search-mode input buffer (active when
/// `state.modal == .account_search`).
account_search_buf: [64]u8 = undefined,
/// Live length of `account_search_buf`.
account_search_len: usize = 0,
/// Indices into `account_list` that match the current search
/// query.
account_search_matches: std.ArrayList(usize) = .empty,
/// Cursor within `account_search_matches` (which match is
/// currently highlighted).
account_search_cursor: usize = 0,
/// Tab-internal modal sub-state (account picker / search).
/// See `Modal` for variants. App's draw / event dispatchers
/// remain mode-agnostic; portfolio's own `handleKey` /
/// `handleMouse` / `drawContent` / `drawStatusBar` check
/// this and route accordingly.
modal: Modal = .none,
};
// Tab framework contract
@ -171,6 +222,7 @@ pub const tab = struct {
state.account_list.deinit(app.allocator);
state.account_numbers.deinit(app.allocator);
state.account_shortcut_keys.deinit(app.allocator);
state.account_search_matches.deinit(app.allocator);
state.* = .{};
}
@ -221,13 +273,13 @@ pub const tab = struct {
},
.open_account_picker => {
if (app.portfolio.file == null) return;
app.mode = .account_picker;
state.modal = .account_picker;
// Position cursor on the currently-active filter (or 0 for "All")
app.account_picker_cursor = 0;
state.account_picker_cursor = 0;
if (state.account_filter) |af| {
for (state.account_list.items, 0..) |acct, ai| {
if (std.mem.eql(u8, acct, af)) {
app.account_picker_cursor = ai + 1; // +1 because 0 = "All accounts"
state.account_picker_cursor = ai + 1; // +1 because 0 = "All accounts"
break;
}
}
@ -285,10 +337,47 @@ pub const tab = struct {
return true;
}
/// Mouse handling: clicks on the column-header row sort by
/// that column; clicks on a data row move the cursor and
/// toggle expand/collapse. Returns `true` if consumed.
/// Pre-empt key handler. Called by the framework BEFORE
/// global keymap matching runs. When portfolio is in a
/// modal sub-state (`state.modal != .none`) we route to the
/// modal's key handler and consume the event so global
/// actions (refresh, tab switch, etc) don't fire underneath.
/// When not in a modal, we return `false` so dispatch falls
/// through to the normal global tab-local path.
pub fn handleKey(state: *State, app: *App, key: vaxis.Key) bool {
return switch (state.modal) {
.none => false,
.account_picker => handleAccountPickerKey(state, app, key),
.account_search => handleAccountSearchKey(state, app, key),
};
}
/// Status-bar override. The picker and search modals get
/// their own hint line; otherwise the App-level default
/// status applies.
pub fn statusOverride(state: *State, app: *App) ?framework.StatusOverride {
_ = app;
return switch (state.modal) {
.none => null,
.account_picker => .{ .hint = " j/k=navigate Enter=select Esc=cancel /=search Click=select " },
.account_search => .{ .hint = " type to filter Enter=select Esc=cancel Ctrl+N/Ctrl+P=cycle " },
};
}
/// Mouse handling. In account-picker mode, drives the modal
/// (wheel scroll, click-to-select). Otherwise: clicks on the
/// column-header row sort by that column; clicks on a data
/// row move the cursor and toggle expand/collapse. Returns
/// `true` if consumed.
pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool {
// Account picker modal swallows all mouse events when
// active, regardless of where they land. This includes
// tab-bar clicks (row 0): the modal blocks tab switching
// until dismissed.
if (state.modal != .none) {
return handleAccountPickerMouse(state, app, mouse);
}
if (mouse.button != .left) return false;
if (mouse.type != .press) return false;
const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset;
@ -1141,6 +1230,13 @@ fn computeFilteredTotals(state: *const State, app: *const App) FilteredTotals {
// Rendering
pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
// Modal sub-state takes over the content surface entirely.
// Picker overlay replaces the portfolio table while open.
if (state.modal != .none) {
try drawAccountPicker(state, app, arena, buf, width, height);
return;
}
const th = app.theme;
if (app.portfolio.file == null and app.watchlist == null) {
@ -1773,7 +1869,7 @@ pub fn drawAccountPicker(state: *State, app: *App, arena: std.mem.Allocator, buf
const th = app.theme;
var lines: std.ArrayList(tui.StyledLine) = .empty;
const is_searching = app.mode == .account_search;
const is_searching = state.modal == .account_search;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Filter by Account", .style = th.headerStyle() });
@ -1782,10 +1878,10 @@ pub fn drawAccountPicker(state: *State, app: *App, arena: std.mem.Allocator, buf
// Build a set of search-highlighted indices for fast lookup
var search_highlight = std.AutoHashMap(usize, void).init(arena);
var search_cursor_idx: ?usize = null;
if (is_searching and app.account_search_matches.items.len > 0) {
for (app.account_search_matches.items, 0..) |match_idx, si| {
if (is_searching and state.account_search_matches.items.len > 0) {
for (state.account_search_matches.items, 0..) |match_idx, si| {
search_highlight.put(match_idx, {}) catch {};
if (si == app.account_search_cursor) search_cursor_idx = match_idx;
if (si == state.account_search_cursor) search_cursor_idx = match_idx;
}
}
@ -1795,13 +1891,13 @@ pub fn drawAccountPicker(state: *State, app: *App, arena: std.mem.Allocator, buf
const is_selected = if (is_searching)
(if (search_cursor_idx) |sci| i == sci + 1 else false)
else
i == app.account_picker_cursor;
i == state.account_picker_cursor;
const marker: []const u8 = if (is_selected) " > " else " ";
if (i == 0) {
const text = try std.fmt.allocPrint(arena, "{s}A: All accounts", .{marker});
const style = if (is_selected) th.selectStyle() else th.contentStyle();
const dimmed = is_searching and app.account_search_len > 0;
const dimmed = is_searching and state.account_search_len > 0;
try lines.append(arena, .{ .text = text, .style = if (dimmed) th.mutedStyle() else style });
} else {
const acct_idx = i - 1;
@ -1820,7 +1916,7 @@ pub fn drawAccountPicker(state: *State, app: *App, arena: std.mem.Allocator, buf
try std.fmt.allocPrint(arena, "{s} {s}", .{ marker, label });
var style = if (is_selected) th.selectStyle() else th.contentStyle();
if (is_searching and app.account_search_len > 0) {
if (is_searching and state.account_search_len > 0) {
if (search_highlight.contains(acct_idx)) {
if (!is_selected) style = th.headerStyle();
} else {
@ -1834,8 +1930,8 @@ pub fn drawAccountPicker(state: *State, app: *App, arena: std.mem.Allocator, buf
// Search prompt at the bottom
if (is_searching) {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const query = app.account_search_buf[0..app.account_search_len];
const match_count = app.account_search_matches.items.len;
const query = state.account_search_buf[0..state.account_search_len];
const match_count = state.account_search_matches.items.len;
const prompt = if (query.len > 0)
try std.fmt.allocPrint(arena, " /{s} ({d} match{s})", .{
query,
@ -1854,7 +1950,7 @@ pub fn drawAccountPicker(state: *State, app: *App, arena: std.mem.Allocator, buf
const effective_cursor = if (is_searching)
(if (search_cursor_idx) |sci| sci + 1 else 0)
else
app.account_picker_cursor;
state.account_picker_cursor;
const cursor_line = effective_cursor + account_picker_header_lines;
var start: usize = 0;
if (cursor_line >= height) {
@ -1865,6 +1961,271 @@ pub fn drawAccountPicker(state: *State, app: *App, arena: std.mem.Allocator, buf
try app.drawStyledContent(arena, buf, width, height, lines.items[start..]);
}
/// Mouse handling for the account-picker modal. Wheel scrolls
/// the cursor; left-click on a list item selects + applies +
/// dismisses. Returns `true` for any consumed event (including
/// non-content clicks) so the picker swallows everything in its
/// mode and the rest of the app's mouse pipeline doesn't see it.
pub fn handleAccountPickerMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool {
const total_items = state.account_list.items.len + 1;
switch (mouse.button) {
.wheel_up => {
if (app.shouldDebounceWheel()) return true;
if (state.account_picker_cursor > 0)
state.account_picker_cursor -= 1;
return true;
},
.wheel_down => {
if (app.shouldDebounceWheel()) return true;
if (total_items > 0 and state.account_picker_cursor < total_items - 1)
state.account_picker_cursor += 1;
return true;
},
.left => {
if (mouse.type != .press) return true;
// Map click row to picker item index. The picker is
// drawn at content origin (row 1), but the existing
// hit-test uses raw `mouse.row` against
// `account_picker_header_lines` preserve that
// behavior. (Drift in the picker layout would shift
// the off-by-one; not changing it here.)
const content_row = @as(usize, @intCast(mouse.row));
if (content_row >= account_picker_header_lines) {
const item_idx = content_row - account_picker_header_lines;
if (item_idx < total_items) {
state.account_picker_cursor = item_idx;
applyAccountPickerSelection(state, app);
return true;
}
}
return true;
},
else => return true,
}
}
/// Key handler for the account-picker modal. Called from
/// `handleKey` (the framework pre-empt hook) when
/// `state.modal == .account_picker`.
///
/// Modal keys are hardcoded universal conventions (Enter/Esc/q
/// to dismiss, '/' to enter search, 'A' for "All accounts",
/// shortcut keys for instant select). Navigation (j/k/g/G) uses
/// the global keymap so user-rebound nav keys work consistently.
///
/// Returns `true` for any consumed key including unrecognized
/// keys, which are intentionally swallowed so they can't
/// "leak" through to global keymap matching while the modal is
/// open.
fn handleAccountPickerKey(state: *State, app: *App, key: vaxis.Key) bool {
const total_items = state.account_list.items.len + 1; // +1 for "All accounts"
if (key.codepoint == vaxis.Key.escape or key.codepoint == 'q') {
state.modal = .none;
return true;
}
if (key.codepoint == vaxis.Key.enter) {
applyAccountPickerSelection(state, app);
return true;
}
// '/' enters search mode
if (key.matches('/', .{})) {
state.modal = .account_search;
state.account_search_len = 0;
updateAccountSearchMatches(state, app);
return true;
}
// 'A' selects "All accounts" instantly
if (key.matches('A', .{})) {
state.account_picker_cursor = 0;
applyAccountPickerSelection(state, app);
return true;
}
// Check shortcut keys for instant selection
if (key.codepoint < std.math.maxInt(u7) and key.matches(key.codepoint, .{})) {
const ch: u8 = @intCast(key.codepoint);
for (state.account_shortcut_keys.items, 0..) |shortcut, i| {
if (shortcut == ch) {
state.account_picker_cursor = i + 1; // +1 for "All accounts" at 0
applyAccountPickerSelection(state, app);
return true;
}
}
}
// Navigation via keymap
if (app.keymap.matchAction(key)) |action| {
switch (action) {
.select_next => {
if (total_items > 0 and state.account_picker_cursor < total_items - 1)
state.account_picker_cursor += 1;
},
.select_prev => {
if (state.account_picker_cursor > 0)
state.account_picker_cursor -= 1;
},
.scroll_top => {
state.account_picker_cursor = 0;
},
.scroll_bottom => {
if (total_items > 0)
state.account_picker_cursor = total_items - 1;
},
else => {},
}
}
// Swallow unrecognized keys modal contract.
return true;
}
/// Key handler for the account-search modal (`/` within picker).
/// Called from `handleKey` when `state.modal == .account_search`.
/// Modal keys are hardcoded.
///
/// Returns `true` for any consumed key (always modal swallows
/// everything).
fn handleAccountSearchKey(state: *State, app: *App, key: vaxis.Key) bool {
// Escape: cancel search, return to picker
if (key.codepoint == vaxis.Key.escape) {
state.modal = .account_picker;
state.account_search_len = 0;
return true;
}
// Enter: select the first match (or current search cursor)
if (key.codepoint == vaxis.Key.enter) {
if (state.account_search_matches.items.len > 0) {
const match_idx = state.account_search_matches.items[state.account_search_cursor];
state.account_picker_cursor = match_idx + 1; // +1 for "All accounts"
}
state.account_search_len = 0;
applyAccountPickerSelection(state, app);
return true;
}
// Ctrl+N / Ctrl+P or arrow keys to cycle through matches
if (key.matches('n', .{ .ctrl = true }) or key.codepoint == vaxis.Key.down) {
if (state.account_search_matches.items.len > 0 and
state.account_search_cursor < state.account_search_matches.items.len - 1)
state.account_search_cursor += 1;
return true;
}
if (key.matches('p', .{ .ctrl = true }) or key.codepoint == vaxis.Key.up) {
if (state.account_search_cursor > 0)
state.account_search_cursor -= 1;
return true;
}
// Backspace
if (key.codepoint == vaxis.Key.backspace) {
if (state.account_search_len > 0) {
state.account_search_len -= 1;
updateAccountSearchMatches(state, app);
}
return true;
}
// Ctrl+U: clear search
if (key.matches('u', .{ .ctrl = true })) {
state.account_search_len = 0;
updateAccountSearchMatches(state, app);
return true;
}
// Printable ASCII
if (key.codepoint < std.math.maxInt(u7) and std.ascii.isPrint(@intCast(key.codepoint)) and state.account_search_len < state.account_search_buf.len) {
state.account_search_buf[state.account_search_len] = @intCast(key.codepoint);
state.account_search_len += 1;
updateAccountSearchMatches(state, app);
return true;
}
// Swallow unrecognized keys modal contract.
return true;
}
/// Update search match indices based on current search string.
/// Searches account name AND account number (so users can find
/// "401k" by typing the number from accounts.srf).
fn updateAccountSearchMatches(state: *State, app: *App) void {
state.account_search_matches.clearRetainingCapacity();
const query = state.account_search_buf[0..state.account_search_len];
if (query.len == 0) return;
var lower_query: [64]u8 = undefined;
for (query, 0..) |c, i| lower_query[i] = std.ascii.toLower(c);
const lq = lower_query[0..query.len];
for (state.account_list.items, 0..) |acct, i| {
if (containsLower(acct, lq)) {
state.account_search_matches.append(app.allocator, i) catch continue;
} else if (i < state.account_numbers.items.len) {
if (state.account_numbers.items[i]) |num| {
if (containsLower(num, lq)) {
state.account_search_matches.append(app.allocator, i) catch continue;
}
}
}
}
if (state.account_search_cursor >= state.account_search_matches.items.len) {
state.account_search_cursor = if (state.account_search_matches.items.len > 0)
state.account_search_matches.items.len - 1
else
0;
}
}
/// Case-insensitive substring search. Linear scan; haystacks here
/// are short (account names) so a smarter algorithm wouldn't pay
/// off.
fn containsLower(haystack: []const u8, needle_lower: []const u8) bool {
if (needle_lower.len == 0) return true;
if (haystack.len < needle_lower.len) return false;
const end = haystack.len - needle_lower.len + 1;
for (0..end) |start| {
var matched = true;
for (0..needle_lower.len) |j| {
if (std.ascii.toLower(haystack[start + j]) != needle_lower[j]) {
matched = false;
break;
}
}
if (matched) return true;
}
return false;
}
/// Apply the current account picker selection and return to
/// normal mode. Selection 0 = "All accounts" (clears filter);
/// 1..N = nth account in `account_list`.
fn applyAccountPickerSelection(state: *State, app: *App) void {
if (state.account_picker_cursor == 0) {
// "All accounts" clear filter
app.setAccountFilter(null);
} else {
const idx = state.account_picker_cursor - 1;
if (idx < state.account_list.items.len) {
app.setAccountFilter(state.account_list.items[idx]);
}
}
state.modal = .none;
state.cursor = 0;
app.scroll_offset = 0;
rebuildPortfolioRows(state, app);
if (state.account_filter) |af| {
var tmp_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&tmp_buf, "Filtered: {s}", .{af}) catch "Filtered";
app.setStatus(msg);
} else {
app.setStatus("Filter cleared: showing all accounts");
}
}
// Tests
const testing = std.testing;

View file

@ -34,7 +34,9 @@ const performance = @import("../analytics/performance.zig");
const valuation = @import("../analytics/valuation.zig");
const view = @import("../views/projections.zig");
const history = @import("../history.zig");
const cli = @import("../commands/common.zig");
const framework = @import("tab_framework.zig");
const input_buffer = @import("input_buffer.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;
@ -126,6 +128,25 @@ pub const State = struct {
/// meaningful when `as_of` is set; the action flashes a status
/// message and leaves this off otherwise.
overlay_actuals: bool = false,
/// Tab-internal modal sub-state. The framework treats the
/// tab as normal; projections' own `handleKey` /
/// `statusOverride` hooks branch on this and route input
/// to the modal handler. App.Mode does NOT carry the
/// `date_input` variant.
modal: Modal = .none,
};
/// Tab-internal modal sub-state. Today only one modal: the
/// as-of date input prompt (`d` keybind). Add variants here
/// if/when projections grows more modals.
pub const Modal = enum {
/// No modal active.
none,
/// Date-input prompt is open. Reads from App's shared
/// `input_buf` / `input_len`; commits via
/// `cli.parseAsOfDate`. Same scaffolding as `symbol_input`.
date_input,
};
// Tab framework contract
@ -184,6 +205,33 @@ pub const tab = struct {
pub const tick = framework.noopTick(State);
/// Pre-empt key handler. When the date-input modal is open
/// (`state.modal == .date_input`), every key goes through
/// here global keymap matching is bypassed so typing `r`
/// during input doesn't fire the refresh action. Returns
/// `false` when no modal is active so dispatch falls through
/// to the normal global tab-local path.
pub fn handleKey(state: *State, app: *App, key: vaxis.Key) bool {
return switch (state.modal) {
.none => false,
.date_input => handleDateInputKey(state, app, key),
};
}
/// Status-bar override. The date-input modal renders an
/// interactive prompt with the live input buffer + cursor;
/// otherwise the App-level default status applies.
pub fn statusOverride(state: *State, app: *App) ?framework.StatusOverride {
_ = app;
return switch (state.modal) {
.none => null,
.date_input => .{ .input_prompt = .{
.prompt = "As-of: ",
.hint = " YYYY-MM-DD | 1M | live Enter=confirm ",
} },
};
}
pub fn handleAction(state: *State, app: *App, action: Action) void {
switch (action) {
.overlay_actuals => {
@ -226,10 +274,10 @@ pub const tab = struct {
app.setStatus(status_msg);
},
.as_of_input => {
app.mode = .date_input;
state.modal = .date_input;
app.input_len = 0;
// No setStatus drawStatusBar replaces the whole
// line with the prompt + hint when mode is .date_input.
// No setStatus `statusOverride` returns the
// input prompt while `state.modal == .date_input`.
},
.clear_as_of => {
// No-op when no as-of date is set. Returns to the
@ -1450,6 +1498,67 @@ fn appendReturnRow(
});
}
/// Key handler for the date-input modal (`d` keybind on
/// projections). Accepts the same input as the CLI `--as-of`
/// flag `YYYY-MM-DD`, relative shortcuts (`1W`, `1M`, `3M`,
/// `1Q`, `1Y`, `3Y`, `5Y`), or `live` / empty for live state.
/// Commit via Enter, cancel via Esc.
///
/// Returns `true` for any consumed key. Always consumes:
/// modal contract keys can't leak through to global keymap
/// matching while the prompt is open. Cleanup of
/// `state.modal` and `app.input_len` happens here on
/// cancel/commit; the shared `handleInputBuffer` no longer
/// touches mode/modal state (its callers do).
fn handleDateInputKey(state: *State, app: *App, key: vaxis.Key) bool {
switch (input_buffer.handleKey(&app.input_buf, &app.input_len, key)) {
.cancelled => {
state.modal = .none;
app.setStatus("Cancelled");
return true;
},
.edited => return true,
.ignored => return true,
.committed => {
const input = app.input_buf[0..app.input_len];
const parsed = cli.parseAsOfDate(input, app.today) catch |err| {
var buf: [256]u8 = undefined;
const msg = cli.fmtAsOfParseError(&buf, input, err);
app.setStatus(msg);
state.modal = .none;
app.input_len = 0;
return true;
};
if (parsed) |d| {
// Guard against future dates.
if (d.days > app.today.days) {
app.setStatus("As-of date is in the future");
state.modal = .none;
app.input_len = 0;
return true;
}
state.as_of = d;
state.as_of_requested = null;
var status_buf: [64]u8 = undefined;
const msg = std.fmt.bufPrint(&status_buf, "As-of: {f}", .{d}) catch "As-of set";
app.setStatus(msg);
} else {
// `null` parse result = live.
state.as_of = null;
state.as_of_requested = null;
app.setStatus("As-of cleared — showing live");
}
tab.reload(state, app) catch {};
state.modal = .none;
app.input_len = 0;
return true;
},
}
}
// Tests
const testing = std.testing;

View file

@ -60,6 +60,7 @@
//! pub fn handleKey(state: *State, app: *App, key: vaxis.Key) bool { ... }
//! pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool { ... }
//! pub fn handlePaste(state: *State, app: *App, text: []const u8) bool { ... }
//! pub fn statusOverride(state: *State, app: *App) ?framework.StatusOverride { ... }
//!
//! // Context-change hooks (optional)
//! // Fire when a global context this tab depends on changes.
@ -130,6 +131,36 @@ pub fn TabBinding(comptime ActionT: type) type {
};
}
/// Returned by a tab's optional `statusOverride` hook to take
/// over the status-bar row while a tab-internal modal is active
/// (account picker, date input, etc).
///
/// Tabs declare their intent here and the App renders it; tabs
/// don't poke vaxis cells directly. Adding a new modal style is
/// "add a variant + render arm in App.drawStatusBar."
pub const StatusOverride = union(enum) {
/// Render `text` as a full-width hint line in the input
/// style. Used for navigation-only modals (e.g. account
/// picker, where the modal accepts j/k/Enter/Esc but has no
/// text input).
hint: []const u8,
/// Render an interactive input prompt. The App draws
/// `prompt`, the live input buffer with cursor, and a
/// right-aligned `hint`. Used for modals that accept text
/// input (e.g. date input on projections).
///
/// Note: the input buffer itself is App-owned shared state
/// (`app.input_buf` / `app.input_len`), so only one input
/// prompt can be active at a time. Modals that need their
/// own buffer will require a framework extension; today,
/// shared input is sufficient.
input_prompt: struct {
prompt: []const u8,
hint: []const u8,
},
};
/// Argument passed to `onScroll` indicating which extreme the
/// user scrolled to. `top` corresponds to the global `scroll_top`
/// action (default: `g`); `bottom` corresponds to `scroll_bottom`
@ -361,6 +392,15 @@ pub fn validateTabModule(comptime Module: type) void {
"pub fn handlePaste(state: *State, app: *App, text: []const u8) bool { ... }",
);
}
if (@hasDecl(tab_decl, "statusOverride")) {
expectFn(
mod_name,
tab_decl,
"statusOverride",
fn (*State, *App) ?StatusOverride,
"pub fn statusOverride(state: *State, app: *App) ?StatusOverride { ... }",
);
}
// Context-change hooks (optional, typed when present)
if (@hasDecl(tab_decl, "onSymbolChange")) {