Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Terminal — Rust and Ratatui

These are the steps to set up and run a Crux app as a terminal UI (TUI) application using Ratatui. This is a great way to build lightweight, keyboard-driven interfaces that share the same core logic as your web and mobile apps.

Note

This walk-through assumes you have already added the shared library to your repo, as described in Shared core and types.

Info

Because both the core and the shell are written in Rust and run in the same process, there is no FFI boundary — the shell calls the core directly with no serialization overhead.

Create the project

Our TUI app is just a new Rust project, which we can create with Cargo.

cargo new tui

Add it to your Cargo workspace by editing the root Cargo.toml:

[workspace]
members = ["shared", "tui"]

Add the dependencies to tui/Cargo.toml:

[package]
name = "tui"
version = "0.1.0"
authors.workspace = true
edition.workspace = true
repository.workspace = true
license.workspace = true
keywords.workspace = true
rust-version.workspace = true

[lints]
workspace = true

[dependencies]
shared = { path = "../shared" }
ratatui = "0.30.0"
crossterm = "0.29.0"

We depend on shared (our Crux core), ratatui (the TUI framework), and crossterm (for terminal input handling).

The shell

The entire TUI shell lives in a single main.rs. Let's walk through the key parts.

use std::io;

use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use ratatui::{
    DefaultTerminal, Frame,
    buffer::Buffer,
    layout::{Constraint, Layout, Rect},
    style::{Color, Style, Styled, Stylize},
    symbols::border,
    text::{Line, Text},
    widgets::{Block, Paragraph, Widget},
};
use shared::{Core, Counter, Effect, Event as AppEvent};

const BUTTONS: [(&str, AppEvent); 3] = [
    ("Increment", AppEvent::Increment),
    ("Decrement", AppEvent::Decrement),
    ("Reset", AppEvent::Reset),
];

#[allow(clippy::cast_possible_truncation)]
const NUM_BUTTONS: u16 = BUTTONS.len() as u16;

struct App {
    core: Core<Counter>,
    selected: usize,
    exit: bool,
}

impl App {
    fn new() -> Self {
        Self {
            core: Core::new(),
            selected: 0,
            exit: false,
        }
    }

    fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
        while !self.exit {
            terminal.draw(|frame| self.draw(frame))?;
            self.handle_events()?;
        }
        Ok(())
    }

    fn draw(&self, frame: &mut Frame) {
        frame.render_widget(self, frame.area());
    }

    fn handle_events(&mut self) -> io::Result<()> {
        match event::read()? {
            Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
                self.handle_key_event(key_event);
            }
            _ => {}
        }
        Ok(())
    }

    fn handle_key_event(&mut self, key_event: KeyEvent) {
        match key_event.code {
            KeyCode::Char('q') | KeyCode::Esc => self.exit = true,
            KeyCode::Left | KeyCode::Char('h') => self.select_prev(),
            KeyCode::Right | KeyCode::Char('l') => self.select_next(),
            KeyCode::Enter | KeyCode::Char(' ') => self.press_selected(),
            KeyCode::Char('+' | '=') => self.dispatch(AppEvent::Increment),
            KeyCode::Char('-') => self.dispatch(AppEvent::Decrement),
            KeyCode::Char('0') => self.dispatch(AppEvent::Reset),
            _ => {}
        }
    }

    const fn select_prev(&mut self) {
        self.selected = self.selected.saturating_sub(1);
    }

    const fn select_next(&mut self) {
        if self.selected < BUTTONS.len() - 1 {
            self.selected += 1;
        }
    }

    fn press_selected(&self) {
        let (_, ref event) = BUTTONS[self.selected];
        self.dispatch(event.clone());
    }

    fn dispatch(&self, event: AppEvent) {
        for effect in self.core.process_event(event) {
            match effect {
                Effect::Render(_) => {
                    // The shell re-renders on the next loop iteration
                }
            }
        }
    }
}

impl Widget for &App {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let view = self.core.view();

        let title = Line::from(" Simple Counter ".bold());
        let instructions = Line::from(vec![
            " Select ".into(),
            "<←→>".blue().bold(),
            " Confirm ".into(),
            "<Enter>".blue().bold(),
            " Quit ".into(),
            "<Q> ".blue().bold(),
        ]);
        let block = Block::bordered()
            .title(title.centered())
            .title_bottom(instructions.centered())
            .border_set(border::THICK);

        let inner = block.inner(area);
        block.render(area, buf);

