From 5eac3a98f33f7ea093bb538aa44ba56adaf3a6f7 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Sat, 13 Jun 2026 02:32:51 +0000 Subject: [PATCH] fipstop: fix garbled screen on startup and quit over SSH/tmux Two independent rendering glitches, both most visible over SSH and inside tmux: Startup: ratatui::try_init() enters the alternate screen but never clears it, and the first terminal.draw() only emits cells that differ from an assumed-blank internal buffer. On terminals that don't hand back a cleared alternate buffer (notably tmux, and amplified by SSH latency) the prior contents show through. Force a full repaint with terminal.clear() before the first draw. Quit: the input EventHandler spawned a detached thread that polled stdin in a loop outliving the main loop, so at quit it kept reading after raw mode was disabled and stray bytes (a keystroke or a terminal query response) echoed onto the restored screen. Give the thread a stop flag and join it before restoring the terminal; poll on a short fixed interval (decoupled from the refresh tick) so quit stays responsive. git log --oneline -1 --- src/bin/fipstop/event.rs | 84 ++++++++++++++++++++++++++++++---------- src/bin/fipstop/main.rs | 12 +++++- 2 files changed, 75 insertions(+), 21 deletions(-) diff --git a/src/bin/fipstop/event.rs b/src/bin/fipstop/event.rs index a8b4ed7..53b8729 100644 --- a/src/bin/fipstop/event.rs +++ b/src/bin/fipstop/event.rs @@ -1,7 +1,9 @@ use ratatui::crossterm::event::{self, Event as CrosstermEvent, KeyEvent}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant}; pub enum Event { Key(KeyEvent), @@ -9,45 +11,87 @@ pub enum Event { Tick, } +/// Upper bound on a single `event::poll` wait. Kept short (vs the full +/// tick interval) so [`EventHandler::stop`] can join the input thread +/// promptly at quit instead of blocking for up to a refresh interval. +const POLL_INTERVAL: Duration = Duration::from_millis(250); + pub struct EventHandler { rx: mpsc::Receiver, + running: Arc, + handle: Option>, } impl EventHandler { pub fn new(tick_rate: Duration) -> Self { let (tx, rx) = mpsc::channel(); + let running = Arc::new(AtomicBool::new(true)); + let thread_running = Arc::clone(&running); - thread::spawn(move || { - loop { - if event::poll(tick_rate).unwrap_or(false) { - if let Ok(evt) = event::read() { - match evt { - CrosstermEvent::Key(key) => { - if tx.send(Event::Key(key)).is_err() { - return; - } + let handle = thread::spawn(move || { + let mut last_tick = Instant::now(); + while thread_running.load(Ordering::Relaxed) { + // Bound the poll by the time left until the next tick, but + // never longer than POLL_INTERVAL so the running flag is + // checked (and quit honored) promptly. + let timeout = tick_rate + .saturating_sub(last_tick.elapsed()) + .min(POLL_INTERVAL); + + match event::poll(timeout) { + Ok(true) => match event::read() { + Ok(CrosstermEvent::Key(key)) => { + if tx.send(Event::Key(key)).is_err() { + return; } - CrosstermEvent::Resize(..) => { - if tx.send(Event::Resize).is_err() { - return; - } - } - _ => {} } - } - } else { - // Poll timed out — send a tick + Ok(CrosstermEvent::Resize(..)) => { + if tx.send(Event::Resize).is_err() { + return; + } + } + Ok(_) => {} + Err(_) => return, + }, + Ok(false) => {} + Err(_) => return, + } + + if last_tick.elapsed() >= tick_rate { if tx.send(Event::Tick).is_err() { return; } + last_tick = Instant::now(); } } }); - Self { rx } + Self { + rx, + running, + handle: Some(handle), + } } pub fn next(&self) -> Result { self.rx.recv() } + + /// Stop the input thread and wait for it to exit. Call this before + /// restoring the terminal so the thread is not still reading stdin + /// after raw mode is disabled — otherwise stray bytes (a keystroke or + /// a terminal query response) echo onto the restored screen, which is + /// especially visible over SSH/tmux. + pub fn stop(&mut self) { + self.running.store(false, Ordering::Relaxed); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} + +impl Drop for EventHandler { + fn drop(&mut self) { + self.stop(); + } } diff --git a/src/bin/fipstop/main.rs b/src/bin/fipstop/main.rs index 77b47c2..f5efcd2 100644 --- a/src/bin/fipstop/main.rs +++ b/src/bin/fipstop/main.rs @@ -254,8 +254,14 @@ fn main() { eprintln!("fipstop: failed to initialize terminal: {e}"); std::process::exit(1); }); + // Force a full repaint of a known-blank screen before the first draw. + // try_init enters the alternate screen but does not clear it, and the + // first draw only emits cells that differ from an assumed-blank buffer; + // on terminals that don't hand back a cleared alternate buffer (notably + // tmux, and over SSH) that leaves stale content showing through. + let _ = terminal.clear(); let mut app = App::new(refresh); - let events = EventHandler::new(refresh); + let mut events = EventHandler::new(refresh); // Initial fetch fetch_data(&rt, &client, &gateway_client, &mut app); @@ -400,5 +406,9 @@ fn main() { } } + // Stop the input thread before restoring the terminal so it is not + // still reading stdin once raw mode is disabled (stray bytes would + // otherwise echo onto the restored screen). + events.stop(); restore_terminal(); }