Basic TUI library in Rust

I've built a basic TUI (text user interface) library in Rust: https://github.com/pjdur/tui.

The library currently supports the following widget types:

  • Sliders
  • Labels / text
  • Buttons
  • Checkboxes

It includes event handling for sliders and checkboxes (buttons aren't supported yet), allowing you to respond to user interactions.

Here's a simple example application built with it:

use tui::*;

fn main() -> std::io::Result<()> {
    let mut ui = UI::new();

    ui.add(WidgetType::Label(Label::new("Choose your ice cream flavours:")));
    ui.add(WidgetType::Checkbox(Checkbox::new("Vanilla")));
    ui.add(WidgetType::Checkbox(Checkbox::new("Chocolate")));
    ui.add(WidgetType::Checkbox(Checkbox::new("Strawberry")));
    ui.add(WidgetType::Checkbox(Checkbox::new("Mint")));
    ui.add(WidgetType::Checkbox(Checkbox::new("Cookie Dough")));
    ui.add(WidgetType::Label(Label::new("Press TAB to switch, SPACE to toggle, ESC to finish")));

    let result = run_ui(ui)?;

    println!("\nYour ice cream will include:");
    for ingredient in result {
        println!("- {}", ingredient);
    }

    Ok(())
}

Feedback or suggestions are welcome.

The "basic" example does not render correctly (I'm using macOS Terminal.app). It looks like this:

  Text Label
            > Slider [0 ──────────────── 100]: 50
                                                   Checkboxes
                                                              [ ] Checkbox 1
                                                                             [ ] Checkbox 2
                                                                                            [ ] Checkbox 3
                                                                                                           ╭────────╮
                                                                                                                      │ Submit │ 
                                                                                                                                  ╰────────╯
                                                                                                                                              Press TAB to switch, SPACE to toggle, ESC to exit

It's caused by printing \n with crossterm::enable_raw_mode(). There is a note in the documentation that notes the newline character will not be processed.

It can be fixed with the queue!() macro:

for line in ui.render().split('\n') {
    queue!(stdout, style::Print(line), cursor::MoveToNextLine(1))?;
}

A good suggestion to start with is using cargo clippy. It will provide its own suggestions that range from minor cleanups to performance improvements. (Note that not everyone likes the default lints, and they can be configured to your tastes.)

One ergonomic idea is allowing Ui::add() to accept any type that implements Into<WidgetType>. This will help callers by not forcing the widgets to be first wrapped in the WidgetType. I'll show you what the example will look like when it's done:

use tui::*;

fn main() -> std::io::Result<()> {
    let mut ui = UI::new();

    ui.add(Label::new("Text Label"));
    ui.add(Slider::new("Slider", 0, 100, 50));
    ui.add(Label::new("Checkboxes"));
    ui.add(Checkbox::new("Checkbox 1"));
    ui.add(Checkbox::new("Checkbox 2"));
    ui.add(Checkbox::new("Checkbox 3"));
    ui.add(Button::new("Submit"));
    ui.add(Label::new(
        "Press TAB to switch, SPACE to toggle, ESC to exit",
    ));

    run_ui(ui)?;
    Ok(())
}

And how you do this is a two-step process. First, add the trait constraint:

impl UI {
    pub fn add(&mut self, widget: impl Into<WidgetType>) {
        let widget = widget.into();
        if self.focused_index.is_none() && widget.is_focusable() {
            self.focused_index = Some(self.widgets.len());
        }
        self.widgets.push(widget);
    }
}

Second, add From implementations for each of your widget types:

impl From<Label> for WidgetType {
    fn from(value: Label) -> Self {
        Self::Label(value)
    }
}

impl From<Button> for WidgetType {
    fn from(value: Button) -> Self {
        Self::Button(value)
    }
}

impl From<Checkbox> for WidgetType {
    fn from(value: Checkbox) -> Self {
        Self::Checkbox(value)
    }
}

impl From<Slider> for WidgetType {
    fn from(label: Slider) -> Self {
        Self::Slider(label)
    }
}

It's a little bit of boilerplate in the library, but it removes boilerplate on the caller side, and that's usually a more valuable tradeoff.

I haven't done a full review. There are some relatively minor things I would change, like making tui::run_ui() a method of Ui, and recommending blocking event reads or async EventStream over polling periodically [1]. And ultimately, taking over the event loop is probably not really what you want to do (though it is easier when getting started).


  1. This is not a problem with the polling, but the current event loop "misses" many key events if I press them in quick succession. This appears to be intentional, but I don't know why. Removing the last_keys HashSet fixes it. â†Šī¸Ž

2 Likes

I didn't think you actually can do non-blocking terminal UI? I'd love to be proven wrong, but I think the best you can do is wrap up a UI thread and event channel.

They currently use crossterm::event::poll with a 200 ms timeout, making it "non-blocking". What I suggested as an alternative is removing the timeout. What you are describing with the thread and channel is what EventStream does.

2 Likes

Ah, cool. I'd be concerned about losing the ability to do synchronous updates to prevent glitching but I think that's inherently part of using ANSI code streams. (Once again, Windows does the right thing in the worst way possible :face_savoring_food:)

I've added your suggestions, including fixing rendering, not forcing widgets to be wrapped in WidgetType, and making run_ui a method of Ui. A run helper function also allows for using Ui::run without having to use UI::run directly.

Updated basic example

use tui::*;

fn main() -> std::io::Result<()> {
    let mut ui = UI::new();

    ui.add(Label::new("Text Label"));
    ui.add(Slider::new("Slider", 0, 100, 50));
    ui.add(Label::new("Checkboxes"));
    ui.add(Checkbox::new("Checkbox 1"));
    ui.add(Checkbox::new("Checkbox 2"));
    ui.add(Checkbox::new("Checkbox 3"));
    ui.add(Button::new("Submit"));
    ui.add(Label::new("Press TAB to switch, SPACE to toggle, ESC to exit"));

    run(ui)?;
    Ok(())
}

This TUI library has been turned into a crate, Uxterm. It implements breaking changes and is totally incompatible with previous implementations of the TUI library.

Repository

Example:

use uxterm::*;

fn main() -> std::io::Result<()> {
    let mut view = View::new("Ice Cream Selector");

    view.add(Label::new("Choose your ice cream flavours:"));
    view.add(Checkbox::new("Vanilla"));
    view.add(Checkbox::new("Chocolate"));
    view.add(Checkbox::new("Strawberry"));
    view.add(Checkbox::new("Mint"));
    view.add(Checkbox::new("Cookie Dough"));
    view.add(Label::new("Press TAB to switch, SPACE to toggle, ESC to finish"));

    let result = run(view)?;

    println!("\nYour ice cream will include:");
    for ingredient in result {
        println!("- {}", ingredient);
    }

    Ok(())
}