move tab-modals into tabs from global
This commit is contained in:
parent
15ae2cbf20
commit
e301757311
5 changed files with 810 additions and 441 deletions
562
src/tui.zig
562
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 {
|
||||
|
|
|
|||
139
src/tui/input_buffer.zig
Normal file
139
src/tui/input_buffer.zig
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue