//! 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 tab = struct { //! pub const ActionT = Action; //! pub const StateT = State; //! //! /// Default keybindings for this tab. //! pub const default_bindings: []const TabBinding(Action) = &.{ ... }; //! //! /// One label per Action variant for the help overlay. //! pub const action_labels = //! std.enums.EnumArray(Action, []const u8).init(.{ ... }); //! //! /// Subset of actions that show in the status-line hint. //! pub const status_hints: []const Action = &.{ ... }; //! //! // ── 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 { ... } //! //! // ── 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 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 //! /// (`j`/`k`, ↑/↓, mouse wheel). `delta` is signed: positive //! /// = down, negative = up. Magnitude is 1 for keys, larger //! /// for wheel events. 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. //! pub fn onCursorMove(state: *State, app: *App, delta: isize) bool { ... } //! //! // ── Misc (required) ───────────────────────────────────── //! pub fn isDisabled(app: *App) bool { ... } //! }; //! ``` //! //! Tabs without keybind actions ship with `Action = enum {}` and //! empty `default_bindings` / `action_labels` / `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"); /// Re-exported KeyCombo so tab modules don't need to import /// keybinds.zig directly for binding declarations. pub const KeyCombo = struct { codepoint: u21, mods: vaxis.Key.Modifiers = .{}, }; /// 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, }; } /// 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 sites: the registry in `src/tui.zig` calls this once per // entry. Each `_tab.zig` can also opt in via a comptime // block in the file itself for faster local feedback: // // ```zig // comptime { framework.validateTabModule(@This()); } // ``` /// 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;`"); } // ── Binding / label / hint constants ─────────────────── expectDeclWithType( mod_name, tab_decl, "default_bindings", []const TabBinding(Action), "pub const default_bindings: []const TabBinding(Action) = &.{ ... };", ); expectDeclWithType( mod_name, tab_decl, "action_labels", std.enums.EnumArray(Action, []const u8), "pub const action_labels = std.enums.EnumArray(Action, []const u8).init(.{ ... });", ); expectDeclWithType( mod_name, tab_decl, "status_hints", []const Action, "pub const status_hints: []const Action = &.{ ... };", ); // ── Lifecycle hooks (required) ───────────────────────── expectFnInferredError( mod_name, tab_decl, "init", &.{ *State, *App }, void, "pub fn init(state: *State, app: *App) !void { ... }", ); expectFn( mod_name, tab_decl, "deinit", fn (*State, *App) void, "pub fn deinit(state: *State, app: *App) void { ... }", ); expectFnInferredError( mod_name, tab_decl, "activate", &.{ *State, *App }, void, "pub fn activate(state: *State, app: *App) !void { ... }", ); expectFn( mod_name, tab_decl, "deactivate", fn (*State, *App) void, "pub fn deactivate(state: *State, app: *App) void { ... }", ); expectFnInferredError( mod_name, tab_decl, "reload", &.{ *State, *App }, void, "pub fn reload(state: *State, app: *App) !void { ... }", ); expectFn( mod_name, tab_decl, "tick", fn (*State, *App, u64) void, "pub fn tick(state: *State, app: *App, frame: u64) void { ... }", ); // ── Action dispatch (required) ───────────────────────── expectFn( mod_name, tab_decl, "handleAction", fn (*State, *App, Action) void, "pub fn handleAction(state: *State, app: *App, action: Action) void { ... }", ); // ── Misc (required) ──────────────────────────────────── expectFn( 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")) { expectFn( 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")) { expectFn( 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")) { expectFn( mod_name, tab_decl, "handlePaste", fn (*State, *App, []const u8) bool, "pub fn handlePaste(state: *State, app: *App, text: []const u8) bool { ... }", ); } // ── Context-change hooks (optional, typed when present) ── if (@hasDecl(tab_decl, "onSymbolChange")) { expectFn( mod_name, tab_decl, "onSymbolChange", fn (*State, *App) void, "pub fn onSymbolChange(state: *State, app: *App) void { ... }", ); } if (@hasDecl(tab_decl, "onScroll")) { expectFn( 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")) { expectFn( mod_name, tab_decl, "onCursorMove", fn (*State, *App, isize) bool, "pub fn onCursorMove(state: *State, app: *App, delta: isize) bool { ... }", ); } } } /// Internal helper: assert a `tab` decl exists and has the /// expected type. The `expected_signature` string is shown /// verbatim in the error message so the developer can copy-paste /// the fix. fn expectDeclWithType( comptime mod_name: []const u8, comptime tab_decl: type, comptime decl_name: []const u8, comptime ExpectedT: type, comptime expected_signature: []const u8, ) void { comptime { if (!@hasDecl(tab_decl, decl_name)) { @compileError("Tab module `" ++ mod_name ++ "`: `tab` is missing `" ++ decl_name ++ "`. Expected:\n " ++ expected_signature); } const ActualT = @TypeOf(@field(tab_decl, decl_name)); if (ActualT != ExpectedT) { @compileError("Tab module `" ++ mod_name ++ "`: `tab." ++ decl_name ++ "` has wrong type.\n Expected: " ++ @typeName(ExpectedT) ++ "\n Got: " ++ @typeName(ActualT) ++ "\n Expected signature:\n " ++ expected_signature); } } } /// Internal helper: assert a `tab` function decl exists and has /// the expected fn-pointer type. The `expected_signature` string /// is shown verbatim in the error message. /// /// For functions returning `!void` (or any error union with an /// inferred error set), pass `expectFnInferredError` instead — /// this helper requires exact type equality, which means the /// expected type must use `anyerror` and the actual function must /// also use `anyerror`. In practice that's not what `pub fn foo() /// !void` produces (Zig infers an empty error set), so for /// fallible-but-error-set-inferred hooks we need the looser check. fn expectFn( comptime mod_name: []const u8, comptime tab_decl: type, comptime decl_name: []const u8, comptime ExpectedT: type, comptime expected_signature: []const u8, ) void { comptime { if (!@hasDecl(tab_decl, decl_name)) { @compileError("Tab module `" ++ mod_name ++ "`: `tab` is missing `" ++ decl_name ++ "`. Expected:\n " ++ expected_signature); } const ActualT = @TypeOf(@field(tab_decl, decl_name)); if (ActualT != ExpectedT) { @compileError("Tab module `" ++ mod_name ++ "`: `tab." ++ decl_name ++ "` has wrong signature.\n Expected: " ++ @typeName(ExpectedT) ++ "\n Got: " ++ @typeName(ActualT) ++ "\n Full expected declaration:\n " ++ expected_signature); } } } /// Internal helper: assert a `tab` fallible function decl exists /// and matches the expected parameter types + returns an error /// union ending in `Return`. The error set itself is not checked, /// because `pub fn foo() !void` produces an inferred (often empty) /// error set whose name varies per function. This loosens equality /// to "params match, return is `!Return`." fn expectFnInferredError( comptime mod_name: []const u8, comptime tab_decl: type, comptime decl_name: []const u8, comptime expected_params: []const type, comptime ExpectedReturn: type, comptime expected_signature: []const u8, ) void { comptime { if (!@hasDecl(tab_decl, decl_name)) { @compileError("Tab module `" ++ mod_name ++ "`: `tab` is missing `" ++ decl_name ++ "`. Expected:\n " ++ expected_signature); } const ActualT = @TypeOf(@field(tab_decl, decl_name)); const info = @typeInfo(ActualT); if (info != .@"fn") { @compileError("Tab module `" ++ mod_name ++ "`: `tab." ++ decl_name ++ "` must be a function, got " ++ @typeName(ActualT) ++ "\n Full expected declaration:\n " ++ expected_signature); } const fn_info = info.@"fn"; // Verify params count + types. if (fn_info.params.len != expected_params.len) { @compileError("Tab module `" ++ mod_name ++ "`: `tab." ++ decl_name ++ "` has wrong arity.\n Got: " ++ @typeName(ActualT) ++ "\n Full expected declaration:\n " ++ expected_signature); } for (expected_params, 0..) |Expected, i| { const ActualParam = fn_info.params[i].type orelse { @compileError("Tab module `" ++ mod_name ++ "`: `tab." ++ decl_name ++ "` parameter " ++ std.fmt.comptimePrint("{d}", .{i}) ++ " is `anytype`, but the contract requires `" ++ @typeName(Expected) ++ "`.\n" ++ " Full expected declaration:\n " ++ expected_signature); }; if (ActualParam != Expected) { @compileError("Tab module `" ++ mod_name ++ "`: `tab." ++ decl_name ++ "` parameter " ++ std.fmt.comptimePrint("{d}", .{i}) ++ " has wrong type.\n Expected: " ++ @typeName(Expected) ++ "\n Got: " ++ @typeName(ActualParam) ++ "\n Full expected declaration:\n " ++ expected_signature); } } // Verify return is an error union ending in ExpectedReturn. const ActualReturn = fn_info.return_type orelse { @compileError("Tab module `" ++ mod_name ++ "`: `tab." ++ decl_name ++ "` has no return type.\n Full expected declaration:\n " ++ expected_signature); }; const ret_info = @typeInfo(ActualReturn); if (ret_info != .error_union) { @compileError("Tab module `" ++ mod_name ++ "`: `tab." ++ decl_name ++ "` must return an error union (`!" ++ @typeName(ExpectedReturn) ++ "`), got " ++ @typeName(ActualReturn) ++ "\n Full expected declaration:\n " ++ expected_signature); } if (ret_info.error_union.payload != ExpectedReturn) { @compileError("Tab module `" ++ mod_name ++ "`: `tab." ++ decl_name ++ "` returns wrong payload type.\n Expected: !" ++ @typeName(ExpectedReturn) ++ "\n Got: " ++ @typeName(ActualReturn) ++ "\n Full expected declaration:\n " ++ expected_signature); } } } // ── 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)); }