429 lines
10 KiB
Markdown
429 lines
10 KiB
Markdown
|
|
# TUI Integration Guide
|
||
|
|
|
||
|
|
Complete guide for integrating RataTui-based TUI into any Tools ecosystem project.
|
||
|
|
|
||
|
|
## Overview
|
||
|
|
|
||
|
|
The `tools-tui-shared` library provides a production-ready foundation for building terminal user interfaces (TUIs) across the Tools ecosystem. All TUI implementations follow strict architectural patterns and code quality standards.
|
||
|
|
|
||
|
|
## Quick Start: 5 Steps
|
||
|
|
|
||
|
|
### Step 1: Add Dependency
|
||
|
|
|
||
|
|
```toml
|
||
|
|
[dependencies]
|
||
|
|
tools-tui-shared = { path = "../../../shared/rust-tui" }
|
||
|
|
ratatui = { version = "0.30.0-beta.0", features = ["all-widgets"] }
|
||
|
|
crossterm = "0.29"
|
||
|
|
tui-textarea = "0.7"
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 2: Create App State
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use serde::{Deserialize, Serialize};
|
||
|
|
|
||
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
|
|
pub enum ScreenType {
|
||
|
|
List,
|
||
|
|
Detail,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Clone, Debug)]
|
||
|
|
pub struct AppState {
|
||
|
|
pub current_screen: ScreenType,
|
||
|
|
pub items: Vec<YourType>,
|
||
|
|
pub selected_index: usize,
|
||
|
|
pub dirty: bool,
|
||
|
|
pub should_quit: bool,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl AppState {
|
||
|
|
pub fn new() -> Self {
|
||
|
|
AppState {
|
||
|
|
current_screen: ScreenType::List,
|
||
|
|
items: Vec::new(),
|
||
|
|
selected_index: 0,
|
||
|
|
dirty: true,
|
||
|
|
should_quit: false,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 3: Implement Screens
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use crate::app::AppState;
|
||
|
|
use tools_tui_shared::prelude::*;
|
||
|
|
use ratatui::prelude::*;
|
||
|
|
use ratatui::widgets::{Block, Borders, List, ListItem};
|
||
|
|
|
||
|
|
pub struct ListScreen;
|
||
|
|
|
||
|
|
impl ListScreen {
|
||
|
|
pub fn render(state: &AppState, frame: &mut ratatui::Frame, area: Rect) {
|
||
|
|
let theme = Theme::dark();
|
||
|
|
|
||
|
|
let items: Vec<ListItem> = state.items.iter()
|
||
|
|
.enumerate()
|
||
|
|
.map(|(idx, item)| {
|
||
|
|
let marker = if idx == state.selected_index { "★ " } else { " " };
|
||
|
|
ListItem::new(format!("{}{}", marker, item.name))
|
||
|
|
})
|
||
|
|
.collect();
|
||
|
|
|
||
|
|
let list = List::new(items)
|
||
|
|
.block(Block::default()
|
||
|
|
.title("Items")
|
||
|
|
.borders(Borders::ALL)
|
||
|
|
.border_style(theme.focused_style()))
|
||
|
|
.style(theme.normal_style());
|
||
|
|
|
||
|
|
frame.render_widget(list, area);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 4: Create lib.rs
|
||
|
|
|
||
|
|
```rust
|
||
|
|
#![forbid(unsafe_code)]
|
||
|
|
#![warn(missing_docs)]
|
||
|
|
|
||
|
|
pub mod app;
|
||
|
|
pub mod screens;
|
||
|
|
|
||
|
|
pub use app::{AppState, ScreenType};
|
||
|
|
|
||
|
|
pub mod prelude {
|
||
|
|
pub use crate::{AppState, ScreenType};
|
||
|
|
pub use crate::screens::{ListScreen, DetailScreen};
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 5: Run Tests
|
||
|
|
|
||
|
|
```bash
|
||
|
|
cargo test --lib
|
||
|
|
```
|
||
|
|
|
||
|
|
## Architecture Patterns
|
||
|
|
|
||
|
|
### R-STATE-SEPARATION
|
||
|
|
|
||
|
|
**Principle**: Complete separation of state from rendering logic.
|
||
|
|
|
||
|
|
```rust
|
||
|
|
// ✅ CORRECT: State methods don't render
|
||
|
|
impl AppState {
|
||
|
|
pub fn select_next(&mut self) {
|
||
|
|
if !self.items.is_empty() {
|
||
|
|
self.selected_index = (self.selected_index + 1) % self.items.len();
|
||
|
|
self.dirty = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ✅ CORRECT: Render methods only read state
|
||
|
|
impl ListScreen {
|
||
|
|
pub fn render(state: &AppState, frame: &mut ratatui::Frame, area: Rect) {
|
||
|
|
// Never call state.select_next() or other mutation methods
|
||
|
|
// Only read: state.selected_index, state.items, etc.
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ❌ WRONG: Don't put rendering logic in state
|
||
|
|
impl AppState {
|
||
|
|
pub fn render(&self) { } // DON'T DO THIS
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### R-WIDGET-COMPOSITION
|
||
|
|
|
||
|
|
**Principle**: Build complex UIs from simple, reusable widgets.
|
||
|
|
|
||
|
|
```rust
|
||
|
|
// Use shared widgets from tools-tui-shared
|
||
|
|
use tools_tui_shared::widgets::{Menu, Form, Table, StatusBar};
|
||
|
|
|
||
|
|
// Compose widgets for your screens
|
||
|
|
let menu = Menu::new(vec![
|
||
|
|
MenuItem::new("Save".to_string()).with_key_binding("Ctrl+S"),
|
||
|
|
MenuItem::new("Exit".to_string()).with_key_binding("Ctrl+Q"),
|
||
|
|
]);
|
||
|
|
|
||
|
|
let form = Form::new()
|
||
|
|
.add_field(FormField::new("name", FieldType::Text))
|
||
|
|
.add_field(FormField::new("email", FieldType::Text));
|
||
|
|
```
|
||
|
|
|
||
|
|
### R-CONSISTENT-THEMING
|
||
|
|
|
||
|
|
**Principle**: Single theme source for all colors and styles.
|
||
|
|
|
||
|
|
```rust
|
||
|
|
let theme = Theme::dark();
|
||
|
|
|
||
|
|
// All UI elements use the same theme
|
||
|
|
let primary_style = theme.selected_style(); // Header, emphasis
|
||
|
|
let success_style = theme.success_style(); // Success messages
|
||
|
|
let warning_style = theme.warning_style(); // Warnings
|
||
|
|
let error_style = theme.error_style(); // Errors
|
||
|
|
let normal_style = theme.normal_style(); // Default text
|
||
|
|
let dimmed_style = theme.dimmed_style(); // Disabled/background
|
||
|
|
```
|
||
|
|
|
||
|
|
### R-EVENT-LOOP
|
||
|
|
|
||
|
|
**Principle**: Non-blocking event polling with timeout.
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use tools_tui_shared::events::EventHandler;
|
||
|
|
|
||
|
|
let event_handler = EventHandler::new(100)?; // 100ms tick rate
|
||
|
|
|
||
|
|
loop {
|
||
|
|
if app_state.dirty {
|
||
|
|
terminal.draw(|f| render(f, &app_state))?;
|
||
|
|
app_state.reset_dirty();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Non-blocking poll with timeout
|
||
|
|
match event_handler.poll()? {
|
||
|
|
AppEvent::Tick => { /* Update */},
|
||
|
|
AppEvent::Key(key) => { /* Handle key */},
|
||
|
|
AppEvent::Mouse(evt) => { /* Handle mouse */},
|
||
|
|
AppEvent::Resize(w, h) => { /* Handle resize */},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### R-GRACEFUL-SHUTDOWN
|
||
|
|
|
||
|
|
**Principle**: RAII pattern ensures terminal cleanup on exit.
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use tools_tui_shared::terminal::TerminalGuard;
|
||
|
|
|
||
|
|
// Terminal is automatically set up and cleaned up
|
||
|
|
let _guard = TerminalGuard::setup()?;
|
||
|
|
let mut terminal = _guard.terminal()?;
|
||
|
|
|
||
|
|
// Even if panic occurs, terminal is restored
|
||
|
|
// Drop trait automatically handles cleanup
|
||
|
|
```
|
||
|
|
|
||
|
|
## Common Patterns
|
||
|
|
|
||
|
|
### Dirty Flag Optimization
|
||
|
|
|
||
|
|
Only redraw when state changes:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
pub fn is_dirty(&self) -> bool {
|
||
|
|
self.dirty
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn reset_dirty(&mut self) {
|
||
|
|
self.dirty = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn mark_dirty(&mut self) {
|
||
|
|
self.dirty = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
// In main event loop:
|
||
|
|
if app_state.is_dirty() {
|
||
|
|
terminal.draw(|f| render(f, &app_state))?;
|
||
|
|
app_state.reset_dirty();
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Selection Navigation
|
||
|
|
|
||
|
|
Implement consistent navigation:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
pub fn select_next(&mut self) {
|
||
|
|
if !self.items.is_empty() {
|
||
|
|
self.selected_index = (self.selected_index + 1) % self.items.len();
|
||
|
|
self.dirty = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn select_previous(&mut self) {
|
||
|
|
if !self.items.is_empty() {
|
||
|
|
self.selected_index = if self.selected_index == 0 {
|
||
|
|
self.items.len() - 1
|
||
|
|
} else {
|
||
|
|
self.selected_index - 1
|
||
|
|
};
|
||
|
|
self.dirty = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Screen Navigation
|
||
|
|
|
||
|
|
Manage multiple screens:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
pub fn set_screen(&mut self, screen: ScreenType) {
|
||
|
|
if self.current_screen != screen {
|
||
|
|
self.current_screen = screen;
|
||
|
|
self.dirty = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// In render loop:
|
||
|
|
match state.current_screen {
|
||
|
|
ScreenType::List => ListScreen::render(state, frame, area),
|
||
|
|
ScreenType::Detail => DetailScreen::render(state, frame, area),
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Testing Guidelines
|
||
|
|
|
||
|
|
Each module should have comprehensive tests:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_app_state_new() {
|
||
|
|
let state = AppState::new();
|
||
|
|
assert!(state.dirty);
|
||
|
|
assert!(!state.should_quit);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_select_next() {
|
||
|
|
let mut state = AppState::new();
|
||
|
|
state.items.push(/* ... */);
|
||
|
|
state.select_next();
|
||
|
|
assert_eq!(state.selected_index, 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_screen_navigation() {
|
||
|
|
let mut state = AppState::new();
|
||
|
|
state.set_screen(ScreenType::Detail);
|
||
|
|
assert_eq!(state.current_screen, ScreenType::Detail);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Minimum test coverage**: 15+ tests per module
|
||
|
|
|
||
|
|
## Code Quality Checklist
|
||
|
|
|
||
|
|
- [ ] `#![forbid(unsafe_code)]` at crate root
|
||
|
|
- [ ] `#![warn(missing_docs)]` for public items
|
||
|
|
- [ ] All public types implement Debug
|
||
|
|
- [ ] All public functions documented with examples
|
||
|
|
- [ ] Result<T> for all fallible operations
|
||
|
|
- [ ] No unwrap() in production code
|
||
|
|
- [ ] 15+ tests per module
|
||
|
|
- [ ] cargo fmt applied
|
||
|
|
- [ ] cargo clippy passes
|
||
|
|
- [ ] cargo test --workspace passes
|
||
|
|
|
||
|
|
## Integration with Dashboard
|
||
|
|
|
||
|
|
To register your tool in the multi-tool dashboard:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use tools_tui_shared::integration::ToolSummaryProvider;
|
||
|
|
|
||
|
|
pub struct MyToolTui;
|
||
|
|
|
||
|
|
impl ToolSummaryProvider for MyToolTui {
|
||
|
|
fn name(&self) -> &str {
|
||
|
|
"my-tool"
|
||
|
|
}
|
||
|
|
|
||
|
|
fn summary(&self) -> Result<ToolSummary, String> {
|
||
|
|
Ok(ToolSummary::new(
|
||
|
|
"my-tool".to_string(),
|
||
|
|
"My Tool".to_string(),
|
||
|
|
"Description of my tool".to_string(),
|
||
|
|
ToolMetrics::new(10, 5, 3, chrono::Utc::now().to_rfc2822()),
|
||
|
|
).with_commands(vec!["list".to_string(), "create".to_string()]))
|
||
|
|
}
|
||
|
|
|
||
|
|
fn execute_command(&self, command: &str, _args: &[String]) -> Result<String, String> {
|
||
|
|
match command {
|
||
|
|
"list" => Ok("Listing items...".to_string()),
|
||
|
|
"create" => Ok("Creating item...".to_string()),
|
||
|
|
_ => Err(format!("Unknown command: {}", command)),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Common Issues & Solutions
|
||
|
|
|
||
|
|
### Issue: Compilation errors with ratatui widgets
|
||
|
|
|
||
|
|
**Solution**: Ensure you're using ratatui 0.30.0-beta.0 or later:
|
||
|
|
|
||
|
|
```toml
|
||
|
|
ratatui = { version = "0.30.0-beta.0", features = ["all-widgets", "underline-color"] }
|
||
|
|
```
|
||
|
|
|
||
|
|
### Issue: Terminal not being cleaned up properly
|
||
|
|
|
||
|
|
**Solution**: Use TerminalGuard:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use tools_tui_shared::terminal::TerminalGuard;
|
||
|
|
|
||
|
|
let _guard = TerminalGuard::setup()?;
|
||
|
|
let mut terminal = _guard.terminal()?;
|
||
|
|
// Terminal is auto-cleaned up when _guard is dropped
|
||
|
|
```
|
||
|
|
|
||
|
|
### Issue: UI not updating
|
||
|
|
|
||
|
|
**Solution**: Check dirty flag logic:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
// Always mark dirty when state changes
|
||
|
|
pub fn some_action(&mut self) {
|
||
|
|
self.items.push(new_item);
|
||
|
|
self.dirty = true; // Don't forget this!
|
||
|
|
}
|
||
|
|
|
||
|
|
// Always reset dirty after rendering
|
||
|
|
if app_state.is_dirty() {
|
||
|
|
terminal.draw(|f| render(f, &app_state))?;
|
||
|
|
app_state.reset_dirty(); // Don't forget this!
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Performance Tips
|
||
|
|
|
||
|
|
1. **Use dirty flag** to avoid unnecessary redraws
|
||
|
|
2. **Limit event polling** with reasonable tick rates (100-200ms)
|
||
|
|
3. **Cache theme** instead of creating new Theme each render
|
||
|
|
4. **Use Constraint::Percentage** for responsive layouts instead of hardcoded sizes
|
||
|
|
5. **Avoid cloning** large data structures in render methods
|
||
|
|
|
||
|
|
## References
|
||
|
|
|
||
|
|
- **RataTui Documentation**: https://ratatui.rs
|
||
|
|
- **Crossterm Documentation**: https://docs.rs/crossterm
|
||
|
|
- **Tools Shared Library**: `/Users/Akasha/Tools/shared/rust-tui/`
|
||
|
|
- **Example Implementation**: `/Users/Akasha/Tools/hello-tool/crates/hello-tui/`
|
||
|
|
|
||
|
|
## Getting Help
|
||
|
|
|
||
|
|
1. Check existing tool implementations in `/Users/Akasha/Tools/*/crates/*-tui/`
|
||
|
|
2. Review `tools-tui-shared` source code for patterns
|
||
|
|
3. Run tests to validate your implementation
|
||
|
|
4. Check compilation with `cargo check`
|
||
|
|
5. Lint with `cargo clippy --all-targets --all-features`
|