Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f99aa79ec | ||
|
|
c594c35b37 | ||
|
|
828a63c30c | ||
|
|
36690e674a | ||
|
|
8788323c62 | ||
|
|
5b64996462 | ||
|
|
3f4380ff48 | ||
|
|
59a29aa54b | ||
|
|
5d084bf822 | ||
|
|
ebe4adaa5d | ||
|
|
c3441647e0 | ||
|
|
574803988d | ||
|
|
9ff3c59961 | ||
|
|
c5f22d7da1 | ||
|
|
3c62877757 | ||
|
|
cc19c61f37 | ||
|
|
ad82bd4302 | ||
|
|
d584a25fdb | ||
|
|
baa4295059 | ||
|
|
6cbfac9d6e | ||
|
|
13d28f19ea | ||
|
|
8fa86965b8 | ||
|
|
72c38f613f | ||
|
|
e4982f871f | ||
|
|
4e0338276f | ||
|
|
fe193f4f91 | ||
|
|
0011ba0c04 | ||
|
|
3c2eef9596 | ||
|
|
dac788351f | ||
|
|
8d5bc1296e | ||
|
|
969ad229e4 | ||
|
|
0d291fcf57 | ||
|
|
d711f4c491 | ||
|
|
9369626e21 | ||
|
|
f84bb0dc9e | ||
|
|
20b428264e | ||
|
|
05bb84fc98 | ||
|
|
46a85e4b4a | ||
|
|
b4d1572c79 | ||
|
|
b8e1b77222 | ||
|
|
1a451a576f | ||
|
|
074b2914d8 | ||
|
|
aec5f80879 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@
|
|||||||
server/tantivy_indexes
|
server/tantivy_indexes
|
||||||
steel_decimal/tests/property_tests.proptest-regressions
|
steel_decimal/tests/property_tests.proptest-regressions
|
||||||
.direnv/
|
.direnv/
|
||||||
|
canvas/*.toml
|
||||||
|
|||||||
15
Cargo.lock
generated
15
Cargo.lock
generated
@@ -472,16 +472,21 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "canvas"
|
name = "canvas"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"async-trait",
|
||||||
"common",
|
"common",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"serde",
|
"serde",
|
||||||
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-test",
|
"tokio-test",
|
||||||
"toml",
|
"toml",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"unicode-width 0.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -555,7 +560,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "client"
|
name = "client"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -606,7 +611,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "common"
|
name = "common"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"prost",
|
"prost",
|
||||||
"prost-types",
|
"prost-types",
|
||||||
@@ -2892,7 +2897,7 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "search"
|
name = "search"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"common",
|
"common",
|
||||||
@@ -2991,7 +2996,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "server"
|
name = "server"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ resolver = "2"
|
|||||||
[workspace.package]
|
[workspace.package]
|
||||||
# TODO: idk how to do the name, fix later
|
# TODO: idk how to do the name, fix later
|
||||||
# name = "komp_ac"
|
# name = "komp_ac"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
authors = ["Filip Priečinský <filippriec@gmail.com>"]
|
authors = ["Filip Priečinský <filippriec@gmail.com>"]
|
||||||
@@ -50,5 +50,6 @@ regex = "1.11.1"
|
|||||||
ratatui = { version = "0.29.0", features = ["crossterm"] }
|
ratatui = { version = "0.29.0", features = ["crossterm"] }
|
||||||
crossterm = "0.28.1"
|
crossterm = "0.28.1"
|
||||||
toml = "0.8.20"
|
toml = "0.8.20"
|
||||||
|
unicode-width = "0.2.0"
|
||||||
|
|
||||||
common = { path = "./common" }
|
common = { path = "./common" }
|
||||||
|
|||||||
@@ -1,160 +1,334 @@
|
|||||||
# Canvas Crate Migration Documentation
|
# Canvas Library Migration Guide
|
||||||
|
|
||||||
## Files Moved from Client to Canvas
|
## Overview
|
||||||
|
|
||||||
### Core Canvas Files
|
This guide covers the migration from the legacy canvas library structure to the new clean, modular architecture. The new design separates core canvas functionality from autocomplete features, providing better type safety and maintainability.
|
||||||
|
|
||||||
| **Canvas Location** | **Original Client Location** | **Purpose** |
|
## Key Changes
|
||||||
|-------------------|---------------------------|-----------|
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
## 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
|
```rust
|
||||||
// OLD
|
# OLD IMPORTS
|
||||||
use crate::state::pages::canvas_state::CanvasState;
|
|
||||||
|
|
||||||
// NEW
|
|
||||||
use canvas::CanvasState;
|
use canvas::CanvasState;
|
||||||
```
|
use canvas::CanvasAction;
|
||||||
|
use canvas::ActionContext;
|
||||||
**Files that need updating:**
|
use canvas::HighlightState;
|
||||||
- `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::CanvasTheme;
|
use canvas::CanvasTheme;
|
||||||
use ratatui::style::Color;
|
use canvas::ActionDispatcher;
|
||||||
|
use canvas::ActionResult;
|
||||||
|
|
||||||
impl CanvasTheme for Theme {
|
# NEW IMPORTS
|
||||||
fn primary_fg(&self) -> Color { self.fg }
|
use canvas::canvas::CanvasState;
|
||||||
fn primary_bg(&self) -> Color { self.bg }
|
use canvas::canvas::CanvasAction;
|
||||||
fn accent(&self) -> Color { self.accent }
|
use canvas::canvas::ActionContext;
|
||||||
fn warning(&self) -> Color { self.warning }
|
use canvas::canvas::HighlightState;
|
||||||
fn secondary(&self) -> Color { self.secondary }
|
use canvas::canvas::CanvasTheme;
|
||||||
fn highlight(&self) -> Color { self.highlight }
|
use canvas::dispatcher::ActionDispatcher;
|
||||||
fn highlight_bg(&self) -> Color { self.highlight_bg }
|
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)
|
**If you want rich autocomplete features:**
|
||||||
1. Update `client/Cargo.toml` to depend on canvas
|
|
||||||
2. Add theme implementation
|
|
||||||
3. Replace imports in core files
|
|
||||||
|
|
||||||
### Phase 2: Replace Feature-Specific Usage
|
```rust
|
||||||
1. Update auth components
|
use canvas::autocomplete::{AutocompleteCanvasState, SuggestionItem, AutocompleteState};
|
||||||
2. Update form components
|
|
||||||
3. Update admin components
|
|
||||||
4. Update mode handlers
|
|
||||||
|
|
||||||
### Phase 3: Remove Old Files (After Everything Works)
|
impl AutocompleteCanvasState for YourFormState {
|
||||||
1. Delete `src/state/pages/canvas_state.rs`
|
type SuggestionData = YourDataType; // e.g., Hit, CustomRecord, etc.
|
||||||
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`
|
|
||||||
|
|
||||||
## 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:**
|
fn autocomplete_state(&self) -> Option<&AutocompleteState<Self::SuggestionData>> {
|
||||||
- `client/src/state/pages/canvas_state.rs`
|
Some(&self.autocomplete)
|
||||||
- `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`
|
|
||||||
|
|
||||||
## Quick Start Commands
|
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> {
|
||||||
|
Some(&mut self.autocomplete)
|
||||||
```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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Expected Compilation Errors
|
**Add autocomplete field to your state:**
|
||||||
|
```rust
|
||||||
|
pub struct YourFormState {
|
||||||
|
// ... existing fields
|
||||||
|
pub autocomplete: AutocompleteState<YourDataType>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
You'll get errors like:
|
### Step 4: Migrate Suggestions
|
||||||
- `cannot find type 'CanvasState' in this scope`
|
|
||||||
- `cannot find function 'execute_edit_action' in this scope`
|
|
||||||
- `cannot find type 'AppMode' in this scope`
|
|
||||||
|
|
||||||
Fix these by replacing the imports as documented above.
|
**Old way (simple strings):**
|
||||||
|
```rust
|
||||||
|
let suggestions = vec!["John".to_string(), "Jane".to_string()];
|
||||||
|
form_state.activate_suggestions(suggestions);
|
||||||
|
```
|
||||||
|
|
||||||
|
**New way (rich objects):**
|
||||||
|
```rust
|
||||||
|
let suggestions = vec![
|
||||||
|
SuggestionItem::new(
|
||||||
|
hit1, // Your data object
|
||||||
|
"John Doe (Manager) | ID: 123".to_string(), // What user sees
|
||||||
|
"123".to_string(), // What gets stored
|
||||||
|
),
|
||||||
|
SuggestionItem::simple(hit2, "Jane".to_string()), // Simple version
|
||||||
|
];
|
||||||
|
form_state.set_autocomplete_suggestions(suggestions);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Update Rendering
|
||||||
|
|
||||||
|
**Old rendering:**
|
||||||
|
```rust
|
||||||
|
// Manual autocomplete rendering
|
||||||
|
if form_state.autocomplete_active {
|
||||||
|
render_autocomplete_dropdown(/* ... */);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**New rendering:**
|
||||||
|
```rust
|
||||||
|
// Canvas handles everything
|
||||||
|
use canvas::canvas::render_canvas;
|
||||||
|
|
||||||
|
let active_field_rect = render_canvas(f, area, form_state, theme, edit_mode, highlight_state);
|
||||||
|
|
||||||
|
// Optional: Rich autocomplete (if implementing AutocompleteCanvasState)
|
||||||
|
if form_state.is_autocomplete_active() {
|
||||||
|
if let Some(autocomplete_state) = form_state.autocomplete_state() {
|
||||||
|
canvas::autocomplete::render_autocomplete_dropdown(
|
||||||
|
f, f.area(), active_field_rect.unwrap(), theme, autocomplete_state
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Update Method Calls
|
||||||
|
|
||||||
|
**Replace legacy method calls:**
|
||||||
|
```rust
|
||||||
|
# OLD
|
||||||
|
form_state.deactivate_suggestions();
|
||||||
|
|
||||||
|
# NEW - Option A: Add your own method
|
||||||
|
impl YourFormState {
|
||||||
|
pub fn deactivate_autocomplete(&mut self) {
|
||||||
|
self.autocomplete_active = false;
|
||||||
|
self.autocomplete_suggestions.clear();
|
||||||
|
self.selected_suggestion_index = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
form_state.deactivate_autocomplete();
|
||||||
|
|
||||||
|
# NEW - Option B: Use rich autocomplete trait
|
||||||
|
form_state.deactivate_autocomplete(); // If implementing AutocompleteCanvasState
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits of New Architecture
|
||||||
|
|
||||||
|
### 1. **Clean Separation of Concerns**
|
||||||
|
- Canvas: Form rendering, navigation, input handling
|
||||||
|
- Autocomplete: Rich suggestions, dropdown management, async loading
|
||||||
|
|
||||||
|
### 2. **Type Safety**
|
||||||
|
```rust
|
||||||
|
// Old: Stringly typed
|
||||||
|
let suggestions: Vec<String> = vec!["user1".to_string()];
|
||||||
|
|
||||||
|
// New: Fully typed with your domain objects
|
||||||
|
let suggestions: Vec<SuggestionItem<UserRecord>> = vec![
|
||||||
|
SuggestionItem::new(user_record, display_text, stored_value)
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **Rich UX Capabilities**
|
||||||
|
- **Display vs Storage**: Show "John Doe (Manager)" but store user ID
|
||||||
|
- **Loading States**: Built-in spinner/loading indicators
|
||||||
|
- **Async Support**: Designed for async suggestion fetching
|
||||||
|
- **Display Overrides**: Show friendly text while storing normalized data
|
||||||
|
|
||||||
|
### 4. **Future-Proof**
|
||||||
|
- Easy to add new autocomplete features
|
||||||
|
- Canvas features don't interfere with autocomplete
|
||||||
|
- Modular: Use only what you need
|
||||||
|
|
||||||
|
## Advanced Features
|
||||||
|
|
||||||
|
### Display Overrides
|
||||||
|
Perfect for foreign key relationships:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// User selects "John Doe (Manager) | ID: 123"
|
||||||
|
// Field stores: "123" (for database)
|
||||||
|
// User sees: "John Doe" (friendly display)
|
||||||
|
|
||||||
|
impl CanvasState for FormState {
|
||||||
|
fn get_display_value_for_field(&self, index: usize) -> &str {
|
||||||
|
if let Some(display_text) = self.link_display_map.get(&index) {
|
||||||
|
return display_text.as_str(); // Shows "John Doe"
|
||||||
|
}
|
||||||
|
self.inputs().get(index).map(|s| s.as_str()).unwrap_or("") // Shows "123"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_display_override(&self, index: usize) -> bool {
|
||||||
|
self.link_display_map.contains_key(&index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Progressive Enhancement
|
||||||
|
Start simple, add features when needed:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Week 1: Basic usage
|
||||||
|
SuggestionItem::simple(data, "John".to_string());
|
||||||
|
|
||||||
|
// Week 5: Rich display
|
||||||
|
SuggestionItem::new(data, "John Doe (Manager)".to_string(), "John".to_string());
|
||||||
|
|
||||||
|
// Week 10: Store IDs, show names
|
||||||
|
SuggestionItem::new(user, "John Doe (Manager)".to_string(), "123".to_string());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Breaking Changes Summary
|
||||||
|
|
||||||
|
1. **Import paths changed**: Add `canvas::` or `dispatcher::` prefixes
|
||||||
|
2. **Legacy suggestion methods removed**: Replace with rich autocomplete or custom methods
|
||||||
|
3. **No more simple suggestions**: Use `SuggestionItem` for typed suggestions
|
||||||
|
4. **Trait split**: `AutocompleteCanvasState` is now separate and optional
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Compilation Errors
|
||||||
|
|
||||||
|
**Error**: `no method named 'get_suggestions' found`
|
||||||
|
**Fix**: Remove legacy method from `CanvasState` implementation
|
||||||
|
|
||||||
|
**Error**: `no 'CanvasState' in the root`
|
||||||
|
**Fix**: Change `use canvas::CanvasState` to `use canvas::canvas::CanvasState`
|
||||||
|
|
||||||
|
**Error**: `trait bound 'FormState: CanvasState' is not satisfied`
|
||||||
|
**Fix**: Make sure your state properly implements the new `CanvasState` trait
|
||||||
|
|
||||||
|
### Migration Checklist
|
||||||
|
|
||||||
|
- [ ] Updated all import paths
|
||||||
|
- [ ] Removed legacy methods from CanvasState implementation
|
||||||
|
- [ ] Added custom autocomplete methods if needed
|
||||||
|
- [ ] Updated suggestion usage to SuggestionItem
|
||||||
|
- [ ] Updated rendering calls
|
||||||
|
- [ ] Tested form functionality
|
||||||
|
- [ ] Tested autocomplete functionality (if using)
|
||||||
|
|
||||||
|
## Example: Complete Migration
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```rust
|
||||||
|
use canvas::{CanvasState, CanvasAction};
|
||||||
|
|
||||||
|
impl CanvasState for FormState {
|
||||||
|
fn get_suggestions(&self) -> Option<&[String]> { /* ... */ }
|
||||||
|
fn deactivate_suggestions(&mut self) { /* ... */ }
|
||||||
|
// ... other methods
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```rust
|
||||||
|
use canvas::canvas::{CanvasState, CanvasAction};
|
||||||
|
use canvas::autocomplete::{AutocompleteCanvasState, SuggestionItem};
|
||||||
|
|
||||||
|
impl CanvasState for FormState {
|
||||||
|
// Only core canvas methods, no suggestion methods
|
||||||
|
fn current_field(&self) -> usize { /* ... */ }
|
||||||
|
fn get_current_input(&self) -> &str { /* ... */ }
|
||||||
|
// ... other core methods only
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AutocompleteCanvasState for FormState {
|
||||||
|
type SuggestionData = Hit;
|
||||||
|
|
||||||
|
fn supports_autocomplete(&self, field_index: usize) -> bool {
|
||||||
|
self.fields[field_index].is_link
|
||||||
|
}
|
||||||
|
|
||||||
|
fn autocomplete_state(&self) -> Option<&AutocompleteState<Self::SuggestionData>> {
|
||||||
|
Some(&self.autocomplete)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> {
|
||||||
|
Some(&mut self.autocomplete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This migration results in cleaner, more maintainable, and more powerful code!
|
||||||
|
|||||||
@@ -11,28 +11,33 @@ categories.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
ratatui = { workspace = true }
|
ratatui = { workspace = true, optional = true }
|
||||||
crossterm = { workspace = true }
|
crossterm = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true, optional = true }
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
unicode-width.workspace = true
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
|
||||||
|
tracing = "0.1.41"
|
||||||
|
tracing-subscriber = "0.3.19"
|
||||||
|
async-trait = { workspace = true, optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio-test = "0.4.4"
|
tokio-test = "0.4.4"
|
||||||
|
|
||||||
[[example]]
|
[features]
|
||||||
name = "simple_login"
|
default = []
|
||||||
path = "examples/simple_login.rs"
|
gui = ["ratatui"]
|
||||||
|
autocomplete = ["tokio", "async-trait"]
|
||||||
|
|
||||||
[[example]]
|
[[example]]
|
||||||
name = "config_screen"
|
name = "autocomplete"
|
||||||
path = "examples/config_screen.rs"
|
required-features = ["autocomplete", "gui"]
|
||||||
|
path = "examples/autocomplete.rs"
|
||||||
|
|
||||||
[[example]]
|
[[example]]
|
||||||
name = "basic_usage"
|
name = "canvas_gui_demo"
|
||||||
path = "examples/basic_usage.rs"
|
required-features = ["gui"]
|
||||||
|
path = "examples/canvas_gui_demo.rs"
|
||||||
[[example]]
|
|
||||||
name = "integration_patterns"
|
|
||||||
path = "examples/integration_patterns.rs"
|
|
||||||
|
|||||||
@@ -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
|
## 🎯 Type-Safe Actions
|
||||||
|
|
||||||
The Canvas system uses strongly-typed actions instead of error-prone strings:
|
The Canvas system uses strongly-typed actions instead of error-prone strings:
|
||||||
@@ -199,7 +170,7 @@ impl CanvasState for MyForm {
|
|||||||
CanvasAction::SelectSuggestion => {
|
CanvasAction::SelectSuggestion => {
|
||||||
if let Some(suggestion) = self.suggestions.get_selected() {
|
if let Some(suggestion) = self.suggestions.get_selected() {
|
||||||
*self.get_current_input_mut() = suggestion.clone();
|
*self.get_current_input_mut() = suggestion.clone();
|
||||||
self.deactivate_suggestions();
|
self.deactivate_autocomplete();
|
||||||
Some("Applied suggestion".to_string())
|
Some("Applied suggestion".to_string())
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
# canvas_config.toml - Complete Canvas Configuration
|
|
||||||
|
|
||||||
[behavior]
|
|
||||||
wrap_around_fields = true
|
|
||||||
auto_save_on_field_change = false
|
|
||||||
word_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"
|
|
||||||
max_suggestions = 6
|
|
||||||
|
|
||||||
[appearance]
|
|
||||||
cursor_style = "block" # "block", "bar", "underline"
|
|
||||||
show_field_numbers = false
|
|
||||||
highlight_current_field = true
|
|
||||||
|
|
||||||
# Read-only mode keybindings (vim-style)
|
|
||||||
[keybindings.read_only]
|
|
||||||
move_left = ["h"]
|
|
||||||
move_right = ["l"]
|
|
||||||
move_up = ["k"]
|
|
||||||
move_down = ["p"]
|
|
||||||
move_word_next = ["w"]
|
|
||||||
move_word_end = ["e"]
|
|
||||||
move_word_prev = ["b"]
|
|
||||||
move_word_end_prev = ["ge"]
|
|
||||||
move_line_start = ["0"]
|
|
||||||
move_line_end = ["$"]
|
|
||||||
move_first_line = ["gg"]
|
|
||||||
move_last_line = ["G"]
|
|
||||||
next_field = ["Tab"]
|
|
||||||
prev_field = ["Shift+Tab"]
|
|
||||||
|
|
||||||
# Edit mode keybindings
|
|
||||||
[keybindings.edit]
|
|
||||||
delete_char_backward = ["Backspace"]
|
|
||||||
delete_char_forward = ["Delete"]
|
|
||||||
move_left = ["Left"]
|
|
||||||
move_right = ["Right"]
|
|
||||||
move_up = ["Up"]
|
|
||||||
move_down = ["Down"]
|
|
||||||
move_line_start = ["Home"]
|
|
||||||
move_line_end = ["End"]
|
|
||||||
move_word_next = ["Ctrl+Right"]
|
|
||||||
move_word_prev = ["Ctrl+Left"]
|
|
||||||
next_field = ["Tab"]
|
|
||||||
prev_field = ["Shift+Tab"]
|
|
||||||
|
|
||||||
# Suggestion/autocomplete keybindings
|
|
||||||
[keybindings.suggestions]
|
|
||||||
suggestion_up = ["Up", "Ctrl+p"]
|
|
||||||
suggestion_down = ["Down", "Ctrl+n"]
|
|
||||||
select_suggestion = ["Enter", "Tab"]
|
|
||||||
exit_suggestions = ["Esc"]
|
|
||||||
|
|
||||||
# Global keybindings (work in both modes)
|
|
||||||
[keybindings.global]
|
|
||||||
move_up = ["Up"]
|
|
||||||
move_down = ["Down"]
|
|
||||||
77
canvas/docs/new_function_to_config.txt
Normal file
77
canvas/docs/new_function_to_config.txt
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
❯ git status
|
||||||
|
On branch main
|
||||||
|
Your branch is ahead of 'origin/main' by 1 commit.
|
||||||
|
(use "git push" to publish your local commits)
|
||||||
|
|
||||||
|
Changes not staged for commit:
|
||||||
|
(use "git add <file>..." to update what will be committed)
|
||||||
|
(use "git restore <file>..." to discard changes in working directory)
|
||||||
|
modified: src/canvas/actions/handlers/edit.rs
|
||||||
|
modified: src/canvas/actions/types.rs
|
||||||
|
|
||||||
|
no changes added to commit (use "git add" and/or "git commit -a")
|
||||||
|
❯ git --no-pager diff
|
||||||
|
diff --git a/canvas/src/canvas/actions/handlers/edit.rs b/canvas/src/canvas/actions/handlers/edit.rs
|
||||||
|
index a26fe6f..fa1becb 100644
|
||||||
|
--- a/canvas/src/canvas/actions/handlers/edit.rs
|
||||||
|
+++ b/canvas/src/canvas/actions/handlers/edit.rs
|
||||||
|
@@ -29,6 +29,21 @@ pub async fn handle_edit_action<S: CanvasState>(
|
||||||
|
Ok(ActionResult::success())
|
||||||
|
}
|
||||||
|
|
||||||
|
+ CanvasAction::SelectAll => {
|
||||||
|
+ // Select all text in current field
|
||||||
|
+ let current_input = state.get_current_input();
|
||||||
|
+ let text_length = current_input.len();
|
||||||
|
+
|
||||||
|
+ // Set cursor to start and select all
|
||||||
|
+ state.set_current_cursor_pos(0);
|
||||||
|
+ // TODO: You'd need to add selection state to CanvasState trait
|
||||||
|
+ // For now, just move cursor to end to "select" all
|
||||||
|
+ state.set_current_cursor_pos(text_length);
|
||||||
|
+ *ideal_cursor_column = text_length;
|
||||||
|
+
|
||||||
|
+ Ok(ActionResult::success_with_message(&format!("Selected all {} characters", text_length)))
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
CanvasAction::DeleteBackward => {
|
||||||
|
let cursor_pos = state.current_cursor_pos();
|
||||||
|
if cursor_pos > 0 {
|
||||||
|
@@ -323,6 +338,13 @@ impl ActionHandlerIntrospection for EditHandler {
|
||||||
|
is_required: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
+ actions.push(ActionSpec {
|
||||||
|
+ name: "select_all".to_string(),
|
||||||
|
+ description: "Select all text in current field".to_string(),
|
||||||
|
+ examples: vec!["Ctrl+a".to_string()],
|
||||||
|
+ is_required: false, // Optional action
|
||||||
|
+ });
|
||||||
|
+
|
||||||
|
HandlerCapabilities {
|
||||||
|
mode_name: "edit".to_string(),
|
||||||
|
actions,
|
||||||
|
diff --git a/canvas/src/canvas/actions/types.rs b/canvas/src/canvas/actions/types.rs
|
||||||
|
index 433a4d5..3794596 100644
|
||||||
|
--- a/canvas/src/canvas/actions/types.rs
|
||||||
|
+++ b/canvas/src/canvas/actions/types.rs
|
||||||
|
@@ -31,6 +31,8 @@ pub enum CanvasAction {
|
||||||
|
NextField,
|
||||||
|
PrevField,
|
||||||
|
|
||||||
|
+ SelectAll,
|
||||||
|
+
|
||||||
|
// Autocomplete actions
|
||||||
|
TriggerAutocomplete,
|
||||||
|
SuggestionUp,
|
||||||
|
@@ -62,6 +64,7 @@ impl CanvasAction {
|
||||||
|
"move_word_end_prev" => Self::MoveWordEndPrev,
|
||||||
|
"next_field" => Self::NextField,
|
||||||
|
"prev_field" => Self::PrevField,
|
||||||
|
+ "select_all" => Self::SelectAll,
|
||||||
|
"trigger_autocomplete" => Self::TriggerAutocomplete,
|
||||||
|
"suggestion_up" => Self::SuggestionUp,
|
||||||
|
"suggestion_down" => Self::SuggestionDown,
|
||||||
|
╭─ ~/Doc/p/komp_ac/canvas on main ⇡1 !2
|
||||||
|
╰─
|
||||||
|
|
||||||
417
canvas/examples/autocomplete.rs
Normal file
417
canvas/examples/autocomplete.rs
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
// examples/autocomplete.rs
|
||||||
|
// Run with: cargo run --example autocomplete --features "autocomplete,gui"
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
use crossterm::{
|
||||||
|
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
|
||||||
|
execute,
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
};
|
||||||
|
use ratatui::{
|
||||||
|
backend::{Backend, CrosstermBackend},
|
||||||
|
layout::{Constraint, Direction, Layout},
|
||||||
|
style::Color,
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
Frame, Terminal,
|
||||||
|
};
|
||||||
|
|
||||||
|
use canvas::{
|
||||||
|
canvas::{
|
||||||
|
gui::render_canvas,
|
||||||
|
modes::AppMode,
|
||||||
|
state::{ActionContext, CanvasState},
|
||||||
|
theme::CanvasTheme,
|
||||||
|
},
|
||||||
|
autocomplete::{
|
||||||
|
AutocompleteCanvasState,
|
||||||
|
AutocompleteState,
|
||||||
|
SuggestionItem,
|
||||||
|
execute_with_autocomplete,
|
||||||
|
handle_autocomplete_feature_action,
|
||||||
|
},
|
||||||
|
CanvasAction,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add the async_trait import
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
// Simple theme implementation
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct DemoTheme;
|
||||||
|
|
||||||
|
impl CanvasTheme for DemoTheme {
|
||||||
|
fn bg(&self) -> Color { Color::Reset }
|
||||||
|
fn fg(&self) -> Color { Color::White }
|
||||||
|
fn accent(&self) -> Color { Color::Cyan }
|
||||||
|
fn secondary(&self) -> Color { Color::Gray }
|
||||||
|
fn highlight(&self) -> Color { Color::Yellow }
|
||||||
|
fn highlight_bg(&self) -> Color { Color::DarkGray }
|
||||||
|
fn warning(&self) -> Color { Color::Red }
|
||||||
|
fn border(&self) -> Color { Color::Gray }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom suggestion data type
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct EmailSuggestion {
|
||||||
|
email: String,
|
||||||
|
provider: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demo form state with autocomplete
|
||||||
|
struct AutocompleteFormState {
|
||||||
|
fields: Vec<String>,
|
||||||
|
field_names: Vec<String>,
|
||||||
|
current_field: usize,
|
||||||
|
cursor_pos: usize,
|
||||||
|
mode: AppMode,
|
||||||
|
has_changes: bool,
|
||||||
|
debug_message: String,
|
||||||
|
|
||||||
|
// Autocomplete state
|
||||||
|
autocomplete: AutocompleteState<EmailSuggestion>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AutocompleteFormState {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
fields: vec![
|
||||||
|
"John Doe".to_string(),
|
||||||
|
"john@".to_string(), // Partial email to demonstrate autocomplete
|
||||||
|
"+1 234 567 8900".to_string(),
|
||||||
|
"San Francisco".to_string(),
|
||||||
|
],
|
||||||
|
field_names: vec![
|
||||||
|
"Name".to_string(),
|
||||||
|
"Email".to_string(),
|
||||||
|
"Phone".to_string(),
|
||||||
|
"City".to_string(),
|
||||||
|
],
|
||||||
|
current_field: 1, // Start on email field
|
||||||
|
cursor_pos: 5, // Position after "john@"
|
||||||
|
mode: AppMode::Edit,
|
||||||
|
has_changes: false,
|
||||||
|
debug_message: "Type in email field, Tab to trigger autocomplete, Enter to select, Esc to cancel".to_string(),
|
||||||
|
autocomplete: AutocompleteState::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CanvasState for AutocompleteFormState {
|
||||||
|
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(self.fields.len().saturating_sub(1));
|
||||||
|
// Clear autocomplete when changing fields
|
||||||
|
if self.is_autocomplete_active() {
|
||||||
|
self.clear_autocomplete_suggestions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||||
|
let max_pos = if self.mode == AppMode::Edit {
|
||||||
|
self.fields[self.current_field].len()
|
||||||
|
} else {
|
||||||
|
self.fields[self.current_field].len().saturating_sub(1)
|
||||||
|
};
|
||||||
|
self.cursor_pos = pos.min(max_pos);
|
||||||
|
}
|
||||||
|
fn current_mode(&self) -> AppMode { self.mode }
|
||||||
|
fn get_current_input(&self) -> &str { &self.fields[self.current_field] }
|
||||||
|
fn get_current_input_mut(&mut self) -> &mut String { &mut self.fields[self.current_field] }
|
||||||
|
fn inputs(&self) -> Vec<&String> { self.fields.iter().collect() }
|
||||||
|
fn fields(&self) -> Vec<&str> { self.field_names.iter().map(|s| s.as_str()).collect() }
|
||||||
|
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> {
|
||||||
|
// Handle autocomplete actions first
|
||||||
|
if let Some(result) = handle_autocomplete_feature_action(action, self) {
|
||||||
|
return Some(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle other custom actions
|
||||||
|
match action {
|
||||||
|
CanvasAction::Custom(cmd) => {
|
||||||
|
match cmd.as_str() {
|
||||||
|
"toggle_mode" => {
|
||||||
|
self.mode = match self.mode {
|
||||||
|
AppMode::Edit => AppMode::ReadOnly,
|
||||||
|
AppMode::ReadOnly => AppMode::Edit,
|
||||||
|
_ => AppMode::Edit,
|
||||||
|
};
|
||||||
|
Some(format!("Switched to {:?} mode", self.mode))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the #[async_trait] attribute to the implementation
|
||||||
|
#[async_trait]
|
||||||
|
impl AutocompleteCanvasState for AutocompleteFormState {
|
||||||
|
type SuggestionData = EmailSuggestion;
|
||||||
|
|
||||||
|
fn supports_autocomplete(&self, field_index: usize) -> bool {
|
||||||
|
// Only enable autocomplete for email field (index 1)
|
||||||
|
field_index == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
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 should_trigger_autocomplete(&self) -> bool {
|
||||||
|
let current_input = self.get_current_input();
|
||||||
|
let current_field = self.current_field();
|
||||||
|
|
||||||
|
// Trigger for email field when we have "@" and at least 1 more character
|
||||||
|
self.supports_autocomplete(current_field) &&
|
||||||
|
current_input.contains('@') &&
|
||||||
|
current_input.len() > current_input.find('@').unwrap_or(0) + 1 &&
|
||||||
|
!self.is_autocomplete_active()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This is where the magic happens - user implements their own async fetching
|
||||||
|
async fn trigger_autocomplete_suggestions(&mut self) {
|
||||||
|
// 1. Activate UI (shows loading spinner)
|
||||||
|
self.activate_autocomplete();
|
||||||
|
self.set_autocomplete_loading(true);
|
||||||
|
|
||||||
|
// 2. Get current input for querying
|
||||||
|
let query = self.get_current_input().to_string();
|
||||||
|
|
||||||
|
// 3. Extract domain part from email
|
||||||
|
let domain_part = if let Some(at_pos) = query.find('@') {
|
||||||
|
query[at_pos + 1..].to_string()
|
||||||
|
} else {
|
||||||
|
self.set_autocomplete_loading(false);
|
||||||
|
return; // No @ symbol, can't suggest
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. SIMULATE ASYNC API CALL (in real code, this would be HTTP request)
|
||||||
|
let email_prefix = query[..query.find('@').unwrap()].to_string();
|
||||||
|
let suggestions = tokio::task::spawn_blocking(move || {
|
||||||
|
// Simulate network delay
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
|
||||||
|
// Create mock suggestions based on domain input
|
||||||
|
let popular_domains = vec![
|
||||||
|
("gmail.com", "Gmail"),
|
||||||
|
("yahoo.com", "Yahoo Mail"),
|
||||||
|
("outlook.com", "Outlook"),
|
||||||
|
("hotmail.com", "Hotmail"),
|
||||||
|
("company.com", "Company Email"),
|
||||||
|
("university.edu", "University"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
for (domain, provider) in popular_domains {
|
||||||
|
if domain.starts_with(&domain_part) || domain_part.is_empty() {
|
||||||
|
let full_email = format!("{}@{}", email_prefix, domain);
|
||||||
|
results.push(SuggestionItem::new(
|
||||||
|
EmailSuggestion {
|
||||||
|
email: full_email.clone(),
|
||||||
|
provider: provider.to_string(),
|
||||||
|
},
|
||||||
|
format!("{} ({})", full_email, provider), // display text
|
||||||
|
full_email, // value to store
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results
|
||||||
|
}).await.unwrap_or_default();
|
||||||
|
|
||||||
|
// 5. Provide suggestions back to library
|
||||||
|
self.set_autocomplete_suggestions(suggestions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut AutocompleteFormState) -> bool {
|
||||||
|
if key == KeyCode::F(10) || (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) {
|
||||||
|
return false; // Quit
|
||||||
|
}
|
||||||
|
|
||||||
|
let action = match key {
|
||||||
|
// === AUTOCOMPLETE KEYS ===
|
||||||
|
KeyCode::Tab => {
|
||||||
|
if state.is_autocomplete_active() {
|
||||||
|
Some(CanvasAction::SuggestionDown) // Navigate suggestions
|
||||||
|
} else if state.supports_autocomplete(state.current_field()) {
|
||||||
|
Some(CanvasAction::TriggerAutocomplete) // Manual trigger
|
||||||
|
} else {
|
||||||
|
Some(CanvasAction::NextField) // Normal tab
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCode::BackTab => {
|
||||||
|
if state.is_autocomplete_active() {
|
||||||
|
Some(CanvasAction::SuggestionUp)
|
||||||
|
} else {
|
||||||
|
Some(CanvasAction::PrevField)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if state.is_autocomplete_active() {
|
||||||
|
Some(CanvasAction::SelectSuggestion) // Apply suggestion
|
||||||
|
} else {
|
||||||
|
Some(CanvasAction::NextField)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCode::Esc => {
|
||||||
|
if state.is_autocomplete_active() {
|
||||||
|
Some(CanvasAction::ExitSuggestions) // Close autocomplete
|
||||||
|
} else {
|
||||||
|
Some(CanvasAction::Custom("toggle_mode".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === STANDARD CANVAS KEYS ===
|
||||||
|
KeyCode::Left => Some(CanvasAction::MoveLeft),
|
||||||
|
KeyCode::Right => Some(CanvasAction::MoveRight),
|
||||||
|
KeyCode::Up => Some(CanvasAction::MoveUp),
|
||||||
|
KeyCode::Down => Some(CanvasAction::MoveDown),
|
||||||
|
KeyCode::Home => Some(CanvasAction::MoveLineStart),
|
||||||
|
KeyCode::End => Some(CanvasAction::MoveLineEnd),
|
||||||
|
KeyCode::Backspace => Some(CanvasAction::DeleteBackward),
|
||||||
|
KeyCode::Delete => Some(CanvasAction::DeleteForward),
|
||||||
|
|
||||||
|
// Character input
|
||||||
|
KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
Some(CanvasAction::InsertChar(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(action) = action {
|
||||||
|
match execute_with_autocomplete(action.clone(), state).await {
|
||||||
|
Ok(result) => {
|
||||||
|
if let Some(msg) = result.message() {
|
||||||
|
state.debug_message = msg.to_string();
|
||||||
|
} else {
|
||||||
|
state.debug_message = format!("Executed: {:?}", action);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
state.debug_message = format!("Error: {}", e);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.debug_message = format!("Unhandled key: {:?}", key);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut state: AutocompleteFormState) -> io::Result<()> {
|
||||||
|
let theme = DemoTheme;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
terminal.draw(|f| ui(f, &state, &theme))?;
|
||||||
|
|
||||||
|
if let Event::Key(key) = event::read()? {
|
||||||
|
let should_continue = handle_key_press(key.code, key.modifiers, &mut state).await;
|
||||||
|
if !should_continue {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ui(f: &mut Frame, state: &AutocompleteFormState, theme: &DemoTheme) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Min(8),
|
||||||
|
Constraint::Length(5),
|
||||||
|
])
|
||||||
|
.split(f.area());
|
||||||
|
|
||||||
|
// Render the canvas form
|
||||||
|
let active_field_rect = render_canvas(
|
||||||
|
f,
|
||||||
|
chunks[0],
|
||||||
|
state,
|
||||||
|
theme,
|
||||||
|
state.mode == AppMode::Edit,
|
||||||
|
&canvas::HighlightState::Off,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render autocomplete dropdown on top if active
|
||||||
|
if let Some(input_rect) = active_field_rect {
|
||||||
|
canvas::render_autocomplete_dropdown(
|
||||||
|
f,
|
||||||
|
chunks[0],
|
||||||
|
input_rect,
|
||||||
|
theme,
|
||||||
|
&state.autocomplete,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status info
|
||||||
|
let autocomplete_status = if state.is_autocomplete_active() {
|
||||||
|
if state.autocomplete.is_loading {
|
||||||
|
"Loading suggestions..."
|
||||||
|
} else if state.has_autocomplete_suggestions() {
|
||||||
|
"Use Tab/Shift+Tab to navigate, Enter to select, Esc to cancel"
|
||||||
|
} else {
|
||||||
|
"No suggestions found"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"Tab to trigger autocomplete"
|
||||||
|
};
|
||||||
|
|
||||||
|
let status_lines = vec![
|
||||||
|
Line::from(Span::raw(format!("Mode: {:?} | Field: {}/{} | Cursor: {}",
|
||||||
|
state.mode, state.current_field + 1, state.fields.len(), state.cursor_pos))),
|
||||||
|
Line::from(Span::raw(format!("Autocomplete: {}", autocomplete_status))),
|
||||||
|
Line::from(Span::raw(state.debug_message.clone())),
|
||||||
|
Line::from(Span::raw("F10: Quit | Tab: Trigger/Navigate autocomplete | Enter: Select | Esc: Cancel/Toggle mode")),
|
||||||
|
];
|
||||||
|
|
||||||
|
let status = Paragraph::new(status_lines)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Status & Help"));
|
||||||
|
|
||||||
|
f.render_widget(status, chunks[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
let state = AutocompleteFormState::new();
|
||||||
|
|
||||||
|
let res = run_app(&mut terminal, state).await;
|
||||||
|
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(
|
||||||
|
terminal.backend_mut(),
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
DisableMouseCapture
|
||||||
|
)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
|
if let Err(err) = res {
|
||||||
|
println!("{:?}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
388
canvas/examples/canvas_gui_demo.rs
Normal file
388
canvas/examples/canvas_gui_demo.rs
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
// examples/canvas_gui_demo.rs
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
use crossterm::{
|
||||||
|
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
|
||||||
|
execute,
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
};
|
||||||
|
use ratatui::{
|
||||||
|
backend::{Backend, CrosstermBackend},
|
||||||
|
layout::{Constraint, Direction, Layout},
|
||||||
|
style::{Color, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
Frame, Terminal,
|
||||||
|
};
|
||||||
|
|
||||||
|
use canvas::{
|
||||||
|
canvas::{
|
||||||
|
gui::render_canvas,
|
||||||
|
modes::{AppMode, HighlightState, ModeManager},
|
||||||
|
state::{ActionContext, CanvasState},
|
||||||
|
theme::CanvasTheme,
|
||||||
|
},
|
||||||
|
CanvasAction, execute,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simple theme implementation
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct DemoTheme;
|
||||||
|
|
||||||
|
impl CanvasTheme for DemoTheme {
|
||||||
|
fn bg(&self) -> Color { Color::Reset }
|
||||||
|
fn fg(&self) -> Color { Color::White }
|
||||||
|
fn accent(&self) -> Color { Color::Cyan }
|
||||||
|
fn secondary(&self) -> Color { Color::Gray }
|
||||||
|
fn highlight(&self) -> Color { Color::Yellow }
|
||||||
|
fn highlight_bg(&self) -> Color { Color::DarkGray }
|
||||||
|
fn warning(&self) -> Color { Color::Red }
|
||||||
|
fn border(&self) -> Color { Color::Gray }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demo form state
|
||||||
|
struct DemoFormState {
|
||||||
|
fields: Vec<String>,
|
||||||
|
field_names: Vec<String>,
|
||||||
|
current_field: usize,
|
||||||
|
cursor_pos: usize,
|
||||||
|
mode: AppMode,
|
||||||
|
highlight_state: HighlightState,
|
||||||
|
has_changes: bool,
|
||||||
|
debug_message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DemoFormState {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
fields: vec![
|
||||||
|
"John Doe".to_string(),
|
||||||
|
"john.doe@example.com".to_string(),
|
||||||
|
"+1 234 567 8900".to_string(),
|
||||||
|
"123 Main Street Apt 4B".to_string(),
|
||||||
|
"San Francisco".to_string(),
|
||||||
|
"This is a test comment with multiple words".to_string(),
|
||||||
|
],
|
||||||
|
field_names: vec![
|
||||||
|
"Name".to_string(),
|
||||||
|
"Email".to_string(),
|
||||||
|
"Phone".to_string(),
|
||||||
|
"Address".to_string(),
|
||||||
|
"City".to_string(),
|
||||||
|
"Comments".to_string(),
|
||||||
|
],
|
||||||
|
current_field: 0,
|
||||||
|
cursor_pos: 0,
|
||||||
|
mode: AppMode::ReadOnly,
|
||||||
|
highlight_state: HighlightState::Off,
|
||||||
|
has_changes: false,
|
||||||
|
debug_message: "Ready - Use hjkl to move, w for next word, i to edit".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enter_edit_mode(&mut self) {
|
||||||
|
if ModeManager::can_enter_edit_mode(self.mode) {
|
||||||
|
self.mode = AppMode::Edit;
|
||||||
|
self.debug_message = "Entered EDIT mode".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enter_readonly_mode(&mut self) {
|
||||||
|
if ModeManager::can_enter_read_only_mode(self.mode) {
|
||||||
|
self.mode = AppMode::ReadOnly;
|
||||||
|
self.highlight_state = HighlightState::Off;
|
||||||
|
self.debug_message = "Entered READ-ONLY mode".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enter_highlight_mode(&mut self) {
|
||||||
|
if ModeManager::can_enter_highlight_mode(self.mode) {
|
||||||
|
self.mode = AppMode::Highlight;
|
||||||
|
self.highlight_state = HighlightState::Characterwise {
|
||||||
|
anchor: (self.current_field, self.cursor_pos),
|
||||||
|
};
|
||||||
|
self.debug_message = "Entered VISUAL mode".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CanvasState for DemoFormState {
|
||||||
|
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(self.fields.len().saturating_sub(1));
|
||||||
|
self.cursor_pos = self.fields[self.current_field].len();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||||
|
let max_pos = self.fields[self.current_field].len();
|
||||||
|
self.cursor_pos = pos.min(max_pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_mode(&self) -> AppMode {
|
||||||
|
self.mode
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_current_input(&self) -> &str {
|
||||||
|
&self.fields[self.current_field]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_current_input_mut(&mut self) -> &mut String {
|
||||||
|
&mut self.fields[self.current_field]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inputs(&self) -> Vec<&String> {
|
||||||
|
self.fields.iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fields(&self) -> Vec<&str> {
|
||||||
|
self.field_names.iter().map(|s| s.as_str()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
"enter_edit_mode" => {
|
||||||
|
self.enter_edit_mode();
|
||||||
|
Some("Entered edit mode".to_string())
|
||||||
|
}
|
||||||
|
"enter_readonly_mode" => {
|
||||||
|
self.enter_readonly_mode();
|
||||||
|
Some("Entered read-only mode".to_string())
|
||||||
|
}
|
||||||
|
"enter_highlight_mode" => {
|
||||||
|
self.enter_highlight_mode();
|
||||||
|
Some("Entered highlight mode".to_string())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple key mapping - users have full control!
|
||||||
|
async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut DemoFormState) -> bool {
|
||||||
|
let is_edit_mode = state.mode == AppMode::Edit;
|
||||||
|
|
||||||
|
// Handle quit first
|
||||||
|
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL)) ||
|
||||||
|
(key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) ||
|
||||||
|
key == KeyCode::F(10) {
|
||||||
|
return false; // Signal to quit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users directly map keys to actions - no configuration needed!
|
||||||
|
let action = match (state.mode, key, modifiers) {
|
||||||
|
// === READ-ONLY MODE KEYS ===
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('h'), _) => Some(CanvasAction::MoveLeft),
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('j'), _) => Some(CanvasAction::MoveDown),
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('k'), _) => Some(CanvasAction::MoveUp),
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('l'), _) => Some(CanvasAction::MoveRight),
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('w'), _) => Some(CanvasAction::MoveWordNext),
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('b'), _) => Some(CanvasAction::MoveWordPrev),
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('e'), _) => Some(CanvasAction::MoveWordEnd),
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('0'), _) => Some(CanvasAction::MoveLineStart),
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('$'), _) => Some(CanvasAction::MoveLineEnd),
|
||||||
|
(AppMode::ReadOnly, KeyCode::Tab, _) => Some(CanvasAction::NextField),
|
||||||
|
(AppMode::ReadOnly, KeyCode::BackTab, _) => Some(CanvasAction::PrevField),
|
||||||
|
|
||||||
|
// === EDIT MODE KEYS ===
|
||||||
|
(AppMode::Edit, KeyCode::Left, _) => Some(CanvasAction::MoveLeft),
|
||||||
|
(AppMode::Edit, KeyCode::Right, _) => Some(CanvasAction::MoveRight),
|
||||||
|
(AppMode::Edit, KeyCode::Up, _) => Some(CanvasAction::MoveUp),
|
||||||
|
(AppMode::Edit, KeyCode::Down, _) => Some(CanvasAction::MoveDown),
|
||||||
|
(AppMode::Edit, KeyCode::Home, _) => Some(CanvasAction::MoveLineStart),
|
||||||
|
(AppMode::Edit, KeyCode::End, _) => Some(CanvasAction::MoveLineEnd),
|
||||||
|
(AppMode::Edit, KeyCode::Backspace, _) => Some(CanvasAction::DeleteBackward),
|
||||||
|
(AppMode::Edit, KeyCode::Delete, _) => Some(CanvasAction::DeleteForward),
|
||||||
|
(AppMode::Edit, KeyCode::Tab, _) => Some(CanvasAction::NextField),
|
||||||
|
(AppMode::Edit, KeyCode::BackTab, _) => Some(CanvasAction::PrevField),
|
||||||
|
|
||||||
|
// Vim-style movement in edit mode (optional)
|
||||||
|
(AppMode::Edit, KeyCode::Char('h'), m) if m.contains(KeyModifiers::CONTROL) => Some(CanvasAction::MoveLeft),
|
||||||
|
(AppMode::Edit, KeyCode::Char('l'), m) if m.contains(KeyModifiers::CONTROL) => Some(CanvasAction::MoveRight),
|
||||||
|
|
||||||
|
// Word movement with Ctrl in edit mode
|
||||||
|
(AppMode::Edit, KeyCode::Left, m) if m.contains(KeyModifiers::CONTROL) => Some(CanvasAction::MoveWordPrev),
|
||||||
|
(AppMode::Edit, KeyCode::Right, m) if m.contains(KeyModifiers::CONTROL) => Some(CanvasAction::MoveWordNext),
|
||||||
|
|
||||||
|
// === MODE TRANSITIONS ===
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('i'), _) => Some(CanvasAction::Custom("enter_edit_mode".to_string())),
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
|
||||||
|
// 'a' moves to end of line then enters edit mode
|
||||||
|
if let Ok(_) = execute(CanvasAction::MoveLineEnd, state).await {
|
||||||
|
Some(CanvasAction::Custom("enter_edit_mode".to_string()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('v'), _) => Some(CanvasAction::Custom("enter_highlight_mode".to_string())),
|
||||||
|
(_, KeyCode::Esc, _) => Some(CanvasAction::Custom("enter_readonly_mode".to_string())),
|
||||||
|
|
||||||
|
// === CHARACTER INPUT IN EDIT MODE ===
|
||||||
|
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) => {
|
||||||
|
Some(CanvasAction::InsertChar(c))
|
||||||
|
},
|
||||||
|
|
||||||
|
// === ARROW KEYS IN READ-ONLY MODE ===
|
||||||
|
(AppMode::ReadOnly, KeyCode::Left, _) => Some(CanvasAction::MoveLeft),
|
||||||
|
(AppMode::ReadOnly, KeyCode::Right, _) => Some(CanvasAction::MoveRight),
|
||||||
|
(AppMode::ReadOnly, KeyCode::Up, _) => Some(CanvasAction::MoveUp),
|
||||||
|
(AppMode::ReadOnly, KeyCode::Down, _) => Some(CanvasAction::MoveDown),
|
||||||
|
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute the action if we found one
|
||||||
|
if let Some(action) = action {
|
||||||
|
match execute(action.clone(), state).await {
|
||||||
|
Ok(result) => {
|
||||||
|
if result.is_success() {
|
||||||
|
// Mark as changed for editing actions
|
||||||
|
if is_edit_mode {
|
||||||
|
match action {
|
||||||
|
CanvasAction::InsertChar(_) | CanvasAction::DeleteBackward | CanvasAction::DeleteForward => {
|
||||||
|
state.set_has_unsaved_changes(true);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(msg) = result.message() {
|
||||||
|
state.debug_message = msg.to_string();
|
||||||
|
} else {
|
||||||
|
state.debug_message = format!("Executed: {}", action.description());
|
||||||
|
}
|
||||||
|
} else if let Some(msg) = result.message() {
|
||||||
|
state.debug_message = format!("Error: {}", msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
state.debug_message = format!("Error executing action: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.debug_message = format!("Unhandled key: {:?} (mode: {:?})", key, state.mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
true // Continue running
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut state: DemoFormState) -> io::Result<()> {
|
||||||
|
let theme = DemoTheme;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
terminal.draw(|f| ui(f, &state, &theme))?;
|
||||||
|
|
||||||
|
if let Event::Key(key) = event::read()? {
|
||||||
|
let should_continue = handle_key_press(key.code, key.modifiers, &mut state).await;
|
||||||
|
if !should_continue {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ui(f: &mut Frame, state: &DemoFormState, theme: &DemoTheme) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Min(8),
|
||||||
|
Constraint::Length(4),
|
||||||
|
])
|
||||||
|
.split(f.area());
|
||||||
|
|
||||||
|
// Render the canvas form
|
||||||
|
render_canvas(
|
||||||
|
f,
|
||||||
|
chunks[0],
|
||||||
|
state,
|
||||||
|
theme,
|
||||||
|
state.mode == AppMode::Edit,
|
||||||
|
&state.highlight_state,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render status bar
|
||||||
|
let mode_text = match state.mode {
|
||||||
|
AppMode::Edit => "EDIT",
|
||||||
|
AppMode::ReadOnly => "NORMAL",
|
||||||
|
AppMode::Highlight => "VISUAL",
|
||||||
|
AppMode::General => "GENERAL",
|
||||||
|
AppMode::Command => "COMMAND",
|
||||||
|
};
|
||||||
|
|
||||||
|
let status_text = if state.has_changes {
|
||||||
|
format!("-- {} -- [Modified]", mode_text)
|
||||||
|
} else {
|
||||||
|
format!("-- {} --", mode_text)
|
||||||
|
};
|
||||||
|
|
||||||
|
let position_text = format!("Field: {}/{} | Cursor: {} | Actions: {}",
|
||||||
|
state.current_field + 1,
|
||||||
|
state.fields.len(),
|
||||||
|
state.cursor_pos,
|
||||||
|
CanvasAction::movement_actions().len() + CanvasAction::editing_actions().len());
|
||||||
|
|
||||||
|
let help_text = match state.mode {
|
||||||
|
AppMode::ReadOnly => "hjkl/arrows: Move | Tab/Shift+Tab: Fields | w/b/e: Words | 0/$: Line | i/a: Edit | v: Visual | F10: Quit",
|
||||||
|
AppMode::Edit => "Type to edit | Arrows/Ctrl+arrows: Move | Tab: Next field | Backspace/Delete: Delete | Home/End: Line | Esc: Normal | F10: Quit",
|
||||||
|
AppMode::Highlight => "hjkl/arrows: Select | w/b/e: Words | 0/$: Line | Esc: Normal | F10: Quit",
|
||||||
|
_ => "Esc: Normal | F10: Quit",
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = Paragraph::new(vec![
|
||||||
|
Line::from(Span::styled(status_text, Style::default().fg(theme.accent()))),
|
||||||
|
Line::from(Span::styled(position_text, Style::default().fg(theme.fg()))),
|
||||||
|
Line::from(Span::styled(state.debug_message.clone(), Style::default().fg(theme.warning()))),
|
||||||
|
Line::from(Span::styled(help_text, Style::default().fg(theme.secondary()))),
|
||||||
|
])
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Status"));
|
||||||
|
|
||||||
|
f.render_widget(status, chunks[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
let state = DemoFormState::new();
|
||||||
|
|
||||||
|
let res = run_app(&mut terminal, state).await;
|
||||||
|
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(
|
||||||
|
terminal.backend_mut(),
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
DisableMouseCapture
|
||||||
|
)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
|
if let Err(err) = res {
|
||||||
|
println!("{:?}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,590 +0,0 @@
|
|||||||
// examples/config_screen.rs
|
|
||||||
//! Advanced configuration screen with suggestions and validation
|
|
||||||
//!
|
|
||||||
//! This example demonstrates:
|
|
||||||
//! - Multiple field types
|
|
||||||
//! - Auto-suggestions
|
|
||||||
//! - Field validation
|
|
||||||
//! - Custom actions
|
|
||||||
//!
|
|
||||||
//! Run with: cargo run --example config_screen
|
|
||||||
|
|
||||||
use canvas::prelude::*;
|
|
||||||
use crossterm::{
|
|
||||||
event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
|
|
||||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
|
||||||
ExecutableCommand,
|
|
||||||
};
|
|
||||||
use std::io::{self, Write};
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct ConfigForm {
|
|
||||||
current_field: usize,
|
|
||||||
cursor_pos: usize,
|
|
||||||
|
|
||||||
// Configuration fields
|
|
||||||
server_host: String,
|
|
||||||
server_port: String,
|
|
||||||
database_url: String,
|
|
||||||
log_level: String,
|
|
||||||
max_connections: String,
|
|
||||||
|
|
||||||
has_changes: bool,
|
|
||||||
suggestions: SuggestionState,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ConfigForm {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
current_field: 0,
|
|
||||||
cursor_pos: 0,
|
|
||||||
server_host: "localhost".to_string(),
|
|
||||||
server_port: "8080".to_string(),
|
|
||||||
database_url: String::new(),
|
|
||||||
log_level: "info".to_string(),
|
|
||||||
max_connections: "100".to_string(),
|
|
||||||
has_changes: false,
|
|
||||||
suggestions: SuggestionState::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn field_names() -> Vec<&'static str> {
|
|
||||||
vec![
|
|
||||||
"Server Host",
|
|
||||||
"Server Port",
|
|
||||||
"Database URL",
|
|
||||||
"Log Level",
|
|
||||||
"Max Connections"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_field_value(&self, index: usize) -> &String {
|
|
||||||
match index {
|
|
||||||
0 => &self.server_host,
|
|
||||||
1 => &self.server_port,
|
|
||||||
2 => &self.database_url,
|
|
||||||
3 => &self.log_level,
|
|
||||||
4 => &self.max_connections,
|
|
||||||
_ => panic!("Invalid field index: {}", index),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_field_value_mut(&mut self, index: usize) -> &mut String {
|
|
||||||
match index {
|
|
||||||
0 => &mut self.server_host,
|
|
||||||
1 => &mut self.server_port,
|
|
||||||
2 => &mut self.database_url,
|
|
||||||
3 => &mut self.log_level,
|
|
||||||
4 => &mut self.max_connections,
|
|
||||||
_ => panic!("Invalid field index: {}", index),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_field(&self, index: usize) -> Option<String> {
|
|
||||||
let value = self.get_field_value(index);
|
|
||||||
match index {
|
|
||||||
0 => { // Server Host
|
|
||||||
if value.trim().is_empty() {
|
|
||||||
Some("Server host cannot be empty".to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1 => { // Server Port
|
|
||||||
if let Ok(port) = value.parse::<u16>() {
|
|
||||||
if port == 0 {
|
|
||||||
Some("Port must be greater than 0".to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Some("Port must be a valid number (1-65535)".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2 => { // Database URL
|
|
||||||
if !value.is_empty() && !value.starts_with("postgresql://") && !value.starts_with("mysql://") && !value.starts_with("sqlite://") {
|
|
||||||
Some("Database URL should start with postgresql://, mysql://, or sqlite://".to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
3 => { // Log Level
|
|
||||||
let valid_levels = ["trace", "debug", "info", "warn", "error"];
|
|
||||||
if !valid_levels.contains(&value.to_lowercase().as_str()) {
|
|
||||||
Some("Log level must be one of: trace, debug, info, warn, error".to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
4 => { // Max Connections
|
|
||||||
if let Ok(connections) = value.parse::<u32>() {
|
|
||||||
if connections == 0 {
|
|
||||||
Some("Max connections must be greater than 0".to_string())
|
|
||||||
} else if connections > 10000 {
|
|
||||||
Some("Max connections seems too high (>10000)".to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Some("Max connections must be a valid number".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_suggestions_for_field(&self, index: usize, current_value: &str) -> Vec<String> {
|
|
||||||
match index {
|
|
||||||
0 => { // Server Host
|
|
||||||
vec![
|
|
||||||
"localhost".to_string(),
|
|
||||||
"127.0.0.1".to_string(),
|
|
||||||
"0.0.0.0".to_string(),
|
|
||||||
format!("{}.local", current_value),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
1 => { // Server Port
|
|
||||||
vec![
|
|
||||||
"8080".to_string(),
|
|
||||||
"3000".to_string(),
|
|
||||||
"8000".to_string(),
|
|
||||||
"80".to_string(),
|
|
||||||
"443".to_string(),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
2 => { // Database URL
|
|
||||||
if current_value.is_empty() {
|
|
||||||
vec![
|
|
||||||
"postgresql://localhost:5432/mydb".to_string(),
|
|
||||||
"mysql://localhost:3306/mydb".to_string(),
|
|
||||||
"sqlite://./database.db".to_string(),
|
|
||||||
]
|
|
||||||
} else {
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
3 => { // Log Level
|
|
||||||
vec![
|
|
||||||
"trace".to_string(),
|
|
||||||
"debug".to_string(),
|
|
||||||
"info".to_string(),
|
|
||||||
"warn".to_string(),
|
|
||||||
"error".to_string(),
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.filter(|level| level.starts_with(¤t_value.to_lowercase()))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
4 => { // Max Connections
|
|
||||||
vec![
|
|
||||||
"10".to_string(),
|
|
||||||
"50".to_string(),
|
|
||||||
"100".to_string(),
|
|
||||||
"200".to_string(),
|
|
||||||
"500".to_string(),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
_ => vec![],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CanvasState for ConfigForm {
|
|
||||||
fn current_field(&self) -> usize {
|
|
||||||
self.current_field
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_cursor_pos(&self) -> usize {
|
|
||||||
self.cursor_pos
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_current_field(&mut self, index: usize) {
|
|
||||||
self.current_field = index.min(4); // 5 fields total (0-4)
|
|
||||||
// Deactivate suggestions when changing fields
|
|
||||||
self.deactivate_suggestions();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_current_cursor_pos(&mut self, pos: usize) {
|
|
||||||
self.cursor_pos = pos;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_current_input(&self) -> &str {
|
|
||||||
self.get_field_value(self.current_field)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_current_input_mut(&mut self) -> &mut String {
|
|
||||||
self.get_field_value_mut(self.current_field)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn inputs(&self) -> Vec<&String> {
|
|
||||||
vec![
|
|
||||||
&self.server_host,
|
|
||||||
&self.server_port,
|
|
||||||
&self.database_url,
|
|
||||||
&self.log_level,
|
|
||||||
&self.max_connections,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fields(&self) -> Vec<&str> {
|
|
||||||
Self::field_names()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn has_unsaved_changes(&self) -> bool {
|
|
||||||
self.has_changes
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_has_unsaved_changes(&mut self, changed: bool) {
|
|
||||||
self.has_changes = changed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Suggestion support
|
|
||||||
fn get_suggestions(&self) -> Option<&[String]> {
|
|
||||||
if self.suggestions.is_active {
|
|
||||||
Some(&self.suggestions.suggestions)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_selected_suggestion_index(&self) -> Option<usize> {
|
|
||||||
self.suggestions.selected_index
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_selected_suggestion_index(&mut self, index: Option<usize>) {
|
|
||||||
self.suggestions.selected_index = index;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn activate_suggestions(&mut self, suggestions: Vec<String>) {
|
|
||||||
self.suggestions.activate_with_suggestions(suggestions);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn deactivate_suggestions(&mut self) {
|
|
||||||
self.suggestions.deactivate();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
|
||||||
match action {
|
|
||||||
CanvasAction::SelectSuggestion => {
|
|
||||||
// Fix: Clone the suggestion first to avoid borrow checker issues
|
|
||||||
if let Some(suggestion) = self.suggestions.get_selected().cloned() {
|
|
||||||
*self.get_current_input_mut() = suggestion.clone();
|
|
||||||
self.set_current_cursor_pos(suggestion.len());
|
|
||||||
self.deactivate_suggestions();
|
|
||||||
self.set_has_unsaved_changes(true);
|
|
||||||
return Some("Applied suggestion".to_string());
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::Custom(cmd) => match cmd.as_str() {
|
|
||||||
"trigger_suggestions" => {
|
|
||||||
let current_value = self.get_current_input();
|
|
||||||
let suggestions = self.get_suggestions_for_field(self.current_field, current_value);
|
|
||||||
if !suggestions.is_empty() {
|
|
||||||
self.activate_suggestions(suggestions);
|
|
||||||
Some("Showing suggestions".to_string())
|
|
||||||
} else {
|
|
||||||
Some("No suggestions available".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"validate_current" => {
|
|
||||||
if let Some(error) = self.validate_field(self.current_field) {
|
|
||||||
Some(format!("Validation Error: {}", error))
|
|
||||||
} else {
|
|
||||||
Some("Field is valid".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"validate_all" => {
|
|
||||||
let mut errors = Vec::new();
|
|
||||||
for i in 0..5 {
|
|
||||||
if let Some(error) = self.validate_field(i) {
|
|
||||||
errors.push(format!("{}: {}", Self::field_names()[i], error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if errors.is_empty() {
|
|
||||||
Some("All fields are valid!".to_string())
|
|
||||||
} else {
|
|
||||||
Some(format!("Errors found: {}", errors.join("; ")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"save_config" => {
|
|
||||||
// Validate all fields first
|
|
||||||
for i in 0..5 {
|
|
||||||
if self.validate_field(i).is_some() {
|
|
||||||
return Some("Cannot save: Please fix validation errors first".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.set_has_unsaved_changes(false);
|
|
||||||
Some("Configuration saved successfully!".to_string())
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Auto-trigger suggestions for certain fields
|
|
||||||
CanvasAction::InsertChar(_) => {
|
|
||||||
// After character insertion, check if we should show suggestions
|
|
||||||
match self.current_field {
|
|
||||||
3 => { // Log level - always show suggestions for autocomplete
|
|
||||||
let current_value = self.get_current_input();
|
|
||||||
let suggestions = self.get_suggestions_for_field(self.current_field, current_value);
|
|
||||||
if !suggestions.is_empty() {
|
|
||||||
self.activate_suggestions(suggestions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
None // Let the generic handler insert the character
|
|
||||||
}
|
|
||||||
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_ui(form: &ConfigForm, message: &str) -> io::Result<()> {
|
|
||||||
print!("\x1B[2J\x1B[1;1H");
|
|
||||||
|
|
||||||
println!("╔════════════════════════════════════════════════════════════════╗");
|
|
||||||
println!("║ CONFIGURATION EDITOR ║");
|
|
||||||
println!("╠════════════════════════════════════════════════════════════════╣");
|
|
||||||
|
|
||||||
let field_names = ConfigForm::field_names();
|
|
||||||
|
|
||||||
for (i, field_name) in field_names.iter().enumerate() {
|
|
||||||
let is_current = i == form.current_field;
|
|
||||||
let indicator = if is_current { ">" } else { " " };
|
|
||||||
let value = form.get_field_value(i);
|
|
||||||
let display_value = if value.is_empty() {
|
|
||||||
format!("<enter {}>", field_name.to_lowercase())
|
|
||||||
} else {
|
|
||||||
value.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Truncate long values for display
|
|
||||||
let display_value = if display_value.len() > 35 {
|
|
||||||
format!("{}...", &display_value[..32])
|
|
||||||
} else {
|
|
||||||
display_value
|
|
||||||
};
|
|
||||||
|
|
||||||
println!("║ {} {:15}: {:35} ║", indicator, field_name, display_value);
|
|
||||||
|
|
||||||
// Show cursor for current field
|
|
||||||
if is_current {
|
|
||||||
let cursor_pos = form.cursor_pos.min(value.len());
|
|
||||||
let cursor_line = format!("║ {}{}║",
|
|
||||||
" ".repeat(18 + cursor_pos),
|
|
||||||
"▊"
|
|
||||||
);
|
|
||||||
println!("{:66}", cursor_line);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show validation error if any
|
|
||||||
if let Some(error) = form.validate_field(i) {
|
|
||||||
let error_display = if error.len() > 58 {
|
|
||||||
format!("{}...", &error[..55])
|
|
||||||
} else {
|
|
||||||
error
|
|
||||||
};
|
|
||||||
println!("║ ⚠️ {:58} ║", error_display);
|
|
||||||
} else if is_current {
|
|
||||||
println!("║{:64}║", "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("╠════════════════════════════════════════════════════════════════╣");
|
|
||||||
|
|
||||||
// Show suggestions if active
|
|
||||||
if let Some(suggestions) = form.get_suggestions() {
|
|
||||||
println!("║ SUGGESTIONS: ║");
|
|
||||||
for (i, suggestion) in suggestions.iter().enumerate() {
|
|
||||||
let selected = form.get_selected_suggestion_index() == Some(i);
|
|
||||||
let marker = if selected { "→" } else { " " };
|
|
||||||
let display_suggestion = if suggestion.len() > 55 {
|
|
||||||
format!("{}...", &suggestion[..52])
|
|
||||||
} else {
|
|
||||||
suggestion.clone()
|
|
||||||
};
|
|
||||||
println!("║ {} {:58} ║", marker, display_suggestion);
|
|
||||||
}
|
|
||||||
println!("╠════════════════════════════════════════════════════════════════╣");
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("║ CONTROLS: ║");
|
|
||||||
println!("║ Tab/↑↓ - Navigate fields ║");
|
|
||||||
println!("║ Ctrl+Space - Show suggestions ║");
|
|
||||||
println!("║ ↑↓ - Navigate suggestions (when shown) ║");
|
|
||||||
println!("║ Enter - Select suggestion / Validate field ║");
|
|
||||||
println!("║ Ctrl+S - Save configuration ║");
|
|
||||||
println!("║ Ctrl+V - Validate all fields ║");
|
|
||||||
println!("║ Ctrl+C - Exit ║");
|
|
||||||
println!("╠════════════════════════════════════════════════════════════════╣");
|
|
||||||
|
|
||||||
// Status
|
|
||||||
let status = if !message.is_empty() {
|
|
||||||
message.to_string()
|
|
||||||
} else if form.has_changes {
|
|
||||||
"Configuration modified - press Ctrl+S to save".to_string()
|
|
||||||
} else {
|
|
||||||
"Ready".to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
let status_display = if status.len() > 58 {
|
|
||||||
format!("{}...", &status[..55])
|
|
||||||
} else {
|
|
||||||
status
|
|
||||||
};
|
|
||||||
|
|
||||||
println!("║ Status: {:55} ║", status_display);
|
|
||||||
println!("╚════════════════════════════════════════════════════════════════╝");
|
|
||||||
|
|
||||||
io::stdout().flush()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> io::Result<()> {
|
|
||||||
enable_raw_mode()?;
|
|
||||||
io::stdout().execute(EnterAlternateScreen)?;
|
|
||||||
|
|
||||||
let mut form = ConfigForm::new();
|
|
||||||
let mut ideal_cursor = 0;
|
|
||||||
let mut message = String::new();
|
|
||||||
|
|
||||||
draw_ui(&form, &message)?;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if let Event::Key(key) = event::read()? {
|
|
||||||
if !message.is_empty() {
|
|
||||||
message.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
match key {
|
|
||||||
// Exit
|
|
||||||
KeyEvent { code: KeyCode::Char('c'), modifiers: KeyModifiers::CONTROL, .. } => {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show suggestions
|
|
||||||
KeyEvent { code: KeyCode::Char(' '), modifiers: KeyModifiers::CONTROL, .. } => {
|
|
||||||
let result = ActionDispatcher::dispatch(
|
|
||||||
CanvasAction::Custom("trigger_suggestions".to_string()),
|
|
||||||
&mut form,
|
|
||||||
&mut ideal_cursor,
|
|
||||||
).await.unwrap();
|
|
||||||
|
|
||||||
if let Some(msg) = result.message() {
|
|
||||||
message = msg.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate current field or select suggestion
|
|
||||||
KeyEvent { code: KeyCode::Enter, .. } => {
|
|
||||||
if form.get_suggestions().is_some() {
|
|
||||||
// Select suggestion
|
|
||||||
let result = ActionDispatcher::dispatch(
|
|
||||||
CanvasAction::SelectSuggestion,
|
|
||||||
&mut form,
|
|
||||||
&mut ideal_cursor,
|
|
||||||
).await.unwrap();
|
|
||||||
|
|
||||||
if let Some(msg) = result.message() {
|
|
||||||
message = msg.to_string();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Validate current field
|
|
||||||
let result = ActionDispatcher::dispatch(
|
|
||||||
CanvasAction::Custom("validate_current".to_string()),
|
|
||||||
&mut form,
|
|
||||||
&mut ideal_cursor,
|
|
||||||
).await.unwrap();
|
|
||||||
|
|
||||||
if let Some(msg) = result.message() {
|
|
||||||
message = msg.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save configuration
|
|
||||||
KeyEvent { code: KeyCode::Char('s'), modifiers: KeyModifiers::CONTROL, .. } => {
|
|
||||||
let result = ActionDispatcher::dispatch(
|
|
||||||
CanvasAction::Custom("save_config".to_string()),
|
|
||||||
&mut form,
|
|
||||||
&mut ideal_cursor,
|
|
||||||
).await.unwrap();
|
|
||||||
|
|
||||||
if let Some(msg) = result.message() {
|
|
||||||
message = msg.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate all fields
|
|
||||||
KeyEvent { code: KeyCode::Char('v'), modifiers: KeyModifiers::CONTROL, .. } => {
|
|
||||||
let result = ActionDispatcher::dispatch(
|
|
||||||
CanvasAction::Custom("validate_all".to_string()),
|
|
||||||
&mut form,
|
|
||||||
&mut ideal_cursor,
|
|
||||||
).await.unwrap();
|
|
||||||
|
|
||||||
if let Some(msg) = result.message() {
|
|
||||||
message = msg.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle up/down for suggestions
|
|
||||||
KeyEvent { code: KeyCode::Up, .. } => {
|
|
||||||
let action = if form.get_suggestions().is_some() {
|
|
||||||
CanvasAction::SuggestionUp
|
|
||||||
} else {
|
|
||||||
CanvasAction::MoveUp
|
|
||||||
};
|
|
||||||
|
|
||||||
let _ = ActionDispatcher::dispatch(action, &mut form, &mut ideal_cursor).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyEvent { code: KeyCode::Down, .. } => {
|
|
||||||
let action = if form.get_suggestions().is_some() {
|
|
||||||
CanvasAction::SuggestionDown
|
|
||||||
} else {
|
|
||||||
CanvasAction::MoveDown
|
|
||||||
};
|
|
||||||
|
|
||||||
let _ = ActionDispatcher::dispatch(action, &mut form, &mut ideal_cursor).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle escape to close suggestions
|
|
||||||
KeyEvent { code: KeyCode::Esc, .. } => {
|
|
||||||
if form.get_suggestions().is_some() {
|
|
||||||
let _ = ActionDispatcher::dispatch(
|
|
||||||
CanvasAction::ExitSuggestions,
|
|
||||||
&mut form,
|
|
||||||
&mut ideal_cursor,
|
|
||||||
).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular key handling
|
|
||||||
_ => {
|
|
||||||
if let Some(action) = CanvasAction::from_key(key.code) {
|
|
||||||
let result = ActionDispatcher::dispatch(action, &mut form, &mut ideal_cursor).await.unwrap();
|
|
||||||
|
|
||||||
if !result.is_success() {
|
|
||||||
if let Some(msg) = result.message() {
|
|
||||||
message = format!("Error: {}", msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
draw_ui(&form, &message)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
disable_raw_mode()?;
|
|
||||||
io::stdout().execute(LeaveAlternateScreen)?;
|
|
||||||
println!("Configuration editor closed!");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,617 +0,0 @@
|
|||||||
// examples/integration_patterns.rs
|
|
||||||
//! Advanced integration patterns showing how Canvas works with:
|
|
||||||
//! - State management patterns
|
|
||||||
//! - Event-driven architectures
|
|
||||||
//! - Validation systems
|
|
||||||
//! - Custom rendering
|
|
||||||
//!
|
|
||||||
//! Run with: cargo run --example integration_patterns
|
|
||||||
|
|
||||||
use canvas::prelude::*;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() {
|
|
||||||
println!("🔧 Canvas Integration Patterns");
|
|
||||||
println!("==============================\n");
|
|
||||||
|
|
||||||
// Pattern 1: State machine integration
|
|
||||||
state_machine_example().await;
|
|
||||||
|
|
||||||
// Pattern 2: Event-driven architecture
|
|
||||||
event_driven_example().await;
|
|
||||||
|
|
||||||
// Pattern 3: Validation pipeline
|
|
||||||
validation_pipeline_example().await;
|
|
||||||
|
|
||||||
// Pattern 4: Multi-form orchestration
|
|
||||||
multi_form_example().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pattern 1: Canvas with state machine
|
|
||||||
async fn state_machine_example() {
|
|
||||||
println!("🔄 Pattern 1: State Machine Integration");
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
enum FormState {
|
|
||||||
Initial,
|
|
||||||
Editing,
|
|
||||||
Validating,
|
|
||||||
Submitting,
|
|
||||||
Success,
|
|
||||||
Error(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct StateMachineForm {
|
|
||||||
// Canvas state
|
|
||||||
current_field: usize,
|
|
||||||
cursor_pos: usize,
|
|
||||||
username: String,
|
|
||||||
password: String,
|
|
||||||
has_changes: bool,
|
|
||||||
|
|
||||||
// State machine
|
|
||||||
state: FormState,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StateMachineForm {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
current_field: 0,
|
|
||||||
cursor_pos: 0,
|
|
||||||
username: String::new(),
|
|
||||||
password: String::new(),
|
|
||||||
has_changes: false,
|
|
||||||
state: FormState::Initial,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn transition_to(&mut self, new_state: FormState) -> String {
|
|
||||||
let old_state = self.state.clone();
|
|
||||||
self.state = new_state;
|
|
||||||
format!("State transition: {:?} -> {:?}", old_state, self.state)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn can_submit(&self) -> bool {
|
|
||||||
matches!(self.state, FormState::Editing) &&
|
|
||||||
!self.username.trim().is_empty() &&
|
|
||||||
!self.password.trim().is_empty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CanvasState for StateMachineForm {
|
|
||||||
fn current_field(&self) -> usize { self.current_field }
|
|
||||||
fn current_cursor_pos(&self) -> usize { self.cursor_pos }
|
|
||||||
fn set_current_field(&mut self, index: usize) { self.current_field = index.min(1); }
|
|
||||||
fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; }
|
|
||||||
|
|
||||||
fn get_current_input(&self) -> &str {
|
|
||||||
match self.current_field {
|
|
||||||
0 => &self.username,
|
|
||||||
1 => &self.password,
|
|
||||||
_ => "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_current_input_mut(&mut self) -> &mut String {
|
|
||||||
match self.current_field {
|
|
||||||
0 => &mut self.username,
|
|
||||||
1 => &mut self.password,
|
|
||||||
_ => unreachable!(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn inputs(&self) -> Vec<&String> { vec![&self.username, &self.password] }
|
|
||||||
fn fields(&self) -> Vec<&str> { vec!["Username", "Password"] }
|
|
||||||
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
|
||||||
|
|
||||||
fn set_has_unsaved_changes(&mut self, changed: bool) {
|
|
||||||
self.has_changes = changed;
|
|
||||||
// Transition to editing state when user starts typing
|
|
||||||
if changed && self.state == FormState::Initial {
|
|
||||||
self.state = FormState::Editing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
|
||||||
match action {
|
|
||||||
CanvasAction::Custom(cmd) => match cmd.as_str() {
|
|
||||||
"submit" => {
|
|
||||||
if self.can_submit() {
|
|
||||||
let msg = self.transition_to(FormState::Submitting);
|
|
||||||
// Simulate submission
|
|
||||||
self.state = FormState::Success;
|
|
||||||
Some(format!("{} -> Form submitted successfully", msg))
|
|
||||||
} else {
|
|
||||||
let msg = self.transition_to(FormState::Error("Invalid form data".to_string()));
|
|
||||||
Some(msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"reset" => {
|
|
||||||
self.username.clear();
|
|
||||||
self.password.clear();
|
|
||||||
self.has_changes = false;
|
|
||||||
Some(self.transition_to(FormState::Initial))
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
},
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut form = StateMachineForm::new();
|
|
||||||
let mut ideal_cursor = 0;
|
|
||||||
|
|
||||||
println!(" Initial state: {:?}", form.state);
|
|
||||||
|
|
||||||
// Type some text to trigger state change
|
|
||||||
let result = ActionDispatcher::dispatch(
|
|
||||||
CanvasAction::InsertChar('u'),
|
|
||||||
&mut form,
|
|
||||||
&mut ideal_cursor,
|
|
||||||
).await.unwrap();
|
|
||||||
println!(" After typing: {:?}", form.state);
|
|
||||||
|
|
||||||
// Try to submit (should fail)
|
|
||||||
let result = ActionDispatcher::dispatch(
|
|
||||||
CanvasAction::Custom("submit".to_string()),
|
|
||||||
&mut form,
|
|
||||||
&mut ideal_cursor,
|
|
||||||
).await.unwrap();
|
|
||||||
println!(" Submit result: {}", result.message().unwrap_or(""));
|
|
||||||
println!(" ✅ State machine integration works!\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pattern 2: Event-driven architecture
|
|
||||||
async fn event_driven_example() {
|
|
||||||
println!("📡 Pattern 2: Event-Driven Architecture");
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
enum FormEvent {
|
|
||||||
FieldChanged { field: usize, old_value: String, new_value: String },
|
|
||||||
ValidationTriggered { field: usize, is_valid: bool },
|
|
||||||
ActionExecuted { action: String, success: bool },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct EventDrivenForm {
|
|
||||||
current_field: usize,
|
|
||||||
cursor_pos: usize,
|
|
||||||
email: String,
|
|
||||||
has_changes: bool,
|
|
||||||
events: Vec<FormEvent>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventDrivenForm {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
current_field: 0,
|
|
||||||
cursor_pos: 0,
|
|
||||||
email: String::new(),
|
|
||||||
has_changes: false,
|
|
||||||
events: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_event(&mut self, event: FormEvent) {
|
|
||||||
println!(" 📡 Event: {:?}", event);
|
|
||||||
self.events.push(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_email(&self) -> bool {
|
|
||||||
self.email.contains('@') && self.email.contains('.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CanvasState for EventDrivenForm {
|
|
||||||
fn current_field(&self) -> usize { self.current_field }
|
|
||||||
fn current_cursor_pos(&self) -> usize { self.cursor_pos }
|
|
||||||
fn set_current_field(&mut self, index: usize) { self.current_field = index; }
|
|
||||||
fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; }
|
|
||||||
|
|
||||||
fn get_current_input(&self) -> &str { &self.email }
|
|
||||||
fn get_current_input_mut(&mut self) -> &mut String { &mut self.email }
|
|
||||||
fn inputs(&self) -> Vec<&String> { vec![&self.email] }
|
|
||||||
fn fields(&self) -> Vec<&str> { vec!["Email"] }
|
|
||||||
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
|
||||||
|
|
||||||
fn set_has_unsaved_changes(&mut self, changed: bool) {
|
|
||||||
if changed != self.has_changes {
|
|
||||||
let old_value = if self.has_changes { "modified" } else { "unmodified" };
|
|
||||||
let new_value = if changed { "modified" } else { "unmodified" };
|
|
||||||
|
|
||||||
self.emit_event(FormEvent::FieldChanged {
|
|
||||||
field: self.current_field,
|
|
||||||
old_value: old_value.to_string(),
|
|
||||||
new_value: new_value.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
self.has_changes = changed;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
|
||||||
match action {
|
|
||||||
CanvasAction::Custom(cmd) => match cmd.as_str() {
|
|
||||||
"validate" => {
|
|
||||||
let is_valid = self.validate_email();
|
|
||||||
self.emit_event(FormEvent::ValidationTriggered {
|
|
||||||
field: self.current_field,
|
|
||||||
is_valid,
|
|
||||||
});
|
|
||||||
|
|
||||||
self.emit_event(FormEvent::ActionExecuted {
|
|
||||||
action: "validate".to_string(),
|
|
||||||
success: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if is_valid {
|
|
||||||
Some("Email is valid!".to_string())
|
|
||||||
} else {
|
|
||||||
Some("Email is invalid".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
},
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut form = EventDrivenForm::new();
|
|
||||||
let mut ideal_cursor = 0;
|
|
||||||
|
|
||||||
// Type an email address
|
|
||||||
let email = "user@example.com";
|
|
||||||
for c in email.chars() {
|
|
||||||
ActionDispatcher::dispatch(
|
|
||||||
CanvasAction::InsertChar(c),
|
|
||||||
&mut form,
|
|
||||||
&mut ideal_cursor,
|
|
||||||
).await.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate the email
|
|
||||||
let result = ActionDispatcher::dispatch(
|
|
||||||
CanvasAction::Custom("validate".to_string()),
|
|
||||||
&mut form,
|
|
||||||
&mut ideal_cursor,
|
|
||||||
).await.unwrap();
|
|
||||||
|
|
||||||
println!(" Final email: {}", form.email);
|
|
||||||
println!(" Validation result: {}", result.message().unwrap_or(""));
|
|
||||||
println!(" Total events captured: {}", form.events.len());
|
|
||||||
println!(" ✅ Event-driven architecture works!\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pattern 3: Validation pipeline
|
|
||||||
async fn validation_pipeline_example() {
|
|
||||||
println!("✅ Pattern 3: Validation Pipeline");
|
|
||||||
|
|
||||||
type ValidationRule = Box<dyn Fn(&str) -> Result<(), String>>;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct ValidatedForm {
|
|
||||||
current_field: usize,
|
|
||||||
cursor_pos: usize,
|
|
||||||
password: String,
|
|
||||||
has_changes: bool,
|
|
||||||
validators: HashMap<usize, Vec<ValidationRule>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ValidatedForm {
|
|
||||||
fn new() -> Self {
|
|
||||||
let mut validators: HashMap<usize, Vec<ValidationRule>> = HashMap::new();
|
|
||||||
|
|
||||||
// Password validators
|
|
||||||
let mut password_validators: Vec<ValidationRule> = Vec::new();
|
|
||||||
password_validators.push(Box::new(|value| {
|
|
||||||
if value.len() < 8 {
|
|
||||||
Err("Password must be at least 8 characters".to_string())
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
password_validators.push(Box::new(|value| {
|
|
||||||
if !value.chars().any(|c| c.is_uppercase()) {
|
|
||||||
Err("Password must contain at least one uppercase letter".to_string())
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
password_validators.push(Box::new(|value| {
|
|
||||||
if !value.chars().any(|c| c.is_numeric()) {
|
|
||||||
Err("Password must contain at least one number".to_string())
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
validators.insert(0, password_validators);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
current_field: 0,
|
|
||||||
cursor_pos: 0,
|
|
||||||
password: String::new(),
|
|
||||||
has_changes: false,
|
|
||||||
validators,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_field(&self, field_index: usize) -> Vec<String> {
|
|
||||||
let mut errors = Vec::new();
|
|
||||||
|
|
||||||
if let Some(validators) = self.validators.get(&field_index) {
|
|
||||||
let value = match field_index {
|
|
||||||
0 => &self.password,
|
|
||||||
_ => return errors,
|
|
||||||
};
|
|
||||||
|
|
||||||
for validator in validators {
|
|
||||||
if let Err(error) = validator(value) {
|
|
||||||
errors.push(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CanvasState for ValidatedForm {
|
|
||||||
fn current_field(&self) -> usize { self.current_field }
|
|
||||||
fn current_cursor_pos(&self) -> usize { self.cursor_pos }
|
|
||||||
fn set_current_field(&mut self, index: usize) { self.current_field = index; }
|
|
||||||
fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; }
|
|
||||||
|
|
||||||
fn get_current_input(&self) -> &str { &self.password }
|
|
||||||
fn get_current_input_mut(&mut self) -> &mut String { &mut self.password }
|
|
||||||
fn inputs(&self) -> Vec<&String> { vec![&self.password] }
|
|
||||||
fn fields(&self) -> Vec<&str> { vec!["Password"] }
|
|
||||||
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
|
||||||
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
|
|
||||||
|
|
||||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
|
||||||
match action {
|
|
||||||
CanvasAction::Custom(cmd) => match cmd.as_str() {
|
|
||||||
"validate" => {
|
|
||||||
let errors = self.validate_field(self.current_field);
|
|
||||||
if errors.is_empty() {
|
|
||||||
Some("Password meets all requirements!".to_string())
|
|
||||||
} else {
|
|
||||||
Some(format!("Validation errors: {}", errors.join(", ")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
},
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut form = ValidatedForm::new();
|
|
||||||
let mut ideal_cursor = 0;
|
|
||||||
|
|
||||||
// Test with weak password
|
|
||||||
let weak_password = "abc";
|
|
||||||
for c in weak_password.chars() {
|
|
||||||
ActionDispatcher::dispatch(
|
|
||||||
CanvasAction::InsertChar(c),
|
|
||||||
&mut form,
|
|
||||||
&mut ideal_cursor,
|
|
||||||
).await.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = ActionDispatcher::dispatch(
|
|
||||||
CanvasAction::Custom("validate".to_string()),
|
|
||||||
&mut form,
|
|
||||||
&mut ideal_cursor,
|
|
||||||
).await.unwrap();
|
|
||||||
println!(" Weak password '{}': {}", form.password, result.message().unwrap_or(""));
|
|
||||||
|
|
||||||
// Clear and test with strong password
|
|
||||||
form.password.clear();
|
|
||||||
form.cursor_pos = 0;
|
|
||||||
|
|
||||||
let strong_password = "StrongPass123";
|
|
||||||
for c in strong_password.chars() {
|
|
||||||
ActionDispatcher::dispatch(
|
|
||||||
CanvasAction::InsertChar(c),
|
|
||||||
&mut form,
|
|
||||||
&mut ideal_cursor,
|
|
||||||
).await.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = ActionDispatcher::dispatch(
|
|
||||||
CanvasAction::Custom("validate".to_string()),
|
|
||||||
&mut form,
|
|
||||||
&mut ideal_cursor,
|
|
||||||
).await.unwrap();
|
|
||||||
println!(" Strong password '{}': {}", form.password, result.message().unwrap_or(""));
|
|
||||||
println!(" ✅ Validation pipeline works!\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pattern 4: Multi-form orchestration
|
|
||||||
async fn multi_form_example() {
|
|
||||||
println!("🎭 Pattern 4: Multi-Form Orchestration");
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct PersonalInfoForm {
|
|
||||||
current_field: usize,
|
|
||||||
cursor_pos: usize,
|
|
||||||
name: String,
|
|
||||||
age: String,
|
|
||||||
has_changes: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct ContactInfoForm {
|
|
||||||
current_field: usize,
|
|
||||||
cursor_pos: usize,
|
|
||||||
email: String,
|
|
||||||
phone: String,
|
|
||||||
has_changes: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Implement CanvasState for both forms
|
|
||||||
impl CanvasState for PersonalInfoForm {
|
|
||||||
fn current_field(&self) -> usize { self.current_field }
|
|
||||||
fn current_cursor_pos(&self) -> usize { self.cursor_pos }
|
|
||||||
fn set_current_field(&mut self, index: usize) { self.current_field = index.min(1); }
|
|
||||||
fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; }
|
|
||||||
|
|
||||||
fn get_current_input(&self) -> &str {
|
|
||||||
match self.current_field {
|
|
||||||
0 => &self.name,
|
|
||||||
1 => &self.age,
|
|
||||||
_ => "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_current_input_mut(&mut self) -> &mut String {
|
|
||||||
match self.current_field {
|
|
||||||
0 => &mut self.name,
|
|
||||||
1 => &mut self.age,
|
|
||||||
_ => unreachable!(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn inputs(&self) -> Vec<&String> { vec![&self.name, &self.age] }
|
|
||||||
fn fields(&self) -> Vec<&str> { vec!["Name", "Age"] }
|
|
||||||
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
|
||||||
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CanvasState for ContactInfoForm {
|
|
||||||
fn current_field(&self) -> usize { self.current_field }
|
|
||||||
fn current_cursor_pos(&self) -> usize { self.cursor_pos }
|
|
||||||
fn set_current_field(&mut self, index: usize) { self.current_field = index.min(1); }
|
|
||||||
fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; }
|
|
||||||
|
|
||||||
fn get_current_input(&self) -> &str {
|
|
||||||
match self.current_field {
|
|
||||||
0 => &self.email,
|
|
||||||
1 => &self.phone,
|
|
||||||
_ => "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_current_input_mut(&mut self) -> &mut String {
|
|
||||||
match self.current_field {
|
|
||||||
0 => &mut self.email,
|
|
||||||
1 => &mut self.phone,
|
|
||||||
_ => unreachable!(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn inputs(&self) -> Vec<&String> { vec![&self.email, &self.phone] }
|
|
||||||
fn fields(&self) -> Vec<&str> { vec!["Email", "Phone"] }
|
|
||||||
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
|
||||||
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form orchestrator
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct FormOrchestrator {
|
|
||||||
personal_form: PersonalInfoForm,
|
|
||||||
contact_form: ContactInfoForm,
|
|
||||||
current_form: usize, // 0 = personal, 1 = contact
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FormOrchestrator {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
personal_form: PersonalInfoForm {
|
|
||||||
current_field: 0,
|
|
||||||
cursor_pos: 0,
|
|
||||||
name: String::new(),
|
|
||||||
age: String::new(),
|
|
||||||
has_changes: false,
|
|
||||||
},
|
|
||||||
contact_form: ContactInfoForm {
|
|
||||||
current_field: 0,
|
|
||||||
cursor_pos: 0,
|
|
||||||
email: String::new(),
|
|
||||||
phone: String::new(),
|
|
||||||
has_changes: false,
|
|
||||||
},
|
|
||||||
current_form: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute_action(&mut self, action: CanvasAction) -> ActionResult {
|
|
||||||
let mut ideal_cursor = 0;
|
|
||||||
|
|
||||||
match self.current_form {
|
|
||||||
0 => ActionDispatcher::dispatch(action, &mut self.personal_form, &mut ideal_cursor).await.unwrap(),
|
|
||||||
1 => ActionDispatcher::dispatch(action, &mut self.contact_form, &mut ideal_cursor).await.unwrap(),
|
|
||||||
_ => ActionResult::error("Invalid form index"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn switch_form(&mut self) -> String {
|
|
||||||
self.current_form = (self.current_form + 1) % 2;
|
|
||||||
match self.current_form {
|
|
||||||
0 => "Switched to Personal Info form".to_string(),
|
|
||||||
1 => "Switched to Contact Info form".to_string(),
|
|
||||||
_ => "Unknown form".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_form_name(&self) -> &str {
|
|
||||||
match self.current_form {
|
|
||||||
0 => "Personal Info",
|
|
||||||
1 => "Contact Info",
|
|
||||||
_ => "Unknown",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut orchestrator = FormOrchestrator::new();
|
|
||||||
|
|
||||||
println!(" Current form: {}", orchestrator.current_form_name());
|
|
||||||
|
|
||||||
// Fill personal info
|
|
||||||
let personal_data = vec![
|
|
||||||
('J', 'o', 'h', 'n'),
|
|
||||||
];
|
|
||||||
|
|
||||||
for &c in &['J', 'o', 'h', 'n'] {
|
|
||||||
orchestrator.execute_action(CanvasAction::InsertChar(c)).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
orchestrator.execute_action(CanvasAction::NextField).await;
|
|
||||||
|
|
||||||
for &c in &['2', '5'] {
|
|
||||||
orchestrator.execute_action(CanvasAction::InsertChar(c)).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
println!(" Personal form - Name: '{}', Age: '{}'",
|
|
||||||
orchestrator.personal_form.name,
|
|
||||||
orchestrator.personal_form.age);
|
|
||||||
|
|
||||||
// Switch to contact form
|
|
||||||
let switch_msg = orchestrator.switch_form();
|
|
||||||
println!(" {}", switch_msg);
|
|
||||||
|
|
||||||
// Fill contact info
|
|
||||||
for &c in &['j', 'o', 'h', 'n', '@', 'e', 'x', 'a', 'm', 'p', 'l', 'e', '.', 'c', 'o', 'm'] {
|
|
||||||
orchestrator.execute_action(CanvasAction::InsertChar(c)).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
orchestrator.execute_action(CanvasAction::NextField).await;
|
|
||||||
|
|
||||||
for &c in &['5', '5', '5', '-', '1', '2', '3', '4'] {
|
|
||||||
orchestrator.execute_action(CanvasAction::InsertChar(c)).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
println!(" Contact form - Email: '{}', Phone: '{}'",
|
|
||||||
orchestrator.contact_form.email,
|
|
||||||
orchestrator.contact_form.phone);
|
|
||||||
|
|
||||||
println!(" ✅ Multi-form orchestration works!\n");
|
|
||||||
|
|
||||||
println!("🎉 All integration patterns completed!");
|
|
||||||
println!("The Canvas crate seamlessly integrates with various architectural patterns!");
|
|
||||||
}
|
|
||||||
@@ -1,354 +0,0 @@
|
|||||||
// examples/simple_login.rs
|
|
||||||
//! A simple login form demonstrating basic canvas usage
|
|
||||||
//!
|
|
||||||
//! Run with: cargo run --example simple_login
|
|
||||||
|
|
||||||
use canvas::prelude::*;
|
|
||||||
use crossterm::{
|
|
||||||
event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
|
|
||||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
|
||||||
ExecutableCommand,
|
|
||||||
};
|
|
||||||
use std::io::{self, Write};
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct LoginForm {
|
|
||||||
current_field: usize,
|
|
||||||
cursor_pos: usize,
|
|
||||||
username: String,
|
|
||||||
password: String,
|
|
||||||
has_changes: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LoginForm {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
current_field: 0,
|
|
||||||
cursor_pos: 0,
|
|
||||||
username: String::new(),
|
|
||||||
password: String::new(),
|
|
||||||
has_changes: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reset(&mut self) {
|
|
||||||
self.username.clear();
|
|
||||||
self.password.clear();
|
|
||||||
self.current_field = 0;
|
|
||||||
self.cursor_pos = 0;
|
|
||||||
self.has_changes = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_valid(&self) -> bool {
|
|
||||||
!self.username.trim().is_empty() && !self.password.trim().is_empty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CanvasState for LoginForm {
|
|
||||||
fn current_field(&self) -> usize {
|
|
||||||
self.current_field
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_cursor_pos(&self) -> usize {
|
|
||||||
self.cursor_pos
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_current_field(&mut self, index: usize) {
|
|
||||||
self.current_field = index.min(1); // Only 2 fields: username(0), password(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_current_cursor_pos(&mut self, pos: usize) {
|
|
||||||
self.cursor_pos = pos;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_current_input(&self) -> &str {
|
|
||||||
match self.current_field {
|
|
||||||
0 => &self.username,
|
|
||||||
1 => &self.password,
|
|
||||||
_ => "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_current_input_mut(&mut self) -> &mut String {
|
|
||||||
match self.current_field {
|
|
||||||
0 => &mut self.username,
|
|
||||||
1 => &mut self.password,
|
|
||||||
_ => unreachable!(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn inputs(&self) -> Vec<&String> {
|
|
||||||
vec![&self.username, &self.password]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fields(&self) -> Vec<&str> {
|
|
||||||
vec!["Username", "Password"]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn has_unsaved_changes(&self) -> bool {
|
|
||||||
self.has_changes
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_has_unsaved_changes(&mut self, changed: bool) {
|
|
||||||
self.has_changes = changed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom action handling
|
|
||||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
|
||||||
match action {
|
|
||||||
CanvasAction::Custom(cmd) => match cmd.as_str() {
|
|
||||||
"submit" => {
|
|
||||||
if self.is_valid() {
|
|
||||||
Some(format!("Login successful! Welcome, {}", self.username))
|
|
||||||
} else {
|
|
||||||
Some("Error: Username and password are required".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"clear" => {
|
|
||||||
self.reset();
|
|
||||||
Some("Form cleared".to_string())
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
},
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override display for password field
|
|
||||||
fn get_display_value_for_field(&self, index: usize) -> &str {
|
|
||||||
match index {
|
|
||||||
0 => &self.username, // Username shows normally
|
|
||||||
1 => &self.password, // We'll handle masking in the UI drawing
|
|
||||||
_ => "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn has_display_override(&self, index: usize) -> bool {
|
|
||||||
index == 1 // Password field has display override
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_ui(form: &LoginForm, message: &str) -> io::Result<()> {
|
|
||||||
// Clear screen and move cursor to top-left
|
|
||||||
print!("\x1B[2J\x1B[1;1H");
|
|
||||||
|
|
||||||
println!("╔═══════════════════════════════════════╗");
|
|
||||||
println!("║ LOGIN FORM ║");
|
|
||||||
println!("╠═══════════════════════════════════════╣");
|
|
||||||
|
|
||||||
// Username field
|
|
||||||
let username_indicator = if form.current_field == 0 { "→" } else { " " };
|
|
||||||
let username_display = if form.username.is_empty() {
|
|
||||||
"<enter username>".to_string()
|
|
||||||
} else {
|
|
||||||
form.username.clone()
|
|
||||||
};
|
|
||||||
println!("║ {} Username: {:22} ║", username_indicator,
|
|
||||||
if username_display.len() > 22 {
|
|
||||||
format!("{}...", &username_display[..19])
|
|
||||||
} else {
|
|
||||||
format!("{:22}", username_display)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show cursor for username field
|
|
||||||
if form.current_field == 0 && !form.username.is_empty() {
|
|
||||||
let cursor_pos = form.cursor_pos.min(form.username.len());
|
|
||||||
let spaces_before = 11 + cursor_pos; // "Username: " = 10 chars + 1 space
|
|
||||||
let cursor_line = format!("║ {}█{:width$}║",
|
|
||||||
" ".repeat(spaces_before),
|
|
||||||
"",
|
|
||||||
width = 25_usize.saturating_sub(spaces_before)
|
|
||||||
);
|
|
||||||
println!("{}", cursor_line);
|
|
||||||
} else {
|
|
||||||
println!("║{:37}║", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Password field
|
|
||||||
let password_indicator = if form.current_field == 1 { "→" } else { " " };
|
|
||||||
let password_display = if form.password.is_empty() {
|
|
||||||
"<enter password>".to_string()
|
|
||||||
} else {
|
|
||||||
"*".repeat(form.password.len())
|
|
||||||
};
|
|
||||||
println!("║ {} Password: {:22} ║", password_indicator,
|
|
||||||
if password_display.len() > 22 {
|
|
||||||
format!("{}...", &password_display[..19])
|
|
||||||
} else {
|
|
||||||
format!("{:22}", password_display)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show cursor for password field
|
|
||||||
if form.current_field == 1 && !form.password.is_empty() {
|
|
||||||
let cursor_pos = form.cursor_pos.min(form.password.len());
|
|
||||||
let spaces_before = 11 + cursor_pos; // "Password: " = 10 chars + 1 space
|
|
||||||
let cursor_line = format!("║ {}█{:width$}║",
|
|
||||||
" ".repeat(spaces_before),
|
|
||||||
"",
|
|
||||||
width = 25_usize.saturating_sub(spaces_before)
|
|
||||||
);
|
|
||||||
println!("{}", cursor_line);
|
|
||||||
} else {
|
|
||||||
println!("║{:37}║", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("╠═══════════════════════════════════════╣");
|
|
||||||
println!("║ CONTROLS: ║");
|
|
||||||
println!("║ Tab/↑↓ - Navigate fields ║");
|
|
||||||
println!("║ Enter - Submit form ║");
|
|
||||||
println!("║ Ctrl+R - Clear form ║");
|
|
||||||
println!("║ Ctrl+C - Exit ║");
|
|
||||||
println!("╠═══════════════════════════════════════╣");
|
|
||||||
|
|
||||||
// Status message
|
|
||||||
let status = if !message.is_empty() {
|
|
||||||
message.to_string()
|
|
||||||
} else if form.has_changes {
|
|
||||||
"Form modified".to_string()
|
|
||||||
} else {
|
|
||||||
"Ready - enter your credentials".to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
let status_display = if status.len() > 33 {
|
|
||||||
format!("{}...", &status[..30])
|
|
||||||
} else {
|
|
||||||
format!("{:33}", status)
|
|
||||||
};
|
|
||||||
|
|
||||||
println!("║ Status: {} ║", status_display);
|
|
||||||
println!("╚═══════════════════════════════════════╝");
|
|
||||||
|
|
||||||
// Show current state info
|
|
||||||
println!();
|
|
||||||
println!("Current field: {} ({})",
|
|
||||||
form.current_field,
|
|
||||||
form.fields()[form.current_field]);
|
|
||||||
println!("Cursor position: {}", form.cursor_pos);
|
|
||||||
println!("Has changes: {}", form.has_changes);
|
|
||||||
|
|
||||||
io::stdout().flush()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> io::Result<()> {
|
|
||||||
println!("Starting Canvas Login Demo...");
|
|
||||||
println!("Setting up terminal...");
|
|
||||||
|
|
||||||
// Setup terminal
|
|
||||||
enable_raw_mode()?;
|
|
||||||
io::stdout().execute(EnterAlternateScreen)?;
|
|
||||||
|
|
||||||
let mut form = LoginForm::new();
|
|
||||||
let mut ideal_cursor = 0;
|
|
||||||
let mut message = String::new();
|
|
||||||
|
|
||||||
// Initial draw
|
|
||||||
if let Err(e) = draw_ui(&form, &message) {
|
|
||||||
// Cleanup on error
|
|
||||||
let _ = disable_raw_mode();
|
|
||||||
let _ = io::stdout().execute(LeaveAlternateScreen);
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("Canvas Login Demo started. Use Ctrl+C to exit.");
|
|
||||||
|
|
||||||
loop {
|
|
||||||
match event::read() {
|
|
||||||
Ok(Event::Key(key)) => {
|
|
||||||
// Clear message after key press
|
|
||||||
if !message.is_empty() {
|
|
||||||
message.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
match key {
|
|
||||||
// Exit
|
|
||||||
KeyEvent { code: KeyCode::Char('c'), modifiers: KeyModifiers::CONTROL, .. } => {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear form
|
|
||||||
KeyEvent { code: KeyCode::Char('r'), modifiers: KeyModifiers::CONTROL, .. } => {
|
|
||||||
match ActionDispatcher::dispatch(
|
|
||||||
CanvasAction::Custom("clear".to_string()),
|
|
||||||
&mut form,
|
|
||||||
&mut ideal_cursor,
|
|
||||||
).await {
|
|
||||||
Ok(result) => {
|
|
||||||
if let Some(msg) = result.message() {
|
|
||||||
message = msg.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
message = format!("Error: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Submit form
|
|
||||||
KeyEvent { code: KeyCode::Enter, .. } => {
|
|
||||||
match ActionDispatcher::dispatch(
|
|
||||||
CanvasAction::Custom("submit".to_string()),
|
|
||||||
&mut form,
|
|
||||||
&mut ideal_cursor,
|
|
||||||
).await {
|
|
||||||
Ok(result) => {
|
|
||||||
if let Some(msg) = result.message() {
|
|
||||||
message = msg.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
message = format!("Error: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular key handling - let canvas handle it!
|
|
||||||
_ => {
|
|
||||||
if let Some(action) = CanvasAction::from_key(key.code) {
|
|
||||||
match ActionDispatcher::dispatch(action, &mut form, &mut ideal_cursor).await {
|
|
||||||
Ok(result) => {
|
|
||||||
if !result.is_success() {
|
|
||||||
if let Some(msg) = result.message() {
|
|
||||||
message = format!("Error: {}", msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
message = format!("Error: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redraw UI
|
|
||||||
if let Err(e) = draw_ui(&form, &message) {
|
|
||||||
eprintln!("Error drawing UI: {}", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(_) => {
|
|
||||||
// Ignore other events (mouse, resize, etc.)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
message = format!("Event error: {}", e);
|
|
||||||
if let Err(_) = draw_ui(&form, &message) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
disable_raw_mode()?;
|
|
||||||
io::stdout().execute(LeaveAlternateScreen)?;
|
|
||||||
|
|
||||||
println!("Thanks for using Canvas Login Demo!");
|
|
||||||
println!("Final form state:");
|
|
||||||
println!(" Username: '{}'", form.username);
|
|
||||||
println!(" Password: '{}'", "*".repeat(form.password.len()));
|
|
||||||
println!(" Valid: {}", form.is_valid());
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
// canvas/src/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};
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
170
canvas/src/autocomplete/actions.rs
Normal file
170
canvas/src/autocomplete/actions.rs
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
// src/autocomplete/actions.rs
|
||||||
|
|
||||||
|
use crate::canvas::state::CanvasState;
|
||||||
|
use crate::autocomplete::state::AutocompleteCanvasState;
|
||||||
|
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
||||||
|
use crate::canvas::actions::execute;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
/// Enhanced execute function for states that support autocomplete
|
||||||
|
/// This is the main entry point for autocomplete-aware canvas execution
|
||||||
|
///
|
||||||
|
/// Use this instead of canvas::execute() if you want autocomplete behavior:
|
||||||
|
/// ```rust
|
||||||
|
/// execute_with_autocomplete(action, &mut state).await?;
|
||||||
|
/// ```
|
||||||
|
pub async fn execute_with_autocomplete<S: CanvasState + AutocompleteCanvasState + Send>(
|
||||||
|
action: CanvasAction,
|
||||||
|
state: &mut S,
|
||||||
|
) -> Result<ActionResult> {
|
||||||
|
match &action {
|
||||||
|
// === AUTOCOMPLETE-SPECIFIC ACTIONS ===
|
||||||
|
|
||||||
|
CanvasAction::TriggerAutocomplete => {
|
||||||
|
if state.supports_autocomplete(state.current_field()) {
|
||||||
|
state.trigger_autocomplete_suggestions().await;
|
||||||
|
Ok(ActionResult::success_with_message("Triggered autocomplete"))
|
||||||
|
} else {
|
||||||
|
Ok(ActionResult::success_with_message("Autocomplete not supported for this field"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::SuggestionUp => {
|
||||||
|
if state.has_autocomplete_suggestions() {
|
||||||
|
state.move_suggestion_selection(-1);
|
||||||
|
Ok(ActionResult::success())
|
||||||
|
} else {
|
||||||
|
Ok(ActionResult::success_with_message("No suggestions available"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::SuggestionDown => {
|
||||||
|
if state.has_autocomplete_suggestions() {
|
||||||
|
state.move_suggestion_selection(1);
|
||||||
|
Ok(ActionResult::success())
|
||||||
|
} else {
|
||||||
|
Ok(ActionResult::success_with_message("No suggestions available"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::SelectSuggestion => {
|
||||||
|
if let Some(message) = state.apply_selected_suggestion() {
|
||||||
|
Ok(ActionResult::success_with_message(&message))
|
||||||
|
} else {
|
||||||
|
Ok(ActionResult::success_with_message("No suggestion to select"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::ExitSuggestions => {
|
||||||
|
state.clear_autocomplete_suggestions();
|
||||||
|
Ok(ActionResult::success_with_message("Closed autocomplete"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// === TEXT INSERTION WITH AUTO-TRIGGER ===
|
||||||
|
|
||||||
|
CanvasAction::InsertChar(_) => {
|
||||||
|
// First, execute the character insertion normally
|
||||||
|
let result = execute(action, state).await?;
|
||||||
|
|
||||||
|
// After successful insertion, check if we should auto-trigger autocomplete
|
||||||
|
if result.is_success() && state.should_trigger_autocomplete() {
|
||||||
|
state.trigger_autocomplete_suggestions().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === NAVIGATION/EDITING ACTIONS (clear autocomplete first) ===
|
||||||
|
|
||||||
|
CanvasAction::MoveLeft | CanvasAction::MoveRight |
|
||||||
|
CanvasAction::MoveUp | CanvasAction::MoveDown |
|
||||||
|
CanvasAction::NextField | CanvasAction::PrevField |
|
||||||
|
CanvasAction::DeleteBackward | CanvasAction::DeleteForward => {
|
||||||
|
// Clear autocomplete when navigating/editing
|
||||||
|
if state.is_autocomplete_active() {
|
||||||
|
state.clear_autocomplete_suggestions();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the action normally
|
||||||
|
execute(action, state).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// === ALL OTHER ACTIONS (normal execution) ===
|
||||||
|
|
||||||
|
_ => {
|
||||||
|
// For all other actions, just execute normally
|
||||||
|
execute(action, state).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to integrate autocomplete actions with CanvasState.handle_feature_action()
|
||||||
|
///
|
||||||
|
/// Use this in your CanvasState implementation like this:
|
||||||
|
/// ```rust
|
||||||
|
/// fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
|
||||||
|
/// // Try autocomplete first
|
||||||
|
/// if let Some(result) = handle_autocomplete_feature_action(action, self) {
|
||||||
|
/// return Some(result);
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// // Handle your other custom actions...
|
||||||
|
/// None
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub fn handle_autocomplete_feature_action<S: CanvasState + AutocompleteCanvasState + Send>(
|
||||||
|
action: &CanvasAction,
|
||||||
|
state: &S,
|
||||||
|
) -> Option<String> {
|
||||||
|
match action {
|
||||||
|
CanvasAction::TriggerAutocomplete => {
|
||||||
|
if state.supports_autocomplete(state.current_field()) {
|
||||||
|
if state.is_autocomplete_active() {
|
||||||
|
Some("Autocomplete already active".to_string())
|
||||||
|
} else {
|
||||||
|
None // Let execute_with_autocomplete handle it
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Some("Autocomplete not available for this field".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::SuggestionUp | CanvasAction::SuggestionDown => {
|
||||||
|
if state.is_autocomplete_active() {
|
||||||
|
None // Let execute_with_autocomplete handle navigation
|
||||||
|
} else {
|
||||||
|
Some("No autocomplete suggestions to navigate".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::SelectSuggestion => {
|
||||||
|
if state.has_autocomplete_suggestions() {
|
||||||
|
None // Let execute_with_autocomplete handle selection
|
||||||
|
} else {
|
||||||
|
Some("No suggestion to select".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::ExitSuggestions => {
|
||||||
|
if state.is_autocomplete_active() {
|
||||||
|
None // Let execute_with_autocomplete handle exit
|
||||||
|
} else {
|
||||||
|
Some("No autocomplete to close".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => None // Not an autocomplete action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy compatibility function - kept for backward compatibility
|
||||||
|
/// This is the old function signature, now it just wraps the new system
|
||||||
|
#[deprecated(note = "Use execute_with_autocomplete instead")]
|
||||||
|
pub async fn execute_canvas_action_with_autocomplete<S: CanvasState + AutocompleteCanvasState + Send>(
|
||||||
|
action: CanvasAction,
|
||||||
|
state: &mut S,
|
||||||
|
_ideal_cursor_column: &mut usize, // Ignored - new system manages this internally
|
||||||
|
_config: Option<&()>, // Ignored - no more config system
|
||||||
|
) -> Result<ActionResult> {
|
||||||
|
execute_with_autocomplete(action, state).await
|
||||||
|
}
|
||||||
192
canvas/src/autocomplete/gui.rs
Normal file
192
canvas/src/autocomplete/gui.rs
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
// src/autocomplete/gui.rs
|
||||||
|
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Alignment, Rect},
|
||||||
|
style::{Modifier, Style},
|
||||||
|
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use the correct import from our types module
|
||||||
|
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, D: Clone + Send + 'static>(
|
||||||
|
f: &mut Frame,
|
||||||
|
frame_area: Rect,
|
||||||
|
input_rect: Rect,
|
||||||
|
theme: &T,
|
||||||
|
autocomplete_state: &AutocompleteState<D>,
|
||||||
|
) {
|
||||||
|
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, D: Clone + Send + 'static>(
|
||||||
|
f: &mut Frame,
|
||||||
|
frame_area: Rect,
|
||||||
|
input_rect: Rect,
|
||||||
|
theme: &T,
|
||||||
|
autocomplete_state: &AutocompleteState<D>,
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
}
|
||||||
22
canvas/src/autocomplete/mod.rs
Normal file
22
canvas/src/autocomplete/mod.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// src/autocomplete/mod.rs
|
||||||
|
|
||||||
|
pub mod types;
|
||||||
|
pub mod state;
|
||||||
|
pub mod actions;
|
||||||
|
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub mod gui;
|
||||||
|
|
||||||
|
// Re-export the main autocomplete API
|
||||||
|
pub use types::{SuggestionItem, AutocompleteState};
|
||||||
|
pub use state::AutocompleteCanvasState;
|
||||||
|
|
||||||
|
// Re-export the new action functions
|
||||||
|
pub use actions::{
|
||||||
|
execute_with_autocomplete,
|
||||||
|
handle_autocomplete_feature_action,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Re-export GUI functions if available
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub use gui::render_autocomplete_dropdown;
|
||||||
189
canvas/src/autocomplete/state.rs
Normal file
189
canvas/src/autocomplete/state.rs
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
// src/autocomplete/state.rs
|
||||||
|
|
||||||
|
use crate::canvas::state::CanvasState;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
/// OPTIONAL extension trait for states that want rich autocomplete functionality.
|
||||||
|
/// Only implement this if you need the new autocomplete features.
|
||||||
|
///
|
||||||
|
/// # User Workflow:
|
||||||
|
/// 1. User presses trigger key (Tab, Ctrl+K, etc.)
|
||||||
|
/// 2. User's key mapping calls CanvasAction::TriggerAutocomplete
|
||||||
|
/// 3. Library calls your trigger_autocomplete_suggestions() method
|
||||||
|
/// 4. You implement async fetching logic in that method
|
||||||
|
/// 5. You call set_autocomplete_suggestions() with results
|
||||||
|
/// 6. Library manages UI state and navigation
|
||||||
|
#[async_trait]
|
||||||
|
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 (user decides which fields)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// === PUBLIC API METHODS (called by library) ===
|
||||||
|
|
||||||
|
/// Activate autocomplete for current field (shows loading spinner)
|
||||||
|
fn activate_autocomplete(&mut self) {
|
||||||
|
let current_field = self.current_field();
|
||||||
|
if let Some(state) = self.autocomplete_state_mut() {
|
||||||
|
state.activate(current_field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deactivate autocomplete (hides dropdown)
|
||||||
|
fn deactivate_autocomplete(&mut self) {
|
||||||
|
if let Some(state) = self.autocomplete_state_mut() {
|
||||||
|
state.deactivate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set suggestions (called after your 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set loading state (show/hide spinner)
|
||||||
|
fn set_autocomplete_loading(&mut self, loading: bool) {
|
||||||
|
if let Some(state) = self.autocomplete_state_mut() {
|
||||||
|
state.is_loading = loading;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === QUERY METHODS ===
|
||||||
|
|
||||||
|
/// Check if autocomplete is currently active/visible
|
||||||
|
fn is_autocomplete_active(&self) -> bool {
|
||||||
|
self.autocomplete_state()
|
||||||
|
.map(|state| state.is_active)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if autocomplete has suggestions ready for navigation
|
||||||
|
fn is_autocomplete_ready(&self) -> bool {
|
||||||
|
self.autocomplete_state()
|
||||||
|
.map(|state| state.is_ready())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if there are available suggestions
|
||||||
|
fn has_autocomplete_suggestions(&self) -> bool {
|
||||||
|
self.autocomplete_state()
|
||||||
|
.map(|state| !state.suggestions.is_empty())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === USER-IMPLEMENTABLE METHODS ===
|
||||||
|
|
||||||
|
/// Check if autocomplete should be triggered automatically (e.g., after typing 2+ chars)
|
||||||
|
/// Override this to implement your own trigger logic
|
||||||
|
fn should_trigger_autocomplete(&self) -> bool {
|
||||||
|
let current_input = self.get_current_input();
|
||||||
|
let current_field = self.current_field();
|
||||||
|
|
||||||
|
self.supports_autocomplete(current_field) &&
|
||||||
|
current_input.len() >= 2 && // Default: trigger after 2 chars
|
||||||
|
!self.is_autocomplete_active()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **USER MUST IMPLEMENT**: Trigger autocomplete suggestions (async)
|
||||||
|
/// This is where you implement your API calls, caching, etc.
|
||||||
|
///
|
||||||
|
/// # Example Implementation:
|
||||||
|
/// ```rust
|
||||||
|
/// #[async_trait]
|
||||||
|
/// impl AutocompleteCanvasState for MyState {
|
||||||
|
/// type SuggestionData = MyData;
|
||||||
|
///
|
||||||
|
/// async fn trigger_autocomplete_suggestions(&mut self) {
|
||||||
|
/// self.activate_autocomplete(); // Show loading state
|
||||||
|
///
|
||||||
|
/// let query = self.get_current_input().to_string();
|
||||||
|
/// let suggestions = my_api.search(&query).await.unwrap_or_default();
|
||||||
|
///
|
||||||
|
/// self.set_autocomplete_suggestions(suggestions);
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
async fn trigger_autocomplete_suggestions(&mut self) {
|
||||||
|
// Activate autocomplete UI
|
||||||
|
self.activate_autocomplete();
|
||||||
|
|
||||||
|
// Default: just show loading state
|
||||||
|
// User should override this to do actual async fetching
|
||||||
|
self.set_autocomplete_loading(true);
|
||||||
|
|
||||||
|
// In a real implementation, you'd:
|
||||||
|
// 1. Get current input: let query = self.get_current_input();
|
||||||
|
// 2. Make API call: let results = api.search(query).await;
|
||||||
|
// 3. Convert to suggestions: let suggestions = results.into_suggestions();
|
||||||
|
// 4. Set suggestions: self.set_autocomplete_suggestions(suggestions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === INTERNAL NAVIGATION METHODS (called by library actions) ===
|
||||||
|
|
||||||
|
/// Clear autocomplete suggestions and hide dropdown
|
||||||
|
fn clear_autocomplete_suggestions(&mut self) {
|
||||||
|
self.deactivate_autocomplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move selection up/down in suggestions list
|
||||||
|
fn move_suggestion_selection(&mut self, direction: i32) {
|
||||||
|
if let Some(state) = self.autocomplete_state_mut() {
|
||||||
|
if direction > 0 {
|
||||||
|
state.select_next();
|
||||||
|
} else {
|
||||||
|
state.select_previous();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get currently selected suggestion for display/application
|
||||||
|
fn get_selected_suggestion(&self) -> Option<crate::autocomplete::SuggestionItem<Self::SuggestionData>> {
|
||||||
|
self.autocomplete_state()?
|
||||||
|
.get_selected()
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the selected suggestion to the current field
|
||||||
|
fn apply_suggestion(&mut self, suggestion: &crate::autocomplete::SuggestionItem<Self::SuggestionData>) {
|
||||||
|
// Apply the value to current field
|
||||||
|
*self.get_current_input_mut() = suggestion.value_to_store.clone();
|
||||||
|
self.set_has_unsaved_changes(true);
|
||||||
|
|
||||||
|
// Clear autocomplete
|
||||||
|
self.clear_autocomplete_suggestions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the currently selected suggestion (convenience method)
|
||||||
|
fn apply_selected_suggestion(&mut self) -> Option<String> {
|
||||||
|
if let Some(suggestion) = self.get_selected_suggestion() {
|
||||||
|
let display_text = suggestion.display_text.clone();
|
||||||
|
self.apply_suggestion(&suggestion);
|
||||||
|
Some(format!("Applied: {}", display_text))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === LEGACY COMPATIBILITY ===
|
||||||
|
|
||||||
|
/// INTERNAL: Apply selected autocomplete value to current field (legacy method)
|
||||||
|
fn apply_autocomplete_selection(&mut self) -> Option<String> {
|
||||||
|
self.apply_selected_suggestion()
|
||||||
|
}
|
||||||
|
}
|
||||||
126
canvas/src/autocomplete/types.rs
Normal file
126
canvas/src/autocomplete/types.rs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
// canvas/src/autocomplete.rs
|
||||||
|
|
||||||
|
/// Generic suggestion item that clients push to canvas
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SuggestionItem<T> {
|
||||||
|
/// The underlying data (client-specific, e.g., Hit, String, etc.)
|
||||||
|
pub data: T,
|
||||||
|
/// Text to display in the dropdown
|
||||||
|
pub display_text: String,
|
||||||
|
/// Value to store in the form field when selected
|
||||||
|
pub value_to_store: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> SuggestionItem<T> {
|
||||||
|
pub fn new(data: T, display_text: String, value_to_store: String) -> Self {
|
||||||
|
Self {
|
||||||
|
data,
|
||||||
|
display_text,
|
||||||
|
value_to_store,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience constructor for simple string suggestions
|
||||||
|
pub fn simple(data: T, text: String) -> Self {
|
||||||
|
Self {
|
||||||
|
data,
|
||||||
|
display_text: text.clone(),
|
||||||
|
value_to_store: text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Autocomplete state managed by canvas
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AutocompleteState<T> {
|
||||||
|
/// Whether autocomplete is currently active/visible
|
||||||
|
pub is_active: bool,
|
||||||
|
/// Whether suggestions are being loaded (for spinner/loading indicator)
|
||||||
|
pub is_loading: bool,
|
||||||
|
/// Current suggestions to display
|
||||||
|
pub suggestions: Vec<SuggestionItem<T>>,
|
||||||
|
/// Currently selected suggestion index
|
||||||
|
pub selected_index: Option<usize>,
|
||||||
|
/// Field index that triggered autocomplete (for context)
|
||||||
|
pub active_field: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Default for AutocompleteState<T> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
is_active: false,
|
||||||
|
is_loading: false,
|
||||||
|
suggestions: Vec::new(),
|
||||||
|
selected_index: None,
|
||||||
|
active_field: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> AutocompleteState<T> {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Activate autocomplete for a specific field
|
||||||
|
pub fn activate(&mut self, field_index: usize) {
|
||||||
|
self.is_active = true;
|
||||||
|
self.active_field = Some(field_index);
|
||||||
|
self.selected_index = None;
|
||||||
|
self.suggestions.clear();
|
||||||
|
self.is_loading = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deactivate autocomplete and clear state
|
||||||
|
pub fn deactivate(&mut self) {
|
||||||
|
self.is_active = false;
|
||||||
|
self.is_loading = false;
|
||||||
|
self.suggestions.clear();
|
||||||
|
self.selected_index = None;
|
||||||
|
self.active_field = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set suggestions and stop loading
|
||||||
|
pub fn set_suggestions(&mut self, suggestions: Vec<SuggestionItem<T>>) {
|
||||||
|
self.suggestions = suggestions;
|
||||||
|
self.is_loading = false;
|
||||||
|
self.selected_index = if self.suggestions.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move selection down
|
||||||
|
pub fn select_next(&mut self) {
|
||||||
|
if !self.suggestions.is_empty() {
|
||||||
|
let current = self.selected_index.unwrap_or(0);
|
||||||
|
self.selected_index = Some((current + 1) % self.suggestions.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move selection up
|
||||||
|
pub fn select_previous(&mut self) {
|
||||||
|
if !self.suggestions.is_empty() {
|
||||||
|
let current = self.selected_index.unwrap_or(0);
|
||||||
|
self.selected_index = Some(
|
||||||
|
if current == 0 {
|
||||||
|
self.suggestions.len() - 1
|
||||||
|
} else {
|
||||||
|
current - 1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get currently selected suggestion
|
||||||
|
pub fn get_selected(&self) -> Option<&SuggestionItem<T>> {
|
||||||
|
self.selected_index
|
||||||
|
.and_then(|idx| self.suggestions.get(idx))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if autocomplete is ready for interaction (active and has suggestions)
|
||||||
|
pub fn is_ready(&self) -> bool {
|
||||||
|
self.is_active && !self.suggestions.is_empty() && !self.is_loading
|
||||||
|
}
|
||||||
|
}
|
||||||
43
canvas/src/canvas/actions/handlers/dispatcher.rs
Normal file
43
canvas/src/canvas/actions/handlers/dispatcher.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// src/canvas/actions/handlers/dispatcher.rs
|
||||||
|
|
||||||
|
use crate::canvas::state::{CanvasState, ActionContext};
|
||||||
|
use crate::canvas::actions::{CanvasAction, ActionResult};
|
||||||
|
use crate::canvas::modes::AppMode;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use super::{handle_edit_action, handle_readonly_action, handle_highlight_action};
|
||||||
|
|
||||||
|
/// Main action dispatcher - routes actions to mode-specific handlers
|
||||||
|
pub async fn dispatch_action<S: CanvasState>(
|
||||||
|
action: CanvasAction,
|
||||||
|
state: &mut S,
|
||||||
|
ideal_cursor_column: &mut usize,
|
||||||
|
) -> Result<ActionResult> {
|
||||||
|
// Check if the application wants to handle this action first
|
||||||
|
let context = ActionContext {
|
||||||
|
key_code: None,
|
||||||
|
ideal_cursor_column: *ideal_cursor_column,
|
||||||
|
current_input: state.get_current_input().to_string(),
|
||||||
|
current_field: state.current_field(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(result) = state.handle_feature_action(&action, &context) {
|
||||||
|
return Ok(ActionResult::HandledByFeature(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route to mode-specific handler
|
||||||
|
match state.current_mode() {
|
||||||
|
AppMode::Edit => {
|
||||||
|
handle_edit_action(action, state, ideal_cursor_column).await
|
||||||
|
}
|
||||||
|
AppMode::ReadOnly => {
|
||||||
|
handle_readonly_action(action, state, ideal_cursor_column).await
|
||||||
|
}
|
||||||
|
AppMode::Highlight => {
|
||||||
|
handle_highlight_action(action, state, ideal_cursor_column).await
|
||||||
|
}
|
||||||
|
AppMode::General | AppMode::Command => {
|
||||||
|
Ok(ActionResult::success_with_message("Mode does not handle canvas actions directly"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
213
canvas/src/canvas/actions/handlers/edit.rs
Normal file
213
canvas/src/canvas/actions/handlers/edit.rs
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
// src/canvas/actions/handlers/edit.rs
|
||||||
|
//! Edit mode action handler
|
||||||
|
//!
|
||||||
|
//! Handles user input when in edit mode, supporting text entry, deletion,
|
||||||
|
//! and cursor movement with edit-specific behavior (cursor can go past end of text).
|
||||||
|
|
||||||
|
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
||||||
|
use crate::canvas::actions::movement::*;
|
||||||
|
use crate::canvas::state::CanvasState;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
/// Edit mode uses cursor-past-end behavior for text insertion
|
||||||
|
const FOR_EDIT_MODE: bool = true;
|
||||||
|
|
||||||
|
/// Handle actions in edit mode with edit-specific cursor behavior
|
||||||
|
///
|
||||||
|
/// Edit mode allows text modification and uses cursor positioning that can
|
||||||
|
/// go past the end of existing text to facilitate insertion.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `action` - The action to perform
|
||||||
|
/// * `state` - Mutable canvas state
|
||||||
|
/// * `ideal_cursor_column` - Desired column for vertical movement (maintained across line changes)
|
||||||
|
pub async fn handle_edit_action<S: CanvasState>(
|
||||||
|
action: CanvasAction,
|
||||||
|
state: &mut S,
|
||||||
|
ideal_cursor_column: &mut usize,
|
||||||
|
) -> Result<ActionResult> {
|
||||||
|
match action {
|
||||||
|
CanvasAction::InsertChar(c) => {
|
||||||
|
// Insert character at cursor position and advance cursor
|
||||||
|
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::DeleteBackward => {
|
||||||
|
// Delete character before cursor (Backspace behavior)
|
||||||
|
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 => {
|
||||||
|
// Delete character at cursor position (Delete key behavior)
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor movement actions
|
||||||
|
CanvasAction::MoveLeft => {
|
||||||
|
let new_pos = move_left(state.current_cursor_pos());
|
||||||
|
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();
|
||||||
|
let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE);
|
||||||
|
state.set_current_cursor_pos(new_pos);
|
||||||
|
*ideal_cursor_column = new_pos;
|
||||||
|
Ok(ActionResult::success())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field navigation (treating single-line fields as "lines")
|
||||||
|
CanvasAction::MoveUp => {
|
||||||
|
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 new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
||||||
|
state.set_current_cursor_pos(new_pos);
|
||||||
|
}
|
||||||
|
Ok(ActionResult::success())
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::MoveDown => {
|
||||||
|
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);
|
||||||
|
let current_input = state.get_current_input();
|
||||||
|
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
||||||
|
state.set_current_cursor_pos(new_pos);
|
||||||
|
}
|
||||||
|
Ok(ActionResult::success())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line-based movement
|
||||||
|
CanvasAction::MoveLineStart => {
|
||||||
|
let new_pos = line_start_position();
|
||||||
|
state.set_current_cursor_pos(new_pos);
|
||||||
|
*ideal_cursor_column = new_pos;
|
||||||
|
Ok(ActionResult::success())
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::MoveLineEnd => {
|
||||||
|
let current_input = state.get_current_input();
|
||||||
|
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
|
||||||
|
state.set_current_cursor_pos(new_pos);
|
||||||
|
*ideal_cursor_column = new_pos;
|
||||||
|
Ok(ActionResult::success())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document-level movement (first/last field)
|
||||||
|
CanvasAction::MoveFirstLine => {
|
||||||
|
state.set_current_field(0);
|
||||||
|
let current_input = state.get_current_input();
|
||||||
|
let new_pos = safe_cursor_position(current_input, 0, FOR_EDIT_MODE);
|
||||||
|
state.set_current_cursor_pos(new_pos);
|
||||||
|
*ideal_cursor_column = new_pos;
|
||||||
|
Ok(ActionResult::success())
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::MoveLastLine => {
|
||||||
|
let last_field = state.fields().len() - 1;
|
||||||
|
state.set_current_field(last_field);
|
||||||
|
let current_input = state.get_current_input();
|
||||||
|
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
|
||||||
|
state.set_current_cursor_pos(new_pos);
|
||||||
|
*ideal_cursor_column = new_pos;
|
||||||
|
Ok(ActionResult::success())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word-based movement
|
||||||
|
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::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())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field navigation with simple wrapping behavior
|
||||||
|
CanvasAction::NextField | CanvasAction::PrevField => {
|
||||||
|
let current_field = state.current_field();
|
||||||
|
let total_fields = state.fields().len();
|
||||||
|
|
||||||
|
let new_field = match action {
|
||||||
|
CanvasAction::NextField => {
|
||||||
|
(current_field + 1) % total_fields // Simple wrap
|
||||||
|
}
|
||||||
|
CanvasAction::PrevField => {
|
||||||
|
if current_field == 0 { total_fields - 1 } else { current_field - 1 } // Simple wrap
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
|
||||||
|
state.set_current_field(new_field);
|
||||||
|
let current_input = state.get_current_input();
|
||||||
|
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
||||||
|
state.set_current_cursor_pos(new_pos);
|
||||||
|
Ok(ActionResult::success())
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::Custom(action_str) => {
|
||||||
|
Ok(ActionResult::success_with_message(&format!("Custom edit action: {}", action_str)))
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {
|
||||||
|
Ok(ActionResult::success_with_message("Action not implemented for edit mode"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
canvas/src/canvas/actions/handlers/highlight.rs
Normal file
104
canvas/src/canvas/actions/handlers/highlight.rs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// src/canvas/actions/handlers/highlight.rs
|
||||||
|
|
||||||
|
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
||||||
|
use crate::canvas::actions::movement::*;
|
||||||
|
use crate::canvas::state::CanvasState;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
const FOR_EDIT_MODE: bool = false; // Highlight mode uses read-only cursor behavior
|
||||||
|
|
||||||
|
/// Handle actions in highlight/visual mode
|
||||||
|
/// TODO: Implement selection logic and highlight-specific behaviors
|
||||||
|
pub async fn handle_highlight_action<S: CanvasState>(
|
||||||
|
action: CanvasAction,
|
||||||
|
state: &mut S,
|
||||||
|
ideal_cursor_column: &mut usize,
|
||||||
|
) -> Result<ActionResult> {
|
||||||
|
match action {
|
||||||
|
// Movement actions work similar to read-only mode but with selection
|
||||||
|
CanvasAction::MoveLeft => {
|
||||||
|
let new_pos = move_left(state.current_cursor_pos());
|
||||||
|
state.set_current_cursor_pos(new_pos);
|
||||||
|
*ideal_cursor_column = new_pos;
|
||||||
|
// TODO: Update selection range
|
||||||
|
Ok(ActionResult::success())
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::MoveRight => {
|
||||||
|
let current_input = state.get_current_input();
|
||||||
|
let current_pos = state.current_cursor_pos();
|
||||||
|
let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE);
|
||||||
|
state.set_current_cursor_pos(new_pos);
|
||||||
|
*ideal_cursor_column = new_pos;
|
||||||
|
// TODO: Update selection range
|
||||||
|
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());
|
||||||
|
let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE);
|
||||||
|
state.set_current_cursor_pos(final_pos);
|
||||||
|
*ideal_cursor_column = final_pos;
|
||||||
|
// TODO: Update selection range
|
||||||
|
}
|
||||||
|
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());
|
||||||
|
let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE);
|
||||||
|
state.set_current_cursor_pos(final_pos);
|
||||||
|
*ideal_cursor_column = final_pos;
|
||||||
|
// TODO: Update selection range
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
// TODO: Update selection range
|
||||||
|
}
|
||||||
|
Ok(ActionResult::success())
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::MoveLineStart => {
|
||||||
|
let new_pos = line_start_position();
|
||||||
|
state.set_current_cursor_pos(new_pos);
|
||||||
|
*ideal_cursor_column = new_pos;
|
||||||
|
// TODO: Update selection range
|
||||||
|
Ok(ActionResult::success())
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::MoveLineEnd => {
|
||||||
|
let current_input = state.get_current_input();
|
||||||
|
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
|
||||||
|
state.set_current_cursor_pos(new_pos);
|
||||||
|
*ideal_cursor_column = new_pos;
|
||||||
|
// TODO: Update selection range
|
||||||
|
Ok(ActionResult::success())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight mode doesn't handle editing actions
|
||||||
|
CanvasAction::InsertChar(_) |
|
||||||
|
CanvasAction::DeleteBackward |
|
||||||
|
CanvasAction::DeleteForward => {
|
||||||
|
Ok(ActionResult::success_with_message("Action not available in highlight mode"))
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::Custom(action_str) => {
|
||||||
|
Ok(ActionResult::success_with_message(&format!("Custom highlight action: {}", action_str)))
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {
|
||||||
|
Ok(ActionResult::success_with_message("Action not implemented for highlight mode"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
canvas/src/canvas/actions/handlers/mod.rs
Normal file
11
canvas/src/canvas/actions/handlers/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// src/canvas/actions/handlers/mod.rs
|
||||||
|
|
||||||
|
pub mod edit;
|
||||||
|
pub mod readonly;
|
||||||
|
pub mod highlight;
|
||||||
|
pub mod dispatcher;
|
||||||
|
|
||||||
|
pub use edit::handle_edit_action;
|
||||||
|
pub use readonly::handle_readonly_action;
|
||||||
|
pub use highlight::handle_highlight_action;
|
||||||
|
pub use dispatcher::dispatch_action;
|
||||||
183
canvas/src/canvas/actions/handlers/readonly.rs
Normal file
183
canvas/src/canvas/actions/handlers/readonly.rs
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
// src/canvas/actions/handlers/readonly.rs
|
||||||
|
|
||||||
|
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
||||||
|
use crate::canvas::actions::movement::*;
|
||||||
|
use crate::canvas::state::CanvasState;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
const FOR_EDIT_MODE: bool = false; // Read-only mode flag
|
||||||
|
|
||||||
|
/// Handle actions in read-only mode with read-only specific cursor behavior
|
||||||
|
pub async fn handle_readonly_action<S: CanvasState>(
|
||||||
|
action: CanvasAction,
|
||||||
|
state: &mut S,
|
||||||
|
ideal_cursor_column: &mut usize,
|
||||||
|
) -> Result<ActionResult> {
|
||||||
|
match action {
|
||||||
|
CanvasAction::MoveLeft => {
|
||||||
|
let new_pos = move_left(state.current_cursor_pos());
|
||||||
|
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();
|
||||||
|
let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE);
|
||||||
|
state.set_current_cursor_pos(new_pos);
|
||||||
|
*ideal_cursor_column = new_pos;
|
||||||
|
Ok(ActionResult::success())
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::MoveUp => {
|
||||||
|
let current_field = state.current_field();
|
||||||
|
let new_field = current_field.saturating_sub(1);
|
||||||
|
state.set_current_field(new_field);
|
||||||
|
|
||||||
|
// Apply ideal cursor column with read-only bounds
|
||||||
|
let current_input = state.get_current_input();
|
||||||
|
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
||||||
|
state.set_current_cursor_pos(new_pos);
|
||||||
|
Ok(ActionResult::success())
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::MoveDown => {
|
||||||
|
let current_field = state.current_field();
|
||||||
|
let total_fields = state.fields().len();
|
||||||
|
if total_fields == 0 {
|
||||||
|
return Ok(ActionResult::success_with_message("No fields to navigate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_field = (current_field + 1).min(total_fields - 1);
|
||||||
|
state.set_current_field(new_field);
|
||||||
|
|
||||||
|
// Apply ideal cursor column with read-only bounds
|
||||||
|
let current_input = state.get_current_input();
|
||||||
|
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
||||||
|
state.set_current_cursor_pos(new_pos);
|
||||||
|
Ok(ActionResult::success())
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::MoveFirstLine => {
|
||||||
|
let total_fields = state.fields().len();
|
||||||
|
if total_fields == 0 {
|
||||||
|
return Ok(ActionResult::success_with_message("No fields to navigate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
state.set_current_field(0);
|
||||||
|
let current_input = state.get_current_input();
|
||||||
|
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
||||||
|
state.set_current_cursor_pos(new_pos);
|
||||||
|
*ideal_cursor_column = new_pos;
|
||||||
|
Ok(ActionResult::success())
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::MoveLastLine => {
|
||||||
|
let total_fields = state.fields().len();
|
||||||
|
if total_fields == 0 {
|
||||||
|
return Ok(ActionResult::success_with_message("No fields to navigate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let last_field = total_fields - 1;
|
||||||
|
state.set_current_field(last_field);
|
||||||
|
let current_input = state.get_current_input();
|
||||||
|
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
||||||
|
state.set_current_cursor_pos(new_pos);
|
||||||
|
*ideal_cursor_column = new_pos;
|
||||||
|
Ok(ActionResult::success())
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::MoveLineStart => {
|
||||||
|
let new_pos = line_start_position();
|
||||||
|
state.set_current_cursor_pos(new_pos);
|
||||||
|
*ideal_cursor_column = new_pos;
|
||||||
|
Ok(ActionResult::success())
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::MoveLineEnd => {
|
||||||
|
let current_input = state.get_current_input();
|
||||||
|
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
|
||||||
|
state.set_current_cursor_pos(new_pos);
|
||||||
|
*ideal_cursor_column = new_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());
|
||||||
|
let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE);
|
||||||
|
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 = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE);
|
||||||
|
state.set_current_cursor_pos(final_pos);
|
||||||
|
*ideal_cursor_column = final_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())
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::NextField | CanvasAction::PrevField => {
|
||||||
|
let current_field = state.current_field();
|
||||||
|
let total_fields = state.fields().len();
|
||||||
|
|
||||||
|
let new_field = match action {
|
||||||
|
CanvasAction::NextField => {
|
||||||
|
(current_field + 1) % total_fields // Simple wrap
|
||||||
|
}
|
||||||
|
CanvasAction::PrevField => {
|
||||||
|
if current_field == 0 { total_fields - 1 } else { current_field - 1 } // Simple wrap
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
|
||||||
|
state.set_current_field(new_field);
|
||||||
|
*ideal_cursor_column = state.current_cursor_pos();
|
||||||
|
Ok(ActionResult::success())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-only mode doesn't handle editing actions
|
||||||
|
CanvasAction::InsertChar(_) |
|
||||||
|
CanvasAction::DeleteBackward |
|
||||||
|
CanvasAction::DeleteForward => {
|
||||||
|
Ok(ActionResult::success_with_message("Action not available in read-only mode"))
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::Custom(action_str) => {
|
||||||
|
Ok(ActionResult::success_with_message(&format!("Custom readonly action: {}", action_str)))
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {
|
||||||
|
Ok(ActionResult::success_with_message("Action not implemented for read-only mode"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
canvas/src/canvas/actions/mod.rs
Normal file
8
canvas/src/canvas/actions/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// src/canvas/actions/mod.rs
|
||||||
|
|
||||||
|
pub mod types;
|
||||||
|
pub mod handlers;
|
||||||
|
pub mod movement;
|
||||||
|
|
||||||
|
// Re-export the main API
|
||||||
|
pub use types::{CanvasAction, ActionResult, execute};
|
||||||
49
canvas/src/canvas/actions/movement/char.rs
Normal file
49
canvas/src/canvas/actions/movement/char.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// src/canvas/actions/movement/char.rs
|
||||||
|
|
||||||
|
/// Calculate new position when moving left
|
||||||
|
pub fn move_left(current_pos: usize) -> usize {
|
||||||
|
current_pos.saturating_sub(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate new position when moving right
|
||||||
|
pub fn move_right(current_pos: usize, text: &str, for_edit_mode: bool) -> usize {
|
||||||
|
if text.is_empty() {
|
||||||
|
return current_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
if for_edit_mode {
|
||||||
|
// Edit mode: can move past end of text
|
||||||
|
(current_pos + 1).min(text.len())
|
||||||
|
} else {
|
||||||
|
// Read-only/highlight mode: stays within text bounds
|
||||||
|
if current_pos < text.len().saturating_sub(1) {
|
||||||
|
current_pos + 1
|
||||||
|
} else {
|
||||||
|
current_pos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if cursor position is valid for the given mode
|
||||||
|
pub fn is_valid_cursor_position(pos: usize, text: &str, for_edit_mode: bool) -> bool {
|
||||||
|
if text.is_empty() {
|
||||||
|
return pos == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if for_edit_mode {
|
||||||
|
pos <= text.len()
|
||||||
|
} else {
|
||||||
|
pos < text.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamp cursor position to valid bounds for the given mode
|
||||||
|
pub fn clamp_cursor_position(pos: usize, text: &str, for_edit_mode: bool) -> usize {
|
||||||
|
if text.is_empty() {
|
||||||
|
0
|
||||||
|
} else if for_edit_mode {
|
||||||
|
pos.min(text.len())
|
||||||
|
} else {
|
||||||
|
pos.min(text.len().saturating_sub(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
32
canvas/src/canvas/actions/movement/line.rs
Normal file
32
canvas/src/canvas/actions/movement/line.rs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// src/canvas/actions/movement/line.rs
|
||||||
|
|
||||||
|
/// Calculate cursor position for line start
|
||||||
|
pub fn line_start_position() -> usize {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate cursor position for line end
|
||||||
|
pub fn line_end_position(text: &str, for_edit_mode: bool) -> usize {
|
||||||
|
if text.is_empty() {
|
||||||
|
0
|
||||||
|
} else if for_edit_mode {
|
||||||
|
// Edit mode: cursor can go past end of text
|
||||||
|
text.len()
|
||||||
|
} else {
|
||||||
|
// Read-only/highlight mode: cursor stays on last character
|
||||||
|
text.len().saturating_sub(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate safe cursor position when switching fields
|
||||||
|
pub fn safe_cursor_position(text: &str, ideal_column: usize, for_edit_mode: bool) -> usize {
|
||||||
|
if text.is_empty() {
|
||||||
|
0
|
||||||
|
} else if for_edit_mode {
|
||||||
|
// Edit mode: cursor can go past end
|
||||||
|
ideal_column.min(text.len())
|
||||||
|
} else {
|
||||||
|
// Read-only/highlight mode: cursor stays within text
|
||||||
|
ideal_column.min(text.len().saturating_sub(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
10
canvas/src/canvas/actions/movement/mod.rs
Normal file
10
canvas/src/canvas/actions/movement/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// src/canvas/actions/movement/mod.rs
|
||||||
|
|
||||||
|
pub mod word;
|
||||||
|
pub mod line;
|
||||||
|
pub mod char;
|
||||||
|
|
||||||
|
// Re-export commonly used functions
|
||||||
|
pub use word::{find_next_word_start, find_word_end, find_prev_word_start, find_prev_word_end};
|
||||||
|
pub use line::{line_start_position, line_end_position, safe_cursor_position};
|
||||||
|
pub use char::{move_left, move_right, is_valid_cursor_position, clamp_cursor_position};
|
||||||
146
canvas/src/canvas/actions/movement/word.rs
Normal file
146
canvas/src/canvas/actions/movement/word.rs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
// src/canvas/actions/movement/word.rs
|
||||||
|
|
||||||
|
#[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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the start of the next word from the current position
|
||||||
|
pub 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]);
|
||||||
|
|
||||||
|
// Skip current word/token
|
||||||
|
while pos < chars.len() && get_char_type(chars[pos]) == initial_type {
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip whitespace
|
||||||
|
while pos < chars.len() && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pos
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the end of the current or next word
|
||||||
|
pub 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 we're not on whitespace, move to end of current word
|
||||||
|
if current_type != CharType::Whitespace {
|
||||||
|
while pos < len && get_char_type(chars[pos]) == current_type {
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
return pos.saturating_sub(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're on whitespace, find next word and go to its end
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the start of the previous word
|
||||||
|
pub 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);
|
||||||
|
|
||||||
|
// Skip whitespace backwards
|
||||||
|
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||||
|
pos -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to start of word
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the end of the previous word
|
||||||
|
pub 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);
|
||||||
|
|
||||||
|
// Skip whitespace backwards
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip whitespace before this word
|
||||||
|
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
|
||||||
|
pos -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if pos > 0 {
|
||||||
|
pos - 1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
184
canvas/src/canvas/actions/types.rs
Normal file
184
canvas/src/canvas/actions/types.rs
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
// src/canvas/actions/types.rs
|
||||||
|
|
||||||
|
use crate::canvas::state::CanvasState;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
/// All available canvas actions
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum CanvasAction {
|
||||||
|
// Movement actions
|
||||||
|
MoveLeft,
|
||||||
|
MoveRight,
|
||||||
|
MoveUp,
|
||||||
|
MoveDown,
|
||||||
|
|
||||||
|
// Word movement
|
||||||
|
MoveWordNext,
|
||||||
|
MoveWordPrev,
|
||||||
|
MoveWordEnd,
|
||||||
|
MoveWordEndPrev,
|
||||||
|
|
||||||
|
// Line movement
|
||||||
|
MoveLineStart,
|
||||||
|
MoveLineEnd,
|
||||||
|
|
||||||
|
// Field movement
|
||||||
|
NextField,
|
||||||
|
PrevField,
|
||||||
|
MoveFirstLine,
|
||||||
|
MoveLastLine,
|
||||||
|
|
||||||
|
// Editing actions
|
||||||
|
InsertChar(char),
|
||||||
|
DeleteBackward,
|
||||||
|
DeleteForward,
|
||||||
|
|
||||||
|
// Autocomplete actions
|
||||||
|
TriggerAutocomplete,
|
||||||
|
SuggestionUp,
|
||||||
|
SuggestionDown,
|
||||||
|
SelectSuggestion,
|
||||||
|
ExitSuggestions,
|
||||||
|
|
||||||
|
// Custom actions
|
||||||
|
Custom(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result type for canvas actions
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ActionResult {
|
||||||
|
Success,
|
||||||
|
Message(String),
|
||||||
|
HandledByApp(String),
|
||||||
|
HandledByFeature(String), // Keep for compatibility
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionResult {
|
||||||
|
pub fn success() -> Self {
|
||||||
|
Self::Success
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn success_with_message(msg: &str) -> Self {
|
||||||
|
Self::Message(msg.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handled_by_app(msg: &str) -> Self {
|
||||||
|
Self::HandledByApp(msg.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error(msg: &str) -> Self {
|
||||||
|
Self::Error(msg.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_success(&self) -> bool {
|
||||||
|
matches!(self, Self::Success | Self::Message(_) | Self::HandledByApp(_) | Self::HandledByFeature(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn message(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
Self::Message(msg) | Self::HandledByApp(msg) | Self::HandledByFeature(msg) | Self::Error(msg) => Some(msg),
|
||||||
|
Self::Success => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a canvas action on the given state
|
||||||
|
pub async fn execute<S: CanvasState>(
|
||||||
|
action: CanvasAction,
|
||||||
|
state: &mut S,
|
||||||
|
) -> Result<ActionResult> {
|
||||||
|
let mut ideal_cursor_column = 0;
|
||||||
|
|
||||||
|
super::handlers::dispatch_action(action, state, &mut ideal_cursor_column).await
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CanvasAction {
|
||||||
|
/// Get a human-readable description of this action
|
||||||
|
pub fn description(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::MoveLeft => "move left",
|
||||||
|
Self::MoveRight => "move right",
|
||||||
|
Self::MoveUp => "move up",
|
||||||
|
Self::MoveDown => "move down",
|
||||||
|
Self::MoveWordNext => "next word",
|
||||||
|
Self::MoveWordPrev => "previous word",
|
||||||
|
Self::MoveWordEnd => "word end",
|
||||||
|
Self::MoveWordEndPrev => "previous word end",
|
||||||
|
Self::MoveLineStart => "line start",
|
||||||
|
Self::MoveLineEnd => "line end",
|
||||||
|
Self::NextField => "next field",
|
||||||
|
Self::PrevField => "previous field",
|
||||||
|
Self::MoveFirstLine => "first field",
|
||||||
|
Self::MoveLastLine => "last field",
|
||||||
|
Self::InsertChar(c) => "insert character",
|
||||||
|
Self::DeleteBackward => "delete backward",
|
||||||
|
Self::DeleteForward => "delete forward",
|
||||||
|
Self::TriggerAutocomplete => "trigger autocomplete",
|
||||||
|
Self::SuggestionUp => "suggestion up",
|
||||||
|
Self::SuggestionDown => "suggestion down",
|
||||||
|
Self::SelectSuggestion => "select suggestion",
|
||||||
|
Self::ExitSuggestions => "exit suggestions",
|
||||||
|
Self::Custom(name) => "custom action",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all movement-related actions
|
||||||
|
pub fn movement_actions() -> Vec<CanvasAction> {
|
||||||
|
vec![
|
||||||
|
Self::MoveLeft,
|
||||||
|
Self::MoveRight,
|
||||||
|
Self::MoveUp,
|
||||||
|
Self::MoveDown,
|
||||||
|
Self::MoveWordNext,
|
||||||
|
Self::MoveWordPrev,
|
||||||
|
Self::MoveWordEnd,
|
||||||
|
Self::MoveWordEndPrev,
|
||||||
|
Self::MoveLineStart,
|
||||||
|
Self::MoveLineEnd,
|
||||||
|
Self::NextField,
|
||||||
|
Self::PrevField,
|
||||||
|
Self::MoveFirstLine,
|
||||||
|
Self::MoveLastLine,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all editing-related actions
|
||||||
|
pub fn editing_actions() -> Vec<CanvasAction> {
|
||||||
|
vec![
|
||||||
|
Self::InsertChar(' '), // Example char
|
||||||
|
Self::DeleteBackward,
|
||||||
|
Self::DeleteForward,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all autocomplete-related actions
|
||||||
|
pub fn autocomplete_actions() -> Vec<CanvasAction> {
|
||||||
|
vec![
|
||||||
|
Self::TriggerAutocomplete,
|
||||||
|
Self::SuggestionUp,
|
||||||
|
Self::SuggestionDown,
|
||||||
|
Self::SelectSuggestion,
|
||||||
|
Self::ExitSuggestions,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this action modifies text content
|
||||||
|
pub fn is_editing_action(&self) -> bool {
|
||||||
|
matches!(self,
|
||||||
|
Self::InsertChar(_) |
|
||||||
|
Self::DeleteBackward |
|
||||||
|
Self::DeleteForward
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this action moves the cursor
|
||||||
|
pub fn is_movement_action(&self) -> bool {
|
||||||
|
matches!(self,
|
||||||
|
Self::MoveLeft | Self::MoveRight | Self::MoveUp | Self::MoveDown |
|
||||||
|
Self::MoveWordNext | Self::MoveWordPrev | Self::MoveWordEnd | Self::MoveWordEndPrev |
|
||||||
|
Self::MoveLineStart | Self::MoveLineEnd | Self::NextField | Self::PrevField |
|
||||||
|
Self::MoveFirstLine | Self::MoveLastLine
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
338
canvas/src/canvas/gui.rs
Normal file
338
canvas/src/canvas/gui.rs
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
// canvas/src/canvas/gui.rs
|
||||||
|
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, BorderType, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::canvas::state::CanvasState;
|
||||||
|
use crate::canvas::modes::HighlightState;
|
||||||
|
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
use crate::canvas::theme::CanvasTheme;
|
||||||
|
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
use std::cmp::{max, min};
|
||||||
|
|
||||||
|
/// Render ONLY the canvas form fields - no autocomplete
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub fn render_canvas<T: CanvasTheme>(
|
||||||
|
f: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
form_state: &impl CanvasState,
|
||||||
|
theme: &T,
|
||||||
|
is_edit_mode: bool,
|
||||||
|
highlight_state: &HighlightState,
|
||||||
|
) -> Option<Rect> {
|
||||||
|
let fields: Vec<&str> = form_state.fields();
|
||||||
|
let current_field_idx = form_state.current_field();
|
||||||
|
let inputs: Vec<&String> = form_state.inputs();
|
||||||
|
|
||||||
|
render_canvas_fields(
|
||||||
|
f,
|
||||||
|
area,
|
||||||
|
&fields,
|
||||||
|
¤t_field_idx,
|
||||||
|
&inputs,
|
||||||
|
theme,
|
||||||
|
is_edit_mode,
|
||||||
|
highlight_state,
|
||||||
|
form_state.current_cursor_pos(),
|
||||||
|
form_state.has_unsaved_changes(),
|
||||||
|
|i| form_state.get_display_value_for_field(i).to_string(),
|
||||||
|
|i| form_state.has_display_override(i),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core canvas field rendering
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
fn render_canvas_fields<T: CanvasTheme, F1, F2>(
|
||||||
|
f: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
fields: &[&str],
|
||||||
|
current_field_idx: &usize,
|
||||||
|
inputs: &[&String],
|
||||||
|
theme: &T,
|
||||||
|
is_edit_mode: bool,
|
||||||
|
highlight_state: &HighlightState,
|
||||||
|
current_cursor_pos: usize,
|
||||||
|
has_unsaved_changes: bool,
|
||||||
|
get_display_value: F1,
|
||||||
|
has_display_override: F2,
|
||||||
|
) -> Option<Rect>
|
||||||
|
where
|
||||||
|
F1: Fn(usize) -> String,
|
||||||
|
F2: Fn(usize) -> bool,
|
||||||
|
{
|
||||||
|
// Create layout
|
||||||
|
let columns = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Border style based on state
|
||||||
|
let border_style = if has_unsaved_changes {
|
||||||
|
Style::default().fg(theme.warning())
|
||||||
|
} else if is_edit_mode {
|
||||||
|
Style::default().fg(theme.accent())
|
||||||
|
} else {
|
||||||
|
Style::default().fg(theme.secondary())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Input container
|
||||||
|
let input_container = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
|
.border_style(border_style)
|
||||||
|
.style(Style::default().bg(theme.bg()));
|
||||||
|
|
||||||
|
let input_block = Rect {
|
||||||
|
x: columns[1].x,
|
||||||
|
y: columns[1].y,
|
||||||
|
width: columns[1].width,
|
||||||
|
height: fields.len() as u16 + 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
f.render_widget(&input_container, input_block);
|
||||||
|
|
||||||
|
// Input area layout
|
||||||
|
let input_area = input_container.inner(input_block);
|
||||||
|
let input_rows = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints(vec![Constraint::Length(1); fields.len()])
|
||||||
|
.split(input_area);
|
||||||
|
|
||||||
|
// Render field labels
|
||||||
|
render_field_labels(f, columns[0], input_block, fields, theme);
|
||||||
|
|
||||||
|
// Render field values and return active field rect
|
||||||
|
render_field_values(
|
||||||
|
f,
|
||||||
|
input_rows.to_vec(), // Fix: Convert Rc<[Rect]> to Vec<Rect>
|
||||||
|
inputs,
|
||||||
|
current_field_idx,
|
||||||
|
theme,
|
||||||
|
highlight_state,
|
||||||
|
current_cursor_pos,
|
||||||
|
get_display_value,
|
||||||
|
has_display_override,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render field labels
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
fn render_field_labels<T: CanvasTheme>(
|
||||||
|
f: &mut Frame,
|
||||||
|
label_area: Rect,
|
||||||
|
input_block: Rect,
|
||||||
|
fields: &[&str],
|
||||||
|
theme: &T,
|
||||||
|
) {
|
||||||
|
for (i, field) in fields.iter().enumerate() {
|
||||||
|
let label = Paragraph::new(Line::from(Span::styled(
|
||||||
|
format!("{}:", field),
|
||||||
|
Style::default().fg(theme.fg()),
|
||||||
|
)));
|
||||||
|
f.render_widget(
|
||||||
|
label,
|
||||||
|
Rect {
|
||||||
|
x: label_area.x,
|
||||||
|
y: input_block.y + 1 + i as u16,
|
||||||
|
width: label_area.width,
|
||||||
|
height: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render field values with highlighting
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
fn render_field_values<T: CanvasTheme, F1, F2>(
|
||||||
|
f: &mut Frame,
|
||||||
|
input_rows: Vec<Rect>,
|
||||||
|
inputs: &[&String],
|
||||||
|
current_field_idx: &usize,
|
||||||
|
theme: &T,
|
||||||
|
highlight_state: &HighlightState,
|
||||||
|
current_cursor_pos: usize,
|
||||||
|
get_display_value: F1,
|
||||||
|
has_display_override: F2,
|
||||||
|
) -> Option<Rect>
|
||||||
|
where
|
||||||
|
F1: Fn(usize) -> String,
|
||||||
|
F2: Fn(usize) -> bool,
|
||||||
|
{
|
||||||
|
let mut active_field_input_rect = None;
|
||||||
|
|
||||||
|
for (i, _input) in inputs.iter().enumerate() {
|
||||||
|
let is_active = i == *current_field_idx;
|
||||||
|
let text = get_display_value(i);
|
||||||
|
|
||||||
|
// Apply highlighting
|
||||||
|
let line = apply_highlighting(
|
||||||
|
&text,
|
||||||
|
i,
|
||||||
|
current_field_idx,
|
||||||
|
current_cursor_pos,
|
||||||
|
highlight_state,
|
||||||
|
theme,
|
||||||
|
is_active,
|
||||||
|
);
|
||||||
|
|
||||||
|
let input_display = Paragraph::new(line).alignment(Alignment::Left);
|
||||||
|
f.render_widget(input_display, input_rows[i]);
|
||||||
|
|
||||||
|
// Set cursor for active field
|
||||||
|
if is_active {
|
||||||
|
active_field_input_rect = Some(input_rows[i]);
|
||||||
|
set_cursor_position(f, input_rows[i], &text, current_cursor_pos, has_display_override(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
active_field_input_rect
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply highlighting based on highlight state
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
fn apply_highlighting<'a, T: CanvasTheme>(
|
||||||
|
text: &'a str,
|
||||||
|
field_index: usize,
|
||||||
|
current_field_idx: &usize,
|
||||||
|
current_cursor_pos: usize,
|
||||||
|
highlight_state: &HighlightState,
|
||||||
|
theme: &T,
|
||||||
|
is_active: bool,
|
||||||
|
) -> Line<'a> {
|
||||||
|
let text_len = text.chars().count();
|
||||||
|
|
||||||
|
match highlight_state {
|
||||||
|
HighlightState::Off => {
|
||||||
|
Line::from(Span::styled(
|
||||||
|
text,
|
||||||
|
if is_active {
|
||||||
|
Style::default().fg(theme.highlight())
|
||||||
|
} else {
|
||||||
|
Style::default().fg(theme.fg())
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
HighlightState::Characterwise { anchor } => {
|
||||||
|
apply_characterwise_highlighting(text, text_len, field_index, current_field_idx, current_cursor_pos, anchor, theme, is_active)
|
||||||
|
}
|
||||||
|
HighlightState::Linewise { anchor_line } => {
|
||||||
|
apply_linewise_highlighting(text, field_index, current_field_idx, anchor_line, theme, is_active)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply characterwise highlighting
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
fn apply_characterwise_highlighting<'a, T: CanvasTheme>(
|
||||||
|
text: &'a str,
|
||||||
|
text_len: usize,
|
||||||
|
field_index: usize,
|
||||||
|
current_field_idx: &usize,
|
||||||
|
current_cursor_pos: usize,
|
||||||
|
anchor: &(usize, usize),
|
||||||
|
theme: &T,
|
||||||
|
is_active: bool,
|
||||||
|
) -> Line<'a> {
|
||||||
|
let (anchor_field, anchor_char) = *anchor;
|
||||||
|
let start_field = min(anchor_field, *current_field_idx);
|
||||||
|
let end_field = max(anchor_field, *current_field_idx);
|
||||||
|
|
||||||
|
let highlight_style = Style::default()
|
||||||
|
.fg(theme.highlight())
|
||||||
|
.bg(theme.highlight_bg())
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
let normal_style_in_highlight = Style::default().fg(theme.highlight());
|
||||||
|
let normal_style_outside = Style::default().fg(theme.fg());
|
||||||
|
|
||||||
|
if field_index >= start_field && field_index <= end_field {
|
||||||
|
if start_field == end_field {
|
||||||
|
let (start_char, end_char) = if anchor_field == *current_field_idx {
|
||||||
|
(min(anchor_char, current_cursor_pos), max(anchor_char, current_cursor_pos))
|
||||||
|
} else if anchor_field < *current_field_idx {
|
||||||
|
(anchor_char, current_cursor_pos)
|
||||||
|
} else {
|
||||||
|
(current_cursor_pos, anchor_char)
|
||||||
|
};
|
||||||
|
|
||||||
|
let clamped_start = start_char.min(text_len);
|
||||||
|
let clamped_end = end_char.min(text_len);
|
||||||
|
|
||||||
|
let before: String = text.chars().take(clamped_start).collect();
|
||||||
|
let highlighted: String = text.chars()
|
||||||
|
.skip(clamped_start)
|
||||||
|
.take(clamped_end.saturating_sub(clamped_start) + 1)
|
||||||
|
.collect();
|
||||||
|
let after: String = text.chars().skip(clamped_end + 1).collect();
|
||||||
|
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(before, normal_style_in_highlight),
|
||||||
|
Span::styled(highlighted, highlight_style),
|
||||||
|
Span::styled(after, normal_style_in_highlight),
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
// Multi-field selection
|
||||||
|
Line::from(Span::styled(text, highlight_style))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Line::from(Span::styled(
|
||||||
|
text,
|
||||||
|
if is_active { normal_style_in_highlight } else { normal_style_outside }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply linewise highlighting
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
fn apply_linewise_highlighting<'a, T: CanvasTheme>(
|
||||||
|
text: &'a str,
|
||||||
|
field_index: usize,
|
||||||
|
current_field_idx: &usize,
|
||||||
|
anchor_line: &usize,
|
||||||
|
theme: &T,
|
||||||
|
is_active: bool,
|
||||||
|
) -> Line<'a> {
|
||||||
|
let start_field = min(*anchor_line, *current_field_idx);
|
||||||
|
let end_field = max(*anchor_line, *current_field_idx);
|
||||||
|
|
||||||
|
let highlight_style = Style::default()
|
||||||
|
.fg(theme.highlight())
|
||||||
|
.bg(theme.highlight_bg())
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
let normal_style_in_highlight = Style::default().fg(theme.highlight());
|
||||||
|
let normal_style_outside = Style::default().fg(theme.fg());
|
||||||
|
|
||||||
|
if field_index >= start_field && field_index <= end_field {
|
||||||
|
Line::from(Span::styled(text, highlight_style))
|
||||||
|
} else {
|
||||||
|
Line::from(Span::styled(
|
||||||
|
text,
|
||||||
|
if is_active { normal_style_in_highlight } else { normal_style_outside }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set cursor position
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
fn set_cursor_position(
|
||||||
|
f: &mut Frame,
|
||||||
|
field_rect: Rect,
|
||||||
|
text: &str,
|
||||||
|
current_cursor_pos: usize,
|
||||||
|
has_display_override: bool,
|
||||||
|
) {
|
||||||
|
let cursor_x = if has_display_override {
|
||||||
|
field_rect.x + text.chars().count() as u16
|
||||||
|
} else {
|
||||||
|
field_rect.x + current_cursor_pos as u16
|
||||||
|
};
|
||||||
|
let cursor_y = field_rect.y;
|
||||||
|
f.set_cursor_position((cursor_x, cursor_y));
|
||||||
|
}
|
||||||
18
canvas/src/canvas/mod.rs
Normal file
18
canvas/src/canvas/mod.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// src/canvas/mod.rs
|
||||||
|
|
||||||
|
pub mod actions;
|
||||||
|
pub mod gui;
|
||||||
|
pub mod modes;
|
||||||
|
pub mod state;
|
||||||
|
pub mod theme;
|
||||||
|
|
||||||
|
// Re-export main types for convenience
|
||||||
|
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;
|
||||||
117
canvas/src/canvas/state.rs
Normal file
117
canvas/src/canvas/state.rs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// src/canvas/state.rs
|
||||||
|
//! Canvas state trait and related types
|
||||||
|
//!
|
||||||
|
//! This module defines the core trait that any form or input system must implement
|
||||||
|
//! to work with the canvas library.
|
||||||
|
|
||||||
|
use crate::canvas::actions::CanvasAction;
|
||||||
|
use crate::canvas::modes::AppMode;
|
||||||
|
|
||||||
|
/// Context information passed to feature-specific action handlers
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ActionContext {
|
||||||
|
/// Original key code that triggered this action (for backwards compatibility)
|
||||||
|
pub key_code: Option<crossterm::event::KeyCode>,
|
||||||
|
/// Current ideal cursor column for vertical movement
|
||||||
|
pub ideal_cursor_column: usize,
|
||||||
|
/// Current input text
|
||||||
|
pub current_input: String,
|
||||||
|
/// Current field index
|
||||||
|
pub current_field: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core trait that any form-like state must implement to work with canvas
|
||||||
|
///
|
||||||
|
/// This trait enables the same mode behaviors (edit, read-only, highlight) to work
|
||||||
|
/// across any implementation - login forms, data entry forms, configuration screens, etc.
|
||||||
|
///
|
||||||
|
/// # Required Implementation
|
||||||
|
///
|
||||||
|
/// Your struct needs to track:
|
||||||
|
/// - Current field index and cursor position
|
||||||
|
/// - All input field values
|
||||||
|
/// - Current interaction mode
|
||||||
|
/// - Whether there are unsaved changes
|
||||||
|
///
|
||||||
|
/// # Example Implementation
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// struct MyForm {
|
||||||
|
/// fields: Vec<String>,
|
||||||
|
/// current_field: usize,
|
||||||
|
/// cursor_pos: usize,
|
||||||
|
/// mode: AppMode,
|
||||||
|
/// dirty: bool,
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// impl CanvasState for MyForm {
|
||||||
|
/// fn current_field(&self) -> usize { self.current_field }
|
||||||
|
/// fn current_cursor_pos(&self) -> usize { self.cursor_pos }
|
||||||
|
/// // ... implement other required methods
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub trait CanvasState {
|
||||||
|
// --- Core Navigation ---
|
||||||
|
|
||||||
|
/// Get current field index (0-based)
|
||||||
|
fn current_field(&self) -> usize;
|
||||||
|
|
||||||
|
/// Get current cursor position within the current field
|
||||||
|
fn current_cursor_pos(&self) -> usize;
|
||||||
|
|
||||||
|
/// Set current field index (should clamp to valid range)
|
||||||
|
fn set_current_field(&mut self, index: usize);
|
||||||
|
|
||||||
|
/// Set cursor position within current field (should clamp to valid range)
|
||||||
|
fn set_current_cursor_pos(&mut self, pos: usize);
|
||||||
|
|
||||||
|
// --- Mode Information ---
|
||||||
|
|
||||||
|
/// Get current interaction mode (edit, read-only, highlight, etc.)
|
||||||
|
fn current_mode(&self) -> AppMode;
|
||||||
|
|
||||||
|
// --- Data Access ---
|
||||||
|
|
||||||
|
/// Get immutable reference to current field's text
|
||||||
|
fn get_current_input(&self) -> &str;
|
||||||
|
|
||||||
|
/// Get mutable reference to current field's text
|
||||||
|
fn get_current_input_mut(&mut self) -> &mut String;
|
||||||
|
|
||||||
|
/// Get all input values as immutable references
|
||||||
|
fn inputs(&self) -> Vec<&String>;
|
||||||
|
|
||||||
|
/// Get all field names/labels
|
||||||
|
fn fields(&self) -> Vec<&str>;
|
||||||
|
|
||||||
|
// --- State Management ---
|
||||||
|
|
||||||
|
/// Check if there are unsaved changes
|
||||||
|
fn has_unsaved_changes(&self) -> bool;
|
||||||
|
|
||||||
|
/// Mark whether there are unsaved changes
|
||||||
|
fn set_has_unsaved_changes(&mut self, changed: bool);
|
||||||
|
|
||||||
|
// --- Optional Overrides ---
|
||||||
|
|
||||||
|
/// Handle application-specific actions not covered by standard handlers
|
||||||
|
/// Return Some(message) if the action was handled, None to use standard handling
|
||||||
|
fn handle_feature_action(&mut self, _action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||||
|
None // Default: no custom handling
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get display value for a field (may differ from actual value)
|
||||||
|
/// Used for things like password masking or computed display values
|
||||||
|
fn get_display_value_for_field(&self, index: usize) -> &str {
|
||||||
|
self.inputs()
|
||||||
|
.get(index)
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a field has a custom display value
|
||||||
|
/// Return true if get_display_value_for_field returns something different than the actual value
|
||||||
|
fn has_display_override(&self, _index: usize) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
17
canvas/src/canvas/theme.rs
Normal file
17
canvas/src/canvas/theme.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// canvas/src/gui/theme.rs
|
||||||
|
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
use ratatui::style::Color;
|
||||||
|
|
||||||
|
/// Theme trait that must be implemented by applications using the canvas GUI
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub trait CanvasTheme {
|
||||||
|
fn bg(&self) -> Color;
|
||||||
|
fn fg(&self) -> Color;
|
||||||
|
fn border(&self) -> Color;
|
||||||
|
fn accent(&self) -> Color;
|
||||||
|
fn secondary(&self) -> Color;
|
||||||
|
fn highlight(&self) -> Color;
|
||||||
|
fn highlight_bg(&self) -> Color;
|
||||||
|
fn warning(&self) -> Color;
|
||||||
|
}
|
||||||
@@ -1,480 +0,0 @@
|
|||||||
// canvas/src/config.rs
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use crossterm::event::{KeyCode, KeyModifiers};
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct CanvasConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub keybindings: CanvasKeybindings,
|
|
||||||
#[serde(default)]
|
|
||||||
pub behavior: CanvasBehavior,
|
|
||||||
#[serde(default)]
|
|
||||||
pub appearance: CanvasAppearance,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct CanvasKeybindings {
|
|
||||||
#[serde(default)]
|
|
||||||
pub read_only: HashMap<String, Vec<String>>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub edit: HashMap<String, Vec<String>>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub suggestions: HashMap<String, Vec<String>>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub global: HashMap<String, Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct CanvasBehavior {
|
|
||||||
#[serde(default = "default_wrap_around")]
|
|
||||||
pub wrap_around_fields: bool,
|
|
||||||
#[serde(default = "default_auto_save")]
|
|
||||||
pub auto_save_on_field_change: bool,
|
|
||||||
#[serde(default = "default_word_chars")]
|
|
||||||
pub word_chars: String,
|
|
||||||
#[serde(default = "default_suggestion_limit")]
|
|
||||||
pub max_suggestions: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct CanvasAppearance {
|
|
||||||
#[serde(default = "default_cursor_style")]
|
|
||||||
pub cursor_style: String, // "block", "bar", "underline"
|
|
||||||
#[serde(default = "default_show_field_numbers")]
|
|
||||||
pub show_field_numbers: bool,
|
|
||||||
#[serde(default = "default_highlight_current_field")]
|
|
||||||
pub highlight_current_field: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default values
|
|
||||||
fn default_wrap_around() -> bool { true }
|
|
||||||
fn default_auto_save() -> bool { false }
|
|
||||||
fn default_word_chars() -> String { "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_".to_string() }
|
|
||||||
fn default_suggestion_limit() -> usize { 10 }
|
|
||||||
fn default_cursor_style() -> String { "block".to_string() }
|
|
||||||
fn default_show_field_numbers() -> bool { false }
|
|
||||||
fn default_highlight_current_field() -> bool { true }
|
|
||||||
|
|
||||||
impl Default for CanvasBehavior {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
wrap_around_fields: default_wrap_around(),
|
|
||||||
auto_save_on_field_change: default_auto_save(),
|
|
||||||
word_chars: default_word_chars(),
|
|
||||||
max_suggestions: default_suggestion_limit(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for CanvasAppearance {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
cursor_style: default_cursor_style(),
|
|
||||||
show_field_numbers: default_show_field_numbers(),
|
|
||||||
highlight_current_field: default_highlight_current_field(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for CanvasConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
keybindings: CanvasKeybindings::with_vim_defaults(),
|
|
||||||
behavior: CanvasBehavior::default(),
|
|
||||||
appearance: CanvasAppearance::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CanvasKeybindings {
|
|
||||||
pub fn with_vim_defaults() -> Self {
|
|
||||||
let mut keybindings = Self::default();
|
|
||||||
|
|
||||||
// Read-only mode (vim-style navigation)
|
|
||||||
keybindings.read_only.insert("move_left".to_string(), vec!["h".to_string()]);
|
|
||||||
keybindings.read_only.insert("move_right".to_string(), vec!["l".to_string()]);
|
|
||||||
keybindings.read_only.insert("move_up".to_string(), vec!["k".to_string()]);
|
|
||||||
keybindings.read_only.insert("move_down".to_string(), vec!["j".to_string()]);
|
|
||||||
keybindings.read_only.insert("move_word_next".to_string(), vec!["w".to_string()]);
|
|
||||||
keybindings.read_only.insert("move_word_end".to_string(), vec!["e".to_string()]);
|
|
||||||
keybindings.read_only.insert("move_word_prev".to_string(), vec!["b".to_string()]);
|
|
||||||
keybindings.read_only.insert("move_word_end_prev".to_string(), vec!["ge".to_string()]);
|
|
||||||
keybindings.read_only.insert("move_line_start".to_string(), vec!["0".to_string()]);
|
|
||||||
keybindings.read_only.insert("move_line_end".to_string(), vec!["$".to_string()]);
|
|
||||||
keybindings.read_only.insert("move_first_line".to_string(), vec!["gg".to_string()]);
|
|
||||||
keybindings.read_only.insert("move_last_line".to_string(), vec!["G".to_string()]);
|
|
||||||
keybindings.read_only.insert("next_field".to_string(), vec!["Tab".to_string()]);
|
|
||||||
keybindings.read_only.insert("prev_field".to_string(), vec!["Shift+Tab".to_string()]);
|
|
||||||
|
|
||||||
// Edit mode
|
|
||||||
keybindings.edit.insert("delete_char_backward".to_string(), vec!["Backspace".to_string()]);
|
|
||||||
keybindings.edit.insert("delete_char_forward".to_string(), vec!["Delete".to_string()]);
|
|
||||||
keybindings.edit.insert("move_left".to_string(), vec!["Left".to_string()]);
|
|
||||||
keybindings.edit.insert("move_right".to_string(), vec!["Right".to_string()]);
|
|
||||||
keybindings.edit.insert("move_up".to_string(), vec!["Up".to_string()]);
|
|
||||||
keybindings.edit.insert("move_down".to_string(), vec!["Down".to_string()]);
|
|
||||||
keybindings.edit.insert("move_line_start".to_string(), vec!["Home".to_string()]);
|
|
||||||
keybindings.edit.insert("move_line_end".to_string(), vec!["End".to_string()]);
|
|
||||||
keybindings.edit.insert("move_word_next".to_string(), vec!["Ctrl+Right".to_string()]);
|
|
||||||
keybindings.edit.insert("move_word_prev".to_string(), vec!["Ctrl+Left".to_string()]);
|
|
||||||
keybindings.edit.insert("next_field".to_string(), vec!["Tab".to_string()]);
|
|
||||||
keybindings.edit.insert("prev_field".to_string(), vec!["Shift+Tab".to_string()]);
|
|
||||||
|
|
||||||
// Suggestions
|
|
||||||
keybindings.suggestions.insert("suggestion_up".to_string(), vec!["Up".to_string(), "Ctrl+p".to_string()]);
|
|
||||||
keybindings.suggestions.insert("suggestion_down".to_string(), vec!["Down".to_string(), "Ctrl+n".to_string()]);
|
|
||||||
keybindings.suggestions.insert("select_suggestion".to_string(), vec!["Enter".to_string(), "Tab".to_string()]);
|
|
||||||
keybindings.suggestions.insert("exit_suggestions".to_string(), vec!["Esc".to_string()]);
|
|
||||||
|
|
||||||
// Global (works in both modes)
|
|
||||||
keybindings.global.insert("move_up".to_string(), vec!["Up".to_string()]);
|
|
||||||
keybindings.global.insert("move_down".to_string(), vec!["Down".to_string()]);
|
|
||||||
|
|
||||||
keybindings
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_emacs_defaults() -> Self {
|
|
||||||
let mut keybindings = Self::default();
|
|
||||||
|
|
||||||
// Emacs-style bindings
|
|
||||||
keybindings.read_only.insert("move_left".to_string(), vec!["Ctrl+b".to_string()]);
|
|
||||||
keybindings.read_only.insert("move_right".to_string(), vec!["Ctrl+f".to_string()]);
|
|
||||||
keybindings.read_only.insert("move_up".to_string(), vec!["Ctrl+p".to_string()]);
|
|
||||||
keybindings.read_only.insert("move_down".to_string(), vec!["Ctrl+n".to_string()]);
|
|
||||||
keybindings.read_only.insert("move_word_next".to_string(), vec!["Alt+f".to_string()]);
|
|
||||||
keybindings.read_only.insert("move_word_prev".to_string(), vec!["Alt+b".to_string()]);
|
|
||||||
keybindings.read_only.insert("move_line_start".to_string(), vec!["Ctrl+a".to_string()]);
|
|
||||||
keybindings.read_only.insert("move_line_end".to_string(), vec!["Ctrl+e".to_string()]);
|
|
||||||
|
|
||||||
keybindings.edit.insert("delete_char_backward".to_string(), vec!["Ctrl+h".to_string(), "Backspace".to_string()]);
|
|
||||||
keybindings.edit.insert("delete_char_forward".to_string(), vec!["Ctrl+d".to_string(), "Delete".to_string()]);
|
|
||||||
|
|
||||||
keybindings
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CanvasConfig {
|
|
||||||
/// Load from canvas_config.toml or fallback to vim defaults
|
|
||||||
pub fn load() -> Self {
|
|
||||||
// Try to load canvas_config.toml from current directory
|
|
||||||
if let Ok(config) = Self::from_file(std::path::Path::new("canvas_config.toml")) {
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to vim defaults
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load from TOML string
|
|
||||||
pub fn from_toml(toml_str: &str) -> Result<Self> {
|
|
||||||
toml::from_str(toml_str)
|
|
||||||
.with_context(|| "Failed to parse canvas config TOML")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load from file
|
|
||||||
pub fn from_file(path: &std::path::Path) -> Result<Self> {
|
|
||||||
let contents = std::fs::read_to_string(path)
|
|
||||||
.with_context(|| format!("Failed to read config file: {:?}", path))?;
|
|
||||||
Self::from_toml(&contents)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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)
|
|
||||||
.or_else(|| self.get_action_in_mode(&self.keybindings.global, key, modifiers))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get action for key in edit mode
|
|
||||||
pub fn get_edit_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
|
||||||
self.get_action_in_mode(&self.keybindings.edit, key, modifiers)
|
|
||||||
.or_else(|| self.get_action_in_mode(&self.keybindings.global, key, modifiers))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get action for key in suggestions mode
|
|
||||||
pub fn get_suggestion_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
|
||||||
self.get_action_in_mode(&self.keybindings.suggestions, key, modifiers)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get action for key (mode-aware)
|
|
||||||
pub fn get_action_for_key(&self, key: KeyCode, modifiers: KeyModifiers, is_edit_mode: bool, has_suggestions: bool) -> Option<&str> {
|
|
||||||
// Suggestions take priority when active
|
|
||||||
if has_suggestions {
|
|
||||||
if let Some(action) = self.get_suggestion_action(key, modifiers) {
|
|
||||||
return Some(action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then check mode-specific
|
|
||||||
if is_edit_mode {
|
|
||||||
self.get_edit_action(key, modifiers)
|
|
||||||
} else {
|
|
||||||
self.get_read_only_action(key, modifiers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_action_in_mode<'a>(&self, mode_bindings: &'a HashMap<String, Vec<String>>, key: KeyCode, modifiers: KeyModifiers) -> Option<&'a str> {
|
|
||||||
for (action, bindings) in mode_bindings {
|
|
||||||
for binding in bindings {
|
|
||||||
if self.matches_keybinding(binding, key, modifiers) {
|
|
||||||
return Some(action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn matches_keybinding(&self, binding: &str, key: KeyCode, modifiers: KeyModifiers) -> bool {
|
|
||||||
// Special handling for shift+character combinations
|
|
||||||
if binding.to_lowercase().starts_with("shift+") {
|
|
||||||
let parts: Vec<&str> = binding.split('+').collect();
|
|
||||||
if parts.len() == 2 && parts[1].len() == 1 {
|
|
||||||
let expected_lowercase = parts[1].chars().next().unwrap().to_lowercase().next().unwrap();
|
|
||||||
let expected_uppercase = expected_lowercase.to_uppercase().next().unwrap();
|
|
||||||
if let KeyCode::Char(actual_char) = key {
|
|
||||||
if actual_char == expected_uppercase && modifiers.contains(KeyModifiers::SHIFT) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Shift+Tab -> BackTab
|
|
||||||
if binding.to_lowercase() == "shift+tab" && key == KeyCode::BackTab && modifiers.is_empty() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle multi-character bindings (all standard keys without modifiers)
|
|
||||||
if binding.len() > 1 && !binding.contains('+') {
|
|
||||||
return match binding.to_lowercase().as_str() {
|
|
||||||
// Navigation keys
|
|
||||||
"left" => key == KeyCode::Left,
|
|
||||||
"right" => key == KeyCode::Right,
|
|
||||||
"up" => key == KeyCode::Up,
|
|
||||||
"down" => key == KeyCode::Down,
|
|
||||||
"home" => key == KeyCode::Home,
|
|
||||||
"end" => key == KeyCode::End,
|
|
||||||
"pageup" | "pgup" => key == KeyCode::PageUp,
|
|
||||||
"pagedown" | "pgdn" => key == KeyCode::PageDown,
|
|
||||||
|
|
||||||
// Editing keys
|
|
||||||
"insert" | "ins" => key == KeyCode::Insert,
|
|
||||||
"delete" | "del" => key == KeyCode::Delete,
|
|
||||||
"backspace" => key == KeyCode::Backspace,
|
|
||||||
|
|
||||||
// Tab keys
|
|
||||||
"tab" => key == KeyCode::Tab,
|
|
||||||
"backtab" => key == KeyCode::BackTab,
|
|
||||||
|
|
||||||
// Special keys
|
|
||||||
"enter" | "return" => key == KeyCode::Enter,
|
|
||||||
"escape" | "esc" => key == KeyCode::Esc,
|
|
||||||
"space" => key == KeyCode::Char(' '),
|
|
||||||
|
|
||||||
// Function keys F1-F24
|
|
||||||
"f1" => key == KeyCode::F(1),
|
|
||||||
"f2" => key == KeyCode::F(2),
|
|
||||||
"f3" => key == KeyCode::F(3),
|
|
||||||
"f4" => key == KeyCode::F(4),
|
|
||||||
"f5" => key == KeyCode::F(5),
|
|
||||||
"f6" => key == KeyCode::F(6),
|
|
||||||
"f7" => key == KeyCode::F(7),
|
|
||||||
"f8" => key == KeyCode::F(8),
|
|
||||||
"f9" => key == KeyCode::F(9),
|
|
||||||
"f10" => key == KeyCode::F(10),
|
|
||||||
"f11" => key == KeyCode::F(11),
|
|
||||||
"f12" => key == KeyCode::F(12),
|
|
||||||
"f13" => key == KeyCode::F(13),
|
|
||||||
"f14" => key == KeyCode::F(14),
|
|
||||||
"f15" => key == KeyCode::F(15),
|
|
||||||
"f16" => key == KeyCode::F(16),
|
|
||||||
"f17" => key == KeyCode::F(17),
|
|
||||||
"f18" => key == KeyCode::F(18),
|
|
||||||
"f19" => key == KeyCode::F(19),
|
|
||||||
"f20" => key == KeyCode::F(20),
|
|
||||||
"f21" => key == KeyCode::F(21),
|
|
||||||
"f22" => key == KeyCode::F(22),
|
|
||||||
"f23" => key == KeyCode::F(23),
|
|
||||||
"f24" => key == KeyCode::F(24),
|
|
||||||
|
|
||||||
// Lock keys (may not work reliably in all terminals)
|
|
||||||
"capslock" => key == KeyCode::CapsLock,
|
|
||||||
"scrolllock" => key == KeyCode::ScrollLock,
|
|
||||||
"numlock" => key == KeyCode::NumLock,
|
|
||||||
|
|
||||||
// System keys
|
|
||||||
"printscreen" => key == KeyCode::PrintScreen,
|
|
||||||
"pause" => key == KeyCode::Pause,
|
|
||||||
"menu" => key == KeyCode::Menu,
|
|
||||||
"keypadbegin" => key == KeyCode::KeypadBegin,
|
|
||||||
|
|
||||||
// Media keys (rarely supported but included for completeness)
|
|
||||||
"mediaplay" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Play),
|
|
||||||
"mediapause" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Pause),
|
|
||||||
"mediaplaypause" => key == KeyCode::Media(crossterm::event::MediaKeyCode::PlayPause),
|
|
||||||
"mediareverse" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Reverse),
|
|
||||||
"mediastop" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Stop),
|
|
||||||
"mediafastforward" => key == KeyCode::Media(crossterm::event::MediaKeyCode::FastForward),
|
|
||||||
"mediarewind" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Rewind),
|
|
||||||
"mediatracknext" => key == KeyCode::Media(crossterm::event::MediaKeyCode::TrackNext),
|
|
||||||
"mediatrackprevious" => key == KeyCode::Media(crossterm::event::MediaKeyCode::TrackPrevious),
|
|
||||||
"mediarecord" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Record),
|
|
||||||
"medialowervolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::LowerVolume),
|
|
||||||
"mediaraisevolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::RaiseVolume),
|
|
||||||
"mediamutevolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::MuteVolume),
|
|
||||||
|
|
||||||
// Modifier keys (these work better as part of combinations)
|
|
||||||
"leftshift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftShift),
|
|
||||||
"leftcontrol" | "leftctrl" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftControl),
|
|
||||||
"leftalt" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftAlt),
|
|
||||||
"leftsuper" | "leftwindows" | "leftcmd" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftSuper),
|
|
||||||
"lefthyper" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftHyper),
|
|
||||||
"leftmeta" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftMeta),
|
|
||||||
"rightshift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightShift),
|
|
||||||
"rightcontrol" | "rightctrl" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightControl),
|
|
||||||
"rightalt" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightAlt),
|
|
||||||
"rightsuper" | "rightwindows" | "rightcmd" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightSuper),
|
|
||||||
"righthyper" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightHyper),
|
|
||||||
"rightmeta" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightMeta),
|
|
||||||
"isolevel3shift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::IsoLevel3Shift),
|
|
||||||
"isolevel5shift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::IsoLevel5Shift),
|
|
||||||
|
|
||||||
// Multi-key sequences need special handling
|
|
||||||
"gg" => false, // This needs sequence handling
|
|
||||||
_ => {
|
|
||||||
// Handle single characters and punctuation
|
|
||||||
if binding.len() == 1 {
|
|
||||||
if let Some(c) = binding.chars().next() {
|
|
||||||
key == KeyCode::Char(c)
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle modifier combinations (like "Ctrl+F5", "Alt+Shift+A")
|
|
||||||
let parts: Vec<&str> = binding.split('+').collect();
|
|
||||||
let mut expected_modifiers = KeyModifiers::empty();
|
|
||||||
let mut expected_key = None;
|
|
||||||
|
|
||||||
for part in parts {
|
|
||||||
match part.to_lowercase().as_str() {
|
|
||||||
// Modifiers
|
|
||||||
"ctrl" | "control" => expected_modifiers |= KeyModifiers::CONTROL,
|
|
||||||
"shift" => expected_modifiers |= KeyModifiers::SHIFT,
|
|
||||||
"alt" => expected_modifiers |= KeyModifiers::ALT,
|
|
||||||
"super" | "windows" | "cmd" => expected_modifiers |= KeyModifiers::SUPER,
|
|
||||||
"hyper" => expected_modifiers |= KeyModifiers::HYPER,
|
|
||||||
"meta" => expected_modifiers |= KeyModifiers::META,
|
|
||||||
|
|
||||||
// Navigation keys
|
|
||||||
"left" => expected_key = Some(KeyCode::Left),
|
|
||||||
"right" => expected_key = Some(KeyCode::Right),
|
|
||||||
"up" => expected_key = Some(KeyCode::Up),
|
|
||||||
"down" => expected_key = Some(KeyCode::Down),
|
|
||||||
"home" => expected_key = Some(KeyCode::Home),
|
|
||||||
"end" => expected_key = Some(KeyCode::End),
|
|
||||||
"pageup" | "pgup" => expected_key = Some(KeyCode::PageUp),
|
|
||||||
"pagedown" | "pgdn" => expected_key = Some(KeyCode::PageDown),
|
|
||||||
|
|
||||||
// Editing keys
|
|
||||||
"insert" | "ins" => expected_key = Some(KeyCode::Insert),
|
|
||||||
"delete" | "del" => expected_key = Some(KeyCode::Delete),
|
|
||||||
"backspace" => expected_key = Some(KeyCode::Backspace),
|
|
||||||
|
|
||||||
// Tab keys
|
|
||||||
"tab" => expected_key = Some(KeyCode::Tab),
|
|
||||||
"backtab" => expected_key = Some(KeyCode::BackTab),
|
|
||||||
|
|
||||||
// Special keys
|
|
||||||
"enter" | "return" => expected_key = Some(KeyCode::Enter),
|
|
||||||
"escape" | "esc" => expected_key = Some(KeyCode::Esc),
|
|
||||||
"space" => expected_key = Some(KeyCode::Char(' ')),
|
|
||||||
|
|
||||||
// Function keys
|
|
||||||
"f1" => expected_key = Some(KeyCode::F(1)),
|
|
||||||
"f2" => expected_key = Some(KeyCode::F(2)),
|
|
||||||
"f3" => expected_key = Some(KeyCode::F(3)),
|
|
||||||
"f4" => expected_key = Some(KeyCode::F(4)),
|
|
||||||
"f5" => expected_key = Some(KeyCode::F(5)),
|
|
||||||
"f6" => expected_key = Some(KeyCode::F(6)),
|
|
||||||
"f7" => expected_key = Some(KeyCode::F(7)),
|
|
||||||
"f8" => expected_key = Some(KeyCode::F(8)),
|
|
||||||
"f9" => expected_key = Some(KeyCode::F(9)),
|
|
||||||
"f10" => expected_key = Some(KeyCode::F(10)),
|
|
||||||
"f11" => expected_key = Some(KeyCode::F(11)),
|
|
||||||
"f12" => expected_key = Some(KeyCode::F(12)),
|
|
||||||
"f13" => expected_key = Some(KeyCode::F(13)),
|
|
||||||
"f14" => expected_key = Some(KeyCode::F(14)),
|
|
||||||
"f15" => expected_key = Some(KeyCode::F(15)),
|
|
||||||
"f16" => expected_key = Some(KeyCode::F(16)),
|
|
||||||
"f17" => expected_key = Some(KeyCode::F(17)),
|
|
||||||
"f18" => expected_key = Some(KeyCode::F(18)),
|
|
||||||
"f19" => expected_key = Some(KeyCode::F(19)),
|
|
||||||
"f20" => expected_key = Some(KeyCode::F(20)),
|
|
||||||
"f21" => expected_key = Some(KeyCode::F(21)),
|
|
||||||
"f22" => expected_key = Some(KeyCode::F(22)),
|
|
||||||
"f23" => expected_key = Some(KeyCode::F(23)),
|
|
||||||
"f24" => expected_key = Some(KeyCode::F(24)),
|
|
||||||
|
|
||||||
// Lock keys
|
|
||||||
"capslock" => expected_key = Some(KeyCode::CapsLock),
|
|
||||||
"scrolllock" => expected_key = Some(KeyCode::ScrollLock),
|
|
||||||
"numlock" => expected_key = Some(KeyCode::NumLock),
|
|
||||||
|
|
||||||
// System keys
|
|
||||||
"printscreen" => expected_key = Some(KeyCode::PrintScreen),
|
|
||||||
"pause" => expected_key = Some(KeyCode::Pause),
|
|
||||||
"menu" => expected_key = Some(KeyCode::Menu),
|
|
||||||
"keypadbegin" => expected_key = Some(KeyCode::KeypadBegin),
|
|
||||||
|
|
||||||
// Single character (letters, numbers, punctuation)
|
|
||||||
part => {
|
|
||||||
if part.len() == 1 {
|
|
||||||
if let Some(c) = part.chars().next() {
|
|
||||||
expected_key = Some(KeyCode::Char(c));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
modifiers == expected_modifiers && Some(key) == expected_key
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convenience method to create vim preset
|
|
||||||
pub fn vim_preset() -> Self {
|
|
||||||
Self {
|
|
||||||
keybindings: CanvasKeybindings::with_vim_defaults(),
|
|
||||||
behavior: CanvasBehavior::default(),
|
|
||||||
appearance: CanvasAppearance::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convenience method to create emacs preset
|
|
||||||
pub fn emacs_preset() -> Self {
|
|
||||||
Self {
|
|
||||||
keybindings: CanvasKeybindings::with_emacs_defaults(),
|
|
||||||
behavior: CanvasBehavior::default(),
|
|
||||||
appearance: CanvasAppearance::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Debug method to print loaded keybindings
|
|
||||||
pub fn debug_keybindings(&self) {
|
|
||||||
println!("📋 Canvas keybindings loaded:");
|
|
||||||
println!(" Read-only: {} actions", self.keybindings.read_only.len());
|
|
||||||
println!(" Edit: {} actions", self.keybindings.edit.len());
|
|
||||||
println!(" Suggestions: {} actions", self.keybindings.suggestions.len());
|
|
||||||
println!(" Global: {} actions", self.keybindings.global.len());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export for convenience
|
|
||||||
pub use crate::actions::CanvasAction;
|
|
||||||
pub use crate::dispatcher::ActionDispatcher;
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
// canvas/src/dispatcher.rs
|
|
||||||
|
|
||||||
use crate::state::CanvasState;
|
|
||||||
use crate::actions::{CanvasAction, ActionResult, execute_canvas_action};
|
|
||||||
|
|
||||||
/// High-level action dispatcher that coordinates between different action types
|
|
||||||
pub struct ActionDispatcher;
|
|
||||||
|
|
||||||
impl ActionDispatcher {
|
|
||||||
/// Dispatch any action to the appropriate handler
|
|
||||||
pub async fn dispatch<S: CanvasState>(
|
|
||||||
action: CanvasAction,
|
|
||||||
state: &mut S,
|
|
||||||
ideal_cursor_column: &mut usize,
|
|
||||||
) -> anyhow::Result<ActionResult> {
|
|
||||||
execute_canvas_action(action, state, ideal_cursor_column).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Quick action dispatch from KeyCode
|
|
||||||
pub async fn dispatch_key<S: CanvasState>(
|
|
||||||
key: crossterm::event::KeyCode,
|
|
||||||
state: &mut S,
|
|
||||||
ideal_cursor_column: &mut usize,
|
|
||||||
) -> anyhow::Result<Option<ActionResult>> {
|
|
||||||
if let Some(action) = CanvasAction::from_key(key) {
|
|
||||||
let result = Self::dispatch(action, state, ideal_cursor_column).await?;
|
|
||||||
Ok(Some(result))
|
|
||||||
} else {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Batch dispatch multiple actions
|
|
||||||
pub async fn dispatch_batch<S: CanvasState>(
|
|
||||||
actions: Vec<CanvasAction>,
|
|
||||||
state: &mut S,
|
|
||||||
ideal_cursor_column: &mut usize,
|
|
||||||
) -> anyhow::Result<Vec<ActionResult>> {
|
|
||||||
let mut results = Vec::new();
|
|
||||||
for action in actions {
|
|
||||||
let result = Self::dispatch(action, state, ideal_cursor_column).await?;
|
|
||||||
let is_success = result.is_success(); // Check success before moving
|
|
||||||
results.push(result);
|
|
||||||
|
|
||||||
// Stop on first error
|
|
||||||
if !is_success {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(results)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::actions::CanvasAction;
|
|
||||||
|
|
||||||
// Simple test implementation
|
|
||||||
struct TestFormState {
|
|
||||||
current_field: usize,
|
|
||||||
cursor_pos: usize,
|
|
||||||
inputs: Vec<String>,
|
|
||||||
field_names: Vec<String>,
|
|
||||||
has_changes: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TestFormState {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
current_field: 0,
|
|
||||||
cursor_pos: 0,
|
|
||||||
inputs: vec!["".to_string(), "".to_string()],
|
|
||||||
field_names: vec!["username".to_string(), "password".to_string()],
|
|
||||||
has_changes: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CanvasState for TestFormState {
|
|
||||||
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.inputs[self.current_field] }
|
|
||||||
fn get_current_input_mut(&mut self) -> &mut String { &mut self.inputs[self.current_field] }
|
|
||||||
fn inputs(&self) -> Vec<&String> { self.inputs.iter().collect() }
|
|
||||||
fn fields(&self) -> Vec<&str> { self.field_names.iter().map(|s| s.as_str()).collect() }
|
|
||||||
|
|
||||||
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 for testing
|
|
||||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &crate::state::ActionContext) -> Option<String> {
|
|
||||||
match action {
|
|
||||||
CanvasAction::Custom(s) if s == "test_custom" => {
|
|
||||||
Some("Custom action handled".to_string())
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_typed_action_dispatch() {
|
|
||||||
let mut state = TestFormState::new();
|
|
||||||
let mut ideal_cursor = 0;
|
|
||||||
|
|
||||||
// Test character insertion
|
|
||||||
let result = ActionDispatcher::dispatch(
|
|
||||||
CanvasAction::InsertChar('a'),
|
|
||||||
&mut state,
|
|
||||||
&mut ideal_cursor,
|
|
||||||
).await.unwrap();
|
|
||||||
|
|
||||||
assert!(result.is_success());
|
|
||||||
assert_eq!(state.get_current_input(), "a");
|
|
||||||
assert_eq!(state.cursor_pos, 1);
|
|
||||||
assert!(state.has_changes);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_key_dispatch() {
|
|
||||||
let mut state = TestFormState::new();
|
|
||||||
let mut ideal_cursor = 0;
|
|
||||||
|
|
||||||
let result = ActionDispatcher::dispatch_key(
|
|
||||||
crossterm::event::KeyCode::Char('b'),
|
|
||||||
&mut state,
|
|
||||||
&mut ideal_cursor,
|
|
||||||
).await.unwrap();
|
|
||||||
|
|
||||||
assert!(result.is_some());
|
|
||||||
assert!(result.unwrap().is_success());
|
|
||||||
assert_eq!(state.get_current_input(), "b");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_custom_action() {
|
|
||||||
let mut state = TestFormState::new();
|
|
||||||
let mut ideal_cursor = 0;
|
|
||||||
|
|
||||||
let result = ActionDispatcher::dispatch(
|
|
||||||
CanvasAction::Custom("test_custom".to_string()),
|
|
||||||
&mut state,
|
|
||||||
&mut ideal_cursor,
|
|
||||||
).await.unwrap();
|
|
||||||
|
|
||||||
match result {
|
|
||||||
ActionResult::HandledByFeature(msg) => {
|
|
||||||
assert_eq!(msg, "Custom action handled");
|
|
||||||
}
|
|
||||||
_ => panic!("Expected HandledByFeature result"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_batch_dispatch() {
|
|
||||||
let mut state = TestFormState::new();
|
|
||||||
let mut ideal_cursor = 0;
|
|
||||||
|
|
||||||
let actions = vec![
|
|
||||||
CanvasAction::InsertChar('h'),
|
|
||||||
CanvasAction::InsertChar('i'),
|
|
||||||
CanvasAction::MoveLeft,
|
|
||||||
CanvasAction::InsertChar('e'),
|
|
||||||
];
|
|
||||||
|
|
||||||
let results = ActionDispatcher::dispatch_batch(
|
|
||||||
actions,
|
|
||||||
&mut state,
|
|
||||||
&mut ideal_cursor,
|
|
||||||
).await.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(results.len(), 4);
|
|
||||||
assert!(results.iter().all(|r| r.is_success()));
|
|
||||||
assert_eq!(state.get_current_input(), "hei");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +1,31 @@
|
|||||||
// canvas/src/lib.rs
|
// 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 canvas;
|
||||||
pub mod actions;
|
|
||||||
pub mod modes;
|
|
||||||
pub mod config;
|
|
||||||
pub mod suggestions;
|
|
||||||
pub mod dispatcher;
|
|
||||||
|
|
||||||
// Re-export the main types for easy use
|
// Only include autocomplete module if feature is enabled
|
||||||
pub use state::{CanvasState, ActionContext};
|
#[cfg(feature = "autocomplete")]
|
||||||
pub use actions::{CanvasAction, ActionResult, execute_edit_action, execute_canvas_action};
|
pub mod autocomplete;
|
||||||
pub use modes::{AppMode, ModeManager, HighlightState};
|
|
||||||
pub use suggestions::SuggestionState;
|
|
||||||
pub use dispatcher::ActionDispatcher;
|
|
||||||
|
|
||||||
// High-level convenience API
|
// Re-export the main API for easy access
|
||||||
pub mod prelude {
|
pub use canvas::actions::{CanvasAction, ActionResult, execute};
|
||||||
pub use crate::{
|
pub use canvas::state::{CanvasState, ActionContext};
|
||||||
CanvasState,
|
pub use canvas::modes::{AppMode, ModeManager, HighlightState};
|
||||||
ActionContext,
|
|
||||||
CanvasAction,
|
#[cfg(feature = "gui")]
|
||||||
ActionResult,
|
pub use canvas::theme::CanvasTheme;
|
||||||
execute_edit_action,
|
|
||||||
execute_canvas_action,
|
#[cfg(feature = "gui")]
|
||||||
ActionDispatcher,
|
pub use canvas::gui::render_canvas;
|
||||||
AppMode,
|
|
||||||
ModeManager,
|
// Re-export autocomplete API if feature is enabled
|
||||||
HighlightState,
|
#[cfg(feature = "autocomplete")]
|
||||||
SuggestionState,
|
pub use autocomplete::{
|
||||||
};
|
AutocompleteCanvasState,
|
||||||
}
|
AutocompleteState,
|
||||||
|
SuggestionItem,
|
||||||
|
execute_with_autocomplete,
|
||||||
|
handle_autocomplete_feature_action,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(all(feature = "gui", feature = "autocomplete"))]
|
||||||
|
pub use autocomplete::render_autocomplete_dropdown;
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
// canvas/src/state.rs
|
|
||||||
|
|
||||||
use crate::actions::CanvasAction;
|
|
||||||
|
|
||||||
/// Context passed to feature-specific action handlers
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct ActionContext {
|
|
||||||
pub key_code: Option<crossterm::event::KeyCode>, // Kept for backwards compatibility
|
|
||||||
pub ideal_cursor_column: usize,
|
|
||||||
pub current_input: String,
|
|
||||||
pub current_field: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Core trait that any form-like state must implement to work with the canvas system.
|
|
||||||
/// This enables the same mode behaviors (edit, read-only, highlight) to work across
|
|
||||||
/// any implementation - login forms, data entry forms, configuration screens, etc.
|
|
||||||
pub trait CanvasState {
|
|
||||||
// --- Core Navigation ---
|
|
||||||
fn current_field(&self) -> usize;
|
|
||||||
fn current_cursor_pos(&self) -> usize;
|
|
||||||
fn set_current_field(&mut self, index: usize);
|
|
||||||
fn set_current_cursor_pos(&mut self, pos: usize);
|
|
||||||
|
|
||||||
// --- Data Access ---
|
|
||||||
fn get_current_input(&self) -> &str;
|
|
||||||
fn get_current_input_mut(&mut self) -> &mut String;
|
|
||||||
fn inputs(&self) -> Vec<&String>;
|
|
||||||
fn fields(&self) -> Vec<&str>;
|
|
||||||
|
|
||||||
// --- State Management ---
|
|
||||||
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 (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()
|
|
||||||
.get(index)
|
|
||||||
.map(|s| s.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
}
|
|
||||||
fn has_display_override(&self, _index: usize) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
55
canvas/view_docs.sh
Executable file
55
canvas/view_docs.sh
Executable file
@@ -0,0 +1,55 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Enhanced documentation viewer for your canvas library
|
||||||
|
echo "=========================================="
|
||||||
|
echo "CANVAS LIBRARY DOCUMENTATION"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
# Function to display module docs with colors
|
||||||
|
show_module() {
|
||||||
|
local module=$1
|
||||||
|
local title=$2
|
||||||
|
|
||||||
|
echo -e "\n\033[1;34m=== $title ===\033[0m"
|
||||||
|
echo -e "\033[33mFiles in $module:\033[0m"
|
||||||
|
find src/$module -name "*.rs" 2>/dev/null | sort
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Show doc comments for this module
|
||||||
|
find src/$module -name "*.rs" 2>/dev/null | while read file; do
|
||||||
|
if grep -q "///" "$file"; then
|
||||||
|
echo -e "\033[32m--- $file ---\033[0m"
|
||||||
|
grep -n "^\s*///" "$file" | sed 's/^\([0-9]*:\)\s*\/\/\/ /\1 /' | head -10
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main modules
|
||||||
|
show_module "canvas" "CANVAS SYSTEM"
|
||||||
|
show_module "autocomplete" "AUTOCOMPLETE SYSTEM"
|
||||||
|
show_module "config" "CONFIGURATION SYSTEM"
|
||||||
|
|
||||||
|
# Show lib.rs and other root files
|
||||||
|
echo -e "\n\033[1;34m=== ROOT DOCUMENTATION ===\033[0m"
|
||||||
|
if [ -f "src/lib.rs" ]; then
|
||||||
|
echo -e "\033[32m--- src/lib.rs ---\033[0m"
|
||||||
|
grep -n "^\s*///" src/lib.rs | sed 's/^\([0-9]*:\)\s*\/\/\/ /\1 /' 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "src/dispatcher.rs" ]; then
|
||||||
|
echo -e "\033[32m--- src/dispatcher.rs ---\033[0m"
|
||||||
|
grep -n "^\s*///" src/dispatcher.rs | sed 's/^\([0-9]*:\)\s*\/\/\/ /\1 /' 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "\n\033[1;36m=========================================="
|
||||||
|
echo "To view specific module documentation:"
|
||||||
|
echo " ./view_canvas_docs.sh canvas"
|
||||||
|
echo " ./view_canvas_docs.sh autocomplete"
|
||||||
|
echo " ./view_canvas_docs.sh config"
|
||||||
|
echo "==========================================\033[0m"
|
||||||
|
|
||||||
|
# If specific module requested
|
||||||
|
if [ $# -eq 1 ]; then
|
||||||
|
show_module "$1" "$(echo $1 | tr '[:lower:]' '[:upper:]') MODULE DETAILS"
|
||||||
|
fi
|
||||||
1
client/.gitignore
vendored
Normal file
1
client/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
canvas_config.toml.txt
|
||||||
@@ -8,7 +8,7 @@ license.workspace = true
|
|||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
async-trait = "0.1.88"
|
async-trait = "0.1.88"
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
canvas = { path = "../canvas" }
|
canvas = { path = "../canvas", features = ["gui"] }
|
||||||
|
|
||||||
ratatui = { workspace = true }
|
ratatui = { workspace = true }
|
||||||
crossterm = { workspace = true }
|
crossterm = { workspace = true }
|
||||||
@@ -27,7 +27,7 @@ tracing = "0.1.41"
|
|||||||
tracing-subscriber = "0.3.19"
|
tracing-subscriber = "0.3.19"
|
||||||
tui-textarea = { version = "0.7.0", features = ["crossterm", "ratatui", "search"] }
|
tui-textarea = { version = "0.7.0", features = ["crossterm", "ratatui", "search"] }
|
||||||
unicode-segmentation = "1.12.0"
|
unicode-segmentation = "1.12.0"
|
||||||
unicode-width = "0.2.0"
|
unicode-width.workspace = true
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
# canvas_config.toml - Complete Canvas Configuration
|
|
||||||
|
|
||||||
[behavior]
|
|
||||||
wrap_around_fields = true
|
|
||||||
auto_save_on_field_change = false
|
|
||||||
word_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"
|
|
||||||
max_suggestions = 6
|
|
||||||
|
|
||||||
[appearance]
|
|
||||||
cursor_style = "block" # "block", "bar", "underline"
|
|
||||||
show_field_numbers = false
|
|
||||||
highlight_current_field = true
|
|
||||||
|
|
||||||
# Read-only mode keybindings (vim-style)
|
|
||||||
[keybindings.read_only]
|
|
||||||
move_left = ["h"]
|
|
||||||
move_right = ["l"]
|
|
||||||
move_up = ["k"]
|
|
||||||
move_down = ["j"]
|
|
||||||
move_word_next = ["w"]
|
|
||||||
move_word_end = ["e"]
|
|
||||||
move_word_prev = ["b"]
|
|
||||||
move_word_end_prev = ["ge"]
|
|
||||||
move_line_start = ["0"]
|
|
||||||
move_line_end = ["$"]
|
|
||||||
move_first_line = ["gg"]
|
|
||||||
move_last_line = ["CapsLock"]
|
|
||||||
next_field = ["Tab"]
|
|
||||||
prev_field = ["Shift+Tab"]
|
|
||||||
|
|
||||||
# Edit mode keybindings
|
|
||||||
[keybindings.edit]
|
|
||||||
delete_char_backward = ["Backspace"]
|
|
||||||
delete_char_forward = ["Delete"]
|
|
||||||
move_left = ["Left"]
|
|
||||||
move_right = ["Right"]
|
|
||||||
move_up = ["Up"]
|
|
||||||
move_down = ["Down"]
|
|
||||||
move_line_start = ["Home"]
|
|
||||||
move_line_end = ["End"]
|
|
||||||
move_word_next = ["Ctrl+Right"]
|
|
||||||
move_word_prev = ["Ctrl+Left"]
|
|
||||||
next_field = ["Tab"]
|
|
||||||
prev_field = ["Shift+Tab"]
|
|
||||||
|
|
||||||
# Suggestion/autocomplete keybindings
|
|
||||||
[keybindings.suggestions]
|
|
||||||
suggestion_up = ["Up", "Ctrl+p"]
|
|
||||||
suggestion_down = ["Down", "Ctrl+n"]
|
|
||||||
select_suggestion = ["Enter", "Tab"]
|
|
||||||
exit_suggestions = ["Esc"]
|
|
||||||
|
|
||||||
# Global keybindings (work in both modes)
|
|
||||||
[keybindings.global]
|
|
||||||
move_up = ["Up"]
|
|
||||||
move_down = ["Down"]
|
|
||||||
@@ -29,6 +29,7 @@ move_up = ["Up"]
|
|||||||
move_down = ["Down"]
|
move_down = ["Down"]
|
||||||
toggle_sidebar = ["ctrl+t"]
|
toggle_sidebar = ["ctrl+t"]
|
||||||
toggle_buffer_list = ["ctrl+b"]
|
toggle_buffer_list = ["ctrl+b"]
|
||||||
|
revert = ["space+b+r"]
|
||||||
|
|
||||||
# MODE SPECIFIC
|
# MODE SPECIFIC
|
||||||
# READ ONLY MODE
|
# READ ONLY MODE
|
||||||
@@ -37,27 +38,46 @@ enter_edit_mode_before = ["i"]
|
|||||||
enter_edit_mode_after = ["a"]
|
enter_edit_mode_after = ["a"]
|
||||||
previous_entry = ["left","q"]
|
previous_entry = ["left","q"]
|
||||||
next_entry = ["right","1"]
|
next_entry = ["right","1"]
|
||||||
revert = ["space+b+r"]
|
|
||||||
|
|
||||||
move_left = ["h"]
|
|
||||||
move_right = ["l"]
|
|
||||||
move_up = ["k"]
|
|
||||||
move_down = ["j"]
|
|
||||||
move_word_next = ["w"]
|
|
||||||
move_word_end = ["e"]
|
|
||||||
move_word_prev = ["b"]
|
|
||||||
move_word_end_prev = ["ge"]
|
|
||||||
move_line_start = ["0"]
|
|
||||||
move_line_end = ["$"]
|
|
||||||
move_first_line = ["gg"]
|
|
||||||
move_last_line = ["x"]
|
|
||||||
enter_highlight_mode = ["v"]
|
enter_highlight_mode = ["v"]
|
||||||
enter_highlight_mode_linewise = ["ctrl+v"]
|
enter_highlight_mode_linewise = ["ctrl+v"]
|
||||||
|
|
||||||
|
### AUTOGENERATED CANVAS CONFIG
|
||||||
|
# Required
|
||||||
|
move_up = ["k", "Up"]
|
||||||
|
move_left = ["h", "Left"]
|
||||||
|
move_right = ["l", "Right"]
|
||||||
|
move_down = ["j", "Down"]
|
||||||
|
# Optional
|
||||||
|
move_line_end = ["$"]
|
||||||
|
# move_word_next = ["w"]
|
||||||
|
next_field = ["Tab"]
|
||||||
|
move_word_prev = ["b"]
|
||||||
|
move_word_end = ["e"]
|
||||||
|
move_last_line = ["shift+g"]
|
||||||
|
move_word_end_prev = ["ge"]
|
||||||
|
move_line_start = ["0"]
|
||||||
|
move_first_line = ["g+g"]
|
||||||
|
prev_field = ["Shift+Tab"]
|
||||||
|
|
||||||
[keybindings.highlight]
|
[keybindings.highlight]
|
||||||
exit_highlight_mode = ["esc"]
|
exit_highlight_mode = ["esc"]
|
||||||
enter_highlight_mode_linewise = ["ctrl+v"]
|
enter_highlight_mode_linewise = ["ctrl+v"]
|
||||||
|
|
||||||
|
### AUTOGENERATED CANVAS CONFIG
|
||||||
|
# Required
|
||||||
|
move_left = ["h", "Left"]
|
||||||
|
move_right = ["l", "Right"]
|
||||||
|
move_up = ["k", "Up"]
|
||||||
|
move_down = ["j", "Down"]
|
||||||
|
# Optional
|
||||||
|
move_word_next = ["w"]
|
||||||
|
move_line_start = ["0"]
|
||||||
|
move_line_end = ["$"]
|
||||||
|
move_word_prev = ["b"]
|
||||||
|
move_word_end = ["e"]
|
||||||
|
|
||||||
|
|
||||||
[keybindings.edit]
|
[keybindings.edit]
|
||||||
# BIG CHANGES NOW EXIT HANDLES EITHER IF THOSE
|
# BIG CHANGES NOW EXIT HANDLES EITHER IF THOSE
|
||||||
# exit_edit_mode = ["esc","ctrl+e"]
|
# exit_edit_mode = ["esc","ctrl+e"]
|
||||||
@@ -65,15 +85,29 @@ enter_highlight_mode_linewise = ["ctrl+v"]
|
|||||||
# select_suggestion = ["enter"]
|
# select_suggestion = ["enter"]
|
||||||
# next_field = ["enter"]
|
# next_field = ["enter"]
|
||||||
enter_decider = ["enter"]
|
enter_decider = ["enter"]
|
||||||
prev_field = ["shift+enter"]
|
|
||||||
exit = ["esc", "ctrl+e"]
|
exit = ["esc", "ctrl+e"]
|
||||||
delete_char_forward = ["delete"]
|
|
||||||
delete_char_backward = ["backspace"]
|
|
||||||
move_left = [""]
|
|
||||||
move_right = ["right"]
|
|
||||||
suggestion_down = ["ctrl+n", "tab"]
|
suggestion_down = ["ctrl+n", "tab"]
|
||||||
suggestion_up = ["ctrl+p", "shift+tab"]
|
suggestion_up = ["ctrl+p", "shift+tab"]
|
||||||
trigger_autocomplete = ["left"]
|
|
||||||
|
### AUTOGENERATED CANVAS CONFIG
|
||||||
|
# Required
|
||||||
|
move_right = ["Right", "l"]
|
||||||
|
delete_char_backward = ["Backspace"]
|
||||||
|
next_field = ["Tab", "Enter"]
|
||||||
|
move_up = ["Up", "k"]
|
||||||
|
move_down = ["Down", "j"]
|
||||||
|
prev_field = ["Shift+Tab"]
|
||||||
|
move_left = ["Left", "h"]
|
||||||
|
# Optional
|
||||||
|
move_last_line = ["Ctrl+End", "G"]
|
||||||
|
delete_char_forward = ["Delete"]
|
||||||
|
move_word_prev = ["Ctrl+Left", "b"]
|
||||||
|
move_word_end = ["e"]
|
||||||
|
move_word_end_prev = ["ge"]
|
||||||
|
move_first_line = ["Ctrl+Home", "gg"]
|
||||||
|
move_word_next = ["Ctrl+Right", "w"]
|
||||||
|
move_line_start = ["Home", "0"]
|
||||||
|
move_line_end = ["End", "$"]
|
||||||
|
|
||||||
[keybindings.command]
|
[keybindings.command]
|
||||||
exit_command_mode = ["ctrl+g", "esc"]
|
exit_command_mode = ["ctrl+g", "esc"]
|
||||||
@@ -92,3 +126,9 @@ keybinding_mode = "vim" # Options: "default", "vim", "emacs"
|
|||||||
[colors]
|
[colors]
|
||||||
theme = "dark"
|
theme = "dark"
|
||||||
# Options: "light", "dark", "high_contrast"
|
# Options: "light", "dark", "high_contrast"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
124
client/docs/canvas_add_functionality.md
Normal file
124
client/docs/canvas_add_functionality.md
Normal 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`
|
||||||
|
|
||||||
@@ -3,7 +3,7 @@ use crate::config::colors::themes::Theme;
|
|||||||
use crate::state::app::highlight::HighlightState;
|
use crate::state::app::highlight::HighlightState;
|
||||||
use crate::state::app::state::AppState;
|
use crate::state::app::state::AppState;
|
||||||
use crate::state::pages::add_logic::{AddLogicFocus, AddLogicState};
|
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::{
|
use ratatui::{
|
||||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
style::{Modifier, Style},
|
style::{Modifier, Style},
|
||||||
@@ -11,10 +11,18 @@ use ratatui::{
|
|||||||
widgets::{Block, BorderType, Borders, Paragraph},
|
widgets::{Block, BorderType, Borders, Paragraph},
|
||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
use crate::components::handlers::canvas::render_canvas;
|
|
||||||
use crate::components::common::{dialog, autocomplete}; // Added autocomplete
|
use crate::components::common::{dialog, autocomplete}; // Added autocomplete
|
||||||
use crate::config::binds::config::EditorKeybindingMode;
|
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(
|
pub fn render_add_logic(
|
||||||
f: &mut Frame,
|
f: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
@@ -152,40 +160,37 @@ pub fn render_add_logic(
|
|||||||
);
|
);
|
||||||
f.render_widget(profile_text, top_info_area);
|
f.render_widget(profile_text, top_info_area);
|
||||||
|
|
||||||
// Canvas
|
// Canvas - USING CANVAS LIBRARY
|
||||||
let focus_on_canvas_inputs = matches!(
|
let focus_on_canvas_inputs = matches!(
|
||||||
add_logic_state.current_focus,
|
add_logic_state.current_focus,
|
||||||
AddLogicFocus::InputLogicName
|
AddLogicFocus::InputLogicName
|
||||||
| AddLogicFocus::InputTargetColumn
|
| AddLogicFocus::InputTargetColumn
|
||||||
| AddLogicFocus::InputDescription
|
| 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(
|
let active_field_rect = render_canvas(
|
||||||
f,
|
f,
|
||||||
canvas_area,
|
canvas_area,
|
||||||
add_logic_state, // Pass the whole state as it impl CanvasState
|
add_logic_state, // AddLogicState implements CanvasState
|
||||||
&add_logic_state.fields(),
|
theme, // Theme implements CanvasTheme
|
||||||
&add_logic_state.current_field(),
|
is_edit_mode && focus_on_canvas_inputs,
|
||||||
&add_logic_state.inputs(),
|
&canvas_highlight_state,
|
||||||
theme,
|
|
||||||
is_edit_mode && focus_on_canvas_inputs, // is_edit_mode for canvas fields
|
|
||||||
highlight_state,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Render Autocomplete for Target Column ---
|
// --- Render Autocomplete for Target Column ---
|
||||||
// `is_edit_mode` here refers to the general edit mode of the EventHandler
|
// `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 is_edit_mode && add_logic_state.current_field() == 1 { // Target Column field
|
||||||
if let Some(suggestions) = add_logic_state.get_suggestions() { // Uses CanvasState impl
|
if add_logic_state.in_target_column_suggestion_mode && add_logic_state.show_target_column_suggestions {
|
||||||
let selected = add_logic_state.get_selected_suggestion_index();
|
if !add_logic_state.target_column_suggestions.is_empty() {
|
||||||
if !suggestions.is_empty() { // Only render if there are suggestions to show
|
|
||||||
if let Some(input_rect) = active_field_rect {
|
if let Some(input_rect) = active_field_rect {
|
||||||
autocomplete::render_autocomplete_dropdown(
|
autocomplete::render_autocomplete_dropdown(
|
||||||
f,
|
f,
|
||||||
input_rect,
|
input_rect,
|
||||||
f.area(), // Full frame area for clamping
|
f.area(), // Full frame area for clamping
|
||||||
theme,
|
theme,
|
||||||
suggestions,
|
&add_logic_state.target_column_suggestions,
|
||||||
selected,
|
add_logic_state.selected_target_column_suggestion_index,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ use crate::config::colors::themes::Theme;
|
|||||||
use crate::state::app::highlight::HighlightState;
|
use crate::state::app::highlight::HighlightState;
|
||||||
use crate::state::app::state::AppState;
|
use crate::state::app::state::AppState;
|
||||||
use crate::state::pages::add_table::{AddTableFocus, AddTableState};
|
use crate::state::pages::add_table::{AddTableFocus, AddTableState};
|
||||||
use crate::state::pages::canvas_state::CanvasState;
|
use canvas::canvas::{render_canvas, CanvasState, HighlightState as CanvasHighlightState};
|
||||||
// use crate::state::pages::add_table::{ColumnDefinition, LinkDefinition}; // Not directly used here
|
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
style::{Modifier, Style},
|
style::{Modifier, Style},
|
||||||
@@ -12,16 +11,24 @@ use ratatui::{
|
|||||||
widgets::{Block, BorderType, Borders, Cell, Paragraph, Row, Table},
|
widgets::{Block, BorderType, Borders, Cell, Paragraph, Row, Table},
|
||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
use crate::components::handlers::canvas::render_canvas;
|
|
||||||
use crate::components::common::dialog;
|
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,
|
/// Renders the Add New Table page layout, structuring the display of table information,
|
||||||
/// input fields, and action buttons. Adapts layout based on terminal width.
|
/// input fields, and action buttons. Adapts layout based on terminal width.
|
||||||
pub fn render_add_table(
|
pub fn render_add_table(
|
||||||
f: &mut Frame,
|
f: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
app_state: &AppState, // Currently unused, might be needed later
|
app_state: &AppState,
|
||||||
add_table_state: &mut AddTableState,
|
add_table_state: &mut AddTableState,
|
||||||
is_edit_mode: bool, // Determines if canvas inputs are in edit mode
|
is_edit_mode: bool, // Determines if canvas inputs are in edit mode
|
||||||
highlight_state: &HighlightState, // For text highlighting in canvas
|
highlight_state: &HighlightState, // For text highlighting in canvas
|
||||||
@@ -349,17 +356,15 @@ pub fn render_add_table(
|
|||||||
&mut add_table_state.column_table_state,
|
&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(
|
let _active_field_rect = render_canvas(
|
||||||
f,
|
f,
|
||||||
canvas_area,
|
canvas_area,
|
||||||
add_table_state,
|
add_table_state, // AddTableState implements CanvasState
|
||||||
&add_table_state.fields(),
|
theme, // Theme implements CanvasTheme
|
||||||
&add_table_state.current_field(),
|
|
||||||
&add_table_state.inputs(),
|
|
||||||
theme,
|
|
||||||
is_edit_mode && focus_on_canvas_inputs,
|
is_edit_mode && focus_on_canvas_inputs,
|
||||||
highlight_state,
|
&canvas_highlight_state,
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Button Style Helpers ---
|
// --- Button Style Helpers ---
|
||||||
@@ -557,7 +562,7 @@ pub fn render_add_table(
|
|||||||
|
|
||||||
// --- DIALOG ---
|
// --- DIALOG ---
|
||||||
// Render the dialog overlay if it's active
|
// 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(
|
dialog::render_dialog(
|
||||||
f,
|
f,
|
||||||
f.area(), // Render over the whole frame area
|
f.area(), // Render over the whole frame area
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ use ratatui::{
|
|||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
use crate::state::app::highlight::HighlightState;
|
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(
|
pub fn render_login(
|
||||||
f: &mut Frame,
|
f: &mut Frame,
|
||||||
@@ -48,17 +58,15 @@ pub fn render_login(
|
|||||||
])
|
])
|
||||||
.split(inner_area);
|
.split(inner_area);
|
||||||
|
|
||||||
// --- FORM RENDERING ---
|
// --- FORM RENDERING (Using canvas library directly) ---
|
||||||
crate::components::handlers::canvas::render_canvas(
|
let canvas_highlight_state = convert_highlight_state(highlight_state);
|
||||||
|
render_canvas(
|
||||||
f,
|
f,
|
||||||
chunks[0],
|
chunks[0],
|
||||||
login_state,
|
login_state, // LoginState implements CanvasState
|
||||||
&["Username/Email", "Password"],
|
theme, // Theme implements CanvasTheme
|
||||||
&login_state.current_field,
|
|
||||||
&[&login_state.username, &login_state.password],
|
|
||||||
theme,
|
|
||||||
is_edit_mode,
|
is_edit_mode,
|
||||||
highlight_state,
|
&canvas_highlight_state,
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- ERROR MESSAGE ---
|
// --- ERROR MESSAGE ---
|
||||||
@@ -71,7 +79,7 @@ pub fn render_login(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- BUTTONS ---
|
// --- BUTTONS (unchanged) ---
|
||||||
let button_chunks = Layout::default()
|
let button_chunks = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||||
@@ -105,11 +113,11 @@ pub fn render_login(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Return Button
|
// 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 {
|
let return_active = if app_state.ui.focus_outside_canvas {
|
||||||
app_state.focused_button_index== return_button_index
|
app_state.focused_button_index== return_button_index
|
||||||
} else {
|
} 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_style = Style::default().fg(theme.fg);
|
||||||
let mut return_border = Style::default().fg(theme.border);
|
let mut return_border = Style::default().fg(theme.border);
|
||||||
@@ -132,16 +140,14 @@ pub fn render_login(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// --- DIALOG ---
|
// --- DIALOG ---
|
||||||
// Check the correct field name for showing the dialog
|
|
||||||
if app_state.ui.dialog.dialog_show {
|
if app_state.ui.dialog.dialog_show {
|
||||||
// Pass all 7 arguments correctly
|
|
||||||
dialog::render_dialog(
|
dialog::render_dialog(
|
||||||
f,
|
f,
|
||||||
f.area(),
|
f.area(),
|
||||||
theme,
|
theme,
|
||||||
&app_state.ui.dialog.dialog_title,
|
&app_state.ui.dialog.dialog_title,
|
||||||
&app_state.ui.dialog.dialog_message,
|
&app_state.ui.dialog.dialog_message,
|
||||||
&app_state.ui.dialog.dialog_buttons, // Pass buttons slice
|
&app_state.ui.dialog.dialog_buttons,
|
||||||
app_state.ui.dialog.dialog_active_button_index,
|
app_state.ui.dialog.dialog_active_button_index,
|
||||||
app_state.ui.dialog.is_loading,
|
app_state.ui.dialog.is_loading,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::colors::themes::Theme,
|
config::colors::themes::Theme,
|
||||||
state::pages::auth::RegisterState, // Use RegisterState
|
state::pages::auth::RegisterState,
|
||||||
components::common::{dialog, autocomplete},
|
components::common::dialog,
|
||||||
state::app::state::AppState,
|
state::app::state::AppState,
|
||||||
state::pages::canvas_state::CanvasState,
|
|
||||||
modes::handlers::mode_manager::AppMode,
|
modes::handlers::mode_manager::AppMode,
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
@@ -15,12 +14,24 @@ use ratatui::{
|
|||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
use crate::state::app::highlight::HighlightState;
|
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(
|
pub fn render_register(
|
||||||
f: &mut Frame,
|
f: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
state: &RegisterState, // Use RegisterState
|
state: &RegisterState,
|
||||||
app_state: &AppState,
|
app_state: &AppState,
|
||||||
is_edit_mode: bool,
|
is_edit_mode: bool,
|
||||||
highlight_state: &HighlightState,
|
highlight_state: &HighlightState,
|
||||||
@@ -29,7 +40,7 @@ pub fn render_register(
|
|||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_type(BorderType::Plain)
|
.border_type(BorderType::Plain)
|
||||||
.border_style(Style::default().fg(theme.border))
|
.border_style(Style::default().fg(theme.border))
|
||||||
.title(" Register ") // Update title
|
.title(" Register ")
|
||||||
.style(Style::default().bg(theme.bg));
|
.style(Style::default().bg(theme.bg));
|
||||||
|
|
||||||
f.render_widget(block, area);
|
f.render_widget(block, area);
|
||||||
@@ -39,7 +50,6 @@ pub fn render_register(
|
|||||||
vertical: 1,
|
vertical: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Adjust constraints for 4 fields + error + buttons
|
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
@@ -50,23 +60,15 @@ pub fn render_register(
|
|||||||
])
|
])
|
||||||
.split(inner_area);
|
.split(inner_area);
|
||||||
|
|
||||||
// --- FORM RENDERING (Using render_canvas) ---
|
// --- FORM RENDERING (Using canvas library directly) ---
|
||||||
let active_field_rect = crate::components::handlers::canvas::render_canvas(
|
let canvas_highlight_state = convert_highlight_state(highlight_state);
|
||||||
|
let input_rect = render_canvas(
|
||||||
f,
|
f,
|
||||||
chunks[0], // Area for the canvas
|
chunks[0],
|
||||||
state, // The state object (RegisterState)
|
state, // RegisterState implements CanvasState
|
||||||
&[ // Field labels
|
theme, // Theme implements CanvasTheme
|
||||||
"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,
|
|
||||||
is_edit_mode,
|
is_edit_mode,
|
||||||
highlight_state,
|
&canvas_highlight_state,
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- HELP TEXT ---
|
// --- HELP TEXT ---
|
||||||
@@ -75,7 +77,6 @@ pub fn render_register(
|
|||||||
.alignment(Alignment::Center);
|
.alignment(Alignment::Center);
|
||||||
f.render_widget(help_text, chunks[1]);
|
f.render_widget(help_text, chunks[1]);
|
||||||
|
|
||||||
|
|
||||||
// --- ERROR MESSAGE ---
|
// --- ERROR MESSAGE ---
|
||||||
if let Some(err) = &state.error_message {
|
if let Some(err) = &state.error_message {
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
@@ -107,7 +108,7 @@ pub fn render_register(
|
|||||||
}
|
}
|
||||||
|
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
Paragraph::new("Register") // Update button text
|
Paragraph::new("Register")
|
||||||
.style(register_style)
|
.style(register_style)
|
||||||
.alignment(Alignment::Center)
|
.alignment(Alignment::Center)
|
||||||
.block(
|
.block(
|
||||||
@@ -119,7 +120,7 @@ pub fn render_register(
|
|||||||
button_chunks[0],
|
button_chunks[0],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Return Button (logic remains similar)
|
// Return Button
|
||||||
let return_button_index = 1;
|
let return_button_index = 1;
|
||||||
let return_active = if app_state.ui.focus_outside_canvas {
|
let return_active = if app_state.ui.focus_outside_canvas {
|
||||||
app_state.focused_button_index== return_button_index
|
app_state.focused_button_index== return_button_index
|
||||||
@@ -146,19 +147,22 @@ pub fn render_register(
|
|||||||
button_chunks[1],
|
button_chunks[1],
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Render Autocomplete Dropdown (Draw AFTER buttons) ---
|
// --- AUTOCOMPLETE DROPDOWN (Using canvas library directly) ---
|
||||||
if app_state.current_mode == AppMode::Edit {
|
if app_state.current_mode == AppMode::Edit {
|
||||||
if let Some(suggestions) = state.get_suggestions() {
|
if let Some(autocomplete_state) = state.autocomplete_state() {
|
||||||
let selected = state.get_selected_suggestion_index();
|
if let Some(input_rect) = input_rect {
|
||||||
if !suggestions.is_empty() {
|
render_autocomplete_dropdown(
|
||||||
if let Some(input_rect) = active_field_rect {
|
f,
|
||||||
autocomplete::render_autocomplete_dropdown(f, input_rect, f.area(), theme, suggestions, selected);
|
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 {
|
if app_state.ui.dialog.dialog_show {
|
||||||
dialog::render_dialog(
|
dialog::render_dialog(
|
||||||
f,
|
f,
|
||||||
|
|||||||
@@ -55,10 +55,10 @@ pub fn render_search_palette(
|
|||||||
.style(Style::default().fg(theme.fg));
|
.style(Style::default().fg(theme.fg));
|
||||||
f.render_widget(input_text, inner_chunks[0]);
|
f.render_widget(input_text, inner_chunks[0]);
|
||||||
// Set cursor position
|
// Set cursor position
|
||||||
f.set_cursor(
|
f.set_cursor_position((
|
||||||
inner_chunks[0].x + state.cursor_position as u16 + 1,
|
inner_chunks[0].x + state.cursor_position as u16 + 1,
|
||||||
inner_chunks[0].y + 1,
|
inner_chunks[0].y + 1,
|
||||||
);
|
));
|
||||||
|
|
||||||
// --- Render Results List ---
|
// --- Render Results List ---
|
||||||
if state.is_loading {
|
if state.is_loading {
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ use ratatui::{
|
|||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::Style,
|
style::Style,
|
||||||
text::{Line, Span, Text},
|
text::{Line, Span, Text},
|
||||||
widgets::{Paragraph, Wrap}, // Make sure Wrap is imported
|
widgets::Paragraph,
|
||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
|
use ratatui::widgets::Wrap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
// src/components/form/form.rs
|
// src/components/form/form.rs
|
||||||
use crate::components::common::autocomplete;
|
use crate::components::common::autocomplete;
|
||||||
use crate::components::handlers::canvas::render_canvas;
|
|
||||||
use crate::config::colors::themes::Theme;
|
use crate::config::colors::themes::Theme;
|
||||||
use crate::state::app::highlight::HighlightState;
|
use canvas::canvas::{CanvasState, render_canvas, HighlightState};
|
||||||
use canvas::CanvasState;
|
|
||||||
use crate::state::pages::form::FormState;
|
use crate::state::pages::form::FormState;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
|
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
|
||||||
@@ -15,7 +13,7 @@ use ratatui::{
|
|||||||
pub fn render_form(
|
pub fn render_form(
|
||||||
f: &mut Frame,
|
f: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
form_state: &FormState, // <--- CHANGE THIS to the concrete type
|
form_state: &FormState,
|
||||||
fields: &[&str],
|
fields: &[&str],
|
||||||
current_field_idx: &usize,
|
current_field_idx: &usize,
|
||||||
inputs: &[&String],
|
inputs: &[&String],
|
||||||
@@ -58,32 +56,31 @@ pub fn render_form(
|
|||||||
total_count, current_position, total_count
|
total_count, current_position, total_count
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let count_para = Paragraph::new(count_position_text)
|
let count_para = Paragraph::new(count_position_text)
|
||||||
.style(Style::default().fg(theme.fg))
|
.style(Style::default().fg(theme.fg))
|
||||||
.alignment(Alignment::Left);
|
.alignment(Alignment::Left);
|
||||||
f.render_widget(count_para, main_layout[0]);
|
f.render_widget(count_para, main_layout[0]);
|
||||||
|
|
||||||
// Get the active field's rect from render_canvas
|
// Use the canvas library's render_canvas function
|
||||||
let active_field_rect = crate::components::handlers::canvas::render_canvas_library(
|
let active_field_rect = render_canvas(
|
||||||
f,
|
f,
|
||||||
main_layout[1],
|
main_layout[1],
|
||||||
form_state,
|
form_state,
|
||||||
fields,
|
|
||||||
current_field_idx,
|
|
||||||
inputs,
|
|
||||||
theme,
|
theme,
|
||||||
is_edit_mode,
|
is_edit_mode,
|
||||||
highlight_state,
|
highlight_state,
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- NEW: RENDER AUTOCOMPLETE ---
|
// --- RENDER RICH AUTOCOMPLETE ONLY ---
|
||||||
if form_state.autocomplete_active {
|
if form_state.autocomplete_active {
|
||||||
if let Some(active_rect) = active_field_rect {
|
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 let Some(rich_suggestions) = form_state.get_rich_suggestions() {
|
||||||
if !rich_suggestions.is_empty() {
|
if !rich_suggestions.is_empty() {
|
||||||
// CHANGE THIS to call the renamed function
|
|
||||||
autocomplete::render_hit_autocomplete_dropdown(
|
autocomplete::render_hit_autocomplete_dropdown(
|
||||||
f,
|
f,
|
||||||
active_rect,
|
active_rect,
|
||||||
@@ -95,21 +92,7 @@ pub fn render_form(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The fallback to simple suggestions is now correctly handled
|
// Removed simple suggestions - we only use rich ones now!
|
||||||
// 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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
// src/components/handlers.rs
|
// src/components/handlers.rs
|
||||||
pub mod canvas;
|
|
||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
pub mod buffer_list;
|
pub mod buffer_list;
|
||||||
|
|
||||||
pub use canvas::*;
|
|
||||||
pub use sidebar::*;
|
pub use sidebar::*;
|
||||||
pub use buffer_list::*;
|
pub use buffer_list::*;
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
// src/client/themes/colors.rs
|
// src/config/colors/themes.rs
|
||||||
use ratatui::style::Color;
|
use ratatui::style::Color;
|
||||||
|
use canvas::canvas::CanvasTheme;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Theme {
|
pub struct Theme {
|
||||||
@@ -74,3 +75,37 @@ impl Default for Theme {
|
|||||||
Self::light() // Default to light theme
|
Self::light() // Default to light theme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl CanvasTheme for Theme {
|
||||||
|
fn bg(&self) -> Color {
|
||||||
|
self.bg
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fg(&self) -> Color {
|
||||||
|
self.fg
|
||||||
|
}
|
||||||
|
|
||||||
|
fn border(&self) -> Color {
|
||||||
|
self.border
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accent(&self) -> Color {
|
||||||
|
self.accent
|
||||||
|
}
|
||||||
|
|
||||||
|
fn secondary(&self) -> Color {
|
||||||
|
self.secondary
|
||||||
|
}
|
||||||
|
|
||||||
|
fn highlight(&self) -> Color {
|
||||||
|
self.highlight
|
||||||
|
}
|
||||||
|
|
||||||
|
fn highlight_bg(&self) -> Color {
|
||||||
|
self.highlight_bg
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warning(&self) -> Color {
|
||||||
|
self.warning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
// src/functions/modes.rs
|
// src/functions/modes.rs
|
||||||
|
|
||||||
pub mod read_only;
|
|
||||||
pub mod edit;
|
|
||||||
pub mod navigation;
|
pub mod navigation;
|
||||||
|
|
||||||
pub use read_only::*;
|
|
||||||
pub use edit::*;
|
|
||||||
pub use navigation::*;
|
pub use navigation::*;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
// src/modes/canvas/edit.rs
|
// src/modes/canvas/edit.rs
|
||||||
use crate::config::binds::config::Config;
|
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::modes::handlers::event::EventHandler;
|
||||||
use crate::services::grpc_client::GrpcClient;
|
use crate::services::grpc_client::GrpcClient;
|
||||||
use crate::state::app::state::AppState;
|
use crate::state::app::state::AppState;
|
||||||
@@ -11,13 +8,13 @@ use crate::state::pages::{
|
|||||||
auth::{LoginState, RegisterState},
|
auth::{LoginState, RegisterState},
|
||||||
form::FormState,
|
form::FormState,
|
||||||
};
|
};
|
||||||
use canvas::CanvasState;
|
use canvas::canvas::CanvasState;
|
||||||
use canvas::{CanvasAction, ActionDispatcher, ActionResult};
|
use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher, canvas::ActionResult};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use common::proto::komp_ac::search::search_response::Hit;
|
use common::proto::komp_ac::search::search_response::Hit;
|
||||||
use crossterm::event::{KeyCode, KeyEvent};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tracing::{debug, info};
|
use tracing::info;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum EditEventOutcome {
|
pub enum EditEventOutcome {
|
||||||
@@ -82,7 +79,9 @@ pub async fn handle_form_edit_with_canvas(
|
|||||||
ideal_cursor_column: &mut usize,
|
ideal_cursor_column: &mut usize,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
// Try canvas action from key first
|
// Try canvas action from key first
|
||||||
if let Some(canvas_action) = CanvasAction::from_key(key_event.code) {
|
let canvas_config = canvas::config::CanvasConfig::load();
|
||||||
|
if let Some(action_name) = canvas_config.get_edit_action(key_event.code, key_event.modifiers) {
|
||||||
|
let canvas_action = CanvasAction::from_string(action_name);
|
||||||
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
|
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
|
||||||
Ok(ActionResult::Success(msg)) => {
|
Ok(ActionResult::Success(msg)) => {
|
||||||
return Ok(msg.unwrap_or_default());
|
return Ok(msg.unwrap_or_default());
|
||||||
@@ -127,6 +126,119 @@ pub async fn handle_form_edit_with_canvas(
|
|||||||
Ok(String::new())
|
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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FIXED: Unified canvas action handler with proper priority order for edit mode
|
||||||
|
async fn handle_canvas_state_edit<S: CanvasState>(
|
||||||
|
key: KeyEvent,
|
||||||
|
config: &Config,
|
||||||
|
state: &mut S,
|
||||||
|
ideal_cursor_column: &mut usize,
|
||||||
|
) -> Result<String> {
|
||||||
|
// println!("DEBUG: Key pressed: {:?}", key); // DEBUG
|
||||||
|
|
||||||
|
// PRIORITY 1: Character insertion in edit mode comes FIRST
|
||||||
|
if let KeyCode::Char(c) = key.code {
|
||||||
|
// Only insert if no modifiers or just shift (for uppercase)
|
||||||
|
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT {
|
||||||
|
// println!("DEBUG: Using character insertion priority for: {}", c); // DEBUG
|
||||||
|
let canvas_action = CanvasAction::InsertChar(c);
|
||||||
|
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) => {
|
||||||
|
// println!("DEBUG: Character insertion failed: {:?}, trying config", e);
|
||||||
|
// Fall through to try config mappings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRIORITY 2: Check canvas config for special keys/combinations
|
||||||
|
let canvas_config = canvas::config::CanvasConfig::load();
|
||||||
|
if let Some(action_name) = canvas_config.get_edit_action(key.code, key.modifiers) {
|
||||||
|
// println!("DEBUG: Canvas config mapped to: {}", action_name); // DEBUG
|
||||||
|
let canvas_action = CanvasAction::from_string(action_name);
|
||||||
|
|
||||||
|
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(_) => {
|
||||||
|
// println!("DEBUG: Canvas action failed, trying client config"); // DEBUG
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// println!("DEBUG: No canvas config mapping found"); // DEBUG
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRIORITY 3: Check client config ONLY for non-character keys or modified keys
|
||||||
|
if !matches!(key.code, KeyCode::Char(_)) || !key.modifiers.is_empty() {
|
||||||
|
if let Some(action_str) = config.get_edit_action_for_key(key.code, key.modifiers) {
|
||||||
|
// println!("DEBUG: Client config mapped to: {} (for non-char key)", action_str); // DEBUG
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// println!("DEBUG: No client config mapping found for non-char key"); // DEBUG
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// println!("DEBUG: Skipping client config for character key in edit mode"); // DEBUG
|
||||||
|
}
|
||||||
|
|
||||||
|
// println!("DEBUG: No action taken for key: {:?}", key); // DEBUG
|
||||||
|
Ok(String::new())
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn handle_edit_event(
|
pub async fn handle_edit_event(
|
||||||
key: KeyEvent,
|
key: KeyEvent,
|
||||||
@@ -170,7 +282,7 @@ pub async fn handle_edit_event(
|
|||||||
return Ok(EditEventOutcome::Message(String::new()));
|
return Ok(EditEventOutcome::Message(String::new()));
|
||||||
}
|
}
|
||||||
"exit" => {
|
"exit" => {
|
||||||
form_state.deactivate_suggestions();
|
form_state.deactivate_autocomplete();
|
||||||
return Ok(EditEventOutcome::Message(
|
return Ok(EditEventOutcome::Message(
|
||||||
"Autocomplete cancelled".to_string(),
|
"Autocomplete cancelled".to_string(),
|
||||||
));
|
));
|
||||||
@@ -202,14 +314,14 @@ pub async fn handle_edit_event(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 4. Finalize state
|
// 4. Finalize state
|
||||||
form_state.deactivate_suggestions();
|
form_state.deactivate_autocomplete();
|
||||||
form_state.set_has_unsaved_changes(true);
|
form_state.set_has_unsaved_changes(true);
|
||||||
return Ok(EditEventOutcome::Message(
|
return Ok(EditEventOutcome::Message(
|
||||||
"Selection made".to_string(),
|
"Selection made".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
form_state.deactivate_suggestions();
|
form_state.deactivate_autocomplete();
|
||||||
// Fall through to default 'enter' behavior
|
// Fall through to default 'enter' behavior
|
||||||
}
|
}
|
||||||
_ => {} // Let other keys fall through to the live search logic
|
_ => {} // Let other keys fall through to the live search logic
|
||||||
@@ -237,8 +349,8 @@ pub async fn handle_edit_event(
|
|||||||
} else {
|
} else {
|
||||||
"insert_char"
|
"insert_char"
|
||||||
};
|
};
|
||||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
// FIXED: Use canvas library instead of form_e::execute_edit_action
|
||||||
form_e::execute_edit_action(
|
execute_canvas_action(
|
||||||
action,
|
action,
|
||||||
key,
|
key,
|
||||||
form_state,
|
form_state,
|
||||||
@@ -267,8 +379,8 @@ pub async fn handle_edit_event(
|
|||||||
{
|
{
|
||||||
// Handle Enter key (next field)
|
// Handle Enter key (next field)
|
||||||
if action_str == "enter_decider" {
|
if action_str == "enter_decider" {
|
||||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
// FIXED: Use canvas library instead of form_e::execute_edit_action
|
||||||
let msg = form_e::execute_edit_action(
|
let msg = execute_canvas_action(
|
||||||
"next_field",
|
"next_field",
|
||||||
key,
|
key,
|
||||||
form_state,
|
form_state,
|
||||||
@@ -283,46 +395,46 @@ pub async fn handle_edit_event(
|
|||||||
return Ok(EditEventOutcome::ExitEditMode);
|
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 {
|
let msg = if app_state.ui.show_login {
|
||||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
|
||||||
auth_e::execute_edit_action(
|
handle_canvas_state_edit(
|
||||||
action_str,
|
|
||||||
key,
|
key,
|
||||||
|
config,
|
||||||
login_state,
|
login_state,
|
||||||
&mut event_handler.ideal_cursor_column,
|
&mut event_handler.ideal_cursor_column,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
} else if app_state.ui.show_add_table {
|
} else if app_state.ui.show_add_table {
|
||||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
// NEW: Use unified canvas handler instead of add_table_e::execute_edit_action
|
||||||
add_table_e::execute_edit_action(
|
handle_canvas_state_edit(
|
||||||
action_str,
|
|
||||||
key,
|
key,
|
||||||
|
config,
|
||||||
&mut admin_state.add_table_state,
|
&mut admin_state.add_table_state,
|
||||||
&mut event_handler.ideal_cursor_column,
|
&mut event_handler.ideal_cursor_column,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
} else if app_state.ui.show_add_logic {
|
} else if app_state.ui.show_add_logic {
|
||||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
// NEW: Use unified canvas handler instead of add_logic_e::execute_edit_action
|
||||||
add_logic_e::execute_edit_action(
|
handle_canvas_state_edit(
|
||||||
action_str,
|
|
||||||
key,
|
key,
|
||||||
|
config,
|
||||||
&mut admin_state.add_logic_state,
|
&mut admin_state.add_logic_state,
|
||||||
&mut event_handler.ideal_cursor_column,
|
&mut event_handler.ideal_cursor_column,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
} else if app_state.ui.show_register {
|
} else if app_state.ui.show_register {
|
||||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
|
||||||
auth_e::execute_edit_action(
|
handle_canvas_state_edit(
|
||||||
action_str,
|
|
||||||
key,
|
key,
|
||||||
|
config,
|
||||||
register_state,
|
register_state,
|
||||||
&mut event_handler.ideal_cursor_column,
|
&mut event_handler.ideal_cursor_column,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
// FIXED: Use canvas library instead of form_e::execute_edit_action
|
||||||
form_e::execute_edit_action(
|
execute_canvas_action(
|
||||||
action_str,
|
action_str,
|
||||||
key,
|
key,
|
||||||
form_state,
|
form_state,
|
||||||
@@ -336,44 +448,44 @@ pub async fn handle_edit_event(
|
|||||||
// --- FALLBACK FOR CHARACTER INSERTION (IF NO OTHER BINDING MATCHED) ---
|
// --- FALLBACK FOR CHARACTER INSERTION (IF NO OTHER BINDING MATCHED) ---
|
||||||
if let KeyCode::Char(_) = key.code {
|
if let KeyCode::Char(_) = key.code {
|
||||||
let msg = if app_state.ui.show_login {
|
let msg = if app_state.ui.show_login {
|
||||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
|
||||||
auth_e::execute_edit_action(
|
handle_canvas_state_edit(
|
||||||
"insert_char",
|
|
||||||
key,
|
key,
|
||||||
|
config,
|
||||||
login_state,
|
login_state,
|
||||||
&mut event_handler.ideal_cursor_column,
|
&mut event_handler.ideal_cursor_column,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
} else if app_state.ui.show_add_table {
|
} else if app_state.ui.show_add_table {
|
||||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
// NEW: Use unified canvas handler instead of add_table_e::execute_edit_action
|
||||||
add_table_e::execute_edit_action(
|
handle_canvas_state_edit(
|
||||||
"insert_char",
|
|
||||||
key,
|
key,
|
||||||
|
config,
|
||||||
&mut admin_state.add_table_state,
|
&mut admin_state.add_table_state,
|
||||||
&mut event_handler.ideal_cursor_column,
|
&mut event_handler.ideal_cursor_column,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
} else if app_state.ui.show_add_logic {
|
} else if app_state.ui.show_add_logic {
|
||||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
// NEW: Use unified canvas handler instead of add_logic_e::execute_edit_action
|
||||||
add_logic_e::execute_edit_action(
|
handle_canvas_state_edit(
|
||||||
"insert_char",
|
|
||||||
key,
|
key,
|
||||||
|
config,
|
||||||
&mut admin_state.add_logic_state,
|
&mut admin_state.add_logic_state,
|
||||||
&mut event_handler.ideal_cursor_column,
|
&mut event_handler.ideal_cursor_column,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
} else if app_state.ui.show_register {
|
} else if app_state.ui.show_register {
|
||||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
|
||||||
auth_e::execute_edit_action(
|
handle_canvas_state_edit(
|
||||||
"insert_char",
|
|
||||||
key,
|
key,
|
||||||
|
config,
|
||||||
register_state,
|
register_state,
|
||||||
&mut event_handler.ideal_cursor_column,
|
&mut event_handler.ideal_cursor_column,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
// FIXED: Use canvas library instead of form_e::execute_edit_action
|
||||||
form_e::execute_edit_action(
|
execute_canvas_action(
|
||||||
"insert_char",
|
"insert_char",
|
||||||
key,
|
key,
|
||||||
form_state,
|
form_state,
|
||||||
|
|||||||
@@ -3,17 +3,87 @@
|
|||||||
use crate::config::binds::config::Config;
|
use crate::config::binds::config::Config;
|
||||||
use crate::config::binds::key_sequences::KeySequenceTracker;
|
use crate::config::binds::key_sequences::KeySequenceTracker;
|
||||||
use crate::services::grpc_client::GrpcClient;
|
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::LoginState;
|
||||||
|
use crate::state::pages::auth::RegisterState;
|
||||||
use crate::state::pages::form::FormState;
|
use crate::state::pages::form::FormState;
|
||||||
use crate::state::pages::add_logic::AddLogicState;
|
use crate::state::pages::add_logic::AddLogicState;
|
||||||
use crate::state::pages::add_table::AddTableState;
|
use crate::state::pages::add_table::AddTableState;
|
||||||
use crate::state::app::state::AppState;
|
use crate::state::app::state::AppState;
|
||||||
use crate::functions::modes::read_only::{add_logic_ro, auth_ro, form_ro, add_table_ro};
|
use canvas::{canvas::{CanvasAction, CanvasState, ActionResult}, dispatcher::ActionDispatcher};
|
||||||
use canvas::{CanvasAction, ActionDispatcher, ActionResult};
|
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
use anyhow::Result;
|
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(
|
pub async fn handle_form_readonly_with_canvas(
|
||||||
key_event: KeyEvent,
|
key_event: KeyEvent,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
@@ -21,7 +91,9 @@ pub async fn handle_form_readonly_with_canvas(
|
|||||||
ideal_cursor_column: &mut usize,
|
ideal_cursor_column: &mut usize,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
// Try canvas action from key first
|
// Try canvas action from key first
|
||||||
if let Some(canvas_action) = CanvasAction::from_key(key_event.code) {
|
let canvas_config = canvas::config::CanvasConfig::load();
|
||||||
|
if let Some(action_name) = canvas_config.get_read_only_action(key_event.code, key_event.modifiers) {
|
||||||
|
let canvas_action = CanvasAction::from_string(action_name);
|
||||||
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
|
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
|
||||||
Ok(ActionResult::Success(msg)) => {
|
Ok(ActionResult::Success(msg)) => {
|
||||||
return Ok(msg.unwrap_or_default());
|
return Ok(msg.unwrap_or_default());
|
||||||
@@ -88,8 +160,7 @@ pub async fn handle_read_only_event(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if config.is_enter_edit_mode_after(key.code, key.modifiers) {
|
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 {
|
if app_state.ui.show_login {
|
||||||
let current_input = login_state.get_current_input();
|
let current_input = login_state.get_current_input();
|
||||||
let current_pos = login_state.current_cursor_pos();
|
let current_pos = login_state.current_cursor_pos();
|
||||||
@@ -119,8 +190,7 @@ pub async fn handle_read_only_event(
|
|||||||
*ideal_cursor_column = add_table_state.current_cursor_pos();
|
*ideal_cursor_column = add_table_state.current_cursor_pos();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Handle FormState (uses library CanvasState)
|
// Handle FormState
|
||||||
use canvas::CanvasState as LibraryCanvasState; // Import at the top of the function
|
|
||||||
let current_input = form_state.get_current_input();
|
let current_input = form_state.get_current_input();
|
||||||
let current_pos = form_state.current_cursor_pos();
|
let current_pos = form_state.current_cursor_pos();
|
||||||
if !current_input.is_empty() && current_pos < current_input.len() {
|
if !current_input.is_empty() && current_pos < current_input.len() {
|
||||||
@@ -134,76 +204,31 @@ pub async fn handle_read_only_event(
|
|||||||
return Ok((false, command_message.clone()));
|
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() {
|
if key.modifiers.is_empty() {
|
||||||
key_sequence_tracker.add_key(key.code);
|
key_sequence_tracker.add_key(key.code);
|
||||||
let sequence = key_sequence_tracker.get_sequence();
|
let sequence = key_sequence_tracker.get_sequence();
|
||||||
|
|
||||||
if let Some(action) = config.matches_key_sequence_generalized(&sequence).as_deref() {
|
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) {
|
// Try context-specific actions first, otherwise use canvas dispatch
|
||||||
crate::tui::functions::form::handle_action(
|
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,
|
action,
|
||||||
|
app_state,
|
||||||
form_state,
|
form_state,
|
||||||
grpc_client,
|
login_state,
|
||||||
ideal_cursor_column,
|
register_state,
|
||||||
)
|
|
||||||
.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,
|
|
||||||
add_table_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,
|
add_logic_state,
|
||||||
ideal_cursor_column,
|
ideal_cursor_column,
|
||||||
key_sequence_tracker,
|
).await
|
||||||
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?
|
|
||||||
};
|
};
|
||||||
key_sequence_tracker.reset();
|
key_sequence_tracker.reset();
|
||||||
return Ok((false, result));
|
return Ok((false, result));
|
||||||
@@ -215,62 +240,26 @@ pub async fn handle_read_only_event(
|
|||||||
|
|
||||||
if sequence.len() == 1 && !config.is_key_sequence_prefix(&sequence) {
|
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() {
|
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) {
|
// Try context-specific actions first, otherwise use canvas dispatch
|
||||||
crate::tui::functions::form::handle_action(
|
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,
|
action,
|
||||||
|
app_state,
|
||||||
form_state,
|
form_state,
|
||||||
grpc_client,
|
login_state,
|
||||||
ideal_cursor_column,
|
register_state,
|
||||||
)
|
|
||||||
.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,
|
|
||||||
add_table_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,
|
add_logic_state,
|
||||||
ideal_cursor_column,
|
ideal_cursor_column,
|
||||||
key_sequence_tracker,
|
).await
|
||||||
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?
|
|
||||||
};
|
};
|
||||||
key_sequence_tracker.reset();
|
key_sequence_tracker.reset();
|
||||||
return Ok((false, result));
|
return Ok((false, result));
|
||||||
@@ -281,62 +270,26 @@ pub async fn handle_read_only_event(
|
|||||||
key_sequence_tracker.reset();
|
key_sequence_tracker.reset();
|
||||||
|
|
||||||
if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers).as_deref() {
|
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) {
|
// Try context-specific actions first, otherwise use canvas dispatch
|
||||||
crate::tui::functions::form::handle_action(
|
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,
|
action,
|
||||||
|
app_state,
|
||||||
form_state,
|
form_state,
|
||||||
grpc_client,
|
login_state,
|
||||||
ideal_cursor_column,
|
register_state,
|
||||||
)
|
|
||||||
.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,
|
|
||||||
add_table_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,
|
add_logic_state,
|
||||||
ideal_cursor_column,
|
ideal_cursor_column,
|
||||||
key_sequence_tracker,
|
).await
|
||||||
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?
|
|
||||||
};
|
};
|
||||||
return Ok((false, result));
|
return Ok((false, result));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
use crate::tui::terminal::core::TerminalCore;
|
use crate::tui::terminal::core::TerminalCore;
|
||||||
use crate::state::app::state::AppState;
|
use crate::state::app::state::AppState;
|
||||||
use crate::state::pages::{form::FormState, auth::LoginState, auth::RegisterState};
|
use crate::state::pages::{form::FormState, auth::LoginState, auth::RegisterState};
|
||||||
use crate::state::pages::canvas_state::CanvasState;
|
use canvas::canvas::CanvasState;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
pub struct CommandHandler;
|
pub struct CommandHandler;
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ use crate::state::pages::auth::LoginState;
|
|||||||
use crate::state::pages::auth::RegisterState;
|
use crate::state::pages::auth::RegisterState;
|
||||||
use crate::state::pages::intro::IntroState;
|
use crate::state::pages::intro::IntroState;
|
||||||
use crate::state::pages::admin::AdminState;
|
use crate::state::pages::admin::AdminState;
|
||||||
use crate::state::pages::canvas_state::CanvasState;
|
|
||||||
use crate::ui::handlers::context::UiContext;
|
use crate::ui::handlers::context::UiContext;
|
||||||
use crate::modes::handlers::event::EventOutcome;
|
use crate::modes::handlers::event::EventOutcome;
|
||||||
use crate::modes::general::command_navigation::{handle_command_navigation_event, NavigationState};
|
use crate::modes::general::command_navigation::{handle_command_navigation_event, NavigationState};
|
||||||
|
use canvas::canvas::CanvasState;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
pub async fn handle_navigation_event(
|
pub async fn handle_navigation_event(
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// src/modes/handlers.rs
|
// src/modes/handlers.rs
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod event_helper;
|
|
||||||
pub mod mode_manager;
|
pub mod mode_manager;
|
||||||
|
|||||||
@@ -15,23 +15,20 @@ use crate::modes::{
|
|||||||
general::{dialog, navigation},
|
general::{dialog, navigation},
|
||||||
handlers::mode_manager::{AppMode, ModeManager},
|
handlers::mode_manager::{AppMode, ModeManager},
|
||||||
};
|
};
|
||||||
use crate::state::pages::canvas_state::CanvasState as LegacyCanvasState;
|
|
||||||
use crate::services::auth::AuthClient;
|
use crate::services::auth::AuthClient;
|
||||||
use crate::services::grpc_client::GrpcClient;
|
use crate::services::grpc_client::GrpcClient;
|
||||||
use canvas::{CanvasAction, ActionDispatcher, ActionResult};
|
use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher};
|
||||||
use canvas::CanvasState as LibraryCanvasState;
|
use canvas::canvas::CanvasState; // Only need this import now
|
||||||
use super::event_helper::*;
|
|
||||||
use crate::state::{
|
use crate::state::{
|
||||||
app::{
|
app::{
|
||||||
buffer::{AppView, BufferState},
|
buffer::{AppView, BufferState},
|
||||||
highlight::HighlightState,
|
highlight::HighlightState,
|
||||||
search::SearchState, // Correctly imported
|
search::SearchState,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
},
|
},
|
||||||
pages::{
|
pages::{
|
||||||
admin::AdminState,
|
admin::AdminState,
|
||||||
auth::{AuthState, LoginState, RegisterState},
|
auth::{AuthState, LoginState, RegisterState},
|
||||||
canvas_state::CanvasState,
|
|
||||||
form::FormState,
|
form::FormState,
|
||||||
intro::IntroState,
|
intro::IntroState,
|
||||||
},
|
},
|
||||||
@@ -48,6 +45,7 @@ use crate::ui::handlers::rat_state::UiStateHandler;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use common::proto::komp_ac::search::search_response::Hit;
|
use common::proto::komp_ac::search::search_response::Hit;
|
||||||
use crossterm::cursor::SetCursorStyle;
|
use crossterm::cursor::SetCursorStyle;
|
||||||
|
use crossterm::event::KeyModifiers;
|
||||||
use crossterm::event::{Event, KeyCode, KeyEvent};
|
use crossterm::event::{Event, KeyCode, KeyEvent};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::sync::mpsc::unbounded_channel;
|
use tokio::sync::mpsc::unbounded_channel;
|
||||||
@@ -89,7 +87,6 @@ pub struct EventHandler {
|
|||||||
pub navigation_state: NavigationState,
|
pub navigation_state: NavigationState,
|
||||||
pub search_result_sender: mpsc::UnboundedSender<Vec<Hit>>,
|
pub search_result_sender: mpsc::UnboundedSender<Vec<Hit>>,
|
||||||
pub search_result_receiver: mpsc::UnboundedReceiver<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_sender: mpsc::UnboundedSender<Vec<Hit>>,
|
||||||
pub autocomplete_result_receiver: mpsc::UnboundedReceiver<Vec<Hit>>,
|
pub autocomplete_result_receiver: mpsc::UnboundedReceiver<Vec<Hit>>,
|
||||||
}
|
}
|
||||||
@@ -103,7 +100,7 @@ impl EventHandler {
|
|||||||
grpc_client: GrpcClient,
|
grpc_client: GrpcClient,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let (search_tx, search_rx) = unbounded_channel();
|
let (search_tx, search_rx) = unbounded_channel();
|
||||||
let (autocomplete_tx, autocomplete_rx) = unbounded_channel(); // ADDED
|
let (autocomplete_tx, autocomplete_rx) = unbounded_channel();
|
||||||
Ok(EventHandler {
|
Ok(EventHandler {
|
||||||
command_mode: false,
|
command_mode: false,
|
||||||
command_input: String::new(),
|
command_input: String::new(),
|
||||||
@@ -122,7 +119,6 @@ impl EventHandler {
|
|||||||
navigation_state: NavigationState::new(),
|
navigation_state: NavigationState::new(),
|
||||||
search_result_sender: search_tx,
|
search_result_sender: search_tx,
|
||||||
search_result_receiver: search_rx,
|
search_result_receiver: search_rx,
|
||||||
// --- ADDED ---
|
|
||||||
autocomplete_result_sender: autocomplete_tx,
|
autocomplete_result_sender: autocomplete_tx,
|
||||||
autocomplete_result_receiver: autocomplete_rx,
|
autocomplete_result_receiver: autocomplete_rx,
|
||||||
})
|
})
|
||||||
@@ -136,6 +132,95 @@ impl EventHandler {
|
|||||||
self.navigation_state.activate_find_file(options);
|
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.
|
// This function handles state changes.
|
||||||
async fn handle_search_palette_event(
|
async fn handle_search_palette_event(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -199,7 +284,6 @@ impl EventHandler {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- START CORRECTED LOGIC ---
|
|
||||||
if trigger_search {
|
if trigger_search {
|
||||||
search_state.is_loading = true;
|
search_state.is_loading = true;
|
||||||
search_state.results.clear();
|
search_state.results.clear();
|
||||||
@@ -214,7 +298,6 @@ impl EventHandler {
|
|||||||
"--- 1. Spawning search task for query: '{}' ---",
|
"--- 1. Spawning search task for query: '{}' ---",
|
||||||
query
|
query
|
||||||
);
|
);
|
||||||
// We now move the grpc_client into the task, just like with login.
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
info!("--- 2. Background task started. ---");
|
info!("--- 2. Background task started. ---");
|
||||||
match grpc_client.search_table(table_name, query).await {
|
match grpc_client.search_table(table_name, query).await {
|
||||||
@@ -226,7 +309,6 @@ impl EventHandler {
|
|||||||
let _ = sender.send(response.hits);
|
let _ = sender.send(response.hits);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// THE FIX: Use the debug formatter `{:?}` to print the full error chain.
|
|
||||||
error!("--- 3b. gRPC call failed: {:?} ---", e);
|
error!("--- 3b. gRPC call failed: {:?} ---", e);
|
||||||
let _ = sender.send(vec![]);
|
let _ = sender.send(vec![]);
|
||||||
}
|
}
|
||||||
@@ -235,8 +317,6 @@ impl EventHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The borrow on `app_state.search_state` ends here.
|
|
||||||
// Now we can safely modify the Option itself.
|
|
||||||
if should_close {
|
if should_close {
|
||||||
app_state.search_state = None;
|
app_state.search_state = None;
|
||||||
app_state.ui.show_search_palette = false;
|
app_state.ui.show_search_palette = false;
|
||||||
@@ -264,7 +344,6 @@ impl EventHandler {
|
|||||||
) -> Result<EventOutcome> {
|
) -> Result<EventOutcome> {
|
||||||
if app_state.ui.show_search_palette {
|
if app_state.ui.show_search_palette {
|
||||||
if let Event::Key(key_event) = event {
|
if let Event::Key(key_event) = event {
|
||||||
// The call no longer passes grpc_client
|
|
||||||
return self
|
return self
|
||||||
.handle_search_palette_event(
|
.handle_search_palette_event(
|
||||||
key_event,
|
key_event,
|
||||||
@@ -581,7 +660,7 @@ impl EventHandler {
|
|||||||
if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise")
|
if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise")
|
||||||
&& ModeManager::can_enter_highlight_mode(current_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,
|
app_state,
|
||||||
login_state,
|
login_state,
|
||||||
register_state,
|
register_state,
|
||||||
@@ -596,13 +675,13 @@ impl EventHandler {
|
|||||||
else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode")
|
else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode")
|
||||||
&& ModeManager::can_enter_highlight_mode(current_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,
|
app_state,
|
||||||
login_state,
|
login_state,
|
||||||
register_state,
|
register_state,
|
||||||
form_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,
|
app_state,
|
||||||
login_state,
|
login_state,
|
||||||
register_state,
|
register_state,
|
||||||
@@ -627,13 +706,13 @@ impl EventHandler {
|
|||||||
else if config.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_after")
|
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)
|
&& 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,
|
app_state,
|
||||||
login_state,
|
login_state,
|
||||||
register_state,
|
register_state,
|
||||||
form_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,
|
app_state,
|
||||||
login_state,
|
login_state,
|
||||||
form_state
|
form_state
|
||||||
@@ -642,14 +721,14 @@ impl EventHandler {
|
|||||||
// Move cursor forward if possible
|
// Move cursor forward if possible
|
||||||
if !current_input.is_empty() && current_cursor_pos < current_input.len() {
|
if !current_input.is_empty() && current_cursor_pos < current_input.len() {
|
||||||
let new_cursor_pos = current_cursor_pos + 1;
|
let new_cursor_pos = current_cursor_pos + 1;
|
||||||
set_current_cursor_pos_for_state(
|
Self::set_current_cursor_pos_for_state(
|
||||||
app_state,
|
app_state,
|
||||||
login_state,
|
login_state,
|
||||||
register_state,
|
register_state,
|
||||||
form_state,
|
form_state,
|
||||||
new_cursor_pos
|
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,
|
app_state,
|
||||||
login_state,
|
login_state,
|
||||||
register_state,
|
register_state,
|
||||||
@@ -694,13 +773,12 @@ 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 app_state.ui.show_form {
|
||||||
if let Ok(Some(canvas_message)) = self.handle_form_canvas_action(
|
if let Ok(Some(canvas_message)) = self.handle_form_canvas_action(
|
||||||
key_event,
|
key_event,
|
||||||
config,
|
|
||||||
form_state,
|
form_state,
|
||||||
false, // not edit mode
|
false,
|
||||||
).await {
|
).await {
|
||||||
return Ok(EventOutcome::Ok(canvas_message));
|
return Ok(EventOutcome::Ok(canvas_message));
|
||||||
}
|
}
|
||||||
@@ -753,7 +831,7 @@ impl EventHandler {
|
|||||||
&mut admin_state.add_table_state,
|
&mut admin_state.add_table_state,
|
||||||
&mut admin_state.add_logic_state,
|
&mut admin_state.add_logic_state,
|
||||||
&mut self.key_sequence_tracker,
|
&mut self.key_sequence_tracker,
|
||||||
&mut self.grpc_client, // <-- FIX 2
|
&mut self.grpc_client,
|
||||||
&mut self.command_message,
|
&mut self.command_message,
|
||||||
&mut self.edit_mode_cooldown,
|
&mut self.edit_mode_cooldown,
|
||||||
&mut self.ideal_cursor_column,
|
&mut self.ideal_cursor_column,
|
||||||
@@ -784,13 +862,12 @@ 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 app_state.ui.show_form {
|
||||||
if let Ok(Some(canvas_message)) = self.handle_form_canvas_action(
|
if let Ok(Some(canvas_message)) = self.handle_form_canvas_action(
|
||||||
key_event,
|
key_event,
|
||||||
config,
|
|
||||||
form_state,
|
form_state,
|
||||||
true, // edit mode
|
true,
|
||||||
).await {
|
).await {
|
||||||
if !canvas_message.is_empty() {
|
if !canvas_message.is_empty() {
|
||||||
self.command_message = canvas_message.clone();
|
self.command_message = canvas_message.clone();
|
||||||
@@ -823,7 +900,7 @@ impl EventHandler {
|
|||||||
self.edit_mode_cooldown = true;
|
self.edit_mode_cooldown = true;
|
||||||
|
|
||||||
// Check for unsaved changes across all states
|
// 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,
|
app_state,
|
||||||
login_state,
|
login_state,
|
||||||
register_state,
|
register_state,
|
||||||
@@ -840,13 +917,13 @@ impl EventHandler {
|
|||||||
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
|
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
|
||||||
|
|
||||||
// Get current input and cursor position
|
// 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,
|
app_state,
|
||||||
login_state,
|
login_state,
|
||||||
register_state,
|
register_state,
|
||||||
form_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,
|
app_state,
|
||||||
login_state,
|
login_state,
|
||||||
register_state,
|
register_state,
|
||||||
@@ -856,7 +933,7 @@ impl EventHandler {
|
|||||||
// Adjust cursor if it's beyond the input length
|
// Adjust cursor if it's beyond the input length
|
||||||
if !current_input.is_empty() && current_cursor_pos >= current_input.len() {
|
if !current_input.is_empty() && current_cursor_pos >= current_input.len() {
|
||||||
let new_pos = current_input.len() - 1;
|
let new_pos = current_input.len() - 1;
|
||||||
set_current_cursor_pos_for_state(
|
Self::set_current_cursor_pos_for_state(
|
||||||
app_state,
|
app_state,
|
||||||
login_state,
|
login_state,
|
||||||
register_state,
|
register_state,
|
||||||
@@ -906,7 +983,7 @@ impl EventHandler {
|
|||||||
form_state,
|
form_state,
|
||||||
&mut self.command_input,
|
&mut self.command_input,
|
||||||
&mut self.command_message,
|
&mut self.command_message,
|
||||||
&mut self.grpc_client, // <-- FIX 5
|
&mut self.grpc_client,
|
||||||
command_handler,
|
command_handler,
|
||||||
terminal,
|
terminal,
|
||||||
&mut current_position,
|
&mut current_position,
|
||||||
@@ -1024,80 +1101,49 @@ impl EventHandler {
|
|||||||
async fn handle_form_canvas_action(
|
async fn handle_form_canvas_action(
|
||||||
&mut self,
|
&mut self,
|
||||||
key_event: KeyEvent,
|
key_event: KeyEvent,
|
||||||
_config: &Config, // Not used anymore - canvas has its own config
|
|
||||||
form_state: &mut FormState,
|
form_state: &mut FormState,
|
||||||
is_edit_mode: bool,
|
is_edit_mode: bool,
|
||||||
) -> Result<Option<String>> {
|
) -> Result<Option<String>> {
|
||||||
// Load canvas config (canvas_config.toml or vim defaults)
|
|
||||||
let canvas_config = canvas::config::CanvasConfig::load();
|
let canvas_config = canvas::config::CanvasConfig::load();
|
||||||
|
|
||||||
// Handle suggestion actions first if suggestions are active
|
// PRIORITY 1: Handle character insertion in edit mode FIRST
|
||||||
if form_state.autocomplete_active {
|
if is_edit_mode {
|
||||||
if let Some(action_str) = canvas_config.get_suggestion_action(key_event.code, key_event.modifiers) {
|
if let KeyCode::Char(c) = key_event.code {
|
||||||
let canvas_action = CanvasAction::from_string(&action_str);
|
// Only insert if it's not a special modifier combination
|
||||||
match ActionDispatcher::dispatch(canvas_action, form_state, &mut self.ideal_cursor_column).await {
|
if key_event.modifiers.is_empty() || key_event.modifiers == KeyModifiers::SHIFT {
|
||||||
Ok(result) => return Ok(Some(result.message().unwrap_or("").to_string())),
|
let canvas_action = CanvasAction::InsertChar(c);
|
||||||
Err(_) => return Ok(Some("Suggestion action failed".to_string())),
|
match ActionDispatcher::dispatch(
|
||||||
}
|
canvas_action,
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback hardcoded suggestion handling
|
|
||||||
match key_event.code {
|
|
||||||
KeyCode::Up => {
|
|
||||||
if let Ok(result) = ActionDispatcher::dispatch(
|
|
||||||
CanvasAction::SuggestionUp,
|
|
||||||
form_state,
|
form_state,
|
||||||
&mut self.ideal_cursor_column,
|
&mut self.ideal_cursor_column,
|
||||||
).await {
|
).await {
|
||||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
Ok(result) => {
|
||||||
|
return Ok(Some(result.message().unwrap_or("").to_string()));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(Some("Character insertion failed".to_string()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Down => {
|
|
||||||
if let Ok(result) = ActionDispatcher::dispatch(
|
|
||||||
CanvasAction::SuggestionDown,
|
|
||||||
form_state,
|
|
||||||
&mut self.ideal_cursor_column,
|
|
||||||
).await {
|
|
||||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Enter => {
|
|
||||||
if let Ok(result) = ActionDispatcher::dispatch(
|
|
||||||
CanvasAction::SelectSuggestion,
|
|
||||||
form_state,
|
|
||||||
&mut self.ideal_cursor_column,
|
|
||||||
).await {
|
|
||||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Esc => {
|
|
||||||
if let Ok(result) = ActionDispatcher::dispatch(
|
|
||||||
CanvasAction::ExitSuggestions,
|
|
||||||
form_state,
|
|
||||||
&mut self.ideal_cursor_column,
|
|
||||||
).await {
|
|
||||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXED: Use canvas config instead of client config
|
// PRIORITY 2: Handle config-mapped actions for non-character keys
|
||||||
let action_str = canvas_config.get_action_for_key(
|
let action_str = canvas_config.get_action_for_key(
|
||||||
key_event.code,
|
key_event.code,
|
||||||
key_event.modifiers,
|
key_event.modifiers,
|
||||||
is_edit_mode,
|
is_edit_mode,
|
||||||
form_state.autocomplete_active
|
form_state.autocomplete_active,
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(action_str) = action_str {
|
if let Some(action_str) = action_str {
|
||||||
// Filter out mode transition actions - let legacy handlers deal with these
|
// Skip mode transition actions - let the main event handler deal with them
|
||||||
if Self::is_mode_transition_action(action_str) {
|
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);
|
// Execute the config-mapped action
|
||||||
|
let canvas_action = CanvasAction::from_string(action_str);
|
||||||
match ActionDispatcher::dispatch(
|
match ActionDispatcher::dispatch(
|
||||||
canvas_action,
|
canvas_action,
|
||||||
form_state,
|
form_state,
|
||||||
@@ -1112,59 +1158,10 @@ impl EventHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to automatic key handling for edit mode
|
// No action found
|
||||||
if is_edit_mode {
|
|
||||||
if let Some(canvas_action) = CanvasAction::from_key(key_event.code) {
|
|
||||||
match ActionDispatcher::dispatch(
|
|
||||||
canvas_action,
|
|
||||||
form_state,
|
|
||||||
&mut self.ideal_cursor_column,
|
|
||||||
).await {
|
|
||||||
Ok(result) => {
|
|
||||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
return Ok(Some("Auto action failed".to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} 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),
|
|
||||||
KeyCode::Down => Some(CanvasAction::MoveDown),
|
|
||||||
KeyCode::Home => Some(CanvasAction::MoveLineStart),
|
|
||||||
KeyCode::End => Some(CanvasAction::MoveLineEnd),
|
|
||||||
KeyCode::Tab => Some(CanvasAction::NextField),
|
|
||||||
KeyCode::BackTab => Some(CanvasAction::PrevField),
|
|
||||||
KeyCode::Delete => Some(CanvasAction::DeleteForward),
|
|
||||||
KeyCode::Backspace => Some(CanvasAction::DeleteBackward),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(canvas_action) = canvas_action {
|
|
||||||
match ActionDispatcher::dispatch(
|
|
||||||
canvas_action,
|
|
||||||
form_state,
|
|
||||||
&mut self.ideal_cursor_column,
|
|
||||||
).await {
|
|
||||||
Ok(result) => {
|
|
||||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
return Ok(Some("Action failed".to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ADDED: Helper function to identify mode transition actions
|
|
||||||
fn is_mode_transition_action(action: &str) -> bool {
|
fn is_mode_transition_action(action: &str) -> bool {
|
||||||
matches!(action,
|
matches!(action,
|
||||||
"exit" |
|
"exit" |
|
||||||
@@ -1181,11 +1178,11 @@ impl EventHandler {
|
|||||||
"force_quit" |
|
"force_quit" |
|
||||||
"save_and_quit" |
|
"save_and_quit" |
|
||||||
"revert" |
|
"revert" |
|
||||||
"enter_decider" | // This is also handled specially by legacy system
|
"enter_decider" |
|
||||||
"trigger_autocomplete" | // This is handled specially by legacy system
|
"trigger_autocomplete" |
|
||||||
"suggestion_up" | // These are handled above in suggestion logic
|
"suggestion_up" |
|
||||||
"suggestion_down" |
|
"suggestion_down" |
|
||||||
"previous_entry" | // Navigation between records
|
"previous_entry" |
|
||||||
"next_entry" |
|
"next_entry" |
|
||||||
"toggle_sidebar" |
|
"toggle_sidebar" |
|
||||||
"toggle_buffer_list" |
|
"toggle_buffer_list" |
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,4 +6,3 @@ pub mod admin;
|
|||||||
pub mod intro;
|
pub mod intro;
|
||||||
pub mod add_table;
|
pub mod add_table;
|
||||||
pub mod add_logic;
|
pub mod add_logic;
|
||||||
pub mod canvas_state;
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// src/state/pages/add_logic.rs
|
// src/state/pages/add_logic.rs
|
||||||
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
|
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
|
||||||
use crate::state::pages::canvas_state::CanvasState;
|
use canvas::canvas::{CanvasState, ActionContext, CanvasAction, AppMode};
|
||||||
use crate::components::common::text_editor::{TextEditor, VimState};
|
use crate::components::common::text_editor::{TextEditor, VimState};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
@@ -54,6 +54,7 @@ pub struct AddLogicState {
|
|||||||
// New fields for same-profile table names and column autocomplete
|
// New fields for same-profile table names and column autocomplete
|
||||||
pub same_profile_table_names: Vec<String>, // Tables from same profile only
|
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
|
pub script_editor_awaiting_column_autocomplete: Option<String>, // Table name waiting for column fetch
|
||||||
|
pub app_mode: AppMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AddLogicState {
|
impl AddLogicState {
|
||||||
@@ -91,6 +92,7 @@ impl AddLogicState {
|
|||||||
|
|
||||||
same_profile_table_names: Vec::new(),
|
same_profile_table_names: Vec::new(),
|
||||||
script_editor_awaiting_column_autocomplete: None,
|
script_editor_awaiting_column_autocomplete: None,
|
||||||
|
app_mode: AppMode::Edit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,67 +227,68 @@ impl AddLogicState {
|
|||||||
self.script_editor_trigger_position = None;
|
self.script_editor_trigger_position = None;
|
||||||
self.script_editor_filter_text.clear();
|
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 {
|
impl Default for AddLogicState {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new(&EditorConfig::default())
|
let mut state = Self::new(&EditorConfig::default());
|
||||||
|
state.app_mode = AppMode::Edit;
|
||||||
|
state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Implement external library's CanvasState for AddLogicState
|
||||||
impl CanvasState for AddLogicState {
|
impl CanvasState for AddLogicState {
|
||||||
fn current_field(&self) -> usize {
|
fn current_field(&self) -> usize {
|
||||||
match self.current_focus {
|
match self.current_focus {
|
||||||
AddLogicFocus::InputLogicName => 0,
|
AddLogicFocus::InputLogicName => 0,
|
||||||
AddLogicFocus::InputTargetColumn => 1,
|
AddLogicFocus::InputTargetColumn => 1,
|
||||||
AddLogicFocus::InputDescription => 2,
|
AddLogicFocus::InputDescription => 2,
|
||||||
|
// If focus is elsewhere, return the last canvas field used
|
||||||
_ => self.last_canvas_field,
|
_ => 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) {
|
fn set_current_field(&mut self, index: usize) {
|
||||||
let new_focus = match index {
|
let new_focus = match index {
|
||||||
0 => AddLogicFocus::InputLogicName,
|
0 => AddLogicFocus::InputLogicName,
|
||||||
@@ -303,6 +306,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) {
|
fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||||
match self.current_focus {
|
match self.current_focus {
|
||||||
AddLogicFocus::InputLogicName => {
|
AddLogicFocus::InputLogicName => {
|
||||||
@@ -318,29 +330,121 @@ 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) {
|
fn set_has_unsaved_changes(&mut self, changed: bool) {
|
||||||
self.has_unsaved_changes = changed;
|
self.has_unsaved_changes = changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_suggestions(&self) -> Option<&[String]> {
|
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||||
if self.current_field() == 1
|
match action {
|
||||||
&& self.in_target_column_suggestion_mode
|
// Handle saving logic script
|
||||||
&& self.show_target_column_suggestions
|
CanvasAction::Custom(action_str) if action_str == "save_logic" => {
|
||||||
{
|
self.save_logic()
|
||||||
Some(&self.target_column_suggestions)
|
}
|
||||||
} 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_selected_suggestion_index(&self) -> Option<usize> {
|
fn current_mode(&self) -> AppMode {
|
||||||
if self.current_field() == 1
|
self.app_mode
|
||||||
&& self.in_target_column_suggestion_mode
|
|
||||||
&& self.show_target_column_suggestions
|
|
||||||
{
|
|
||||||
self.selected_target_column_suggestion_index
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// src/state/pages/add_table.rs
|
// src/state/pages/add_table.rs
|
||||||
use crate::state::pages::canvas_state::CanvasState;
|
use canvas::canvas::{CanvasState, ActionContext, CanvasAction, AppMode};
|
||||||
use ratatui::widgets::TableState;
|
use ratatui::widgets::TableState;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -63,11 +63,11 @@ pub struct AddTableState {
|
|||||||
pub column_name_cursor_pos: usize,
|
pub column_name_cursor_pos: usize,
|
||||||
pub column_type_cursor_pos: usize,
|
pub column_type_cursor_pos: usize,
|
||||||
pub has_unsaved_changes: bool,
|
pub has_unsaved_changes: bool,
|
||||||
|
pub app_mode: AppMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AddTableState {
|
impl Default for AddTableState {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
// Initialize with some dummy data for demonstration
|
|
||||||
AddTableState {
|
AddTableState {
|
||||||
profile_name: "default".to_string(),
|
profile_name: "default".to_string(),
|
||||||
table_name: String::new(),
|
table_name: String::new(),
|
||||||
@@ -86,22 +86,98 @@ impl Default for AddTableState {
|
|||||||
column_name_cursor_pos: 0,
|
column_name_cursor_pos: 0,
|
||||||
column_type_cursor_pos: 0,
|
column_type_cursor_pos: 0,
|
||||||
has_unsaved_changes: false,
|
has_unsaved_changes: false,
|
||||||
|
app_mode: AppMode::Edit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AddTableState {
|
impl AddTableState {
|
||||||
pub const INPUT_FIELD_COUNT: usize = 3;
|
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 {
|
impl CanvasState for AddTableState {
|
||||||
fn current_field(&self) -> usize {
|
fn current_field(&self) -> usize {
|
||||||
match self.current_focus {
|
match self.current_focus {
|
||||||
AddTableFocus::InputTableName => 0,
|
AddTableFocus::InputTableName => 0,
|
||||||
AddTableFocus::InputColumnName => 1,
|
AddTableFocus::InputColumnName => 1,
|
||||||
AddTableFocus::InputColumnType => 2,
|
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,
|
_ => self.last_canvas_field,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,37 +191,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) {
|
fn set_current_field(&mut self, index: usize) {
|
||||||
// Update both current focus and last canvas field
|
// Update both current focus and last canvas field
|
||||||
self.current_focus = match index {
|
self.current_focus = match index {
|
||||||
@@ -174,17 +219,88 @@ 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) {
|
fn set_has_unsaved_changes(&mut self, changed: bool) {
|
||||||
self.has_unsaved_changes = changed;
|
self.has_unsaved_changes = changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Autocomplete Support (Not needed for this form yet) ---
|
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||||
fn get_suggestions(&self) -> Option<&[String]> {
|
match action {
|
||||||
None
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_selected_suggestion_index(&self) -> Option<usize> {
|
fn current_mode(&self) -> AppMode {
|
||||||
None
|
self.app_mode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// src/state/pages/auth.rs
|
// src/state/pages/auth.rs
|
||||||
use crate::state::pages::canvas_state::CanvasState;
|
use canvas::canvas::{CanvasState, ActionContext, CanvasAction, AppMode};
|
||||||
|
use canvas::autocomplete::{AutocompleteCanvasState, AutocompleteState, SuggestionItem};
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
@@ -21,7 +22,6 @@ pub struct AuthState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Represents the state of the Login form UI
|
/// Represents the state of the Login form UI
|
||||||
#[derive(Default)]
|
|
||||||
pub struct LoginState {
|
pub struct LoginState {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
@@ -30,10 +30,26 @@ pub struct LoginState {
|
|||||||
pub current_cursor_pos: usize,
|
pub current_cursor_pos: usize,
|
||||||
pub has_unsaved_changes: bool,
|
pub has_unsaved_changes: bool,
|
||||||
pub login_request_pending: bool,
|
pub login_request_pending: bool,
|
||||||
|
pub app_mode: AppMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LoginState {
|
||||||
|
fn default() -> 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,
|
||||||
|
app_mode: AppMode::Edit,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents the state of the Registration form UI
|
/// Represents the state of the Registration form UI
|
||||||
#[derive(Default, Clone)]
|
#[derive(Clone)]
|
||||||
pub struct RegisterState {
|
pub struct RegisterState {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
@@ -44,42 +60,12 @@ pub struct RegisterState {
|
|||||||
pub current_field: usize,
|
pub current_field: usize,
|
||||||
pub current_cursor_pos: usize,
|
pub current_cursor_pos: usize,
|
||||||
pub has_unsaved_changes: bool,
|
pub has_unsaved_changes: bool,
|
||||||
pub show_role_suggestions: bool,
|
pub autocomplete: AutocompleteState<String>,
|
||||||
pub role_suggestions: Vec<String>,
|
pub app_mode: AppMode,
|
||||||
pub selected_suggestion_index: Option<usize>,
|
|
||||||
pub in_suggestion_mode: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AuthState {
|
impl Default for RegisterState {
|
||||||
/// Creates a new empty AuthState (unauthenticated)
|
fn default() -> Self {
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
auth_token: None,
|
|
||||||
user_id: None,
|
|
||||||
role: None,
|
|
||||||
decoded_username: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RegisterState {
|
|
||||||
/// Creates a new empty RegisterState
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
username: String::new(),
|
username: String::new(),
|
||||||
email: String::new(),
|
email: String::new(),
|
||||||
@@ -90,45 +76,67 @@ impl RegisterState {
|
|||||||
current_field: 0,
|
current_field: 0,
|
||||||
current_cursor_pos: 0,
|
current_cursor_pos: 0,
|
||||||
has_unsaved_changes: false,
|
has_unsaved_changes: false,
|
||||||
show_role_suggestions: false,
|
autocomplete: AutocompleteState::new(),
|
||||||
role_suggestions: Vec::new(),
|
app_mode: AppMode::Edit,
|
||||||
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
|
|
||||||
.iter()
|
|
||||||
.filter(|role| role.to_lowercase().contains(¤t_input))
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
self.show_role_suggestions = !self.role_suggestions.is_empty();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl AuthState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoginState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
app_mode: AppMode::Edit,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RegisterState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut state = Self {
|
||||||
|
autocomplete: AutocompleteState::new(),
|
||||||
|
app_mode: AppMode::Edit,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize autocomplete with role suggestions
|
||||||
|
let suggestions: Vec<SuggestionItem<String>> = AVAILABLE_ROLES
|
||||||
|
.iter()
|
||||||
|
.map(|role| SuggestionItem::simple(role.clone(), role.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 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 {
|
impl CanvasState for LoginState {
|
||||||
fn current_field(&self) -> usize {
|
fn current_field(&self) -> usize {
|
||||||
self.current_field
|
self.current_field
|
||||||
}
|
}
|
||||||
|
|
||||||
fn current_cursor_pos(&self) -> usize {
|
fn current_cursor_pos(&self) -> usize {
|
||||||
let len = match self.current_field {
|
self.current_cursor_pos
|
||||||
0 => self.username.len(),
|
|
||||||
1 => self.password.len(),
|
|
||||||
_ => 0,
|
|
||||||
};
|
|
||||||
self.current_cursor_pos.min(len)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has_unsaved_changes(&self) -> bool {
|
fn set_current_field(&mut self, index: usize) {
|
||||||
self.has_unsaved_changes
|
if index < 2 {
|
||||||
|
self.current_field = index;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inputs(&self) -> Vec<&String> {
|
fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||||
vec![&self.username, &self.password]
|
self.current_cursor_pos = pos;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_current_input(&self) -> &str {
|
fn get_current_input(&self) -> &str {
|
||||||
@@ -147,73 +155,65 @@ impl CanvasState for LoginState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn inputs(&self) -> Vec<&String> {
|
||||||
|
vec![&self.username, &self.password]
|
||||||
|
}
|
||||||
|
|
||||||
fn fields(&self) -> Vec<&str> {
|
fn fields(&self) -> Vec<&str> {
|
||||||
vec!["Username/Email", "Password"]
|
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 {
|
fn has_unsaved_changes(&self) -> bool {
|
||||||
self.has_unsaved_changes
|
self.has_unsaved_changes
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inputs(&self) -> Vec<&String> {
|
fn set_has_unsaved_changes(&mut self, changed: bool) {
|
||||||
vec![
|
self.has_unsaved_changes = changed;
|
||||||
&self.username,
|
}
|
||||||
&self.email,
|
|
||||||
&self.password,
|
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||||
&self.password_confirmation,
|
match action {
|
||||||
&self.role,
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_mode(&self) -> AppMode {
|
||||||
|
self.app_mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
fn get_current_input(&self) -> &str {
|
||||||
@@ -238,6 +238,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> {
|
fn fields(&self) -> Vec<&str> {
|
||||||
vec![
|
vec![
|
||||||
"Username",
|
"Username",
|
||||||
@@ -248,50 +258,103 @@ impl CanvasState for RegisterState {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_current_field(&mut self, index: usize) {
|
fn has_unsaved_changes(&self) -> bool {
|
||||||
if index < 5 {
|
self.has_unsaved_changes
|
||||||
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 set_has_unsaved_changes(&mut self, changed: bool) {
|
fn set_has_unsaved_changes(&mut self, changed: bool) {
|
||||||
self.has_unsaved_changes = changed;
|
self.has_unsaved_changes = changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_suggestions(&self) -> Option<&[String]> {
|
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||||
if self.current_field == 4 && self.in_suggestion_mode && self.show_role_suggestions {
|
match action {
|
||||||
Some(&self.role_suggestions)
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_mode(&self) -> AppMode {
|
||||||
|
self.app_mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add autocomplete support for RegisterState
|
||||||
|
impl AutocompleteCanvasState for RegisterState {
|
||||||
|
type SuggestionData = String;
|
||||||
|
|
||||||
|
fn supports_autocomplete(&self, field_index: usize) -> bool {
|
||||||
|
field_index == 4 // Only role field supports autocomplete
|
||||||
|
}
|
||||||
|
|
||||||
|
fn autocomplete_state(&self) -> Option<&AutocompleteState<Self::SuggestionData>> {
|
||||||
|
Some(&self.autocomplete)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> {
|
||||||
|
Some(&mut self.autocomplete)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activate_autocomplete(&mut self) {
|
||||||
|
let current_field = self.current_field();
|
||||||
|
if self.supports_autocomplete(current_field) {
|
||||||
|
self.autocomplete.activate(current_field);
|
||||||
|
|
||||||
|
// Re-filter suggestions based on current input
|
||||||
|
let current_input = self.role.to_lowercase();
|
||||||
|
let filtered_suggestions: Vec<SuggestionItem<String>> = AVAILABLE_ROLES
|
||||||
|
.iter()
|
||||||
|
.filter(|role| role.to_lowercase().contains(¤t_input))
|
||||||
|
.map(|role| SuggestionItem::simple(role.clone(), role.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
self.autocomplete.set_suggestions(filtered_suggestions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deactivate_autocomplete(&mut self) {
|
||||||
|
self.autocomplete.deactivate();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_autocomplete_active(&self) -> bool {
|
||||||
|
self.autocomplete.is_active
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_autocomplete_ready(&self) -> bool {
|
||||||
|
self.autocomplete.is_ready()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_autocomplete_selection(&mut self) -> Option<String> {
|
||||||
|
// First, get the data we need and clone it to avoid borrowing conflicts
|
||||||
|
let selection_info = self.autocomplete.get_selected().map(|selected| {
|
||||||
|
(selected.value_to_store.clone(), selected.display_text.clone())
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now do the mutable operations
|
||||||
|
if let Some((value, display_text)) = selection_info {
|
||||||
|
self.role = value;
|
||||||
|
self.set_has_unsaved_changes(true);
|
||||||
|
self.deactivate_autocomplete();
|
||||||
|
Some(format!("Selected role: {}", display_text))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_selected_suggestion_index(&self) -> Option<usize> {
|
fn set_autocomplete_suggestions(&mut self, suggestions: Vec<SuggestionItem<Self::SuggestionData>>) {
|
||||||
if self.current_field == 4 && self.in_suggestion_mode && self.show_role_suggestions {
|
if let Some(state) = self.autocomplete_state_mut() {
|
||||||
self.selected_suggestion_index
|
state.set_suggestions(suggestions);
|
||||||
} else {
|
}
|
||||||
None
|
}
|
||||||
|
|
||||||
|
fn set_autocomplete_loading(&mut self, loading: bool) {
|
||||||
|
if let Some(state) = self.autocomplete_state_mut() {
|
||||||
|
state.is_loading = loading;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
// src/state/pages/form.rs
|
// src/state/pages/form.rs
|
||||||
|
|
||||||
use crate::config::colors::themes::Theme;
|
use crate::config::colors::themes::Theme;
|
||||||
use crate::state::app::highlight::HighlightState;
|
use canvas::canvas::{CanvasState, CanvasAction, ActionContext, HighlightState, AppMode};
|
||||||
use canvas::{CanvasState, CanvasAction, ActionContext}; // CHANGED: Use canvas crate
|
|
||||||
use common::proto::komp_ac::search::search_response::Hit;
|
use common::proto::komp_ac::search::search_response::Hit;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
@@ -42,9 +41,18 @@ pub struct FormState {
|
|||||||
pub selected_suggestion_index: Option<usize>,
|
pub selected_suggestion_index: Option<usize>,
|
||||||
pub autocomplete_loading: bool,
|
pub autocomplete_loading: bool,
|
||||||
pub link_display_map: HashMap<usize, String>,
|
pub link_display_map: HashMap<usize, String>,
|
||||||
|
pub app_mode: AppMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl 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(
|
pub fn new(
|
||||||
profile_name: String,
|
profile_name: String,
|
||||||
table_name: String,
|
table_name: String,
|
||||||
@@ -67,6 +75,7 @@ impl FormState {
|
|||||||
selected_suggestion_index: None,
|
selected_suggestion_index: None,
|
||||||
autocomplete_loading: false,
|
autocomplete_loading: false,
|
||||||
link_display_map: HashMap::new(),
|
link_display_map: HashMap::new(),
|
||||||
|
app_mode: AppMode::Edit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +122,7 @@ impl FormState {
|
|||||||
area: Rect,
|
area: Rect,
|
||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
is_edit_mode: bool,
|
is_edit_mode: bool,
|
||||||
highlight_state: &HighlightState,
|
highlight_state: &HighlightState, // Now using canvas::HighlightState
|
||||||
) {
|
) {
|
||||||
let fields_str_slice: Vec<&str> =
|
let fields_str_slice: Vec<&str> =
|
||||||
self.fields().iter().map(|s| *s).collect();
|
self.fields().iter().map(|s| *s).collect();
|
||||||
@@ -146,7 +155,7 @@ impl FormState {
|
|||||||
} else {
|
} else {
|
||||||
self.current_position = 1;
|
self.current_position = 1;
|
||||||
}
|
}
|
||||||
self.deactivate_suggestions(); // CHANGED: Use canvas trait method
|
self.deactivate_autocomplete();
|
||||||
self.link_display_map.clear();
|
self.link_display_map.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,12 +214,10 @@ impl FormState {
|
|||||||
self.has_unsaved_changes = false;
|
self.has_unsaved_changes = false;
|
||||||
self.current_field = 0;
|
self.current_field = 0;
|
||||||
self.current_cursor_pos = 0;
|
self.current_cursor_pos = 0;
|
||||||
self.deactivate_suggestions(); // CHANGED: Use canvas trait method
|
self.deactivate_autocomplete();
|
||||||
self.link_display_map.clear();
|
self.link_display_map.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// REMOVED: deactivate_autocomplete() - now using trait method
|
|
||||||
|
|
||||||
// NEW: Keep the rich suggestions methods for compatibility
|
// NEW: Keep the rich suggestions methods for compatibility
|
||||||
pub fn get_rich_suggestions(&self) -> Option<&[Hit]> {
|
pub fn get_rich_suggestions(&self) -> Option<&[Hit]> {
|
||||||
if self.autocomplete_active {
|
if self.autocomplete_active {
|
||||||
@@ -226,6 +233,15 @@ impl FormState {
|
|||||||
self.selected_suggestion_index = if self.autocomplete_active { Some(0) } else { None };
|
self.selected_suggestion_index = if self.autocomplete_active { Some(0) } else { None };
|
||||||
self.autocomplete_loading = false;
|
self.autocomplete_loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NEW: Add these methods to change modes
|
||||||
|
pub fn set_edit_mode(&mut self) {
|
||||||
|
self.app_mode = AppMode::Edit;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_readonly_mode(&mut self) {
|
||||||
|
self.app_mode = AppMode::ReadOnly;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CanvasState for FormState {
|
impl CanvasState for FormState {
|
||||||
@@ -264,7 +280,7 @@ impl CanvasState for FormState {
|
|||||||
if index < self.fields.len() {
|
if index < self.fields.len() {
|
||||||
self.current_field = index;
|
self.current_field = index;
|
||||||
}
|
}
|
||||||
self.deactivate_suggestions(); // CHANGED: Use canvas trait method
|
self.deactivate_autocomplete();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_current_cursor_pos(&mut self, pos: usize) {
|
fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||||
@@ -275,55 +291,23 @@ impl CanvasState for FormState {
|
|||||||
self.has_unsaved_changes = changed;
|
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 ---
|
// --- FEATURE-SPECIFIC ACTION HANDLING ---
|
||||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||||
match action {
|
match action {
|
||||||
CanvasAction::SelectSuggestion => {
|
CanvasAction::SelectSuggestion => {
|
||||||
if let Some(selected_idx) = self.selected_suggestion_index {
|
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
|
// Extract the value from the selected suggestion
|
||||||
if let Ok(content_map) = serde_json::from_str::<HashMap<String, serde_json::Value>>(&hit.content_json) {
|
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];
|
let current_field_def = &self.fields[self.current_field];
|
||||||
if let Some(value) = content_map.get(¤t_field_def.data_key) {
|
if let Some(value) = content_map.get(¤t_field_def.data_key) {
|
||||||
let new_value = json_value_to_string(value);
|
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.get_current_input_mut() = new_value.clone();
|
||||||
self.set_current_cursor_pos(new_value.len());
|
self.set_current_cursor_pos(new_value.len());
|
||||||
self.set_has_unsaved_changes(true);
|
self.set_has_unsaved_changes(true);
|
||||||
self.deactivate_suggestions();
|
self.deactivate_autocomplete();
|
||||||
return Some(format!("Selected: {}", display_name)); // Use calculated value
|
return Some(format!("Selected: {}", display_name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,4 +331,8 @@ impl CanvasState for FormState {
|
|||||||
fn has_display_override(&self, index: usize) -> bool {
|
fn has_display_override(&self, index: usize) -> bool {
|
||||||
self.link_display_map.contains_key(&index)
|
self.link_display_map.contains_key(&index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn current_mode(&self) -> AppMode {
|
||||||
|
self.app_mode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ use crate::state::pages::auth::LoginState;
|
|||||||
use crate::state::app::state::AppState;
|
use crate::state::app::state::AppState;
|
||||||
use crate::state::app::buffer::{AppView, BufferState};
|
use crate::state::app::buffer::{AppView, BufferState};
|
||||||
use crate::config::storage::storage::{StoredAuthData, save_auth_data};
|
use crate::config::storage::storage::{StoredAuthData, save_auth_data};
|
||||||
use crate::state::pages::canvas_state::CanvasState;
|
|
||||||
use crate::ui::handlers::context::DialogPurpose;
|
use crate::ui::handlers::context::DialogPurpose;
|
||||||
use common::proto::komp_ac::auth::LoginResponse;
|
use common::proto::komp_ac::auth::LoginResponse;
|
||||||
|
use canvas::canvas::CanvasState;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use tokio::spawn;
|
use tokio::spawn;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ use crate::services::auth::AuthClient;
|
|||||||
use crate::state::{
|
use crate::state::{
|
||||||
pages::auth::RegisterState,
|
pages::auth::RegisterState,
|
||||||
app::state::AppState,
|
app::state::AppState,
|
||||||
pages::canvas_state::CanvasState,
|
|
||||||
};
|
};
|
||||||
use crate::ui::handlers::context::DialogPurpose;
|
use crate::ui::handlers::context::DialogPurpose;
|
||||||
use crate::state::app::buffer::{AppView, BufferState};
|
use crate::state::app::buffer::{AppView, BufferState};
|
||||||
use common::proto::komp_ac::auth::AuthResponse;
|
use common::proto::komp_ac::auth::AuthResponse;
|
||||||
|
use canvas::canvas::CanvasState;
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use tokio::spawn;
|
use tokio::spawn;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/tui/functions/form.rs
|
// src/tui/functions/form.rs
|
||||||
use crate::state::pages::form::FormState;
|
use crate::state::pages::form::FormState;
|
||||||
use crate::services::grpc_client::GrpcClient;
|
use crate::services::grpc_client::GrpcClient;
|
||||||
use canvas::CanvasState;
|
use canvas::canvas::CanvasState;
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
|
|
||||||
pub async fn handle_action(
|
pub async fn handle_action(
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use crate::components::{
|
|||||||
common::dialog::render_dialog,
|
common::dialog::render_dialog,
|
||||||
common::find_file_palette,
|
common::find_file_palette,
|
||||||
common::search_palette::render_search_palette,
|
common::search_palette::render_search_palette,
|
||||||
form::form::render_form,
|
|
||||||
handlers::sidebar::{self, calculate_sidebar_layout},
|
handlers::sidebar::{self, calculate_sidebar_layout},
|
||||||
intro::intro::render_intro,
|
intro::intro::render_intro,
|
||||||
render_background,
|
render_background,
|
||||||
@@ -17,9 +16,10 @@ use crate::components::{
|
|||||||
};
|
};
|
||||||
use crate::config::colors::themes::Theme;
|
use crate::config::colors::themes::Theme;
|
||||||
use crate::modes::general::command_navigation::NavigationState;
|
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::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::app::state::AppState;
|
||||||
use crate::state::pages::admin::AdminState;
|
use crate::state::pages::admin::AdminState;
|
||||||
use crate::state::pages::auth::AuthState;
|
use crate::state::pages::auth::AuthState;
|
||||||
@@ -32,6 +32,15 @@ use ratatui::{
|
|||||||
Frame,
|
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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn render_ui(
|
pub fn render_ui(
|
||||||
f: &mut Frame,
|
f: &mut Frame,
|
||||||
@@ -44,7 +53,7 @@ pub fn render_ui(
|
|||||||
buffer_state: &BufferState,
|
buffer_state: &BufferState,
|
||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
is_event_handler_edit_mode: bool,
|
is_event_handler_edit_mode: bool,
|
||||||
highlight_state: &HighlightState,
|
highlight_state: &LocalHighlightState, // Keep using local version
|
||||||
event_handler_command_input: &str,
|
event_handler_command_input: &str,
|
||||||
event_handler_command_mode_active: bool,
|
event_handler_command_mode_active: bool,
|
||||||
event_handler_command_message: &str,
|
event_handler_command_message: &str,
|
||||||
@@ -69,7 +78,6 @@ pub fn render_ui(
|
|||||||
|
|
||||||
const PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT: u16 = 15;
|
const PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT: u16 = 15;
|
||||||
|
|
||||||
|
|
||||||
let mut bottom_area_constraints: Vec<Constraint> = vec![Constraint::Length(status_line_height)];
|
let mut bottom_area_constraints: Vec<Constraint> = vec![Constraint::Length(status_line_height)];
|
||||||
let command_palette_area_height = if navigation_state.active {
|
let command_palette_area_height = if navigation_state.active {
|
||||||
1 + PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT
|
1 + PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT
|
||||||
@@ -128,8 +136,8 @@ pub fn render_ui(
|
|||||||
theme,
|
theme,
|
||||||
register_state,
|
register_state,
|
||||||
app_state,
|
app_state,
|
||||||
register_state.current_field() < 4,
|
register_state.current_field() < 4, // Now using CanvasState trait method
|
||||||
highlight_state,
|
highlight_state, // Uses local version
|
||||||
);
|
);
|
||||||
} else if app_state.ui.show_add_table {
|
} else if app_state.ui.show_add_table {
|
||||||
render_add_table(
|
render_add_table(
|
||||||
@@ -139,7 +147,7 @@ pub fn render_ui(
|
|||||||
app_state,
|
app_state,
|
||||||
&mut admin_state.add_table_state,
|
&mut admin_state.add_table_state,
|
||||||
is_event_handler_edit_mode,
|
is_event_handler_edit_mode,
|
||||||
highlight_state,
|
highlight_state, // Uses local version
|
||||||
);
|
);
|
||||||
} else if app_state.ui.show_add_logic {
|
} else if app_state.ui.show_add_logic {
|
||||||
render_add_logic(
|
render_add_logic(
|
||||||
@@ -149,7 +157,7 @@ pub fn render_ui(
|
|||||||
app_state,
|
app_state,
|
||||||
&mut admin_state.add_logic_state,
|
&mut admin_state.add_logic_state,
|
||||||
is_event_handler_edit_mode,
|
is_event_handler_edit_mode,
|
||||||
highlight_state,
|
highlight_state, // Uses local version
|
||||||
);
|
);
|
||||||
} else if app_state.ui.show_login {
|
} else if app_state.ui.show_login {
|
||||||
render_login(
|
render_login(
|
||||||
@@ -158,8 +166,8 @@ pub fn render_ui(
|
|||||||
theme,
|
theme,
|
||||||
login_state,
|
login_state,
|
||||||
app_state,
|
app_state,
|
||||||
login_state.current_field() < 2,
|
login_state.current_field() < 2, // Now using CanvasState trait method
|
||||||
highlight_state,
|
highlight_state, // Uses local version
|
||||||
);
|
);
|
||||||
} else if app_state.ui.show_admin {
|
} else if app_state.ui.show_admin {
|
||||||
crate::components::admin::admin_panel::render_admin_panel(
|
crate::components::admin::admin_panel::render_admin_panel(
|
||||||
@@ -201,12 +209,14 @@ pub fn render_ui(
|
|||||||
.split(form_actual_area)[1]
|
.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(
|
form_state.render(
|
||||||
f,
|
f,
|
||||||
form_render_area,
|
form_render_area,
|
||||||
theme,
|
theme,
|
||||||
is_event_handler_edit_mode,
|
is_event_handler_edit_mode,
|
||||||
highlight_state,
|
&canvas_highlight_state, // Use converted version
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ use crate::config::storage::storage::load_auth_data;
|
|||||||
use crate::modes::common::commands::CommandHandler;
|
use crate::modes::common::commands::CommandHandler;
|
||||||
use crate::modes::handlers::event::{EventHandler, EventOutcome};
|
use crate::modes::handlers::event::{EventHandler, EventOutcome};
|
||||||
use crate::modes::handlers::mode_manager::{AppMode, ModeManager};
|
use crate::modes::handlers::mode_manager::{AppMode, ModeManager};
|
||||||
use crate::state::pages::canvas_state::CanvasState;
|
use canvas::canvas::CanvasState; // Only external library import
|
||||||
use crate::state::pages::form::{FormState, FieldDefinition}; // Import FieldDefinition
|
use crate::state::pages::form::{FormState, FieldDefinition};
|
||||||
use crate::state::pages::auth::AuthState;
|
use crate::state::pages::auth::AuthState;
|
||||||
use crate::state::pages::auth::LoginState;
|
use crate::state::pages::auth::LoginState;
|
||||||
use crate::state::pages::auth::RegisterState;
|
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::login;
|
||||||
use crate::tui::functions::common::register;
|
use crate::tui::functions::common::register;
|
||||||
use crate::utils::columns::filter_user_columns;
|
use crate::utils::columns::filter_user_columns;
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use crossterm::cursor::SetCursorStyle;
|
use crossterm::cursor::SetCursorStyle;
|
||||||
use crossterm::event as crossterm_event;
|
use crossterm::event as crossterm_event;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Instant, Duration};
|
||||||
#[cfg(feature = "ui-debug")]
|
#[cfg(feature = "ui-debug")]
|
||||||
use crate::state::app::state::DebugState;
|
use crate::state::app::state::DebugState;
|
||||||
#[cfg(feature = "ui-debug")]
|
#[cfg(feature = "ui-debug")]
|
||||||
use crate::utils::debug_logger::pop_next_debug_message;
|
use crate::utils::debug_logger::pop_next_debug_message;
|
||||||
|
|
||||||
|
// Rest of the file remains the same...
|
||||||
pub async fn run_ui() -> Result<()> {
|
pub async fn run_ui() -> Result<()> {
|
||||||
let config = Config::load().context("Failed to load configuration")?;
|
let config = Config::load().context("Failed to load configuration")?;
|
||||||
let theme = Theme::from_str(&config.colors.theme);
|
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 {
|
if app_state.ui.show_form {
|
||||||
let current_view_profile = app_state.current_view_profile_name.clone();
|
let current_view_profile = app_state.current_view_profile_name.clone();
|
||||||
let current_view_table = app_state.current_view_table_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
|
if prev_view_profile_name != current_view_profile
|
||||||
|| prev_view_table_name != current_view_table
|
|| prev_view_table_name != current_view_table
|
||||||
{
|
{
|
||||||
if let (Some(prof_name), Some(tbl_name)) =
|
if let (Some(prof_name), Some(tbl_name)) =
|
||||||
(current_view_profile.as_ref(), current_view_table.as_ref())
|
(current_view_profile.as_ref(), current_view_table.as_ref())
|
||||||
{
|
{
|
||||||
// --- START OF REFACTORED LOGIC ---
|
|
||||||
app_state.show_loading_dialog(
|
app_state.show_loading_dialog(
|
||||||
"Loading Table",
|
"Loading Table",
|
||||||
&format!("Fetching data for {}.{}...", prof_name, tbl_name),
|
&format!("Fetching data for {}.{}...", prof_name, tbl_name),
|
||||||
);
|
);
|
||||||
needs_redraw = true;
|
needs_redraw = true;
|
||||||
|
|
||||||
// 1. Call our new, central function. It handles fetching AND caching.
|
|
||||||
match UiService::load_table_view(
|
match UiService::load_table_view(
|
||||||
&mut grpc_client,
|
&mut grpc_client,
|
||||||
&mut app_state,
|
&mut app_state,
|
||||||
@@ -374,72 +375,62 @@ pub async fn run_ui() -> Result<()> {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(mut new_form_state) => {
|
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(
|
if let Err(e) = UiService::fetch_and_set_table_count(
|
||||||
&mut grpc_client,
|
&mut grpc_client,
|
||||||
&mut new_form_state,
|
&mut new_form_state,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
// Handle count fetching error
|
|
||||||
app_state.update_dialog_content(
|
app_state.update_dialog_content(
|
||||||
&format!("Error fetching count: {}", e),
|
&format!("Error fetching count: {}", e),
|
||||||
vec!["OK".to_string()],
|
vec!["OK".to_string()],
|
||||||
DialogPurpose::LoginFailed, // Or a more appropriate purpose
|
DialogPurpose::LoginFailed,
|
||||||
);
|
);
|
||||||
} else if new_form_state.total_count > 0 {
|
} 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(
|
if let Err(e) = UiService::load_table_data_by_position(
|
||||||
&mut grpc_client,
|
&mut grpc_client,
|
||||||
&mut new_form_state,
|
&mut new_form_state,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
// Handle data loading error
|
|
||||||
app_state.update_dialog_content(
|
app_state.update_dialog_content(
|
||||||
&format!("Error loading data: {}", e),
|
&format!("Error loading data: {}", e),
|
||||||
vec!["OK".to_string()],
|
vec!["OK".to_string()],
|
||||||
DialogPurpose::LoginFailed, // Or a more appropriate purpose
|
DialogPurpose::LoginFailed,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Success! Hide the loading dialog.
|
|
||||||
app_state.hide_dialog();
|
app_state.hide_dialog();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No records, so just reset to an empty form.
|
|
||||||
new_form_state.reset_to_empty();
|
new_form_state.reset_to_empty();
|
||||||
app_state.hide_dialog();
|
app_state.hide_dialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. CRITICAL: Replace the old form_state with the new one.
|
|
||||||
form_state = new_form_state;
|
form_state = new_form_state;
|
||||||
|
|
||||||
// 4. Update our tracking variables.
|
|
||||||
prev_view_profile_name = current_view_profile;
|
prev_view_profile_name = current_view_profile;
|
||||||
prev_view_table_name = current_view_table;
|
prev_view_table_name = current_view_table;
|
||||||
table_just_switched = true;
|
table_just_switched = true;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// This handles errors from load_table_view (e.g., schema fetch failed)
|
|
||||||
app_state.update_dialog_content(
|
app_state.update_dialog_content(
|
||||||
&format!("Error loading table: {}", e),
|
&format!("Error loading table: {}", e),
|
||||||
vec!["OK".to_string()],
|
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 =
|
app_state.current_view_profile_name =
|
||||||
prev_view_profile_name.clone();
|
prev_view_profile_name.clone();
|
||||||
app_state.current_view_table_name =
|
app_state.current_view_table_name =
|
||||||
prev_view_table_name.clone();
|
prev_view_table_name.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// --- END OF REFACTORED LOGIC ---
|
|
||||||
}
|
}
|
||||||
needs_redraw = true;
|
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 let Some((profile_name, table_name)) = app_state.pending_table_structure_fetch.take() {
|
||||||
if app_state.ui.show_add_logic {
|
if app_state.ui.show_add_logic {
|
||||||
if admin_state.add_logic_state.profile_name == profile_name &&
|
if admin_state.add_logic_state.profile_name == profile_name &&
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
use rstest::{fixture, rstest};
|
use rstest::{fixture, rstest};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use client::state::pages::form::{FormState, FieldDefinition};
|
use client::state::pages::form::{FormState, FieldDefinition};
|
||||||
use client::state::pages::canvas_state::CanvasState;
|
use canvas::canvas::CanvasState;
|
||||||
|
|
||||||
#[fixture]
|
#[fixture]
|
||||||
fn test_form_state() -> FormState {
|
fn test_form_state() -> FormState {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
pub use rstest::{fixture, rstest};
|
pub use rstest::{fixture, rstest};
|
||||||
pub use client::services::grpc_client::GrpcClient;
|
pub use client::services::grpc_client::GrpcClient;
|
||||||
pub use client::state::pages::form::FormState;
|
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;
|
||||||
pub use prost_types::value::Kind;
|
pub use prost_types::value::Kind;
|
||||||
pub use std::collections::HashMap;
|
pub use std::collections::HashMap;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use sqlx::{PgPool, Transaction, Postgres};
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use common::proto::komp_ac::table_definition::{PostTableDefinitionRequest, TableDefinitionResponse};
|
use common::proto::komp_ac::table_definition::{PostTableDefinitionRequest, TableDefinitionResponse};
|
||||||
|
|
||||||
|
// TODO CRITICAL add decimal with optional precision"
|
||||||
const PREDEFINED_FIELD_TYPES: &[(&str, &str)] = &[
|
const PREDEFINED_FIELD_TYPES: &[(&str, &str)] = &[
|
||||||
("text", "TEXT"),
|
("text", "TEXT"),
|
||||||
("string", "TEXT"),
|
("string", "TEXT"),
|
||||||
|
|||||||
Reference in New Issue
Block a user