← Main

Updatable text in a console in Rust

by David Sherret

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:

  1. Clear the existing static text.
  2. Log your new text.
  3. 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
}

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!