Compare commits

..

25 Commits

Author SHA1 Message Date
Priec
baa4295059 removed _e files completely 2025-07-30 20:25:58 +02:00
Priec
6cbfac9d6e read only deleted completely 2025-07-30 19:39:27 +02:00
Priec
13d28f19ea removing _ro files completely 2025-07-30 19:30:55 +02:00
Priec
8fa86965b8 removing canvasstate permanently 2025-07-30 19:20:23 +02:00
Priec
72c38f613f canvasstate is now officially nonexistent as dep 2025-07-30 19:14:35 +02:00
Priec
e4982f871f add_logic is now using canvas library 2025-07-30 18:02:59 +02:00
Priec
4e0338276f autotrigger vs manual trigger 2025-07-30 17:16:20 +02:00
Priec
fe193f4f91 unimportant 2025-07-30 16:34:21 +02:00
Priec
0011ba0c04 add_table now ported to the canvas library also 2025-07-30 14:06:05 +02:00
Priec
3c2eef9596 registering canvas functions now instead of internal state 2025-07-30 13:24:49 +02:00
Priec
dac788351f autocomplete gui as original, needs logic change in the future 2025-07-30 13:00:23 +02:00
Priec
8d5bc1296e usage of the canvas is fully implemented, time to fix bugs. Working now fully 2025-07-30 12:51:18 +02:00
Priec
969ad229e4 compiled 2025-07-30 12:45:13 +02:00
Priec
0d291fcf57 auth not working with canvas crate yet 2025-07-30 12:08:35 +02:00
Priec
d711f4c491 usage of canvas lib for auth BROKEN 2025-07-30 11:14:05 +02:00
Priec
9369626e21 migration md 2025-07-29 23:56:53 +02:00
Priec
f84bb0dc9e compiled successfulywith rich suggestions now 2025-07-29 23:49:58 +02:00
Priec
20b428264e library is now conflicting with client and its breaking it, but lets change the client 2025-07-29 23:25:10 +02:00
Priec
05bb84fc98 different structure of the library 2025-07-29 23:04:48 +02:00
Priec
46a85e4b4a autocomplete separate traits, one for autocomplete one for canvas purely 2025-07-29 22:31:35 +02:00
Priec
b4d1572c79 autocomplete is now robust, but unified, time to split it up 2025-07-29 22:18:44 +02:00
Priec
b8e1b77222 compiled autocomplete 2025-07-29 21:46:55 +02:00
Priec
1a451a576f working, cleaning trash via cargo fix 2025-07-29 20:14:24 +02:00
Priec
074b2914d8 gui canvas with rounded corners 2025-07-29 20:00:16 +02:00
Priec
aec5f80879 gui of canvas is from the canvas crate now 2025-07-29 19:54:29 +02:00
77 changed files with 2848 additions and 6651 deletions

13
Cargo.lock generated
View File

@@ -472,7 +472,7 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]]
name = "canvas"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"common",
@@ -482,6 +482,9 @@ dependencies = [
"tokio",
"tokio-test",
"toml",
"tracing",
"tracing-subscriber",
"unicode-width 0.2.0",
]
[[package]]
@@ -555,7 +558,7 @@ dependencies = [
[[package]]
name = "client"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"async-trait",
@@ -606,7 +609,7 @@ dependencies = [
[[package]]
name = "common"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"prost",
"prost-types",
@@ -2892,7 +2895,7 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "search"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"common",
@@ -2991,7 +2994,7 @@ dependencies = [
[[package]]
name = "server"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"bcrypt",

View File

@@ -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" }

View File

@@ -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!

View File

@@ -11,28 +11,20 @@ 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
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
[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"]

View File

@@ -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

View File

@@ -16,7 +16,7 @@ highlight_current_field = true
move_left = ["h"]
move_right = ["l"]
move_up = ["k"]
move_down = ["p"]
move_down = ["j"]
move_word_next = ["w"]
move_word_end = ["e"]
move_word_prev = ["b"]
@@ -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"]
@@ -42,6 +42,8 @@ move_word_next = ["Ctrl+Right"]
move_word_prev = ["Ctrl+Left"]
next_field = ["Tab"]
prev_field = ["Shift+Tab"]
trigger_autocomplete = ["Ctrl+p"]
# Suggestion/autocomplete keybindings
[keybindings.suggestions]

View File

@@ -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);
}

View File

@@ -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(&current_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(())
}

View File

@@ -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!");
}

View File

@@ -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(())
}

View File

@@ -1,455 +0,0 @@
// canvas/src/actions/edit.rs
use crate::state::{CanvasState, ActionContext};
use crate::actions::types::{CanvasAction, ActionResult};
use crossterm::event::{KeyCode, KeyEvent};
use anyhow::Result;
/// Execute a typed canvas action on any CanvasState implementation
pub async fn execute_canvas_action<S: CanvasState>(
action: CanvasAction,
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
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 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>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> Result<ActionResult> {
match action {
CanvasAction::InsertChar(c) => {
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();
state.set_current_cursor_pos(cursor_pos + 1);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = state.current_cursor_pos();
Ok(ActionResult::success())
} else {
Ok(ActionResult::error("Invalid cursor position for character insertion"))
}
}
CanvasAction::DeleteBackward => {
if state.current_cursor_pos() > 0 {
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();
let new_pos = cursor_pos - 1;
state.set_current_cursor_pos(new_pos);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = new_pos;
}
}
Ok(ActionResult::success())
}
CanvasAction::DeleteForward => {
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();
state.set_has_unsaved_changes(true);
*ideal_cursor_column = cursor_pos;
}
Ok(ActionResult::success())
}
CanvasAction::NextField => {
let num_fields = state.fields().len();
if num_fields > 0 {
let current_field = state.current_field();
let new_field = (current_field + 1) % num_fields;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok(ActionResult::success())
}
CanvasAction::PrevField => {
let num_fields = state.fields().len();
if num_fields > 0 {
let current_field = state.current_field();
let new_field = if current_field == 0 {
num_fields - 1
} else {
current_field - 1
};
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok(ActionResult::success())
}
CanvasAction::MoveLeft => {
let new_pos = state.current_cursor_pos().saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
}
CanvasAction::MoveRight => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
if current_pos < current_input.len() {
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok(ActionResult::success())
}
CanvasAction::MoveUp => {
let num_fields = state.fields().len();
if num_fields > 0 {
let current_field = state.current_field();
let new_field = current_field.saturating_sub(1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok(ActionResult::success())
}
CanvasAction::MoveDown => {
let num_fields = state.fields().len();
if num_fields > 0 {
let new_field = (state.current_field() + 1).min(num_fields - 1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok(ActionResult::success())
}
CanvasAction::MoveLineStart => {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
Ok(ActionResult::success())
}
CanvasAction::MoveLineEnd => {
let current_input = state.get_current_input();
let new_pos = current_input.len();
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
}
CanvasAction::MoveFirstLine => {
let num_fields = state.fields().len();
if num_fields > 0 {
state.set_current_field(0);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok(ActionResult::success_with_message("Moved to first field"))
}
CanvasAction::MoveLastLine => {
let num_fields = state.fields().len();
if num_fields > 0 {
let new_field = num_fields - 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok(ActionResult::success_with_message("Moved to last field"))
}
CanvasAction::MoveWordNext => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
let final_pos = new_pos.min(current_input.len());
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
}
Ok(ActionResult::success())
}
CanvasAction::MoveWordEnd => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let current_pos = state.current_cursor_pos();
let new_pos = find_word_end(current_input, current_pos);
let final_pos = if new_pos == current_pos {
find_word_end(current_input, new_pos + 1)
} else {
new_pos
};
let max_valid_index = current_input.len().saturating_sub(1);
let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_pos;
}
Ok(ActionResult::success())
}
CanvasAction::MoveWordPrev => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok(ActionResult::success())
}
CanvasAction::MoveWordEndPrev => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_end(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok(ActionResult::success_with_message("Moved to previous word end"))
}
CanvasAction::Custom(action_str) => {
Ok(ActionResult::error(format!("Unknown or unhandled custom action: {}", action_str)))
}
// Suggestion actions should have been handled above
CanvasAction::SuggestionUp | CanvasAction::SuggestionDown |
CanvasAction::SelectSuggestion | CanvasAction::ExitSuggestions => {
Ok(ActionResult::error("Suggestion action not handled properly"))
}
}
}
// Word movement helper functions
#[derive(PartialEq)]
enum CharType {
Whitespace,
Alphanumeric,
Punctuation,
}
fn get_char_type(c: char) -> CharType {
if c.is_whitespace() {
CharType::Whitespace
} else if c.is_alphanumeric() {
CharType::Alphanumeric
} else {
CharType::Punctuation
}
}
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 || current_pos >= len {
return len;
}
let mut pos = current_pos;
let initial_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == initial_type {
pos += 1;
}
while pos < len && get_char_type(chars[pos]) == CharType::Whitespace {
pos += 1;
}
pos
}
fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 {
return 0;
}
let mut pos = current_pos.min(len - 1);
if get_char_type(chars[pos]) == CharType::Whitespace {
pos = find_next_word_start(text, pos);
}
if pos >= len {
return len.saturating_sub(1);
}
let word_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == word_type {
pos += 1;
}
pos.saturating_sub(1).min(len.saturating_sub(1))
}
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
return 0;
}
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
pos
}
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
return 0;
}
if pos == 0 && get_char_type(chars[pos]) != CharType::Whitespace {
return 0;
}
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
pos -= 1;
}
if pos > 0 {
pos - 1
} else {
0
}
}

View File

@@ -1,225 +0,0 @@
// canvas/src/actions/types.rs
use crossterm::event::KeyCode;
/// All possible canvas actions, type-safe and exhaustive
#[derive(Debug, Clone, PartialEq, Eq)]
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
SuggestionUp,
SuggestionDown,
SelectSuggestion,
ExitSuggestions,
// Custom actions (escape hatch for feature-specific behavior)
Custom(String),
}
impl CanvasAction {
/// Convert a string action to typed action (for backwards compatibility during migration)
pub fn from_string(action: &str) -> Self {
match action {
"insert_char" => {
// This is a bit tricky - we need the char from context
// For now, we'll use Custom until we refactor the call sites
Self::Custom(action.to_string())
}
"delete_char_backward" => Self::DeleteBackward,
"delete_char_forward" => Self::DeleteForward,
"move_left" => Self::MoveLeft,
"move_right" => Self::MoveRight,
"move_up" => Self::MoveUp,
"move_down" => Self::MoveDown,
"move_line_start" => Self::MoveLineStart,
"move_line_end" => Self::MoveLineEnd,
"move_first_line" => Self::MoveFirstLine,
"move_last_line" => Self::MoveLastLine,
"move_word_next" => Self::MoveWordNext,
"move_word_end" => Self::MoveWordEnd,
"move_word_prev" => Self::MoveWordPrev,
"move_word_end_prev" => Self::MoveWordEndPrev,
"next_field" => Self::NextField,
"prev_field" => Self::PrevField,
"suggestion_up" => Self::SuggestionUp,
"suggestion_down" => Self::SuggestionDown,
"select_suggestion" => Self::SelectSuggestion,
"exit_suggestions" => Self::ExitSuggestions,
_ => Self::Custom(action.to_string()),
}
}
/// Get string representation (for logging, debugging)
pub fn as_str(&self) -> &str {
match self {
Self::InsertChar(_) => "insert_char",
Self::DeleteBackward => "delete_char_backward",
Self::DeleteForward => "delete_char_forward",
Self::MoveLeft => "move_left",
Self::MoveRight => "move_right",
Self::MoveUp => "move_up",
Self::MoveDown => "move_down",
Self::MoveLineStart => "move_line_start",
Self::MoveLineEnd => "move_line_end",
Self::MoveFirstLine => "move_first_line",
Self::MoveLastLine => "move_last_line",
Self::MoveWordNext => "move_word_next",
Self::MoveWordEnd => "move_word_end",
Self::MoveWordPrev => "move_word_prev",
Self::MoveWordEndPrev => "move_word_end_prev",
Self::NextField => "next_field",
Self::PrevField => "prev_field",
Self::SuggestionUp => "suggestion_up",
Self::SuggestionDown => "suggestion_down",
Self::SelectSuggestion => "select_suggestion",
Self::ExitSuggestions => "exit_suggestions",
Self::Custom(s) => s,
}
}
/// Create action from KeyCode for common cases
pub fn from_key(key: KeyCode) -> Option<Self> {
match key {
KeyCode::Char(c) => Some(Self::InsertChar(c)),
KeyCode::Backspace => Some(Self::DeleteBackward),
KeyCode::Delete => Some(Self::DeleteForward),
KeyCode::Left => Some(Self::MoveLeft),
KeyCode::Right => Some(Self::MoveRight),
KeyCode::Up => Some(Self::MoveUp),
KeyCode::Down => Some(Self::MoveDown),
KeyCode::Home => Some(Self::MoveLineStart),
KeyCode::End => Some(Self::MoveLineEnd),
KeyCode::Tab => Some(Self::NextField),
KeyCode::BackTab => Some(Self::PrevField),
_ => None,
}
}
/// Check if this action modifies content
pub fn is_modifying(&self) -> bool {
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,
Self::MoveLeft | Self::MoveRight | Self::MoveUp | Self::MoveDown |
Self::MoveLineStart | Self::MoveLineEnd | Self::MoveFirstLine | Self::MoveLastLine |
Self::MoveWordNext | Self::MoveWordEnd | Self::MoveWordPrev | Self::MoveWordEndPrev |
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::SelectSuggestion | Self::ExitSuggestions
)
}
}
/// Result of executing a canvas action
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ActionResult {
/// Action completed successfully, optional message for user feedback
Success(Option<String>),
/// Action was handled by custom feature logic
HandledByFeature(String),
/// Action requires additional context or cannot be performed
RequiresContext(String),
/// Action failed with error message
Error(String),
}
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(),
Self::HandledByFeature(msg) => Some(msg),
Self::RequiresContext(msg) => Some(msg),
Self::Error(msg) => Some(msg),
}
}
}
#[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("unknown"), CanvasAction::Custom("unknown".to_string()));
}
#[test]
fn test_action_from_key() {
assert_eq!(CanvasAction::from_key(KeyCode::Char('a')), Some(CanvasAction::InsertChar('a')));
assert_eq!(CanvasAction::from_key(KeyCode::Left), Some(CanvasAction::MoveLeft));
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::MoveLeft.is_suggestion());
}
}

View File

@@ -0,0 +1,133 @@
// canvas/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;
use crate::config::CanvasConfig;
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,
config: Option<&CanvasConfig>,
) -> 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) = handle_rich_autocomplete_action(action.clone(), state, &context) {
return Ok(result);
}
// 2. Handle generic actions and add auto-trigger logic
let result = handle_generic_canvas_action(action.clone(), state, ideal_cursor_column, config).await?;
// 3. AUTO-TRIGGER LOGIC: Check if we should activate/deactivate autocomplete
if let Some(cfg) = config {
println!("{:?}, {}", action, cfg.should_auto_trigger_autocomplete());
if cfg.should_auto_trigger_autocomplete() {
println!("AUTO-TRIGGER");
match action {
CanvasAction::InsertChar(_) => {
println!("AUTO-T on Ins");
let current_field = state.current_field();
let current_input = state.get_current_input();
if state.supports_autocomplete(current_field)
&& !state.is_autocomplete_active()
&& current_input.len() >= 1
{
println!("ACT AUTOC");
state.activate_autocomplete();
}
}
CanvasAction::NextField | CanvasAction::PrevField => {
println!("AUTO-T on nav");
let current_field = state.current_field();
if state.supports_autocomplete(current_field) && !state.is_autocomplete_active() {
state.activate_autocomplete();
} else if !state.supports_autocomplete(current_field) && state.is_autocomplete_active() {
state.deactivate_autocomplete();
}
}
_ => {} // No auto-trigger for other actions
}
}
}
Ok(result)
}
/// Handle rich autocomplete actions for AutocompleteCanvasState
fn handle_rich_autocomplete_action<S: CanvasState + AutocompleteCanvasState>(
action: CanvasAction,
state: &mut S,
_context: &ActionContext,
) -> Option<ActionResult> {
match action {
CanvasAction::TriggerAutocomplete => {
let current_field = state.current_field();
if state.supports_autocomplete(current_field) {
state.activate_autocomplete();
Some(ActionResult::success_with_message("Autocomplete activated"))
} else {
Some(ActionResult::success_with_message("Autocomplete not supported for this field"))
}
}
CanvasAction::SuggestionUp => {
if state.is_autocomplete_ready() {
if let Some(autocomplete_state) = state.autocomplete_state_mut() {
autocomplete_state.select_previous();
}
Some(ActionResult::success())
} else {
Some(ActionResult::success_with_message("No suggestions available"))
}
}
CanvasAction::SuggestionDown => {
if state.is_autocomplete_ready() {
if let Some(autocomplete_state) = state.autocomplete_state_mut() {
autocomplete_state.select_next();
}
Some(ActionResult::success())
} else {
Some(ActionResult::success_with_message("No suggestions available"))
}
}
CanvasAction::SelectSuggestion => {
if state.is_autocomplete_ready() {
if let Some(msg) = state.apply_autocomplete_selection() {
Some(ActionResult::success_with_message(&msg))
} else {
Some(ActionResult::success_with_message("No suggestion selected"))
}
} else {
Some(ActionResult::success_with_message("No suggestions available"))
}
}
CanvasAction::ExitSuggestions => {
if state.is_autocomplete_active() {
state.deactivate_autocomplete();
Some(ActionResult::success_with_message("Exited autocomplete"))
} else {
Some(ActionResult::success())
}
}
_ => None, // Not a rich autocomplete action
}
}