        // Split inner into: space for subtitle | main content (count+buttons) | bottom pad
        // count(3) + gap(1) + buttons(3) = 7
        let [top_space, main_content, _] = Layout::vertical([
            Constraint::Fill(1),
            Constraint::Length(7),
            Constraint::Fill(1),
        ])
        .areas(inner);

        // -- Subtitle (vertically centered in the space above the counter) --
        let [_, subtitle_area, _] = Layout::vertical([
            Constraint::Fill(1),
            Constraint::Length(1),
            Constraint::Fill(1),
        ])
        .areas(top_space);

        let sub_title = Line::from("Rust Core, Rust Shell (Ratatui)".bold());
        Paragraph::new(sub_title)
            .centered()
            .render(subtitle_area, buf);

        // -- Main content areas --
        let [count_area, _, buttons_area] = Layout::vertical([
            Constraint::Length(3),
            Constraint::Length(1),
            Constraint::Length(3),
        ])
        .areas(main_content);

        // -- Count display --
        let counter_text = Text::from(vec![Line::from(view.count.yellow().bold())]);
        let count_block = Block::bordered().border_set(border::PLAIN);
        Paragraph::new(counter_text)
            .centered()
            .block(count_block)
            .render(count_area, buf);

        // -- Buttons --
        ButtonBar::new(self.selected).render(buttons_area, buf);
    }
}

struct ButtonBar {
    selected: usize,
}

impl ButtonBar {
    const fn new(selected: usize) -> Self {
        Self { selected }
    }
}

impl Widget for ButtonBar {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let button_width: u16 = 14;
        let gap_width: u16 = 2;
        let total_width = button_width * NUM_BUTTONS + gap_width * (NUM_BUTTONS - 1);

        let [_, button_strip, _] = Layout::horizontal([
            Constraint::Fill(1),
            Constraint::Length(total_width),
            Constraint::Fill(1),
        ])
        .areas(area);

        let constraints: Vec<Constraint> = BUTTONS
            .iter()
            .enumerate()
            .flat_map(|(i, _)| {
                if i < BUTTONS.len() - 1 {
                    vec![
                        Constraint::Length(button_width),
                        Constraint::Length(gap_width),
                    ]
                } else {
                    vec![Constraint::Length(button_width)]
                }
            })
            .collect();

        let cols = Layout::horizontal(constraints).split(button_strip);

        let colors = [Color::Green, Color::Yellow, Color::Red];

        for (i, (label, _)) in BUTTONS.iter().enumerate() {
            let col = cols[i * 2]; // even indices are buttons, odd are gaps
            let is_selected = i == self.selected;
            let color = colors[i];

            let (text_style, bdr_set) = if is_selected {
                (
                    Style::new().fg(Color::Black).bg(color).bold(),
                    border::THICK,
                )
            } else {
                (Style::new().fg(color), border::PLAIN)
            };

            let line = Line::from((*label).set_style(text_style));
            let btn_block = Block::bordered()
                .border_set(bdr_set)
                .border_style(text_style);
            Paragraph::new(line)
                .centered()
                .style(text_style)
                .block(btn_block)
                .render(col, buf);
        }
    }
}

fn main() -> io::Result<()> {
    ratatui::run(|terminal| App::new().run(terminal))
}

How it works

The TUI shell follows the same pattern as any Crux shell, but with a terminal render loop instead of a UI framework:

  1. Event loop — Ratatui runs a loop that draws the UI and then waits for keyboard input. Each keypress is mapped to an app Event (e.g. pressing + sends Event::Increment).

  2. Dispatching events — The dispatch method sends events to the core via core.process_event() and processes the resulting effects. For this simple example, the only effect is Render, which is a no-op in the TUI — the shell re-renders on every loop iteration anyway.

  3. Rendering the view — On each frame, the shell calls core.view() to get the current ViewModel and renders it using Ratatui widgets. The counter value is displayed in a bordered box with a row of selectable buttons below it.

  4. No serialization — Because both the core and the shell are Rust running in the same process, we call Core::new(), core.process_event(), and core.view() directly with native Rust types.

Build and run

cargo run -p tui

Success

Your app should look something like this in the terminal:

┏━━━━━━━━━━━━━━ Simple Counter ━━━━━━━━━━━━━━┓
┃                                             ┃
┃       Rust Core, Rust Shell (Ratatui)       ┃
┃                                             ┃
┃          ┌───────────────────┐              ┃
┃          │         0         │              ┃
┃          └───────────────────┘              ┃
┃                                             ┃
┃    ┃ Increment ┃  │ Decrement │  │  Reset  │┃
┃                                             ┃
┗━━ Select <←→> Confirm <Enter> Quit <Q> ━━━━┛

</div>
</div>