Introduction
Welcome to Chapter 13! So far, we’ve explored the foundational elements of Ratatui: setting up your environment, drawing basic widgets, and handling user input. Now, it’s time to put all those pieces together and build something truly functional and interactive. In this chapter, we’re going to create a simple, yet robust, Terminal User Interface (TUI) Task Manager.
This project will serve as a practical application of the concepts we’ve covered. You’ll learn how to manage application state, handle diverse user inputs to interact with that state, and dynamically render different UI components based on the application’s current mode. Think of it as your first full Ratatui “meal” – cooking with all the ingredients you’ve gathered!
By the end of this chapter, you will have a working task manager that allows you to add, complete, delete, and navigate through tasks directly from your terminal. This hands-on experience will solidify your understanding and prepare you for building more complex Ratatui applications. Ready to build something awesome? Let’s dive in!
Prerequisites: This chapter assumes you’re comfortable with:
- Basic Rust syntax and project structure.
- Setting up Ratatui and Crossterm (as covered in previous chapters).
- The core
drawandhandle_eventsloop. - Basic widget usage (Blocks, Paragraphs, Lists).
Core Concepts for Our Task Manager
Building an interactive application like a task manager requires a clear strategy for how the application behaves and responds. We’ll focus on three main pillars: Application State, Event Handling, and UI Drawing Logic.
1. Application State Management: The Brain of Our App
Every interactive application needs to keep track of its current situation. This “situation” is what we call the application state. For our task manager, what kind of information do you think we’ll need to store?
- The Tasks Themselves: A list of
Taskobjects, each with a description and a completion status. - Selected Task: Which task is currently highlighted? This helps with navigation and actions like “complete” or “delete”.
- Input Mode: Are we currently just viewing tasks, or are we actively typing a new task? This changes how the UI looks and how key presses are interpreted.
- Input Buffer: If we’re typing a new task, where do we store the characters being typed?
We’ll encapsulate all this information into a single struct App. This makes it easy to pass around and update our application’s “brain.”
// A basic Task struct
pub struct Task {
pub description: String,
pub completed: bool,
}
// The main application state struct
pub enum InputMode {
Normal,
AddingTask,
}
pub struct App {
pub tasks: Vec<Task>,
pub selected_task: Option<usize>, // Index of the selected task, if any
pub input: String, // Current input buffer for new tasks
pub input_mode: InputMode, // Current mode (viewing or adding)
pub scroll_offset: usize, // For scrolling the task list
}
Why this matters: By centralizing our state, we create a single source of truth for our application. When the state changes (e.g., a task is added), the UI can react predictably and redraw itself based on this new truth.
2. Event Handling: Responding to User Actions
Our task manager needs to react to keyboard input. A key press like ‘j’ should move the selection down, while ‘a’ might switch us into “add task” mode. This requires a robust event handling mechanism.
Consider the different “modes” our application can be in:
Explanation:
- Normal Mode: This is where you navigate, mark tasks complete, or delete them.
- Adding Task Mode: This mode is for typing the description of a new task. Pressing
Enterin this mode adds the task, andEsccancels the input.
Our event handling logic will use a match statement on the App’s input_mode to determine how to interpret each key press. This pattern is crucial for building interactive TUIs.
3. UI Drawing Logic: What the User Sees
The final piece is rendering the UI. Our task manager will have:
- A list of tasks, with completed tasks potentially styled differently.
- A cursor or highlight indicating the currently selected task.
- An input field, visible only when
InputMode::AddingTaskis active. - A status bar or help text.
We’ll use Ratatui’s layout system (Layout, Constraint) to divide the terminal into logical areas for our task list, input field, and instructions. The List widget will be perfect for displaying our tasks, and a Paragraph will serve as our input field.
Remember: Ratatui is a rendering library. It takes your application state and draws it. It doesn’t manage the state itself, nor does it handle events directly. That’s our job!
Step-by-Step Implementation
Let’s start building our task manager! We’ll go piece by piece, explaining each addition.
Step 1: Project Setup and Dependencies
First, create a new Rust project and add the necessary dependencies.
Create a new project:
cargo new ratatui-task-manager cd ratatui-task-managerAdd dependencies to
Cargo.toml: OpenCargo.tomland add the following under[dependencies]. As of 2026-03-17, these are the current stable and recommended versions.# ratatui-task-manager/Cargo.toml [package] name = "ratatui-task-manager" version = "0.1.0" edition = "2021" [dependencies] ratatui = "0.26.0" # Latest stable as of 2026-03-17 crossterm = "0.27.0" # Latest stable as of 2026-03-17Explanation:
ratatui: The core TUI rendering library.crossterm: Provides cross-platform terminal event handling (keyboard, mouse, resize) and basic terminal manipulation (raw mode, clearing screen). This is the recommended backend for Ratatui.
Step 2: Defining the Application State
Now, let’s define our Task and App structs in src/main.rs.
// src/main.rs
use std::{io, time::{Duration, Instant}};
use crossterm::{
event::{self, Event as CrosstermEvent, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
Frame, Terminal,
};
// --- Application State Definitions ---
/// Represents a single task in our manager.
#[derive(Debug, Clone)]
pub struct Task {
pub description: String,
pub completed: bool,
}
impl Task {
pub fn new(description: String) -> Self {
Self {
description,
completed: false,
}
}
}
/// Defines the current mode of the application.
pub enum InputMode {
/// User is viewing and navigating tasks.
Normal,
/// User is typing a new task.
AddingTask,
}
/// The main application state struct.
pub struct App {
pub tasks: Vec<Task>,
pub selected_task: Option<usize>, // Index of the selected task
pub input: String, // Buffer for text input
pub input_mode: InputMode, // Current application mode
pub scroll_offset: usize, // For scrolling the task list if it exceeds screen height
}
impl App {
pub fn new() -> App {
App {
tasks: vec![
Task::new("Learn Ratatui basics".into()),
Task::new("Build a simple TUI app".into()),
Task::new("Master event handling".into()),
],
selected_task: Some(0), // Select the first task by default
input: String::new(),
input_mode: InputMode::Normal,
scroll_offset: 0,
}
}
/// Selects the next task in the list.
pub fn next_task(&mut self) {
let i = match self.selected_task {
Some(i) => {
if i >= self.tasks.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.selected_task = Some(i);
self.adjust_scroll_offset();
}
/// Selects the previous task in the list.
pub fn previous_task(&mut self) {
let i = match self.selected_task {
Some(i) => {
if i == 0 {
self.tasks.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.selected_task = Some(i);
self.adjust_scroll_offset();
}
/// Toggles the completion status of the selected task.
pub fn toggle_complete_selected_task(&mut self) {
if let Some(i) = self.selected_task {
if let Some(task) = self.tasks.get_mut(i) {
task.completed = !task.completed;
}
}
}
/// Deletes the selected task.
pub fn delete_selected_task(&mut self) {
if let Some(i) = self.selected_task {
if !self.tasks.is_empty() {
self.tasks.remove(i);
// Adjust selected_task after deletion
self.selected_task = if self.tasks.is_empty() {
None
} else if i >= self.tasks.len() {
Some(self.tasks.len() - 1)
} else {
Some(i)
};
self.adjust_scroll_offset();
}
}
}
/// Adds a new task from the input buffer.
pub fn add_task(&mut self) {
if !self.input.trim().is_empty() {
self.tasks.push(Task::new(self.input.drain(..).collect()));
self.selected_task = Some(self.tasks.len() - 1); // Select the newly added task
self.adjust_scroll_offset();
}
}
/// Adjusts the scroll offset to keep the selected item in view.
pub fn adjust_scroll_offset(&mut self) {
if let Some(selected) = self.selected_task {
// This is a simplified adjustment. In a real app, you'd need the actual list height
// from the layout to calculate this precisely. For now, we'll ensure it's not
// wildly out of bounds.
if selected < self.scroll_offset {
self.scroll_offset = selected;
} else if selected >= self.scroll_offset + 10 { // Assume ~10 visible items for simplicity
self.scroll_offset = selected - 9; // Adjust to show it near the bottom
}
}
}
}
Explanation:
Taskstruct: Simple data structure for our tasks.newis a convenient constructor.InputModeenum: This is crucial for controlling behavior.Normalmeans we’re navigating tasks;AddingTaskmeans we’re typing into the input field.Appstruct: Holds all our application data.tasks:Vec<Task>to store all tasks.selected_task:Option<usize>to track which task is highlighted.Optionbecause there might be no tasks.input:Stringto temporarily hold characters typed for a new task.input_mode:InputModeenum to know what the user is currently doing.scroll_offset: An integer to manage scrolling if the task list gets too long.
App::new(): Creates an initialAppstate with some sample tasks.- Helper methods (
next_task,previous_task, etc.): These methods encapsulate the logic for changing the application state. This keeps our main loop clean and focuses on what needs to happen, not how. Notice howadjust_scroll_offsetis called after any selection change.
Step 3: The Main Application Loop and UI Drawing
Now, let’s set up the main function and the run_app loop. This is where we initialize the terminal, handle events, and draw the UI.
// src/main.rs (continued)
// --- Event Handling (will be filled in) ---
fn handle_event(app: &mut App, event: CrosstermEvent) -> io::Result<bool> {
if let CrosstermEvent::Key(key) = event {
if key.kind == KeyEventKind::Press { // Only process key down events
match app.input_mode {
InputMode::Normal => match key.code {
KeyCode::Char('q') => return Ok(true), // Quit application
KeyCode::Char('j') | KeyCode::Down => app.next_task(),
KeyCode::Char('k') | KeyCode::Up => app.previous_task(),
KeyCode::Char('a') => {
app.input_mode = InputMode::AddingTask;
app.input.clear(); // Clear previous input
}
KeyCode::Char('d') => app.delete_selected_task(),
KeyCode::Enter => app.toggle_complete_selected_task(),
_ => {}
},
InputMode::AddingTask => match key.code {
KeyCode::Enter => {
app.add_task();
app.input_mode = InputMode::Normal;
}
KeyCode::Esc => {
app.input_mode = InputMode::Normal;
app.input.clear(); // Discard input
}
KeyCode::Backspace => {
app.input.pop();
}
KeyCode::Char(c) => {
app.input.push(c);
}
_ => {}
},
}
}
}
Ok(false) // Don't quit
}
// --- UI Drawing Function ---
fn ui(frame: &mut Frame, app: &mut App) {
// Define main layout: two vertical chunks for tasks and input/help
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(3)].as_ref()) // Task list, then input/help
.split(frame.size());
// --- Task List Widget ---
let mut list_items: Vec<ListItem> = app.tasks
.iter()
.enumerate()
.map(|(i, task)| {
let mut text = String::new();
if task.completed {
text.push_str("✓ "); // Checkmark for completed tasks
} else {
text.push_str(" "); // Space for incomplete
}
text.push_str(&task.description);
let mut style = Style::default().fg(Color::White);
if task.completed {
style = style.add_modifier(Modifier::DIM | Modifier::CROSSED_OUT);
}
ListItem::new(text).style(style)
})
.collect();
// If there are no tasks, add a placeholder
if list_items.is_empty() {
list_items.push(ListItem::new(Span::styled("No tasks yet! Press 'a' to add one.", Style::default().fg(Color::DarkGray))));
}
let title = format!(" Ratatui Task Manager ({}) ", app.tasks.len());
let tasks_block = Block::default()
.borders(Borders::ALL)
.title(Span::styled(title, Style::default().add_modifier(Modifier::BOLD)));
let mut list_state = ListState::default();
list_state.select(app.selected_task);
let tasks_list = List::new(list_items)
.block(tasks_block)
.highlight_style(Style::default().bg(Color::LightBlue).fg(Color::Black).add_modifier(Modifier::BOLD))
.highlight_symbol(">> ")
.state(&mut list_state) // We need to pass a mutable reference to ListState
.scroll_offset(app.scroll_offset);
frame.render_stateful_widget(tasks_list, main_chunks[0], &mut list_state);
// --- Input/Help Widget ---
let (msg, style) = match app.input_mode {
InputMode::Normal => (
vec![
Span::raw("Press "),
Span::styled("q", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to quit, "),
Span::styled("j/k", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to navigate, "),
Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to toggle complete, "),
Span::styled("d", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to delete, "),
Span::styled("a", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to add task."),
],
Style::default().fg(Color::LightCyan),
),
InputMode::AddingTask => (
vec![
Span::raw("Press "),
Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to add task, "),
Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to cancel."),
],
Style::default().fg(Color::Yellow),
),
};
let help_message = Paragraph::new(Line::from(msg)).style(style);
frame.render_widget(help_message, main_chunks[1]);
// Conditionally render the input box and cursor
if let InputMode::AddingTask = app.input_mode {
// Calculate position for the input box (below help message, or just above if no help)
// For simplicity, let's render it over the help message for now,
// or create an additional chunk if desired.
// Let's create a dedicated small area for input for clarity.
let input_area = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(100)].as_ref())
.margin(1) // Some margin
.split(main_chunks[1])[0]; // Use the bottom chunk
let input_block = Block::default()
.borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
.title(" New Task ");
let input_paragraph = Paragraph::new(app.input.as_str())
.style(Style::default().fg(Color::Cyan).bg(Color::DarkGray))
.block(input_block);
frame.render_widget(input_paragraph, input_area);
// Set cursor position
frame.set_cursor(
input_area.x + app.input.len() as u16 + 1, // +1 for the border
input_area.y + 1, // +1 for the border
);
}
}
// --- Main Application Loop Function ---
fn run_app<B: CrosstermBackend>(terminal: &mut Terminal<B>, mut app: App) -> io::Result<()> {
// Event polling interval (e.g., 250ms)
let tick_rate = Duration::from_millis(250);
let mut last_tick = Instant::now();
loop {
// Draw the UI
terminal.draw(|frame| ui(frame, &mut app))?;
// Handle events
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or_else(|| Duration::from_secs(0));
if crossterm::event::poll(timeout)? {
let event = event::read()?;
if handle_event(&mut app, event)? {
break; // Quit signal received
}
}
if last_tick.elapsed() >= tick_rate {
// This is where you'd put any periodic updates for your app
last_tick = Instant::now();
}
}
Ok(())
}
// --- Main Function ---
fn main() -> io::Result<()> {
// 1. Setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// 2. Create app and run
let app = App::new();
let res = run_app(&mut terminal, app);
// 3. Restore terminal
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
// Handle any errors from the app run
if let Err(err) = res {
println!("{:?}", err)
}
Ok(())
}
Explanation of the additions:
handle_event function:
- This function takes a mutable reference to our
Appstate and aCrosstermEvent. - It uses a
matchstatement onapp.input_modeto decide how to process key presses.InputMode::Normal:q: ReturnsOk(true)to signal the main loop to quit.j/k(or arrow keys): Callsapp.next_task()orapp.previous_task().a: Changesinput_modetoAddingTaskand clears the input buffer.d: Callsapp.delete_selected_task().Enter: Callsapp.toggle_complete_selected_task().
InputMode::AddingTask:Enter: Callsapp.add_task(), then switches back toNormalmode.Esc: Switches back toNormalmode and clears the input buffer (canceling the addition).Backspace: Removes the last character fromapp.input.Char(c): Appends the typed characterctoapp.input.
- It returns
Ok(false)if the app should not quit.
ui function:
- This function takes a
Frame(for drawing) and a mutableAppstate. - Layout: It uses
Layout::default().direction(Direction::Vertical)to split the screen into two main chunks:main_chunks[0]: For the task list (takes minimum 1 height, expanding).main_chunks[1]: For the input field and help message (fixed to 3 lines height).
- Task List:
- It iterates over
app.tasksto createListItems. - Completed tasks get a “✓ " prefix and
Modifier::CROSSED_OUTstyle. - A
Blockwith borders and a dynamic title is used for the list container. ListStateis used to manage the selected item’s highlighting. We pass&mut list_statetorender_stateful_widget.scroll_offsetis applied toListto handle scrolling.
- It iterates over
- Help Message/Input:
- A
Paragraphis used to display different help messages based onapp.input_mode. - Conditional Input Box: If
app.input_modeisAddingTask, a separateParagraphis rendered for theapp.inputbuffer. - Cursor Positioning: Crucially,
frame.set_cursor()is used to place the terminal cursor at the end of the input text when inAddingTaskmode, making it feel like a real input field.
- A
run_app function:
- This is our familiar main loop.
terminal.draw(|frame| ui(frame, &mut app))?: This is where ouruifunction is called to redraw the screen on each iteration.crossterm::event::poll(timeout)?andevent::read()?: This handles reading events with a timeout, preventing the app from consuming 100% CPU.handle_event(&mut app, event)?: Calls our event handler. If it returnstrue, the loop breaks.
main function:
- Standard Ratatui setup:
enable_raw_mode,EnterAlternateScreen. - Creates an
Appinstance. - Calls
run_app. - Standard Ratatui teardown:
disable_raw_mode,LeaveAlternateScreen,show_cursor.
Step 4: Run Your Task Manager!
Save the src/main.rs file and run your application:
cargo run
You should now see a simple task manager in your terminal! Try these actions:
j/korUp/Downarrows: Navigate tasks.Enter: Toggle task completion.a: Enter “add task” mode. Type something, thenEnterto add it.Esc: While in “add task” mode, cancel adding.d: Delete the selected task.q: Quit the application.
Congratulations, you’ve built an interactive TUI application!
Mini-Challenge: Filtering Tasks
Our task list can grow quite long. Let’s add a simple filtering mechanism.
Challenge: Implement a way to filter tasks. When the user presses f, they should enter a “filter mode” where they can type a search string. Only tasks whose descriptions contain this string should be visible. Pressing Esc should clear the filter and return to normal mode.
Hint:
- Add a new
InputMode::Filteringenum variant. - Add a
filter_input: Stringfield to yourAppstruct. - Modify
handle_eventto switch toFilteringmode onfand handle input forfilter_input. - Modify
uito filter theapp.tasksvector before creatingListItems, based onapp.filter_input(only ifInputMode::Filteringis active). - Remember to clear
filter_inputwhen exitingFilteringmode. - Consider adding a visual indicator (e.g., a “Filter: " prefix) when filtering.
What to observe/learn: This challenge reinforces the concept of state-driven UI. Changing the filter_input in your App state should immediately reflect in the rendered task list, demonstrating the reactivity of your Ratatui application.
Common Pitfalls & Troubleshooting
Cursor Not Showing/Moving Correctly:
- Problem: You’re in an input mode, but the terminal cursor isn’t visible or doesn’t follow your typing.
- Solution: Ensure you’re calling
frame.set_cursor()in youruifunction, and that itsxandycoordinates are correctly calculated relative to your inputParagraph’s area. Also, make sureterminal.show_cursor()?is called inmainbeforerun_appexits.
State Not Updating / UI Not Reacting:
- Problem: You perform an action (e.g., add a task), but the UI doesn’t change.
- Solution: Double-check that your
handle_eventfunction correctly modifies theappstruct’s fields (app.tasks.push(),app.selected_task = Some(...),app.input_mode = ...). Remember, Ratatui redraws based on the current state ofappon every tick. If the state isn’t changed, the UI won’t change. Also, ensureterminal.draw()is called in every loop iteration.
Layout Issues / Widgets Overlapping:
- Problem: Your widgets aren’t appearing where you expect, or they’re overlapping.
- Solution: Carefully review your
Layout::default().constraints(...)andsplit()calls. Useframe.size()to understand the total area, and then print intermediateRects (e.g.,dbg!(main_chunks)) to see how your layout is dividing the screen. Constraints likeConstraint::Length()are for fixed sizes, whileConstraint::Min()andConstraint::Percentage()are for flexible areas.
Raw Mode Issues (Terminal Malfunctions After Exit):
- Problem: After your application exits, your terminal looks weird (e.g., doesn’t echo input, strange characters).
- Solution: This usually means
disable_raw_mode()orLeaveAlternateScreenwasn’t called. Ensure yourmainfunction’s cleanup code is robust, even ifrun_appreturns an error. A common pattern is to put cleanup in adeferorDropimplementation, or use afinallyblock if your language supports it (Rust usesDropor careful error handling). Formain, wrapping therun_appcall in amatchorif let Errblock ensures cleanup happens.
Summary
In this chapter, you moved from theoretical understanding to practical application by building a fully functional Ratatui task manager. Here are the key takeaways:
- Centralized State: You learned to manage your application’s data and current behavior using a single
Appstruct and anInputModeenum. - Event-Driven Logic: You implemented a robust event handler that processes user input differently based on the application’s current mode, demonstrating how to build interactive experiences.
- Dynamic UI Rendering: You saw how to use Ratatui’s widgets and layout system to draw a dynamic interface that reflects the current application state, including conditional rendering of an input field and cursor.
- Incremental Development: Building the application piece by piece, from state definition to event handling and UI rendering, is a highly effective way to tackle complex projects.
You now have a solid foundation for building your own interactive TUI applications. The principles of state management, event handling, and conditional rendering are fundamental to virtually any interactive software.
What’s Next?
In the next chapter, we’ll dive deeper into more advanced Ratatui features, such as custom widgets, managing complex layouts, and potentially integrating asynchronous operations for fetching data or long-running tasks. We’ll also explore testing strategies for your TUI applications!
References
This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.