Updatable text in a console in Rust
Showing progress bars/messages and getting user input is a common task that many CLI applications have to do. This post will outline a Rust crate called console_static_text, which logs text that can be updated at the bottom of a console window.
API
The API behind this is low level and basic. Essentially there is only one function, which is to render some text that can be sent to the console that overwrites the previous state and updates the state for the new text. The rest of the functionality is helper methods built on top of that.
Here's an example that logs the numbers 0 to 199 to the console, then clears the text:
# cargo.toml dependency
console_static_text = { version = "0.7.0", features = ["sized"] }
use console_static_text::ConsoleStaticText;
use std::time::Duration;
fn main() {
// returns `None` when not a tty
let mut static_text = ConsoleStaticText::new_sized().unwrap();
for i in 0..200 {
static_text.eprint(&i.to_string());
std::thread::sleep(Duration::from_millis(30));
}
static_text.eprint_clear();
}
Outputs as:
Note that internally eprint
and eprint_clear
are helper methods around the
render(new_text: &str)
method. For example, this is the implementation of
static_text.eprint(...)
:
pub fn eprint(&mut self, new_text: &str) {
// `render` returns `None` when there's nothing to update
if let Some(text) = self.render(new_text) {
std::io::stderr().write_all(text.as_bytes()).unwrap();
}
}
Example - Automatic word wrapping
console_static_text will automatically word wrap text and only update the console if there are changes.
The following example shows word wrapping and how the crate handles the console window resizing:
use console_static_text::ConsoleStaticText;
use std::time::Duration;
fn main() {
let mut static_text = ConsoleStaticText::new_sized().unwrap();
let text = format!(
"{}\nPress ctrl+c to exit...",
"some words repeated ".repeat(40).trim(),
);
let mut last_size = None;
loop {
let mut delay_ms = 60;
let current_size = static_text.console_size();
if last_size.is_some() && last_size.unwrap() != current_size {
// debounce while the user is resizing
delay_ms = 200;
} else {
// this will not update the console when the size hasn't
// changed since the output should be the same
static_text.eprint_with_size(&text, current_size);
}
std::thread::sleep(Duration::from_millis(delay_ms));
last_size = Some(current_size);
}
}
I'm not aware of a good cross platform way to handle console resize events, so this example renders every little while and only if necessary (handled internally in the static text object).
Resizing - Not perfect
If the console has resized since the last render, console_static_text does its best to redraw over the previous text, but often some text from a previous render will be left over. From my knowledge, I don't believe it's practical to make this perfect... for example, some terminals might add or remove line breaks when resizing or move the current cursor position to a hard to predict spot.
Here's an example showing how lines can be added or removed on Windows in a console. Both commands executed are the same, but have different outputs based on the size of the console when the command was executed:
I think handling all these scenarios would be a lot of effort and most users don't expect things to be perfect when resizing the console and the render area isn't full screen anyway.
Example - Logging above while outputting
The key to logging above while the static text is outputting is to:
- Clear the existing static text.
- Log your new text.
- Redraw the static text.
For example:
use console_static_text::ConsoleStaticText;
use std::io::Write;
use std::time::Duration;
fn main() {
let mut static_text = ConsoleStaticText::new_sized().unwrap();
for i in 0..200 {
let i_str = i.to_string();
if i % 10 == 0 {
// only get the console size once and use
// the same console size state on all calls
let size = static_text.console_size();
let mut new_text = String::new();
// first clear the existing static text
if let Some(text) = static_text.render_clear_with_size(size) {
new_text.push_str(&text);
}
// log the new text
new_text.push_str(&format!("Hello from {}\n", i));
// then redraw the static text
if let Some(text) = static_text.render_with_size(&i_str, size) {
new_text.push_str(&text);
}
// now output everything to stderr in one go
std::io::stderr().write_all(new_text.as_bytes()).unwrap();
} else {
static_text.eprint(&i_str);
}
std::thread::sleep(Duration::from_millis(30));
}
static_text.eprint_clear();
}
Outputs as:
Example - User input
Here's an example that asks the user to make a selection.
It uses Crossterm to get the console size, turn on/off raw mode (necessary for getting arrow key presses), hide/show the cursor, and get key presses. We need to use Crossterm or something like it for this because console_static_text is only concerned with displaying text.
use std::io::stderr;
use console_static_text::ConsoleSize;
use console_static_text::ConsoleStaticText;
use console_static_text::TextItem;
use crossterm::event;
use crossterm::event::Event;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::execute;
struct DrawState {
active_index: usize,
message: String,
items: Vec<String>,
}
pub fn main() {
assert!(crossterm::tty::IsTty::is_tty(&std::io::stderr()));
let mut static_text = ConsoleStaticText::new(|| {
// since we're already using crossterm, get the size from
// it and don't bother with console_static_text's "sized"
// feature in order to reduce our dependencies
let (cols, rows) = crossterm::terminal::size().unwrap();
ConsoleSize {
rows: Some(rows),
cols: Some(cols),
}
});
let mut state = DrawState {
active_index: 0,
message: "Which option would you like to select?".to_string(),
items: vec![
"Option 1".to_string(),
"Option 2".to_string(),
"Option 3 with long text. ".repeat(10),
"Option 4".to_string(),
],
};
// enable raw mode to get special key presses
crossterm::terminal::enable_raw_mode().unwrap();
// hide the cursor
execute!(stderr(), crossterm::cursor::Hide).unwrap();
// render, then act on up and down arrow key presses
loop {
let items = render(&state);
static_text.eprint_items(items.iter());
if let Event::Key(event) = event::read().unwrap() {
// in a real implementation you will want to handle ctrl+c here
// (make sure to handle always turning off raw mode)
match event {
KeyEvent {
code: KeyCode::Up, ..
} => {
if state.active_index == 0 {
state.active_index = state.items.len() - 1;
} else {
state.active_index -= 1;
}
}
KeyEvent {
code: KeyCode::Down,
..
} => {
state.active_index =
(state.active_index + 1) % state.items.len();
}
KeyEvent {
code: KeyCode::Enter,
..
} => {
break;
}
_ => {
// ignore
}
}
};
}
// disable raw mode, show the cursor, clear the static text, then
// display what the user selected
crossterm::terminal::disable_raw_mode().unwrap();
execute!(stderr(), crossterm::cursor::Show).unwrap();
static_text.eprint_clear();
eprintln!("Selected: {}", state.items[state.active_index]);
}
/// Renders the draw state
fn render(state: &DrawState) -> Vec<TextItem> {
let mut items = Vec::new();
// display the question message
items.push(TextItem::new(&state.message));
// now render each item, showing a `>` beside the active index
for (i, item_text) in state.items.iter().enumerate() {
let selection_char = if i == state.active_index { '>' } else { ' ' };
let text = format!("{} {}", selection_char, item_text);
items.push(TextItem::HangingText {
text: std::borrow::Cow::Owned(text),
indent: 4,
});
}
items
}
Recommended architecture
The stdout and stderr pipes are a global concept in a CLI application. For that
reason, I recommend the implementation of your output to the global stdout and
stderr pipes should be controlled in one place and have that code use a single
instance of a ConsoleStaticText
.
For example, either have a logging implementation using a single
ConsoleStaticText
that's passed around to do all your logging (as is done in
dprint) or create a single global instance of ConsoleStaticText
that's used in
the application (as is done in dax). Then create an abstraction on top of that
to handle rendering your application's state, getting user input, and handle
logging at the same time so nothing conflicts.
Projects using this
The following projects I work on are using this now: Deno (in 1.29 and above), dprint, and dax (via Wasm)
All the examples in this blog post are found in the console_static_text repo: https://github.com/dsherret/console_static_text
As always, thanks for reading!