syntaxis/shared/rust-tui/EXAMPLE_WALKTHROUGH.md
Jesús Pérez 9cef9b8d57 refactor: consolidate configuration directories
Merge _configs/ into config/ for single configuration directory.
Update all path references.

Changes:
- Move _configs/* to config/
- Update .gitignore for new patterns
- No code references to _configs/ found

Impact: -1 root directory (layout_conventions.md compliance)
2025-12-26 18:36:23 +00:00

19 KiB

TUI Integration Example Walkthrough

Complete step-by-step guide for creating a new tool TUI from scratch using templates.

Overview

This walkthrough shows how to create a TUI for an existing tool. We'll use a hypothetical note-manager tool as the example, but the same steps apply to any tool.

Step 1: Project Structure Setup

Create the following directory structure within your tool's workspace:

note-manager/
├── crates/
│   ├── note-manager-core/        # Your existing core library
│   │   ├── Cargo.toml
│   │   └── src/
│   │       ├── lib.rs
│   │       ├── types.rs
│   │       └── handlers/
│   │
│   └── note-manager-tui/         # NEW: TUI crate (create this)
│       ├── Cargo.toml
│       ├── src/
│       │   ├── lib.rs
│       │   ├── app.rs
│       │   ├── screens/
│       │   │   ├── mod.rs
│       │   │   ├── list.rs
│       │   │   ├── create.rs
│       │   │   └── detail.rs
│       │   └── integration.rs
│       └── tests/
│           └── integration_tests.rs
│
├── Cargo.toml (workspace root)
└── Cargo.lock

Step 2: Create Cargo.toml for TUI Crate

Create crates/note-manager-tui/Cargo.toml:

[package]
name = "note-manager-tui"
version = "0.1.0"
edition = "2021"

[dependencies]
note-manager-core = { path = "../note-manager-core" }
tools-tui-shared = { path = "../../../shared/rust-tui" }
ratatui = { version = "0.30.0-beta.0", features = ["all-widgets"] }
crossterm = "0.29"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.40", features = ["full"] }

[lib]
path = "src/lib.rs"

[dev-dependencies]
tempfile = "3.12"

Update workspace root Cargo.toml to include the new crate:

[workspace]
members = [
    "crates/note-manager-core",
    "crates/note-manager-tui",  # Add this line
]

Step 3: Implement Application State (app.rs)

Create crates/note-manager-tui/src/app.rs using the template:

//! Application state for Note Manager TUI

use serde::{Deserialize, Serialize};
use std::fmt;

/// Screen types - customize based on your tool's screens
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ScreenType {
    /// List screen - show all notes
    List,
    /// Create screen - create new note
    Create,
    /// Detail screen - show note details
    Detail,
}

impl fmt::Display for ScreenType {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ScreenType::List => write!(f, "List"),
            ScreenType::Create => write!(f, "Create"),
            ScreenType::Detail => write!(f, "Detail"),
        }
    }
}

/// Application state - manages all mutable state
///
/// # Design Pattern: R-STATE-SEPARATION
/// All state mutations go through explicit methods. Rendering functions
/// only read state (take `&AppState`, never `&mut AppState`).
#[derive(Clone, Debug)]
pub struct AppState {
    /// Current screen (navigation state)
    pub current_screen: ScreenType,

    /// Notes collection
    pub notes: Vec<String>,

    /// Currently selected note index
    pub selected_index: usize,

    /// Whether state changed (needs redraw)
    pub dirty: bool,

    /// Whether application should quit
    pub should_quit: bool,

    /// Form field: note title
    pub form_title: String,

    /// Form field: note content
    pub form_content: String,
}

impl AppState {
    /// Create new application state
    pub fn new() -> Self {
        AppState {
            current_screen: ScreenType::List,
            notes: Vec::new(),
            selected_index: 0,
            dirty: true,
            should_quit: false,
            form_title: String::new(),
            form_content: String::new(),
        }
    }

    // === Note Management ===

    /// Add a new note
    pub fn add_note(&mut self, title: String) {
        self.notes.push(title);
        self.dirty = true;
    }

    /// Get currently selected note
    pub fn selected_note(&self) -> Option<&String> {
        self.notes.get(self.selected_index)
    }

    // === Navigation ===

    /// Select next note in list
    pub fn select_next(&mut self) {
        if !self.notes.is_empty() {
            self.selected_index = (self.selected_index + 1) % self.notes.len();
            self.dirty = true;
        }
    }

    /// Select previous note in list
    pub fn select_previous(&mut self) {
        if !self.notes.is_empty() {
            self.selected_index = if self.selected_index == 0 {
                self.notes.len() - 1
            } else {
                self.selected_index - 1
            };
            self.dirty = true;
        }
    }

    /// Switch to a different screen
    pub fn set_screen(&mut self, screen: ScreenType) {
        if self.current_screen != screen {
            self.current_screen = screen;
            self.dirty = true;
        }
    }

    // === Form Management ===

    /// Reset form fields to empty
    pub fn reset_form(&mut self) {
        self.form_title.clear();
        self.form_content.clear();
        self.dirty = true;
    }

    // === Lifecycle ===

    /// Request application quit
    pub fn request_quit(&mut self) {
        self.should_quit = true;
    }

    /// Check if state has changed since last render
    pub fn is_dirty(&self) -> bool {
        self.dirty
    }

    /// Mark state as rendered (clear dirty flag)
    pub fn reset_dirty(&mut self) {
        self.dirty = false;
    }

    /// Check if should quit
    pub fn should_quit(&self) -> bool {
        self.should_quit
    }
}

impl Default for AppState {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_app_state_new() {
        let state = AppState::new();
        assert_eq!(state.current_screen, ScreenType::List);
        assert!(state.notes.is_empty());
        assert!(state.dirty);
        assert!(!state.should_quit);
    }

    #[test]
    fn test_add_note() {
        let mut state = AppState::new();
        state.add_note("Test Note".to_string());
        assert_eq!(state.notes.len(), 1);
        assert!(state.dirty);
    }

    #[test]
    fn test_select_next() {
        let mut state = AppState::new();
        state.add_note("Note 1".to_string());
        state.add_note("Note 2".to_string());
        state.reset_dirty();

        state.select_next();
        assert_eq!(state.selected_index, 1);
        assert!(state.dirty);
    }

    #[test]
    fn test_set_screen() {
        let mut state = AppState::new();
        state.reset_dirty();

        state.set_screen(ScreenType::Create);
        assert_eq!(state.current_screen, ScreenType::Create);
        assert!(state.dirty);
    }

    #[test]
    fn test_reset_form() {
        let mut state = AppState::new();
        state.form_title = "Test".to_string();
        state.form_content = "Content".to_string();
        state.reset_dirty();

        state.reset_form();
        assert!(state.form_title.is_empty());
        assert!(state.form_content.is_empty());
        assert!(state.dirty);
    }
}

Step 4: Implement Screens

Create crates/note-manager-tui/src/screens/list.rs:

//! List screen - shows all notes

use crate::app::AppState;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, List, ListItem};
use tools_tui_shared::prelude::*;

/// List screen - display and navigate notes
pub struct ListScreen;

impl ListScreen {
    /// Render the list screen
    pub fn render(state: &AppState, frame: &mut ratatui::Frame, area: Rect) {
        let theme = Theme::dark();

        // Handle empty state
        if state.notes.is_empty() {
            let empty_msg = ratatui::widgets::Paragraph::new("No notes yet. Create one to get started.")
                .style(theme.dimmed_style())
                .alignment(ratatui::layout::Alignment::Center);
            frame.render_widget(empty_msg, area);
            return;
        }

        // Create list items from state
        let items: Vec<ListItem> = state
            .notes
            .iter()
            .enumerate()
            .map(|(idx, title)| {
                let marker = if idx == state.selected_index { "★ " } else { "  " };
                ListItem::new(format!("{}{}", marker, title))
            })
            .collect();

        // Build list widget
        let list = List::new(items)
            .block(
                Block::default()
                    .title("Notes")
                    .borders(Borders::ALL)
                    .border_style(theme.focused_style()),
            )
            .style(theme.normal_style())
            .row_highlight_style(theme.selected_style());

        // Render to frame
        frame.render_widget(list, area);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_list_screen_empty() {
        let state = AppState::new();
        assert!(state.notes.is_empty());
    }

    #[test]
    fn test_list_screen_with_notes() {
        let mut state = AppState::new();
        state.add_note("Note 1".to_string());
        state.add_note("Note 2".to_string());
        assert_eq!(state.notes.len(), 2);
    }
}

Create crates/note-manager-tui/src/screens/create.rs:

//! Create screen - form for creating new notes

use crate::app::AppState;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph};
use tools_tui_shared::prelude::*;

/// Create screen - form for creating new notes
pub struct CreateScreen;

impl CreateScreen {
    /// Render the create screen
    pub fn render(state: &AppState, frame: &mut ratatui::Frame, area: Rect) {
        let theme = Theme::dark();

        // Split area into form and help sections
        let chunks = Layout::default()
            .direction(ratatui::layout::Direction::Vertical)
            .constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
            .split(area);

        // Form section
        let form_text = vec![
            Line::from(vec![
                Span::styled("Title: ", theme.focused_style()),
                Span::raw(if state.form_title.is_empty() {
                    "<empty>".to_string()
                } else {
                    state.form_title.clone()
                }),
            ]),
            Line::from(""),
            Line::from(vec![
                Span::styled("Content: ", theme.focused_style()),
                Span::raw(if state.form_content.is_empty() {
                    "<empty>".to_string()
                } else {
                    state.form_content.clone()
                }),
            ]),
        ];

        let form = Paragraph::new(form_text)
            .block(
                Block::default()
                    .title("Create Note")
                    .borders(Borders::ALL)
                    .border_style(theme.focused_style()),
            )
            .style(theme.normal_style());

        frame.render_widget(form, chunks[0]);

        // Help section
        let help_text = vec![Line::from(vec![
            Span::styled("Tab", theme.selected_style()),
            Span::raw(" - Next field | "),
            Span::styled("Enter", theme.selected_style()),
            Span::raw(" - Create | "),
            Span::styled("Esc", theme.selected_style()),
            Span::raw(" - Cancel"),
        ])];

        let help = Paragraph::new(help_text)
            .block(Block::default().title("Help").borders(Borders::ALL))
            .style(theme.dimmed_style());

        frame.render_widget(help, chunks[1]);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_create_screen_empty_form() {
        let state = AppState::new();
        assert!(state.form_title.is_empty());
        assert!(state.form_content.is_empty());
    }
}

Create crates/note-manager-tui/src/screens/detail.rs:

//! Detail screen - shows note details

use crate::app::AppState;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph};
use tools_tui_shared::prelude::*;

/// Detail screen - show note details
pub struct DetailScreen;

impl DetailScreen {
    /// Render the detail screen
    pub fn render(state: &AppState, frame: &mut ratatui::Frame, area: Rect) {
        let theme = Theme::dark();

        if let Some(note) = state.selected_note() {
            let detail_text = vec![
                Line::from(vec![
                    Span::styled("Note: ", theme.selected_style()),
                    Span::raw(note),
                ]),
                Line::from(""),
                Line::from(vec![
                    Span::styled("Status: ", theme.selected_style()),
                    Span::raw("Active"),
                ]),
            ];

            let detail = Paragraph::new(detail_text)
                .block(
                    Block::default()
                        .title("Details")
                        .borders(Borders::ALL)
                        .border_style(theme.focused_style()),
                )
                .style(theme.normal_style());

            frame.render_widget(detail, area);
        } else {
            let empty = Paragraph::new("No note selected")
                .style(theme.dimmed_style())
                .alignment(ratatui::layout::Alignment::Center);
            frame.render_widget(empty, area);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_detail_screen_no_selection() {
        let state = AppState::new();
        assert!(state.selected_note().is_none());
    }
}

Create crates/note-manager-tui/src/screens/mod.rs:

//! Screen implementations

pub mod create;
pub mod detail;
pub mod list;

pub use create::CreateScreen;
pub use detail::DetailScreen;
pub use list::ListScreen;

Step 5: Create Library Entry Point (lib.rs)

Create crates/note-manager-tui/src/lib.rs:

#![forbid(unsafe_code)]
#![warn(missing_docs)]

//! Note Manager TUI - Terminal user interface for note management

pub mod app;
pub mod screens;

pub use app::{AppState, ScreenType};

/// Prelude module - convenient imports for TUI usage
pub mod prelude {
    pub use crate::{AppState, ScreenType};
    pub use crate::screens::{CreateScreen, DetailScreen, ListScreen};
}

Step 6: Create Integration Module (integration.rs)

Create crates/note-manager-tui/src/integration.rs to register with multi-tool dashboard:

//! Integration with tools-dashboard

use crate::app::AppState;
use std::sync::{Arc, Mutex};
use tools_tui_shared::integration::{ToolMetrics, ToolSummary, ToolSummaryProvider};

/// Note Manager TUI integration for tools-dashboard
pub struct NoteManagerTui {
    /// Shared state
    state: Arc<Mutex<AppState>>,
}

impl NoteManagerTui {
    /// Create new note manager TUI integration
    pub fn new() -> Self {
        NoteManagerTui {
            state: Arc::new(Mutex::new(AppState::new())),
        }
    }

    /// Get note count
    pub fn note_count(&self) -> usize {
        self.state.lock().unwrap().notes.len()
    }
}

impl Default for NoteManagerTui {
    fn default() -> Self {
        Self::new()
    }
}

impl ToolSummaryProvider for NoteManagerTui {
    fn name(&self) -> &str {
        "note-manager"
    }

    fn summary(&self) -> Result<ToolSummary, String> {
        let state = self.state.lock().unwrap();
        let metrics = ToolMetrics::new(
            state.notes.len() as u32,
            state.notes.len() as u32,
            0,
            chrono::Utc::now().to_rfc2822(),
        );

        Ok(ToolSummary::new(
            "note-manager".to_string(),
            "Note Manager".to_string(),
            "Manage notes with TUI interface".to_string(),
            metrics,
        )
        .with_commands(vec![
            "list".to_string(),
            "create".to_string(),
            "delete".to_string(),
        ]))
    }

    fn execute_command(&self, command: &str, _args: &[String]) -> Result<String, String> {
        match command {
            "list" => Ok(format!("Listed {} notes", self.note_count())),
            "create" => Ok("Creating new note...".to_string()),
            "delete" => Ok("Note deleted".to_string()),
            _ => Err(format!("Unknown command: {}", command)),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_note_manager_tui_creation() {
        let tui = NoteManagerTui::new();
        assert_eq!(tui.name(), "note-manager");
    }

    #[test]
    fn test_note_manager_empty_summary() {
        let tui = NoteManagerTui::new();
        let summary = tui.summary().unwrap();
        assert_eq!(summary.label, "Note Manager");
    }
}

Step 7: Create Tests

Create crates/note-manager-tui/tests/integration_tests.rs:

//! Integration tests for Note Manager TUI

use note_manager_tui::{AppState, ScreenType};

#[test]
fn test_full_workflow() {
    let mut state = AppState::new();

    // Start on list screen
    assert_eq!(state.current_screen, ScreenType::List);

    // Create a note
    state.add_note("My First Note".to_string());
    assert_eq!(state.notes.len(), 1);

    // Navigate to create screen
    state.set_screen(ScreenType::Create);
    assert_eq!(state.current_screen, ScreenType::Create);

    // Fill form
    state.form_title = "New Note".to_string();
    state.form_content = "Note content".to_string();

    // Reset form
    state.reset_form();
    assert!(state.form_title.is_empty());
    assert!(state.form_content.is_empty());

    // Navigate back to list
    state.set_screen(ScreenType::List);
    assert_eq!(state.current_screen, ScreenType::List);

    // Request quit
    state.request_quit();
    assert!(state.should_quit());
}

#[test]
fn test_dirty_flag_optimization() {
    let mut state = AppState::new();
    assert!(state.is_dirty());

    state.reset_dirty();
    assert!(!state.is_dirty());

    // Any change should mark dirty
    state.add_note("Test".to_string());
    assert!(state.is_dirty());
}

#[test]
fn test_selection_navigation() {
    let mut state = AppState::new();
    state.add_note("Note 1".to_string());
    state.add_note("Note 2".to_string());
    state.add_note("Note 3".to_string());

    assert_eq!(state.selected_index, 0);

    state.select_next();
    assert_eq!(state.selected_index, 1);

    state.select_next();
    assert_eq!(state.selected_index, 2);

    // Wraps around
    state.select_next();
    assert_eq!(state.selected_index, 0);

    state.select_previous();
    assert_eq!(state.selected_index, 2);
}

Step 8: Verify Everything Compiles and Tests Pass

cd /Users/Akasha/Tools/note-manager
cargo check --workspace
cargo test --workspace
cargo clippy --all-targets
cargo fmt --check

Expected output:

test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Step 9: Run Your TUI

If you created a binary entry point in src/main.rs, you can run:

cargo run -p note-manager-tui --release

Summary

This walkthrough demonstrates:

  1. Directory structure - How to organize TUI as separate crate
  2. Cargo.toml - Proper dependency setup with shared library
  3. AppState - Implementation of R-STATE-SEPARATION pattern
  4. Screens - Multiple screen implementations following templates
  5. Integration - Dashboard registration via ToolSummaryProvider
  6. Testing - Comprehensive test coverage (15+ tests minimum)
  7. Quality - Zero unsafe code, proper error handling, documentation

You can adapt this structure and code for any tool in the Tools ecosystem. The key is following the patterns established in the templates and tools-tui-shared library.