View File

@@ -0,0 +1,191 @@
// 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()
.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()
.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 - updated to match client dimensions
#[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 = 2; // Changed from 4 to 2 to match client
let width = (max_width + horizontal_padding).max(10); // Changed from 12 to 10 to match client
let height = (display_texts.len() as u16).min(5); // Removed +2 since no 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 - updated to match client spacing
#[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 = 2; // Changed from 4 to 2 to match client
let available_width = dropdown_width; // No border padding needed
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,
}

View 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;

View 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
}
}
}

View 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
}
}

View File

@@ -0,0 +1,253 @@
// canvas/src/canvas/actions/edit.rs
use crate::canvas::state::{CanvasState, ActionContext};
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::config::CanvasConfig;
use anyhow::Result;
/// Execute a typed canvas action on any CanvasState implementation
pub async fn execute_canvas_action<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
config: Option<&CanvasConfig>,
) -> Result<ActionResult> {
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));
}
handle_generic_canvas_action(action, state, ideal_cursor_column, config).await
}
/// Handle core canvas actions with full type safety
pub async fn handle_generic_canvas_action<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
config: Option<&CanvasConfig>,
) -> Result<ActionResult> {
match action {
CanvasAction::InsertChar(c) => {
let cursor_pos = state.current_cursor_pos();
let input = state.get_current_input_mut();
input.insert(cursor_pos, c);
state.set_current_cursor_pos(cursor_pos + 1);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = cursor_pos + 1;
Ok(ActionResult::success())
}
CanvasAction::NextField | CanvasAction::PrevField => {
let old_field = state.current_field();
let total_fields = state.fields().len();
// Perform field navigation
let new_field = match action {
CanvasAction::NextField => {
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
(old_field + 1) % total_fields
} else {
(old_field + 1).min(total_fields - 1)
}
}
CanvasAction::PrevField => {
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
if old_field == 0 { total_fields - 1 } else { old_field - 1 }
} else {
old_field.saturating_sub(1)
}
}
_ => unreachable!(),
};
state.set_current_field(new_field);
*ideal_cursor_column = state.current_cursor_pos();
Ok(ActionResult::success())
}
CanvasAction::DeleteBackward => {
let cursor_pos = state.current_cursor_pos();
if cursor_pos > 0 {
let input = state.get_current_input_mut();
input.remove(cursor_pos - 1);
state.set_current_cursor_pos(cursor_pos - 1);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = cursor_pos - 1;
}
Ok(ActionResult::success())
}
CanvasAction::DeleteForward => {
let cursor_pos = state.current_cursor_pos();
let input = state.get_current_input_mut();
if cursor_pos < input.len() {
input.remove(cursor_pos);
state.set_has_unsaved_changes(true);
}
Ok(ActionResult::success())
}
CanvasAction::MoveLeft => {
let cursor_pos = state.current_cursor_pos();
if cursor_pos > 0 {
state.set_current_cursor_pos(cursor_pos - 1);
*ideal_cursor_column = cursor_pos - 1;
}
Ok(ActionResult::success())
}
CanvasAction::MoveRight => {
let cursor_pos = state.current_cursor_pos();
let current_input = state.get_current_input();
if cursor_pos < current_input.len() {
state.set_current_cursor_pos(cursor_pos + 1);
*ideal_cursor_column = cursor_pos + 1;
}
Ok(ActionResult::success())
}
CanvasAction::MoveLineStart => {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
Ok(ActionResult::success())
}
CanvasAction::MoveLineEnd => {
let end_pos = state.get_current_input().len();
state.set_current_cursor_pos(end_pos);
*ideal_cursor_column = end_pos;
Ok(ActionResult::success())
}
CanvasAction::MoveUp => {
// For single-line fields, move to previous field
let current_field = state.current_field();
if current_field > 0 {
state.set_current_field(current_field - 1);
*ideal_cursor_column = state.current_cursor_pos();
}
Ok(ActionResult::success())
}
CanvasAction::MoveDown => {
// For single-line fields, move to next field
let current_field = state.current_field();
let total_fields = state.fields().len();
if current_field < total_fields - 1 {
state.set_current_field(current_field + 1);
*ideal_cursor_column = state.current_cursor_pos();
}
Ok(ActionResult::success())
}
CanvasAction::MoveFirstLine => {
state.set_current_field(0);
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
Ok(ActionResult::success())
}
CanvasAction::MoveLastLine => {
let last_field = state.fields().len() - 1;
state.set_current_field(last_field);
let end_pos = state.get_current_input().len();
state.set_current_cursor_pos(end_pos);
*ideal_cursor_column = end_pos;
Ok(ActionResult::success())
}
CanvasAction::MoveWordNext => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok(ActionResult::success())
}
CanvasAction::MoveWordEnd => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_word_end(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok(ActionResult::success())
}
CanvasAction::MoveWordPrev => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok(ActionResult::success())
}
CanvasAction::Custom(action_str) => {
Ok(ActionResult::success_with_message(&format!("Custom action: {}", action_str)))
}
_ => Ok(ActionResult::success_with_message("Action not implemented")),
}
}
// Helper functions for word navigation
fn find_next_word_start(text: &str, cursor_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let mut pos = cursor_pos;
// Skip current word
while pos < chars.len() && chars[pos].is_alphanumeric() {
pos += 1;
}
// Skip whitespace
while pos < chars.len() && chars[pos].is_whitespace() {
pos += 1;
}
pos
}
fn find_word_end(text: &str, cursor_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let mut pos = cursor_pos;
// Move to end of current word
while pos < chars.len() && chars[pos].is_alphanumeric() {
pos += 1;
}
pos
}
fn find_prev_word_start(text: &str, cursor_pos: usize) -> usize {
if cursor_pos == 0 {
return 0;
}
let chars: Vec<char> = text.chars().collect();
let mut pos = cursor_pos.saturating_sub(1);
// Skip whitespace
while pos > 0 && chars[pos].is_whitespace() {
pos -= 1;
}
// Skip to start of word
while pos > 0 && chars[pos - 1].is_alphanumeric() {
pos -= 1;
}
pos
}

View File

@@ -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;

View File

@@ -0,0 +1,123 @@
// src/canvas/actions/types.rs
#[derive(Debug, Clone, PartialEq)]
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,
// Field navigation
NextField,
PrevField,
// Autocomplete actions
TriggerAutocomplete,
SuggestionUp,
SuggestionDown,
SelectSuggestion,
ExitSuggestions,
// Custom actions
Custom(String),
}
impl CanvasAction {
pub fn from_key(key: crossterm::event::KeyCode) -> Option<Self> {
match key {
crossterm::event::KeyCode::Char(c) => Some(Self::InsertChar(c)),
crossterm::event::KeyCode::Backspace => Some(Self::DeleteBackward),
crossterm::event::KeyCode::Delete => Some(Self::DeleteForward),
crossterm::event::KeyCode::Left => Some(Self::MoveLeft),
crossterm::event::KeyCode::Right => Some(Self::MoveRight),
crossterm::event::KeyCode::Up => Some(Self::MoveUp),
crossterm::event::KeyCode::Down => Some(Self::MoveDown),
crossterm::event::KeyCode::Home => Some(Self::MoveLineStart),
crossterm::event::KeyCode::End => Some(Self::MoveLineEnd),
crossterm::event::KeyCode::Tab => Some(Self::NextField),
crossterm::event::KeyCode::BackTab => Some(Self::PrevField),
_ => None,
}
}
// Backward compatibility method
pub fn from_string(action: &str) -> Self {
match action {
"delete_char_backward" => Self::DeleteBackward,
"delete_char_forward" => Self::DeleteForward,
"move_left" => Self::MoveLeft,
"move_right" => Self::MoveRight,
"move_up" => Self::MoveUp,
"move_down" => Self::MoveDown,
"move_line_start" => Self::MoveLineStart,
"move_line_end" => Self::MoveLineEnd,
"move_first_line" => Self::MoveFirstLine,
"move_last_line" => Self::MoveLastLine,
"move_word_next" => Self::MoveWordNext,
"move_word_end" => Self::MoveWordEnd,
"move_word_prev" => Self::MoveWordPrev,
"next_field" => Self::NextField,
"prev_field" => Self::PrevField,
"trigger_autocomplete" => Self::TriggerAutocomplete,
"suggestion_up" => Self::SuggestionUp,
"suggestion_down" => Self::SuggestionDown,
"select_suggestion" => Self::SelectSuggestion,
"exit_suggestions" => Self::ExitSuggestions,
_ => Self::Custom(action.to_string()),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ActionResult {
Success(Option<String>),
HandledByFeature(String),
RequiresContext(String),
Error(String),
}
impl ActionResult {
pub fn success() -> Self {
Self::Success(None)
}
pub fn success_with_message(msg: &str) -> Self {
Self::Success(Some(msg.to_string()))
}
pub fn error(msg: &str) -> 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(),
Self::HandledByFeature(msg) => Some(msg),
Self::RequiresContext(msg) => Some(msg),
Self::Error(msg) => Some(msg),
}
}
}

338
canvas/src/canvas/gui.rs Normal file
View 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,
&current_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
View 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;

View File

@@ -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
}

View 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;
}

View File

@@ -180,6 +180,20 @@ impl CanvasConfig {
Self::from_toml(&contents)
}
/// NEW: Check if autocomplete should auto-trigger (simple logic)
pub fn should_auto_trigger_autocomplete(&self) -> bool {
// If trigger_autocomplete keybinding exists anywhere, use manual mode only
// If no trigger_autocomplete keybinding, use auto-trigger mode
!self.has_trigger_autocomplete_keybinding()
}
/// NEW: Check if user has configured manual trigger keybinding
pub fn has_trigger_autocomplete_keybinding(&self) -> bool {
self.keybindings.edit.contains_key("trigger_autocomplete") ||
self.keybindings.read_only.contains_key("trigger_autocomplete") ||
self.keybindings.global.contains_key("trigger_autocomplete")
}
/// Get action for key in read-only mode
pub fn get_read_only_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
self.get_action_in_mode(&self.keybindings.read_only, key, modifiers)
@@ -476,5 +490,5 @@ impl CanvasConfig {
}
// Re-export for convenience
pub use crate::actions::CanvasAction;
pub use crate::canvas::actions::CanvasAction;
pub use crate::dispatcher::ActionDispatcher;

View File

@@ -1,7 +1,8 @@
// 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};
use crate::config::CanvasConfig;
/// High-level action dispatcher that coordinates between different action types
pub struct ActionDispatcher;
@@ -13,7 +14,9 @@ impl ActionDispatcher {
state: &mut S,
ideal_cursor_column: &mut usize,
) -> anyhow::Result<ActionResult> {
execute_canvas_action(action, state, ideal_cursor_column).await
// Load config once here instead of threading it everywhere
execute_canvas_action(action, state, ideal_cursor_column, Some(&CanvasConfig::load())).await
}
/// Quick action dispatch from KeyCode

View File

@@ -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,
};
}

View File

@@ -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)
}
}

View File

@@ -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 = []

View File

@@ -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"]
@@ -42,6 +42,7 @@ move_word_next = ["Ctrl+Right"]
move_word_prev = ["Ctrl+Left"]
next_field = ["Tab"]
prev_field = ["Shift+Tab"]
trigger_autocomplete = ["Ctrl+p"]
# Suggestion/autocomplete keybindings
[keybindings.suggestions]
@@ -49,6 +50,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]

View File

@@ -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"]

View File

@@ -0,0 +1,124 @@
## How Canvas Library Custom Functionality Works
### 1. **The Canvas Library Calls YOUR Custom Code First**
When you call `ActionDispatcher::dispatch()`, here's what happens:
```rust
// Inside canvas library (canvas/src/actions/edit.rs):
pub async fn execute_canvas_action<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> Result<ActionResult> {
// 1. FIRST: Canvas library calls YOUR custom handler
if let Some(result) = state.handle_feature_action(&action, &context) {
return Ok(ActionResult::HandledByFeature(result)); // YOUR code handled it
}
// 2. ONLY IF your code returns None: Canvas handles generic actions
handle_generic_canvas_action(action, state, ideal_cursor_column).await
}
```
### 2. **Your Extension Point: `handle_feature_action`**
You add custom functionality by implementing `handle_feature_action` in your states:
```rust
// In src/state/pages/auth.rs
impl CanvasState for LoginState {
// ... other methods ...
fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
match action {
// Custom login-specific actions
CanvasAction::Custom(action_str) if action_str == "submit_login" => {
if self.username.is_empty() || self.password.is_empty() {
Some("Please fill in all required fields".to_string())
} else {
// Trigger login process
Some(format!("Logging in user: {}", self.username))
}
}
CanvasAction::Custom(action_str) if action_str == "clear_form" => {
self.username.clear();
self.password.clear();
self.set_has_unsaved_changes(false);
Some("Login form cleared".to_string())
}
// Custom behavior for standard actions
CanvasAction::NextField => {
// Custom validation when moving between fields
if self.current_field == 0 && self.username.is_empty() {
Some("Username cannot be empty".to_string())
} else {
None // Let canvas library handle the normal field movement
}
}
// Let canvas library handle everything else
_ => None,
}
}
}
```
### 3. **Multiple Ways to Add Custom Functionality**
#### A) **Custom Actions via Config**
```toml
# In config.toml
[keybindings.edit]
submit_login = ["ctrl+enter"]
clear_form = ["ctrl+r"]
```
#### B) **Override Standard Actions**
```rust
fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
match action {
CanvasAction::InsertChar('p') if self.current_field == 1 => {
// Custom behavior when typing 'p' in password field
Some("Password field - use secure input".to_string())
}
_ => None, // Let canvas handle normally
}
}
```
#### C) **Context-Aware Logic**
```rust
fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
match action {
CanvasAction::MoveDown => {
// Custom logic based on current state
if context.current_field == 1 && context.current_input.len() < 8 {
Some("Password should be at least 8 characters".to_string())
} else {
None // Normal field movement
}
}
_ => None,
}
}
```
## The Canvas Library Philosophy
**Canvas Library = Generic behavior + Your extension points**
-**Canvas handles**: Character insertion, cursor movement, field navigation, etc.
-**You handle**: Validation, submission, clearing, app-specific logic
-**You decide**: Return `Some(message)` to override, `None` to use canvas default
## Summary
You **don't communicate with the library elsewhere**. Instead:
1. **Canvas library calls your code first** via `handle_feature_action`
2. **Your code decides** whether to handle the action or let canvas handle it
3. **Canvas library handles** generic form behavior when you return `None`

View File

