zfin/src/tui/tab_framework.zig

612 lines
26 KiB
Zig

//! TUI tab framework — common contract types for tab modules.
//!
//! Every TUI tab module exports a `pub const tab = struct { ... };`
//! that conforms to the contract documented here. A small comptime
//! walker in `src/tui.zig` discovers tabs via the `tab_modules`
//! anonymous struct literal and uses these types to wire dispatch,
//! the help overlay, and the status-line hint.
//!
//! ## Contract (per-tab)
//!
//! ```zig
//! pub const Action = enum { /* tab-local keybind actions */ };
//! pub const State = struct { /* tab-private state */ };
//!
//! pub const meta: framework.TabMeta(Action) = .{
//! .label = "...",
//! .default_bindings = &.{ ... },
//! .action_labels = std.enums.EnumArray(Action, []const u8).init(.{ ... }),
//! .status_hints = &.{ ... },
//! };
//!
//! pub const tab = struct {
//! pub const ActionT = Action;
//! pub const StateT = State;
//!
//! // ── Lifecycle hooks (required) ──────────────────────────
//! pub fn init(state: *State, app: *App) !void { ... }
//! pub fn deinit(state: *State, app: *App) void { ... }
//! pub fn activate(state: *State, app: *App) !void { ... }
//! pub fn deactivate(state: *State, app: *App) void { ... }
//! pub fn reload(state: *State, app: *App) !void { ... }
//! pub fn tick(state: *State, app: *App, frame: u64) void { ... }
//!
//! // ── Action dispatch (required) ──────────────────────────
//! pub fn handleAction(state: *State, app: *App, action: Action) void { ... }
//!
//! // ── Event hooks (each optional) ─────────────────────────
//! // Tabs that don't care about an event class simply omit the
//! // method. The framework's dispatcher checks `@hasDecl` and
//! // skips the call entirely. Each method returns `bool` —
//! // true means "consumed; don't fall through to global
//! // handling."
//! //
//! // Chrome ownership: the framework owns the tab bar (row 0)
//! // and filters chrome-region events out before dispatching.
//! // Tab handlers receive only events the framework didn't
//! // claim — so `handleMouse` will never see a row-0 click,
//! // and there's no need for tab handlers to test `row == 0`
//! // themselves. (Future: a per-tab "claim chrome region" hook
//! // would let a tab opt in to drawing into chrome and
//! // receiving its events; for now, chrome is framework-only.)
//! 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.
//! // Tabs that don't care simply omit the method. Contrast with
//! // `reload` (drops data AND triggers a fetch); these hooks
//! // drop data but DON'T trigger a fetch — the fetch happens
//! // lazily on next `activate`.
//! pub fn onSymbolChange(state: *State, app: *App) void { ... }
//!
//! /// Fired when the portfolio file is reloaded (user pressed
//! /// `r`/F5, file watcher triggered, etc.). Every tab that
//! /// holds derived state pointing into the previous portfolio
//! /// (cached `findings_view`, analysis `result`, projection
//! /// caches, row indices, account list) MUST drop it here —
//! /// the underlying portfolio data has already been freed by
//! /// the time this is called.
//! ///
//! /// Tabs that don't hold portfolio-derived state simply omit
//! /// the hook. Broadcast via `App.broadcast`; called BEFORE
//! /// the new portfolio is loaded so tabs see a clean-slate
//! /// state, not a half-populated one.
//! pub fn onPortfolioReload(state: *State, app: *App) void { ... }
//!
//! /// Fired when the user invokes a global scroll-to-extreme
//! /// action (`g`/`G`). Tabs with a cursor reset it to match
//! /// the new scroll position. Tabs without a cursor omit
//! /// this hook.
//! pub fn onScroll(state: *State, app: *App, where: ScrollEdge) void { ... }
//!
//! /// Fired when the user invokes a relative cursor-move via
//! /// keyboard (`j`/`k`, ↑/↓). `delta` is signed: positive =
//! /// down, negative = up. Magnitude is 1 per keypress. Tabs
//! /// with a row cursor step it, clamp to row count, and
//! /// ensure visibility; return `true` to consume. Tabs without
//! /// a cursor (or with empty rows) return `false` so the
//! /// framework falls through to scroll-by-`delta` instead.
//! ///
//! /// **Mouse wheel events go through `onWheelMove`, NOT this
//! /// hook.** A tab that wants wheel-as-cursor-move (the legacy
//! /// portfolio-tab convention) implements `onWheelMove` to
//! /// delegate to `onCursorMove`. A tab that wants
//! /// wheel-as-viewport-scroll (the cleaner default for tabs
//! /// with multiple cursor regions) declines `onWheelMove` and
//! /// the framework scrolls instead.
//! pub fn onCursorMove(state: *State, app: *App, delta: isize) bool { ... }
//!
//! /// Fired when the user wheels the mouse. `delta` is signed:
//! /// positive = down, negative = up. Magnitude is whatever the
//! /// terminal reports per wheel detent (typically 3-5 lines on
//! /// most platforms; the framework already debounces).
//! ///
//! /// Return `true` to consume; return `false` to fall through
//! /// to viewport scroll. If a tab omits this hook entirely,
//! /// the framework's default behavior is to delegate to
//! /// `onCursorMove` — which preserves the legacy
//! /// "wheel moves cursor" behavior for single-cursor tabs.
//! ///
//! /// New multi-region tabs (e.g. review tab with separate
//! /// holdings + findings tables) should declare this hook and
//! /// return `false` so wheel always scrolls the viewport,
//! /// reserving cursor movement for keyboard and click.
//! pub fn onWheelMove(state: *State, app: *App, delta: isize) bool { ... }
//!
//! // ── Misc (required) ─────────────────────────────────────
//! pub fn isDisabled(app: *App) bool { ... }
//! };
//! ```
//!
//! `meta` carries the four declarative fields; `tab` carries the
//! function hooks. Functions can't be bundled into a struct value,
//! so the data/behavior split is a Zig-language concession rather
//! than a stylistic preference. The split mirrors `commands/framework.zig`'s
//! `Meta` for command modules.
//!
//! Tabs without keybind actions ship with `Action = enum {}`,
//! `default_bindings = &.{}`, `action_labels =
//! std.enums.EnumArray(Action, []const u8).initFill("")`, and
//! `status_hints = &.{}`. No implicit defaults — the contract is
//! fully explicit for action-related fields and lifecycle hooks.
//! The event hooks (`handleKey`, `handleMouse`, `handlePaste`) and
//! context-change hooks (`onSymbolChange`) are the exception:
//! their absence means "this tab doesn't process that event class."
//!
//! Lifecycle hooks that aren't meaningful for a given tab can use
//! the `noop*` factory helpers below to inherit no-op
//! implementations:
//!
//! ```zig
//! pub const tab = struct {
//! pub const deactivate = framework.noopDeactivate(State);
//! pub const tick = framework.noopTick(State);
//! // ... other hooks defined explicitly ...
//! };
//! ```
const std = @import("std");
const vaxis = @import("vaxis");
const StyledLine = @import("../tui.zig").StyledLine;
const validator = @import("../comptime_validator.zig");
/// Re-exported KeyCombo so tab modules don't need to import
/// keybinds.zig directly for binding declarations. This is the
/// SAME type as `keybinds.KeyCombo` (re-export, not a copy) — the
/// two names are interchangeable at type-level so values flow
/// freely between framework and keybind code without repacking.
pub const KeyCombo = @import("keybinds.zig").KeyCombo;
/// A single (action, key) pair for a tab's `default_bindings`.
/// Generic over the tab's Action enum so `default_bindings` can
/// be type-checked at comptime against the tab's own enum.
pub fn TabBinding(comptime ActionT: type) type {
return struct {
action: ActionT,
key: KeyCombo,
};
}
/// Per-tab declarative metadata. Mirrors `commands/framework.zig`'s
/// `Meta`: every tab module declares
/// `pub const meta: framework.TabMeta(Action) = .{ ... };` with all
/// fields populated. The compiler enforces the contract via the
/// field types; `validateTabModule` reduces to a single
/// `expectDeclWithType` check on the declaration's type.
///
/// Function hooks (init, deinit, activate, ..., handleAction, the
/// optional event hooks) live on the separate `pub const tab =
/// struct { ... };` namespace because Zig structs can't carry
/// `pub fn` declarations as values. The split is deliberate: data
/// in `meta`, behavior in `tab`.
///
/// Generic over `ActionT` so `default_bindings`, `action_labels`,
/// and `status_hints` can be type-checked at comptime against the
/// tab's own Action enum.
pub fn TabMeta(comptime ActionT: type) type {
return struct {
/// Display name for the tab bar. The framework composes
/// this into `" {N}:{label} "` where N is the tab's
/// 1-indexed registry position.
label: []const u8,
/// Default keybindings for this tab. Tabs without
/// keybind actions ship with `&.{}`.
default_bindings: []const TabBinding(ActionT),
/// One label per Action variant for the help overlay.
/// Tabs without keybind actions use
/// `std.enums.EnumArray(Action, []const u8).init(.{})`.
action_labels: std.enums.EnumArray(ActionT, []const u8),
/// Subset of actions that show in the status-line hint.
/// Tabs without status hints use `&.{}`.
status_hints: []const ActionT,
};
}
/// 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`
/// (default: `G`).
pub const ScrollEdge = enum { top, bottom };
// ── Lifecycle hook factories (no-op defaults) ─────────────────
//
// Tabs that don't need a particular lifecycle hook can declare
// `pub const deactivate = framework.noopDeactivate(State);` etc.
// This keeps the contract explicit (every required field is named
// in the tab struct) while letting tabs avoid writing dummy bodies.
//
// Event hooks (handleKey, handleMouse, handlePaste) and context-
// change hooks (onSymbolChange) are NOT in this list — they're
// optional via `@hasDecl` checking, so a tab that doesn't care
// simply omits the method.
const App = @import("../tui.zig").App;
/// Returns a `deactivate(state, app) void` no-op for the given
/// State type.
pub fn noopDeactivate(comptime StateT: type) fn (*StateT, *App) void {
return struct {
fn f(_: *StateT, _: *App) void {}
}.f;
}
/// Returns a `tick(state, app, frame) void` no-op for the given
/// State type.
pub fn noopTick(comptime StateT: type) fn (*StateT, *App, u64) void {
return struct {
fn f(_: *StateT, _: *App, _: u64) void {}
}.f;
}
/// Returns an `isDisabled(app) bool` that always returns false.
/// The most common case — most tabs are always enabled.
pub fn alwaysEnabled() fn (*App) bool {
return struct {
fn f(_: *App) bool {
return false;
}
}.f;
}
// ── Comptime contract validation ──────────────────────────────
//
// `validateTabModule(comptime Module: type)` walks a tab module
// at comptime and asserts it conforms to the framework contract.
// Every error message includes the full expected signature so the
// developer knows exactly what to add, copy-paste ready.
//
// Call site policy: registry-walk only. The registry in
// `src/tui.zig` calls this once per entry. Do NOT add in-file
// `comptime { framework.validateTabModule(@This()); }` blocks to
// individual tab files — they're redundant with the registry walk
// under both `zig build` and ZLS build-on-save (the only ZLS mode
// that runs comptime), and the registry walk produces a better
// diagnostic. Mirrors the policy in `src/commands/framework.zig`.
/// Validate a tab module against the framework contract. Emits a
/// `@compileError` with the full expected signature for any
/// missing or wrong-shape decl.
///
/// The contract is documented at the top of this file. This
/// function is the source of truth — if you change the contract,
/// update both.
pub fn validateTabModule(comptime Module: type) void {
comptime {
const mod_name = @typeName(Module);
// ── Top-level decls ────────────────────────────────────
if (!@hasDecl(Module, "Action")) {
@compileError("Tab module `" ++ mod_name ++ "` is missing `pub const Action = enum { ... };` " ++
"(use `enum {}` if the tab has no keybind actions)");
}
if (!@hasDecl(Module, "State")) {
@compileError("Tab module `" ++ mod_name ++ "` is missing `pub const State = struct { ... };`");
}
if (!@hasDecl(Module, "tab")) {
@compileError("Tab module `" ++ mod_name ++ "` is missing `pub const tab = struct { ... };` " ++
"(see src/tui/tab_framework.zig for the full contract)");
}
const Action = Module.Action;
const State = Module.State;
const tab_decl = Module.tab;
// Sanity: Action must be an enum, State must be a struct.
if (@typeInfo(Action) != .@"enum") {
@compileError("Tab module `" ++ mod_name ++ "`: `Action` must be an enum type, got " ++ @typeName(Action));
}
if (@typeInfo(State) != .@"struct") {
@compileError("Tab module `" ++ mod_name ++ "`: `State` must be a struct type, got " ++ @typeName(State));
}
// ── Type aliases inside `tab` ──────────────────────────
if (!@hasDecl(tab_decl, "ActionT")) {
@compileError("Tab module `" ++ mod_name ++ "`: `tab` is missing `pub const ActionT = Action;`");
}
if (!@hasDecl(tab_decl, "StateT")) {
@compileError("Tab module `" ++ mod_name ++ "`: `tab` is missing `pub const StateT = State;`");
}
// ── meta (top-level decl, struct value) ────────────────
// One check covers all four declarative fields. Field
// types are enforced by `TabMeta(Action)`'s declaration;
// missing fields surface as Zig's native "missing field"
// comptime errors pointing at the call site.
validator.expectDeclWithType(
"Tab module",
mod_name,
Module,
"meta",
TabMeta(Action),
"pub const meta: framework.TabMeta(Action) = .{ .label = \"...\", .default_bindings = &.{ ... }, .action_labels = std.enums.EnumArray(Action, []const u8).init(.{ ... }), .status_hints = &.{ ... } };",
);
// ── Lifecycle hooks (required) ─────────────────────────
validator.expectFnInferredError(
"Tab module",
mod_name,
tab_decl,
"init",
&.{ *State, *App },
void,
"pub fn init(state: *State, app: *App) !void { ... }",
);
validator.expectFn(
"Tab module",
mod_name,
tab_decl,
"deinit",
fn (*State, *App) void,
"pub fn deinit(state: *State, app: *App) void { ... }",
);
validator.expectFnInferredError(
"Tab module",
mod_name,
tab_decl,
"activate",
&.{ *State, *App },
void,
"pub fn activate(state: *State, app: *App) !void { ... }",
);
validator.expectFn(
"Tab module",
mod_name,
tab_decl,
"deactivate",
fn (*State, *App) void,
"pub fn deactivate(state: *State, app: *App) void { ... }",
);
validator.expectFnInferredError(
"Tab module",
mod_name,
tab_decl,
"reload",
&.{ *State, *App },
void,
"pub fn reload(state: *State, app: *App) !void { ... }",
);
validator.expectFn(
"Tab module",
mod_name,
tab_decl,
"tick",
fn (*State, *App, u64) void,
"pub fn tick(state: *State, app: *App, frame: u64) void { ... }",
);
// ── Action dispatch (required) ─────────────────────────
validator.expectFn(
"Tab module",
mod_name,
tab_decl,
"handleAction",
fn (*State, *App, Action) void,
"pub fn handleAction(state: *State, app: *App, action: Action) void { ... }",
);
// ── Misc (required) ────────────────────────────────────
validator.expectFn(
"Tab module",
mod_name,
tab_decl,
"isDisabled",
fn (*App) bool,
"pub fn isDisabled(app: *App) bool { ... }",
);
// ── Event hooks (optional, but typed when present) ─────
// `@hasDecl` returns true; if the signature is wrong we
// surface that as a hard error so a typo'd signature
// doesn't silently get skipped at dispatch time.
if (@hasDecl(tab_decl, "handleKey")) {
validator.expectFn(
"Tab module",
mod_name,
tab_decl,
"handleKey",
fn (*State, *App, vaxis.Key) bool,
"pub fn handleKey(state: *State, app: *App, key: vaxis.Key) bool { ... }",
);
}
if (@hasDecl(tab_decl, "handleMouse")) {
validator.expectFn(
"Tab module",
mod_name,
tab_decl,
"handleMouse",
fn (*State, *App, vaxis.Mouse) bool,
"pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool { ... }",
);
}
if (@hasDecl(tab_decl, "handlePaste")) {
validator.expectFn(
"Tab module",
mod_name,
tab_decl,
"handlePaste",
fn (*State, *App, []const u8) bool,
"pub fn handlePaste(state: *State, app: *App, text: []const u8) bool { ... }",
);
}
if (@hasDecl(tab_decl, "statusOverride")) {
validator.expectFn(
"Tab module",
mod_name,
tab_decl,
"statusOverride",
fn (*State, *App) ?StatusOverride,
"pub fn statusOverride(state: *State, app: *App) ?StatusOverride { ... }",
);
}
// ── Draw hooks (mutually exclusive, exactly one required) ──
//
// Every tab declares EXACTLY ONE of:
//
// pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine
// pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void
//
// `buildStyledLines` is for line-list tabs (App handles
// scroll_offset clamping + cell rendering). `drawContent`
// is for direct-buffer tabs (cell layout / Kitty
// graphics that don't fit the line-list shape).
//
// The framework's draw dispatcher tries `drawContent`
// first, then falls back to `buildStyledLines`. Declaring
// neither leaves the tab unrenderable; declaring both is
// ambiguous. The validator surfaces both as compile errors.
const has_build = @hasDecl(Module, "buildStyledLines");
const has_draw = @hasDecl(Module, "drawContent");
if (!has_build and !has_draw) {
@compileError("Tab module `" ++ mod_name ++ "` must declare exactly one of " ++
"`buildStyledLines` or `drawContent`. See the framework draw-hook docs in tab_framework.zig.");
}
if (has_build and has_draw) {
@compileError("Tab module `" ++ mod_name ++ "` declares both `buildStyledLines` and " ++
"`drawContent`. Only one is allowed — pick the right one for your tab's render shape.");
}
if (has_build) {
validator.expectFnInferredError(
"Tab module",
mod_name,
Module,
"buildStyledLines",
&.{ *State, *App, std.mem.Allocator },
[]const StyledLine,
"pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine { ... }",
);
}
if (has_draw) {
validator.expectFnInferredError(
"Tab module",
mod_name,
Module,
"drawContent",
&.{ *State, *App, std.mem.Allocator, []vaxis.Cell, u16, u16 },
void,
"pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { ... }",
);
}
// ── Context-change hooks (optional, typed when present) ──
if (@hasDecl(tab_decl, "onSymbolChange")) {
validator.expectFn(
"Tab module",
mod_name,
tab_decl,
"onSymbolChange",
fn (*State, *App) void,
"pub fn onSymbolChange(state: *State, app: *App) void { ... }",
);
}
if (@hasDecl(tab_decl, "onPortfolioReload")) {
validator.expectFn(
"Tab module",
mod_name,
tab_decl,
"onPortfolioReload",
fn (*State, *App) void,
"pub fn onPortfolioReload(state: *State, app: *App) void { ... }",
);
}
if (@hasDecl(tab_decl, "onScroll")) {
validator.expectFn(
"Tab module",
mod_name,
tab_decl,
"onScroll",
fn (*State, *App, ScrollEdge) void,
"pub fn onScroll(state: *State, app: *App, where: ScrollEdge) void { ... }",
);
}
if (@hasDecl(tab_decl, "onCursorMove")) {
validator.expectFn(
"Tab module",
mod_name,
tab_decl,
"onCursorMove",
fn (*State, *App, isize) bool,
"pub fn onCursorMove(state: *State, app: *App, delta: isize) bool { ... }",
);
}
if (@hasDecl(tab_decl, "onWheelMove")) {
validator.expectFn(
"Tab module",
mod_name,
tab_decl,
"onWheelMove",
fn (*State, *App, isize) bool,
"pub fn onWheelMove(state: *State, app: *App, delta: isize) bool { ... }",
);
}
}
}
// ── Tests ─────────────────────────────────────────────────────
const testing = std.testing;
test "TabBinding generic over Action" {
const A = enum { foo, bar };
const B = TabBinding(A);
const b: B = .{ .action = .foo, .key = .{ .codepoint = 'a' } };
try testing.expectEqual(A.foo, b.action);
try testing.expectEqual(@as(u21, 'a'), b.key.codepoint);
}
test "noopDeactivate returns a callable no-op" {
const S = struct { x: u32 = 0 };
const fn_ptr = noopDeactivate(S);
var s: S = .{};
var dummy_app: App = undefined;
fn_ptr(&s, &dummy_app);
try testing.expectEqual(@as(u32, 0), s.x);
}
test "noopTick returns a callable no-op" {
const S = struct { x: u32 = 0 };
const fn_ptr = noopTick(S);
var s: S = .{};
var dummy_app: App = undefined;
fn_ptr(&s, &dummy_app, 42);
try testing.expectEqual(@as(u32, 0), s.x);
}
test "alwaysEnabled returns false" {
const fn_ptr = alwaysEnabled();
var dummy_app: App = undefined;
try testing.expect(!fn_ptr(&dummy_app));
}