From d9fd5d5b970692040a2057556a0f1af95d4b8fcc Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Fri, 12 Jun 2026 08:17:20 -0700 Subject: [PATCH] more flexible tui async plan --- src/tui.zig | 41 +++++++++++++++------------------ src/tui/review_tab.zig | 48 +++++++++++++++++++-------------------- src/tui/tab_framework.zig | 27 ++++++++++++++++++++++ 3 files changed, 68 insertions(+), 48 deletions(-) diff --git a/src/tui.zig b/src/tui.zig index 7d44891..6f679b3 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -468,18 +468,11 @@ pub const App = struct { /// observation engine async dispatch, etc.) has a wall-clock- /// independent tick to react to. frame: u64 = 0, - /// True when the active tab has async work in flight that needs - /// poll-driven redraws (e.g. review's observation panel with - /// pending async checks). Set by the tab (loadData / activate); - /// cleared by the tab when the work completes or the tab - /// deactivates. While set, the event handler keeps a one-shot - /// vxfw Tick timer armed so the draw loop wakes up to poll — - /// without it, vxfw sleeps until the next user event and async - /// results would sit invisible until a keypress. - wants_poll_tick: bool = false, /// True while a vxfw Tick command is scheduled and not yet /// delivered. Prevents stacking duplicate timers (each event - /// would otherwise arm another). + /// would otherwise arm another). Whether polling is WANTED is + /// derived fresh from the active tab's optional + /// `wantsPollTick` hook — see `typeErasedEventHandler`. poll_tick_armed: bool = false, has_explicit_symbol: bool = false, // true if -s was used @@ -532,16 +525,18 @@ pub const App = struct { fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vaxis.vxfw.EventContext, event: vaxis.vxfw.Event) anyerror!void { const self: *App = @ptrCast(@alignCast(ptr)); - // Poll-driven redraw support: while a tab has async work - // in flight (`wants_poll_tick`), keep a one-shot vxfw - // Tick timer armed. The timer fires a `.tick` event, - // which requests a redraw; the draw pass runs the active - // tab's `tick` hook, which polls the async work and - // clears `wants_poll_tick` when done. Re-armed here on - // every event (including the .tick itself) until the tab - // clears the flag. 100ms cadence — fast enough to feel + // Poll-driven redraw support: while the ACTIVE tab reports + // async work in flight (via its optional `wantsPollTick` + // hook), keep a one-shot vxfw Tick timer armed. The timer + // fires a `.tick` event, which requests a redraw; the draw + // pass runs the active tab's `tick` hook, which polls the + // async work. The decision is derived fresh after every + // event — no stored flag to choreograph across + // activate/deactivate/completion, and tab switches are + // picked up automatically because dispatch targets the + // active tab. 100ms cadence — fast enough to feel // responsive, slow enough to be invisible in CPU terms. - defer if (self.wants_poll_tick and !self.poll_tick_armed) { + defer if (!self.poll_tick_armed and self.dispatchBool("wantsPollTick", .{})) { if (ctx.tick(100, self.widget())) { self.poll_tick_armed = true; } else |err| { @@ -589,11 +584,11 @@ pub const App = struct { }, .tick => { // Our scheduled poll timer fired. Mark it - // un-armed (the defer above re-arms if the tab - // still wants polling) and request a redraw so - // the draw pass runs the tab's tick hook. + // un-armed (the defer above re-arms if the active + // tab still wants polling) and request a redraw + // so the draw pass runs the tab's tick hook. self.poll_tick_armed = false; - if (self.wants_poll_tick) { + if (self.dispatchBool("wantsPollTick", .{})) { ctx.redraw = true; } }, diff --git a/src/tui/review_tab.zig b/src/tui/review_tab.zig index 356198e..8527ece 100644 --- a/src/tui/review_tab.zig +++ b/src/tui/review_tab.zig @@ -260,10 +260,6 @@ pub const tab = struct { pub fn activate(state: *State, app: *App) !void { if (tab.isDisabled(app)) return; - // Re-arm the poll timer if we still have async checks in - // flight from a previous activation (user switched away - // and back while a check was running). - if (state.observations_pending) app.wants_poll_tick = true; if (state.loaded) return; // Load the journal before building findings; `loadData` // joins both into `findings_view`. @@ -271,15 +267,18 @@ pub const tab = struct { loadData(state, app); } - /// Pause poll-driven redraws while this tab is inactive. The - /// tick hook only dispatches to the ACTIVE tab, so leaving - /// `wants_poll_tick` set after switching away would spin the - /// 100ms timer with nobody polling. `activate` re-arms when - /// the user returns; the async check keeps running in the - /// background either way. - pub fn deactivate(state: *State, app: *App) void { - _ = state; - app.wants_poll_tick = false; + pub const deactivate = framework.noopDeactivate(State); + + /// Framework poll-tick hook: true while the observation panel + /// has async checks in flight. Drives the App's poll timer so + /// the `tick` hook below gets called without user input. The + /// framework only asks the ACTIVE tab, so switching away + /// pauses UI polling automatically (the checks keep running + /// in the background; `tick` picks the results up when the + /// user returns). + pub fn wantsPollTick(state: *State, app: *App) bool { + _ = app; + return state.observations_pending; } /// Manual refresh: drops the cached view and re-builds. Also @@ -303,11 +302,11 @@ pub const tab = struct { /// Poll in-flight async observation checks. No-op (one bool /// test) unless `loadData` left the panel incomplete. When /// the last pending check resolves, rebuild the findings - /// view so late findings appear, and clear both the local - /// flag and the App-level poll-tick request so subsequent - /// frames cost nothing. + /// view so late findings appear, and clear the flag — + /// `wantsPollTick` then answers false and the App's poll + /// timer stops re-arming. /// - /// Redraw cadence: while `app.wants_poll_tick` is set, the + /// Redraw cadence: while `wantsPollTick` answers true, the /// App keeps a 100ms vxfw Tick timer armed (see /// `typeErasedEventHandler`), so this hook gets called /// even when the user isn't generating events. Without @@ -320,7 +319,6 @@ pub const tab = struct { const panel = if (view.observations) |*p| p else return; if (!panel.isComplete()) return; state.observations_pending = false; - app.wants_poll_tick = false; rebuildFindingsView(state, app); } @@ -997,17 +995,17 @@ fn loadData(state: *State, app: *App) void { applySort(state); rebuildFindingsView(state, app); - // If any check is still running (async dispatch), arm the - // tick-poll so the findings view refreshes when results - // land. `isComplete` also transitions newly-finished checks, - // so a fast async check that finished during buildReview is + // If any check is still running (async dispatch), flag it so + // the findings view refreshes when results land. + // `isComplete` also transitions newly-finished checks, so a + // fast async check that finished during buildReview is // caught right here without waiting for the next frame. - // `wants_poll_tick` tells the App to keep a vxfw Tick timer - // armed so the poll runs even with no user input. + // While the flag is set, `wantsPollTick` answers true and + // the App keeps a vxfw Tick timer armed so the poll runs + // even with no user input. if (state.view) |*v| { if (v.observations) |*panel| { state.observations_pending = !panel.isComplete(); - if (state.observations_pending) app.wants_poll_tick = true; } } } diff --git a/src/tui/tab_framework.zig b/src/tui/tab_framework.zig index ef0f1a9..d941a7a 100644 --- a/src/tui/tab_framework.zig +++ b/src/tui/tab_framework.zig @@ -54,6 +54,23 @@ //! pub fn handlePaste(state: *State, app: *App, text: []const u8) bool { ... } //! pub fn statusOverride(state: *State, app: *App) ?framework.StatusOverride { ... } //! +//! /// Optional: does this tab currently have async work in +//! /// flight that needs poll-driven redraws? While the ACTIVE +//! /// tab answers true, the App keeps a one-shot vxfw Tick +//! /// timer armed (~100ms cadence) so the draw loop wakes up +//! /// and runs the tab's `tick` hook even with no user input — +//! /// without it, async results would sit invisible until the +//! /// next keypress. +//! /// +//! /// Answer from your own state (e.g. "my observation panel +//! /// has pending checks"); do NOT cache the answer anywhere on +//! /// App. The framework derives the polling decision fresh +//! /// after every event, so tab switches and work completion +//! /// are picked up automatically with zero choreography. +//! /// Inactive tabs are never asked — switching away pauses +//! /// UI polling while background work continues. +//! pub fn wantsPollTick(state: *State, app: *App) 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 @@ -471,6 +488,16 @@ pub fn validateTabModule(comptime Module: type) void { "pub fn statusOverride(state: *State, app: *App) ?StatusOverride { ... }", ); } + if (@hasDecl(tab_decl, "wantsPollTick")) { + validator.expectFn( + "Tab module", + mod_name, + tab_decl, + "wantsPollTick", + fn (*State, *App) bool, + "pub fn wantsPollTick(state: *State, app: *App) bool { ... }", + ); + } // ── Draw hooks (mutually exclusive, exactly one required) ── //