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-
/// 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;
}
},

View file

@ -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;
}
}
}

View file

@ -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)
//