@@ -3,7 +3,7 @@ use crate::config::colors::themes::Theme;
use crate::state::app::highlight::HighlightState;
use crate::state::app::state::AppState;
use crate::state::pages::add_logic::{AddLogicFocus, AddLogicState};
use crate::state::pages::canvas_state::CanvasState;
use canvas::canvas::{render_canvas, CanvasState, HighlightState as CanvasHighlightState}; // Use canvas library
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
@@ -11,10 +11,18 @@ use ratatui::{
widgets::{Block, BorderType, Borders, Paragraph},
Frame,
};
use crate::components::handlers::canvas::render_canvas;
use crate::components::common::{dialog, autocomplete}; // Added autocomplete
use crate::config::binds::config::EditorKeybindingMode;
// 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_add_logic(
f: &mut Frame,
area: Rect,
@@ -77,18 +85,18 @@ pub fn render_add_logic(
let editor_borrow = add_logic_state.script_content_editor.borrow();
editor_borrow.cursor() // Returns (row, col) as (usize, usize)
};
let (cursor_line, cursor_col) = current_cursor;
// Account for TextArea's block borders (1 for each side)
let block_offset_x = 1;
let block_offset_y = 1;
// Position autocomplete at current cursor position
// Add 1 to column to position dropdown right after the cursor
let autocomplete_x = cursor_col + 1;
let autocomplete_y = cursor_line;
let input_rect = Rect {
x: (inner_area.x + block_offset_x + autocomplete_x as u16).min(inner_area.right().saturating_sub(20)),
y: (inner_area.y + block_offset_y + autocomplete_y as u16).min(inner_area.bottom().saturating_sub(5)),
@@ -152,40 +160,37 @@ pub fn render_add_logic(
);
f.render_widget(profile_text, top_info_area);
// Canvas
// Canvas - USING CANVAS LIBRARY
let focus_on_canvas_inputs = matches!(
add_logic_state.current_focus,
AddLogicFocus::InputLogicName
| AddLogicFocus::InputTargetColumn
| AddLogicFocus::InputDescription
);
// Call render_canvas and get the active_field_rect
let canvas_highlight_state = convert_highlight_state(highlight_state);
let active_field_rect = render_canvas(
f,
canvas_area,
add_logic_state, // Pass the whole state as it impl CanvasState
&add_logic_state.fields(),
&add_logic_state.current_field(),
&add_logic_state.inputs(),
theme,
is_edit_mode && focus_on_canvas_inputs, // is_edit_mode for canvas fields
highlight_state,
add_logic_state, // AddLogicState implements CanvasState
theme, // Theme implements CanvasTheme
is_edit_mode && focus_on_canvas_inputs,
&canvas_highlight_state,
);
// --- Render Autocomplete for Target Column ---
// `is_edit_mode` here refers to the general edit mode of the EventHandler
if is_edit_mode && add_logic_state.current_field() == 1 { // Target Column field
if let Some(suggestions) = add_logic_state.get_suggestions() { // Uses CanvasState impl
let selected = add_logic_state.get_selected_suggestion_index();
if !suggestions.is_empty() { // Only render if there are suggestions to show
if add_logic_state.in_target_column_suggestion_mode && add_logic_state.show_target_column_suggestions {
if !add_logic_state.target_column_suggestions.is_empty() {
if let Some(input_rect) = active_field_rect {
autocomplete::render_autocomplete_dropdown(
f,
input_rect,
f.area(), // Full frame area for clamping
theme,
suggestions,
selected,
&add_logic_state.target_column_suggestions,
add_logic_state.selected_target_column_suggestion_index,
);
}
}

View File

@@ -3,8 +3,7 @@ use crate::config::colors::themes::Theme;
use crate::state::app::highlight::HighlightState;
use crate::state::app::state::AppState;
use crate::state::pages::add_table::{AddTableFocus, AddTableState};
use crate::state::pages::canvas_state::CanvasState;
// use crate::state::pages::add_table::{ColumnDefinition, LinkDefinition}; // Not directly used here
use canvas::canvas::{render_canvas, CanvasState, HighlightState as CanvasHighlightState};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
@@ -12,16 +11,24 @@ use ratatui::{
widgets::{Block, BorderType, Borders, Cell, Paragraph, Row, Table},
Frame,
};
use crate::components::handlers::canvas::render_canvas;
use crate::components::common::dialog;
// 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 },
}
}
/// Renders the Add New Table page layout, structuring the display of table information,
/// input fields, and action buttons. Adapts layout based on terminal width.
pub fn render_add_table(
f: &mut Frame,
area: Rect,
theme: &Theme,
app_state: &AppState, // Currently unused, might be needed later
app_state: &AppState,
add_table_state: &mut AddTableState,
is_edit_mode: bool, // Determines if canvas inputs are in edit mode
highlight_state: &HighlightState, // For text highlighting in canvas
@@ -349,17 +356,15 @@ pub fn render_add_table(
&mut add_table_state.column_table_state,
);
// --- Canvas Rendering (Column Definition Input) ---
// --- Canvas Rendering (Column Definition Input) - USING CANVAS LIBRARY ---
let canvas_highlight_state = convert_highlight_state(highlight_state);
let _active_field_rect = render_canvas(
f,
canvas_area,
add_table_state,
&add_table_state.fields(),
&add_table_state.current_field(),
&add_table_state.inputs(),
theme,
add_table_state, // AddTableState implements CanvasState
theme, // Theme implements CanvasTheme
is_edit_mode && focus_on_canvas_inputs,
highlight_state,
&canvas_highlight_state,
);
// --- Button Style Helpers ---
@@ -557,7 +562,7 @@ pub fn render_add_table(
// --- DIALOG ---
// Render the dialog overlay if it's active
if app_state.ui.dialog.dialog_show { // Use the passed-in app_state
if app_state.ui.dialog.dialog_show {
dialog::render_dialog(
f,
f.area(), // Render over the whole frame area

View File

@@ -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,
);
}

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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!
}
}
}

View File

@@ -1,8 +1,6 @@
// src/components/handlers.rs
pub mod canvas;
pub mod sidebar;
pub mod buffer_list;
pub use canvas::*;
pub use sidebar::*;
pub use buffer_list::*;

View File

@@ -1,255 +0,0 @@
// src/components/handlers/canvas.rs
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
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 std::cmp::{max, min};
/// Render canvas for legacy CanvasState (AddTableState, LoginState, RegisterState, AddLogicState)
pub fn render_canvas(
f: &mut Frame,
area: Rect,
form_state: &impl LegacyCanvasState,
fields: &[&str],
current_field_idx: &usize,
inputs: &[&String],
theme: &Theme,
is_edit_mode: bool,
highlight_state: &HighlightState,
) -> Option<Rect> {
render_canvas_impl(
f,
area,
fields,
current_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),
)
}
/// Render canvas for library CanvasState (FormState)
pub fn render_canvas_library(
f: &mut Frame,
area: Rect,
form_state: &impl LibraryCanvasState,
fields: &[&str],
current_field_idx: &usize,
inputs: &[&String],
theme: &Theme,
is_edit_mode: bool,
highlight_state: &HighlightState,
) -> Option<Rect> {
render_canvas_impl(
f,
area,
fields,
current_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),
)
}
/// Internal implementation shared by both render functions
fn render_canvas_impl<F1, F2>(
f: &mut Frame,
area: Rect,
fields: &[&str],
current_field_idx: &usize,
inputs: &[&String],
theme: &Theme,
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,
{
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(area);
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)
};
let input_container = Block::default()
.borders(Borders::ALL)
.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);
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);
let mut active_field_input_rect = None;
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: columns[0].x,
y: input_block.y + 1 + i as u16,
width: columns[0].width,
height: 1,
},
);
}
for (i, _input) in inputs.iter().enumerate() {
let is_active = i == *current_field_idx;
// Use the provided closure to get display value
let text = get_display_value(i);
let text_len = text.chars().count();
let line: Line;
match highlight_state {
HighlightState::Off => {
line = Line::from(Span::styled(
&text,
if is_active {
Style::default().fg(theme.highlight)
} else {
Style::default().fg(theme.fg)
},
));
}
HighlightState::Characterwise { anchor } => {
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 (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 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 i >= start_field && i <= end_field {
if start_field == end_field {
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 = Line::from(vec![
Span::styled(before, normal_style_in_highlight),
Span::styled(highlighted, highlight_style),
Span::styled(after, normal_style_in_highlight),
]);
} else if i == start_field {
let safe_start = start_char.min(text_len);
let before: String = text.chars().take(safe_start).collect();
let highlighted: String = text.chars().skip(safe_start).collect();
line = Line::from(vec![
Span::styled(before, normal_style_in_highlight),
Span::styled(highlighted, highlight_style),
]);
} else if i == end_field {
let safe_end_inclusive = if text_len > 0 { end_char.min(text_len - 1) } else { 0 };
let highlighted: String = text.chars().take(safe_end_inclusive + 1).collect();
let after: String = text.chars().skip(safe_end_inclusive + 1).collect();
line = Line::from(vec![
Span::styled(highlighted, highlight_style),
Span::styled(after, normal_style_in_highlight),
]);
} else {
line = Line::from(Span::styled(&text, highlight_style));
}
} else {
line = Line::from(Span::styled(
&text,
if is_active { normal_style_in_highlight } else { normal_style_outside }
));
}
}
HighlightState::Linewise { anchor_line } => {
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 i >= start_field && i <= end_field {
line = Line::from(Span::styled(&text, highlight_style));
} else {
line = Line::from(Span::styled(
&text,
if is_active { normal_style_in_highlight } else { normal_style_outside }
));
}
}
}
let input_display = Paragraph::new(line).alignment(Alignment::Left);
f.render_widget(input_display, input_rows[i]);
if is_active {
active_field_input_rect = Some(input_rows[i]);
// Use the provided closure to check for display override
let cursor_x = if has_display_override(i) {
// If an override exists, place the cursor at the end.
input_rows[i].x + text.chars().count() as u16
} else {
// Otherwise, use the real cursor position.
input_rows[i].x + current_cursor_pos as u16
};
let cursor_y = input_rows[i].y;
f.set_cursor_position((cursor_x, cursor_y));
}
}
active_field_input_rect
}

View File

@@ -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
}
}

View File

@@ -1,9 +1,5 @@
// src/functions/modes.rs
pub mod read_only;
pub mod edit;
pub mod navigation;
pub use read_only::*;
pub use edit::*;
pub use navigation::*;

View File

@@ -1,6 +0,0 @@
// src/functions/modes/edit.rs
pub mod form_e;
pub mod auth_e;
pub mod add_table_e;
pub mod add_logic_e;

View File

