diff --git a/src/tui.zig b/src/tui.zig index 7b56349..d90ece3 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -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 { diff --git a/src/tui/input_buffer.zig b/src/tui/input_buffer.zig new file mode 100644 index 0000000..c5b195e --- /dev/null +++ b/src/tui/input_buffer.zig @@ -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); +} diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 5f1919b..8a17d14 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -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; diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index 59111cf..e96e453 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -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; diff --git a/src/tui/tab_framework.zig b/src/tui/tab_framework.zig index e65ac9c..14f1830 100644 --- a/src/tui/tab_framework.zig +++ b/src/tui/tab_framework.zig @@ -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")) {