I am building a TUI and need my main loop to handle two things: redraw the UI on a fixed timer (a "tick") and immediately handle user input.
My current loop uses crossterm::event::read()
, but it's a blocking call, which means the UI only updates after the user presses a key.
This approach doesn't work for showing real-time updates like a clock.
What is the idiomatic Rust pattern for an event loop that handles both timed ticks and blocking input?
I've seen that crossterm::event::poll
has a timeout, which seems promising. Is using poll the standard way to solve this, or is there a better, more robust pattern?
Here is a Minimal, Reproducible Example (MRE) of my problem:
Cargo.toml:
[dependencies]
crossterm = "0.27"
main.rs:
use crossterm::event::{self, Event, KeyCode};
use std::io::{self, Write};
use std::time::{Duration, Instant};
fn main() -> io::Result<()> {
println!("Starting event loop. Press 'q' to quit.");
println!("Notice how the 'tick' message only appears AFTER you press a key.");
let mut last_tick = Instant::now();
let tick_rate = Duration::from_secs(1);
loop {
// This is a blocking call. The loop will pause here until an event occurs.
let event = event::read()?;
println!("- Event received: {:?}", event);
if let Event::Key(key) = event {
if key.code == KeyCode::Char('q') {
println!("'q' pressed, quitting.");
break;
}
}
// This part of the code is only reached AFTER the blocking `event::read()` returns.
if last_tick.elapsed() >= tick_rate {
println!("Tick!");
io::stdout().flush()?;
last_tick = Instant::now();
}
}
Ok(())
}
When you run this code, you will see that the "Tick!" message is only printed to the console after you press a key, which demonstrates the blocking issue I need to solve. I am looking for the standard Rust pattern to handle both the timed "Tick!" and the user input concurrently.
I am looking for the standard Rust pattern to handle both the timed "Tick!" and the user input concurrently.
A fundamental characteristic of blocking function calls is that there is no general way to multiplex them with any other activity on a single thread — they tie up the thread doing the one thing, and so in order to handle some other event/process concurrently, you have to do one of these things:
Use a separate thread to handle the other activity. (In this case, you could have a thread dedicated to only receiving input. However, this might interfere with some crossterm operations that involve issuing a command to the terminal and reading back.)
Use some way the blocking operation offers for it to do more than one thing at once; for example, a channel receiver can block on receiving messages from multiple sources. (You have found crossterm::event::poll()
, which perfectly does this for your case of “either input was received or time passed”, but would not work for “either input or some other event happening”.)
Use some way the blocking operation offers for you to cancel it. (crossterm::event::poll()
is in a sense a way to do this, but if the event you’re looking for isn’t going to happen at a predictable time then it’s inefficient.)
Don’t use blocking function calls; use Rust async
, which allows multiplexing anything participating in the async mechanism, using select!
. (For crossterm, this means you would receive events using EventStream
.)