338 lines
9.5 KiB
Markdown
338 lines
9.5 KiB
Markdown
# Canvas 🎨
|
|
|
|
A reusable, type-safe canvas system for building form-based TUI applications with vim-like modal editing.
|
|
|
|
## ✨ Features
|
|
|
|
- **Type-Safe Actions**: No more string-based action names - everything is compile-time checked
|
|
- **Generic Design**: Implement `CanvasState` once, get navigation, editing, and suggestions for free
|
|
- **Vim-Like Experience**: Modal editing with familiar keybindings
|
|
- **Suggestion System**: Built-in suggestions dropdown support
|
|
- **Framework Agnostic**: Works with any TUI framework or raw terminal handling
|
|
- **Async Ready**: Full async/await support for modern Rust applications
|
|
- **Batch Operations**: Execute multiple actions atomically
|
|
- **Extensible**: Custom actions and feature-specific handling
|
|
|
|
## 🚀 Quick Start
|
|
|
|
Add to your `Cargo.toml`:
|
|
|
|
```toml
|
|
cargo add canvas
|
|
```
|
|
|
|
Implement the `CanvasState` trait:
|
|
|
|
```rust
|
|
use canvas::prelude::*;
|
|
|
|
#[derive(Debug)]
|
|
struct LoginForm {
|
|
current_field: usize,
|
|
cursor_pos: usize,
|
|
username: String,
|
|
password: String,
|
|
has_changes: bool,
|
|
}
|
|
|
|
impl CanvasState for LoginForm {
|
|
fn current_field(&self) -> usize { self.current_field }
|
|
fn current_cursor_pos(&self) -> usize { self.cursor_pos }
|
|
fn set_current_field(&mut self, index: usize) { self.current_field = index; }
|
|
fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; }
|
|
|
|
fn get_current_input(&self) -> &str {
|
|
match self.current_field {
|
|
0 => &self.username,
|
|
1 => &self.password,
|
|
_ => "",
|
|
}
|
|
}
|
|
|
|
fn get_current_input_mut(&mut self) -> &mut String {
|
|
match self.current_field {
|
|
0 => &mut self.username,
|
|
1 => &mut self.password,
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
|
|
fn inputs(&self) -> Vec<&String> { vec![&self.username, &self.password] }
|
|
fn fields(&self) -> Vec<&str> { vec!["Username", "Password"] }
|
|
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
|
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
|
|
}
|
|
```
|
|
|
|
Use the type-safe action dispatcher:
|
|
|
|
```rust
|
|
#[tokio::main]
|
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
let mut form = LoginForm::new();
|
|
let mut ideal_cursor = 0;
|
|
|
|
// Type a character - compile-time safe!
|
|
ActionDispatcher::dispatch(
|
|
CanvasAction::InsertChar('h'),
|
|
&mut form,
|
|
&mut ideal_cursor,
|
|
).await?;
|
|
|
|
// Move to next field
|
|
ActionDispatcher::dispatch(
|
|
CanvasAction::NextField,
|
|
&mut form,
|
|
&mut ideal_cursor,
|
|
).await?;
|
|
|
|
// Batch operations
|
|
let actions = vec![
|
|
CanvasAction::InsertChar('p'),
|
|
CanvasAction::InsertChar('a'),
|
|
CanvasAction::InsertChar('s'),
|
|
CanvasAction::InsertChar('s'),
|
|
];
|
|
|
|
ActionDispatcher::dispatch_batch(actions, &mut form, &mut ideal_cursor).await?;
|
|
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
## 🎯 Type-Safe Actions
|
|
|
|
The Canvas system uses strongly-typed actions instead of error-prone strings:
|
|
|
|
```rust
|
|
// ✅ Type-safe - impossible to make typos
|
|
ActionDispatcher::dispatch(CanvasAction::MoveLeft, &mut form, &mut cursor).await?;
|
|
|
|
// ❌ Old way - runtime errors waiting to happen
|
|
execute_edit_action("move_left", key, &mut form, &mut cursor).await?;
|
|
execute_edit_action("move_leftt", key, &mut form, &mut cursor).await?; // Oops!
|
|
```
|
|
|
|
### Available Actions
|
|
|
|
```rust
|
|
pub enum CanvasAction {
|
|
// Character input
|
|
InsertChar(char),
|
|
|
|
// Deletion
|
|
DeleteBackward,
|
|
DeleteForward,
|
|
|
|
// Movement
|
|
MoveLeft, MoveRight, MoveUp, MoveDown,
|
|
MoveLineStart, MoveLineEnd,
|
|
MoveWordNext, MoveWordPrev,
|
|
|
|
// Navigation
|
|
NextField, PrevField,
|
|
MoveFirstLine, MoveLastLine,
|
|
|
|
// Suggestions
|
|
SuggestionUp, SuggestionDown,
|
|
SelectSuggestion, ExitSuggestions,
|
|
|
|
// Extensibility
|
|
Custom(String),
|
|
}
|
|
```
|
|
|
|
## 🔧 Advanced Features
|
|
|
|
### Suggestions Dropdown (not inline autocomplete)
|
|
|
|
```rust
|
|
impl CanvasState for MyForm {
|
|
fn get_suggestions(&self) -> Option<&[String]> {
|
|
if self.suggestions.is_active {
|
|
Some(&self.suggestions.suggestions)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
|
match action {
|
|
CanvasAction::InsertChar('@') => {
|
|
// Trigger email suggestions
|
|
let suggestions = vec![
|
|
format!("{}@gmail.com", self.username),
|
|
format!("{}@company.com", self.username),
|
|
];
|
|
self.activate_suggestions(suggestions);
|
|
None // Let generic handler insert the '@'
|
|
}
|
|
CanvasAction::SelectSuggestion => {
|
|
if let Some(suggestion) = self.suggestions.get_selected() {
|
|
*self.get_current_input_mut() = suggestion.clone();
|
|
self.deactivate_suggestions();
|
|
Some("Applied suggestion".to_string())
|
|
}
|
|
None
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Custom Actions
|
|
|
|
```rust
|
|
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
|
match action {
|
|
CanvasAction::Custom(cmd) => match cmd.as_str() {
|
|
"uppercase" => {
|
|
*self.get_current_input_mut() = self.get_current_input().to_uppercase();
|
|
Some("Converted to uppercase".to_string())
|
|
}
|
|
"validate_email" => {
|
|
if self.get_current_input().contains('@') {
|
|
Some("Email is valid".to_string())
|
|
} else {
|
|
Some("Invalid email format".to_string())
|
|
}
|
|
}
|
|
_ => None,
|
|
},
|
|
_ => None,
|
|
}
|
|
}
|
|
```
|
|
|
|
### Integration with TUI Frameworks
|
|
|
|
Canvas is framework-agnostic and works with any TUI library:
|
|
|
|
```rust
|
|
// Works with crossterm (see examples)
|
|
// Works with termion
|
|
// Works with ratatui/tui-rs
|
|
// Works with cursive
|
|
// Works with raw terminal I/O
|
|
```
|
|
|
|
## 🏗️ Architecture
|
|
|
|
Canvas follows a clean, layered architecture:
|
|
|
|
```
|
|
┌─────────────────────────────────────┐
|
|
│ Your Application │
|
|
├─────────────────────────────────────┤
|
|
│ ActionDispatcher │ ← High-level API
|
|
├─────────────────────────────────────┤
|
|
│ CanvasAction (Type-Safe) │ ← Type safety layer
|
|
├─────────────────────────────────────┤
|
|
│ Action Handlers │ ← Core logic
|
|
├─────────────────────────────────────┤
|
|
│ CanvasState Trait │ ← Your implementation
|
|
└─────────────────────────────────────┘
|
|
```
|
|
|
|
## 🤝 Why Canvas?
|
|
|
|
### Before Canvas
|
|
```rust
|
|
// ❌ Error-prone string actions
|
|
execute_action("move_left", key, state)?;
|
|
execute_action("move_leftt", key, state)?; // Runtime error!
|
|
|
|
// ❌ Duplicate navigation logic everywhere
|
|
impl MyLoginForm { /* navigation code */ }
|
|
impl MyConfigForm { /* same navigation code */ }
|
|
impl MyDataForm { /* same navigation code again */ }
|
|
|
|
// ❌ Manual cursor and field management
|
|
if key == Key::Tab {
|
|
current_field = (current_field + 1) % fields.len();
|
|
cursor_pos = cursor_pos.min(current_input.len());
|
|
}
|
|
```
|
|
|
|
### With Canvas
|
|
```rust
|
|
// ✅ Type-safe actions
|
|
ActionDispatcher::dispatch(CanvasAction::MoveLeft, state, cursor)?;
|
|
// Typos are impossible - won't compile!
|
|
|
|
// ✅ Implement once, use everywhere
|
|
impl CanvasState for MyForm { /* minimal implementation */ }
|
|
// All navigation, editing, suggestions work automatically!
|
|
|
|
// ✅ High-level operations
|
|
ActionDispatcher::dispatch_batch(actions, state, cursor)?;
|
|
```
|
|
|
|
## 📖 Documentation
|
|
|
|
- **API Docs**: `cargo doc --open`
|
|
- **Examples**: See `examples/` directory
|
|
- **Migration Guide**: See `CANVAS_MIGRATION.md`
|
|
|
|
## 🔄 Migration from String-Based Actions
|
|
|
|
Canvas provides backwards compatibility during migration:
|
|
|
|
```rust
|
|
// Legacy support (deprecated)
|
|
execute_edit_action("move_left", key, state, cursor).await?;
|
|
|
|
// New type-safe way
|
|
ActionDispatcher::dispatch(CanvasAction::MoveLeft, state, cursor).await?;
|
|
```
|
|
|
|
## 🧪 Testing
|
|
|
|
```bash
|
|
# Run all tests
|
|
cargo test
|
|
|
|
# Run specific example
|
|
cargo run --example simple_login
|
|
|
|
# Check type safety
|
|
cargo check
|
|
```
|
|
|
|
## 📋 Requirements
|
|
|
|
- Rust 1.70+
|
|
- Terminal with cursor support
|
|
- Optional: async runtime (tokio) for examples
|
|
|
|
## 🤔 FAQ
|
|
|
|
**Q: Does Canvas work with [my TUI framework]?**
|
|
A: Yes! Canvas is framework-agnostic. Just implement `CanvasState` and handle the key events.
|
|
|
|
**Q: Can I extend Canvas with custom actions?**
|
|
A: Absolutely! Use `CanvasAction::Custom("my_action")` or implement `handle_feature_action`.
|
|
|
|
**Q: Is Canvas suitable for complex forms?**
|
|
A: Yes! See the `config_screen` example for validation, suggestions, and multi-field forms.
|
|
|
|
**Q: How do I migrate from string-based actions?**
|
|
A: Canvas provides backwards compatibility. Migrate incrementally using the type-safe APIs.
|
|
|
|
## 📄 License
|
|
|
|
Licensed under either of:
|
|
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
|
|
- MIT License ([LICENSE-MIT](LICENSE-MIT))
|
|
|
|
at your option.
|
|
|
|
## 🙏 Contributing
|
|
|
|
Will write here something later on, too busy rn
|
|
|
|
---
|
|
|
|
Built with ❤️ for the Rust TUI community
|