Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d5bc1296e | ||
|
|
969ad229e4 | ||
|
|
0d291fcf57 | ||
|
|
d711f4c491 | ||
|
|
9369626e21 | ||
|
|
f84bb0dc9e | ||
|
|
20b428264e | ||
|
|
05bb84fc98 | ||
|
|
46a85e4b4a | ||
|
|
b4d1572c79 | ||
|
|
b8e1b77222 | ||
|
|
1a451a576f | ||
|
|
074b2914d8 | ||
|
|
aec5f80879 |
11
Cargo.lock
generated
11
Cargo.lock
generated
@@ -472,7 +472,7 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
||||
|
||||
[[package]]
|
||||
name = "canvas"
|
||||
version = "0.4.1"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"common",
|
||||
@@ -482,6 +482,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
"toml",
|
||||
"unicode-width 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -555,7 +556,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "client"
|
||||
version = "0.4.1"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -606,7 +607,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common"
|
||||
version = "0.4.1"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"prost",
|
||||
"prost-types",
|
||||
@@ -2892,7 +2893,7 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
||||
|
||||
[[package]]
|
||||
name = "search"
|
||||
version = "0.4.1"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"common",
|
||||
@@ -2991,7 +2992,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "server"
|
||||
version = "0.4.1"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bcrypt",
|
||||
|
||||
@@ -5,7 +5,7 @@ resolver = "2"
|
||||
[workspace.package]
|
||||
# TODO: idk how to do the name, fix later
|
||||
# name = "komp_ac"
|
||||
version = "0.4.1"
|
||||
version = "0.4.2"
|
||||
edition = "2021"
|
||||
license = "GPL-3.0-or-later"
|
||||
authors = ["Filip Priečinský <filippriec@gmail.com>"]
|
||||
@@ -50,5 +50,6 @@ regex = "1.11.1"
|
||||
ratatui = { version = "0.29.0", features = ["crossterm"] }
|
||||
crossterm = "0.28.1"
|
||||
toml = "0.8.20"
|
||||
unicode-width = "0.2.0"
|
||||
|
||||
common = { path = "./common" }
|
||||
|
||||
@@ -1,160 +1,334 @@
|
||||
# Canvas Crate Migration Documentation
|
||||
# Canvas Library Migration Guide
|
||||
|
||||
## Files Moved from Client to Canvas
|
||||
## Overview
|
||||
|
||||
### Core Canvas Files
|
||||
This guide covers the migration from the legacy canvas library structure to the new clean, modular architecture. The new design separates core canvas functionality from autocomplete features, providing better type safety and maintainability.
|
||||
|
||||
| **Canvas Location** | **Original Client Location** | **Purpose** |
|
||||
|-------------------|---------------------------|-----------|
|
||||
| `canvas/src/state.rs` | `client/src/state/pages/canvas_state.rs` | Core CanvasState trait |
|
||||
| `canvas/src/actions/edit.rs` | `client/src/functions/modes/edit/form_e.rs` | Generic edit actions |
|
||||
| `canvas/src/renderer.rs` | `client/src/components/handlers/canvas.rs` | Canvas rendering logic |
|
||||
| `canvas/src/modes/highlight.rs` | `client/src/state/app/highlight.rs` | Highlight state types |
|
||||
| `canvas/src/modes/manager.rs` | `client/src/modes/handlers/mode_manager.rs` | Mode management |
|
||||
## Key Changes
|
||||
|
||||
## Import Replacements Needed in Client
|
||||
### 1. **Modular Architecture**
|
||||
```
|
||||
# Old Structure (LEGACY)
|
||||
src/
|
||||
├── state.rs # Mixed canvas + autocomplete
|
||||
├── actions/edit.rs # Mixed concerns
|
||||
├── gui/render.rs # Everything together
|
||||
└── suggestions.rs # Legacy file
|
||||
|
||||
# New Structure (CLEAN)
|
||||
src/
|
||||
├── canvas/ # Core canvas functionality
|
||||
│ ├── state.rs # CanvasState trait only
|
||||
│ ├── actions/edit.rs # Canvas actions only
|
||||
│ └── gui.rs # Canvas rendering
|
||||
├── autocomplete/ # Rich autocomplete features
|
||||
│ ├── state.rs # AutocompleteCanvasState trait
|
||||
│ ├── types.rs # SuggestionItem, AutocompleteState
|
||||
│ ├── actions.rs # Autocomplete actions
|
||||
│ └── gui.rs # Autocomplete dropdown rendering
|
||||
└── dispatcher.rs # Action routing
|
||||
```
|
||||
|
||||
### 2. **Trait Separation**
|
||||
- **CanvasState**: Core form functionality (navigation, input, validation)
|
||||
- **AutocompleteCanvasState**: Optional rich autocomplete features
|
||||
|
||||
### 3. **Rich Suggestions**
|
||||
Replaced simple string suggestions with typed, rich suggestion objects.
|
||||
|
||||
## Migration Steps
|
||||
|
||||
### Step 1: Update Import Paths
|
||||
|
||||
**Find and Replace these imports:**
|
||||
|
||||
### 1. CanvasState Trait Usage
|
||||
**Replace these imports:**
|
||||
```rust
|
||||
// OLD
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
|
||||
// NEW
|
||||
# OLD IMPORTS
|
||||
use canvas::CanvasState;
|
||||
```
|
||||
|
||||
**Files that need updating:**
|
||||
- `src/modes/canvas/edit.rs` - Line 9
|
||||
- `src/modes/canvas/read_only.rs` - Line 5
|
||||
- `src/ui/handlers/render.rs` - Line 17
|
||||
- `src/state/pages/auth.rs` - All CanvasState impls
|
||||
- `src/state/pages/form.rs` - CanvasState impl
|
||||
- `src/state/pages/add_table.rs` - CanvasState impl
|
||||
- `src/state/pages/add_logic.rs` - CanvasState impl
|
||||
|
||||
### 2. Edit Actions Usage
|
||||
**Replace these imports:**
|
||||
```rust
|
||||
// OLD
|
||||
use crate::functions::modes::edit::form_e::{execute_edit_action, execute_common_action};
|
||||
|
||||
// NEW
|
||||
use canvas::{execute_edit_action, execute_common_action};
|
||||
```
|
||||
|
||||
**Files that need updating:**
|
||||
- `src/modes/canvas/edit.rs` - Lines 3-5
|
||||
- `src/functions/modes/edit/auth_e.rs`
|
||||
- `src/functions/modes/edit/add_table_e.rs`
|
||||
- `src/functions/modes/edit/add_logic_e.rs`
|
||||
|
||||
### 3. Canvas Rendering Usage
|
||||
**Replace these imports:**
|
||||
```rust
|
||||
// OLD
|
||||
use crate::components::handlers::canvas::render_canvas;
|
||||
|
||||
// NEW
|
||||
use canvas::render_canvas;
|
||||
```
|
||||
|
||||
**Files that need updating:**
|
||||
- Any component that renders forms (login, register, add_table, add_logic, forms)
|
||||
|
||||
### 4. Mode System Usage
|
||||
**Replace these imports:**
|
||||
```rust
|
||||
// OLD
|
||||
use crate::modes::handlers::mode_manager::{AppMode, ModeManager};
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
|
||||
// NEW
|
||||
use canvas::{AppMode, ModeManager, HighlightState};
|
||||
```
|
||||
|
||||
**Files that need updating:**
|
||||
- `src/modes/handlers/event.rs` - Line 14
|
||||
- `src/ui/handlers/ui.rs` - Mode derivation calls
|
||||
- All mode handling files
|
||||
|
||||
## Theme Integration Required
|
||||
|
||||
The canvas crate expects a `CanvasTheme` trait. You need to implement this for your existing theme:
|
||||
|
||||
```rust
|
||||
// In client/src/config/colors/themes.rs
|
||||
use canvas::CanvasAction;
|
||||
use canvas::ActionContext;
|
||||
use canvas::HighlightState;
|
||||
use canvas::CanvasTheme;
|
||||
use ratatui::style::Color;
|
||||
use canvas::ActionDispatcher;
|
||||
use canvas::ActionResult;
|
||||
|
||||
impl CanvasTheme for Theme {
|
||||
fn primary_fg(&self) -> Color { self.fg }
|
||||
fn primary_bg(&self) -> Color { self.bg }
|
||||
fn accent(&self) -> Color { self.accent }
|
||||
fn warning(&self) -> Color { self.warning }
|
||||
fn secondary(&self) -> Color { self.secondary }
|
||||
fn highlight(&self) -> Color { self.highlight }
|
||||
fn highlight_bg(&self) -> Color { self.highlight_bg }
|
||||
# NEW IMPORTS
|
||||
use canvas::canvas::CanvasState;
|
||||
use canvas::canvas::CanvasAction;
|
||||
use canvas::canvas::ActionContext;
|
||||
use canvas::canvas::HighlightState;
|
||||
use canvas::canvas::CanvasTheme;
|
||||
use canvas::dispatcher::ActionDispatcher;
|
||||
use canvas::canvas::ActionResult;
|
||||
```
|
||||
|
||||
**Complex imports:**
|
||||
```rust
|
||||
# OLD
|
||||
use canvas::{CanvasAction, ActionDispatcher, ActionResult};
|
||||
|
||||
# NEW
|
||||
use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher, canvas::ActionResult};
|
||||
```
|
||||
|
||||
### Step 2: Clean Up State Implementation
|
||||
|
||||
**Remove legacy methods from your CanvasState implementation:**
|
||||
|
||||
```rust
|
||||
impl CanvasState for YourFormState {
|
||||
// Keep all the core methods:
|
||||
fn current_field(&self) -> usize { /* ... */ }
|
||||
fn get_current_input(&self) -> &str { /* ... */ }
|
||||
// ... etc
|
||||
|
||||
// ❌ REMOVE these legacy methods:
|
||||
// fn get_suggestions(&self) -> Option<&[String]>
|
||||
// fn get_selected_suggestion_index(&self) -> Option<usize>
|
||||
// fn set_selected_suggestion_index(&mut self, index: Option<usize>)
|
||||
// fn activate_suggestions(&mut self, suggestions: Vec<String>)
|
||||
// fn deactivate_suggestions(&mut self)
|
||||
}
|
||||
```
|
||||
|
||||
## Systematic Replacement Strategy
|
||||
### Step 3: Implement Rich Autocomplete (Optional)
|
||||
|
||||
### Phase 1: Fix Compilation (Do This First)
|
||||
1. Update `client/Cargo.toml` to depend on canvas
|
||||
2. Add theme implementation
|
||||
3. Replace imports in core files
|
||||
**If you want rich autocomplete features:**
|
||||
|
||||
### Phase 2: Replace Feature-Specific Usage
|
||||
1. Update auth components
|
||||
2. Update form components
|
||||
3. Update admin components
|
||||
4. Update mode handlers
|
||||
```rust
|
||||
use canvas::autocomplete::{AutocompleteCanvasState, SuggestionItem, AutocompleteState};
|
||||
|
||||
### Phase 3: Remove Old Files (After Everything Works)
|
||||
1. Delete `src/state/pages/canvas_state.rs`
|
||||
2. Delete `src/functions/modes/edit/form_e.rs`
|
||||
3. Delete `src/components/handlers/canvas.rs`
|
||||
4. Delete `src/state/app/highlight.rs`
|
||||
5. Delete `src/modes/handlers/mode_manager.rs`
|
||||
impl AutocompleteCanvasState for YourFormState {
|
||||
type SuggestionData = YourDataType; // e.g., Hit, CustomRecord, etc.
|
||||
|
||||
## Files Safe to Delete After Migration
|
||||
fn supports_autocomplete(&self, field_index: usize) -> bool {
|
||||
// Define which fields support autocomplete
|
||||
matches!(field_index, 2 | 3 | 5) // Example: only certain fields
|
||||
}
|
||||
|
||||
**These can be removed once imports are updated:**
|
||||
- `client/src/state/pages/canvas_state.rs`
|
||||
- `client/src/functions/modes/edit/form_e.rs`
|
||||
- `client/src/components/handlers/canvas.rs`
|
||||
- `client/src/state/app/highlight.rs`
|
||||
- `client/src/modes/handlers/mode_manager.rs`
|
||||
fn autocomplete_state(&self) -> Option<&AutocompleteState<Self::SuggestionData>> {
|
||||
Some(&self.autocomplete)
|
||||
}
|
||||
|
||||
## Quick Start Commands
|
||||
|
||||
```bash
|
||||
# 1. Add canvas dependency to client
|
||||
cd client
|
||||
echo 'canvas = { path = "../canvas" }' >> Cargo.toml
|
||||
|
||||
# 2. Test compilation
|
||||
cargo check
|
||||
|
||||
# 3. Fix imports one file at a time
|
||||
# Start with: src/config/colors/themes.rs (add CanvasTheme impl)
|
||||
# Then: src/modes/canvas/edit.rs (replace form_e imports)
|
||||
# Then: src/modes/canvas/read_only.rs (replace canvas_state import)
|
||||
|
||||
# 4. After all imports fixed, delete old files
|
||||
rm src/state/pages/canvas_state.rs
|
||||
rm src/functions/modes/edit/form_e.rs
|
||||
rm src/components/handlers/canvas.rs
|
||||
rm src/state/app/highlight.rs
|
||||
rm src/modes/handlers/mode_manager.rs
|
||||
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> {
|
||||
Some(&mut self.autocomplete)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Expected Compilation Errors
|
||||
**Add autocomplete field to your state:**
|
||||
```rust
|
||||
pub struct YourFormState {
|
||||
// ... existing fields
|
||||
pub autocomplete: AutocompleteState<YourDataType>,
|
||||
}
|
||||
```
|
||||
|
||||
You'll get errors like:
|
||||
- `cannot find type 'CanvasState' in this scope`
|
||||
- `cannot find function 'execute_edit_action' in this scope`
|
||||
- `cannot find type 'AppMode' in this scope`
|
||||
### Step 4: Migrate Suggestions
|
||||
|
||||
Fix these by replacing the imports as documented above.
|
||||
**Old way (simple strings):**
|
||||
```rust
|
||||
let suggestions = vec!["John".to_string(), "Jane".to_string()];
|
||||
form_state.activate_suggestions(suggestions);
|
||||
```
|
||||
|
||||
**New way (rich objects):**
|
||||
```rust
|
||||
let suggestions = vec![
|
||||
SuggestionItem::new(
|
||||
hit1, // Your data object
|
||||
"John Doe (Manager) | ID: 123".to_string(), // What user sees
|
||||
"123".to_string(), // What gets stored
|
||||
),
|
||||
SuggestionItem::simple(hit2, "Jane".to_string()), // Simple version
|
||||
];
|
||||
form_state.set_autocomplete_suggestions(suggestions);
|
||||
```
|
||||
|
||||
### Step 5: Update Rendering
|
||||
|
||||
**Old rendering:**
|
||||
```rust
|
||||
// Manual autocomplete rendering
|
||||
if form_state.autocomplete_active {
|
||||
render_autocomplete_dropdown(/* ... */);
|
||||
}
|
||||
```
|
||||
|
||||
**New rendering:**
|
||||
```rust
|
||||
// Canvas handles everything
|
||||
use canvas::canvas::render_canvas;
|
||||
|
||||
let active_field_rect = render_canvas(f, area, form_state, theme, edit_mode, highlight_state);
|
||||
|
||||
// Optional: Rich autocomplete (if implementing AutocompleteCanvasState)
|
||||
if form_state.is_autocomplete_active() {
|
||||
if let Some(autocomplete_state) = form_state.autocomplete_state() {
|
||||
canvas::autocomplete::render_autocomplete_dropdown(
|
||||
f, f.area(), active_field_rect.unwrap(), theme, autocomplete_state
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Update Method Calls
|
||||
|
||||
**Replace legacy method calls:**
|
||||
```rust
|
||||
# OLD
|
||||
form_state.deactivate_suggestions();
|
||||
|
||||
# NEW - Option A: Add your own method
|
||||
impl YourFormState {
|
||||
pub fn deactivate_autocomplete(&mut self) {
|
||||
self.autocomplete_active = false;
|
||||
self.autocomplete_suggestions.clear();
|
||||
self.selected_suggestion_index = None;
|
||||
}
|
||||
}
|
||||
form_state.deactivate_autocomplete();
|
||||
|
||||
# NEW - Option B: Use rich autocomplete trait
|
||||
form_state.deactivate_autocomplete(); // If implementing AutocompleteCanvasState
|
||||
```
|
||||
|
||||
## Benefits of New Architecture
|
||||
|
||||
### 1. **Clean Separation of Concerns**
|
||||
- Canvas: Form rendering, navigation, input handling
|
||||
- Autocomplete: Rich suggestions, dropdown management, async loading
|
||||
|
||||
### 2. **Type Safety**
|
||||
```rust
|
||||
// Old: Stringly typed
|
||||
let suggestions: Vec<String> = vec!["user1".to_string()];
|
||||
|
||||
// New: Fully typed with your domain objects
|
||||
let suggestions: Vec<SuggestionItem<UserRecord>> = vec![
|
||||
SuggestionItem::new(user_record, display_text, stored_value)
|
||||
];
|
||||
```
|
||||
|
||||
### 3. **Rich UX Capabilities**
|
||||
- **Display vs Storage**: Show "John Doe (Manager)" but store user ID
|
||||
- **Loading States**: Built-in spinner/loading indicators
|
||||
- **Async Support**: Designed for async suggestion fetching
|
||||
- **Display Overrides**: Show friendly text while storing normalized data
|
||||
|
||||
### 4. **Future-Proof**
|
||||
- Easy to add new autocomplete features
|
||||
- Canvas features don't interfere with autocomplete
|
||||
- Modular: Use only what you need
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Display Overrides
|
||||
Perfect for foreign key relationships:
|
||||
|
||||
```rust
|
||||
// User selects "John Doe (Manager) | ID: 123"
|
||||
// Field stores: "123" (for database)
|
||||
// User sees: "John Doe" (friendly display)
|
||||
|
||||
impl CanvasState for FormState {
|
||||
fn get_display_value_for_field(&self, index: usize) -> &str {
|
||||
if let Some(display_text) = self.link_display_map.get(&index) {
|
||||
return display_text.as_str(); // Shows "John Doe"
|
||||
}
|
||||
self.inputs().get(index).map(|s| s.as_str()).unwrap_or("") // Shows "123"
|
||||
}
|
||||
|
||||
fn has_display_override(&self, index: usize) -> bool {
|
||||
self.link_display_map.contains_key(&index)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Progressive Enhancement
|
||||
Start simple, add features when needed:
|
||||
|
||||
```rust
|
||||
// Week 1: Basic usage
|
||||
SuggestionItem::simple(data, "John".to_string());
|
||||
|
||||
// Week 5: Rich display
|
||||
SuggestionItem::new(data, "John Doe (Manager)".to_string(), "John".to_string());
|
||||
|
||||
// Week 10: Store IDs, show names
|
||||
SuggestionItem::new(user, "John Doe (Manager)".to_string(), "123".to_string());
|
||||
```
|
||||
|
||||
## Breaking Changes Summary
|
||||
|
||||
1. **Import paths changed**: Add `canvas::` or `dispatcher::` prefixes
|
||||
2. **Legacy suggestion methods removed**: Replace with rich autocomplete or custom methods
|
||||
3. **No more simple suggestions**: Use `SuggestionItem` for typed suggestions
|
||||
4. **Trait split**: `AutocompleteCanvasState` is now separate and optional
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Compilation Errors
|
||||
|
||||
**Error**: `no method named 'get_suggestions' found`
|
||||
**Fix**: Remove legacy method from `CanvasState` implementation
|
||||
|
||||
**Error**: `no 'CanvasState' in the root`
|
||||
**Fix**: Change `use canvas::CanvasState` to `use canvas::canvas::CanvasState`
|
||||
|
||||
**Error**: `trait bound 'FormState: CanvasState' is not satisfied`
|
||||
**Fix**: Make sure your state properly implements the new `CanvasState` trait
|
||||
|
||||
### Migration Checklist
|
||||
|
||||
- [ ] Updated all import paths
|
||||
- [ ] Removed legacy methods from CanvasState implementation
|
||||
- [ ] Added custom autocomplete methods if needed
|
||||
- [ ] Updated suggestion usage to SuggestionItem
|
||||
- [ ] Updated rendering calls
|
||||
- [ ] Tested form functionality
|
||||
- [ ] Tested autocomplete functionality (if using)
|
||||
|
||||
## Example: Complete Migration
|
||||
|
||||
**Before:**
|
||||
```rust
|
||||
use canvas::{CanvasState, CanvasAction};
|
||||
|
||||
impl CanvasState for FormState {
|
||||
fn get_suggestions(&self) -> Option<&[String]> { /* ... */ }
|
||||
fn deactivate_suggestions(&mut self) { /* ... */ }
|
||||
// ... other methods
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```rust
|
||||
use canvas::canvas::{CanvasState, CanvasAction};
|
||||
use canvas::autocomplete::{AutocompleteCanvasState, SuggestionItem};
|
||||
|
||||
impl CanvasState for FormState {
|
||||
// Only core canvas methods, no suggestion methods
|
||||
fn current_field(&self) -> usize { /* ... */ }
|
||||
fn get_current_input(&self) -> &str { /* ... */ }
|
||||
// ... other core methods only
|
||||
}
|
||||
|
||||
impl AutocompleteCanvasState for FormState {
|
||||
type SuggestionData = Hit;
|
||||
|
||||
fn supports_autocomplete(&self, field_index: usize) -> bool {
|
||||
self.fields[field_index].is_link
|
||||
}
|
||||
|
||||
fn autocomplete_state(&self) -> Option<&AutocompleteState<Self::SuggestionData>> {
|
||||
Some(&self.autocomplete)
|
||||
}
|
||||
|
||||
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> {
|
||||
Some(&mut self.autocomplete)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This migration results in cleaner, more maintainable, and more powerful code!
|
||||
|
||||
@@ -11,28 +11,17 @@ categories.workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
ratatui = { workspace = true }
|
||||
ratatui = { workspace = true, optional = true }
|
||||
crossterm = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
unicode-width.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4.4"
|
||||
|
||||
[[example]]
|
||||
name = "simple_login"
|
||||
path = "examples/simple_login.rs"
|
||||
|
||||
[[example]]
|
||||
name = "config_screen"
|
||||
path = "examples/config_screen.rs"
|
||||
|
||||
[[example]]
|
||||
name = "basic_usage"
|
||||
path = "examples/basic_usage.rs"
|
||||
|
||||
[[example]]
|
||||
name = "integration_patterns"
|
||||
path = "examples/integration_patterns.rs"
|
||||
[features]
|
||||
default = []
|
||||
gui = ["ratatui"]
|
||||
|
||||
@@ -100,35 +100,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
```
|
||||
|
||||
## 📚 Examples
|
||||
|
||||
The `examples/` directory contains comprehensive examples showing different usage patterns:
|
||||
|
||||
### Run the Examples
|
||||
|
||||
```bash
|
||||
# Basic login form with TUI
|
||||
cargo run --example simple_login
|
||||
|
||||
# Advanced configuration screen with suggestions and validation
|
||||
cargo run --example config_screen
|
||||
|
||||
# API usage patterns and quick start guide
|
||||
cargo run --example basic_usage
|
||||
|
||||
# Advanced integration patterns (state machines, events, validation)
|
||||
cargo run --example integration_patterns
|
||||
```
|
||||
|
||||
### Example Overview
|
||||
|
||||
| Example | Description | Key Features |
|
||||
|---------|-------------|--------------|
|
||||
| `simple_login` | Interactive login form TUI | Basic form, custom actions, password masking |
|
||||
| `config_screen` | Configuration editor | Auto-suggestions, field validation, complex UI |
|
||||
| `basic_usage` | API demonstration | All core patterns, non-interactive |
|
||||
| `integration_patterns` | Architecture patterns | State machines, events, validation pipelines |
|
||||
|
||||
## 🎯 Type-Safe Actions
|
||||
|
||||
The Canvas system uses strongly-typed actions instead of error-prone strings:
|
||||
@@ -199,7 +170,7 @@ impl CanvasState for MyForm {
|
||||
CanvasAction::SelectSuggestion => {
|
||||
if let Some(suggestion) = self.suggestions.get_selected() {
|
||||
*self.get_current_input_mut() = suggestion.clone();
|
||||
self.deactivate_suggestions();
|
||||
self.deactivate_autocomplete();
|
||||
Some("Applied suggestion".to_string())
|
||||
}
|
||||
None
|
||||
|
||||
@@ -24,7 +24,7 @@ move_word_end_prev = ["ge"]
|
||||
move_line_start = ["0"]
|
||||
move_line_end = ["$"]
|
||||
move_first_line = ["gg"]
|
||||
move_last_line = ["G"]
|
||||
move_last_line = ["shift+g"]
|
||||
next_field = ["Tab"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
|
||||
|
||||
@@ -1,378 +0,0 @@
|
||||
// examples/basic_usage.rs
|
||||
//! Basic usage patterns and quick start guide
|
||||
//!
|
||||
//! This example demonstrates the core patterns for using the canvas crate:
|
||||
//! 1. Implementing CanvasState
|
||||
//! 2. Using the ActionDispatcher
|
||||
//! 3. Handling different types of actions
|
||||
//! 4. Working with suggestions
|
||||
//!
|
||||
//! Run with: cargo run --example basic_usage
|
||||
|
||||
use canvas::prelude::*;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
println!("🎨 Canvas Crate - Basic Usage Patterns");
|
||||
println!("=====================================\n");
|
||||
|
||||
// Example 1: Minimal form implementation
|
||||
example_1_minimal_form();
|
||||
|
||||
// Example 2: Form with suggestions
|
||||
example_2_with_suggestions();
|
||||
|
||||
// Example 3: Custom actions
|
||||
example_3_custom_actions().await;
|
||||
|
||||
// Example 4: Batch operations
|
||||
example_4_batch_operations().await;
|
||||
}
|
||||
|
||||
// Example 1: Minimal form - just the required methods
|
||||
fn example_1_minimal_form() {
|
||||
println!("📝 Example 1: Minimal Form Implementation");
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SimpleForm {
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
name: String,
|
||||
email: String,
|
||||
has_changes: bool,
|
||||
}
|
||||
|
||||
impl SimpleForm {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
current_field: 0,
|
||||
cursor_pos: 0,
|
||||
name: String::new(),
|
||||
email: String::new(),
|
||||
has_changes: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasState for SimpleForm {
|
||||
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.min(1); }
|
||||
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.name,
|
||||
1 => &self.email,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_input_mut(&mut self) -> &mut String {
|
||||
match self.current_field {
|
||||
0 => &mut self.name,
|
||||
1 => &mut self.email,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> { vec![&self.name, &self.email] }
|
||||
fn fields(&self) -> Vec<&str> { vec!["Name", "Email"] }
|
||||
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
|
||||
}
|
||||
|
||||
let form = SimpleForm::new();
|
||||
println!(" Created form with {} fields", form.fields().len());
|
||||
println!(" Current field: {}", form.fields()[form.current_field()]);
|
||||
println!(" ✅ Minimal implementation works!\n");
|
||||
}
|
||||
|
||||
// Example 2: Form with suggestion support
|
||||
fn example_2_with_suggestions() {
|
||||
println!("💡 Example 2: Form with Suggestions");
|
||||
|
||||
#[derive(Debug)]
|
||||
struct FormWithSuggestions {
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
country: String,
|
||||
has_changes: bool,
|
||||
suggestions: SuggestionState,
|
||||
}
|
||||
|
||||
impl FormWithSuggestions {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
current_field: 0,
|
||||
cursor_pos: 0,
|
||||
country: String::new(),
|
||||
has_changes: false,
|
||||
suggestions: SuggestionState::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasState for FormWithSuggestions {
|
||||
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 { &self.country }
|
||||
fn get_current_input_mut(&mut self) -> &mut String { &mut self.country }
|
||||
fn inputs(&self) -> Vec<&String> { vec![&self.country] }
|
||||
fn fields(&self) -> Vec<&str> { vec!["Country"] }
|
||||
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
|
||||
|
||||
// Suggestion support
|
||||
fn get_suggestions(&self) -> Option<&[String]> {
|
||||
if self.suggestions.is_active {
|
||||
Some(&self.suggestions.suggestions)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn get_selected_suggestion_index(&self) -> Option<usize> {
|
||||
self.suggestions.selected_index
|
||||
}
|
||||
|
||||
fn set_selected_suggestion_index(&mut self, index: Option<usize>) {
|
||||
self.suggestions.selected_index = index;
|
||||
}
|
||||
|
||||
fn activate_suggestions(&mut self, suggestions: Vec<String>) {
|
||||
self.suggestions.activate_with_suggestions(suggestions);
|
||||
}
|
||||
|
||||
fn deactivate_suggestions(&mut self) {
|
||||
self.suggestions.deactivate();
|
||||
}
|
||||
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::SelectSuggestion => {
|
||||
// Fix: Clone the suggestion first to avoid borrow checker issues
|
||||
if let Some(suggestion) = self.suggestions.get_selected().cloned() {
|
||||
self.country = suggestion.clone();
|
||||
self.cursor_pos = suggestion.len();
|
||||
self.deactivate_suggestions();
|
||||
self.has_changes = true;
|
||||
return Some(format!("Selected: {}", suggestion));
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut form = FormWithSuggestions::new();
|
||||
|
||||
// Simulate user typing and triggering suggestions
|
||||
form.activate_suggestions(vec![
|
||||
"United States".to_string(),
|
||||
"United Kingdom".to_string(),
|
||||
"Ukraine".to_string(),
|
||||
]);
|
||||
|
||||
println!(" Activated suggestions: {:?}", form.get_suggestions().unwrap());
|
||||
println!(" Current selection: {:?}", form.get_selected_suggestion_index());
|
||||
|
||||
// Navigate suggestions
|
||||
form.set_selected_suggestion_index(Some(1));
|
||||
println!(" Navigated to: {}", form.suggestions.get_selected().unwrap());
|
||||
println!(" ✅ Suggestions work!\n");
|
||||
}
|
||||
|
||||
// Example 3: Custom actions
|
||||
async fn example_3_custom_actions() {
|
||||
println!("⚡ Example 3: Custom Actions");
|
||||
|
||||
#[derive(Debug)]
|
||||
struct FormWithCustomActions {
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
text: String,
|
||||
has_changes: bool,
|
||||
}
|
||||
|
||||
impl FormWithCustomActions {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
current_field: 0,
|
||||
cursor_pos: 0,
|
||||
text: "hello world".to_string(),
|
||||
has_changes: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasState for FormWithCustomActions {
|
||||
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 { &self.text }
|
||||
fn get_current_input_mut(&mut self) -> &mut String { &mut self.text }
|
||||
fn inputs(&self) -> Vec<&String> { vec![&self.text] }
|
||||
fn fields(&self) -> Vec<&str> { vec!["Text"] }
|
||||
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
|
||||
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::Custom(cmd) => match cmd.as_str() {
|
||||
"uppercase" => {
|
||||
self.text = self.text.to_uppercase();
|
||||
self.has_changes = true;
|
||||
Some("Converted to uppercase".to_string())
|
||||
}
|
||||
"reverse" => {
|
||||
self.text = self.text.chars().rev().collect();
|
||||
self.has_changes = true;
|
||||
Some("Reversed text".to_string())
|
||||
}
|
||||
"word_count" => {
|
||||
let count = self.text.split_whitespace().count();
|
||||
Some(format!("Word count: {}", count))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut form = FormWithCustomActions::new();
|
||||
let mut ideal_cursor = 0;
|
||||
|
||||
println!(" Initial text: '{}'", form.text);
|
||||
|
||||
// Execute custom actions
|
||||
let result = ActionDispatcher::dispatch(
|
||||
CanvasAction::Custom("uppercase".to_string()),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
|
||||
println!(" After uppercase: '{}' - {}", form.text, result.message().unwrap());
|
||||
|
||||
let result = ActionDispatcher::dispatch(
|
||||
CanvasAction::Custom("reverse".to_string()),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
|
||||
println!(" After reverse: '{}' - {}", form.text, result.message().unwrap());
|
||||
|
||||
let result = ActionDispatcher::dispatch(
|
||||
CanvasAction::Custom("word_count".to_string()),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
|
||||
println!(" {}", result.message().unwrap());
|
||||
println!(" ✅ Custom actions work!\n");
|
||||
}
|
||||
|
||||
// Example 4: Batch operations
|
||||
async fn example_4_batch_operations() {
|
||||
println!("📦 Example 4: Batch Operations");
|
||||
|
||||
// Reuse the simple form from example 1
|
||||
#[derive(Debug)]
|
||||
struct SimpleForm {
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
name: String,
|
||||
email: String,
|
||||
has_changes: bool,
|
||||
}
|
||||
|
||||
impl SimpleForm {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
current_field: 0,
|
||||
cursor_pos: 0,
|
||||
name: String::new(),
|
||||
email: String::new(),
|
||||
has_changes: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasState for SimpleForm {
|
||||
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.min(1); }
|
||||
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.name,
|
||||
1 => &self.email,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_input_mut(&mut self) -> &mut String {
|
||||
match self.current_field {
|
||||
0 => &mut self.name,
|
||||
1 => &mut self.email,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> { vec![&self.name, &self.email] }
|
||||
fn fields(&self) -> Vec<&str> { vec!["Name", "Email"] }
|
||||
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
|
||||
}
|
||||
|
||||
let mut form = SimpleForm::new();
|
||||
let mut ideal_cursor = 0;
|
||||
|
||||
// Execute a sequence of actions to type "John" in the name field
|
||||
let actions = vec![
|
||||
CanvasAction::InsertChar('J'),
|
||||
CanvasAction::InsertChar('o'),
|
||||
CanvasAction::InsertChar('h'),
|
||||
CanvasAction::InsertChar('n'),
|
||||
CanvasAction::NextField, // Move to email field
|
||||
CanvasAction::InsertChar('j'),
|
||||
CanvasAction::InsertChar('o'),
|
||||
CanvasAction::InsertChar('h'),
|
||||
CanvasAction::InsertChar('n'),
|
||||
CanvasAction::InsertChar('@'),
|
||||
CanvasAction::InsertChar('e'),
|
||||
CanvasAction::InsertChar('x'),
|
||||
CanvasAction::InsertChar('a'),
|
||||
CanvasAction::InsertChar('m'),
|
||||
CanvasAction::InsertChar('p'),
|
||||
CanvasAction::InsertChar('l'),
|
||||
CanvasAction::InsertChar('e'),
|
||||
CanvasAction::InsertChar('.'),
|
||||
CanvasAction::InsertChar('c'),
|
||||
CanvasAction::InsertChar('o'),
|
||||
CanvasAction::InsertChar('m'),
|
||||
];
|
||||
|
||||
println!(" Executing {} actions in batch...", actions.len());
|
||||
|
||||
let results = ActionDispatcher::dispatch_batch(
|
||||
actions,
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
|
||||
println!(" Completed {} actions", results.len());
|
||||
println!(" Final state:");
|
||||
println!(" Name: '{}'", form.name);
|
||||
println!(" Email: '{}'", form.email);
|
||||
println!(" Current field: {}", form.fields()[form.current_field()]);
|
||||
println!(" Has changes: {}", form.has_changes);
|
||||
}
|
||||
@@ -1,590 +0,0 @@
|
||||
// examples/config_screen.rs
|
||||
//! Advanced configuration screen with suggestions and validation
|
||||
//!
|
||||
//! This example demonstrates:
|
||||
//! - Multiple field types
|
||||
//! - Auto-suggestions
|
||||
//! - Field validation
|
||||
//! - Custom actions
|
||||
//!
|
||||
//! Run with: cargo run --example config_screen
|
||||
|
||||
use canvas::prelude::*;
|
||||
use crossterm::{
|
||||
event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
ExecutableCommand,
|
||||
};
|
||||
use std::io::{self, Write};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ConfigForm {
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
|
||||
// Configuration fields
|
||||
server_host: String,
|
||||
server_port: String,
|
||||
database_url: String,
|
||||
log_level: String,
|
||||
max_connections: String,
|
||||
|
||||
has_changes: bool,
|
||||
suggestions: SuggestionState,
|
||||
}
|
||||
|
||||
impl ConfigForm {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
current_field: 0,
|
||||
cursor_pos: 0,
|
||||
server_host: "localhost".to_string(),
|
||||
server_port: "8080".to_string(),
|
||||
database_url: String::new(),
|
||||
log_level: "info".to_string(),
|
||||
max_connections: "100".to_string(),
|
||||
has_changes: false,
|
||||
suggestions: SuggestionState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn field_names() -> Vec<&'static str> {
|
||||
vec![
|
||||
"Server Host",
|
||||
"Server Port",
|
||||
"Database URL",
|
||||
"Log Level",
|
||||
"Max Connections"
|
||||
]
|
||||
}
|
||||
|
||||
fn get_field_value(&self, index: usize) -> &String {
|
||||
match index {
|
||||
0 => &self.server_host,
|
||||
1 => &self.server_port,
|
||||
2 => &self.database_url,
|
||||
3 => &self.log_level,
|
||||
4 => &self.max_connections,
|
||||
_ => panic!("Invalid field index: {}", index),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_field_value_mut(&mut self, index: usize) -> &mut String {
|
||||
match index {
|
||||
0 => &mut self.server_host,
|
||||
1 => &mut self.server_port,
|
||||
2 => &mut self.database_url,
|
||||
3 => &mut self.log_level,
|
||||
4 => &mut self.max_connections,
|
||||
_ => panic!("Invalid field index: {}", index),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_field(&self, index: usize) -> Option<String> {
|
||||
let value = self.get_field_value(index);
|
||||
match index {
|
||||
0 => { // Server Host
|
||||
if value.trim().is_empty() {
|
||||
Some("Server host cannot be empty".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
1 => { // Server Port
|
||||
if let Ok(port) = value.parse::<u16>() {
|
||||
if port == 0 {
|
||||
Some("Port must be greater than 0".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
Some("Port must be a valid number (1-65535)".to_string())
|
||||
}
|
||||
}
|
||||
2 => { // Database URL
|
||||
if !value.is_empty() && !value.starts_with("postgresql://") && !value.starts_with("mysql://") && !value.starts_with("sqlite://") {
|
||||
Some("Database URL should start with postgresql://, mysql://, or sqlite://".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
3 => { // Log Level
|
||||
let valid_levels = ["trace", "debug", "info", "warn", "error"];
|
||||
if !valid_levels.contains(&value.to_lowercase().as_str()) {
|
||||
Some("Log level must be one of: trace, debug, info, warn, error".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
4 => { // Max Connections
|
||||
if let Ok(connections) = value.parse::<u32>() {
|
||||
if connections == 0 {
|
||||
Some("Max connections must be greater than 0".to_string())
|
||||
} else if connections > 10000 {
|
||||
Some("Max connections seems too high (>10000)".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
Some("Max connections must be a valid number".to_string())
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_suggestions_for_field(&self, index: usize, current_value: &str) -> Vec<String> {
|
||||
match index {
|
||||
0 => { // Server Host
|
||||
vec![
|
||||
"localhost".to_string(),
|
||||
"127.0.0.1".to_string(),
|
||||
"0.0.0.0".to_string(),
|
||||
format!("{}.local", current_value),
|
||||
]
|
||||
}
|
||||
1 => { // Server Port
|
||||
vec![
|
||||
"8080".to_string(),
|
||||
"3000".to_string(),
|
||||
"8000".to_string(),
|
||||
"80".to_string(),
|
||||
"443".to_string(),
|
||||
]
|
||||
}
|
||||
2 => { // Database URL
|
||||
if current_value.is_empty() {
|
||||
vec![
|
||||
"postgresql://localhost:5432/mydb".to_string(),
|
||||
"mysql://localhost:3306/mydb".to_string(),
|
||||
"sqlite://./database.db".to_string(),
|
||||
]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
3 => { // Log Level
|
||||
vec![
|
||||
"trace".to_string(),
|
||||
"debug".to_string(),
|
||||
"info".to_string(),
|
||||
"warn".to_string(),
|
||||
"error".to_string(),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|level| level.starts_with(¤t_value.to_lowercase()))
|
||||
.collect()
|
||||
}
|
||||
4 => { // Max Connections
|
||||
vec![
|
||||
"10".to_string(),
|
||||
"50".to_string(),
|
||||
"100".to_string(),
|
||||
"200".to_string(),
|
||||
"500".to_string(),
|
||||
]
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasState for ConfigForm {
|
||||
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.min(4); // 5 fields total (0-4)
|
||||
// Deactivate suggestions when changing fields
|
||||
self.deactivate_suggestions();
|
||||
}
|
||||
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||
self.cursor_pos = pos;
|
||||
}
|
||||
|
||||
fn get_current_input(&self) -> &str {
|
||||
self.get_field_value(self.current_field)
|
||||
}
|
||||
|
||||
fn get_current_input_mut(&mut self) -> &mut String {
|
||||
self.get_field_value_mut(self.current_field)
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> {
|
||||
vec![
|
||||
&self.server_host,
|
||||
&self.server_port,
|
||||
&self.database_url,
|
||||
&self.log_level,
|
||||
&self.max_connections,
|
||||
]
|
||||
}
|
||||
|
||||
fn fields(&self) -> Vec<&str> {
|
||||
Self::field_names()
|
||||
}
|
||||
|
||||
fn has_unsaved_changes(&self) -> bool {
|
||||
self.has_changes
|
||||
}
|
||||
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) {
|
||||
self.has_changes = changed;
|
||||
}
|
||||
|
||||
// Suggestion support
|
||||
fn get_suggestions(&self) -> Option<&[String]> {
|
||||
if self.suggestions.is_active {
|
||||
Some(&self.suggestions.suggestions)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn get_selected_suggestion_index(&self) -> Option<usize> {
|
||||
self.suggestions.selected_index
|
||||
}
|
||||
|
||||
fn set_selected_suggestion_index(&mut self, index: Option<usize>) {
|
||||
self.suggestions.selected_index = index;
|
||||
}
|
||||
|
||||
fn activate_suggestions(&mut self, suggestions: Vec<String>) {
|
||||
self.suggestions.activate_with_suggestions(suggestions);
|
||||
}
|
||||
|
||||
fn deactivate_suggestions(&mut self) {
|
||||
self.suggestions.deactivate();
|
||||
}
|
||||
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::SelectSuggestion => {
|
||||
// Fix: Clone the suggestion first to avoid borrow checker issues
|
||||
if let Some(suggestion) = self.suggestions.get_selected().cloned() {
|
||||
*self.get_current_input_mut() = suggestion.clone();
|
||||
self.set_current_cursor_pos(suggestion.len());
|
||||
self.deactivate_suggestions();
|
||||
self.set_has_unsaved_changes(true);
|
||||
return Some("Applied suggestion".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
CanvasAction::Custom(cmd) => match cmd.as_str() {
|
||||
"trigger_suggestions" => {
|
||||
let current_value = self.get_current_input();
|
||||
let suggestions = self.get_suggestions_for_field(self.current_field, current_value);
|
||||
if !suggestions.is_empty() {
|
||||
self.activate_suggestions(suggestions);
|
||||
Some("Showing suggestions".to_string())
|
||||
} else {
|
||||
Some("No suggestions available".to_string())
|
||||
}
|
||||
}
|
||||
"validate_current" => {
|
||||
if let Some(error) = self.validate_field(self.current_field) {
|
||||
Some(format!("Validation Error: {}", error))
|
||||
} else {
|
||||
Some("Field is valid".to_string())
|
||||
}
|
||||
}
|
||||
"validate_all" => {
|
||||
let mut errors = Vec::new();
|
||||
for i in 0..5 {
|
||||
if let Some(error) = self.validate_field(i) {
|
||||
errors.push(format!("{}: {}", Self::field_names()[i], error));
|
||||
}
|
||||
}
|
||||
if errors.is_empty() {
|
||||
Some("All fields are valid!".to_string())
|
||||
} else {
|
||||
Some(format!("Errors found: {}", errors.join("; ")))
|
||||
}
|
||||
}
|
||||
"save_config" => {
|
||||
// Validate all fields first
|
||||
for i in 0..5 {
|
||||
if self.validate_field(i).is_some() {
|
||||
return Some("Cannot save: Please fix validation errors first".to_string());
|
||||
}
|
||||
}
|
||||
self.set_has_unsaved_changes(false);
|
||||
Some("Configuration saved successfully!".to_string())
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
|
||||
// Auto-trigger suggestions for certain fields
|
||||
CanvasAction::InsertChar(_) => {
|
||||
// After character insertion, check if we should show suggestions
|
||||
match self.current_field {
|
||||
3 => { // Log level - always show suggestions for autocomplete
|
||||
let current_value = self.get_current_input();
|
||||
let suggestions = self.get_suggestions_for_field(self.current_field, current_value);
|
||||
if !suggestions.is_empty() {
|
||||
self.activate_suggestions(suggestions);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None // Let the generic handler insert the character
|
||||
}
|
||||
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_ui(form: &ConfigForm, message: &str) -> io::Result<()> {
|
||||
print!("\x1B[2J\x1B[1;1H");
|
||||
|
||||
println!("╔════════════════════════════════════════════════════════════════╗");
|
||||
println!("║ CONFIGURATION EDITOR ║");
|
||||
println!("╠════════════════════════════════════════════════════════════════╣");
|
||||
|
||||
let field_names = ConfigForm::field_names();
|
||||
|
||||
for (i, field_name) in field_names.iter().enumerate() {
|
||||
let is_current = i == form.current_field;
|
||||
let indicator = if is_current { ">" } else { " " };
|
||||
let value = form.get_field_value(i);
|
||||
let display_value = if value.is_empty() {
|
||||
format!("<enter {}>", field_name.to_lowercase())
|
||||
} else {
|
||||
value.clone()
|
||||
};
|
||||
|
||||
// Truncate long values for display
|
||||
let display_value = if display_value.len() > 35 {
|
||||
format!("{}...", &display_value[..32])
|
||||
} else {
|
||||
display_value
|
||||
};
|
||||
|
||||
println!("║ {} {:15}: {:35} ║", indicator, field_name, display_value);
|
||||
|
||||
// Show cursor for current field
|
||||
if is_current {
|
||||
let cursor_pos = form.cursor_pos.min(value.len());
|
||||
let cursor_line = format!("║ {}{}║",
|
||||
" ".repeat(18 + cursor_pos),
|
||||
"▊"
|
||||
);
|
||||
println!("{:66}", cursor_line);
|
||||
}
|
||||
|
||||
// Show validation error if any
|
||||
if let Some(error) = form.validate_field(i) {
|
||||
let error_display = if error.len() > 58 {
|
||||
format!("{}...", &error[..55])
|
||||
} else {
|
||||
error
|
||||
};
|
||||
println!("║ ⚠️ {:58} ║", error_display);
|
||||
} else if is_current {
|
||||
println!("║{:64}║", "");
|
||||
}
|
||||
}
|
||||
|
||||
println!("╠════════════════════════════════════════════════════════════════╣");
|
||||
|
||||
// Show suggestions if active
|
||||
if let Some(suggestions) = form.get_suggestions() {
|
||||
println!("║ SUGGESTIONS: ║");
|
||||
for (i, suggestion) in suggestions.iter().enumerate() {
|
||||
let selected = form.get_selected_suggestion_index() == Some(i);
|
||||
let marker = if selected { "→" } else { " " };
|
||||
let display_suggestion = if suggestion.len() > 55 {
|
||||
format!("{}...", &suggestion[..52])
|
||||
} else {
|
||||
suggestion.clone()
|
||||
};
|
||||
println!("║ {} {:58} ║", marker, display_suggestion);
|
||||
}
|
||||
println!("╠════════════════════════════════════════════════════════════════╣");
|
||||
}
|
||||
|
||||
println!("║ CONTROLS: ║");
|
||||
println!("║ Tab/↑↓ - Navigate fields ║");
|
||||
println!("║ Ctrl+Space - Show suggestions ║");
|
||||
println!("║ ↑↓ - Navigate suggestions (when shown) ║");
|
||||
println!("║ Enter - Select suggestion / Validate field ║");
|
||||
println!("║ Ctrl+S - Save configuration ║");
|
||||
println!("║ Ctrl+V - Validate all fields ║");
|
||||
println!("║ Ctrl+C - Exit ║");
|
||||
println!("╠════════════════════════════════════════════════════════════════╣");
|
||||
|
||||
// Status
|
||||
let status = if !message.is_empty() {
|
||||
message.to_string()
|
||||
} else if form.has_changes {
|
||||
"Configuration modified - press Ctrl+S to save".to_string()
|
||||
} else {
|
||||
"Ready".to_string()
|
||||
};
|
||||
|
||||
let status_display = if status.len() > 58 {
|
||||
format!("{}...", &status[..55])
|
||||
} else {
|
||||
status
|
||||
};
|
||||
|
||||
println!("║ Status: {:55} ║", status_display);
|
||||
println!("╚════════════════════════════════════════════════════════════════╝");
|
||||
|
||||
io::stdout().flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> io::Result<()> {
|
||||
enable_raw_mode()?;
|
||||
io::stdout().execute(EnterAlternateScreen)?;
|
||||
|
||||
let mut form = ConfigForm::new();
|
||||
let mut ideal_cursor = 0;
|
||||
let mut message = String::new();
|
||||
|
||||
draw_ui(&form, &message)?;
|
||||
|
||||
loop {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if !message.is_empty() {
|
||||
message.clear();
|
||||
}
|
||||
|
||||
match key {
|
||||
// Exit
|
||||
KeyEvent { code: KeyCode::Char('c'), modifiers: KeyModifiers::CONTROL, .. } => {
|
||||
break;
|
||||
}
|
||||
|
||||
// Show suggestions
|
||||
KeyEvent { code: KeyCode::Char(' '), modifiers: KeyModifiers::CONTROL, .. } => {
|
||||
let result = ActionDispatcher::dispatch(
|
||||
CanvasAction::Custom("trigger_suggestions".to_string()),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
|
||||
if let Some(msg) = result.message() {
|
||||
message = msg.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Validate current field or select suggestion
|
||||
KeyEvent { code: KeyCode::Enter, .. } => {
|
||||
if form.get_suggestions().is_some() {
|
||||
// Select suggestion
|
||||
let result = ActionDispatcher::dispatch(
|
||||
CanvasAction::SelectSuggestion,
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
|
||||
if let Some(msg) = result.message() {
|
||||
message = msg.to_string();
|
||||
}
|
||||
} else {
|
||||
// Validate current field
|
||||
let result = ActionDispatcher::dispatch(
|
||||
CanvasAction::Custom("validate_current".to_string()),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
|
||||
if let Some(msg) = result.message() {
|
||||
message = msg.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save configuration
|
||||
KeyEvent { code: KeyCode::Char('s'), modifiers: KeyModifiers::CONTROL, .. } => {
|
||||
let result = ActionDispatcher::dispatch(
|
||||
CanvasAction::Custom("save_config".to_string()),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
|
||||
if let Some(msg) = result.message() {
|
||||
message = msg.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Validate all fields
|
||||
KeyEvent { code: KeyCode::Char('v'), modifiers: KeyModifiers::CONTROL, .. } => {
|
||||
let result = ActionDispatcher::dispatch(
|
||||
CanvasAction::Custom("validate_all".to_string()),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
|
||||
if let Some(msg) = result.message() {
|
||||
message = msg.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle up/down for suggestions
|
||||
KeyEvent { code: KeyCode::Up, .. } => {
|
||||
let action = if form.get_suggestions().is_some() {
|
||||
CanvasAction::SuggestionUp
|
||||
} else {
|
||||
CanvasAction::MoveUp
|
||||
};
|
||||
|
||||
let _ = ActionDispatcher::dispatch(action, &mut form, &mut ideal_cursor).await;
|
||||
}
|
||||
|
||||
KeyEvent { code: KeyCode::Down, .. } => {
|
||||
let action = if form.get_suggestions().is_some() {
|
||||
CanvasAction::SuggestionDown
|
||||
} else {
|
||||
CanvasAction::MoveDown
|
||||
};
|
||||
|
||||
let _ = ActionDispatcher::dispatch(action, &mut form, &mut ideal_cursor).await;
|
||||
}
|
||||
|
||||
// Handle escape to close suggestions
|
||||
KeyEvent { code: KeyCode::Esc, .. } => {
|
||||
if form.get_suggestions().is_some() {
|
||||
let _ = ActionDispatcher::dispatch(
|
||||
CanvasAction::ExitSuggestions,
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Regular key handling
|
||||
_ => {
|
||||
if let Some(action) = CanvasAction::from_key(key.code) {
|
||||
let result = ActionDispatcher::dispatch(action, &mut form, &mut ideal_cursor).await.unwrap();
|
||||
|
||||
if !result.is_success() {
|
||||
if let Some(msg) = result.message() {
|
||||
message = format!("Error: {}", msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
draw_ui(&form, &message)?;
|
||||
}
|
||||
}
|
||||
|
||||
disable_raw_mode()?;
|
||||
io::stdout().execute(LeaveAlternateScreen)?;
|
||||
println!("Configuration editor closed!");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,617 +0,0 @@
|
||||
// examples/integration_patterns.rs
|
||||
//! Advanced integration patterns showing how Canvas works with:
|
||||
//! - State management patterns
|
||||
//! - Event-driven architectures
|
||||
//! - Validation systems
|
||||
//! - Custom rendering
|
||||
//!
|
||||
//! Run with: cargo run --example integration_patterns
|
||||
|
||||
use canvas::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
println!("🔧 Canvas Integration Patterns");
|
||||
println!("==============================\n");
|
||||
|
||||
// Pattern 1: State machine integration
|
||||
state_machine_example().await;
|
||||
|
||||
// Pattern 2: Event-driven architecture
|
||||
event_driven_example().await;
|
||||
|
||||
// Pattern 3: Validation pipeline
|
||||
validation_pipeline_example().await;
|
||||
|
||||
// Pattern 4: Multi-form orchestration
|
||||
multi_form_example().await;
|
||||
}
|
||||
|
||||
// Pattern 1: Canvas with state machine
|
||||
async fn state_machine_example() {
|
||||
println!("🔄 Pattern 1: State Machine Integration");
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum FormState {
|
||||
Initial,
|
||||
Editing,
|
||||
Validating,
|
||||
Submitting,
|
||||
Success,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct StateMachineForm {
|
||||
// Canvas state
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
username: String,
|
||||
password: String,
|
||||
has_changes: bool,
|
||||
|
||||
// State machine
|
||||
state: FormState,
|
||||
}
|
||||
|
||||
impl StateMachineForm {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
current_field: 0,
|
||||
cursor_pos: 0,
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
has_changes: false,
|
||||
state: FormState::Initial,
|
||||
}
|
||||
}
|
||||
|
||||
fn transition_to(&mut self, new_state: FormState) -> String {
|
||||
let old_state = self.state.clone();
|
||||
self.state = new_state;
|
||||
format!("State transition: {:?} -> {:?}", old_state, self.state)
|
||||
}
|
||||
|
||||
fn can_submit(&self) -> bool {
|
||||
matches!(self.state, FormState::Editing) &&
|
||||
!self.username.trim().is_empty() &&
|
||||
!self.password.trim().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasState for StateMachineForm {
|
||||
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.min(1); }
|
||||
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;
|
||||
// Transition to editing state when user starts typing
|
||||
if changed && self.state == FormState::Initial {
|
||||
self.state = FormState::Editing;
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::Custom(cmd) => match cmd.as_str() {
|
||||
"submit" => {
|
||||
if self.can_submit() {
|
||||
let msg = self.transition_to(FormState::Submitting);
|
||||
// Simulate submission
|
||||
self.state = FormState::Success;
|
||||
Some(format!("{} -> Form submitted successfully", msg))
|
||||
} else {
|
||||
let msg = self.transition_to(FormState::Error("Invalid form data".to_string()));
|
||||
Some(msg)
|
||||
}
|
||||
}
|
||||
"reset" => {
|
||||
self.username.clear();
|
||||
self.password.clear();
|
||||
self.has_changes = false;
|
||||
Some(self.transition_to(FormState::Initial))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut form = StateMachineForm::new();
|
||||
let mut ideal_cursor = 0;
|
||||
|
||||
println!(" Initial state: {:?}", form.state);
|
||||
|
||||
// Type some text to trigger state change
|
||||
let result = ActionDispatcher::dispatch(
|
||||
CanvasAction::InsertChar('u'),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
println!(" After typing: {:?}", form.state);
|
||||
|
||||
// Try to submit (should fail)
|
||||
let result = ActionDispatcher::dispatch(
|
||||
CanvasAction::Custom("submit".to_string()),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
println!(" Submit result: {}", result.message().unwrap_or(""));
|
||||
println!(" ✅ State machine integration works!\n");
|
||||
}
|
||||
|
||||
// Pattern 2: Event-driven architecture
|
||||
async fn event_driven_example() {
|
||||
println!("📡 Pattern 2: Event-Driven Architecture");
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum FormEvent {
|
||||
FieldChanged { field: usize, old_value: String, new_value: String },
|
||||
ValidationTriggered { field: usize, is_valid: bool },
|
||||
ActionExecuted { action: String, success: bool },
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct EventDrivenForm {
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
email: String,
|
||||
has_changes: bool,
|
||||
events: Vec<FormEvent>,
|
||||
}
|
||||
|
||||
impl EventDrivenForm {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
current_field: 0,
|
||||
cursor_pos: 0,
|
||||
email: String::new(),
|
||||
has_changes: false,
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_event(&mut self, event: FormEvent) {
|
||||
println!(" 📡 Event: {:?}", event);
|
||||
self.events.push(event);
|
||||
}
|
||||
|
||||
fn validate_email(&self) -> bool {
|
||||
self.email.contains('@') && self.email.contains('.')
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasState for EventDrivenForm {
|
||||
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 { &self.email }
|
||||
fn get_current_input_mut(&mut self) -> &mut String { &mut self.email }
|
||||
fn inputs(&self) -> Vec<&String> { vec![&self.email] }
|
||||
fn fields(&self) -> Vec<&str> { vec!["Email"] }
|
||||
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
||||
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) {
|
||||
if changed != self.has_changes {
|
||||
let old_value = if self.has_changes { "modified" } else { "unmodified" };
|
||||
let new_value = if changed { "modified" } else { "unmodified" };
|
||||
|
||||
self.emit_event(FormEvent::FieldChanged {
|
||||
field: self.current_field,
|
||||
old_value: old_value.to_string(),
|
||||
new_value: new_value.to_string(),
|
||||
});
|
||||
}
|
||||
self.has_changes = changed;
|
||||
}
|
||||
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::Custom(cmd) => match cmd.as_str() {
|
||||
"validate" => {
|
||||
let is_valid = self.validate_email();
|
||||
self.emit_event(FormEvent::ValidationTriggered {
|
||||
field: self.current_field,
|
||||
is_valid,
|
||||
});
|
||||
|
||||
self.emit_event(FormEvent::ActionExecuted {
|
||||
action: "validate".to_string(),
|
||||
success: true,
|
||||
});
|
||||
|
||||
if is_valid {
|
||||
Some("Email is valid!".to_string())
|
||||
} else {
|
||||
Some("Email is invalid".to_string())
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut form = EventDrivenForm::new();
|
||||
let mut ideal_cursor = 0;
|
||||
|
||||
// Type an email address
|
||||
let email = "user@example.com";
|
||||
for c in email.chars() {
|
||||
ActionDispatcher::dispatch(
|
||||
CanvasAction::InsertChar(c),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
}
|
||||
|
||||
// Validate the email
|
||||
let result = ActionDispatcher::dispatch(
|
||||
CanvasAction::Custom("validate".to_string()),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
|
||||
println!(" Final email: {}", form.email);
|
||||
println!(" Validation result: {}", result.message().unwrap_or(""));
|
||||
println!(" Total events captured: {}", form.events.len());
|
||||
println!(" ✅ Event-driven architecture works!\n");
|
||||
}
|
||||
|
||||
// Pattern 3: Validation pipeline
|
||||
async fn validation_pipeline_example() {
|
||||
println!("✅ Pattern 3: Validation Pipeline");
|
||||
|
||||
type ValidationRule = Box<dyn Fn(&str) -> Result<(), String>>;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ValidatedForm {
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
password: String,
|
||||
has_changes: bool,
|
||||
validators: HashMap<usize, Vec<ValidationRule>>,
|
||||
}
|
||||
|
||||
impl ValidatedForm {
|
||||
fn new() -> Self {
|
||||
let mut validators: HashMap<usize, Vec<ValidationRule>> = HashMap::new();
|
||||
|
||||
// Password validators
|
||||
let mut password_validators: Vec<ValidationRule> = Vec::new();
|
||||
password_validators.push(Box::new(|value| {
|
||||
if value.len() < 8 {
|
||||
Err("Password must be at least 8 characters".to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}));
|
||||
password_validators.push(Box::new(|value| {
|
||||
if !value.chars().any(|c| c.is_uppercase()) {
|
||||
Err("Password must contain at least one uppercase letter".to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}));
|
||||
password_validators.push(Box::new(|value| {
|
||||
if !value.chars().any(|c| c.is_numeric()) {
|
||||
Err("Password must contain at least one number".to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}));
|
||||
|
||||
validators.insert(0, password_validators);
|
||||
|
||||
Self {
|
||||
current_field: 0,
|
||||
cursor_pos: 0,
|
||||
password: String::new(),
|
||||
has_changes: false,
|
||||
validators,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_field(&self, field_index: usize) -> Vec<String> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if let Some(validators) = self.validators.get(&field_index) {
|
||||
let value = match field_index {
|
||||
0 => &self.password,
|
||||
_ => return errors,
|
||||
};
|
||||
|
||||
for validator in validators {
|
||||
if let Err(error) = validator(value) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
errors
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasState for ValidatedForm {
|
||||
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 { &self.password }
|
||||
fn get_current_input_mut(&mut self) -> &mut String { &mut self.password }
|
||||
fn inputs(&self) -> Vec<&String> { vec![&self.password] }
|
||||
fn fields(&self) -> Vec<&str> { vec!["Password"] }
|
||||
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
|
||||
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::Custom(cmd) => match cmd.as_str() {
|
||||
"validate" => {
|
||||
let errors = self.validate_field(self.current_field);
|
||||
if errors.is_empty() {
|
||||
Some("Password meets all requirements!".to_string())
|
||||
} else {
|
||||
Some(format!("Validation errors: {}", errors.join(", ")))
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut form = ValidatedForm::new();
|
||||
let mut ideal_cursor = 0;
|
||||
|
||||
// Test with weak password
|
||||
let weak_password = "abc";
|
||||
for c in weak_password.chars() {
|
||||
ActionDispatcher::dispatch(
|
||||
CanvasAction::InsertChar(c),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
}
|
||||
|
||||
let result = ActionDispatcher::dispatch(
|
||||
CanvasAction::Custom("validate".to_string()),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
println!(" Weak password '{}': {}", form.password, result.message().unwrap_or(""));
|
||||
|
||||
// Clear and test with strong password
|
||||
form.password.clear();
|
||||
form.cursor_pos = 0;
|
||||
|
||||
let strong_password = "StrongPass123";
|
||||
for c in strong_password.chars() {
|
||||
ActionDispatcher::dispatch(
|
||||
CanvasAction::InsertChar(c),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
}
|
||||
|
||||
let result = ActionDispatcher::dispatch(
|
||||
CanvasAction::Custom("validate".to_string()),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
println!(" Strong password '{}': {}", form.password, result.message().unwrap_or(""));
|
||||
println!(" ✅ Validation pipeline works!\n");
|
||||
}
|
||||
|
||||
// Pattern 4: Multi-form orchestration
|
||||
async fn multi_form_example() {
|
||||
println!("🎭 Pattern 4: Multi-Form Orchestration");
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PersonalInfoForm {
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
name: String,
|
||||
age: String,
|
||||
has_changes: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ContactInfoForm {
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
email: String,
|
||||
phone: String,
|
||||
has_changes: bool,
|
||||
}
|
||||
|
||||
// Implement CanvasState for both forms
|
||||
impl CanvasState for PersonalInfoForm {
|
||||
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.min(1); }
|
||||
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.name,
|
||||
1 => &self.age,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_input_mut(&mut self) -> &mut String {
|
||||
match self.current_field {
|
||||
0 => &mut self.name,
|
||||
1 => &mut self.age,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> { vec![&self.name, &self.age] }
|
||||
fn fields(&self) -> Vec<&str> { vec!["Name", "Age"] }
|
||||
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
|
||||
}
|
||||
|
||||
impl CanvasState for ContactInfoForm {
|
||||
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.min(1); }
|
||||
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.email,
|
||||
1 => &self.phone,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_input_mut(&mut self) -> &mut String {
|
||||
match self.current_field {
|
||||
0 => &mut self.email,
|
||||
1 => &mut self.phone,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> { vec![&self.email, &self.phone] }
|
||||
fn fields(&self) -> Vec<&str> { vec!["Email", "Phone"] }
|
||||
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
|
||||
}
|
||||
|
||||
// Form orchestrator
|
||||
#[derive(Debug)]
|
||||
struct FormOrchestrator {
|
||||
personal_form: PersonalInfoForm,
|
||||
contact_form: ContactInfoForm,
|
||||
current_form: usize, // 0 = personal, 1 = contact
|
||||
}
|
||||
|
||||
impl FormOrchestrator {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
personal_form: PersonalInfoForm {
|
||||
current_field: 0,
|
||||
cursor_pos: 0,
|
||||
name: String::new(),
|
||||
age: String::new(),
|
||||
has_changes: false,
|
||||
},
|
||||
contact_form: ContactInfoForm {
|
||||
current_field: 0,
|
||||
cursor_pos: 0,
|
||||
email: String::new(),
|
||||
phone: String::new(),
|
||||
has_changes: false,
|
||||
},
|
||||
current_form: 0,
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_action(&mut self, action: CanvasAction) -> ActionResult {
|
||||
let mut ideal_cursor = 0;
|
||||
|
||||
match self.current_form {
|
||||
0 => ActionDispatcher::dispatch(action, &mut self.personal_form, &mut ideal_cursor).await.unwrap(),
|
||||
1 => ActionDispatcher::dispatch(action, &mut self.contact_form, &mut ideal_cursor).await.unwrap(),
|
||||
_ => ActionResult::error("Invalid form index"),
|
||||
}
|
||||
}
|
||||
|
||||
fn switch_form(&mut self) -> String {
|
||||
self.current_form = (self.current_form + 1) % 2;
|
||||
match self.current_form {
|
||||
0 => "Switched to Personal Info form".to_string(),
|
||||
1 => "Switched to Contact Info form".to_string(),
|
||||
_ => "Unknown form".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_form_name(&self) -> &str {
|
||||
match self.current_form {
|
||||
0 => "Personal Info",
|
||||
1 => "Contact Info",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut orchestrator = FormOrchestrator::new();
|
||||
|
||||
println!(" Current form: {}", orchestrator.current_form_name());
|
||||
|
||||
// Fill personal info
|
||||
let personal_data = vec![
|
||||
('J', 'o', 'h', 'n'),
|
||||
];
|
||||
|
||||
for &c in &['J', 'o', 'h', 'n'] {
|
||||
orchestrator.execute_action(CanvasAction::InsertChar(c)).await;
|
||||
}
|
||||
|
||||
orchestrator.execute_action(CanvasAction::NextField).await;
|
||||
|
||||
for &c in &['2', '5'] {
|
||||
orchestrator.execute_action(CanvasAction::InsertChar(c)).await;
|
||||
}
|
||||
|
||||
println!(" Personal form - Name: '{}', Age: '{}'",
|
||||
orchestrator.personal_form.name,
|
||||
orchestrator.personal_form.age);
|
||||
|
||||
// Switch to contact form
|
||||
let switch_msg = orchestrator.switch_form();
|
||||
println!(" {}", switch_msg);
|
||||
|
||||
// Fill contact info
|
||||
for &c in &['j', 'o', 'h', 'n', '@', 'e', 'x', 'a', 'm', 'p', 'l', 'e', '.', 'c', 'o', 'm'] {
|
||||
orchestrator.execute_action(CanvasAction::InsertChar(c)).await;
|
||||
}
|
||||
|
||||
orchestrator.execute_action(CanvasAction::NextField).await;
|
||||
|
||||
for &c in &['5', '5', '5', '-', '1', '2', '3', '4'] {
|
||||
orchestrator.execute_action(CanvasAction::InsertChar(c)).await;
|
||||
}
|
||||
|
||||
println!(" Contact form - Email: '{}', Phone: '{}'",
|
||||
orchestrator.contact_form.email,
|
||||
orchestrator.contact_form.phone);
|
||||
|
||||
println!(" ✅ Multi-form orchestration works!\n");
|
||||
|
||||
println!("🎉 All integration patterns completed!");
|
||||
println!("The Canvas crate seamlessly integrates with various architectural patterns!");
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
// examples/simple_login.rs
|
||||
//! A simple login form demonstrating basic canvas usage
|
||||
//!
|
||||
//! Run with: cargo run --example simple_login
|
||||
|
||||
use canvas::prelude::*;
|
||||
use crossterm::{
|
||||
event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
ExecutableCommand,
|
||||
};
|
||||
use std::io::{self, Write};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct LoginForm {
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
username: String,
|
||||
password: String,
|
||||
has_changes: bool,
|
||||
}
|
||||
|
||||
impl LoginForm {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
current_field: 0,
|
||||
cursor_pos: 0,
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
has_changes: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.username.clear();
|
||||
self.password.clear();
|
||||
self.current_field = 0;
|
||||
self.cursor_pos = 0;
|
||||
self.has_changes = false;
|
||||
}
|
||||
|
||||
fn is_valid(&self) -> bool {
|
||||
!self.username.trim().is_empty() && !self.password.trim().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
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.min(1); // Only 2 fields: username(0), password(1)
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Custom action handling
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::Custom(cmd) => match cmd.as_str() {
|
||||
"submit" => {
|
||||
if self.is_valid() {
|
||||
Some(format!("Login successful! Welcome, {}", self.username))
|
||||
} else {
|
||||
Some("Error: Username and password are required".to_string())
|
||||
}
|
||||
}
|
||||
"clear" => {
|
||||
self.reset();
|
||||
Some("Form cleared".to_string())
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Override display for password field
|
||||
fn get_display_value_for_field(&self, index: usize) -> &str {
|
||||
match index {
|
||||
0 => &self.username, // Username shows normally
|
||||
1 => &self.password, // We'll handle masking in the UI drawing
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn has_display_override(&self, index: usize) -> bool {
|
||||
index == 1 // Password field has display override
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_ui(form: &LoginForm, message: &str) -> io::Result<()> {
|
||||
// Clear screen and move cursor to top-left
|
||||
print!("\x1B[2J\x1B[1;1H");
|
||||
|
||||
println!("╔═══════════════════════════════════════╗");
|
||||
println!("║ LOGIN FORM ║");
|
||||
println!("╠═══════════════════════════════════════╣");
|
||||
|
||||
// Username field
|
||||
let username_indicator = if form.current_field == 0 { "→" } else { " " };
|
||||
let username_display = if form.username.is_empty() {
|
||||
"<enter username>".to_string()
|
||||
} else {
|
||||
form.username.clone()
|
||||
};
|
||||
println!("║ {} Username: {:22} ║", username_indicator,
|
||||
if username_display.len() > 22 {
|
||||
format!("{}...", &username_display[..19])
|
||||
} else {
|
||||
format!("{:22}", username_display)
|
||||
});
|
||||
|
||||
// Show cursor for username field
|
||||
if form.current_field == 0 && !form.username.is_empty() {
|
||||
let cursor_pos = form.cursor_pos.min(form.username.len());
|
||||
let spaces_before = 11 + cursor_pos; // "Username: " = 10 chars + 1 space
|
||||
let cursor_line = format!("║ {}█{:width$}║",
|
||||
" ".repeat(spaces_before),
|
||||
"",
|
||||
width = 25_usize.saturating_sub(spaces_before)
|
||||
);
|
||||
println!("{}", cursor_line);
|
||||
} else {
|
||||
println!("║{:37}║", "");
|
||||
}
|
||||
|
||||
// Password field
|
||||
let password_indicator = if form.current_field == 1 { "→" } else { " " };
|
||||
let password_display = if form.password.is_empty() {
|
||||
"<enter password>".to_string()
|
||||
} else {
|
||||
"*".repeat(form.password.len())
|
||||
};
|
||||
println!("║ {} Password: {:22} ║", password_indicator,
|
||||
if password_display.len() > 22 {
|
||||
format!("{}...", &password_display[..19])
|
||||
} else {
|
||||
format!("{:22}", password_display)
|
||||
});
|
||||
|
||||
// Show cursor for password field
|
||||
if form.current_field == 1 && !form.password.is_empty() {
|
||||
let cursor_pos = form.cursor_pos.min(form.password.len());
|
||||
let spaces_before = 11 + cursor_pos; // "Password: " = 10 chars + 1 space
|
||||
let cursor_line = format!("║ {}█{:width$}║",
|
||||
" ".repeat(spaces_before),
|
||||
"",
|
||||
width = 25_usize.saturating_sub(spaces_before)
|
||||
);
|
||||
println!("{}", cursor_line);
|
||||
} else {
|
||||
println!("║{:37}║", "");
|
||||
}
|
||||
|
||||
println!("╠═══════════════════════════════════════╣");
|
||||
println!("║ CONTROLS: ║");
|
||||
println!("║ Tab/↑↓ - Navigate fields ║");
|
||||
println!("║ Enter - Submit form ║");
|
||||
println!("║ Ctrl+R - Clear form ║");
|
||||
println!("║ Ctrl+C - Exit ║");
|
||||
println!("╠═══════════════════════════════════════╣");
|
||||
|
||||
// Status message
|
||||
let status = if !message.is_empty() {
|
||||
message.to_string()
|
||||
} else if form.has_changes {
|
||||
"Form modified".to_string()
|
||||
} else {
|
||||
"Ready - enter your credentials".to_string()
|
||||
};
|
||||
|
||||
let status_display = if status.len() > 33 {
|
||||
format!("{}...", &status[..30])
|
||||
} else {
|
||||
format!("{:33}", status)
|
||||
};
|
||||
|
||||
println!("║ Status: {} ║", status_display);
|
||||
println!("╚═══════════════════════════════════════╝");
|
||||
|
||||
// Show current state info
|
||||
println!();
|
||||
println!("Current field: {} ({})",
|
||||
form.current_field,
|
||||
form.fields()[form.current_field]);
|
||||
println!("Cursor position: {}", form.cursor_pos);
|
||||
println!("Has changes: {}", form.has_changes);
|
||||
|
||||
io::stdout().flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> io::Result<()> {
|
||||
println!("Starting Canvas Login Demo...");
|
||||
println!("Setting up terminal...");
|
||||
|
||||
// Setup terminal
|
||||
enable_raw_mode()?;
|
||||
io::stdout().execute(EnterAlternateScreen)?;
|
||||
|
||||
let mut form = LoginForm::new();
|
||||
let mut ideal_cursor = 0;
|
||||
let mut message = String::new();
|
||||
|
||||
// Initial draw
|
||||
if let Err(e) = draw_ui(&form, &message) {
|
||||
// Cleanup on error
|
||||
let _ = disable_raw_mode();
|
||||
let _ = io::stdout().execute(LeaveAlternateScreen);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
println!("Canvas Login Demo started. Use Ctrl+C to exit.");
|
||||
|
||||
loop {
|
||||
match event::read() {
|
||||
Ok(Event::Key(key)) => {
|
||||
// Clear message after key press
|
||||
if !message.is_empty() {
|
||||
message.clear();
|
||||
}
|
||||
|
||||
match key {
|
||||
// Exit
|
||||
KeyEvent { code: KeyCode::Char('c'), modifiers: KeyModifiers::CONTROL, .. } => {
|
||||
break;
|
||||
}
|
||||
|
||||
// Clear form
|
||||
KeyEvent { code: KeyCode::Char('r'), modifiers: KeyModifiers::CONTROL, .. } => {
|
||||
match ActionDispatcher::dispatch(
|
||||
CanvasAction::Custom("clear".to_string()),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await {
|
||||
Ok(result) => {
|
||||
if let Some(msg) = result.message() {
|
||||
message = msg.to_string();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
message = format!("Error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Submit form
|
||||
KeyEvent { code: KeyCode::Enter, .. } => {
|
||||
match ActionDispatcher::dispatch(
|
||||
CanvasAction::Custom("submit".to_string()),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await {
|
||||
Ok(result) => {
|
||||
if let Some(msg) = result.message() {
|
||||
message = msg.to_string();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
message = format!("Error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Regular key handling - let canvas handle it!
|
||||
_ => {
|
||||
if let Some(action) = CanvasAction::from_key(key.code) {
|
||||
match ActionDispatcher::dispatch(action, &mut form, &mut ideal_cursor).await {
|
||||
Ok(result) => {
|
||||
if !result.is_success() {
|
||||
if let Some(msg) = result.message() {
|
||||
message = format!("Error: {}", msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
message = format!("Error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Redraw UI
|
||||
if let Err(e) = draw_ui(&form, &message) {
|
||||
eprintln!("Error drawing UI: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
// Ignore other events (mouse, resize, etc.)
|
||||
}
|
||||
Err(e) => {
|
||||
message = format!("Event error: {}", e);
|
||||
if let Err(_) = draw_ui(&form, &message) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
disable_raw_mode()?;
|
||||
io::stdout().execute(LeaveAlternateScreen)?;
|
||||
|
||||
println!("Thanks for using Canvas Login Demo!");
|
||||
println!("Final form state:");
|
||||
println!(" Username: '{}'", form.username);
|
||||
println!(" Password: '{}'", "*".repeat(form.password.len()));
|
||||
println!(" Valid: {}", form.is_valid());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
93
canvas/src/autocomplete/actions.rs
Normal file
93
canvas/src/autocomplete/actions.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
// src/autocomplete/actions.rs
|
||||
|
||||
use crate::canvas::state::{CanvasState, ActionContext};
|
||||
use crate::autocomplete::state::AutocompleteCanvasState;
|
||||
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
||||
use crate::canvas::actions::edit::handle_generic_canvas_action; // Import the core function
|
||||
use anyhow::Result;
|
||||
|
||||
/// Version for states that implement rich autocomplete
|
||||
pub async fn execute_canvas_action_with_autocomplete<S: CanvasState + AutocompleteCanvasState>(
|
||||
action: CanvasAction,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<ActionResult> {
|
||||
// 1. Try feature-specific handler first
|
||||
let context = ActionContext {
|
||||
key_code: None,
|
||||
ideal_cursor_column: *ideal_cursor_column,
|
||||
current_input: state.get_current_input().to_string(),
|
||||
current_field: state.current_field(),
|
||||
};
|
||||
|
||||
if let Some(result) = state.handle_feature_action(&action, &context) {
|
||||
return Ok(ActionResult::HandledByFeature(result));
|
||||
}
|
||||
|
||||
// 2. Handle rich autocomplete actions
|
||||
if let Some(result) = handle_rich_autocomplete_action(&action, state)? {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// 3. Handle generic canvas actions
|
||||
handle_generic_canvas_action(action, state, ideal_cursor_column).await
|
||||
}
|
||||
|
||||
/// Handle rich autocomplete actions for AutocompleteCanvasState
|
||||
fn handle_rich_autocomplete_action<S: CanvasState + AutocompleteCanvasState>(
|
||||
action: &CanvasAction,
|
||||
state: &mut S,
|
||||
) -> Result<Option<ActionResult>> {
|
||||
match action {
|
||||
CanvasAction::TriggerAutocomplete => {
|
||||
if state.supports_autocomplete(state.current_field()) {
|
||||
state.activate_autocomplete();
|
||||
Ok(Some(ActionResult::success_with_message("Autocomplete activated - fetching suggestions...")))
|
||||
} else {
|
||||
Ok(Some(ActionResult::error("Autocomplete not supported for this field")))
|
||||
}
|
||||
}
|
||||
|
||||
CanvasAction::SuggestionDown => {
|
||||
if state.is_autocomplete_ready() {
|
||||
if let Some(autocomplete_state) = state.autocomplete_state_mut() {
|
||||
autocomplete_state.select_next();
|
||||
return Ok(Some(ActionResult::success()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
CanvasAction::SuggestionUp => {
|
||||
if state.is_autocomplete_ready() {
|
||||
if let Some(autocomplete_state) = state.autocomplete_state_mut() {
|
||||
autocomplete_state.select_previous();
|
||||
return Ok(Some(ActionResult::success()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
CanvasAction::SelectSuggestion => {
|
||||
if state.is_autocomplete_ready() {
|
||||
if let Some(message) = state.apply_autocomplete_selection() {
|
||||
return Ok(Some(ActionResult::success_with_message(message)));
|
||||
} else {
|
||||
return Ok(Some(ActionResult::error("No suggestion selected")));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
CanvasAction::ExitSuggestions => {
|
||||
if state.is_autocomplete_active() {
|
||||
state.deactivate_autocomplete();
|
||||
Ok(Some(ActionResult::success_with_message("Autocomplete cancelled")))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
195
canvas/src/autocomplete/gui.rs
Normal file
195
canvas/src/autocomplete/gui.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
// canvas/src/autocomplete/gui.rs
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use ratatui::{
|
||||
layout::{Alignment, Rect},
|
||||
style::{Modifier, Style},
|
||||
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::autocomplete::types::AutocompleteState;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use crate::canvas::theme::CanvasTheme;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// Render autocomplete dropdown - call this AFTER rendering canvas
|
||||
#[cfg(feature = "gui")]
|
||||
pub fn render_autocomplete_dropdown<T: CanvasTheme>(
|
||||
f: &mut Frame,
|
||||
frame_area: Rect,
|
||||
input_rect: Rect,
|
||||
theme: &T,
|
||||
autocomplete_state: &AutocompleteState<impl Clone + Send + 'static>,
|
||||
) {
|
||||
if !autocomplete_state.is_active {
|
||||
return;
|
||||
}
|
||||
|
||||
if autocomplete_state.is_loading {
|
||||
render_loading_indicator(f, frame_area, input_rect, theme);
|
||||
} else if !autocomplete_state.suggestions.is_empty() {
|
||||
render_suggestions_dropdown(f, frame_area, input_rect, theme, autocomplete_state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show loading spinner/text
|
||||
#[cfg(feature = "gui")]
|
||||
fn render_loading_indicator<T: CanvasTheme>(
|
||||
f: &mut Frame,
|
||||
frame_area: Rect,
|
||||
input_rect: Rect,
|
||||
theme: &T,
|
||||
) {
|
||||
let loading_text = "Loading suggestions...";
|
||||
let loading_width = loading_text.width() as u16 + 4; // +4 for borders and padding
|
||||
let loading_height = 3;
|
||||
|
||||
let dropdown_area = calculate_dropdown_position(
|
||||
input_rect,
|
||||
frame_area,
|
||||
loading_width,
|
||||
loading_height,
|
||||
);
|
||||
|
||||
let loading_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme.accent()))
|
||||
.style(Style::default().bg(theme.bg()));
|
||||
|
||||
let loading_paragraph = Paragraph::new(loading_text)
|
||||
.block(loading_block)
|
||||
.style(Style::default().fg(theme.fg()))
|
||||
.alignment(Alignment::Center);
|
||||
|
||||
f.render_widget(loading_paragraph, dropdown_area);
|
||||
}
|
||||
|
||||
/// Show actual suggestions list
|
||||
#[cfg(feature = "gui")]
|
||||
fn render_suggestions_dropdown<T: CanvasTheme>(
|
||||
f: &mut Frame,
|
||||
frame_area: Rect,
|
||||
input_rect: Rect,
|
||||
theme: &T,
|
||||
autocomplete_state: &AutocompleteState<impl Clone + Send + 'static>,
|
||||
) {
|
||||
let display_texts: Vec<&str> = autocomplete_state.suggestions
|
||||
.iter()
|
||||
.map(|item| item.display_text.as_str())
|
||||
.collect();
|
||||
|
||||
let dropdown_dimensions = calculate_dropdown_dimensions(&display_texts);
|
||||
let dropdown_area = calculate_dropdown_position(
|
||||
input_rect,
|
||||
frame_area,
|
||||
dropdown_dimensions.width,
|
||||
dropdown_dimensions.height,
|
||||
);
|
||||
|
||||
// Background
|
||||
let dropdown_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme.accent()))
|
||||
.style(Style::default().bg(theme.bg()));
|
||||
|
||||
// List items
|
||||
let items = create_suggestion_list_items(
|
||||
&display_texts,
|
||||
autocomplete_state.selected_index,
|
||||
dropdown_dimensions.width,
|
||||
theme,
|
||||
);
|
||||
|
||||
let list = List::new(items).block(dropdown_block);
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(autocomplete_state.selected_index);
|
||||
|
||||
f.render_stateful_widget(list, dropdown_area, &mut list_state);
|
||||
}
|
||||
|
||||
/// Calculate dropdown size based on suggestions
|
||||
#[cfg(feature = "gui")]
|
||||
fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions {
|
||||
let max_width = display_texts
|
||||
.iter()
|
||||
.map(|text| text.width())
|
||||
.max()
|
||||
.unwrap_or(0) as u16;
|
||||
|
||||
let horizontal_padding = 4; // borders + padding
|
||||
let width = (max_width + horizontal_padding).max(12);
|
||||
let height = (display_texts.len() as u16).min(8) + 2; // max 8 visible items + borders
|
||||
|
||||
DropdownDimensions { width, height }
|
||||
}
|
||||
|
||||
/// Position dropdown to stay in bounds
|
||||
#[cfg(feature = "gui")]
|
||||
fn calculate_dropdown_position(
|
||||
input_rect: Rect,
|
||||
frame_area: Rect,
|
||||
dropdown_width: u16,
|
||||
dropdown_height: u16,
|
||||
) -> Rect {
|
||||
let mut dropdown_area = Rect {
|
||||
x: input_rect.x,
|
||||
y: input_rect.y + 1, // below input field
|
||||
width: dropdown_width,
|
||||
height: dropdown_height,
|
||||
};
|
||||
|
||||
// Keep in bounds
|
||||
if dropdown_area.bottom() > frame_area.height {
|
||||
dropdown_area.y = input_rect.y.saturating_sub(dropdown_height);
|
||||
}
|
||||
if dropdown_area.right() > frame_area.width {
|
||||
dropdown_area.x = frame_area.width.saturating_sub(dropdown_width);
|
||||
}
|
||||
dropdown_area.x = dropdown_area.x.max(0);
|
||||
dropdown_area.y = dropdown_area.y.max(0);
|
||||
|
||||
dropdown_area
|
||||
}
|
||||
|
||||
/// Create styled list items
|
||||
#[cfg(feature = "gui")]
|
||||
fn create_suggestion_list_items<'a, T: CanvasTheme>(
|
||||
display_texts: &'a [&'a str],
|
||||
selected_index: Option<usize>,
|
||||
dropdown_width: u16,
|
||||
theme: &T,
|
||||
) -> Vec<ListItem<'a>> {
|
||||
let horizontal_padding = 4;
|
||||
let available_width = dropdown_width.saturating_sub(horizontal_padding);
|
||||
|
||||
display_texts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, text)| {
|
||||
let is_selected = selected_index == Some(i);
|
||||
let text_width = text.width() as u16;
|
||||
let padding_needed = available_width.saturating_sub(text_width);
|
||||
let padded_text = format!("{}{}", text, " ".repeat(padding_needed as usize));
|
||||
|
||||
ListItem::new(padded_text).style(if is_selected {
|
||||
Style::default()
|
||||
.fg(theme.bg())
|
||||
.bg(theme.highlight())
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.fg()).bg(theme.bg())
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Helper struct for dropdown dimensions
|
||||
#[cfg(feature = "gui")]
|
||||
struct DropdownDimensions {
|
||||
width: u16,
|
||||
height: u16,
|
||||
}
|
||||
10
canvas/src/autocomplete/mod.rs
Normal file
10
canvas/src/autocomplete/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
// src/autocomplete/mod.rs
|
||||
pub mod types;
|
||||
pub mod gui;
|
||||
pub mod state;
|
||||
pub mod actions;
|
||||
|
||||
// Re-export autocomplete types
|
||||
pub use types::{SuggestionItem, AutocompleteState};
|
||||
pub use state::AutocompleteCanvasState;
|
||||
pub use actions::execute_canvas_action_with_autocomplete;
|
||||
96
canvas/src/autocomplete/state.rs
Normal file
96
canvas/src/autocomplete/state.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
// canvas/src/state.rs
|
||||
|
||||
use crate::canvas::state::CanvasState;
|
||||
|
||||
/// OPTIONAL extension trait for states that want rich autocomplete functionality.
|
||||
/// Only implement this if you need the new autocomplete features.
|
||||
pub trait AutocompleteCanvasState: CanvasState {
|
||||
/// Associated type for suggestion data (e.g., Hit, String, CustomType)
|
||||
type SuggestionData: Clone + Send + 'static;
|
||||
|
||||
/// Check if a field supports autocomplete
|
||||
fn supports_autocomplete(&self, _field_index: usize) -> bool {
|
||||
false // Default: no autocomplete support
|
||||
}
|
||||
|
||||
/// Get autocomplete state (read-only)
|
||||
fn autocomplete_state(&self) -> Option<&crate::autocomplete::AutocompleteState<Self::SuggestionData>> {
|
||||
None // Default: no autocomplete state
|
||||
}
|
||||
|
||||
/// Get autocomplete state (mutable)
|
||||
fn autocomplete_state_mut(&mut self) -> Option<&mut crate::autocomplete::AutocompleteState<Self::SuggestionData>> {
|
||||
None // Default: no autocomplete state
|
||||
}
|
||||
|
||||
/// CLIENT API: Activate autocomplete for current field
|
||||
fn activate_autocomplete(&mut self) {
|
||||
let current_field = self.current_field(); // Get field first
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
state.activate(current_field); // Then use it
|
||||
}
|
||||
}
|
||||
|
||||
/// CLIENT API: Deactivate autocomplete
|
||||
fn deactivate_autocomplete(&mut self) {
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
state.deactivate();
|
||||
}
|
||||
}
|
||||
|
||||
/// CLIENT API: Set suggestions (called after async fetch completes)
|
||||
fn set_autocomplete_suggestions(&mut self, suggestions: Vec<crate::autocomplete::SuggestionItem<Self::SuggestionData>>) {
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
state.set_suggestions(suggestions);
|
||||
}
|
||||
}
|
||||
|
||||
/// CLIENT API: Set loading state
|
||||
fn set_autocomplete_loading(&mut self, loading: bool) {
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
state.is_loading = loading;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if autocomplete is currently active
|
||||
fn is_autocomplete_active(&self) -> bool {
|
||||
self.autocomplete_state()
|
||||
.map(|state| state.is_active)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check if autocomplete is ready for interaction
|
||||
fn is_autocomplete_ready(&self) -> bool {
|
||||
self.autocomplete_state()
|
||||
.map(|state| state.is_ready())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// INTERNAL: Apply selected autocomplete value to current field
|
||||
fn apply_autocomplete_selection(&mut self) -> Option<String> {
|
||||
// First, get the selected value and display text (if any)
|
||||
let selection_info = if let Some(state) = self.autocomplete_state() {
|
||||
state.get_selected().map(|selected| {
|
||||
(selected.value_to_store.clone(), selected.display_text.clone())
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Apply the selection if we have one
|
||||
if let Some((value, display)) = selection_info {
|
||||
// Apply the value to current field
|
||||
*self.get_current_input_mut() = value;
|
||||
self.set_has_unsaved_changes(true);
|
||||
|
||||
// Deactivate autocomplete
|
||||
if let Some(state_mut) = self.autocomplete_state_mut() {
|
||||
state_mut.deactivate();
|
||||
}
|
||||
|
||||
Some(format!("Selected: {}", display))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
126
canvas/src/autocomplete/types.rs
Normal file
126
canvas/src/autocomplete/types.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
// canvas/src/autocomplete.rs
|
||||
|
||||
/// Generic suggestion item that clients push to canvas
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SuggestionItem<T> {
|
||||
/// The underlying data (client-specific, e.g., Hit, String, etc.)
|
||||
pub data: T,
|
||||
/// Text to display in the dropdown
|
||||
pub display_text: String,
|
||||
/// Value to store in the form field when selected
|
||||
pub value_to_store: String,
|
||||
}
|
||||
|
||||
impl<T> SuggestionItem<T> {
|
||||
pub fn new(data: T, display_text: String, value_to_store: String) -> Self {
|
||||
Self {
|
||||
data,
|
||||
display_text,
|
||||
value_to_store,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience constructor for simple string suggestions
|
||||
pub fn simple(data: T, text: String) -> Self {
|
||||
Self {
|
||||
data,
|
||||
display_text: text.clone(),
|
||||
value_to_store: text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Autocomplete state managed by canvas
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutocompleteState<T> {
|
||||
/// Whether autocomplete is currently active/visible
|
||||
pub is_active: bool,
|
||||
/// Whether suggestions are being loaded (for spinner/loading indicator)
|
||||
pub is_loading: bool,
|
||||
/// Current suggestions to display
|
||||
pub suggestions: Vec<SuggestionItem<T>>,
|
||||
/// Currently selected suggestion index
|
||||
pub selected_index: Option<usize>,
|
||||
/// Field index that triggered autocomplete (for context)
|
||||
pub active_field: Option<usize>,
|
||||
}
|
||||
|
||||
impl<T> Default for AutocompleteState<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
is_active: false,
|
||||
is_loading: false,
|
||||
suggestions: Vec::new(),
|
||||
selected_index: None,
|
||||
active_field: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> AutocompleteState<T> {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Activate autocomplete for a specific field
|
||||
pub fn activate(&mut self, field_index: usize) {
|
||||
self.is_active = true;
|
||||
self.active_field = Some(field_index);
|
||||
self.selected_index = None;
|
||||
self.suggestions.clear();
|
||||
self.is_loading = true;
|
||||
}
|
||||
|
||||
/// Deactivate autocomplete and clear state
|
||||
pub fn deactivate(&mut self) {
|
||||
self.is_active = false;
|
||||
self.is_loading = false;
|
||||
self.suggestions.clear();
|
||||
self.selected_index = None;
|
||||
self.active_field = None;
|
||||
}
|
||||
|
||||
/// Set suggestions and stop loading
|
||||
pub fn set_suggestions(&mut self, suggestions: Vec<SuggestionItem<T>>) {
|
||||
self.suggestions = suggestions;
|
||||
self.is_loading = false;
|
||||
self.selected_index = if self.suggestions.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(0)
|
||||
};
|
||||
}
|
||||
|
||||
/// Move selection down
|
||||
pub fn select_next(&mut self) {
|
||||
if !self.suggestions.is_empty() {
|
||||
let current = self.selected_index.unwrap_or(0);
|
||||
self.selected_index = Some((current + 1) % self.suggestions.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// Move selection up
|
||||
pub fn select_previous(&mut self) {
|
||||
if !self.suggestions.is_empty() {
|
||||
let current = self.selected_index.unwrap_or(0);
|
||||
self.selected_index = Some(
|
||||
if current == 0 {
|
||||
self.suggestions.len() - 1
|
||||
} else {
|
||||
current - 1
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get currently selected suggestion
|
||||
pub fn get_selected(&self) -> Option<&SuggestionItem<T>> {
|
||||
self.selected_index
|
||||
.and_then(|idx| self.suggestions.get(idx))
|
||||
}
|
||||
|
||||
/// Check if autocomplete is ready for interaction (active and has suggestions)
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.is_active && !self.suggestions.is_empty() && !self.is_loading
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
// canvas/src/actions/edit.rs
|
||||
|
||||
use crate::state::{CanvasState, ActionContext};
|
||||
use crate::actions::types::{CanvasAction, ActionResult};
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use crate::canvas::state::{CanvasState, ActionContext};
|
||||
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Execute a typed canvas action on any CanvasState implementation
|
||||
@@ -11,9 +10,8 @@ pub async fn execute_canvas_action<S: CanvasState>(
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<ActionResult> {
|
||||
// 1. Try feature-specific handler first
|
||||
let context = ActionContext {
|
||||
key_code: None, // We don't need KeyCode anymore since action is typed
|
||||
key_code: None,
|
||||
ideal_cursor_column: *ideal_cursor_column,
|
||||
current_input: state.get_current_input().to_string(),
|
||||
current_field: state.current_field(),
|
||||
@@ -23,85 +21,11 @@ pub async fn execute_canvas_action<S: CanvasState>(
|
||||
return Ok(ActionResult::HandledByFeature(result));
|
||||
}
|
||||
|
||||
// 2. Handle suggestion actions
|
||||
if let Some(result) = handle_suggestion_action(&action, state)? {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// 3. Handle generic canvas actions
|
||||
handle_generic_canvas_action(action, state, ideal_cursor_column).await
|
||||
}
|
||||
|
||||
/// Legacy function for string-based actions (backwards compatibility)
|
||||
pub async fn execute_edit_action<S: CanvasState>(
|
||||
action: &str,
|
||||
key: KeyEvent,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<String> {
|
||||
let typed_action = match action {
|
||||
"insert_char" => {
|
||||
if let KeyCode::Char(c) = key.code {
|
||||
CanvasAction::InsertChar(c)
|
||||
} else {
|
||||
return Ok("Error: insert_char called without a char key.".to_string());
|
||||
}
|
||||
}
|
||||
_ => CanvasAction::from_string(action),
|
||||
};
|
||||
|
||||
let result = execute_canvas_action(typed_action, state, ideal_cursor_column).await?;
|
||||
|
||||
// Convert ActionResult back to string for backwards compatibility
|
||||
Ok(result.message().unwrap_or("").to_string())
|
||||
}
|
||||
|
||||
/// Handle suggestion-related actions
|
||||
fn handle_suggestion_action<S: CanvasState>(
|
||||
action: &CanvasAction,
|
||||
state: &mut S,
|
||||
) -> Result<Option<ActionResult>> {
|
||||
match action {
|
||||
CanvasAction::SuggestionDown => {
|
||||
if let Some(suggestions) = state.get_suggestions() {
|
||||
if !suggestions.is_empty() {
|
||||
let current = state.get_selected_suggestion_index().unwrap_or(0);
|
||||
let next = (current + 1) % suggestions.len();
|
||||
state.set_selected_suggestion_index(Some(next));
|
||||
return Ok(Some(ActionResult::success()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
CanvasAction::SuggestionUp => {
|
||||
if let Some(suggestions) = state.get_suggestions() {
|
||||
if !suggestions.is_empty() {
|
||||
let current = state.get_selected_suggestion_index().unwrap_or(0);
|
||||
let prev = if current == 0 { suggestions.len() - 1 } else { current - 1 };
|
||||
state.set_selected_suggestion_index(Some(prev));
|
||||
return Ok(Some(ActionResult::success()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
CanvasAction::SelectSuggestion => {
|
||||
// Let feature handle this via handle_feature_action since it's feature-specific
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
CanvasAction::ExitSuggestions => {
|
||||
state.deactivate_suggestions();
|
||||
Ok(Some(ActionResult::success()))
|
||||
}
|
||||
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle core canvas actions with full type safety
|
||||
async fn handle_generic_canvas_action<S: CanvasState>(
|
||||
pub async fn handle_generic_canvas_action<S: CanvasState>(
|
||||
action: CanvasAction,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
@@ -111,7 +35,7 @@ async fn handle_generic_canvas_action<S: CanvasState>(
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
let field_value = state.get_current_input_mut();
|
||||
let mut chars: Vec<char> = field_value.chars().collect();
|
||||
|
||||
|
||||
if cursor_pos <= chars.len() {
|
||||
chars.insert(cursor_pos, c);
|
||||
*field_value = chars.into_iter().collect();
|
||||
@@ -129,7 +53,7 @@ async fn handle_generic_canvas_action<S: CanvasState>(
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
let field_value = state.get_current_input_mut();
|
||||
let mut chars: Vec<char> = field_value.chars().collect();
|
||||
|
||||
|
||||
if cursor_pos <= chars.len() {
|
||||
chars.remove(cursor_pos - 1);
|
||||
*field_value = chars.into_iter().collect();
|
||||
@@ -146,7 +70,7 @@ async fn handle_generic_canvas_action<S: CanvasState>(
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
let field_value = state.get_current_input_mut();
|
||||
let mut chars: Vec<char> = field_value.chars().collect();
|
||||
|
||||
|
||||
if cursor_pos < chars.len() {
|
||||
chars.remove(cursor_pos);
|
||||
*field_value = chars.into_iter().collect();
|
||||
@@ -321,16 +245,15 @@ async fn handle_generic_canvas_action<S: CanvasState>(
|
||||
Ok(ActionResult::error(format!("Unknown or unhandled custom action: {}", action_str)))
|
||||
}
|
||||
|
||||
// Suggestion actions should have been handled above
|
||||
CanvasAction::SuggestionUp | CanvasAction::SuggestionDown |
|
||||
// Autocomplete actions are handled by the autocomplete module
|
||||
CanvasAction::TriggerAutocomplete | CanvasAction::SuggestionUp | CanvasAction::SuggestionDown |
|
||||
CanvasAction::SelectSuggestion | CanvasAction::ExitSuggestions => {
|
||||
Ok(ActionResult::error("Suggestion action not handled properly"))
|
||||
Ok(ActionResult::error("Autocomplete actions should be handled by autocomplete module"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Word movement helper functions
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum CharType {
|
||||
Whitespace,
|
||||
@@ -1,8 +1,7 @@
|
||||
// canvas/src/actions/mod.rs
|
||||
|
||||
// canvas/src/canvas/actions/mod.rs
|
||||
pub mod types;
|
||||
pub mod edit;
|
||||
|
||||
// Re-export the main types for convenience
|
||||
pub use types::{CanvasAction, ActionResult};
|
||||
pub use edit::{execute_canvas_action, execute_edit_action};
|
||||
pub use edit::execute_canvas_action; // Remove execute_edit_action
|
||||
@@ -7,39 +7,45 @@ use crossterm::event::KeyCode;
|
||||
pub enum CanvasAction {
|
||||
// Character input
|
||||
InsertChar(char),
|
||||
|
||||
|
||||
// Deletion
|
||||
DeleteBackward,
|
||||
DeleteForward,
|
||||
|
||||
|
||||
// Basic cursor movement
|
||||
MoveLeft,
|
||||
MoveRight,
|
||||
MoveUp,
|
||||
MoveDown,
|
||||
|
||||
|
||||
// Line movement
|
||||
MoveLineStart,
|
||||
MoveLineEnd,
|
||||
MoveFirstLine,
|
||||
MoveLastLine,
|
||||
|
||||
|
||||
// Word movement
|
||||
MoveWordNext,
|
||||
MoveWordEnd,
|
||||
MoveWordPrev,
|
||||
MoveWordEndPrev,
|
||||
|
||||
|
||||
// Field navigation
|
||||
NextField,
|
||||
PrevField,
|
||||
|
||||
// Suggestions
|
||||
|
||||
// AUTOCOMPLETE ACTIONS (NEW)
|
||||
/// Manually trigger autocomplete for current field
|
||||
TriggerAutocomplete,
|
||||
/// Move to next suggestion
|
||||
SuggestionUp,
|
||||
/// Move to previous suggestion
|
||||
SuggestionDown,
|
||||
/// Select the currently highlighted suggestion
|
||||
SelectSuggestion,
|
||||
/// Cancel/exit autocomplete mode
|
||||
ExitSuggestions,
|
||||
|
||||
|
||||
// Custom actions (escape hatch for feature-specific behavior)
|
||||
Custom(String),
|
||||
}
|
||||
@@ -69,6 +75,8 @@ impl CanvasAction {
|
||||
"move_word_end_prev" => Self::MoveWordEndPrev,
|
||||
"next_field" => Self::NextField,
|
||||
"prev_field" => Self::PrevField,
|
||||
// Autocomplete actions
|
||||
"trigger_autocomplete" => Self::TriggerAutocomplete,
|
||||
"suggestion_up" => Self::SuggestionUp,
|
||||
"suggestion_down" => Self::SuggestionDown,
|
||||
"select_suggestion" => Self::SelectSuggestion,
|
||||
@@ -76,7 +84,7 @@ impl CanvasAction {
|
||||
_ => Self::Custom(action.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Get string representation (for logging, debugging)
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
@@ -97,6 +105,8 @@ impl CanvasAction {
|
||||
Self::MoveWordEndPrev => "move_word_end_prev",
|
||||
Self::NextField => "next_field",
|
||||
Self::PrevField => "prev_field",
|
||||
// Autocomplete actions
|
||||
Self::TriggerAutocomplete => "trigger_autocomplete",
|
||||
Self::SuggestionUp => "suggestion_up",
|
||||
Self::SuggestionDown => "suggestion_down",
|
||||
Self::SelectSuggestion => "select_suggestion",
|
||||
@@ -104,7 +114,7 @@ impl CanvasAction {
|
||||
Self::Custom(s) => s,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Create action from KeyCode for common cases
|
||||
pub fn from_key(key: KeyCode) -> Option<Self> {
|
||||
match key {
|
||||
@@ -122,17 +132,17 @@ impl CanvasAction {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Check if this action modifies content
|
||||
pub fn is_modifying(&self) -> bool {
|
||||
matches!(self,
|
||||
Self::InsertChar(_) |
|
||||
Self::DeleteBackward |
|
||||
matches!(self,
|
||||
Self::InsertChar(_) |
|
||||
Self::DeleteBackward |
|
||||
Self::DeleteForward |
|
||||
Self::SelectSuggestion
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/// Check if this action moves the cursor
|
||||
pub fn is_movement(&self) -> bool {
|
||||
matches!(self,
|
||||
@@ -142,11 +152,11 @@ impl CanvasAction {
|
||||
Self::NextField | Self::PrevField
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/// Check if this is a suggestion-related action
|
||||
pub fn is_suggestion(&self) -> bool {
|
||||
matches!(self,
|
||||
Self::SuggestionUp | Self::SuggestionDown |
|
||||
Self::TriggerAutocomplete | Self::SuggestionUp | Self::SuggestionDown |
|
||||
Self::SelectSuggestion | Self::ExitSuggestions
|
||||
)
|
||||
}
|
||||
@@ -169,19 +179,19 @@ impl ActionResult {
|
||||
pub fn success() -> Self {
|
||||
Self::Success(None)
|
||||
}
|
||||
|
||||
|
||||
pub fn success_with_message(msg: impl Into<String>) -> Self {
|
||||
Self::Success(Some(msg.into()))
|
||||
}
|
||||
|
||||
|
||||
pub fn error(msg: impl Into<String>) -> Self {
|
||||
Self::Error(msg.into())
|
||||
}
|
||||
|
||||
|
||||
pub fn is_success(&self) -> bool {
|
||||
matches!(self, Self::Success(_) | Self::HandledByFeature(_))
|
||||
}
|
||||
|
||||
|
||||
pub fn message(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Success(msg) => msg.as_deref(),
|
||||
@@ -195,14 +205,15 @@ impl ActionResult {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
#[test]
|
||||
fn test_action_from_string() {
|
||||
assert_eq!(CanvasAction::from_string("move_left"), CanvasAction::MoveLeft);
|
||||
assert_eq!(CanvasAction::from_string("delete_char_backward"), CanvasAction::DeleteBackward);
|
||||
assert_eq!(CanvasAction::from_string("trigger_autocomplete"), CanvasAction::TriggerAutocomplete);
|
||||
assert_eq!(CanvasAction::from_string("unknown"), CanvasAction::Custom("unknown".to_string()));
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn test_action_from_key() {
|
||||
assert_eq!(CanvasAction::from_key(KeyCode::Char('a')), Some(CanvasAction::InsertChar('a')));
|
||||
@@ -210,16 +221,17 @@ mod tests {
|
||||
assert_eq!(CanvasAction::from_key(KeyCode::Backspace), Some(CanvasAction::DeleteBackward));
|
||||
assert_eq!(CanvasAction::from_key(KeyCode::F(1)), None);
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn test_action_properties() {
|
||||
assert!(CanvasAction::InsertChar('a').is_modifying());
|
||||
assert!(!CanvasAction::MoveLeft.is_modifying());
|
||||
|
||||
|
||||
assert!(CanvasAction::MoveLeft.is_movement());
|
||||
assert!(!CanvasAction::InsertChar('a').is_movement());
|
||||
|
||||
|
||||
assert!(CanvasAction::SuggestionUp.is_suggestion());
|
||||
assert!(CanvasAction::TriggerAutocomplete.is_suggestion());
|
||||
assert!(!CanvasAction::MoveLeft.is_suggestion());
|
||||
}
|
||||
}
|
||||
338
canvas/src/canvas/gui.rs
Normal file
338
canvas/src/canvas/gui.rs
Normal file
@@ -0,0 +1,338 @@
|
||||
// canvas/src/canvas/gui.rs
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, BorderType, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::canvas::state::CanvasState;
|
||||
use crate::canvas::modes::HighlightState;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use crate::canvas::theme::CanvasTheme;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use std::cmp::{max, min};
|
||||
|
||||
/// Render ONLY the canvas form fields - no autocomplete
|
||||
#[cfg(feature = "gui")]
|
||||
pub fn render_canvas<T: CanvasTheme>(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
form_state: &impl CanvasState,
|
||||
theme: &T,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
) -> Option<Rect> {
|
||||
let fields: Vec<&str> = form_state.fields();
|
||||
let current_field_idx = form_state.current_field();
|
||||
let inputs: Vec<&String> = form_state.inputs();
|
||||
|
||||
render_canvas_fields(
|
||||
f,
|
||||
area,
|
||||
&fields,
|
||||
¤t_field_idx,
|
||||
&inputs,
|
||||
theme,
|
||||
is_edit_mode,
|
||||
highlight_state,
|
||||
form_state.current_cursor_pos(),
|
||||
form_state.has_unsaved_changes(),
|
||||
|i| form_state.get_display_value_for_field(i).to_string(),
|
||||
|i| form_state.has_display_override(i),
|
||||
)
|
||||
}
|
||||
|
||||
/// Core canvas field rendering
|
||||
#[cfg(feature = "gui")]
|
||||
fn render_canvas_fields<T: CanvasTheme, F1, F2>(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
fields: &[&str],
|
||||
current_field_idx: &usize,
|
||||
inputs: &[&String],
|
||||
theme: &T,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
current_cursor_pos: usize,
|
||||
has_unsaved_changes: bool,
|
||||
get_display_value: F1,
|
||||
has_display_override: F2,
|
||||
) -> Option<Rect>
|
||||
where
|
||||
F1: Fn(usize) -> String,
|
||||
F2: Fn(usize) -> bool,
|
||||
{
|
||||
// Create layout
|
||||
let columns = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
|
||||
.split(area);
|
||||
|
||||
// Border style based on state
|
||||
let border_style = if has_unsaved_changes {
|
||||
Style::default().fg(theme.warning())
|
||||
} else if is_edit_mode {
|
||||
Style::default().fg(theme.accent())
|
||||
} else {
|
||||
Style::default().fg(theme.secondary())
|
||||
};
|
||||
|
||||
// Input container
|
||||
let input_container = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(border_style)
|
||||
.style(Style::default().bg(theme.bg()));
|
||||
|
||||
let input_block = Rect {
|
||||
x: columns[1].x,
|
||||
y: columns[1].y,
|
||||
width: columns[1].width,
|
||||
height: fields.len() as u16 + 2,
|
||||
};
|
||||
|
||||
f.render_widget(&input_container, input_block);
|
||||
|
||||
// Input area layout
|
||||
let input_area = input_container.inner(input_block);
|
||||
let input_rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(vec![Constraint::Length(1); fields.len()])
|
||||
.split(input_area);
|
||||
|
||||
// Render field labels
|
||||
render_field_labels(f, columns[0], input_block, fields, theme);
|
||||
|
||||
// Render field values and return active field rect
|
||||
render_field_values(
|
||||
f,
|
||||
input_rows.to_vec(), // Fix: Convert Rc<[Rect]> to Vec<Rect>
|
||||
inputs,
|
||||
current_field_idx,
|
||||
theme,
|
||||
highlight_state,
|
||||
current_cursor_pos,
|
||||
get_display_value,
|
||||
has_display_override,
|
||||
)
|
||||
}
|
||||
|
||||
/// Render field labels
|
||||
#[cfg(feature = "gui")]
|
||||
fn render_field_labels<T: CanvasTheme>(
|
||||
f: &mut Frame,
|
||||
label_area: Rect,
|
||||
input_block: Rect,
|
||||
fields: &[&str],
|
||||
theme: &T,
|
||||
) {
|
||||
for (i, field) in fields.iter().enumerate() {
|
||||
let label = Paragraph::new(Line::from(Span::styled(
|
||||
format!("{}:", field),
|
||||
Style::default().fg(theme.fg()),
|
||||
)));
|
||||
f.render_widget(
|
||||
label,
|
||||
Rect {
|
||||
x: label_area.x,
|
||||
y: input_block.y + 1 + i as u16,
|
||||
width: label_area.width,
|
||||
height: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render field values with highlighting
|
||||
#[cfg(feature = "gui")]
|
||||
fn render_field_values<T: CanvasTheme, F1, F2>(
|
||||
f: &mut Frame,
|
||||
input_rows: Vec<Rect>,
|
||||
inputs: &[&String],
|
||||
current_field_idx: &usize,
|
||||
theme: &T,
|
||||
highlight_state: &HighlightState,
|
||||
current_cursor_pos: usize,
|
||||
get_display_value: F1,
|
||||
has_display_override: F2,
|
||||
) -> Option<Rect>
|
||||
where
|
||||
F1: Fn(usize) -> String,
|
||||
F2: Fn(usize) -> bool,
|
||||
{
|
||||
let mut active_field_input_rect = None;
|
||||
|
||||
for (i, _input) in inputs.iter().enumerate() {
|
||||
let is_active = i == *current_field_idx;
|
||||
let text = get_display_value(i);
|
||||
|
||||
// Apply highlighting
|
||||
let line = apply_highlighting(
|
||||
&text,
|
||||
i,
|
||||
current_field_idx,
|
||||
current_cursor_pos,
|
||||
highlight_state,
|
||||
theme,
|
||||
is_active,
|
||||
);
|
||||
|
||||
let input_display = Paragraph::new(line).alignment(Alignment::Left);
|
||||
f.render_widget(input_display, input_rows[i]);
|
||||
|
||||
// Set cursor for active field
|
||||
if is_active {
|
||||
active_field_input_rect = Some(input_rows[i]);
|
||||
set_cursor_position(f, input_rows[i], &text, current_cursor_pos, has_display_override(i));
|
||||
}
|
||||
}
|
||||
|
||||
active_field_input_rect
|
||||
}
|
||||
|
||||
/// Apply highlighting based on highlight state
|
||||
#[cfg(feature = "gui")]
|
||||
fn apply_highlighting<'a, T: CanvasTheme>(
|
||||
text: &'a str,
|
||||
field_index: usize,
|
||||
current_field_idx: &usize,
|
||||
current_cursor_pos: usize,
|
||||
highlight_state: &HighlightState,
|
||||
theme: &T,
|
||||
is_active: bool,
|
||||
) -> Line<'a> {
|
||||
let text_len = text.chars().count();
|
||||
|
||||
match highlight_state {
|
||||
HighlightState::Off => {
|
||||
Line::from(Span::styled(
|
||||
text,
|
||||
if is_active {
|
||||
Style::default().fg(theme.highlight())
|
||||
} else {
|
||||
Style::default().fg(theme.fg())
|
||||
},
|
||||
))
|
||||
}
|
||||
HighlightState::Characterwise { anchor } => {
|
||||
apply_characterwise_highlighting(text, text_len, field_index, current_field_idx, current_cursor_pos, anchor, theme, is_active)
|
||||
}
|
||||
HighlightState::Linewise { anchor_line } => {
|
||||
apply_linewise_highlighting(text, field_index, current_field_idx, anchor_line, theme, is_active)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply characterwise highlighting
|
||||
#[cfg(feature = "gui")]
|
||||
fn apply_characterwise_highlighting<'a, T: CanvasTheme>(
|
||||
text: &'a str,
|
||||
text_len: usize,
|
||||
field_index: usize,
|
||||
current_field_idx: &usize,
|
||||
current_cursor_pos: usize,
|
||||
anchor: &(usize, usize),
|
||||
theme: &T,
|
||||
is_active: bool,
|
||||
) -> Line<'a> {
|
||||
let (anchor_field, anchor_char) = *anchor;
|
||||
let start_field = min(anchor_field, *current_field_idx);
|
||||
let end_field = max(anchor_field, *current_field_idx);
|
||||
|
||||
let highlight_style = Style::default()
|
||||
.fg(theme.highlight())
|
||||
.bg(theme.highlight_bg())
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let normal_style_in_highlight = Style::default().fg(theme.highlight());
|
||||
let normal_style_outside = Style::default().fg(theme.fg());
|
||||
|
||||
if field_index >= start_field && field_index <= end_field {
|
||||
if start_field == end_field {
|
||||
let (start_char, end_char) = if anchor_field == *current_field_idx {
|
||||
(min(anchor_char, current_cursor_pos), max(anchor_char, current_cursor_pos))
|
||||
} else if anchor_field < *current_field_idx {
|
||||
(anchor_char, current_cursor_pos)
|
||||
} else {
|
||||
(current_cursor_pos, anchor_char)
|
||||
};
|
||||
|
||||
let clamped_start = start_char.min(text_len);
|
||||
let clamped_end = end_char.min(text_len);
|
||||
|
||||
let before: String = text.chars().take(clamped_start).collect();
|
||||
let highlighted: String = text.chars()
|
||||
.skip(clamped_start)
|
||||
.take(clamped_end.saturating_sub(clamped_start) + 1)
|
||||
.collect();
|
||||
let after: String = text.chars().skip(clamped_end + 1).collect();
|
||||
|
||||
Line::from(vec![
|
||||
Span::styled(before, normal_style_in_highlight),
|
||||
Span::styled(highlighted, highlight_style),
|
||||
Span::styled(after, normal_style_in_highlight),
|
||||
])
|
||||
} else {
|
||||
// Multi-field selection
|
||||
Line::from(Span::styled(text, highlight_style))
|
||||
}
|
||||
} else {
|
||||
Line::from(Span::styled(
|
||||
text,
|
||||
if is_active { normal_style_in_highlight } else { normal_style_outside }
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply linewise highlighting
|
||||
#[cfg(feature = "gui")]
|
||||
fn apply_linewise_highlighting<'a, T: CanvasTheme>(
|
||||
text: &'a str,
|
||||
field_index: usize,
|
||||
current_field_idx: &usize,
|
||||
anchor_line: &usize,
|
||||
theme: &T,
|
||||
is_active: bool,
|
||||
) -> Line<'a> {
|
||||
let start_field = min(*anchor_line, *current_field_idx);
|
||||
let end_field = max(*anchor_line, *current_field_idx);
|
||||
|
||||
let highlight_style = Style::default()
|
||||
.fg(theme.highlight())
|
||||
.bg(theme.highlight_bg())
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let normal_style_in_highlight = Style::default().fg(theme.highlight());
|
||||
let normal_style_outside = Style::default().fg(theme.fg());
|
||||
|
||||
if field_index >= start_field && field_index <= end_field {
|
||||
Line::from(Span::styled(text, highlight_style))
|
||||
} else {
|
||||
Line::from(Span::styled(
|
||||
text,
|
||||
if is_active { normal_style_in_highlight } else { normal_style_outside }
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Set cursor position
|
||||
#[cfg(feature = "gui")]
|
||||
fn set_cursor_position(
|
||||
f: &mut Frame,
|
||||
field_rect: Rect,
|
||||
text: &str,
|
||||
current_cursor_pos: usize,
|
||||
has_display_override: bool,
|
||||
) {
|
||||
let cursor_x = if has_display_override {
|
||||
field_rect.x + text.chars().count() as u16
|
||||
} else {
|
||||
field_rect.x + current_cursor_pos as u16
|
||||
};
|
||||
let cursor_y = field_rect.y;
|
||||
f.set_cursor_position((cursor_x, cursor_y));
|
||||
}
|
||||
17
canvas/src/canvas/mod.rs
Normal file
17
canvas/src/canvas/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
// src/canvas/mod.rs
|
||||
pub mod actions;
|
||||
pub mod modes;
|
||||
pub mod gui;
|
||||
pub mod theme;
|
||||
pub mod state;
|
||||
|
||||
// Re-export commonly used canvas types
|
||||
pub use actions::{CanvasAction, ActionResult};
|
||||
pub use modes::{AppMode, ModeManager, HighlightState};
|
||||
pub use state::{CanvasState, ActionContext};
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
pub use theme::CanvasTheme;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
pub use gui::render_canvas;
|
||||
@@ -1,6 +1,6 @@
|
||||
// canvas/src/state.rs
|
||||
|
||||
use crate::actions::CanvasAction;
|
||||
use crate::canvas::actions::CanvasAction;
|
||||
|
||||
/// Context passed to feature-specific action handlers
|
||||
#[derive(Debug)]
|
||||
@@ -31,45 +31,13 @@ pub trait CanvasState {
|
||||
fn has_unsaved_changes(&self) -> bool;
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool);
|
||||
|
||||
// --- Autocomplete/Suggestions (Optional) ---
|
||||
fn get_suggestions(&self) -> Option<&[String]> {
|
||||
None
|
||||
}
|
||||
fn get_selected_suggestion_index(&self) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
fn set_selected_suggestion_index(&mut self, _index: Option<usize>) {
|
||||
// Default: no-op (override if you support suggestions)
|
||||
}
|
||||
fn activate_suggestions(&mut self, _suggestions: Vec<String>) {
|
||||
// Default: no-op (override if you support suggestions)
|
||||
}
|
||||
fn deactivate_suggestions(&mut self) {
|
||||
// Default: no-op (override if you support suggestions)
|
||||
}
|
||||
// --- Feature-specific action handling ---
|
||||
|
||||
// --- Feature-specific action handling (NEW: Type-safe) ---
|
||||
/// Feature-specific action handling (NEW: Type-safe)
|
||||
fn handle_feature_action(&mut self, _action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
None // Default: no feature-specific handling
|
||||
}
|
||||
|
||||
// --- Legacy string-based action handling (for backwards compatibility) ---
|
||||
fn handle_feature_action_legacy(&mut self, action: &str, context: &ActionContext) -> Option<String> {
|
||||
// Convert string to typed action and delegate
|
||||
let typed_action = match action {
|
||||
"insert_char" => {
|
||||
// This is tricky - we need the char from the KeyCode in context
|
||||
if let Some(crossterm::event::KeyCode::Char(c)) = context.key_code {
|
||||
CanvasAction::InsertChar(c)
|
||||
} else {
|
||||
CanvasAction::Custom(action.to_string())
|
||||
}
|
||||
}
|
||||
_ => CanvasAction::from_string(action),
|
||||
};
|
||||
self.handle_feature_action(&typed_action, context)
|
||||
}
|
||||
|
||||
// --- Display Overrides (for links, computed values, etc.) ---
|
||||
fn get_display_value_for_field(&self, index: usize) -> &str {
|
||||
self.inputs()
|
||||
@@ -77,6 +45,7 @@ pub trait CanvasState {
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
fn has_display_override(&self, _index: usize) -> bool {
|
||||
false
|
||||
}
|
||||
17
canvas/src/canvas/theme.rs
Normal file
17
canvas/src/canvas/theme.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
// canvas/src/gui/theme.rs
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use ratatui::style::Color;
|
||||
|
||||
/// Theme trait that must be implemented by applications using the canvas GUI
|
||||
#[cfg(feature = "gui")]
|
||||
pub trait CanvasTheme {
|
||||
fn bg(&self) -> Color;
|
||||
fn fg(&self) -> Color;
|
||||
fn border(&self) -> Color;
|
||||
fn accent(&self) -> Color;
|
||||
fn secondary(&self) -> Color;
|
||||
fn highlight(&self) -> Color;
|
||||
fn highlight_bg(&self) -> Color;
|
||||
fn warning(&self) -> Color;
|
||||
}
|
||||
@@ -476,5 +476,5 @@ impl CanvasConfig {
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
pub use crate::actions::CanvasAction;
|
||||
pub use crate::canvas::actions::CanvasAction;
|
||||
pub use crate::dispatcher::ActionDispatcher;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// canvas/src/dispatcher.rs
|
||||
|
||||
use crate::state::CanvasState;
|
||||
use crate::actions::{CanvasAction, ActionResult, execute_canvas_action};
|
||||
use crate::canvas::state::CanvasState;
|
||||
use crate::canvas::actions::{CanvasAction, ActionResult, execute_canvas_action};
|
||||
|
||||
/// High-level action dispatcher that coordinates between different action types
|
||||
pub struct ActionDispatcher;
|
||||
|
||||
@@ -1,36 +1,5 @@
|
||||
// canvas/src/lib.rs
|
||||
//! Canvas - A reusable text editing and form canvas system
|
||||
//!
|
||||
//! This crate provides a generic canvas abstraction for building text-based interfaces
|
||||
//! with multiple input fields, cursor management, and mode-based editing.
|
||||
|
||||
pub mod state;
|
||||
pub mod actions;
|
||||
pub mod modes;
|
||||
// src/lib.rs
|
||||
pub mod canvas;
|
||||
pub mod autocomplete;
|
||||
pub mod config;
|
||||
pub mod suggestions;
|
||||
pub mod dispatcher;
|
||||
|
||||
// Re-export the main types for easy use
|
||||
pub use state::{CanvasState, ActionContext};
|
||||
pub use actions::{CanvasAction, ActionResult, execute_edit_action, execute_canvas_action};
|
||||
pub use modes::{AppMode, ModeManager, HighlightState};
|
||||
pub use suggestions::SuggestionState;
|
||||
pub use dispatcher::ActionDispatcher;
|
||||
|
||||
// High-level convenience API
|
||||
pub mod prelude {
|
||||
pub use crate::{
|
||||
CanvasState,
|
||||
ActionContext,
|
||||
CanvasAction,
|
||||
ActionResult,
|
||||
execute_edit_action,
|
||||
execute_canvas_action,
|
||||
ActionDispatcher,
|
||||
AppMode,
|
||||
ModeManager,
|
||||
HighlightState,
|
||||
SuggestionState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
// canvas/src/suggestions.rs
|
||||
|
||||
/// Generic suggestion system that can be implemented by any CanvasState
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SuggestionState {
|
||||
pub suggestions: Vec<String>,
|
||||
pub selected_index: Option<usize>,
|
||||
pub is_active: bool,
|
||||
pub trigger_chars: Vec<char>, // Characters that trigger suggestions
|
||||
}
|
||||
|
||||
impl Default for SuggestionState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
suggestions: Vec::new(),
|
||||
selected_index: None,
|
||||
is_active: false,
|
||||
trigger_chars: vec![], // No auto-trigger by default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SuggestionState {
|
||||
pub fn new(trigger_chars: Vec<char>) -> Self {
|
||||
Self {
|
||||
trigger_chars,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn activate_with_suggestions(&mut self, suggestions: Vec<String>) {
|
||||
self.suggestions = suggestions;
|
||||
self.is_active = !self.suggestions.is_empty();
|
||||
self.selected_index = if self.is_active { Some(0) } else { None };
|
||||
}
|
||||
|
||||
pub fn deactivate(&mut self) {
|
||||
self.suggestions.clear();
|
||||
self.selected_index = None;
|
||||
self.is_active = false;
|
||||
}
|
||||
|
||||
pub fn select_next(&mut self) {
|
||||
if !self.suggestions.is_empty() {
|
||||
let current = self.selected_index.unwrap_or(0);
|
||||
self.selected_index = Some((current + 1) % self.suggestions.len());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_previous(&mut self) {
|
||||
if !self.suggestions.is_empty() {
|
||||
let current = self.selected_index.unwrap_or(0);
|
||||
self.selected_index = Some(
|
||||
if current == 0 { self.suggestions.len() - 1 } else { current - 1 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_selected(&self) -> Option<&String> {
|
||||
self.selected_index
|
||||
.and_then(|idx| self.suggestions.get(idx))
|
||||
}
|
||||
|
||||
pub fn should_trigger(&self, c: char) -> bool {
|
||||
self.trigger_chars.contains(&c)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ license.workspace = true
|
||||
anyhow = { workspace = true }
|
||||
async-trait = "0.1.88"
|
||||
common = { path = "../common" }
|
||||
canvas = { path = "../canvas" }
|
||||
canvas = { path = "../canvas", features = ["gui"] }
|
||||
|
||||
ratatui = { workspace = true }
|
||||
crossterm = { workspace = true }
|
||||
@@ -27,7 +27,7 @@ tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.19"
|
||||
tui-textarea = { version = "0.7.0", features = ["crossterm", "ratatui", "search"] }
|
||||
unicode-segmentation = "1.12.0"
|
||||
unicode-width = "0.2.0"
|
||||
unicode-width.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -24,7 +24,7 @@ move_word_end_prev = ["ge"]
|
||||
move_line_start = ["0"]
|
||||
move_line_end = ["$"]
|
||||
move_first_line = ["gg"]
|
||||
move_last_line = ["CapsLock"]
|
||||
move_last_line = ["shift+g"]
|
||||
next_field = ["Tab"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
|
||||
@@ -49,6 +49,7 @@ suggestion_up = ["Up", "Ctrl+p"]
|
||||
suggestion_down = ["Down", "Ctrl+n"]
|
||||
select_suggestion = ["Enter", "Tab"]
|
||||
exit_suggestions = ["Esc"]
|
||||
trigger_autocomplete = ["Tab"]
|
||||
|
||||
# Global keybindings (work in both modes)
|
||||
[keybindings.global]
|
||||
|
||||
@@ -29,6 +29,7 @@ move_up = ["Up"]
|
||||
move_down = ["Down"]
|
||||
toggle_sidebar = ["ctrl+t"]
|
||||
toggle_buffer_list = ["ctrl+b"]
|
||||
revert = ["space+b+r"]
|
||||
|
||||
# MODE SPECIFIC
|
||||
# READ ONLY MODE
|
||||
@@ -37,7 +38,6 @@ enter_edit_mode_before = ["i"]
|
||||
enter_edit_mode_after = ["a"]
|
||||
previous_entry = ["left","q"]
|
||||
next_entry = ["right","1"]
|
||||
revert = ["space+b+r"]
|
||||
|
||||
move_left = ["h"]
|
||||
move_right = ["l"]
|
||||
@@ -69,11 +69,10 @@ prev_field = ["shift+enter"]
|
||||
exit = ["esc", "ctrl+e"]
|
||||
delete_char_forward = ["delete"]
|
||||
delete_char_backward = ["backspace"]
|
||||
move_left = [""]
|
||||
move_left = ["left"]
|
||||
move_right = ["right"]
|
||||
suggestion_down = ["ctrl+n", "tab"]
|
||||
suggestion_up = ["ctrl+p", "shift+tab"]
|
||||
trigger_autocomplete = ["left"]
|
||||
|
||||
[keybindings.command]
|
||||
exit_command_mode = ["ctrl+g", "esc"]
|
||||
|
||||
@@ -13,6 +13,16 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use canvas::canvas::{render_canvas, HighlightState as CanvasHighlightState}; // Use canvas library's render function
|
||||
|
||||
// Helper function to convert between HighlightState types
|
||||
fn convert_highlight_state(local: &HighlightState) -> CanvasHighlightState {
|
||||
match local {
|
||||
HighlightState::Off => CanvasHighlightState::Off,
|
||||
HighlightState::Characterwise { anchor } => CanvasHighlightState::Characterwise { anchor: *anchor },
|
||||
HighlightState::Linewise { anchor_line } => CanvasHighlightState::Linewise { anchor_line: *anchor_line },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_login(
|
||||
f: &mut Frame,
|
||||
@@ -48,17 +58,15 @@ pub fn render_login(
|
||||
])
|
||||
.split(inner_area);
|
||||
|
||||
// --- FORM RENDERING ---
|
||||
crate::components::handlers::canvas::render_canvas(
|
||||
// --- FORM RENDERING (Using canvas library directly) ---
|
||||
let canvas_highlight_state = convert_highlight_state(highlight_state);
|
||||
render_canvas(
|
||||
f,
|
||||
chunks[0],
|
||||
login_state,
|
||||
&["Username/Email", "Password"],
|
||||
&login_state.current_field,
|
||||
&[&login_state.username, &login_state.password],
|
||||
theme,
|
||||
login_state, // LoginState implements CanvasState
|
||||
theme, // Theme implements CanvasTheme
|
||||
is_edit_mode,
|
||||
highlight_state,
|
||||
&canvas_highlight_state,
|
||||
);
|
||||
|
||||
// --- ERROR MESSAGE ---
|
||||
@@ -71,7 +79,7 @@ pub fn render_login(
|
||||
);
|
||||
}
|
||||
|
||||
// --- BUTTONS ---
|
||||
// --- BUTTONS (unchanged) ---
|
||||
let button_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
@@ -83,7 +91,7 @@ pub fn render_login(
|
||||
app_state.focused_button_index== login_button_index
|
||||
} else {
|
||||
false
|
||||
};
|
||||
};
|
||||
let mut login_style = Style::default().fg(theme.fg);
|
||||
let mut login_border = Style::default().fg(theme.border);
|
||||
if login_active {
|
||||
@@ -105,12 +113,12 @@ pub fn render_login(
|
||||
);
|
||||
|
||||
// Return Button
|
||||
let return_button_index = 1; // Assuming Return is the second general element
|
||||
let return_button_index = 1;
|
||||
let return_active = if app_state.ui.focus_outside_canvas {
|
||||
app_state.focused_button_index== return_button_index
|
||||
} else {
|
||||
false // Not active if focus is in canvas or other modes
|
||||
};
|
||||
false
|
||||
};
|
||||
let mut return_style = Style::default().fg(theme.fg);
|
||||
let mut return_border = Style::default().fg(theme.border);
|
||||
if return_active {
|
||||
@@ -132,17 +140,15 @@ pub fn render_login(
|
||||
);
|
||||
|
||||
// --- DIALOG ---
|
||||
// Check the correct field name for showing the dialog
|
||||
if app_state.ui.dialog.dialog_show {
|
||||
// Pass all 7 arguments correctly
|
||||
dialog::render_dialog(
|
||||
f,
|
||||
f.area(),
|
||||
theme,
|
||||
&app_state.ui.dialog.dialog_title,
|
||||
&app_state.ui.dialog.dialog_message,
|
||||
&app_state.ui.dialog.dialog_buttons, // Pass buttons slice
|
||||
app_state.ui.dialog.dialog_active_button_index,
|
||||
&app_state.ui.dialog.dialog_buttons,
|
||||
app_state.ui.dialog.dialog_active_button_index,
|
||||
app_state.ui.dialog.is_loading,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
use crate::{
|
||||
config::colors::themes::Theme,
|
||||
state::pages::auth::RegisterState, // Use RegisterState
|
||||
components::common::{dialog, autocomplete},
|
||||
state::pages::auth::RegisterState,
|
||||
components::common::dialog,
|
||||
state::app::state::AppState,
|
||||
state::pages::canvas_state::CanvasState,
|
||||
modes::handlers::mode_manager::AppMode,
|
||||
};
|
||||
use ratatui::{
|
||||
@@ -15,12 +14,24 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use canvas::canvas::{render_canvas, HighlightState as CanvasHighlightState}; // Use canvas library's render function
|
||||
use canvas::autocomplete::gui::render_autocomplete_dropdown;
|
||||
use canvas::autocomplete::AutocompleteCanvasState;
|
||||
|
||||
// Helper function to convert between HighlightState types
|
||||
fn convert_highlight_state(local: &HighlightState) -> CanvasHighlightState {
|
||||
match local {
|
||||
HighlightState::Off => CanvasHighlightState::Off,
|
||||
HighlightState::Characterwise { anchor } => CanvasHighlightState::Characterwise { anchor: *anchor },
|
||||
HighlightState::Linewise { anchor_line } => CanvasHighlightState::Linewise { anchor_line: *anchor_line },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_register(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
state: &RegisterState, // Use RegisterState
|
||||
state: &RegisterState,
|
||||
app_state: &AppState,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
@@ -29,7 +40,7 @@ pub fn render_register(
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Plain)
|
||||
.border_style(Style::default().fg(theme.border))
|
||||
.title(" Register ") // Update title
|
||||
.title(" Register ")
|
||||
.style(Style::default().bg(theme.bg));
|
||||
|
||||
f.render_widget(block, area);
|
||||
@@ -39,7 +50,6 @@ pub fn render_register(
|
||||
vertical: 1,
|
||||
});
|
||||
|
||||
// Adjust constraints for 4 fields + error + buttons
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
@@ -50,23 +60,15 @@ pub fn render_register(
|
||||
])
|
||||
.split(inner_area);
|
||||
|
||||
// --- FORM RENDERING (Using render_canvas) ---
|
||||
let active_field_rect = crate::components::handlers::canvas::render_canvas(
|
||||
// --- FORM RENDERING (Using canvas library directly) ---
|
||||
let canvas_highlight_state = convert_highlight_state(highlight_state);
|
||||
let input_rect = render_canvas(
|
||||
f,
|
||||
chunks[0], // Area for the canvas
|
||||
state, // The state object (RegisterState)
|
||||
&[ // Field labels
|
||||
"Username",
|
||||
"Email*",
|
||||
"Password*",
|
||||
"Confirm Password",
|
||||
"Role* (Tab)",
|
||||
],
|
||||
&state.current_field(), // Pass current field index
|
||||
&state.inputs().iter().map(|s| *s).collect::<Vec<&String>>(), // Pass inputs directly
|
||||
theme,
|
||||
chunks[0],
|
||||
state, // RegisterState implements CanvasState
|
||||
theme, // Theme implements CanvasTheme
|
||||
is_edit_mode,
|
||||
highlight_state,
|
||||
&canvas_highlight_state,
|
||||
);
|
||||
|
||||
// --- HELP TEXT ---
|
||||
@@ -75,7 +77,6 @@ pub fn render_register(
|
||||
.alignment(Alignment::Center);
|
||||
f.render_widget(help_text, chunks[1]);
|
||||
|
||||
|
||||
// --- ERROR MESSAGE ---
|
||||
if let Some(err) = &state.error_message {
|
||||
f.render_widget(
|
||||
@@ -107,7 +108,7 @@ pub fn render_register(
|
||||
}
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new("Register") // Update button text
|
||||
Paragraph::new("Register")
|
||||
.style(register_style)
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
@@ -119,7 +120,7 @@ pub fn render_register(
|
||||
button_chunks[0],
|
||||
);
|
||||
|
||||
// Return Button (logic remains similar)
|
||||
// Return Button
|
||||
let return_button_index = 1;
|
||||
let return_active = if app_state.ui.focus_outside_canvas {
|
||||
app_state.focused_button_index== return_button_index
|
||||
@@ -146,19 +147,22 @@ pub fn render_register(
|
||||
button_chunks[1],
|
||||
);
|
||||
|
||||
// --- Render Autocomplete Dropdown (Draw AFTER buttons) ---
|
||||
// --- AUTOCOMPLETE DROPDOWN (Using canvas library directly) ---
|
||||
if app_state.current_mode == AppMode::Edit {
|
||||
if let Some(suggestions) = state.get_suggestions() {
|
||||
let selected = state.get_selected_suggestion_index();
|
||||
if !suggestions.is_empty() {
|
||||
if let Some(input_rect) = active_field_rect {
|
||||
autocomplete::render_autocomplete_dropdown(f, input_rect, f.area(), theme, suggestions, selected);
|
||||
}
|
||||
if let Some(autocomplete_state) = state.autocomplete_state() {
|
||||
if let Some(input_rect) = input_rect {
|
||||
render_autocomplete_dropdown(
|
||||
f,
|
||||
f.area(), // Frame area
|
||||
input_rect, // Current input field rect
|
||||
theme, // Theme implements CanvasTheme
|
||||
autocomplete_state,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- DIALOG --- (Keep dialog logic)
|
||||
// --- DIALOG ---
|
||||
if app_state.ui.dialog.dialog_show {
|
||||
dialog::render_dialog(
|
||||
f,
|
||||
|
||||
@@ -55,10 +55,10 @@ pub fn render_search_palette(
|
||||
.style(Style::default().fg(theme.fg));
|
||||
f.render_widget(input_text, inner_chunks[0]);
|
||||
// Set cursor position
|
||||
f.set_cursor(
|
||||
f.set_cursor_position((
|
||||
inner_chunks[0].x + state.cursor_position as u16 + 1,
|
||||
inner_chunks[0].y + 1,
|
||||
);
|
||||
));
|
||||
|
||||
// --- Render Results List ---
|
||||
if state.is_loading {
|
||||
|
||||
@@ -5,9 +5,10 @@ use ratatui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Paragraph, Wrap}, // Make sure Wrap is imported
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
use ratatui::widgets::Wrap;
|
||||
use std::path::Path;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// src/components/form/form.rs
|
||||
use crate::components::common::autocomplete;
|
||||
use crate::components::handlers::canvas::render_canvas;
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use canvas::CanvasState;
|
||||
use canvas::canvas::{CanvasState, render_canvas, HighlightState};
|
||||
use crate::state::pages::form::FormState;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
|
||||
@@ -15,7 +13,7 @@ use ratatui::{
|
||||
pub fn render_form(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
form_state: &FormState, // <--- CHANGE THIS to the concrete type
|
||||
form_state: &FormState,
|
||||
fields: &[&str],
|
||||
current_field_idx: &usize,
|
||||
inputs: &[&String],
|
||||
@@ -58,32 +56,31 @@ pub fn render_form(
|
||||
total_count, current_position, total_count
|
||||
)
|
||||
};
|
||||
|
||||
let count_para = Paragraph::new(count_position_text)
|
||||
.style(Style::default().fg(theme.fg))
|
||||
.alignment(Alignment::Left);
|
||||
f.render_widget(count_para, main_layout[0]);
|
||||
|
||||
// Get the active field's rect from render_canvas
|
||||
let active_field_rect = crate::components::handlers::canvas::render_canvas_library(
|
||||
// Use the canvas library's render_canvas function
|
||||
let active_field_rect = render_canvas(
|
||||
f,
|
||||
main_layout[1],
|
||||
form_state,
|
||||
fields,
|
||||
current_field_idx,
|
||||
inputs,
|
||||
theme,
|
||||
is_edit_mode,
|
||||
highlight_state,
|
||||
);
|
||||
|
||||
// --- NEW: RENDER AUTOCOMPLETE ---
|
||||
// --- RENDER RICH AUTOCOMPLETE ONLY ---
|
||||
if form_state.autocomplete_active {
|
||||
if let Some(active_rect) = active_field_rect {
|
||||
let selected_index = form_state.get_selected_suggestion_index();
|
||||
// Get selected index directly from form_state
|
||||
let selected_index = form_state.selected_suggestion_index;
|
||||
|
||||
// Only render rich suggestions (your Hit objects)
|
||||
if let Some(rich_suggestions) = form_state.get_rich_suggestions() {
|
||||
if !rich_suggestions.is_empty() {
|
||||
// CHANGE THIS to call the renamed function
|
||||
autocomplete::render_hit_autocomplete_dropdown(
|
||||
f,
|
||||
active_rect,
|
||||
@@ -95,21 +92,7 @@ pub fn render_form(
|
||||
);
|
||||
}
|
||||
}
|
||||
// The fallback to simple suggestions is now correctly handled
|
||||
// because the original render_autocomplete_dropdown exists again.
|
||||
else if let Some(simple_suggestions) = form_state.get_suggestions() {
|
||||
if !simple_suggestions.is_empty() {
|
||||
autocomplete::render_autocomplete_dropdown(
|
||||
f,
|
||||
active_rect,
|
||||
f.area(),
|
||||
theme,
|
||||
simple_suggestions,
|
||||
selected_index,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Removed simple suggestions - we only use rich ones now!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ use ratatui::{
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use crate::state::pages::canvas_state::CanvasState as LegacyCanvasState;
|
||||
use canvas::CanvasState as LibraryCanvasState;
|
||||
use canvas::canvas::CanvasState as LibraryCanvasState;
|
||||
use std::cmp::{max, min};
|
||||
|
||||
/// Render canvas for legacy CanvasState (AddTableState, LoginState, RegisterState, AddLogicState)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// src/client/themes/colors.rs
|
||||
// src/config/colors/themes.rs
|
||||
use ratatui::style::Color;
|
||||
use canvas::canvas::CanvasTheme;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Theme {
|
||||
@@ -74,3 +75,37 @@ impl Default for Theme {
|
||||
Self::light() // Default to light theme
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasTheme for Theme {
|
||||
fn bg(&self) -> Color {
|
||||
self.bg
|
||||
}
|
||||
|
||||
fn fg(&self) -> Color {
|
||||
self.fg
|
||||
}
|
||||
|
||||
fn border(&self) -> Color {
|
||||
self.border
|
||||
}
|
||||
|
||||
fn accent(&self) -> Color {
|
||||
self.accent
|
||||
}
|
||||
|
||||
fn secondary(&self) -> Color {
|
||||
self.secondary
|
||||
}
|
||||
|
||||
fn highlight(&self) -> Color {
|
||||
self.highlight
|
||||
}
|
||||
|
||||
fn highlight_bg(&self) -> Color {
|
||||
self.highlight_bg
|
||||
}
|
||||
|
||||
fn warning(&self) -> Color {
|
||||
self.warning
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// src/functions/modes/edit/auth_e.rs
|
||||
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::state::pages::auth::RegisterState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::tui::functions::common::form::{revert, save};
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use canvas::autocomplete::AutocompleteCanvasState;
|
||||
use canvas::canvas::CanvasState;
|
||||
use std::any::Any;
|
||||
use anyhow::Result;
|
||||
|
||||
@@ -295,53 +296,42 @@ pub async fn execute_edit_action<S: CanvasState + Any + Send>(
|
||||
"suggestion_down" | "suggestion_up" | "select_suggestion" | "exit_suggestion_mode" => {
|
||||
// Attempt to downcast to RegisterState to handle suggestion logic here
|
||||
if let Some(register_state) = (state as &mut dyn Any).downcast_mut::<RegisterState>() {
|
||||
// Only handle if it's the role field (index 4)
|
||||
if register_state.current_field() == 4 {
|
||||
// Only handle if it's the role field (index 4) and autocomplete is active
|
||||
if register_state.current_field() == 4 && register_state.is_autocomplete_active() {
|
||||
match action {
|
||||
"suggestion_down" if register_state.in_suggestion_mode => {
|
||||
let max_index = register_state.role_suggestions.len().saturating_sub(1);
|
||||
let current_index = register_state.selected_suggestion_index.unwrap_or(0);
|
||||
register_state.selected_suggestion_index = Some(if current_index >= max_index { 0 } else { current_index + 1 });
|
||||
Ok("Suggestion changed down".to_string())
|
||||
"suggestion_down" => {
|
||||
if let Some(autocomplete_state) = register_state.autocomplete_state_mut() {
|
||||
autocomplete_state.select_next();
|
||||
Ok("Suggestion changed down".to_string())
|
||||
} else {
|
||||
Ok("No autocomplete state".to_string())
|
||||
}
|
||||
}
|
||||
"suggestion_up" if register_state.in_suggestion_mode => {
|
||||
let max_index = register_state.role_suggestions.len().saturating_sub(1);
|
||||
let current_index = register_state.selected_suggestion_index.unwrap_or(0);
|
||||
register_state.selected_suggestion_index = Some(if current_index == 0 { max_index } else { current_index.saturating_sub(1) });
|
||||
Ok("Suggestion changed up".to_string())
|
||||
"suggestion_up" => {
|
||||
if let Some(autocomplete_state) = register_state.autocomplete_state_mut() {
|
||||
autocomplete_state.select_previous();
|
||||
Ok("Suggestion changed up".to_string())
|
||||
} else {
|
||||
Ok("No autocomplete state".to_string())
|
||||
}
|
||||
}
|
||||
"select_suggestion" if register_state.in_suggestion_mode => {
|
||||
if let Some(index) = register_state.selected_suggestion_index {
|
||||
if let Some(selected_role) = register_state.role_suggestions.get(index).cloned() {
|
||||
register_state.role = selected_role.clone(); // Update the role field
|
||||
register_state.in_suggestion_mode = false; // Exit suggestion mode
|
||||
register_state.show_role_suggestions = false; // Hide suggestions
|
||||
register_state.selected_suggestion_index = None; // Clear selection
|
||||
Ok(format!("Selected role: {}", selected_role)) // Return success message
|
||||
} else {
|
||||
Ok("Selected suggestion index out of bounds.".to_string()) // Error case
|
||||
}
|
||||
"select_suggestion" => {
|
||||
if let Some(message) = register_state.apply_autocomplete_selection() {
|
||||
Ok(message)
|
||||
} else {
|
||||
Ok("No suggestion selected".to_string())
|
||||
}
|
||||
}
|
||||
"exit_suggestion_mode" => { // Handle Esc or other conditions
|
||||
register_state.show_role_suggestions = false;
|
||||
register_state.selected_suggestion_index = None;
|
||||
register_state.in_suggestion_mode = false;
|
||||
"exit_suggestion_mode" => {
|
||||
register_state.deactivate_autocomplete();
|
||||
Ok("Suggestions hidden".to_string())
|
||||
}
|
||||
_ => {
|
||||
// Action is suggestion-related but state doesn't match (e.g., not in suggestion mode)
|
||||
Ok("Suggestion action ignored: State mismatch.".to_string())
|
||||
}
|
||||
_ => Ok("Suggestion action ignored: State mismatch.".to_string())
|
||||
}
|
||||
} else {
|
||||
// It's RegisterState, but not the role field
|
||||
Ok("Suggestion action ignored: Not on role field.".to_string())
|
||||
}
|
||||
Ok("Suggestion action ignored: Not on role field or autocomplete not active.".to_string())
|
||||
}
|
||||
} else {
|
||||
// Downcast failed - this action is only for RegisterState
|
||||
Ok(format!("Action '{}' not applicable for this state type.", action))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::tui::functions::common::form::{revert, save};
|
||||
use crate::tui::functions::common::form::SaveOutcome;
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use canvas::CanvasState;
|
||||
use canvas::canvas::CanvasState;
|
||||
use std::any::Any;
|
||||
use anyhow::Result;
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// src/functions/modes/read_only/auth_ro.rs
|
||||
|
||||
use crate::config::binds::key_sequences::KeySequenceTracker;
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use crate::state::app::state::AppState;
|
||||
use canvas::canvas::CanvasState;
|
||||
use anyhow::Result;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/functions/modes/read_only/form_ro.rs
|
||||
|
||||
use crate::config::binds::key_sequences::KeySequenceTracker;
|
||||
use canvas::CanvasState;
|
||||
use canvas::canvas::CanvasState;
|
||||
use anyhow::Result;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
|
||||
@@ -11,13 +11,13 @@ use crate::state::pages::{
|
||||
auth::{LoginState, RegisterState},
|
||||
form::FormState,
|
||||
};
|
||||
use canvas::CanvasState;
|
||||
use canvas::{CanvasAction, ActionDispatcher, ActionResult};
|
||||
use canvas::canvas::CanvasState;
|
||||
use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher, canvas::ActionResult};
|
||||
use anyhow::Result;
|
||||
use common::proto::komp_ac::search::search_response::Hit;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, info};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EditEventOutcome {
|
||||
@@ -170,7 +170,7 @@ pub async fn handle_edit_event(
|
||||
return Ok(EditEventOutcome::Message(String::new()));
|
||||
}
|
||||
"exit" => {
|
||||
form_state.deactivate_suggestions();
|
||||
form_state.deactivate_autocomplete();
|
||||
return Ok(EditEventOutcome::Message(
|
||||
"Autocomplete cancelled".to_string(),
|
||||
));
|
||||
@@ -202,14 +202,14 @@ pub async fn handle_edit_event(
|
||||
);
|
||||
|
||||
// 4. Finalize state
|
||||
form_state.deactivate_suggestions();
|
||||
form_state.deactivate_autocomplete();
|
||||
form_state.set_has_unsaved_changes(true);
|
||||
return Ok(EditEventOutcome::Message(
|
||||
"Selection made".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
form_state.deactivate_suggestions();
|
||||
form_state.deactivate_autocomplete();
|
||||
// Fall through to default 'enter' behavior
|
||||
}
|
||||
_ => {} // Let other keys fall through to the live search logic
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::config::binds::key_sequences::KeySequenceTracker;
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::pages::{canvas_state::CanvasState, auth::RegisterState};
|
||||
use crate::state::pages::auth::LoginState;
|
||||
use crate::state::pages::auth::RegisterState;
|
||||
use crate::state::pages::canvas_state::CanvasState as LocalCanvasState;
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::state::pages::add_logic::AddLogicState;
|
||||
use crate::state::pages::add_table::AddTableState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::functions::modes::read_only::{add_logic_ro, auth_ro, form_ro, add_table_ro};
|
||||
use canvas::{CanvasAction, ActionDispatcher, ActionResult};
|
||||
use canvas::{canvas::{CanvasAction, CanvasState, ActionResult}, dispatcher::ActionDispatcher};
|
||||
use crossterm::event::KeyEvent;
|
||||
use anyhow::Result;
|
||||
|
||||
@@ -120,7 +121,7 @@ pub async fn handle_read_only_event(
|
||||
}
|
||||
} else {
|
||||
// Handle FormState (uses library CanvasState)
|
||||
use canvas::CanvasState as LibraryCanvasState; // Import at the top of the function
|
||||
use canvas::canvas::CanvasState as LibraryCanvasState; // Import at the top of the function
|
||||
let current_input = form_state.get_current_input();
|
||||
let current_pos = form_state.current_cursor_pos();
|
||||
if !current_input.is_empty() && current_pos < current_input.len() {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
use crate::tui::terminal::core::TerminalCore;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::{form::FormState, auth::LoginState, auth::RegisterState};
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use canvas::canvas::CanvasState;
|
||||
use anyhow::Result;
|
||||
|
||||
pub struct CommandHandler;
|
||||
|
||||
@@ -8,10 +8,10 @@ use crate::state::pages::auth::LoginState;
|
||||
use crate::state::pages::auth::RegisterState;
|
||||
use crate::state::pages::intro::IntroState;
|
||||
use crate::state::pages::admin::AdminState;
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use crate::ui::handlers::context::UiContext;
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use crate::modes::general::command_navigation::{handle_command_navigation_event, NavigationState};
|
||||
use canvas::canvas::CanvasState;
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn handle_navigation_event(
|
||||
|
||||
@@ -18,8 +18,8 @@ use crate::modes::{
|
||||
use crate::state::pages::canvas_state::CanvasState as LegacyCanvasState;
|
||||
use crate::services::auth::AuthClient;
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use canvas::{CanvasAction, ActionDispatcher, ActionResult};
|
||||
use canvas::CanvasState as LibraryCanvasState;
|
||||
use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher};
|
||||
use canvas::canvas::CanvasState as LibraryCanvasState;
|
||||
use super::event_helper::*;
|
||||
use crate::state::{
|
||||
app::{
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::state::pages::{
|
||||
auth::{LoginState, RegisterState},
|
||||
};
|
||||
use crate::state::pages::canvas_state::CanvasState as LegacyCanvasState;
|
||||
use canvas::CanvasState as LibraryCanvasState;
|
||||
use canvas::canvas::CanvasState as LibraryCanvasState;
|
||||
|
||||
/// Get the current field index from the appropriate state based on which UI is active
|
||||
pub fn get_current_field_for_state(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// src/state/pages/auth.rs
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use canvas::canvas::{CanvasState, ActionContext, CanvasAction};
|
||||
use canvas::autocomplete::{AutocompleteCanvasState, AutocompleteState, SuggestionItem};
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
lazy_static! {
|
||||
@@ -44,91 +45,61 @@ pub struct RegisterState {
|
||||
pub current_field: usize,
|
||||
pub current_cursor_pos: usize,
|
||||
pub has_unsaved_changes: bool,
|
||||
pub show_role_suggestions: bool,
|
||||
pub role_suggestions: Vec<String>,
|
||||
pub selected_suggestion_index: Option<usize>,
|
||||
pub in_suggestion_mode: bool,
|
||||
// NEW: Replace old autocomplete with external library's system
|
||||
pub autocomplete: AutocompleteState<String>,
|
||||
}
|
||||
|
||||
impl AuthState {
|
||||
/// Creates a new empty AuthState (unauthenticated)
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
auth_token: None,
|
||||
user_id: None,
|
||||
role: None,
|
||||
decoded_username: None,
|
||||
}
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl LoginState {
|
||||
/// Creates a new empty LoginState
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
error_message: None,
|
||||
current_field: 0,
|
||||
current_cursor_pos: 0,
|
||||
has_unsaved_changes: false,
|
||||
login_request_pending: false,
|
||||
}
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl RegisterState {
|
||||
/// Creates a new empty RegisterState
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
username: String::new(),
|
||||
email: String::new(),
|
||||
password: String::new(),
|
||||
password_confirmation: String::new(),
|
||||
role: String::new(),
|
||||
error_message: None,
|
||||
current_field: 0,
|
||||
current_cursor_pos: 0,
|
||||
has_unsaved_changes: false,
|
||||
show_role_suggestions: false,
|
||||
role_suggestions: Vec::new(),
|
||||
selected_suggestion_index: None,
|
||||
in_suggestion_mode: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates role suggestions based on current input
|
||||
pub fn update_role_suggestions(&mut self) {
|
||||
let current_input = self.role.to_lowercase();
|
||||
self.role_suggestions = AVAILABLE_ROLES
|
||||
let mut state = Self {
|
||||
autocomplete: AutocompleteState::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Initialize autocomplete with role suggestions
|
||||
let suggestions: Vec<SuggestionItem<String>> = AVAILABLE_ROLES
|
||||
.iter()
|
||||
.filter(|role| role.to_lowercase().contains(¤t_input))
|
||||
.cloned()
|
||||
.map(|role| SuggestionItem::simple(role.clone(), role.clone()))
|
||||
.collect();
|
||||
self.show_role_suggestions = !self.role_suggestions.is_empty();
|
||||
|
||||
// Set suggestions but keep inactive initially
|
||||
state.autocomplete.set_suggestions(suggestions);
|
||||
state.autocomplete.is_active = false; // Not active by default
|
||||
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
// Implement external library's CanvasState for LoginState
|
||||
impl CanvasState for LoginState {
|
||||
fn current_field(&self) -> usize {
|
||||
self.current_field
|
||||
}
|
||||
|
||||
fn current_cursor_pos(&self) -> usize {
|
||||
let len = match self.current_field {
|
||||
0 => self.username.len(),
|
||||
1 => self.password.len(),
|
||||
_ => 0,
|
||||
};
|
||||
self.current_cursor_pos.min(len)
|
||||
self.current_cursor_pos
|
||||
}
|
||||
|
||||
fn has_unsaved_changes(&self) -> bool {
|
||||
self.has_unsaved_changes
|
||||
fn set_current_field(&mut self, index: usize) {
|
||||
if index < 2 {
|
||||
self.current_field = index;
|
||||
}
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> {
|
||||
vec![&self.username, &self.password]
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||
self.current_cursor_pos = pos;
|
||||
}
|
||||
|
||||
fn get_current_input(&self) -> &str {
|
||||
@@ -147,73 +118,61 @@ impl CanvasState for LoginState {
|
||||
}
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> {
|
||||
vec![&self.username, &self.password]
|
||||
}
|
||||
|
||||
fn fields(&self) -> Vec<&str> {
|
||||
vec!["Username/Email", "Password"]
|
||||
}
|
||||
|
||||
fn set_current_field(&mut self, index: usize) {
|
||||
if index < 2 {
|
||||
self.current_field = index;
|
||||
let len = match self.current_field {
|
||||
0 => self.username.len(),
|
||||
1 => self.password.len(),
|
||||
_ => 0,
|
||||
};
|
||||
self.current_cursor_pos = self.current_cursor_pos.min(len);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||
let len = match self.current_field {
|
||||
0 => self.username.len(),
|
||||
1 => self.password.len(),
|
||||
_ => 0,
|
||||
};
|
||||
self.current_cursor_pos = pos.min(len);
|
||||
}
|
||||
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) {
|
||||
self.has_unsaved_changes = changed;
|
||||
}
|
||||
|
||||
fn get_suggestions(&self) -> Option<&[String]> {
|
||||
None
|
||||
}
|
||||
|
||||
fn get_selected_suggestion_index(&self) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasState for RegisterState {
|
||||
fn current_field(&self) -> usize {
|
||||
self.current_field
|
||||
}
|
||||
|
||||
fn current_cursor_pos(&self) -> usize {
|
||||
let len = match self.current_field {
|
||||
0 => self.username.len(),
|
||||
1 => self.email.len(),
|
||||
2 => self.password.len(),
|
||||
3 => self.password_confirmation.len(),
|
||||
4 => self.role.len(),
|
||||
_ => 0,
|
||||
};
|
||||
self.current_cursor_pos.min(len)
|
||||
}
|
||||
|
||||
fn has_unsaved_changes(&self) -> bool {
|
||||
self.has_unsaved_changes
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> {
|
||||
vec![
|
||||
&self.username,
|
||||
&self.email,
|
||||
&self.password,
|
||||
&self.password_confirmation,
|
||||
&self.role,
|
||||
]
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) {
|
||||
self.has_unsaved_changes = changed;
|
||||
}
|
||||
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::Custom(action_str) if action_str == "submit" => {
|
||||
if !self.username.is_empty() && !self.password.is_empty() {
|
||||
Some(format!("Submitting login for: {}", self.username))
|
||||
} else {
|
||||
Some("Please fill in all required fields".to_string())
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Implement external library's CanvasState for RegisterState
|
||||
impl CanvasState for RegisterState {
|
||||
fn current_field(&self) -> usize {
|
||||
self.current_field
|
||||
}
|
||||
|
||||
fn current_cursor_pos(&self) -> usize {
|
||||
self.current_cursor_pos
|
||||
}
|
||||
|
||||
fn set_current_field(&mut self, index: usize) {
|
||||
if index < 5 {
|
||||
self.current_field = index;
|
||||
|
||||
// Auto-activate autocomplete when moving to role field (index 4)
|
||||
if index == 4 && !self.autocomplete.is_active {
|
||||
self.activate_autocomplete();
|
||||
} else if index != 4 && self.autocomplete.is_active {
|
||||
self.deactivate_autocomplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||
self.current_cursor_pos = pos;
|
||||
}
|
||||
|
||||
fn get_current_input(&self) -> &str {
|
||||
@@ -238,6 +197,16 @@ impl CanvasState for RegisterState {
|
||||
}
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> {
|
||||
vec![
|
||||
&self.username,
|
||||
&self.email,
|
||||
&self.password,
|
||||
&self.password_confirmation,
|
||||
&self.role,
|
||||
]
|
||||
}
|
||||
|
||||
fn fields(&self) -> Vec<&str> {
|
||||
vec![
|
||||
"Username",
|
||||
@@ -248,50 +217,99 @@ impl CanvasState for RegisterState {
|
||||
]
|
||||
}
|
||||
|
||||
fn set_current_field(&mut self, index: usize) {
|
||||
if index < 5 {
|
||||
self.current_field = index;
|
||||
let len = match self.current_field {
|
||||
0 => self.username.len(),
|
||||
1 => self.email.len(),
|
||||
2 => self.password.len(),
|
||||
3 => self.password_confirmation.len(),
|
||||
4 => self.role.len(),
|
||||
_ => 0,
|
||||
};
|
||||
self.current_cursor_pos = self.current_cursor_pos.min(len);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||
let len = match self.current_field {
|
||||
0 => self.username.len(),
|
||||
1 => self.email.len(),
|
||||
2 => self.password.len(),
|
||||
3 => self.password_confirmation.len(),
|
||||
4 => self.role.len(),
|
||||
_ => 0,
|
||||
};
|
||||
self.current_cursor_pos = pos.min(len);
|
||||
fn has_unsaved_changes(&self) -> bool {
|
||||
self.has_unsaved_changes
|
||||
}
|
||||
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) {
|
||||
self.has_unsaved_changes = changed;
|
||||
}
|
||||
|
||||
fn get_suggestions(&self) -> Option<&[String]> {
|
||||
if self.current_field == 4 && self.in_suggestion_mode && self.show_role_suggestions {
|
||||
Some(&self.role_suggestions)
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::Custom(action_str) if action_str == "submit" => {
|
||||
if !self.username.is_empty() {
|
||||
Some(format!("Submitting registration for: {}", self.username))
|
||||
} else {
|
||||
Some("Username is required".to_string())
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add autocomplete support for RegisterState
|
||||
impl AutocompleteCanvasState for RegisterState {
|
||||
type SuggestionData = String;
|
||||
|
||||
fn supports_autocomplete(&self, field_index: usize) -> bool {
|
||||
field_index == 4 // Only role field supports autocomplete
|
||||
}
|
||||
|
||||
fn autocomplete_state(&self) -> Option<&AutocompleteState<Self::SuggestionData>> {
|
||||
Some(&self.autocomplete)
|
||||
}
|
||||
|
||||
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> {
|
||||
Some(&mut self.autocomplete)
|
||||
}
|
||||
|
||||
fn activate_autocomplete(&mut self) {
|
||||
let current_field = self.current_field();
|
||||
if self.supports_autocomplete(current_field) {
|
||||
self.autocomplete.activate(current_field);
|
||||
|
||||
// Re-filter suggestions based on current input
|
||||
let current_input = self.role.to_lowercase();
|
||||
let filtered_suggestions: Vec<SuggestionItem<String>> = AVAILABLE_ROLES
|
||||
.iter()
|
||||
.filter(|role| role.to_lowercase().contains(¤t_input))
|
||||
.map(|role| SuggestionItem::simple(role.clone(), role.clone()))
|
||||
.collect();
|
||||
|
||||
self.autocomplete.set_suggestions(filtered_suggestions);
|
||||
}
|
||||
}
|
||||
|
||||
fn deactivate_autocomplete(&mut self) {
|
||||
self.autocomplete.deactivate();
|
||||
}
|
||||
|
||||
fn is_autocomplete_active(&self) -> bool {
|
||||
self.autocomplete.is_active
|
||||
}
|
||||
|
||||
fn is_autocomplete_ready(&self) -> bool {
|
||||
self.autocomplete.is_ready()
|
||||
}
|
||||
|
||||
fn apply_autocomplete_selection(&mut self) -> Option<String> {
|
||||
// First, get the data we need and clone it to avoid borrowing conflicts
|
||||
let selection_info = self.autocomplete.get_selected().map(|selected| {
|
||||
(selected.value_to_store.clone(), selected.display_text.clone())
|
||||
});
|
||||
|
||||
// Now do the mutable operations
|
||||
if let Some((value, display_text)) = selection_info {
|
||||
self.role = value;
|
||||
self.set_has_unsaved_changes(true);
|
||||
self.deactivate_autocomplete();
|
||||
Some(format!("Selected role: {}", display_text))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn get_selected_suggestion_index(&self) -> Option<usize> {
|
||||
if self.current_field == 4 && self.in_suggestion_mode && self.show_role_suggestions {
|
||||
self.selected_suggestion_index
|
||||
} else {
|
||||
None
|
||||
fn set_autocomplete_suggestions(&mut self, suggestions: Vec<SuggestionItem<Self::SuggestionData>>) {
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
state.set_suggestions(suggestions);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_autocomplete_loading(&mut self, loading: bool) {
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
state.is_loading = loading;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// src/state/pages/form.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use canvas::{CanvasState, CanvasAction, ActionContext}; // CHANGED: Use canvas crate
|
||||
use canvas::canvas::{CanvasState, CanvasAction, ActionContext, HighlightState};
|
||||
use common::proto::komp_ac::search::search_response::Hit;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::Frame;
|
||||
@@ -45,6 +44,14 @@ pub struct FormState {
|
||||
}
|
||||
|
||||
impl FormState {
|
||||
// Add this method
|
||||
pub fn deactivate_autocomplete(&mut self) {
|
||||
self.autocomplete_active = false;
|
||||
self.autocomplete_suggestions.clear();
|
||||
self.selected_suggestion_index = None;
|
||||
self.autocomplete_loading = false;
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
profile_name: String,
|
||||
table_name: String,
|
||||
@@ -113,7 +120,7 @@ impl FormState {
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
highlight_state: &HighlightState, // Now using canvas::HighlightState
|
||||
) {
|
||||
let fields_str_slice: Vec<&str> =
|
||||
self.fields().iter().map(|s| *s).collect();
|
||||
@@ -146,7 +153,7 @@ impl FormState {
|
||||
} else {
|
||||
self.current_position = 1;
|
||||
}
|
||||
self.deactivate_suggestions(); // CHANGED: Use canvas trait method
|
||||
self.deactivate_autocomplete();
|
||||
self.link_display_map.clear();
|
||||
}
|
||||
|
||||
@@ -205,12 +212,10 @@ impl FormState {
|
||||
self.has_unsaved_changes = false;
|
||||
self.current_field = 0;
|
||||
self.current_cursor_pos = 0;
|
||||
self.deactivate_suggestions(); // CHANGED: Use canvas trait method
|
||||
self.deactivate_autocomplete();
|
||||
self.link_display_map.clear();
|
||||
}
|
||||
|
||||
// REMOVED: deactivate_autocomplete() - now using trait method
|
||||
|
||||
// NEW: Keep the rich suggestions methods for compatibility
|
||||
pub fn get_rich_suggestions(&self) -> Option<&[Hit]> {
|
||||
if self.autocomplete_active {
|
||||
@@ -232,98 +237,66 @@ impl CanvasState for FormState {
|
||||
fn current_field(&self) -> usize {
|
||||
self.current_field
|
||||
}
|
||||
|
||||
|
||||
fn current_cursor_pos(&self) -> usize {
|
||||
self.current_cursor_pos
|
||||
}
|
||||
|
||||
|
||||
fn has_unsaved_changes(&self) -> bool {
|
||||
self.has_unsaved_changes
|
||||
}
|
||||
|
||||
|
||||
fn inputs(&self) -> Vec<&String> {
|
||||
self.values.iter().collect()
|
||||
}
|
||||
|
||||
|
||||
fn get_current_input(&self) -> &str {
|
||||
FormState::get_current_input(self)
|
||||
}
|
||||
|
||||
|
||||
fn get_current_input_mut(&mut self) -> &mut String {
|
||||
FormState::get_current_input_mut(self)
|
||||
}
|
||||
|
||||
|
||||
fn fields(&self) -> Vec<&str> {
|
||||
self.fields
|
||||
.iter()
|
||||
.map(|f| f.display_name.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
fn set_current_field(&mut self, index: usize) {
|
||||
if index < self.fields.len() {
|
||||
self.current_field = index;
|
||||
}
|
||||
self.deactivate_suggestions(); // CHANGED: Use canvas trait method
|
||||
self.deactivate_autocomplete();
|
||||
}
|
||||
|
||||
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||
self.current_cursor_pos = pos;
|
||||
}
|
||||
|
||||
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) {
|
||||
self.has_unsaved_changes = changed;
|
||||
}
|
||||
|
||||
// --- CANVAS CRATE SUGGESTIONS SUPPORT ---
|
||||
fn get_suggestions(&self) -> Option<&[String]> {
|
||||
None // We use rich suggestions instead
|
||||
}
|
||||
|
||||
fn get_selected_suggestion_index(&self) -> Option<usize> {
|
||||
if self.autocomplete_active {
|
||||
self.selected_suggestion_index
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn set_selected_suggestion_index(&mut self, index: Option<usize>) {
|
||||
if self.autocomplete_active {
|
||||
self.selected_suggestion_index = index;
|
||||
}
|
||||
}
|
||||
|
||||
fn activate_suggestions(&mut self, suggestions: Vec<String>) {
|
||||
// For compatibility - convert to rich format if needed
|
||||
self.autocomplete_active = true;
|
||||
self.selected_suggestion_index = if suggestions.is_empty() { None } else { Some(0) };
|
||||
}
|
||||
|
||||
fn deactivate_suggestions(&mut self) {
|
||||
self.autocomplete_active = false;
|
||||
self.autocomplete_suggestions.clear();
|
||||
self.selected_suggestion_index = None;
|
||||
self.autocomplete_loading = false;
|
||||
}
|
||||
|
||||
// --- FEATURE-SPECIFIC ACTION HANDLING ---
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::SelectSuggestion => {
|
||||
if let Some(selected_idx) = self.selected_suggestion_index {
|
||||
if let Some(hit) = self.autocomplete_suggestions.get(selected_idx).cloned() { // ADD .cloned()
|
||||
if let Some(hit) = self.autocomplete_suggestions.get(selected_idx).cloned() {
|
||||
// Extract the value from the selected suggestion
|
||||
if let Ok(content_map) = serde_json::from_str::<HashMap<String, serde_json::Value>>(&hit.content_json) {
|
||||
let current_field_def = &self.fields[self.current_field];
|
||||
if let Some(value) = content_map.get(¤t_field_def.data_key) {
|
||||
let new_value = json_value_to_string(value);
|
||||
let display_name = self.get_display_name_for_hit(&hit); // Calculate first
|
||||
let display_name = self.get_display_name_for_hit(&hit);
|
||||
*self.get_current_input_mut() = new_value.clone();
|
||||
self.set_current_cursor_pos(new_value.len());
|
||||
self.set_has_unsaved_changes(true);
|
||||
self.deactivate_suggestions();
|
||||
return Some(format!("Selected: {}", display_name)); // Use calculated value
|
||||
self.deactivate_autocomplete();
|
||||
return Some(format!("Selected: {}", display_name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ use crate::state::pages::auth::LoginState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::app::buffer::{AppView, BufferState};
|
||||
use crate::config::storage::storage::{StoredAuthData, save_auth_data};
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
use common::proto::komp_ac::auth::LoginResponse;
|
||||
use canvas::canvas::CanvasState;
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
@@ -4,11 +4,11 @@ use crate::services::auth::AuthClient;
|
||||
use crate::state::{
|
||||
pages::auth::RegisterState,
|
||||
app::state::AppState,
|
||||
pages::canvas_state::CanvasState,
|
||||
};
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
use crate::state::app::buffer::{AppView, BufferState};
|
||||
use common::proto::komp_ac::auth::AuthResponse;
|
||||
use canvas::canvas::CanvasState;
|
||||
use anyhow::Context;
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/tui/functions/form.rs
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use canvas::CanvasState;
|
||||
use canvas::canvas::CanvasState;
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
pub async fn handle_action(
|
||||
|
||||
@@ -7,7 +7,6 @@ use crate::components::{
|
||||
common::dialog::render_dialog,
|
||||
common::find_file_palette,
|
||||
common::search_palette::render_search_palette,
|
||||
form::form::render_form,
|
||||
handlers::sidebar::{self, calculate_sidebar_layout},
|
||||
intro::intro::render_intro,
|
||||
render_background,
|
||||
@@ -17,9 +16,11 @@ use crate::components::{
|
||||
};
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::modes::general::command_navigation::NavigationState;
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use crate::state::pages::canvas_state::CanvasState as LocalCanvasState; // Keep local one with alias
|
||||
use canvas::canvas::CanvasState; // Import external library's CanvasState trait
|
||||
use crate::state::app::buffer::BufferState;
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use crate::state::app::highlight::HighlightState as LocalHighlightState; // CHANGED: Alias local version
|
||||
use canvas::canvas::HighlightState as CanvasHighlightState; // CHANGED: Import canvas version with alias
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::admin::AdminState;
|
||||
use crate::state::pages::auth::AuthState;
|
||||
@@ -32,6 +33,15 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
|
||||
// Helper function to convert between HighlightState types
|
||||
fn convert_highlight_state(local: &LocalHighlightState) -> CanvasHighlightState {
|
||||
match local {
|
||||
LocalHighlightState::Off => CanvasHighlightState::Off,
|
||||
LocalHighlightState::Characterwise { anchor } => CanvasHighlightState::Characterwise { anchor: *anchor },
|
||||
LocalHighlightState::Linewise { anchor_line } => CanvasHighlightState::Linewise { anchor_line: *anchor_line },
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render_ui(
|
||||
f: &mut Frame,
|
||||
@@ -44,7 +54,7 @@ pub fn render_ui(
|
||||
buffer_state: &BufferState,
|
||||
theme: &Theme,
|
||||
is_event_handler_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
highlight_state: &LocalHighlightState, // Keep using local version
|
||||
event_handler_command_input: &str,
|
||||
event_handler_command_mode_active: bool,
|
||||
event_handler_command_message: &str,
|
||||
@@ -69,7 +79,6 @@ pub fn render_ui(
|
||||
|
||||
const PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT: u16 = 15;
|
||||
|
||||
|
||||
let mut bottom_area_constraints: Vec<Constraint> = vec![Constraint::Length(status_line_height)];
|
||||
let command_palette_area_height = if navigation_state.active {
|
||||
1 + PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT
|
||||
@@ -128,8 +137,8 @@ pub fn render_ui(
|
||||
theme,
|
||||
register_state,
|
||||
app_state,
|
||||
register_state.current_field() < 4,
|
||||
highlight_state,
|
||||
register_state.current_field() < 4, // Now using CanvasState trait method
|
||||
highlight_state, // Uses local version
|
||||
);
|
||||
} else if app_state.ui.show_add_table {
|
||||
render_add_table(
|
||||
@@ -139,7 +148,7 @@ pub fn render_ui(
|
||||
app_state,
|
||||
&mut admin_state.add_table_state,
|
||||
is_event_handler_edit_mode,
|
||||
highlight_state,
|
||||
highlight_state, // Uses local version
|
||||
);
|
||||
} else if app_state.ui.show_add_logic {
|
||||
render_add_logic(
|
||||
@@ -149,7 +158,7 @@ pub fn render_ui(
|
||||
app_state,
|
||||
&mut admin_state.add_logic_state,
|
||||
is_event_handler_edit_mode,
|
||||
highlight_state,
|
||||
highlight_state, // Uses local version
|
||||
);
|
||||
} else if app_state.ui.show_login {
|
||||
render_login(
|
||||
@@ -158,8 +167,8 @@ pub fn render_ui(
|
||||
theme,
|
||||
login_state,
|
||||
app_state,
|
||||
login_state.current_field() < 2,
|
||||
highlight_state,
|
||||
login_state.current_field() < 2, // Now using CanvasState trait method
|
||||
highlight_state, // Uses local version
|
||||
);
|
||||
} else if app_state.ui.show_admin {
|
||||
crate::components::admin::admin_panel::render_admin_panel(
|
||||
@@ -200,13 +209,15 @@ pub fn render_ui(
|
||||
])
|
||||
.split(form_actual_area)[1]
|
||||
};
|
||||
|
||||
|
||||
// CHANGED: Convert local HighlightState to canvas HighlightState for FormState
|
||||
let canvas_highlight_state = convert_highlight_state(highlight_state);
|
||||
form_state.render(
|
||||
f,
|
||||
form_render_area,
|
||||
theme,
|
||||
is_event_handler_edit_mode,
|
||||
highlight_state,
|
||||
&canvas_highlight_state, // Use converted version
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ use crate::config::storage::storage::load_auth_data;
|
||||
use crate::modes::common::commands::CommandHandler;
|
||||
use crate::modes::handlers::event::{EventHandler, EventOutcome};
|
||||
use crate::modes::handlers::mode_manager::{AppMode, ModeManager};
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use crate::state::pages::canvas_state::CanvasState as LocalCanvasState; // Keep local one with alias
|
||||
use canvas::canvas::CanvasState; // Import external library's CanvasState trait
|
||||
use crate::state::pages::form::{FormState, FieldDefinition}; // Import FieldDefinition
|
||||
use crate::state::pages::auth::AuthState;
|
||||
use crate::state::pages::auth::LoginState;
|
||||
@@ -27,17 +28,18 @@ use crate::ui::handlers::context::DialogPurpose;
|
||||
use crate::tui::functions::common::login;
|
||||
use crate::tui::functions::common::register;
|
||||
use crate::utils::columns::filter_user_columns;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use anyhow::{Context, Result};
|
||||
use crossterm::cursor::SetCursorStyle;
|
||||
use crossterm::event as crossterm_event;
|
||||
use tracing::{error, info, warn};
|
||||
use tokio::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::{Instant, Duration};
|
||||
#[cfg(feature = "ui-debug")]
|
||||
use crate::state::app::state::DebugState;
|
||||
#[cfg(feature = "ui-debug")]
|
||||
use crate::utils::debug_logger::pop_next_debug_message;
|
||||
|
||||
// Rest of the file remains the same...
|
||||
pub async fn run_ui() -> Result<()> {
|
||||
let config = Config::load().context("Failed to load configuration")?;
|
||||
let theme = Theme::from_str(&config.colors.theme);
|
||||
@@ -346,25 +348,25 @@ pub async fn run_ui() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Continue with the rest of the function...
|
||||
// (The rest remains the same, but now CanvasState trait methods are available)
|
||||
|
||||
if app_state.ui.show_form {
|
||||
let current_view_profile = app_state.current_view_profile_name.clone();
|
||||
let current_view_table = app_state.current_view_table_name.clone();
|
||||
|
||||
// This condition correctly detects a table switch.
|
||||
if prev_view_profile_name != current_view_profile
|
||||
|| prev_view_table_name != current_view_table
|
||||
{
|
||||
if let (Some(prof_name), Some(tbl_name)) =
|
||||
(current_view_profile.as_ref(), current_view_table.as_ref())
|
||||
{
|
||||
// --- START OF REFACTORED LOGIC ---
|
||||
app_state.show_loading_dialog(
|
||||
"Loading Table",
|
||||
&format!("Fetching data for {}.{}...", prof_name, tbl_name),
|
||||
);
|
||||
needs_redraw = true;
|
||||
|
||||
// 1. Call our new, central function. It handles fetching AND caching.
|
||||
match UiService::load_table_view(
|
||||
&mut grpc_client,
|
||||
&mut app_state,
|
||||
@@ -374,72 +376,62 @@ pub async fn run_ui() -> Result<()> {
|
||||
.await
|
||||
{
|
||||
Ok(mut new_form_state) => {
|
||||
// 2. The function succeeded, we have a new FormState.
|
||||
// Now, fetch its data.
|
||||
if let Err(e) = UiService::fetch_and_set_table_count(
|
||||
&mut grpc_client,
|
||||
&mut new_form_state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
// Handle count fetching error
|
||||
app_state.update_dialog_content(
|
||||
&format!("Error fetching count: {}", e),
|
||||
vec!["OK".to_string()],
|
||||
DialogPurpose::LoginFailed, // Or a more appropriate purpose
|
||||
DialogPurpose::LoginFailed,
|
||||
);
|
||||
} else if new_form_state.total_count > 0 {
|
||||
// If there are records, load the first/last one
|
||||
if let Err(e) = UiService::load_table_data_by_position(
|
||||
&mut grpc_client,
|
||||
&mut new_form_state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
// Handle data loading error
|
||||
app_state.update_dialog_content(
|
||||
&format!("Error loading data: {}", e),
|
||||
vec!["OK".to_string()],
|
||||
DialogPurpose::LoginFailed, // Or a more appropriate purpose
|
||||
DialogPurpose::LoginFailed,
|
||||
);
|
||||
} else {
|
||||
// Success! Hide the loading dialog.
|
||||
app_state.hide_dialog();
|
||||
}
|
||||
} else {
|
||||
// No records, so just reset to an empty form.
|
||||
new_form_state.reset_to_empty();
|
||||
app_state.hide_dialog();
|
||||
}
|
||||
|
||||
// 3. CRITICAL: Replace the old form_state with the new one.
|
||||
form_state = new_form_state;
|
||||
|
||||
// 4. Update our tracking variables.
|
||||
prev_view_profile_name = current_view_profile;
|
||||
prev_view_table_name = current_view_table;
|
||||
table_just_switched = true;
|
||||
}
|
||||
Err(e) => {
|
||||
// This handles errors from load_table_view (e.g., schema fetch failed)
|
||||
app_state.update_dialog_content(
|
||||
&format!("Error loading table: {}", e),
|
||||
vec!["OK".to_string()],
|
||||
DialogPurpose::LoginFailed, // Or a more appropriate purpose
|
||||
DialogPurpose::LoginFailed,
|
||||
);
|
||||
// Revert the view change in app_state to avoid a loop
|
||||
app_state.current_view_profile_name =
|
||||
prev_view_profile_name.clone();
|
||||
app_state.current_view_table_name =
|
||||
prev_view_table_name.clone();
|
||||
}
|
||||
}
|
||||
// --- END OF REFACTORED LOGIC ---
|
||||
}
|
||||
needs_redraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Continue with the rest of the positioning logic...
|
||||
// Now we can use CanvasState methods like get_current_input(), current_field(), etc.
|
||||
|
||||
if let Some((profile_name, table_name)) = app_state.pending_table_structure_fetch.take() {
|
||||
if app_state.ui.show_add_logic {
|
||||
if admin_state.add_logic_state.profile_name == profile_name &&
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
use rstest::{fixture, rstest};
|
||||
use std::collections::HashMap;
|
||||
use client::state::pages::form::{FormState, FieldDefinition};
|
||||
use canvas::state::CanvasState
|
||||
use client::state::pages::canvas_state::CanvasState;
|
||||
|
||||
#[fixture]
|
||||
|
||||
Reference in New Issue
Block a user