612 lines
26 KiB
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));
|
|
}
|