@@ -1,135 +0,0 @@
// src/functions/modes/edit/add_logic_e.rs
use crate::state::pages::add_logic::AddLogicState;
use crate::state::pages::canvas_state::CanvasState;
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent};
pub async fn execute_edit_action(
action: &str,
key: KeyEvent, // Keep key for insert_char
state: &mut AddLogicState,
ideal_cursor_column: &mut usize,
) -> Result<String> {
let mut message = String::new();
match action {
"next_field" => {
let current_field = state.current_field();
let next_field = (current_field + 1) % AddLogicState::INPUT_FIELD_COUNT;
state.set_current_field(next_field);
*ideal_cursor_column = state.current_cursor_pos();
message = format!("Focus on field {}", state.fields()[next_field]);
}
"prev_field" => {
let current_field = state.current_field();
let prev_field = if current_field == 0 {
AddLogicState::INPUT_FIELD_COUNT - 1
} else {
current_field - 1
};
state.set_current_field(prev_field);
*ideal_cursor_column = state.current_cursor_pos();
message = format!("Focus on field {}", state.fields()[prev_field]);
}
"delete_char_forward" => {
let current_pos = state.current_cursor_pos();
let current_input_mut = state.get_current_input_mut();
if current_pos < current_input_mut.len() {
current_input_mut.remove(current_pos);
state.set_has_unsaved_changes(true);
if state.current_field() == 1 { state.update_target_column_suggestions(); }
}
}
"delete_char_backward" => {
let current_pos = state.current_cursor_pos();
if current_pos > 0 {
let new_pos = current_pos - 1;
state.get_current_input_mut().remove(new_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
state.set_has_unsaved_changes(true);
if state.current_field() == 1 { state.update_target_column_suggestions(); }
}
}
"move_left" => {
let current_pos = state.current_cursor_pos();
if current_pos > 0 {
let new_pos = current_pos - 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
}
"move_right" => {
let current_pos = state.current_cursor_pos();
let input_len = state.get_current_input().len();
if current_pos < input_len {
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
}
"insert_char" => {
if let KeyCode::Char(c) = key.code {
let current_pos = state.current_cursor_pos();
state.get_current_input_mut().insert(current_pos, c);
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
state.set_has_unsaved_changes(true);
if state.current_field() == 1 {
state.update_target_column_suggestions();
}
}
}
"suggestion_down" => {
if state.in_target_column_suggestion_mode && !state.target_column_suggestions.is_empty() {
let current_selection = state.selected_target_column_suggestion_index.unwrap_or(0);
let next_selection = (current_selection + 1) % state.target_column_suggestions.len();
state.selected_target_column_suggestion_index = Some(next_selection);
}
}
"suggestion_up" => {
if state.in_target_column_suggestion_mode && !state.target_column_suggestions.is_empty() {
let current_selection = state.selected_target_column_suggestion_index.unwrap_or(0);
let prev_selection = if current_selection == 0 {
state.target_column_suggestions.len() - 1
} else {
current_selection - 1
};
state.selected_target_column_suggestion_index = Some(prev_selection);
}
}
"select_suggestion" => {
if state.in_target_column_suggestion_mode {
let mut selected_suggestion_text: Option<String> = None;
if let Some(selected_idx) = state.selected_target_column_suggestion_index {
if let Some(suggestion) = state.target_column_suggestions.get(selected_idx) {
selected_suggestion_text = Some(suggestion.clone());
}
}
if let Some(suggestion_text) = selected_suggestion_text {
state.target_column_input = suggestion_text.clone();
state.target_column_cursor_pos = state.target_column_input.len();
*ideal_cursor_column = state.target_column_cursor_pos;
state.set_has_unsaved_changes(true);
message = format!("Selected column: '{}'", suggestion_text);
}
state.in_target_column_suggestion_mode = false;
state.show_target_column_suggestions = false;
state.selected_target_column_suggestion_index = None;
state.update_target_column_suggestions();
} else {
let current_field = state.current_field();
let next_field = (current_field + 1) % AddLogicState::INPUT_FIELD_COUNT;
state.set_current_field(next_field);
*ideal_cursor_column = state.current_cursor_pos();
message = format!("Focus on field {}", state.fields()[next_field]);
}
}
_ => {}
}
Ok(message)
}

View File

@@ -1,341 +0,0 @@
// src/functions/modes/edit/add_table_e.rs
use crate::state::pages::add_table::AddTableState;
use crate::state::pages::canvas_state::CanvasState; // Use trait
use crossterm::event::{KeyCode, KeyEvent};
use anyhow::Result;
#[derive(PartialEq)]
enum CharType {
Whitespace,
Alphanumeric,
Punctuation,
}
fn get_char_type(c: char) -> CharType {
if c.is_whitespace() {
CharType::Whitespace
} else if c.is_alphanumeric() {
CharType::Alphanumeric
} else {
CharType::Punctuation
}
}
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 || current_pos >= len {
return len;
}
let mut pos = current_pos;
let initial_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == initial_type {
pos += 1;
}
while pos < len && get_char_type(chars[pos]) == CharType::Whitespace {
pos += 1;
}
pos
}
fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 {
return 0;
}
let mut pos = current_pos.min(len - 1);
if get_char_type(chars[pos]) == CharType::Whitespace {
pos = find_next_word_start(text, pos);
}
if pos >= len {
return len.saturating_sub(1);
}
let word_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == word_type {
pos += 1;
}
pos.saturating_sub(1).min(len.saturating_sub(1))
}
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
return 0;
}
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
pos
}
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
return 0;
}
if pos == 0 && get_char_type(chars[pos]) != CharType::Whitespace {
return 0;
}
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
pos -= 1;
}
if pos > 0 {
pos - 1
} else {
0
}
}
/// Executes edit actions for the AddTable view canvas.
pub async fn execute_edit_action(
action: &str,
key: KeyEvent, // Needed for insert_char
state: &mut AddTableState,
ideal_cursor_column: &mut usize,
// Add other params like grpc_client if needed for future actions (e.g., validation)
) -> Result<String> {
// Use the CanvasState trait methods implemented for AddTableState
match action {
"insert_char" => {
if let KeyCode::Char(c) = key.code {
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();
state.set_current_cursor_pos(cursor_pos + 1);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = state.current_cursor_pos();
}
} else {
return Ok("Error: insert_char called without a char key.".to_string());
}
Ok("".to_string()) // No message needed for char insertion
}
"delete_char_backward" => {
if state.current_cursor_pos() > 0 {
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();
let new_pos = cursor_pos - 1;
state.set_current_cursor_pos(new_pos);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = new_pos;
}
}
Ok("".to_string())
}
"delete_char_forward" => {
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();
state.set_has_unsaved_changes(true);
*ideal_cursor_column = cursor_pos;
}
Ok("".to_string())
}
"next_field" => {
let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields > 0 {
let current_field = state.current_field();
let last_field_index = num_fields - 1;
// Prevent cycling forward
if current_field < last_field_index {
state.set_current_field(current_field + 1);
}
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("".to_string())
}
"prev_field" => {
let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields > 0 {
let current_field = state.current_field();
if current_field > 0 {
state.set_current_field(current_field - 1);
}
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("".to_string())
}
"move_left" => {
let new_pos = state.current_cursor_pos().saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_right" => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
if current_pos < current_input.len() {
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_up" => {
let current_field = state.current_field();
// Prevent moving up from the first field
if current_field > 0 {
let new_field = current_field - 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("ahoj".to_string())
}
"move_down" => {
let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields > 0 {
let current_field = state.current_field();
let last_field_index = num_fields - 1;
if current_field < last_field_index {
let new_field = current_field + 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
}
Ok("".to_string())
}
"move_line_start" => {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
Ok("".to_string())
}
"move_line_end" => {
let current_input = state.get_current_input();
let new_pos = current_input.len();
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_first_line" => {
if AddTableState::INPUT_FIELD_COUNT > 0 {
state.set_current_field(0);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("".to_string())
}
"move_last_line" => {
let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields > 0 {
let new_field = num_fields - 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("".to_string())
}
"move_word_next" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
let final_pos = new_pos.min(current_input.len());
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
}
Ok("".to_string())
}
"move_word_end" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let current_pos = state.current_cursor_pos();
let new_pos = find_word_end(current_input, current_pos);
let final_pos = if new_pos == current_pos {
find_word_end(current_input, new_pos + 1)
} else {
new_pos
};
let max_valid_index = current_input.len().saturating_sub(1);
let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_pos;
}
Ok("".to_string())
}
"move_word_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_word_end_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_end(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
// Actions handled by main event loop (mode changes, save, revert)
"exit_edit_mode" | "save" | "revert" => {
Ok("Action handled by main loop".to_string())
}
_ => Ok(format!("Unknown or unhandled edit action: {}", action)),
}
}

View File

@@ -1,476 +0,0 @@
// 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 std::any::Any;
use anyhow::Result;
pub async fn execute_common_action<S: CanvasState + Any>(
action: &str,
state: &mut S,
grpc_client: &mut GrpcClient,
app_state: &AppState,
current_position: &mut u64,
total_count: u64,
) -> Result<String> {
match action {
"save" | "revert" => {
if !state.has_unsaved_changes() {
return Ok("No changes to save or revert.".to_string());
}
if let Some(form_state) =
(state as &mut dyn Any).downcast_mut::<FormState>()
{
match action {
"save" => {
let outcome = save(
app_state,
form_state,
grpc_client,
)
.await?;
let message = format!("Save successful: {:?}", outcome); // Simple message for now
Ok(message)
}
"revert" => {
revert(
form_state,
grpc_client,
)
.await
}
_ => unreachable!(),
}
} else {
Ok(format!(
"Action '{}' not implemented for this state type.",
action
))
}
}
_ => Ok(format!("Common action '{}' not handled here.", action)),
}
}
pub async fn execute_edit_action<S: CanvasState + Any + Send>(
action: &str,
key: KeyEvent,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> Result<String> {
match action {
"insert_char" => {
if let KeyCode::Char(c) = key.code {
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();
state.set_current_cursor_pos(cursor_pos + 1);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = state.current_cursor_pos();
}
} else {
return Ok("Error: insert_char called without a char key."
.to_string());
}
Ok("working?".to_string())
}
"delete_char_backward" => {
if state.current_cursor_pos() > 0 {
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();
let new_pos = cursor_pos - 1;
state.set_current_cursor_pos(new_pos);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = new_pos;
}
}
Ok("".to_string())
}
"delete_char_forward" => {
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();
state.set_has_unsaved_changes(true);
*ideal_cursor_column = cursor_pos;
}
Ok("".to_string())
}
"next_field" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let current_field = state.current_field();
let new_field = (current_field + 1).min(num_fields - 1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_pos),
);
}
Ok("".to_string())
}
"prev_field" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let current_field = state.current_field();
let new_field = current_field.saturating_sub(1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_pos),
);
}
Ok("".to_string())
}
"move_left" => {
let new_pos = state.current_cursor_pos().saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_right" => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
if current_pos < current_input.len() {
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_up" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let current_field = state.current_field();
let new_field = current_field.saturating_sub(1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_pos),
);
}
Ok("".to_string())
}
"move_down" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let new_field = (state.current_field() + 1).min(num_fields - 1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_pos),
);
}
Ok("".to_string())
}
"move_line_start" => {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
Ok("".to_string())
}
"move_line_end" => {
let current_input = state.get_current_input();
let new_pos = current_input.len();
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_first_line" => {
let num_fields = state.fields().len();
if num_fields > 0 {
state.set_current_field(0);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_pos),
);
}
Ok("Moved to first field".to_string())
}
"move_last_line" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let new_field = num_fields - 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_pos),
);
}
Ok("Moved to last field".to_string())
}
"move_word_next" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_next_word_start(
current_input,
state.current_cursor_pos(),
);
let final_pos = new_pos.min(current_input.len());
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
}
Ok("".to_string())
}
"move_word_end" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let current_pos = state.current_cursor_pos();
let new_pos = find_word_end(current_input, current_pos);
let final_pos = if new_pos == current_pos {
find_word_end(current_input, new_pos + 1)
} else {
new_pos
};
let max_valid_index = current_input.len().saturating_sub(1);
let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_pos;
}
Ok("".to_string())
}
"move_word_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_start(
current_input,
state.current_cursor_pos(),
);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_word_end_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_end(
current_input,
state.current_cursor_pos(),
);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("Moved to previous word end".to_string())
}
// --- Autocomplete Actions ---
"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 {
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_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())
}
"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
}
} 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;
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())
}
}
} else {
// It's RegisterState, but not the role field
Ok("Suggestion action ignored: Not on role field.".to_string())
}
} else {
// Downcast failed - this action is only for RegisterState
Ok(format!("Action '{}' not applicable for this state type.", action))
}
}
// --- End Autocomplete Actions ---
_ => Ok(format!("Unknown or unhandled edit action: {}", action)),
}
}
#[derive(PartialEq)]
enum CharType {
Whitespace,
Alphanumeric,
Punctuation,
}
fn get_char_type(c: char) -> CharType {
if c.is_whitespace() {
CharType::Whitespace
} else if c.is_alphanumeric() {
CharType::Alphanumeric
} else {
CharType::Punctuation
}
}
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 || current_pos >= len {
return len;
}
let mut pos = current_pos;
let initial_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == initial_type {
pos += 1;
}
while pos < len && get_char_type(chars[pos]) == CharType::Whitespace {
pos += 1;
}
pos
}
fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 {
return 0;
}
let mut pos = current_pos.min(len - 1);
if get_char_type(chars[pos]) == CharType::Whitespace {
pos = find_next_word_start(text, pos);
}
if pos >= len {
return len.saturating_sub(1);
}
let word_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == word_type {
pos += 1;
}
pos.saturating_sub(1).min(len.saturating_sub(1))
}
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
return 0;
}
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
pos
}
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
return 0;
}
if pos == 0 && get_char_type(chars[pos]) != CharType::Whitespace {
return 0;
}
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
pos -= 1;
}
if pos > 0 {
pos - 1
} else {
0
}
}

View File

@@ -1,435 +0,0 @@
// src/functions/modes/edit/form_e.rs
use crate::services::grpc_client::GrpcClient;
use crate::state::pages::form::FormState;
use crate::state::app::state::AppState;
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 std::any::Any;
use anyhow::Result;
pub async fn execute_common_action<S: CanvasState + Any>(
action: &str,
state: &mut S,
grpc_client: &mut GrpcClient,
app_state: &AppState,
) -> Result<EventOutcome> {
match action {
"save" | "revert" => {
if !state.has_unsaved_changes() {
return Ok(EventOutcome::Ok("No changes to save or revert.".to_string()));
}
if let Some(form_state) =
(state as &mut dyn Any).downcast_mut::<FormState>()
{
match action {
"save" => {
let save_result = save(
app_state,
form_state,
grpc_client,
).await;
match save_result {
Ok(save_outcome) => {
let message = match save_outcome {
SaveOutcome::NoChange => "No changes to save.".to_string(),
SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
SaveOutcome::CreatedNew(_) => "New entry created.".to_string(),
};
Ok(EventOutcome::DataSaved(save_outcome, message))
}
Err(e) => Err(e),
}
}
"revert" => {
let revert_result = revert(
form_state,
grpc_client,
).await;
match revert_result {
Ok(message) => Ok(EventOutcome::Ok(message)),
Err(e) => Err(e),
}
}
_ => unreachable!(),
}
} else {
Ok(EventOutcome::Ok(format!(
"Action '{}' not implemented for this state type.",
action
)))
}
}
_ => Ok(EventOutcome::Ok(format!("Common action '{}' not handled here.", action))),
}
}
pub async fn execute_edit_action<S: CanvasState>(
action: &str,
key: KeyEvent,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> Result<String> {
match action {
"insert_char" => {
if let KeyCode::Char(c) = key.code {
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();
state.set_current_cursor_pos(cursor_pos + 1);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = state.current_cursor_pos();
}
} else {
return Ok("Error: insert_char called without a char key."
.to_string());
}
Ok("".to_string())
}
"delete_char_backward" => {
if state.current_cursor_pos() > 0 {
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();
let new_pos = cursor_pos - 1;
state.set_current_cursor_pos(new_pos);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = new_pos;
}
}
Ok("".to_string())
}
"delete_char_forward" => {
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();
state.set_has_unsaved_changes(true);
*ideal_cursor_column = cursor_pos;
}
Ok("".to_string())
}
"next_field" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let current_field = state.current_field();
let new_field = (current_field + 1) % num_fields;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_pos),
);
}
Ok("".to_string())
}
"prev_field" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let current_field = state.current_field();
let new_field = if current_field == 0 {
num_fields - 1
} else {
current_field - 1
};
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_pos),
);
}
Ok("".to_string())
}
"move_left" => {
let new_pos = state.current_cursor_pos().saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_right" => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
if current_pos < current_input.len() {
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_up" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let current_field = state.current_field();
let new_field = current_field.saturating_sub(1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_pos),
);
}
Ok("".to_string())
}
"move_down" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let new_field = (state.current_field() + 1).min(num_fields - 1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_pos),
);
}
Ok("".to_string())
}
"move_line_start" => {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
Ok("".to_string())
}
"move_line_end" => {
let current_input = state.get_current_input();
let new_pos = current_input.len();
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_first_line" => {
let num_fields = state.fields().len();
if num_fields > 0 {
state.set_current_field(0);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_pos),
);
}
Ok("Moved to first field".to_string())
}
"move_last_line" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let new_field = num_fields - 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_pos),
);
}
Ok("Moved to last field".to_string())
}
"move_word_next" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_next_word_start(
current_input,
state.current_cursor_pos(),
);
let final_pos = new_pos.min(current_input.len());
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
}
Ok("".to_string())
}
"move_word_end" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let current_pos = state.current_cursor_pos();
let new_pos = find_word_end(current_input, current_pos);
let final_pos = if new_pos == current_pos {
find_word_end(current_input, new_pos + 1)
} else {
new_pos
};
let max_valid_index = current_input.len().saturating_sub(1);
let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_pos;
}
Ok("".to_string())
}
"move_word_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_start(
current_input,
state.current_cursor_pos(),
);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_word_end_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_end(
current_input,
state.current_cursor_pos(),
);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("Moved to previous word end".to_string())
}
_ => Ok(format!("Unknown or unhandled edit action: {}", action)),
}
}
#[derive(PartialEq)]
enum CharType {
Whitespace,
Alphanumeric,
Punctuation,
}
fn get_char_type(c: char) -> CharType {
if c.is_whitespace() {
CharType::Whitespace
} else if c.is_alphanumeric() {
CharType::Alphanumeric
} else {
CharType::Punctuation
}
}
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 || current_pos >= len {
return len;
}
let mut pos = current_pos;
let initial_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == initial_type {
pos += 1;
}
while pos < len && get_char_type(chars[pos]) == CharType::Whitespace {
pos += 1;
}
pos
}
fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 {
return 0;
}
let mut pos = current_pos.min(len - 1);
if get_char_type(chars[pos]) == CharType::Whitespace {
pos = find_next_word_start(text, pos);
}
if pos >= len {
return len.saturating_sub(1);
}
let word_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == word_type {
pos += 1;
}
pos.saturating_sub(1).min(len.saturating_sub(1))
}
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
return 0;
}
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
pos
}
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
return 0;
}
if pos == 0 && get_char_type(chars[pos]) != CharType::Whitespace {
return 0;
}
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
pos -= 1;
}
if pos > 0 {
pos - 1
} else {
0
}
}

View File

@@ -1,6 +0,0 @@
// src/functions/modes/read_only.rs
pub mod auth_ro;
pub mod form_ro;
pub mod add_table_ro;
pub mod add_logic_ro;

View File

@@ -1,235 +0,0 @@
// src/functions/modes/read_only/add_logic_ro.rs
use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::state::pages::add_logic::AddLogicState; // Changed
use crate::state::pages::canvas_state::CanvasState;
use crate::state::app::state::AppState;
use anyhow::Result;
// Word navigation helpers (get_char_type, find_next_word_start, etc.)
// can be kept as they are generic.
#[derive(PartialEq)]
enum CharType {
Whitespace,
Alphanumeric,
Punctuation,
}
fn get_char_type(c: char) -> CharType {
if c.is_whitespace() { CharType::Whitespace }
else if c.is_alphanumeric() { CharType::Alphanumeric }
else { CharType::Punctuation }
}
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 || current_pos >= len { return len; }
let mut pos = current_pos;
let initial_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == initial_type { pos += 1; }
while pos < len && get_char_type(chars[pos]) == CharType::Whitespace { pos += 1; }
pos
}
fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 { return 0; }
let mut pos = current_pos.min(len - 1);
if get_char_type(chars[pos]) == CharType::Whitespace {
pos = find_next_word_start(text, pos);
}
if pos >= len { return len.saturating_sub(1); }
let word_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == word_type { pos += 1; }
pos.saturating_sub(1).min(len.saturating_sub(1))
}
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 { return 0; }
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace { pos -= 1; }
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace { return 0; }
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type { pos -= 1; }
pos
}
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let prev_start = find_prev_word_start(text, current_pos);
if prev_start == 0 { return 0; }
find_word_end(text, prev_start.saturating_sub(1))
}
/// Executes read-only actions for the AddLogic view canvas.
pub async fn execute_action(
action: &str,
app_state: &mut AppState,
state: &mut AddLogicState,
ideal_cursor_column: &mut usize,
key_sequence_tracker: &mut KeySequenceTracker,
command_message: &mut String,
) -> Result<String> {
match action {
"move_up" => {
key_sequence_tracker.reset();
let num_fields = AddLogicState::INPUT_FIELD_COUNT;
if num_fields == 0 { return Ok("No fields.".to_string()); }
let current_field = state.current_field();
if current_field > 0 {
let new_field = current_field - 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
} else {
*command_message = "At top of form.".to_string();
}
Ok(command_message.clone())
}
"move_down" => {
key_sequence_tracker.reset();
let num_fields = AddLogicState::INPUT_FIELD_COUNT;
if num_fields == 0 { return Ok("No fields.".to_string()); }
let current_field = state.current_field();
let last_field_index = num_fields - 1;
if current_field < last_field_index {
let new_field = current_field + 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
} else {
// Move focus outside canvas when moving down from the last field
// FIX: Go to ScriptContentPreview instead of SaveButton
app_state.ui.focus_outside_canvas = true;
state.last_canvas_field = 2;
state.current_focus = crate::state::pages::add_logic::AddLogicFocus::ScriptContentPreview; // FIXED!
*command_message = "Focus moved to script preview".to_string();
}
Ok(command_message.clone())
}
// ... (rest of the actions remain the same) ...
"move_first_line" => {
key_sequence_tracker.reset();
if AddLogicState::INPUT_FIELD_COUNT > 0 {
state.set_current_field(0);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_last_line" => {
key_sequence_tracker.reset();
let num_fields = AddLogicState::INPUT_FIELD_COUNT;
if num_fields > 0 {
let last_field_index = num_fields - 1;
state.set_current_field(last_field_index);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_left" => {
let current_pos = state.current_cursor_pos();
let new_pos = current_pos.saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_right" => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
if !current_input.is_empty() && current_pos < current_input.len().saturating_sub(1) {
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_word_next" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
let final_pos = new_pos.min(current_input.len().saturating_sub(1));
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
}
Ok("".to_string())
}
"move_word_end" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let current_pos = state.current_cursor_pos();
let new_pos = find_word_end(current_input, current_pos);
let final_pos = if new_pos == current_pos && current_pos < current_input.len().saturating_sub(1) {
find_word_end(current_input, current_pos + 1)
} else {
new_pos
};
let max_valid_index = current_input.len().saturating_sub(1);
let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_pos;
}
Ok("".to_string())
}
"move_word_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_word_end_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_end(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_line_start" => {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
Ok("".to_string())
}
"move_line_end" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = current_input.len().saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
} else {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
}
Ok("".to_string())
}
"enter_edit_mode_before" | "enter_edit_mode_after" | "enter_command_mode" | "exit_highlight_mode" => {
key_sequence_tracker.reset();
Ok("Mode change handled by main loop".to_string())
}
_ => {
key_sequence_tracker.reset();
command_message.clear();
Ok(format!("Unknown read-only action: {}", action))
},
}
}

