more flexible tui async plan
All checks were successful
Generic zig build / build (push) Successful in 12m11s
Generic zig build / publish-macos (push) Successful in 12s
Generic zig build / deploy (push) Successful in 19s

This commit is contained in:
Emil Lerch 2026-06-12 08:17:20 -07:00
parent 8860efb371
commit d9fd5d5b97
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 68 additions and 48 deletions

View file

@ -468,18 +468,11 @@ pub const App = struct {
/// observation engine async dispatch, etc.) has a wall-clock- /// observation engine async dispatch, etc.) has a wall-clock-
/// independent tick to react to. /// independent tick to react to.
frame: u64 = 0, 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 /// True while a vxfw Tick command is scheduled and not yet
/// delivered. Prevents stacking duplicate timers (each event /// 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, poll_tick_armed: bool = false,
has_explicit_symbol: bool = false, // true if -s was used 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 { fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vaxis.vxfw.EventContext, event: vaxis.vxfw.Event) anyerror!void {
const self: *App = @ptrCast(@alignCast(ptr)); const self: *App = @ptrCast(@alignCast(ptr));
// Poll-driven redraw support: while a tab has async work // Poll-driven redraw support: while the ACTIVE tab reports
// in flight (`wants_poll_tick`), keep a one-shot vxfw // async work in flight (via its optional `wantsPollTick`
// Tick timer armed. The timer fires a `.tick` event, // hook), keep a one-shot vxfw Tick timer armed. The timer
// which requests a redraw; the draw pass runs the active // fires a `.tick` event, which requests a redraw; the draw
// tab's `tick` hook, which polls the async work and // pass runs the active tab's `tick` hook, which polls the
// clears `wants_poll_tick` when done. Re-armed here on // async work. The decision is derived fresh after every
// every event (including the .tick itself) until the tab // event no stored flag to choreograph across
// clears the flag. 100ms cadence fast enough to feel // 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. // 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())) { if (ctx.tick(100, self.widget())) {
self.poll_tick_armed = true; self.poll_tick_armed = true;
} else |err| { } else |err| {
@ -589,11 +584,11 @@ pub const App = struct {
}, },
.tick => { .tick => {
// Our scheduled poll timer fired. Mark it // Our scheduled poll timer fired. Mark it
// un-armed (the defer above re-arms if the tab // un-armed (the defer above re-arms if the active
// still wants polling) and request a redraw so // tab still wants polling) and request a redraw
// the draw pass runs the tab's tick hook. // so the draw pass runs the tab's tick hook.
self.poll_tick_armed = false; self.poll_tick_armed = false;
if (self.wants_poll_tick) { if (self.dispatchBool("wantsPollTick", .{})) {
ctx.redraw = true; ctx.redraw = true;
} }
}, },

View file

@ -260,10 +260,6 @@ pub const tab = struct {
pub fn activate(state: *State, app: *App) !void { pub fn activate(state: *State, app: *App) !void {
if (tab.isDisabled(app)) return; 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; if (state.loaded) return;
// Load the journal before building findings; `loadData` // Load the journal before building findings; `loadData`
// joins both into `findings_view`. // joins both into `findings_view`.
@ -271,15 +267,18 @@ pub const tab = struct {
loadData(state, app); loadData(state, app);
} }
/// Pause poll-driven redraws while this tab is inactive. The pub const deactivate = framework.noopDeactivate(State);
/// tick hook only dispatches to the ACTIVE tab, so leaving
/// `wants_poll_tick` set after switching away would spin the /// Framework poll-tick hook: true while the observation panel
/// 100ms timer with nobody polling. `activate` re-arms when /// has async checks in flight. Drives the App's poll timer so
/// the user returns; the async check keeps running in the /// the `tick` hook below gets called without user input. The
/// background either way. /// framework only asks the ACTIVE tab, so switching away
pub fn deactivate(state: *State, app: *App) void { /// pauses UI polling automatically (the checks keep running
_ = state; /// in the background; `tick` picks the results up when the
app.wants_poll_tick = false; /// 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 /// 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 /// Poll in-flight async observation checks. No-op (one bool
/// test) unless `loadData` left the panel incomplete. When /// test) unless `loadData` left the panel incomplete. When
/// the last pending check resolves, rebuild the findings /// the last pending check resolves, rebuild the findings
/// view so late findings appear, and clear both the local /// view so late findings appear, and clear the flag
/// flag and the App-level poll-tick request so subsequent /// `wantsPollTick` then answers false and the App's poll
/// frames cost nothing. /// 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 /// App keeps a 100ms vxfw Tick timer armed (see
/// `typeErasedEventHandler`), so this hook gets called /// `typeErasedEventHandler`), so this hook gets called
/// even when the user isn't generating events. Without /// 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; const panel = if (view.observations) |*p| p else return;
if (!panel.isComplete()) return; if (!panel.isComplete()) return;
state.observations_pending = false; state.observations_pending = false;
app.wants_poll_tick = false;
rebuildFindingsView(state, app); rebuildFindingsView(state, app);
} }
@ -997,17 +995,17 @@ fn loadData(state: *State, app: *App) void {
applySort(state); applySort(state);
rebuildFindingsView(state, app); rebuildFindingsView(state, app);
// If any check is still running (async dispatch), arm the // If any check is still running (async dispatch), flag it so
// tick-poll so the findings view refreshes when results // the findings view refreshes when results land.
// land. `isComplete` also transitions newly-finished checks, // `isComplete` also transitions newly-finished checks, so a
// so a fast async check that finished during buildReview is // fast async check that finished during buildReview is
// caught right here without waiting for the next frame. // caught right here without waiting for the next frame.
// `wants_poll_tick` tells the App to keep a vxfw Tick timer // While the flag is set, `wantsPollTick` answers true and
// armed so the poll runs even with no user input. // the App keeps a vxfw Tick timer armed so the poll runs
// even with no user input.
if (state.view) |*v| { if (state.view) |*v| {
if (v.observations) |*panel| { if (v.observations) |*panel| {
state.observations_pending = !panel.isComplete(); state.observations_pending = !panel.isComplete();
if (state.observations_pending) app.wants_poll_tick = true;
} }
} }
} }

View file

@ -54,6 +54,23 @@
//! pub fn handlePaste(state: *State, app: *App, text: []const u8) bool { ... } //! pub fn handlePaste(state: *State, app: *App, text: []const u8) bool { ... }
//! pub fn statusOverride(state: *State, app: *App) ?framework.StatusOverride { ... } //! 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) //! // Context-change hooks (optional)
//! // Fire when a global context this tab depends on changes. //! // Fire when a global context this tab depends on changes.
//! // Tabs that don't care simply omit the method. Contrast with //! // 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 { ... }", "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) // Draw hooks (mutually exclusive, exactly one required)
// //