syntaxis/shared/rust-tui/EXAMPLE_WALKTHROUGH.md

758 lines
19 KiB
Markdown
Raw Normal View History

# 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`:
```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:
```toml
[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:
```rust
//! 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`:
```rust
//! 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`:
```rust
//! 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`:
```rust
//! 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`:
```rust
//! 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`:
```rust
#![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:
```rust
//! 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`:
```rust
//! 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
```bash
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:
```bash
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.