View File

@@ -1,267 +0,0 @@
// src/functions/modes/read_only/add_table_ro.rs
use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::state::pages::add_table::AddTableState;
use crate::state::pages::canvas_state::CanvasState;
use crate::state::app::state::AppState;
use anyhow::Result;
// Re-use word navigation helpers if they are public or move them to a common module
// For now, duplicating them here for simplicity. Consider refactoring later.
#[derive(PartialEq)]
enum CharType {
Whitespace,
Alphanumeric,
Punctuation,
}
fn get_char_type(c: char) -> CharType {
if c.is_whitespace() { CharType::Whitespace }
else if c.is_alphanumeric() { CharType::Alphanumeric }
else { CharType::Punctuation }
}
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 || current_pos >= len { return len; }
let mut pos = current_pos;
let initial_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == initial_type { pos += 1; }
while pos < len && get_char_type(chars[pos]) == CharType::Whitespace { pos += 1; }
pos
}
fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 { return 0; }
let mut pos = current_pos.min(len - 1);
if get_char_type(chars[pos]) == CharType::Whitespace {
pos = find_next_word_start(text, pos);
}
if pos >= len { return len.saturating_sub(1); }
let word_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == word_type { pos += 1; }
pos.saturating_sub(1).min(len.saturating_sub(1))
}
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 { return 0; }
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace { pos -= 1; }
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace { return 0; }
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type { pos -= 1; }
pos
}
// Note: find_prev_word_end might need adjustments based on desired behavior.
// This version finds the end of the word *before* the previous word start.
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let prev_start = find_prev_word_start(text, current_pos);
if prev_start == 0 { return 0; }
// Find the end of the word that starts at prev_start - 1
find_word_end(text, prev_start.saturating_sub(1))
}
/// Executes read-only actions for the AddTable view canvas.
pub async fn execute_action(
action: &str,
app_state: &mut AppState, // Needed for focus_outside_canvas
state: &mut AddTableState,
ideal_cursor_column: &mut usize,
key_sequence_tracker: &mut KeySequenceTracker,
command_message: &mut String, // Keep for potential messages
) -> Result<String> {
// Use the CanvasState trait methods implemented for AddTableState
match action {
"move_up" => {
key_sequence_tracker.reset();
let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields == 0 {
*command_message = "No fields.".to_string();
return Ok(command_message.clone());
}
let current_field = state.current_field(); // Gets the index (0, 1, or 2)
if current_field > 0 {
// This handles moving from field 2 -> 1, or 1 -> 0
let new_field = current_field - 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_cursor_pos = current_input.len(); // Allow cursor at end
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos; // Update ideal column as cursor moved
*command_message = "".to_string(); // Clear message for successful internal navigation
} else {
// current_field is 0 (InputTableName), and user pressed Up.
// Forbid moving up. Do not change focus or cursor.
*command_message = "At top of form.".to_string();
}
Ok(command_message.clone())
}
"move_down" => {
key_sequence_tracker.reset();
let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields == 0 {
*command_message = "No fields.".to_string();
return Ok(command_message.clone());
}
let current_field = state.current_field();
let last_field_index = num_fields - 1;
if current_field < last_field_index {
let new_field = current_field + 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_cursor_pos = current_input.len(); // Allow cursor at end
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos; // Update ideal column
*command_message = "".to_string();
} else {
// Move focus outside canvas when moving down from the last field
app_state.ui.focus_outside_canvas = true;
// Set focus to the first element outside canvas (AddColumnButton)
state.current_focus =
crate::state::pages::add_table::AddTableFocus::AddColumnButton;
*command_message = "Focus moved below canvas".to_string();
}
Ok(command_message.clone())
}
// ... (other actions like "move_first_line", "move_left", etc. remain the same) ...
"move_first_line" => {
key_sequence_tracker.reset();
if AddTableState::INPUT_FIELD_COUNT > 0 {
state.set_current_field(0);
let current_input = state.get_current_input();
let max_cursor_pos = current_input.len();
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos; // Update ideal column
}
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_last_line" => {
key_sequence_tracker.reset();
let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields > 0 {
let last_field_index = num_fields - 1;
state.set_current_field(last_field_index);
let current_input = state.get_current_input();
let max_cursor_pos = current_input.len();
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos; // Update ideal column
}
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_left" => {
let current_pos = state.current_cursor_pos();
let new_pos = current_pos.saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_right" => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
// Allow moving cursor one position past the end
if current_pos < current_input.len() {
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_word_next" => {
let current_input = state.get_current_input();
let new_pos = find_next_word_start(
current_input,
state.current_cursor_pos(),
);
let final_pos = new_pos.min(current_input.len()); // Allow cursor at end
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_word_end" => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
let new_pos = find_word_end(current_input, current_pos);
// If find_word_end returns current_pos, try starting search from next char
let final_pos =
if new_pos == current_pos && current_pos < current_input.len() {
find_word_end(current_input, current_pos + 1)
} else {
new_pos
};
let max_valid_index = current_input.len(); // Allow cursor at end
let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_pos;
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_word_prev" => {
let current_input = state.get_current_input();
let new_pos = find_prev_word_start(
current_input,
state.current_cursor_pos(),
);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_word_end_prev" => {
let current_input = state.get_current_input();
let new_pos = find_prev_word_end(
current_input,
state.current_cursor_pos(),
);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_line_start" => {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_line_end" => {
let current_input = state.get_current_input();
let new_pos = current_input.len(); // Allow cursor at end
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
*command_message = "".to_string();
Ok(command_message.clone())
}
// Actions handled by main event loop (mode changes)
"enter_edit_mode_before" | "enter_edit_mode_after"
| "enter_command_mode" | "exit_highlight_mode" => {
key_sequence_tracker.reset();
// These actions are primarily mode changes handled by the main event loop.
// The message here might be overridden by the main loop's message for mode change.
*command_message = "Mode change initiated".to_string();
Ok(command_message.clone())
}
_ => {
key_sequence_tracker.reset();
*command_message =
format!("Unknown read-only action: {}", action);
Ok(command_message.clone())
}
}
}

View File

@@ -1,343 +0,0 @@
// 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 anyhow::Result;
#[derive(PartialEq)]
enum CharType {
Whitespace,
Alphanumeric,
Punctuation,
}
pub async fn execute_action<S: CanvasState>(
action: &str,
app_state: &mut AppState,
state: &mut S,
ideal_cursor_column: &mut usize,
key_sequence_tracker: &mut KeySequenceTracker,
command_message: &mut String,
) -> Result<String> {
match action {
"previous_entry" | "next_entry" => {
key_sequence_tracker.reset();
Ok(format!(
"Action '{}' should be handled by context-specific logic",
action
))
}
"move_up" => {
key_sequence_tracker.reset();
let num_fields = state.fields().len();
if num_fields == 0 {
return Ok("No fields to navigate.".to_string());
}
let current_field = state.current_field();
let new_field = current_field.saturating_sub(1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() {
0
} else {
current_input.len().saturating_sub(1)
};
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
Ok("move up from functions/modes/read_only/auth_ro.rs".to_string())
}
"move_down" => {
key_sequence_tracker.reset();
let num_fields = state.fields().len();
if num_fields == 0 {
return Ok("No fields to navigate.".to_string());
}
let current_field = state.current_field();
let last_field_index = num_fields - 1;
if current_field == last_field_index {
// Already on the last field, move focus outside
app_state.ui.focus_outside_canvas = true;
app_state.focused_button_index= 0;
key_sequence_tracker.reset();
Ok("Focus moved below canvas".to_string())
} else {
// Move to the next field within the canvas
let new_field = (current_field + 1).min(last_field_index);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() {
0
} else {
current_input.len().saturating_sub(1)
};
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
Ok("".to_string()) // Clear previous debug message
}
}
"move_first_line" => {
key_sequence_tracker.reset();
let num_fields = state.fields().len();
if num_fields == 0 {
return Ok("No fields to navigate to.".to_string());
}
state.set_current_field(0);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() {
0
} else {
current_input.len().saturating_sub(1)
};
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_last_line" => {
key_sequence_tracker.reset();
let num_fields = state.fields().len();
if num_fields == 0 {
return Ok("No fields to navigate to.".to_string());
}
let last_field_index = num_fields - 1;
state.set_current_field(last_field_index);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() {
0
} else {
current_input.len().saturating_sub(1)
};
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"exit_edit_mode" => {
key_sequence_tracker.reset();
command_message.clear();
Ok("".to_string())
}
"move_left" => {
let current_pos = state.current_cursor_pos();
let new_pos = current_pos.saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_right" => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
if !current_input.is_empty()
&& current_pos < current_input.len().saturating_sub(1)
{
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_word_next" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos =
find_next_word_start(current_input, state.current_cursor_pos());
let final_pos = new_pos.min(current_input.len().saturating_sub(1));
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
}
Ok("".to_string())
}
"move_word_end" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let current_pos = state.current_cursor_pos();
let new_pos = find_word_end(current_input, current_pos);
let final_pos = if new_pos != current_pos {
new_pos
} else {
find_word_end(current_input, new_pos + 1)
};
let max_valid_index = current_input.len().saturating_sub(1);
let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_pos;
}
Ok("".to_string())
}
"move_word_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_start(
current_input,
state.current_cursor_pos(),
);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_word_end_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_end(
current_input,
state.current_cursor_pos(),
);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("Moved to previous word end".to_string())
}
"move_line_start" => {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
Ok("".to_string())
}
"move_line_end" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = current_input.len().saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
} else {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
}
Ok("".to_string())
}
_ => {
key_sequence_tracker.reset();
Ok(format!("Unknown read-only action: {}", action))
},
}
}
fn get_char_type(c: char) -> CharType {
if c.is_whitespace() {
CharType::Whitespace
} else if c.is_alphanumeric() {
CharType::Alphanumeric
} else {
CharType::Punctuation
}
}
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
let current_pos = current_pos.min(chars.len());
if current_pos == chars.len() {
return current_pos;
}
let mut pos = current_pos;
let initial_type = get_char_type(chars[pos]);
while pos < chars.len() && get_char_type(chars[pos]) == initial_type {
pos += 1;
}
while pos < chars.len() && get_char_type(chars[pos]) == CharType::Whitespace {
pos += 1;
}
pos
}
fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 {
return 0;
}
let mut pos = current_pos.min(len - 1);
let current_type = get_char_type(chars[pos]);
if current_type != CharType::Whitespace {
while pos < len && get_char_type(chars[pos]) == current_type {
pos += 1;
}
return pos.saturating_sub(1);
}
pos = find_next_word_start(text, pos);
if pos >= len {
return len.saturating_sub(1);
}
let word_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == word_type {
pos += 1;
}
pos.saturating_sub(1).min(len.saturating_sub(1))
}
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if get_char_type(chars[pos]) != CharType::Whitespace {
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
}
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
0
} else {
pos
}
}
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
return 0;
}
if pos == 0 && get_char_type(chars[0]) != CharType::Whitespace {
return 0;
}
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
pos -= 1;
}
if pos > 0 {
pos - 1
} else {
0
}
}

View File

@@ -1,329 +0,0 @@
// src/functions/modes/read_only/form_ro.rs
use crate::config::binds::key_sequences::KeySequenceTracker;
use canvas::CanvasState;
use anyhow::Result;
#[derive(PartialEq)]
enum CharType {
Whitespace,
Alphanumeric,
Punctuation,
}
pub async fn execute_action<S: CanvasState>(
action: &str,
state: &mut S,
ideal_cursor_column: &mut usize,
key_sequence_tracker: &mut KeySequenceTracker,
command_message: &mut String,
) -> Result<String> {
match action {
"previous_entry" | "next_entry" => {
key_sequence_tracker.reset();
Ok(format!(
"Action '{}' should be handled by context-specific logic",
action
))
}
"move_up" => {
key_sequence_tracker.reset();
let num_fields = state.fields().len();
if num_fields == 0 {
return Ok("No fields to navigate.".to_string());
}
let current_field = state.current_field();
let new_field = current_field.saturating_sub(1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() {
0
} else {
current_input.len().saturating_sub(1)
};
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
Ok("".to_string())
}
"move_down" => {
key_sequence_tracker.reset();
let num_fields = state.fields().len();
if num_fields == 0 {
return Ok("No fields to navigate.".to_string());
}
let current_field = state.current_field();
let new_field = (current_field + 1).min(num_fields - 1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() {
0
} else {
current_input.len().saturating_sub(1)
};
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
Ok("".to_string())
}
"move_first_line" => {
key_sequence_tracker.reset();
let num_fields = state.fields().len();
if num_fields == 0 {
return Ok("No fields to navigate to.".to_string());
}
state.set_current_field(0);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() {
0
} else {
current_input.len().saturating_sub(1)
};
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_last_line" => {
key_sequence_tracker.reset();
let num_fields = state.fields().len();
if num_fields == 0 {
return Ok("No fields to navigate to.".to_string());
}
let last_field_index = num_fields - 1;
state.set_current_field(last_field_index);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() {
0
} else {
current_input.len().saturating_sub(1)
};
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"exit_edit_mode" => {
key_sequence_tracker.reset();
command_message.clear();
Ok("".to_string())
}
"move_left" => {
let current_pos = state.current_cursor_pos();
let new_pos = current_pos.saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_right" => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
if !current_input.is_empty()
&& current_pos < current_input.len().saturating_sub(1)
{
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_word_next" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos =
find_next_word_start(current_input, state.current_cursor_pos());
let final_pos = new_pos.min(current_input.len().saturating_sub(1));
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
}
Ok("".to_string())
}
"move_word_end" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let current_pos = state.current_cursor_pos();
let new_pos = find_word_end(current_input, current_pos);
let final_pos = if new_pos != current_pos {
new_pos
} else {
find_word_end(current_input, new_pos + 1)
};
let max_valid_index = current_input.len().saturating_sub(1);
let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_pos;
}
Ok("".to_string())
}
"move_word_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_start(
current_input,
state.current_cursor_pos(),
);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_word_end_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_end(
current_input,
state.current_cursor_pos(),
);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("Moved to previous word end".to_string())
}
"move_line_start" => {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
Ok("".to_string())
}
"move_line_end" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = current_input.len().saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
} else {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
}
Ok("".to_string())
}
_ => {
key_sequence_tracker.reset();
Ok(format!("Unknown read-only action: {}", action))
},
}
}
fn get_char_type(c: char) -> CharType {
if c.is_whitespace() {
CharType::Whitespace
} else if c.is_alphanumeric() {
CharType::Alphanumeric
} else {
CharType::Punctuation
}
}
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
let current_pos = current_pos.min(chars.len());
if current_pos == chars.len() {
return current_pos;
}
let mut pos = current_pos;
let initial_type = get_char_type(chars[pos]);
while pos < chars.len() && get_char_type(chars[pos]) == initial_type {
pos += 1;
}
while pos < chars.len() && get_char_type(chars[pos]) == CharType::Whitespace {
pos += 1;
}
pos
}
fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 {
return 0;
}
let mut pos = current_pos.min(len - 1);
let current_type = get_char_type(chars[pos]);
if current_type != CharType::Whitespace {
while pos < len && get_char_type(chars[pos]) == current_type {
pos += 1;
}
return pos.saturating_sub(1);
}
pos = find_next_word_start(text, pos);
if pos >= len {
return len.saturating_sub(1);
}
let word_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == word_type {
pos += 1;
}
pos.saturating_sub(1).min(len.saturating_sub(1))
}
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if get_char_type(chars[pos]) != CharType::Whitespace {
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
}
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
0
} else {
pos
}
}
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
return 0;
}
if pos == 0 && get_char_type(chars[0]) != CharType::Whitespace {
return 0;
}
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
pos -= 1;
}
if pos > 0 {
pos - 1
} else {
0
}
}

