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)
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:
- Directory structure - How to organize TUI as separate crate
- Cargo.toml - Proper dependency setup with shared library
- AppState - Implementation of R-STATE-SEPARATION pattern
- Screens - Multiple screen implementations following templates
- Integration - Dashboard registration via ToolSummaryProvider
- Testing - Comprehensive test coverage (15+ tests minimum)
- 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.