Crate tundra

source ·
Expand description

Tundra provides a framework and utilities for creating interactive terminal applications with Ratatui.

Ratatui is a comprehensive library for creating singular user interfaces in the terminal, but lacks features for organizing larger applications — composed of several interfaces — and for receiving user data input.

Tundra aims to extend the functionality of Ratatui with utilities for:

Tundra is also highly extensible with tools to easily define your own dialogs and input fields.

It is not intended to be a replacement for or wrapper over Ratatui, nor the backend. Ratatui is still required to draw the user interface of each application state, and the backend is still required for low-level terminal operations.

§Getting Started

Add Tundra and Ratatui to the project dependencies, and note that the version of Ratatui used must be compatible with the one used by Tundra:

$ cargo add tundra ratatui@0.26

Next, import the Tundra prelude, exposing symbols required in virtually all applications:

use tundra::prelude::*;

§Basic Usage

First construct a context. This represents the underlying terminal, but has added RAII to automatically initialize and reset the terminal environment.

let ctx = Context::new()?;

This context gets passed around between application states — allowing them to draw to the terminal.

Next, create an application state. For this example, we’ll create a state with a tally that increases when the user presses up.

struct Tally {
    value: u32, 
}

To define the event loop of the state, implement the State trait.

use ratatui::widgets::Paragraph;
 
impl State for Tally {
    type Result<T> = T;
    type Out = u32;
    type Global = ();
     
    fn draw(&self, frame: &mut Frame) {
        let widget = Paragraph::new(self.value.to_string());
        frame.render_widget(widget, frame.size());
    }
     
    fn input(mut self, key: KeyEvent, ctx: &mut Context) -> Signal<Self> {
        match key.code {
            KeyCode::Up    => self.value += 1, 
            KeyCode::Enter => return Signal::Return(self.value), 
            _ => (), 
        }
        Signal::Continue(self)
    }
}

Some notes on the implementation:

  • Result can be used to specify what can go wrong when running the state — analogous to an error type but more flexible.
  • Out is the type that is returned from the state once it’s done running.
  • Global can be used to store a global value inside the context.
  • draw is used to draw the user interface using Ratatui.
  • input is used to handle key press events. The Signal return value indicates to the event loop when and what to return.

Our tally can now be ran using State::run.

// returns the value when the user presses enter (per our State::input)
let value: u32 = Tally{ value: 0 }.run(ctx);

There is not much boiler-plate in running this simple example, but for more complex states, a wrapper function that constructs and runs the state should be used.

pub fn tally(ctx: &mut Context) -> u32 {
    Tally{ value: 0 }.run(ctx)
}

This interface can now be used from other states to “transition” to the Tally state! For the sake of argument, let’s create and run a new tally from within our existing tally whenever the user presses tab. The current value will be multiplied with the value entered in the new tally (the “transitioned-to” state).

fn input(mut self, key: KeyEvent, ctx: &mut Context) -> Signal<Self> {
    match key.code {
        KeyCode::Up    => self.value += 1, 
        KeyCode::Tab   => self.value *= tally(ctx), 
        KeyCode::Enter => return Signal::Return(self.value), 
        _ => (), 
    }
    Signal::Continue(self)
}

That’s all you need to get started! See the tally example for the complete code.

Modal dialogs are small pop-up “windows” displayed atop a background state. They contain messages or prompt for user input and are shown simply by calling a function. Here is an example of showing an error message in a dialog:

// let current_state: &impl State
// let ctx: &mut Context<_>
dialog::error("Failed evicting tenant.", current_state, ctx);

Here, current_state is a reference to whatever state the dialog should be drawn over. If the dialog is being invoked from within a state, this would be &self. The error dialog — and the dialog::error function by extension — returns once the user presses a key, acknowledging the error. Above some state with a Ratatui table, this shows as:

dialog error demo

See the dialog module for a full list of the dialogs provided by Tundra, and for how to create your own!

Note that there is nothing magic about dialogs; they are implemented using the same machinery as any other state, but have added logic to store a reference to the background state and to draw it before the dialog.

§User Input

User input is facilitated through the form macro, which displays a dialog containing a specified set of input fields. The return value of the macro invocation is a struct containing the values entered by the user for each field. Here is an example of showing a form, and once it’s been submitted, retrieving the entered values:

use tundra::field::*;
 
// let current_state: &impl State
// let ctx: &mut Context<_>
 
let values = dialog::form!{
    location: Textbox{ name: "Location" }, 
    rent: Slider<u32>{ name: "Monthly rent", range: 1..=5000, step: 50 }, 
    pets_allowed: Checkbox{ name: "Pets allowed" }, 
    [title]: "Register Rent Unit", 
    [context]: ctx, 
    [background]: current_state, 
};
 
if let Some(values) = values {
    let location: String = values.location;
    let rent: u32 = values.rent;
    let pets_allowed: bool = values.pets_allowed;
}

Some notes about the example:

  • As with other dialogs, a context and background state must be provided. For forms, this is done with the [context] and [background] “meta-fields.”
  • The values (and the fields) are stored as members of unspellable structs created inside the macro — no runtime lookup is required! Values are accessed using the same identifers that the corresponding fields were declared with.
  • Forms can be cancelled by the user, which is represented by a None return value.
  • The type annotations for the field values are not required.

Above some state with a Ratatui table (and with some example data entered), this shows as:

dialog form demo

See the form macro for the complete macro specification, and see the field module for a full list of the field types provided by Tundra, and for how to create your own!

§A Note on the Backend

Ratatui has support for several terminal backends. If you don’t know what that means, this note holds no significance for you.

Tundra currently only supports the crossterm backend. This is due to a lack of abstraction over the different backends. Code — particularly pertaining to context and event handling — would have to be written and repeated for each backend.

If you need another backend for your project, Tundra is not for you — at least for the moment.

§Examples

See the examples folder on GitHub.

Re-exports§

Modules§

  • Modal dialogs displayed in the middle of the screen, covering some background State.
  • Input fields for allowing the user to enter various kinds of data.
  • Exposes symbols required in virtually all applications.

Macros§

Structs§

  • Manages the terminal environment.

Enums§

  • Dictates when and what to return from a running State.

Traits§

  • Generalisation over data-carrying Result-like types.
  • Defines the event loop of an application state.

Type Aliases§