tui: event-driven rendering with dirty bit

Only redraw when something actually changed. The 50ms render
interval still ticks (for process count updates) but no longer
triggers draws. Dirty is set by key events, mouse events,
resize, UI messages, turn completions, and DMN ticks.

Saves bandwidth over SSH and reduces CPU usage when idle.

Co-Developed-By: Kent Overstreet <kent.overstreet@linux.dev>
This commit is contained in:
ProofOfConcept 2026-04-03 18:51:22 -04:00
parent ad5f69abb8
commit a7f19cdc7e

View file

@ -909,9 +909,10 @@ async fn run(cli: cli::CliArgs) -> Result<()> {
// Crossterm event stream // Crossterm event stream
let mut reader = EventStream::new(); let mut reader = EventStream::new();
// Render timer: 20fps // Render timer — only draws when dirty
let mut render_interval = tokio::time::interval(Duration::from_millis(50)); let mut render_interval = tokio::time::interval(Duration::from_millis(50));
render_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); render_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut dirty = true; // draw first frame
// Hide terminal cursor — tui-textarea renders its own cursor as a styled cell // Hide terminal cursor — tui-textarea renders its own cursor as a styled cell
terminal.hide_cursor()?; terminal.hide_cursor()?;
@ -934,13 +935,16 @@ async fn run(cli: cli::CliArgs) -> Result<()> {
continue; continue;
} }
app.handle_key(key); app.handle_key(key);
dirty = true;
} }
Some(Ok(Event::Mouse(mouse))) => { Some(Ok(Event::Mouse(mouse))) => {
app.handle_mouse(mouse); app.handle_mouse(mouse);
dirty = true;
} }
Some(Ok(Event::Resize(w, h))) => { Some(Ok(Event::Resize(w, h))) => {
app.handle_resize(w, h); app.handle_resize(w, h);
terminal.clear()?; terminal.clear()?;
dirty = true;
} }
Some(Err(_)) => break, Some(Err(_)) => break,
None => break, None => break,
@ -951,14 +955,16 @@ async fn run(cli: cli::CliArgs) -> Result<()> {
// Input from observation socket clients // Input from observation socket clients
Some(line) = observe_input_rx.recv() => { Some(line) = observe_input_rx.recv() => {
app.submitted.push(line); app.submitted.push(line);
dirty = true;
} }
// Turn completed in background task // Turn completed in background task
Some((result, target)) = turn_rx.recv() => { Some((result, target)) = turn_rx.recv() => {
session.handle_turn_result(result, target).await; session.handle_turn_result(result, target).await;
dirty = true;
} }
// Render tick // Render tick — only redraws if dirty
_ = render_interval.tick() => { _ = render_interval.tick() => {
app.running_processes = session.process_tracker.list().await.len() as u32; app.running_processes = session.process_tracker.list().await.len() as u32;
} }
@ -966,11 +972,13 @@ async fn run(cli: cli::CliArgs) -> Result<()> {
// DMN timer (only when no turn is running) // DMN timer (only when no turn is running)
_ = tokio::time::sleep(timeout), if !session.turn_in_progress => { _ = tokio::time::sleep(timeout), if !session.turn_in_progress => {
session.dmn_tick(); session.dmn_tick();
dirty = true;
} }
// UI messages (lowest priority — processed in bulk during render) // UI messages (lowest priority — processed in bulk during render)
Some(msg) = ui_rx.recv() => { Some(msg) = ui_rx.recv() => {
app.handle_ui_message(msg); app.handle_ui_message(msg);
dirty = true;
} }
} }
@ -999,9 +1007,16 @@ async fn run(cli: cli::CliArgs) -> Result<()> {
} }
} }
// Drain pending UI messages and redraw // Drain pending UI messages
drain_ui_messages(&mut ui_rx, &mut app); if drain_ui_messages(&mut ui_rx, &mut app) {
dirty = true;
}
// Only redraw when something changed
if dirty {
terminal.draw(|f| app.draw(f))?; terminal.draw(|f| app.draw(f))?;
dirty = false;
}
if app.should_quit { if app.should_quit {
break; break;
@ -1015,10 +1030,13 @@ async fn run(cli: cli::CliArgs) -> Result<()> {
// --- Free functions --- // --- Free functions ---
fn drain_ui_messages(rx: &mut ui_channel::UiReceiver, app: &mut tui::App) { fn drain_ui_messages(rx: &mut ui_channel::UiReceiver, app: &mut tui::App) -> bool {
let mut any = false;
while let Ok(msg) = rx.try_recv() { while let Ok(msg) = rx.try_recv() {
app.handle_ui_message(msg); app.handle_ui_message(msg);
any = true;
} }
any
} }
async fn run_tool_tests(ui_tx: &ui_channel::UiSender, tracker: &tools::ProcessTracker) { async fn run_tool_tests(ui_tx: &ui_channel::UiSender, tracker: &tools::ProcessTracker) {