View File

@@ -1,8 +1,5 @@
// src/modes/canvas/edit.rs
use crate::config::binds::config::Config;
use crate::functions::modes::edit::{
add_logic_e, add_table_e, auth_e, form_e,
};
use crate::modes::handlers::event::EventHandler;
use crate::services::grpc_client::GrpcClient;
use crate::state::app::state::AppState;
@@ -11,13 +8,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 {
@@ -127,6 +124,77 @@ pub async fn handle_form_edit_with_canvas(
Ok(String::new())
}
/// Helper function to execute a specific action using canvas library
async fn execute_canvas_action(
action: &str,
key: KeyEvent,
form_state: &mut FormState,
ideal_cursor_column: &mut usize,
) -> Result<String> {
let canvas_action = CanvasAction::from_string(action);
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => Ok(msg.unwrap_or_default()),
Ok(ActionResult::HandledByFeature(msg)) => Ok(msg),
Ok(ActionResult::Error(msg)) => Ok(format!("Error: {}", msg)),
Ok(ActionResult::RequiresContext(msg)) => Ok(format!("Context needed: {}", msg)),
Err(e) => Ok(format!("Action failed: {}", e)),
}
}
/// NEW: Unified canvas action handler for any CanvasState (LoginState, RegisterState, etc.)
/// This replaces the old auth_e::execute_edit_action calls with the new canvas library
async fn handle_canvas_state_edit<S: CanvasState>(
key: KeyEvent,
config: &Config,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> Result<String> {
// Try direct key mapping first (same pattern as FormState)
if let Some(canvas_action) = CanvasAction::from_key(key.code) {
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => {
return Ok(msg.unwrap_or_default());
}
Ok(ActionResult::HandledByFeature(msg)) => {
return Ok(msg);
}
Ok(ActionResult::Error(msg)) => {
return Ok(format!("Error: {}", msg));
}
Ok(ActionResult::RequiresContext(msg)) => {
return Ok(format!("Context needed: {}", msg));
}
Err(_) => {
// Fall through to try config mapping
}
}
}
// Try config-mapped action (same pattern as FormState)
if let Some(action_str) = config.get_edit_action_for_key(key.code, key.modifiers) {
let canvas_action = CanvasAction::from_string(&action_str);
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => {
return Ok(msg.unwrap_or_default());
}
Ok(ActionResult::HandledByFeature(msg)) => {
return Ok(msg);
}
Ok(ActionResult::Error(msg)) => {
return Ok(format!("Error: {}", msg));
}
Ok(ActionResult::RequiresContext(msg)) => {
return Ok(format!("Context needed: {}", msg));
}
Err(e) => {
return Ok(format!("Action failed: {}", e));
}
}
}
Ok(String::new())
}
#[allow(clippy::too_many_arguments)]
pub async fn handle_edit_event(
key: KeyEvent,
@@ -170,7 +238,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 +270,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
@@ -237,8 +305,8 @@ pub async fn handle_edit_event(
} else {
"insert_char"
};
// FIX: Pass &mut event_handler.ideal_cursor_column
form_e::execute_edit_action(
// FIXED: Use canvas library instead of form_e::execute_edit_action
execute_canvas_action(
action,
key,
form_state,
@@ -267,8 +335,8 @@ pub async fn handle_edit_event(
{
// Handle Enter key (next field)
if action_str == "enter_decider" {
// FIX: Pass &mut event_handler.ideal_cursor_column
let msg = form_e::execute_edit_action(
// FIXED: Use canvas library instead of form_e::execute_edit_action
let msg = execute_canvas_action(
"next_field",
key,
form_state,
@@ -283,46 +351,46 @@ pub async fn handle_edit_event(
return Ok(EditEventOutcome::ExitEditMode);
}
// Handle all other edit actions
// Handle all other edit actions - NOW USING CANVAS LIBRARY
let msg = if app_state.ui.show_login {
// FIX: Pass &mut event_handler.ideal_cursor_column
auth_e::execute_edit_action(
action_str,
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
login_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_table {
// FIX: Pass &mut event_handler.ideal_cursor_column
add_table_e::execute_edit_action(
action_str,
// NEW: Use unified canvas handler instead of add_table_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
&mut admin_state.add_table_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_logic {
// FIX: Pass &mut event_handler.ideal_cursor_column
add_logic_e::execute_edit_action(
action_str,
// NEW: Use unified canvas handler instead of add_logic_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
&mut admin_state.add_logic_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_register {
// FIX: Pass &mut event_handler.ideal_cursor_column
auth_e::execute_edit_action(
action_str,
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
register_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else {
// FIX: Pass &mut event_handler.ideal_cursor_column
form_e::execute_edit_action(
// FIXED: Use canvas library instead of form_e::execute_edit_action
execute_canvas_action(
action_str,
key,
form_state,
@@ -336,44 +404,44 @@ pub async fn handle_edit_event(
// --- FALLBACK FOR CHARACTER INSERTION (IF NO OTHER BINDING MATCHED) ---
if let KeyCode::Char(_) = key.code {
let msg = if app_state.ui.show_login {
// FIX: Pass &mut event_handler.ideal_cursor_column
auth_e::execute_edit_action(
"insert_char",
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
login_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_table {
// FIX: Pass &mut event_handler.ideal_cursor_column
add_table_e::execute_edit_action(
"insert_char",
// NEW: Use unified canvas handler instead of add_table_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
&mut admin_state.add_table_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_logic {
// FIX: Pass &mut event_handler.ideal_cursor_column
add_logic_e::execute_edit_action(
"insert_char",
// NEW: Use unified canvas handler instead of add_logic_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
&mut admin_state.add_logic_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_register {
// FIX: Pass &mut event_handler.ideal_cursor_column
auth_e::execute_edit_action(
"insert_char",
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
register_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else {
// FIX: Pass &mut event_handler.ideal_cursor_column
form_e::execute_edit_action(
// FIXED: Use canvas library instead of form_e::execute_edit_action
execute_canvas_action(
"insert_char",
key,
form_state,

View File

@@ -3,17 +3,87 @@
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::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;
/// Helper function to dispatch canvas action for any CanvasState
async fn dispatch_canvas_action<S: CanvasState>(
action: &str,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> String {
let canvas_action = CanvasAction::from_string(action);
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => msg.unwrap_or_default(),
Ok(ActionResult::HandledByFeature(msg)) => msg,
Ok(ActionResult::Error(msg)) => format!("Error: {}", msg),
Ok(ActionResult::RequiresContext(msg)) => format!("Context needed: {}", msg),
Err(e) => format!("Action failed: {}", e),
}
}
/// Helper function to dispatch canvas action to the appropriate state based on UI
async fn dispatch_to_active_state(
action: &str,
app_state: &AppState,
form_state: &mut FormState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
add_table_state: &mut AddTableState,
add_logic_state: &mut AddLogicState,
ideal_cursor_column: &mut usize,
) -> String {
if app_state.ui.show_add_table {
dispatch_canvas_action(action, add_table_state, ideal_cursor_column).await
} else if app_state.ui.show_add_logic {
dispatch_canvas_action(action, add_logic_state, ideal_cursor_column).await
} else if app_state.ui.show_register {
dispatch_canvas_action(action, register_state, ideal_cursor_column).await
} else if app_state.ui.show_login {
dispatch_canvas_action(action, login_state, ideal_cursor_column).await
} else {
dispatch_canvas_action(action, form_state, ideal_cursor_column).await
}
}
/// Helper function to handle context-specific actions that need special treatment
async fn handle_context_action(
action: &str,
app_state: &AppState,
form_state: &mut FormState,
grpc_client: &mut GrpcClient,
ideal_cursor_column: &mut usize,
) -> Result<Option<String>> {
const CONTEXT_ACTIONS_FORM: &[&str] = &[
"previous_entry",
"next_entry",
];
const CONTEXT_ACTIONS_LOGIN: &[&str] = &[
"previous_entry",
"next_entry",
];
if app_state.ui.show_form && CONTEXT_ACTIONS_FORM.contains(&action) {
Ok(Some(crate::tui::functions::form::handle_action(
action,
form_state,
grpc_client,
ideal_cursor_column,
).await?))
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
Ok(Some(crate::tui::functions::login::handle_action(action).await?))
} else {
Ok(None) // Not a context action, use regular canvas dispatch
}
}
pub async fn handle_form_readonly_with_canvas(
key_event: KeyEvent,
config: &Config,
@@ -88,8 +158,7 @@ pub async fn handle_read_only_event(
}
if config.is_enter_edit_mode_after(key.code, key.modifiers) {
// Determine target state to adjust cursor
// Determine target state to adjust cursor - all states now use CanvasState trait
if app_state.ui.show_login {
let current_input = login_state.get_current_input();
let current_pos = login_state.current_cursor_pos();
@@ -119,8 +188,7 @@ pub async fn handle_read_only_event(
*ideal_cursor_column = add_table_state.current_cursor_pos();
}
} else {
// Handle FormState (uses library CanvasState)
use canvas::CanvasState as LibraryCanvasState; // Import at the top of the function
// Handle FormState
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() {
@@ -134,76 +202,31 @@ pub async fn handle_read_only_event(
return Ok((false, command_message.clone()));
}
const CONTEXT_ACTIONS_FORM: &[&str] = &[
"previous_entry",
"next_entry",
];
const CONTEXT_ACTIONS_LOGIN: &[&str] = &[
"previous_entry",
"next_entry",
];
if key.modifiers.is_empty() {
key_sequence_tracker.add_key(key.code);
let sequence = key_sequence_tracker.get_sequence();
if let Some(action) = config.matches_key_sequence_generalized(&sequence).as_deref() {
let result = if app_state.ui.show_form && CONTEXT_ACTIONS_FORM.contains(&action) {
crate::tui::functions::form::handle_action(
// Try context-specific actions first, otherwise use canvas dispatch
let result = if let Some(context_result) = handle_context_action(
action,
app_state,
form_state,
grpc_client,
ideal_cursor_column,
).await? {
context_result
} else {
dispatch_to_active_state(
action,
app_state,
form_state,
grpc_client,
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
crate::tui::functions::login::handle_action(action).await?
} else if app_state.ui.show_add_table {
add_table_ro::execute_action(
action,
app_state,
login_state,
register_state,
add_table_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_add_logic {
add_logic_ro::execute_action(
action,
app_state,
add_logic_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_register{
auth_ro::execute_action(
action,
app_state,
register_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_login {
auth_ro::execute_action(
action,
app_state,
login_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
)
.await?
} else {
form_ro::execute_action(
action,
form_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
)
.await?
).await
};
key_sequence_tracker.reset();
return Ok((false, result));
@@ -215,62 +238,26 @@ pub async fn handle_read_only_event(
if sequence.len() == 1 && !config.is_key_sequence_prefix(&sequence) {
if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers).as_deref() {
let result = if app_state.ui.show_form && CONTEXT_ACTIONS_FORM.contains(&action) {
crate::tui::functions::form::handle_action(
// Try context-specific actions first, otherwise use canvas dispatch
let result = if let Some(context_result) = handle_context_action(
action,
app_state,
form_state,
grpc_client,
ideal_cursor_column,
).await? {
context_result
} else {
dispatch_to_active_state(
action,
app_state,
form_state,
grpc_client,
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
crate::tui::functions::login::handle_action(action).await?
} else if app_state.ui.show_add_table {
add_table_ro::execute_action(
action,
app_state,
login_state,
register_state,
add_table_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_add_logic {
add_logic_ro::execute_action(
action,
app_state,
add_logic_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_register {
auth_ro::execute_action(
action,
app_state,
register_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_login {
auth_ro::execute_action(
action,
app_state,
login_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
)
.await?
} else {
form_ro::execute_action(
action,
form_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
)
.await?
).await
};
key_sequence_tracker.reset();
return Ok((false, result));
@@ -281,62 +268,26 @@ pub async fn handle_read_only_event(
key_sequence_tracker.reset();
if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers).as_deref() {
let result = if app_state.ui.show_form && CONTEXT_ACTIONS_FORM.contains(&action) {
crate::tui::functions::form::handle_action(
// Try context-specific actions first, otherwise use canvas dispatch
let result = if let Some(context_result) = handle_context_action(
action,
app_state,
form_state,
grpc_client,
ideal_cursor_column,
).await? {
context_result
} else {
dispatch_to_active_state(
action,
app_state,
form_state,
grpc_client,
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
crate::tui::functions::login::handle_action(action).await?
} else if app_state.ui.show_add_table {
add_table_ro::execute_action(
action,
app_state,
login_state,
register_state,
add_table_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_add_logic {
add_logic_ro::execute_action(
action,
app_state,
add_logic_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_register {
auth_ro::execute_action(
action,
app_state,
register_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_login {
auth_ro::execute_action(
action,
app_state,
login_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
)
.await?
} else {
form_ro::execute_action(
action,
form_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
)
.await?
).await
};
return Ok((false, result));
}

View File

@@ -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;

View File

@@ -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(

View File

@@ -1,4 +1,3 @@
// src/modes/handlers.rs
pub mod event;
pub mod event_helper;
pub mod mode_manager;

View File

@@ -15,23 +15,20 @@ use crate::modes::{
general::{dialog, navigation},
handlers::mode_manager::{AppMode, ModeManager},
};
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 super::event_helper::*;
use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher};
use canvas::canvas::CanvasState; // Only need this import now
use crate::state::{
app::{
buffer::{AppView, BufferState},
highlight::HighlightState,
search::SearchState, // Correctly imported
search::SearchState,
state::AppState,
},
pages::{
admin::AdminState,
auth::{AuthState, LoginState, RegisterState},
canvas_state::CanvasState,
form::FormState,
intro::IntroState,
},
@@ -89,7 +86,6 @@ pub struct EventHandler {
pub navigation_state: NavigationState,
pub search_result_sender: mpsc::UnboundedSender<Vec<Hit>>,
pub search_result_receiver: mpsc::UnboundedReceiver<Vec<Hit>>,
// --- ADDED FOR LIVE AUTOCOMPLETE ---
pub autocomplete_result_sender: mpsc::UnboundedSender<Vec<Hit>>,
pub autocomplete_result_receiver: mpsc::UnboundedReceiver<Vec<Hit>>,
}
@@ -103,7 +99,7 @@ impl EventHandler {
grpc_client: GrpcClient,
) -> Result<Self> {
let (search_tx, search_rx) = unbounded_channel();
let (autocomplete_tx, autocomplete_rx) = unbounded_channel(); // ADDED
let (autocomplete_tx, autocomplete_rx) = unbounded_channel();
Ok(EventHandler {
command_mode: false,
command_input: String::new(),
@@ -122,7 +118,6 @@ impl EventHandler {
navigation_state: NavigationState::new(),
search_result_sender: search_tx,
search_result_receiver: search_rx,
// --- ADDED ---
autocomplete_result_sender: autocomplete_tx,
autocomplete_result_receiver: autocomplete_rx,
})
@@ -136,6 +131,95 @@ impl EventHandler {
self.navigation_state.activate_find_file(options);
}
// Helper functions - replace the removed event_helper functions
fn get_current_field_for_state(
app_state: &AppState,
login_state: &LoginState,
register_state: &RegisterState,
form_state: &FormState,
) -> usize {
if app_state.ui.show_login {
login_state.current_field()
} else if app_state.ui.show_register {
register_state.current_field()
} else {
form_state.current_field()
}
}
fn get_current_cursor_pos_for_state(
app_state: &AppState,
login_state: &LoginState,
register_state: &RegisterState,
form_state: &FormState,
) -> usize {
if app_state.ui.show_login {
login_state.current_cursor_pos()
} else if app_state.ui.show_register {
register_state.current_cursor_pos()
} else {
form_state.current_cursor_pos()
}
}
fn get_has_unsaved_changes_for_state(
app_state: &AppState,
login_state: &LoginState,
register_state: &RegisterState,
form_state: &FormState,
) -> bool {
if app_state.ui.show_login {
login_state.has_unsaved_changes()
} else if app_state.ui.show_register {
register_state.has_unsaved_changes()
} else {
form_state.has_unsaved_changes()
}
}
fn get_current_input_for_state<'a>(
app_state: &AppState,
login_state: &'a LoginState,
register_state: &'a RegisterState,
form_state: &'a FormState,
) -> &'a str {
if app_state.ui.show_login {
login_state.get_current_input()
} else if app_state.ui.show_register {
register_state.get_current_input()
} else {
form_state.get_current_input()
}
}
fn set_current_cursor_pos_for_state(
app_state: &AppState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
form_state: &mut FormState,
pos: usize,
) {
if app_state.ui.show_login {
login_state.set_current_cursor_pos(pos);
} else if app_state.ui.show_register {
register_state.set_current_cursor_pos(pos);
} else {
form_state.set_current_cursor_pos(pos);
}
}
fn get_cursor_pos_for_mixed_state(
app_state: &AppState,
login_state: &LoginState,
form_state: &FormState,
) -> usize {
if app_state.ui.show_login || app_state.ui.show_register {
login_state.current_cursor_pos()
} else {
form_state.current_cursor_pos()
}
}
// This function handles state changes.
async fn handle_search_palette_event(
&mut self,
@@ -199,7 +283,6 @@ impl EventHandler {
_ => {}
}
// --- START CORRECTED LOGIC ---
if trigger_search {
search_state.is_loading = true;
search_state.results.clear();
@@ -214,7 +297,6 @@ impl EventHandler {
"--- 1. Spawning search task for query: '{}' ---",
query
);
// We now move the grpc_client into the task, just like with login.
tokio::spawn(async move {
info!("--- 2. Background task started. ---");
match grpc_client.search_table(table_name, query).await {
@@ -226,7 +308,6 @@ impl EventHandler {
let _ = sender.send(response.hits);
}
Err(e) => {
// THE FIX: Use the debug formatter `{:?}` to print the full error chain.
error!("--- 3b. gRPC call failed: {:?} ---", e);
let _ = sender.send(vec![]);
}
@@ -235,8 +316,6 @@ impl EventHandler {
}
}
// The borrow on `app_state.search_state` ends here.
// Now we can safely modify the Option itself.
if should_close {
app_state.search_state = None;
app_state.ui.show_search_palette = false;
@@ -264,7 +343,6 @@ impl EventHandler {
) -> Result<EventOutcome> {
if app_state.ui.show_search_palette {
if let Event::Key(key_event) = event {
// The call no longer passes grpc_client
return self
.handle_search_palette_event(
key_event,
@@ -581,7 +659,7 @@ impl EventHandler {
if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise")
&& ModeManager::can_enter_highlight_mode(current_mode)
{
let current_field_index = get_current_field_for_state(
let current_field_index = Self::get_current_field_for_state(
app_state,
login_state,
register_state,
@@ -596,13 +674,13 @@ impl EventHandler {
else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode")
&& ModeManager::can_enter_highlight_mode(current_mode)
{
let current_field_index = get_current_field_for_state(
let current_field_index = Self::get_current_field_for_state(
app_state,
login_state,
register_state,
form_state
);
let current_cursor_pos = get_current_cursor_pos_for_state(
let current_cursor_pos = Self::get_current_cursor_pos_for_state(
app_state,
login_state,
register_state,
@@ -627,13 +705,13 @@ impl EventHandler {
else if config.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_after")
&& ModeManager::can_enter_edit_mode(current_mode)
{
let current_input = get_current_input_for_state(
let current_input = Self::get_current_input_for_state(
app_state,
login_state,
register_state,
form_state
);
let current_cursor_pos = get_cursor_pos_for_mixed_state(
let current_cursor_pos = Self::get_cursor_pos_for_mixed_state(
app_state,
login_state,
form_state
@@ -642,14 +720,14 @@ impl EventHandler {
// Move cursor forward if possible
if !current_input.is_empty() && current_cursor_pos < current_input.len() {
let new_cursor_pos = current_cursor_pos + 1;
set_current_cursor_pos_for_state(
Self::set_current_cursor_pos_for_state(
app_state,
login_state,
register_state,
form_state,
new_cursor_pos
);
self.ideal_cursor_column = get_current_cursor_pos_for_state(
self.ideal_cursor_column = Self::get_current_cursor_pos_for_state(
app_state,
login_state,
register_state,
@@ -694,13 +772,13 @@ impl EventHandler {
}
}
// Try canvas action for form first (NEW: Canvas library integration)
// Try canvas action for form first
if app_state.ui.show_form {
if let Ok(Some(canvas_message)) = self.handle_form_canvas_action(
key_event,
config,
form_state,
false, // not edit mode
false,
).await {
return Ok(EventOutcome::Ok(canvas_message));
}
@@ -753,7 +831,7 @@ impl EventHandler {
&mut admin_state.add_table_state,
&mut admin_state.add_logic_state,
&mut self.key_sequence_tracker,
&mut self.grpc_client, // <-- FIX 2
&mut self.grpc_client,
&mut self.command_message,
&mut self.edit_mode_cooldown,
&mut self.ideal_cursor_column,
@@ -784,13 +862,13 @@ impl EventHandler {
}
}
// Try canvas action for form first (NEW: Canvas library integration)
// Try canvas action for form first
if app_state.ui.show_form {
if let Ok(Some(canvas_message)) = self.handle_form_canvas_action(
key_event,
config,
form_state,
true, // edit mode
true,
).await {
if !canvas_message.is_empty() {
self.command_message = canvas_message.clone();
@@ -823,7 +901,7 @@ impl EventHandler {
self.edit_mode_cooldown = true;
// Check for unsaved changes across all states
let has_changes = get_has_unsaved_changes_for_state(
let has_changes = Self::get_has_unsaved_changes_for_state(
app_state,
login_state,
register_state,
@@ -840,13 +918,13 @@ impl EventHandler {
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
// Get current input and cursor position
let current_input = get_current_input_for_state(
let current_input = Self::get_current_input_for_state(
app_state,
login_state,
register_state,
form_state
);
let current_cursor_pos = get_current_cursor_pos_for_state(
let current_cursor_pos = Self::get_current_cursor_pos_for_state(
app_state,
login_state,
register_state,
@@ -856,7 +934,7 @@ impl EventHandler {
// Adjust cursor if it's beyond the input length
if !current_input.is_empty() && current_cursor_pos >= current_input.len() {
let new_pos = current_input.len() - 1;
set_current_cursor_pos_for_state(
Self::set_current_cursor_pos_for_state(
app_state,
login_state,
register_state,
@@ -906,7 +984,7 @@ impl EventHandler {
form_state,
&mut self.command_input,
&mut self.command_message,
&mut self.grpc_client, // <-- FIX 5
&mut self.grpc_client,
command_handler,
terminal,
&mut current_position,
@@ -1024,11 +1102,10 @@ impl EventHandler {
async fn handle_form_canvas_action(
&mut self,
key_event: KeyEvent,
_config: &Config, // Not used anymore - canvas has its own config
_config: &Config,
form_state: &mut FormState,
is_edit_mode: bool,
) -> Result<Option<String>> {
// Load canvas config (canvas_config.toml or vim defaults)
let canvas_config = canvas::config::CanvasConfig::load();
// Handle suggestion actions first if suggestions are active
@@ -1083,7 +1160,6 @@ impl EventHandler {
}
}
// FIXED: Use canvas config instead of client config
let action_str = canvas_config.get_action_for_key(
key_event.code,
key_event.modifiers,
@@ -1092,9 +1168,8 @@ impl EventHandler {
);
if let Some(action_str) = action_str {
// Filter out mode transition actions - let legacy handlers deal with these
if Self::is_mode_transition_action(action_str) {
return Ok(None); // Let legacy handler handle mode transitions
return Ok(None);
}
let canvas_action = CanvasAction::from_string(&action_str);
@@ -1131,7 +1206,6 @@ impl EventHandler {
} else {
// In read-only mode, only handle non-character keys
let canvas_action = match key_event.code {
// Only handle special keys that don't conflict with vim bindings
KeyCode::Left => Some(CanvasAction::MoveLeft),
KeyCode::Right => Some(CanvasAction::MoveRight),
KeyCode::Up => Some(CanvasAction::MoveUp),
@@ -1164,7 +1238,6 @@ impl EventHandler {
Ok(None)
}
// ADDED: Helper function to identify mode transition actions
fn is_mode_transition_action(action: &str) -> bool {
matches!(action,
"exit" |
@@ -1181,11 +1254,11 @@ impl EventHandler {
"force_quit" |
"save_and_quit" |
"revert" |
"enter_decider" | // This is also handled specially by legacy system
"trigger_autocomplete" | // This is handled specially by legacy system
"suggestion_up" | // These are handled above in suggestion logic
"enter_decider" |
"trigger_autocomplete" |
"suggestion_up" |
"suggestion_down" |
"previous_entry" | // Navigation between records
"previous_entry" |
"next_entry" |
"toggle_sidebar" |
"toggle_buffer_list" |

View File

@@ -1,105 +0,0 @@
// src/modes/handlers/event_helper.rs
//! Helper functions to handle the differences between legacy and library CanvasState traits
use crate::state::app::state::AppState;
use crate::state::pages::{
form::FormState,
auth::{LoginState, RegisterState},
};
use crate::state::pages::canvas_state::CanvasState as LegacyCanvasState;
use 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(
app_state: &AppState,
login_state: &LoginState,
register_state: &RegisterState,
form_state: &FormState,
) -> usize {
if app_state.ui.show_login {
login_state.current_field() // Uses LegacyCanvasState
} else if app_state.ui.show_register {
register_state.current_field() // Uses LegacyCanvasState
} else {
form_state.current_field() // Uses LibraryCanvasState
}
}
/// Get the current cursor position from the appropriate state based on which UI is active
pub fn get_current_cursor_pos_for_state(
app_state: &AppState,
login_state: &LoginState,
register_state: &RegisterState,
form_state: &FormState,
) -> usize {
if app_state.ui.show_login {
login_state.current_cursor_pos() // Uses LegacyCanvasState
} else if app_state.ui.show_register {
register_state.current_cursor_pos() // Uses LegacyCanvasState
} else {
form_state.current_cursor_pos() // Uses LibraryCanvasState
}
}
/// Check if the appropriate state has unsaved changes based on which UI is active
pub fn get_has_unsaved_changes_for_state(
app_state: &AppState,
login_state: &LoginState,
register_state: &RegisterState,
form_state: &FormState,
) -> bool {
if app_state.ui.show_login {
login_state.has_unsaved_changes() // Uses LegacyCanvasState
} else if app_state.ui.show_register {
register_state.has_unsaved_changes() // Uses LegacyCanvasState
} else {
form_state.has_unsaved_changes() // Uses LibraryCanvasState
}
}
/// Get the current input from the appropriate state based on which UI is active
pub fn get_current_input_for_state<'a>(
app_state: &AppState,
login_state: &'a LoginState,
register_state: &'a RegisterState,
form_state: &'a FormState,
) -> &'a str {
if app_state.ui.show_login {
login_state.get_current_input() // Uses LegacyCanvasState
} else if app_state.ui.show_register {
register_state.get_current_input() // Uses LegacyCanvasState
} else {
form_state.get_current_input() // Uses LibraryCanvasState
}
}
/// Set the cursor position for the appropriate state based on which UI is active
pub fn set_current_cursor_pos_for_state(
app_state: &AppState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
form_state: &mut FormState,
pos: usize,
) {
if app_state.ui.show_login {
login_state.set_current_cursor_pos(pos); // Uses LegacyCanvasState
} else if app_state.ui.show_register {
register_state.set_current_cursor_pos(pos); // Uses LegacyCanvasState
} else {
form_state.set_current_cursor_pos(pos); // Uses LibraryCanvasState
}
}
/// Get cursor position for mixed login/register vs form logic
pub fn get_cursor_pos_for_mixed_state(
app_state: &AppState,
login_state: &LoginState,
form_state: &FormState,
) -> usize {
if app_state.ui.show_login || app_state.ui.show_register {
login_state.current_cursor_pos() // Uses LegacyCanvasState
} else {
form_state.current_cursor_pos() // Uses LibraryCanvasState
}
}

View File

@@ -6,4 +6,3 @@ pub mod admin;
pub mod intro;
pub mod add_table;
pub mod add_logic;
pub mod canvas_state;

View File

@@ -1,6 +1,6 @@
// src/state/pages/add_logic.rs
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
use crate::state::pages::canvas_state::CanvasState;
use canvas::canvas::{CanvasState, ActionContext, CanvasAction}; // External library
use crate::components::common::text_editor::{TextEditor, VimState};
use std::cell::RefCell;
use std::rc::Rc;
@@ -50,7 +50,7 @@ pub struct AddLogicState {
pub script_editor_trigger_position: Option<(usize, usize)>, // (line, column)
pub all_table_names: Vec<String>,
pub script_editor_filter_text: String,
// New fields for same-profile table names and column autocomplete
pub same_profile_table_names: Vec<String>, // Tables from same profile only
pub script_editor_awaiting_column_autocomplete: Option<String>, // Table name waiting for column fetch
@@ -88,7 +88,7 @@ impl AddLogicState {
script_editor_trigger_position: None,
all_table_names: Vec::new(),
script_editor_filter_text: String::new(),
same_profile_table_names: Vec::new(),
script_editor_awaiting_column_autocomplete: None,
}
@@ -181,7 +181,7 @@ impl AddLogicState {
}
self.same_profile_table_names.contains(&suggestion.to_string())
}
/// Sets table columns for autocomplete suggestions
pub fn set_table_columns(&mut self, columns: Vec<String>) {
self.table_columns_for_suggestions = columns.clone();
@@ -225,6 +225,46 @@ impl AddLogicState {
self.script_editor_trigger_position = None;
self.script_editor_filter_text.clear();
}
/// Helper method to validate and save logic
pub fn save_logic(&mut self) -> Option<String> {
if self.logic_name_input.trim().is_empty() {
return Some("Logic name is required".to_string());
}
if self.target_column_input.trim().is_empty() {
return Some("Target column is required".to_string());
}
let script_content = {
let editor_borrow = self.script_content_editor.borrow();
editor_borrow.lines().join("\n")
};
if script_content.trim().is_empty() {
return Some("Script content is required".to_string());
}
// Here you would typically save to database/storage
// For now, just clear the form and mark as saved
self.has_unsaved_changes = false;
Some(format!("Logic '{}' saved successfully", self.logic_name_input.trim()))
}
/// Helper method to clear the form
pub fn clear_form(&mut self) -> Option<String> {
let profile = self.profile_name.clone();
let table_id = self.selected_table_id;
let table_name = self.selected_table_name.clone();
let editor_config = EditorConfig::default(); // You might want to preserve the actual config
*self = Self::new(&editor_config);
self.profile_name = profile;
self.selected_table_id = table_id;
self.selected_table_name = table_name;
Some("Form cleared".to_string())
}
}
impl Default for AddLogicState {
@@ -233,59 +273,18 @@ impl Default for AddLogicState {
}
}
// Implement external library's CanvasState for AddLogicState
impl CanvasState for AddLogicState {
fn current_field(&self) -> usize {
match self.current_focus {
AddLogicFocus::InputLogicName => 0,
AddLogicFocus::InputTargetColumn => 1,
AddLogicFocus::InputDescription => 2,
// If focus is elsewhere, return the last canvas field used
_ => self.last_canvas_field,
}
}
fn current_cursor_pos(&self) -> usize {
match self.current_focus {
AddLogicFocus::InputLogicName => self.logic_name_cursor_pos,
AddLogicFocus::InputTargetColumn => self.target_column_cursor_pos,
AddLogicFocus::InputDescription => self.description_cursor_pos,
_ => 0,
}
}
fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
fn inputs(&self) -> Vec<&String> {
vec![
&self.logic_name_input,
&self.target_column_input,
&self.description_input,
]
}
fn get_current_input(&self) -> &str {
match self.current_focus {
AddLogicFocus::InputLogicName => &self.logic_name_input,
AddLogicFocus::InputTargetColumn => &self.target_column_input,
AddLogicFocus::InputDescription => &self.description_input,
_ => "",
}
}
fn get_current_input_mut(&mut self) -> &mut String {
match self.current_focus {
AddLogicFocus::InputLogicName => &mut self.logic_name_input,
AddLogicFocus::InputTargetColumn => &mut self.target_column_input,
AddLogicFocus::InputDescription => &mut self.description_input,
_ => &mut self.logic_name_input,
}
}
fn fields(&self) -> Vec<&str> {
vec!["Logic Name", "Target Column", "Description"]
}
fn set_current_field(&mut self, index: usize) {
let new_focus = match index {
0 => AddLogicFocus::InputLogicName,
@@ -303,6 +302,15 @@ impl CanvasState for AddLogicState {
}
}
fn current_cursor_pos(&self) -> usize {
match self.current_focus {
AddLogicFocus::InputLogicName => self.logic_name_cursor_pos,
AddLogicFocus::InputTargetColumn => self.target_column_cursor_pos,
AddLogicFocus::InputDescription => self.description_cursor_pos,
_ => 0,
}
}
fn set_current_cursor_pos(&mut self, pos: usize) {
match self.current_focus {
AddLogicFocus::InputLogicName => {
@@ -318,29 +326,117 @@ impl CanvasState for AddLogicState {
}
}
fn get_current_input(&self) -> &str {
match self.current_focus {
AddLogicFocus::InputLogicName => &self.logic_name_input,
AddLogicFocus::InputTargetColumn => &self.target_column_input,
AddLogicFocus::InputDescription => &self.description_input,
_ => "", // Should not happen if called correctly
}
}
fn get_current_input_mut(&mut self) -> &mut String {
match self.current_focus {
AddLogicFocus::InputLogicName => &mut self.logic_name_input,
AddLogicFocus::InputTargetColumn => &mut self.target_column_input,
AddLogicFocus::InputDescription => &mut self.description_input,
_ => &mut self.logic_name_input, // Fallback
}
}
fn inputs(&self) -> Vec<&String> {
vec![
&self.logic_name_input,
&self.target_column_input,
&self.description_input,
]
}
fn fields(&self) -> Vec<&str> {
vec!["Logic Name", "Target Column", "Description"]
}
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() == 1
&& self.in_target_column_suggestion_mode
&& self.show_target_column_suggestions
{
Some(&self.target_column_suggestions)
} else {
None
}
}
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
match action {
// Handle saving logic script
CanvasAction::Custom(action_str) if action_str == "save_logic" => {
self.save_logic()
}
fn get_selected_suggestion_index(&self) -> Option<usize> {
if self.current_field() == 1
&& self.in_target_column_suggestion_mode
&& self.show_target_column_suggestions
{
self.selected_target_column_suggestion_index
} else {
None
// Handle clearing the form
CanvasAction::Custom(action_str) if action_str == "clear_form" => {
self.clear_form()
}
// Handle target column autocomplete activation
CanvasAction::Custom(action_str) if action_str == "activate_autocomplete" => {
if self.current_field() == 1 { // Target Column field
self.in_target_column_suggestion_mode = true;
self.update_target_column_suggestions();
Some("Autocomplete activated".to_string())
} else {
None
}
}
// Handle target column suggestion selection
CanvasAction::Custom(action_str) if action_str == "select_suggestion" => {
if self.current_field() == 1 && self.in_target_column_suggestion_mode {
if let Some(selected_idx) = self.selected_target_column_suggestion_index {
if let Some(suggestion) = self.target_column_suggestions.get(selected_idx) {
self.target_column_input = suggestion.clone();
self.target_column_cursor_pos = suggestion.len();
self.in_target_column_suggestion_mode = false;
self.show_target_column_suggestions = false;
self.has_unsaved_changes = true;
return Some(format!("Selected: {}", suggestion));
}
}
}
None
}
// Custom validation when moving between fields
CanvasAction::NextField => {
match self.current_field() {
0 => { // Logic Name field
if self.logic_name_input.trim().is_empty() {
Some("Logic name cannot be empty".to_string())
} else {
None // Let canvas library handle the normal field movement
}
}
1 => { // Target Column field
// Update suggestions when entering target column field
self.update_target_column_suggestions();
None
}
_ => None,
}
}
// Handle character insertion with validation
CanvasAction::InsertChar(c) => {
if self.current_field() == 1 { // Target Column field
// Update suggestions after character insertion
// Note: Canvas library will handle the actual insertion
// This is just for triggering suggestion updates
None // Let canvas handle insertion, then we'll update suggestions
} else {
None // Let canvas handle normally
}
}
// Let canvas library handle everything else
_ => None,
}
}
}

View File

@@ -1,5 +1,5 @@
// src/state/pages/add_table.rs
use crate::state::pages::canvas_state::CanvasState;
use canvas::canvas::{CanvasState, ActionContext, CanvasAction}; // External library
use ratatui::widgets::TableState;
use serde::{Deserialize, Serialize};
@@ -67,7 +67,6 @@ pub struct AddTableState {
impl Default for AddTableState {
fn default() -> Self {
// Initialize with some dummy data for demonstration
AddTableState {
profile_name: "default".to_string(),
table_name: String::new(),
@@ -92,16 +91,91 @@ impl Default for AddTableState {
impl AddTableState {
pub const INPUT_FIELD_COUNT: usize = 3;
/// Helper method to add a column from current inputs
pub fn add_column_from_inputs(&mut self) -> Option<String> {
if self.column_name_input.trim().is_empty() || self.column_type_input.trim().is_empty() {
return Some("Both column name and type are required".to_string());
}
// Check for duplicate column names
if self.columns.iter().any(|col| col.name == self.column_name_input.trim()) {
return Some("Column name already exists".to_string());
}
// Add the column
self.columns.push(ColumnDefinition {
name: self.column_name_input.trim().to_string(),
data_type: self.column_type_input.trim().to_string(),
selected: false,
});
// Clear inputs and reset focus to column name for next entry
self.column_name_input.clear();
self.column_type_input.clear();
self.column_name_cursor_pos = 0;
self.column_type_cursor_pos = 0;
self.current_focus = AddTableFocus::InputColumnName;
self.last_canvas_field = 1;
self.has_unsaved_changes = true;
Some(format!("Column '{}' added successfully", self.columns.last().unwrap().name))
}
/// Helper method to delete selected items
pub fn delete_selected_items(&mut self) -> Option<String> {
let mut deleted_items = Vec::new();
// Remove selected columns
let initial_column_count = self.columns.len();
self.columns.retain(|col| {
if col.selected {
deleted_items.push(format!("column '{}'", col.name));
false
} else {
true
}
});
// Remove selected indexes
let initial_index_count = self.indexes.len();
self.indexes.retain(|idx| {
if idx.selected {
deleted_items.push(format!("index '{}'", idx.name));
false
} else {
true
}
});
// Remove selected links
let initial_link_count = self.links.len();
self.links.retain(|link| {
if link.selected {
deleted_items.push(format!("link to '{}'", link.linked_table_name));
false
} else {
true
}
});
if deleted_items.is_empty() {
Some("No items selected for deletion".to_string())
} else {
self.has_unsaved_changes = true;
Some(format!("Deleted: {}", deleted_items.join(", ")))
}
}
}
// Implement CanvasState for the input fields
// Implement external library's CanvasState for AddTableState
impl CanvasState for AddTableState {
fn current_field(&self) -> usize {
match self.current_focus {
AddTableFocus::InputTableName => 0,
AddTableFocus::InputColumnName => 1,
AddTableFocus::InputColumnType => 2,
// If focus is elsewhere, default to the first field for canvas rendering logic
// If focus is elsewhere, return the last canvas field used
_ => self.last_canvas_field,
}
}
@@ -115,37 +189,6 @@ impl CanvasState for AddTableState {
}
}
fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
fn inputs(&self) -> Vec<&String> {
vec![&self.table_name_input, &self.column_name_input, &self.column_type_input]
}
fn get_current_input(&self) -> &str {
match self.current_focus {
AddTableFocus::InputTableName => &self.table_name_input,
AddTableFocus::InputColumnName => &self.column_name_input,
AddTableFocus::InputColumnType => &self.column_type_input,
_ => "", // Should not happen if called correctly
}
}
fn get_current_input_mut(&mut self) -> &mut String {
match self.current_focus {
AddTableFocus::InputTableName => &mut self.table_name_input,
AddTableFocus::InputColumnName => &mut self.column_name_input,
AddTableFocus::InputColumnType => &mut self.column_type_input,
_ => &mut self.table_name_input,
}
}
fn fields(&self) -> Vec<&str> {
// These must match the order used in render_add_table
vec!["Table name", "Name", "Type"]
}
fn set_current_field(&mut self, index: usize) {
// Update both current focus and last canvas field
self.current_focus = match index {
@@ -174,17 +217,84 @@ impl CanvasState for AddTableState {
}
}
fn get_current_input(&self) -> &str {
match self.current_focus {
AddTableFocus::InputTableName => &self.table_name_input,
AddTableFocus::InputColumnName => &self.column_name_input,
AddTableFocus::InputColumnType => &self.column_type_input,
_ => "", // Should not happen if called correctly
}
}
fn get_current_input_mut(&mut self) -> &mut String {
match self.current_focus {
AddTableFocus::InputTableName => &mut self.table_name_input,
AddTableFocus::InputColumnName => &mut self.column_name_input,
AddTableFocus::InputColumnType => &mut self.column_type_input,
_ => &mut self.table_name_input, // Fallback
}
}
fn inputs(&self) -> Vec<&String> {
vec![&self.table_name_input, &self.column_name_input, &self.column_type_input]
}
fn fields(&self) -> Vec<&str> {
// These must match the order used in render_add_table
vec!["Table name", "Name", "Type"]
}
fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
fn set_has_unsaved_changes(&mut self, changed: bool) {
self.has_unsaved_changes = changed;
}
// --- Autocomplete Support (Not needed for this form yet) ---
fn get_suggestions(&self) -> Option<&[String]> {
None
}
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
match action {
// Handle adding column when user presses Enter on the Add button or uses specific action
CanvasAction::Custom(action_str) if action_str == "add_column" => {
self.add_column_from_inputs()
}
fn get_selected_suggestion_index(&self) -> Option<usize> {
None
// Handle table saving
CanvasAction::Custom(action_str) if action_str == "save_table" => {
if self.table_name_input.trim().is_empty() {
Some("Table name is required".to_string())
} else if self.columns.is_empty() {
Some("At least one column is required".to_string())
} else {
Some(format!("Saving table: {}", self.table_name_input))
}
}
// Handle deleting selected items
CanvasAction::Custom(action_str) if action_str == "delete_selected" => {
self.delete_selected_items()
}
// Handle canceling (clear form)
CanvasAction::Custom(action_str) if action_str == "cancel" => {
// Reset to defaults but keep profile_name
let profile = self.profile_name.clone();
*self = Self::default();
self.profile_name = profile;
Some("Form cleared".to_string())
}
// Custom validation when moving between fields
CanvasAction::NextField => {
// When leaving table name field, update the table_name for display
if self.current_field() == 0 && !self.table_name_input.trim().is_empty() {
self.table_name = self.table_name_input.trim().to_string();
}
None // Let canvas library handle the normal field movement
}
// Let canvas library handle everything else
_ => None,
}
}
}

View File

@@ -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(&current_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(&current_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;
}
}
}

View File

@@ -1,32 +0,0 @@
// src/state/pages/canvas_state.rs
use common::proto::komp_ac::search::search_response::Hit;
pub trait CanvasState {
// --- Existing methods (unchanged) ---
fn current_field(&self) -> usize;
fn current_cursor_pos(&self) -> usize;
fn has_unsaved_changes(&self) -> bool;
fn inputs(&self) -> Vec<&String>;
fn get_current_input(&self) -> &str;
fn get_current_input_mut(&mut self) -> &mut String;
fn fields(&self) -> Vec<&str>;
fn set_current_field(&mut self, index: usize);
fn set_current_cursor_pos(&mut self, pos: usize);
fn set_has_unsaved_changes(&mut self, changed: bool);
fn get_suggestions(&self) -> Option<&[String]>;
fn get_selected_suggestion_index(&self) -> Option<usize>;
fn get_rich_suggestions(&self) -> Option<&[Hit]> {
None
}
fn get_display_value_for_field(&self, index: usize) -> &str {
self.inputs()
.get(index)
.map(|s| s.as_str())
.unwrap_or("")
}
fn has_display_override(&self, _index: usize) -> bool {
false
}
}

View File

@@ -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(&current_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));
}
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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(

View File

@@ -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,10 @@ use crate::components::{
};
use crate::config::colors::themes::Theme;
use crate::modes::general::command_navigation::NavigationState;
use crate::state::pages::canvas_state::CanvasState;
use canvas::canvas::CanvasState;
use crate::state::app::buffer::BufferState;
use crate::state::app::highlight::HighlightState;
use crate::state::app::highlight::HighlightState as LocalHighlightState;
use canvas::canvas::HighlightState as CanvasHighlightState;
use crate::state::app::state::AppState;
use crate::state::pages::admin::AdminState;
use crate::state::pages::auth::AuthState;
@@ -32,6 +32,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 +53,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 +78,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 +136,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 +147,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 +157,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 +166,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 +208,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
);
}

View File

@@ -8,8 +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::form::{FormState, FieldDefinition}; // Import FieldDefinition
use canvas::canvas::CanvasState; // Only external library import
use crate::state::pages::form::{FormState, FieldDefinition};
use crate::state::pages::auth::AuthState;
use crate::state::pages::auth::LoginState;
use crate::state::pages::auth::RegisterState;
@@ -27,17 +27,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 +347,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 +375,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 &&

View File

@@ -2,7 +2,7 @@
use rstest::{fixture, rstest};
use std::collections::HashMap;
use client::state::pages::form::{FormState, FieldDefinition};
use client::state::pages::canvas_state::CanvasState;
use canvas::canvas::CanvasState;
#[fixture]
fn test_form_state() -> FormState {

View File

@@ -2,7 +2,7 @@
pub use rstest::{fixture, rstest};
pub use client::services::grpc_client::GrpcClient;
pub use client::state::pages::form::FormState;
pub use client::state::pages::canvas_state::CanvasState;
pub use canvas::canvas::CanvasState;
pub use prost_types::Value;
pub use prost_types::value::Kind;
pub use std::collections::HashMap;

View File

@@ -5,6 +5,7 @@ use sqlx::{PgPool, Transaction, Postgres};
use serde_json::json;
use common::proto::komp_ac::table_definition::{PostTableDefinitionRequest, TableDefinitionResponse};
// TODO CRITICAL add decimal with optional precision"
const PREDEFINED_FIELD_TYPES: &[(&str, &str)] = &[
("text", "TEXT"),
("string", "TEXT"),