//! 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)); }