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 draw and handle_events loop.
  • 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 Task objects, 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:

flowchart TD App_Start[Application Start] --> Normal_Mode[Normal Mode: View/Navigate Tasks] Normal_Mode -->|\1| Adding_Task_Mode[Adding Task Mode: Type New Task] Normal_Mode -->|\1| Normal_Mode Normal_Mode -->|\1| Normal_Mode Normal_Mode -->|\1| Normal_Mode Adding_Task_Mode -->|\1| Normal_Mode Adding_Task_Mode -->|\1| Normal_Mode Adding_Task_Mode -->|\1| Adding_Task_Mode

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 Enter in this mode adds the task, and Esc cancels 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::AddingTask is 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.

  1. Create a new project:

    cargo new ratatui-task-manager
    cd ratatui-task-manager
    
  2. Add dependencies to Cargo.toml: Open Cargo.toml and 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-17
    

    Explanation:

    • 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:

  • Task struct: Simple data structure for our tasks. new is a convenient constructor.
  • InputMode enum: This is crucial for controlling behavior. Normal means we’re navigating tasks; AddingTask means we’re typing into the input field.
  • App struct: Holds all our application data.
    • tasks: Vec<Task> to store all tasks.
    • selected_task: Option<usize> to track which task is highlighted. Option because there might be no tasks.
    • input: String to temporarily hold characters typed for a new task.
    • input_mode: InputMode enum 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 initial App state 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 how adjust_scroll_offset is 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 App state and a CrosstermEvent.
  • It uses a match statement on app.input_mode to decide how to process key presses.
    • InputMode::Normal:
      • q: Returns Ok(true) to signal the main loop to quit.
      • j/k (or arrow keys): Calls app.next_task() or app.previous_task().
      • a: Changes input_mode to AddingTask and clears the input buffer.
      • d: Calls app.delete_selected_task().
      • Enter: Calls app.toggle_complete_selected_task().
    • InputMode::AddingTask:
      • Enter: Calls app.add_task(), then switches back to Normal mode.
      • Esc: Switches back to Normal mode and clears the input buffer (canceling the addition).
      • Backspace: Removes the last character from app.input.
      • Char(c): Appends the typed character c to app.input.
  • It returns Ok(false) if the app should not quit.

ui function:

  • This function takes a Frame (for drawing) and a mutable App state.
  • 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.tasks to create ListItems.
    • Completed tasks get a “✓ " prefix and Modifier::CROSSED_OUT style.
    • A Block with borders and a dynamic title is used for the list container.
    • ListState is used to manage the selected item’s highlighting. We pass &mut list_state to render_stateful_widget.
    • scroll_offset is applied to List to handle scrolling.
  • Help Message/Input:
    • A Paragraph is used to display different help messages based on app.input_mode.
    • Conditional Input Box: If app.input_mode is AddingTask, a separate Paragraph is rendered for the app.input buffer.
    • Cursor Positioning: Crucially, frame.set_cursor() is used to place the terminal cursor at the end of the input text when in AddingTask mode, making it feel like a real input field.

run_app function:

  • This is our familiar main loop.
  • terminal.draw(|frame| ui(frame, &mut app))?: This is where our ui function is called to redraw the screen on each iteration.
  • crossterm::event::poll(timeout)? and event::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 returns true, the loop breaks.

main function:

  • Standard Ratatui setup: enable_raw_mode, EnterAlternateScreen.
  • Creates an App instance.
  • 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/k or Up/Down arrows: Navigate tasks.
  • Enter: Toggle task completion.
  • a: Enter “add task” mode. Type something, then Enter to 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:

  1. Add a new InputMode::Filtering enum variant.
  2. Add a filter_input: String field to your App struct.
  3. Modify handle_event to switch to Filtering mode on f and handle input for filter_input.
  4. Modify ui to filter the app.tasks vector before creating ListItems, based on app.filter_input (only if InputMode::Filtering is active).
  5. Remember to clear filter_input when exiting Filtering mode.
  6. 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

  1. 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 your ui function, and that its x and y coordinates are correctly calculated relative to your input Paragraph’s area. Also, make sure terminal.show_cursor()? is called in main before run_app exits.
  2. 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_event function correctly modifies the app struct’s fields (app.tasks.push(), app.selected_task = Some(...), app.input_mode = ...). Remember, Ratatui redraws based on the current state of app on every tick. If the state isn’t changed, the UI won’t change. Also, ensure terminal.draw() is called in every loop iteration.
  3. Layout Issues / Widgets Overlapping:

    • Problem: Your widgets aren’t appearing where you expect, or they’re overlapping.
    • Solution: Carefully review your Layout::default().constraints(...) and split() calls. Use frame.size() to understand the total area, and then print intermediate Rects (e.g., dbg!(main_chunks)) to see how your layout is dividing the screen. Constraints like Constraint::Length() are for fixed sizes, while Constraint::Min() and Constraint::Percentage() are for flexible areas.
  4. 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() or LeaveAlternateScreen wasn’t called. Ensure your main function’s cleanup code is robust, even if run_app returns an error. A common pattern is to put cleanup in a defer or Drop implementation, or use a finally block if your language supports it (Rust uses Drop or careful error handling). For main, wrapping the run_app call in a match or if let Err block 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 App struct and an InputMode enum.
  • 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.