Compare commits
122 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
225bdc2bb6 | ||
|
|
8605ed1547 | ||
|
|
91cecabaca | ||
|
|
c00a214a0f | ||
|
|
0baf152c3e | ||
|
|
c92c617314 | ||
|
|
8c8ba53668 | ||
|
|
2b08e64db8 | ||
|
|
643db8e586 | ||
|
|
5c39386a3a | ||
|
|
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 | ||
|
|
a1fa42e204 | ||
|
|
306cb956a0 | ||
|
|
d837acde63 | ||
|
|
db938a2c8d | ||
|
|
f24156775a | ||
|
|
2a7f94cf17 | ||
|
|
15922ed953 | ||
|
|
7129ec97fd | ||
|
|
a921806e62 | ||
|
|
d1b28b4fdd | ||
|
|
64fd7e4af2 | ||
|
|
7b52a739c2 | ||
|
|
a4e94878e7 | ||
|
|
c7353ac81e | ||
|
|
1fbc720620 | ||
|
|
263ccc3260 | ||
|
|
00c0a399cd | ||
|
|
8127c7bb1b | ||
|
|
7437908baf | ||
|
|
9eb46cb5d3 | ||
|
|
38a70128b0 | ||
|
|
c58ce52b33 | ||
|
|
c82813185f | ||
|
|
a96681e9d6 | ||
|
|
4df6c40034 | ||
|
|
089d728cc7 | ||
|
|
aca3d718b5 | ||
|
|
8a6a584cf3 | ||
|
|
00ed0cf796 | ||
|
|
7e54b2fe43 | ||
|
|
84871faad4 | ||
|
|
bcb433d7b2 | ||
|
|
7d1b130b68 | ||
|
|
24c2376ea1 | ||
|
|
810ef5fc10 | ||
|
|
fe246b1fe6 | ||
|
|
de42bb48aa | ||
|
|
17495c49ac | ||
|
|
0e3a7a06a3 | ||
|
|
e0ee48eb9c | ||
|
|
d2053b1d5a | ||
|
|
fbe8e53858 | ||
|
|
8fe2581b3f | ||
|
|
60cc0e562e | ||
|
|
26898d474f | ||
|
|
2311fbaa3b | ||
|
|
be99cd9423 | ||
|
|
a3dd6fa95b | ||
|
|
433d87c96d | ||
|
|
aff4383671 | ||
|
|
b7c8f6b1a2 | ||
|
|
3443839ba4 | ||
|
|
6c31d48f3b | ||
|
|
1770292fd8 | ||
|
|
afdd5c5740 | ||
|
|
11487f0833 | ||
|
|
4d5d22d0c2 | ||
|
|
314a957922 | ||
|
|
4c57b562e6 | ||
|
|
a757acf51c | ||
|
|
f4a23be1a2 | ||
|
|
93c67ffa14 | ||
|
|
d1ebe4732f | ||
|
|
7b7f3ca05a | ||
|
|
234613f831 | ||
|
|
f6d84e70cc | ||
|
|
5cd324b6ae | ||
|
|
a7457f5749 | ||
|
|
a5afc75099 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,3 +2,6 @@
|
|||||||
.env
|
.env
|
||||||
/tantivy_indexes
|
/tantivy_indexes
|
||||||
server/tantivy_indexes
|
server/tantivy_indexes
|
||||||
|
steel_decimal/tests/property_tests.proptest-regressions
|
||||||
|
.direnv/
|
||||||
|
canvas/*.toml
|
||||||
|
|||||||
1075
Cargo.lock
generated
1075
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
20
Cargo.toml
20
Cargo.toml
@@ -1,17 +1,17 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["client", "server", "common", "search"]
|
members = ["client", "server", "common", "search", "canvas"]
|
||||||
resolver = "2"
|
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 = "Multieko2"
|
# name = "komp_ac"
|
||||||
version = "0.3.13"
|
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>"]
|
||||||
description = "Poriadny uctovnicky software."
|
description = "Poriadny uctovnicky software."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
repository = "https://gitlab.com/filipriec/multieko2"
|
repository = "https://gitlab.com/filipriec/komp_ac"
|
||||||
categories = ["command-line-interface"]
|
categories = ["command-line-interface"]
|
||||||
|
|
||||||
# [workspace.metadata]
|
# [workspace.metadata]
|
||||||
@@ -40,4 +40,16 @@ tracing = "0.1.41"
|
|||||||
# Search crate
|
# Search crate
|
||||||
tantivy = "0.24.1"
|
tantivy = "0.24.1"
|
||||||
|
|
||||||
|
# Steel_decimal crate
|
||||||
|
rust_decimal = { version = "1.37.2", features = ["maths", "serde"] }
|
||||||
|
rust_decimal_macros = "1.37.1"
|
||||||
|
thiserror = "2.0.12"
|
||||||
|
regex = "1.11.1"
|
||||||
|
|
||||||
|
# Canvas crate
|
||||||
|
ratatui = { version = "0.29.0", features = ["crossterm"] }
|
||||||
|
crossterm = "0.28.1"
|
||||||
|
toml = "0.8.20"
|
||||||
|
unicode-width = "0.2.0"
|
||||||
|
|
||||||
common = { path = "./common" }
|
common = { path = "./common" }
|
||||||
|
|||||||
334
canvas/CANVAS_MIGRATION.md
Normal file
334
canvas/CANVAS_MIGRATION.md
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
# Canvas Library Migration Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Key Changes
|
||||||
|
|
||||||
|
### 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:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# OLD IMPORTS
|
||||||
|
use canvas::CanvasState;
|
||||||
|
use canvas::CanvasAction;
|
||||||
|
use canvas::ActionContext;
|
||||||
|
use canvas::HighlightState;
|
||||||
|
use canvas::CanvasTheme;
|
||||||
|
use canvas::ActionDispatcher;
|
||||||
|
use canvas::ActionResult;
|
||||||
|
|
||||||
|
# NEW IMPORTS
|
||||||
|
use canvas::canvas::CanvasState;
|
||||||
|
use canvas::canvas::CanvasAction;
|
||||||
|
use canvas::canvas::ActionContext;
|
||||||
|
use canvas::canvas::HighlightState;
|
||||||
|
use canvas::canvas::CanvasTheme;
|
||||||
|
use canvas::dispatcher::ActionDispatcher;
|
||||||
|
use canvas::canvas::ActionResult;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Complex imports:**
|
||||||
|
```rust
|
||||||
|
# OLD
|
||||||
|
use canvas::{CanvasAction, ActionDispatcher, ActionResult};
|
||||||
|
|
||||||
|
# NEW
|
||||||
|
use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher, canvas::ActionResult};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Clean Up State Implementation
|
||||||
|
|
||||||
|
**Remove legacy methods from your CanvasState implementation:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl CanvasState for YourFormState {
|
||||||
|
// Keep all the core methods:
|
||||||
|
fn current_field(&self) -> usize { /* ... */ }
|
||||||
|
fn get_current_input(&self) -> &str { /* ... */ }
|
||||||
|
// ... etc
|
||||||
|
|
||||||
|
// ❌ REMOVE these legacy methods:
|
||||||
|
// fn get_suggestions(&self) -> Option<&[String]>
|
||||||
|
// fn get_selected_suggestion_index(&self) -> Option<usize>
|
||||||
|
// fn set_selected_suggestion_index(&mut self, index: Option<usize>)
|
||||||
|
// fn activate_suggestions(&mut self, suggestions: Vec<String>)
|
||||||
|
// fn deactivate_suggestions(&mut self)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Implement Rich Autocomplete (Optional)
|
||||||
|
|
||||||
|
**If you want rich autocomplete features:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use canvas::autocomplete::{AutocompleteCanvasState, SuggestionItem, AutocompleteState};
|
||||||
|
|
||||||
|
impl AutocompleteCanvasState for YourFormState {
|
||||||
|
type SuggestionData = YourDataType; // e.g., Hit, CustomRecord, etc.
|
||||||
|
|
||||||
|
fn supports_autocomplete(&self, field_index: usize) -> bool {
|
||||||
|
// Define which fields support autocomplete
|
||||||
|
matches!(field_index, 2 | 3 | 5) // Example: only certain fields
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add autocomplete field to your state:**
|
||||||
|
```rust
|
||||||
|
pub struct YourFormState {
|
||||||
|
// ... existing fields
|
||||||
|
pub autocomplete: AutocompleteState<YourDataType>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Migrate Suggestions
|
||||||
|
|
||||||
|
**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!
|
||||||
44
canvas/Cargo.toml
Normal file
44
canvas/Cargo.toml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
[package]
|
||||||
|
name = "canvas"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description.workspace = true
|
||||||
|
readme.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
categories.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
|
ratatui = { workspace = true, optional = true }
|
||||||
|
crossterm = { workspace = true, optional = true }
|
||||||
|
anyhow.workspace = true
|
||||||
|
tokio = { workspace = true, optional = true }
|
||||||
|
toml = { 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
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio-test = "0.4.4"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
gui = ["ratatui"]
|
||||||
|
autocomplete = ["tokio"]
|
||||||
|
cursor-style = ["crossterm"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "autocomplete"
|
||||||
|
required-features = ["autocomplete", "gui"]
|
||||||
|
path = "examples/autocomplete.rs"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "canvas_gui_demo"
|
||||||
|
required-features = ["gui"]
|
||||||
|
path = "examples/canvas_gui_demo.rs"
|
||||||
337
canvas/README.md
Normal file
337
canvas/README.md
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
# Canvas 🎨
|
||||||
|
|
||||||
|
A reusable, type-safe canvas system for building form-based TUI applications with vim-like modal editing.
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- **Type-Safe Actions**: No more string-based action names - everything is compile-time checked
|
||||||
|
- **Generic Design**: Implement `CanvasState` once, get navigation, editing, and suggestions for free
|
||||||
|
- **Vim-Like Experience**: Modal editing with familiar keybindings
|
||||||
|
- **Suggestion System**: Built-in autocomplete and suggestions support
|
||||||
|
- **Framework Agnostic**: Works with any TUI framework or raw terminal handling
|
||||||
|
- **Async Ready**: Full async/await support for modern Rust applications
|
||||||
|
- **Batch Operations**: Execute multiple actions atomically
|
||||||
|
- **Extensible**: Custom actions and feature-specific handling
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
Add to your `Cargo.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
cargo add canvas
|
||||||
|
```
|
||||||
|
|
||||||
|
Implement the `CanvasState` trait:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use canvas::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct LoginForm {
|
||||||
|
current_field: usize,
|
||||||
|
cursor_pos: usize,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
has_changes: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CanvasState for LoginForm {
|
||||||
|
fn current_field(&self) -> usize { self.current_field }
|
||||||
|
fn current_cursor_pos(&self) -> usize { self.cursor_pos }
|
||||||
|
fn set_current_field(&mut self, index: usize) { self.current_field = index; }
|
||||||
|
fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; }
|
||||||
|
|
||||||
|
fn get_current_input(&self) -> &str {
|
||||||
|
match self.current_field {
|
||||||
|
0 => &self.username,
|
||||||
|
1 => &self.password,
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_current_input_mut(&mut self) -> &mut String {
|
||||||
|
match self.current_field {
|
||||||
|
0 => &mut self.username,
|
||||||
|
1 => &mut self.password,
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inputs(&self) -> Vec<&String> { vec![&self.username, &self.password] }
|
||||||
|
fn fields(&self) -> Vec<&str> { vec!["Username", "Password"] }
|
||||||
|
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
||||||
|
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the type-safe action dispatcher:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut form = LoginForm::new();
|
||||||
|
let mut ideal_cursor = 0;
|
||||||
|
|
||||||
|
// Type a character - compile-time safe!
|
||||||
|
ActionDispatcher::dispatch(
|
||||||
|
CanvasAction::InsertChar('h'),
|
||||||
|
&mut form,
|
||||||
|
&mut ideal_cursor,
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
// Move to next field
|
||||||
|
ActionDispatcher::dispatch(
|
||||||
|
CanvasAction::NextField,
|
||||||
|
&mut form,
|
||||||
|
&mut ideal_cursor,
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
// Batch operations
|
||||||
|
let actions = vec![
|
||||||
|
CanvasAction::InsertChar('p'),
|
||||||
|
CanvasAction::InsertChar('a'),
|
||||||
|
CanvasAction::InsertChar('s'),
|
||||||
|
CanvasAction::InsertChar('s'),
|
||||||
|
];
|
||||||
|
|
||||||
|
ActionDispatcher::dispatch_batch(actions, &mut form, &mut ideal_cursor).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Type-Safe Actions
|
||||||
|
|
||||||
|
The Canvas system uses strongly-typed actions instead of error-prone strings:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// ✅ Type-safe - impossible to make typos
|
||||||
|
ActionDispatcher::dispatch(CanvasAction::MoveLeft, &mut form, &mut cursor).await?;
|
||||||
|
|
||||||
|
// ❌ Old way - runtime errors waiting to happen
|
||||||
|
execute_edit_action("move_left", key, &mut form, &mut cursor).await?;
|
||||||
|
execute_edit_action("move_leftt", key, &mut form, &mut cursor).await?; // Oops!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Actions
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum CanvasAction {
|
||||||
|
// Character input
|
||||||
|
InsertChar(char),
|
||||||
|
|
||||||
|
// Deletion
|
||||||
|
DeleteBackward,
|
||||||
|
DeleteForward,
|
||||||
|
|
||||||
|
// Movement
|
||||||
|
MoveLeft, MoveRight, MoveUp, MoveDown,
|
||||||
|
MoveLineStart, MoveLineEnd,
|
||||||
|
MoveWordNext, MoveWordPrev,
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
NextField, PrevField,
|
||||||
|
MoveFirstLine, MoveLastLine,
|
||||||
|
|
||||||
|
// Suggestions
|
||||||
|
SuggestionUp, SuggestionDown,
|
||||||
|
SelectSuggestion, ExitSuggestions,
|
||||||
|
|
||||||
|
// Extensibility
|
||||||
|
Custom(String),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Advanced Features
|
||||||
|
|
||||||
|
### Suggestions and Autocomplete
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl CanvasState for MyForm {
|
||||||
|
fn get_suggestions(&self) -> Option<&[String]> {
|
||||||
|
if self.suggestions.is_active {
|
||||||
|
Some(&self.suggestions.suggestions)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||||
|
match action {
|
||||||
|
CanvasAction::InsertChar('@') => {
|
||||||
|
// Trigger email suggestions
|
||||||
|
let suggestions = vec![
|
||||||
|
format!("{}@gmail.com", self.username),
|
||||||
|
format!("{}@company.com", self.username),
|
||||||
|
];
|
||||||
|
self.activate_suggestions(suggestions);
|
||||||
|
None // Let generic handler insert the '@'
|
||||||
|
}
|
||||||
|
CanvasAction::SelectSuggestion => {
|
||||||
|
if let Some(suggestion) = self.suggestions.get_selected() {
|
||||||
|
*self.get_current_input_mut() = suggestion.clone();
|
||||||
|
self.deactivate_autocomplete();
|
||||||
|
Some("Applied suggestion".to_string())
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Actions
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||||
|
match action {
|
||||||
|
CanvasAction::Custom(cmd) => match cmd.as_str() {
|
||||||
|
"uppercase" => {
|
||||||
|
*self.get_current_input_mut() = self.get_current_input().to_uppercase();
|
||||||
|
Some("Converted to uppercase".to_string())
|
||||||
|
}
|
||||||
|
"validate_email" => {
|
||||||
|
if self.get_current_input().contains('@') {
|
||||||
|
Some("Email is valid".to_string())
|
||||||
|
} else {
|
||||||
|
Some("Invalid email format".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration with TUI Frameworks
|
||||||
|
|
||||||
|
Canvas is framework-agnostic and works with any TUI library:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Works with crossterm (see examples)
|
||||||
|
// Works with termion
|
||||||
|
// Works with ratatui/tui-rs
|
||||||
|
// Works with cursive
|
||||||
|
// Works with raw terminal I/O
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
Canvas follows a clean, layered architecture:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Your Application │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ ActionDispatcher │ ← High-level API
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ CanvasAction (Type-Safe) │ ← Type safety layer
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ Action Handlers │ ← Core logic
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ CanvasState Trait │ ← Your implementation
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🤝 Why Canvas?
|
||||||
|
|
||||||
|
### Before Canvas
|
||||||
|
```rust
|
||||||
|
// ❌ Error-prone string actions
|
||||||
|
execute_action("move_left", key, state)?;
|
||||||
|
execute_action("move_leftt", key, state)?; // Runtime error!
|
||||||
|
|
||||||
|
// ❌ Duplicate navigation logic everywhere
|
||||||
|
impl MyLoginForm { /* navigation code */ }
|
||||||
|
impl MyConfigForm { /* same navigation code */ }
|
||||||
|
impl MyDataForm { /* same navigation code again */ }
|
||||||
|
|
||||||
|
// ❌ Manual cursor and field management
|
||||||
|
if key == Key::Tab {
|
||||||
|
current_field = (current_field + 1) % fields.len();
|
||||||
|
cursor_pos = cursor_pos.min(current_input.len());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Canvas
|
||||||
|
```rust
|
||||||
|
// ✅ Type-safe actions
|
||||||
|
ActionDispatcher::dispatch(CanvasAction::MoveLeft, state, cursor)?;
|
||||||
|
// Typos are impossible - won't compile!
|
||||||
|
|
||||||
|
// ✅ Implement once, use everywhere
|
||||||
|
impl CanvasState for MyForm { /* minimal implementation */ }
|
||||||
|
// All navigation, editing, suggestions work automatically!
|
||||||
|
|
||||||
|
// ✅ High-level operations
|
||||||
|
ActionDispatcher::dispatch_batch(actions, state, cursor)?;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📖 Documentation
|
||||||
|
|
||||||
|
- **API Docs**: `cargo doc --open`
|
||||||
|
- **Examples**: See `examples/` directory
|
||||||
|
- **Migration Guide**: See `CANVAS_MIGRATION.md`
|
||||||
|
|
||||||
|
## 🔄 Migration from String-Based Actions
|
||||||
|
|
||||||
|
Canvas provides backwards compatibility during migration:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Legacy support (deprecated)
|
||||||
|
execute_edit_action("move_left", key, state, cursor).await?;
|
||||||
|
|
||||||
|
// New type-safe way
|
||||||
|
ActionDispatcher::dispatch(CanvasAction::MoveLeft, state, cursor).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# Run specific example
|
||||||
|
cargo run --example simple_login
|
||||||
|
|
||||||
|
# Check type safety
|
||||||
|
cargo check
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Requirements
|
||||||
|
|
||||||
|
- Rust 1.70+
|
||||||
|
- Terminal with cursor support
|
||||||
|
- Optional: async runtime (tokio) for examples
|
||||||
|
|
||||||
|
## 🤔 FAQ
|
||||||
|
|
||||||
|
**Q: Does Canvas work with [my TUI framework]?**
|
||||||
|
A: Yes! Canvas is framework-agnostic. Just implement `CanvasState` and handle the key events.
|
||||||
|
|
||||||
|
**Q: Can I extend Canvas with custom actions?**
|
||||||
|
A: Absolutely! Use `CanvasAction::Custom("my_action")` or implement `handle_feature_action`.
|
||||||
|
|
||||||
|
**Q: Is Canvas suitable for complex forms?**
|
||||||
|
A: Yes! See the `config_screen` example for validation, suggestions, and multi-field forms.
|
||||||
|
|
||||||
|
**Q: How do I migrate from string-based actions?**
|
||||||
|
A: Canvas provides backwards compatibility. Migrate incrementally using the type-safe APIs.
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
Licensed under either of:
|
||||||
|
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
|
||||||
|
- MIT License ([LICENSE-MIT](LICENSE-MIT))
|
||||||
|
|
||||||
|
at your option.
|
||||||
|
|
||||||
|
## 🙏 Contributing
|
||||||
|
|
||||||
|
Will write here something later on, too busy rn
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Built with ❤️ for the Rust TUI community
|
||||||
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
|
||||||
|
╰─
|
||||||
|
|
||||||
392
canvas/examples/autocomplete.rs
Normal file
392
canvas/examples/autocomplete.rs
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
// 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,
|
||||||
|
theme::CanvasTheme,
|
||||||
|
},
|
||||||
|
autocomplete::gui::render_autocomplete_dropdown,
|
||||||
|
FormEditor, DataProvider, AutocompleteProvider, SuggestionItem,
|
||||||
|
};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// SIMPLE DATA PROVIDER - Only business data, no UI concerns!
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
struct ContactForm {
|
||||||
|
// Only business data - no UI state!
|
||||||
|
name: String,
|
||||||
|
email: String,
|
||||||
|
phone: String,
|
||||||
|
city: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContactForm {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
name: "John Doe".to_string(),
|
||||||
|
email: "john@".to_string(), // Partial email for demo
|
||||||
|
phone: "+1 234 567 8900".to_string(),
|
||||||
|
city: "San Francisco".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple trait implementation - only 4 methods!
|
||||||
|
impl DataProvider for ContactForm {
|
||||||
|
fn field_count(&self) -> usize { 4 }
|
||||||
|
|
||||||
|
fn field_name(&self, index: usize) -> &str {
|
||||||
|
match index {
|
||||||
|
0 => "Name",
|
||||||
|
1 => "Email",
|
||||||
|
2 => "Phone",
|
||||||
|
3 => "City",
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_value(&self, index: usize) -> &str {
|
||||||
|
match index {
|
||||||
|
0 => &self.name,
|
||||||
|
1 => &self.email,
|
||||||
|
2 => &self.phone,
|
||||||
|
3 => &self.city,
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_field_value(&mut self, index: usize, value: String) {
|
||||||
|
match index {
|
||||||
|
0 => self.name = value,
|
||||||
|
1 => self.email = value,
|
||||||
|
2 => self.phone = value,
|
||||||
|
3 => self.city = value,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_autocomplete(&self, field_index: usize) -> bool {
|
||||||
|
field_index == 1 // Only email field
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// SIMPLE AUTOCOMPLETE PROVIDER - Only data fetching!
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
struct EmailAutocomplete;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AutocompleteProvider for EmailAutocomplete {
|
||||||
|
type SuggestionData = EmailSuggestion;
|
||||||
|
|
||||||
|
async fn fetch_suggestions(&mut self, _field_index: usize, query: &str)
|
||||||
|
-> Result<Vec<SuggestionItem<Self::SuggestionData>>>
|
||||||
|
{
|
||||||
|
// Extract domain part from email
|
||||||
|
let (email_prefix, domain_part) = if let Some(at_pos) = query.find('@') {
|
||||||
|
(query[..at_pos].to_string(), query[at_pos + 1..].to_string())
|
||||||
|
} else {
|
||||||
|
return Ok(Vec::new()); // No @ symbol
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simulate async API call
|
||||||
|
let suggestions = tokio::task::spawn_blocking(move || {
|
||||||
|
// Simulate network delay
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
|
||||||
|
// Mock email suggestions
|
||||||
|
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 {
|
||||||
|
data: EmailSuggestion {
|
||||||
|
email: full_email.clone(),
|
||||||
|
provider: provider.to_string(),
|
||||||
|
},
|
||||||
|
display_text: format!("{} ({})", full_email, provider),
|
||||||
|
value_to_store: full_email,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results
|
||||||
|
}).await.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(suggestions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// APPLICATION STATE - Much simpler!
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
struct AppState {
|
||||||
|
editor: FormEditor<ContactForm>,
|
||||||
|
autocomplete: EmailAutocomplete,
|
||||||
|
debug_message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
fn new() -> Self {
|
||||||
|
let contact_form = ContactForm::new();
|
||||||
|
let mut editor = FormEditor::new(contact_form);
|
||||||
|
|
||||||
|
// Start on email field (index 1) at end of existing text
|
||||||
|
editor.set_mode(AppMode::Edit);
|
||||||
|
// TODO: Add method to set initial field/cursor position
|
||||||
|
|
||||||
|
Self {
|
||||||
|
editor,
|
||||||
|
autocomplete: EmailAutocomplete,
|
||||||
|
debug_message: "Type in email field, Tab to trigger autocomplete, Enter to select, Esc to cancel".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// INPUT HANDLING - Much cleaner!
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut AppState) -> bool {
|
||||||
|
if key == KeyCode::F(10) || (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) {
|
||||||
|
return false; // Quit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle input based on key
|
||||||
|
let result = match key {
|
||||||
|
// === AUTOCOMPLETE KEYS ===
|
||||||
|
KeyCode::Tab => {
|
||||||
|
if state.editor.is_autocomplete_active() {
|
||||||
|
state.editor.autocomplete_next();
|
||||||
|
Ok("Navigated to next suggestion".to_string())
|
||||||
|
} else if state.editor.data_provider().supports_autocomplete(state.editor.current_field()) {
|
||||||
|
state.editor.trigger_autocomplete(&mut state.autocomplete).await
|
||||||
|
.map(|_| "Triggered autocomplete".to_string())
|
||||||
|
} else {
|
||||||
|
state.editor.move_to_next_field();
|
||||||
|
Ok("Moved to next field".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if state.editor.is_autocomplete_active() {
|
||||||
|
if let Some(applied) = state.editor.apply_autocomplete() {
|
||||||
|
Ok(format!("Applied: {}", applied))
|
||||||
|
} else {
|
||||||
|
Ok("No suggestion to apply".to_string())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.editor.move_to_next_field();
|
||||||
|
Ok("Moved to next field".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCode::Esc => {
|
||||||
|
if state.editor.is_autocomplete_active() {
|
||||||
|
// Autocomplete will be cleared automatically by mode change
|
||||||
|
Ok("Cancelled autocomplete".to_string())
|
||||||
|
} else {
|
||||||
|
// Toggle between edit and readonly mode
|
||||||
|
let new_mode = match state.editor.mode() {
|
||||||
|
AppMode::Edit => AppMode::ReadOnly,
|
||||||
|
_ => AppMode::Edit,
|
||||||
|
};
|
||||||
|
state.editor.set_mode(new_mode);
|
||||||
|
Ok(format!("Switched to {:?} mode", new_mode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === MOVEMENT KEYS ===
|
||||||
|
KeyCode::Left => {
|
||||||
|
state.editor.move_left();
|
||||||
|
Ok("Moved left".to_string())
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
state.editor.move_right();
|
||||||
|
Ok("Moved right".to_string())
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
state.editor.move_to_next_field(); // TODO: Add move_up method
|
||||||
|
Ok("Moved up".to_string())
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
state.editor.move_to_next_field(); // TODO: Add move_down method
|
||||||
|
Ok("Moved down".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// === TEXT INPUT ===
|
||||||
|
KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
state.editor.insert_char(c)
|
||||||
|
.map(|_| format!("Inserted '{}'", c))
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
// TODO: Add delete_backward method to FormEditor
|
||||||
|
Ok("Backspace (not implemented yet)".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => Ok(format!("Unhandled key: {:?}", key)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update debug message
|
||||||
|
match result {
|
||||||
|
Ok(msg) => state.debug_message = msg,
|
||||||
|
Err(e) => state.debug_message = format!("Error: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut state: AppState) -> 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: &AppState, theme: &DemoTheme) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Min(8),
|
||||||
|
Constraint::Length(5),
|
||||||
|
])
|
||||||
|
.split(f.area());
|
||||||
|
|
||||||
|
// Render the canvas form - much simpler!
|
||||||
|
let active_field_rect = render_canvas(
|
||||||
|
f,
|
||||||
|
chunks[0],
|
||||||
|
&state.editor,
|
||||||
|
theme,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render autocomplete dropdown if active
|
||||||
|
if let Some(input_rect) = active_field_rect {
|
||||||
|
render_autocomplete_dropdown(
|
||||||
|
f,
|
||||||
|
chunks[0],
|
||||||
|
input_rect,
|
||||||
|
theme,
|
||||||
|
&state.editor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status info
|
||||||
|
let autocomplete_status = if state.editor.is_autocomplete_active() {
|
||||||
|
if state.editor.ui_state().is_autocomplete_loading() {
|
||||||
|
"Loading suggestions..."
|
||||||
|
} else if !state.editor.suggestions().is_empty() {
|
||||||
|
"Use 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.editor.mode(),
|
||||||
|
state.editor.current_field() + 1,
|
||||||
|
state.editor.data_provider().field_count(),
|
||||||
|
state.editor.cursor_position()))),
|
||||||
|
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 = AppState::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(())
|
||||||
|
}
|
||||||
751
canvas/examples/canvas_cursor_auto.rs
Normal file
751
canvas/examples/canvas_cursor_auto.rs
Normal file
@@ -0,0 +1,751 @@
|
|||||||
|
// examples/canvas-cursor-auto.rs
|
||||||
|
//! Demonstrates automatic cursor management with the canvas library
|
||||||
|
//!
|
||||||
|
//! This example REQUIRES the `cursor-style` feature to compile.
|
||||||
|
//!
|
||||||
|
//! Run with:
|
||||||
|
//! cargo run --example canvas_cursor_auto --features "gui,cursor-style"
|
||||||
|
//!
|
||||||
|
//! This will fail without cursor-style:
|
||||||
|
//! cargo run --example canvas-cursor-auto --features "gui"
|
||||||
|
|
||||||
|
// REQUIRE cursor-style feature - example won't compile without it
|
||||||
|
#[cfg(not(feature = "cursor-style"))]
|
||||||
|
compile_error!(
|
||||||
|
"This example requires the 'cursor-style' feature. \
|
||||||
|
Run with: cargo run --example canvas-cursor-auto --features \"gui,cursor-style\""
|
||||||
|
);
|
||||||
|
|
||||||
|
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_default,
|
||||||
|
modes::{AppMode, ModeManager, HighlightState},
|
||||||
|
CursorManager, // This import only exists when cursor-style feature is enabled
|
||||||
|
},
|
||||||
|
DataProvider, FormEditor,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enhanced FormEditor that demonstrates automatic cursor management
|
||||||
|
struct AutoCursorFormEditor<D: DataProvider> {
|
||||||
|
editor: FormEditor<D>,
|
||||||
|
highlight_state: HighlightState,
|
||||||
|
has_unsaved_changes: bool,
|
||||||
|
debug_message: String,
|
||||||
|
command_buffer: String, // For multi-key vim commands like "gg"
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<D: DataProvider> AutoCursorFormEditor<D> {
|
||||||
|
fn new(data_provider: D) -> Self {
|
||||||
|
Self {
|
||||||
|
editor: FormEditor::new(data_provider),
|
||||||
|
highlight_state: HighlightState::Off,
|
||||||
|
has_unsaved_changes: false,
|
||||||
|
debug_message: "🎯 Automatic Cursor Demo - cursor-style feature enabled!".to_string(),
|
||||||
|
command_buffer: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === COMMAND BUFFER HANDLING ===
|
||||||
|
|
||||||
|
fn clear_command_buffer(&mut self) {
|
||||||
|
self.command_buffer.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_to_command_buffer(&mut self, ch: char) {
|
||||||
|
self.command_buffer.push(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_command_buffer(&self) -> &str {
|
||||||
|
&self.command_buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_pending_command(&self) -> bool {
|
||||||
|
!self.command_buffer.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// === VISUAL/HIGHLIGHT MODE SUPPORT ===
|
||||||
|
|
||||||
|
fn enter_visual_mode(&mut self) {
|
||||||
|
if ModeManager::can_enter_highlight_mode(self.editor.mode()) {
|
||||||
|
self.editor.set_mode(AppMode::Highlight);
|
||||||
|
self.highlight_state = HighlightState::Characterwise {
|
||||||
|
anchor: (
|
||||||
|
self.editor.current_field(),
|
||||||
|
self.editor.cursor_position(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
self.debug_message = "🔥 VISUAL MODE - Cursor: Blinking Block █".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enter_visual_line_mode(&mut self) {
|
||||||
|
if ModeManager::can_enter_highlight_mode(self.editor.mode()) {
|
||||||
|
self.editor.set_mode(AppMode::Highlight);
|
||||||
|
self.highlight_state =
|
||||||
|
HighlightState::Linewise { anchor_line: self.editor.current_field() };
|
||||||
|
self.debug_message = "🔥 VISUAL LINE MODE - Cursor: Blinking Block █".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exit_visual_mode(&mut self) {
|
||||||
|
self.highlight_state = HighlightState::Off;
|
||||||
|
if self.editor.mode() == AppMode::Highlight {
|
||||||
|
self.editor.set_mode(AppMode::ReadOnly);
|
||||||
|
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_visual_selection(&mut self) {
|
||||||
|
if self.editor.mode() == AppMode::Highlight {
|
||||||
|
match &self.highlight_state {
|
||||||
|
HighlightState::Characterwise { anchor: _ } => {
|
||||||
|
self.debug_message = format!(
|
||||||
|
"🎯 Visual selection: char {} in field {} - Cursor: Blinking Block █",
|
||||||
|
self.editor.cursor_position(),
|
||||||
|
self.editor.current_field()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
HighlightState::Linewise { anchor_line: _ } => {
|
||||||
|
self.debug_message = format!(
|
||||||
|
"🎯 Visual line selection: field {} - Cursor: Blinking Block █",
|
||||||
|
self.editor.current_field()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === ENHANCED MOVEMENT WITH VISUAL UPDATES ===
|
||||||
|
|
||||||
|
fn move_left(&mut self) {
|
||||||
|
self.editor.move_left();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_right(&mut self) {
|
||||||
|
self.editor.move_right();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_up(&mut self) {
|
||||||
|
self.editor.move_up();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_down(&mut self) {
|
||||||
|
self.editor.move_down();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_word_next(&mut self) {
|
||||||
|
self.editor.move_word_next();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_word_prev(&mut self) {
|
||||||
|
self.editor.move_word_prev();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_word_end(&mut self) {
|
||||||
|
self.editor.move_word_end();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_word_end_prev(&mut self) {
|
||||||
|
self.editor.move_word_end_prev();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_line_start(&mut self) {
|
||||||
|
self.editor.move_line_start();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_line_end(&mut self) {
|
||||||
|
self.editor.move_line_end();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_first_line(&mut self) {
|
||||||
|
self.editor.move_first_line();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_last_line(&mut self) {
|
||||||
|
self.editor.move_last_line();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prev_field(&mut self) {
|
||||||
|
self.editor.prev_field();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_field(&mut self) {
|
||||||
|
self.editor.next_field();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === DELETE OPERATIONS ===
|
||||||
|
|
||||||
|
fn delete_backward(&mut self) -> anyhow::Result<()> {
|
||||||
|
let result = self.editor.delete_backward();
|
||||||
|
if result.is_ok() {
|
||||||
|
self.has_unsaved_changes = true;
|
||||||
|
self.debug_message = "⌫ Deleted character backward".to_string();
|
||||||
|
}
|
||||||
|
Ok(result?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_forward(&mut self) -> anyhow::Result<()> {
|
||||||
|
let result = self.editor.delete_forward();
|
||||||
|
if result.is_ok() {
|
||||||
|
self.has_unsaved_changes = true;
|
||||||
|
self.debug_message = "⌦ Deleted character forward".to_string();
|
||||||
|
}
|
||||||
|
Ok(result?)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === MODE TRANSITIONS WITH AUTOMATIC CURSOR MANAGEMENT ===
|
||||||
|
|
||||||
|
fn enter_edit_mode(&mut self) {
|
||||||
|
self.editor.enter_edit_mode(); // 🎯 Library automatically sets cursor to bar |
|
||||||
|
self.debug_message = "✏️ INSERT MODE - Cursor: Steady Bar |".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enter_append_mode(&mut self) {
|
||||||
|
self.editor.enter_append_mode(); // 🎯 Library automatically positions cursor and sets mode
|
||||||
|
self.debug_message = "✏️ INSERT (append) - Cursor: Steady Bar |".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exit_edit_mode(&mut self) {
|
||||||
|
self.editor.exit_edit_mode(); // 🎯 Library automatically sets cursor to block █
|
||||||
|
self.exit_visual_mode();
|
||||||
|
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
|
||||||
|
let result = self.editor.insert_char(ch);
|
||||||
|
if result.is_ok() {
|
||||||
|
self.has_unsaved_changes = true;
|
||||||
|
}
|
||||||
|
Ok(result?)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === MANUAL CURSOR OVERRIDE DEMONSTRATION ===
|
||||||
|
|
||||||
|
/// Demonstrate manual cursor control (for advanced users)
|
||||||
|
fn demo_manual_cursor_control(&mut self) -> std::io::Result<()> {
|
||||||
|
// Users can still manually control cursor if needed
|
||||||
|
CursorManager::update_for_mode(AppMode::Command)?;
|
||||||
|
self.debug_message = "🔧 Manual override: Command cursor _".to_string();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_automatic_cursor(&mut self) -> std::io::Result<()> {
|
||||||
|
// Restore automatic cursor based on current mode
|
||||||
|
CursorManager::update_for_mode(self.editor.mode())?;
|
||||||
|
self.debug_message = "🎯 Restored automatic cursor management".to_string();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// === DELEGATE TO ORIGINAL EDITOR ===
|
||||||
|
|
||||||
|
fn current_field(&self) -> usize {
|
||||||
|
self.editor.current_field()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cursor_position(&self) -> usize {
|
||||||
|
self.editor.cursor_position()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mode(&self) -> AppMode {
|
||||||
|
self.editor.mode()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_text(&self) -> &str {
|
||||||
|
self.editor.current_text()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data_provider(&self) -> &D {
|
||||||
|
self.editor.data_provider()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ui_state(&self) -> &canvas::EditorState {
|
||||||
|
self.editor.ui_state()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_mode(&mut self, mode: AppMode) {
|
||||||
|
self.editor.set_mode(mode); // 🎯 Library automatically updates cursor
|
||||||
|
if mode != AppMode::Highlight {
|
||||||
|
self.exit_visual_mode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === STATUS AND DEBUG ===
|
||||||
|
|
||||||
|
fn set_debug_message(&mut self, msg: String) {
|
||||||
|
self.debug_message = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn debug_message(&self) -> &str {
|
||||||
|
&self.debug_message
|
||||||
|
}
|
||||||
|
|
||||||
|
fn highlight_state(&self) -> &HighlightState {
|
||||||
|
&self.highlight_state
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_unsaved_changes(&self) -> bool {
|
||||||
|
self.has_unsaved_changes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demo form data with interesting text for cursor demonstration
|
||||||
|
struct CursorDemoData {
|
||||||
|
fields: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CursorDemoData {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
fields: vec![
|
||||||
|
("👤 Name".to_string(), "John-Paul McDonald".to_string()),
|
||||||
|
("📧 Email".to_string(), "user@example-domain.com".to_string()),
|
||||||
|
("📱 Phone".to_string(), "+1 (555) 123-4567".to_string()),
|
||||||
|
("🏠 Address".to_string(), "123 Main St, Apt 4B".to_string()),
|
||||||
|
("🏷️ Tags".to_string(), "urgent,important,follow-up".to_string()),
|
||||||
|
("📝 Notes".to_string(), "Watch the cursor change! Normal=█ Insert=| Visual=blinking█".to_string()),
|
||||||
|
("🎯 Cursor Demo".to_string(), "Press 'i' for insert, 'v' for visual, 'Esc' for normal".to_string()),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DataProvider for CursorDemoData {
|
||||||
|
fn field_count(&self) -> usize {
|
||||||
|
self.fields.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_name(&self, index: usize) -> &str {
|
||||||
|
&self.fields[index].0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_value(&self, index: usize) -> &str {
|
||||||
|
&self.fields[index].1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_field_value(&mut self, index: usize, value: String) {
|
||||||
|
self.fields[index].1 = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_autocomplete(&self, _field_index: usize) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_value(&self, _index: usize) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Automatic cursor management demonstration
|
||||||
|
/// Features the CursorManager directly to show it's working
|
||||||
|
fn handle_key_press(
|
||||||
|
key: KeyCode,
|
||||||
|
modifiers: KeyModifiers,
|
||||||
|
editor: &mut AutoCursorFormEditor<CursorDemoData>,
|
||||||
|
) -> anyhow::Result<bool> {
|
||||||
|
let mode = editor.mode();
|
||||||
|
|
||||||
|
// Quit handling
|
||||||
|
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL))
|
||||||
|
|| (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL))
|
||||||
|
|| key == KeyCode::F(10)
|
||||||
|
{
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
match (mode, key, modifiers) {
|
||||||
|
// === MODE TRANSITIONS WITH AUTOMATIC CURSOR MANAGEMENT ===
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('i'), _) => {
|
||||||
|
editor.enter_edit_mode(); // 🎯 Automatic: cursor becomes bar |
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
|
||||||
|
editor.enter_append_mode();
|
||||||
|
editor.set_debug_message("✏️ INSERT (append) - Cursor: Steady Bar |".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('A'), _) => {
|
||||||
|
editor.move_line_end();
|
||||||
|
editor.enter_edit_mode(); // 🎯 Automatic: cursor becomes bar |
|
||||||
|
editor.set_debug_message("✏️ INSERT (end of line) - Cursor: Steady Bar |".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('o'), _) => {
|
||||||
|
editor.move_line_end();
|
||||||
|
editor.enter_edit_mode(); // 🎯 Automatic: cursor becomes bar |
|
||||||
|
editor.set_debug_message("✏️ INSERT (open line) - Cursor: Steady Bar |".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('v'), _) => {
|
||||||
|
editor.enter_visual_mode(); // 🎯 Automatic: cursor becomes blinking block
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('V'), _) => {
|
||||||
|
editor.enter_visual_line_mode(); // 🎯 Automatic: cursor becomes blinking block
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(_, KeyCode::Esc, _) => {
|
||||||
|
editor.exit_edit_mode(); // 🎯 Automatic: cursor becomes steady block
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === CURSOR MANAGEMENT DEMONSTRATION ===
|
||||||
|
(AppMode::ReadOnly, KeyCode::F(1), _) => {
|
||||||
|
editor.demo_manual_cursor_control()?;
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly, KeyCode::F(2), _) => {
|
||||||
|
editor.restore_automatic_cursor()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === MOVEMENT: VIM-STYLE NAVIGATION ===
|
||||||
|
|
||||||
|
// Basic movement (hjkl and arrows)
|
||||||
|
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('h'), _)
|
||||||
|
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Left, _) => {
|
||||||
|
editor.move_left();
|
||||||
|
editor.set_debug_message("← left".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('l'), _)
|
||||||
|
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Right, _) => {
|
||||||
|
editor.move_right();
|
||||||
|
editor.set_debug_message("→ right".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('j'), _)
|
||||||
|
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Down, _) => {
|
||||||
|
editor.move_down();
|
||||||
|
editor.set_debug_message("↓ next field".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('k'), _)
|
||||||
|
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Up, _) => {
|
||||||
|
editor.move_up();
|
||||||
|
editor.set_debug_message("↑ previous field".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word movement
|
||||||
|
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('w'), _) => {
|
||||||
|
editor.move_word_next();
|
||||||
|
editor.set_debug_message("w: next word start".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('b'), _) => {
|
||||||
|
editor.move_word_prev();
|
||||||
|
editor.set_debug_message("b: previous word start".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('e'), _) => {
|
||||||
|
editor.move_word_end();
|
||||||
|
editor.set_debug_message("e: word end".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line movement
|
||||||
|
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('0'), _)
|
||||||
|
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Home, _) => {
|
||||||
|
editor.move_line_start();
|
||||||
|
editor.set_debug_message("0: line start".to_string());
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('$'), _)
|
||||||
|
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::End, _) => {
|
||||||
|
editor.move_line_end();
|
||||||
|
editor.set_debug_message("$: line end".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field/document movement
|
||||||
|
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('g'), _) => {
|
||||||
|
if editor.get_command_buffer() == "g" {
|
||||||
|
editor.move_first_line();
|
||||||
|
editor.set_debug_message("gg: first field".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
} else {
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
editor.add_to_command_buffer('g');
|
||||||
|
editor.set_debug_message("g".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('G'), _) => {
|
||||||
|
editor.move_last_line();
|
||||||
|
editor.set_debug_message("G: last field".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === EDIT MODE MOVEMENT ===
|
||||||
|
(AppMode::Edit, KeyCode::Left, m) if m.contains(KeyModifiers::CONTROL) => {
|
||||||
|
editor.move_word_prev();
|
||||||
|
editor.set_debug_message("Ctrl+← word back".to_string());
|
||||||
|
}
|
||||||
|
(AppMode::Edit, KeyCode::Right, m) if m.contains(KeyModifiers::CONTROL) => {
|
||||||
|
editor.move_word_next();
|
||||||
|
editor.set_debug_message("Ctrl+→ word forward".to_string());
|
||||||
|
}
|
||||||
|
(AppMode::Edit, KeyCode::Left, _) => {
|
||||||
|
editor.move_left();
|
||||||
|
}
|
||||||
|
(AppMode::Edit, KeyCode::Right, _) => {
|
||||||
|
editor.move_right();
|
||||||
|
}
|
||||||
|
(AppMode::Edit, KeyCode::Up, _) => {
|
||||||
|
editor.move_up();
|
||||||
|
}
|
||||||
|
(AppMode::Edit, KeyCode::Down, _) => {
|
||||||
|
editor.move_down();
|
||||||
|
}
|
||||||
|
(AppMode::Edit, KeyCode::Home, _) => {
|
||||||
|
editor.move_line_start();
|
||||||
|
}
|
||||||
|
(AppMode::Edit, KeyCode::End, _) => {
|
||||||
|
editor.move_line_end();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === DELETE OPERATIONS ===
|
||||||
|
(AppMode::Edit, KeyCode::Backspace, _) => {
|
||||||
|
editor.delete_backward()?;
|
||||||
|
}
|
||||||
|
(AppMode::Edit, KeyCode::Delete, _) => {
|
||||||
|
editor.delete_forward()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete operations in normal mode (vim x)
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('x'), _) => {
|
||||||
|
editor.delete_forward()?;
|
||||||
|
editor.set_debug_message("x: deleted character".to_string());
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('X'), _) => {
|
||||||
|
editor.delete_backward()?;
|
||||||
|
editor.set_debug_message("X: deleted character backward".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// === TAB NAVIGATION ===
|
||||||
|
(_, KeyCode::Tab, _) => {
|
||||||
|
editor.next_field();
|
||||||
|
editor.set_debug_message("Tab: next field".to_string());
|
||||||
|
}
|
||||||
|
(_, KeyCode::BackTab, _) => {
|
||||||
|
editor.prev_field();
|
||||||
|
editor.set_debug_message("Shift+Tab: previous field".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// === CHARACTER INPUT ===
|
||||||
|
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
|
||||||
|
editor.insert_char(c)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === DEBUG/INFO COMMANDS ===
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
|
||||||
|
editor.set_debug_message(format!(
|
||||||
|
"Field {}/{}, Pos {}, Mode: {:?} - Cursor managed automatically!",
|
||||||
|
editor.current_field() + 1,
|
||||||
|
editor.data_provider().field_count(),
|
||||||
|
editor.cursor_position(),
|
||||||
|
editor.mode()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {
|
||||||
|
if editor.has_pending_command() {
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
editor.set_debug_message("Invalid command sequence".to_string());
|
||||||
|
} else {
|
||||||
|
editor.set_debug_message(format!(
|
||||||
|
"Unhandled: {:?} + {:?} in {:?} mode",
|
||||||
|
key, modifiers, mode
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_app<B: Backend>(
|
||||||
|
terminal: &mut Terminal<B>,
|
||||||
|
mut editor: AutoCursorFormEditor<CursorDemoData>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
loop {
|
||||||
|
terminal.draw(|f| ui(f, &editor))?;
|
||||||
|
|
||||||
|
if let Event::Key(key) = event::read()? {
|
||||||
|
match handle_key_press(key.code, key.modifiers, &mut editor) {
|
||||||
|
Ok(should_continue) => {
|
||||||
|
if !should_continue {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
editor.set_debug_message(format!("Error: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ui(f: &mut Frame, editor: &AutoCursorFormEditor<CursorDemoData>) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Min(8), Constraint::Length(10)])
|
||||||
|
.split(f.area());
|
||||||
|
|
||||||
|
render_enhanced_canvas(f, chunks[0], editor);
|
||||||
|
render_status_and_help(f, chunks[1], editor);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_enhanced_canvas(
|
||||||
|
f: &mut Frame,
|
||||||
|
area: ratatui::layout::Rect,
|
||||||
|
editor: &AutoCursorFormEditor<CursorDemoData>,
|
||||||
|
) {
|
||||||
|
render_canvas_default(f, area, &editor.editor);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_status_and_help(
|
||||||
|
f: &mut Frame,
|
||||||
|
area: ratatui::layout::Rect,
|
||||||
|
editor: &AutoCursorFormEditor<CursorDemoData>,
|
||||||
|
) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Length(3), Constraint::Length(7)])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Status bar with cursor information
|
||||||
|
let mode_text = match editor.mode() {
|
||||||
|
AppMode::Edit => "INSERT | (bar cursor)",
|
||||||
|
AppMode::ReadOnly => "NORMAL █ (block cursor)",
|
||||||
|
AppMode::Highlight => match editor.highlight_state() {
|
||||||
|
HighlightState::Characterwise { .. } => "VISUAL █ (blinking block)",
|
||||||
|
HighlightState::Linewise { .. } => "VISUAL LINE █ (blinking block)",
|
||||||
|
_ => "VISUAL █ (blinking block)",
|
||||||
|
},
|
||||||
|
_ => "NORMAL █ (block cursor)",
|
||||||
|
};
|
||||||
|
|
||||||
|
let status_text = if editor.has_pending_command() {
|
||||||
|
format!("-- {} -- {} [{}]", mode_text, editor.debug_message(), editor.get_command_buffer())
|
||||||
|
} else if editor.has_unsaved_changes() {
|
||||||
|
format!("-- {} -- [Modified] {}", mode_text, editor.debug_message())
|
||||||
|
} else {
|
||||||
|
format!("-- {} -- {}", mode_text, editor.debug_message())
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = Paragraph::new(Line::from(Span::raw(status_text)))
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("🎯 Automatic Cursor Status"));
|
||||||
|
|
||||||
|
f.render_widget(status, chunks[0]);
|
||||||
|
|
||||||
|
// Enhanced help text
|
||||||
|
let help_text = match editor.mode() {
|
||||||
|
AppMode::ReadOnly => {
|
||||||
|
if editor.has_pending_command() {
|
||||||
|
match editor.get_command_buffer() {
|
||||||
|
"g" => "Press 'g' again for first field, or any other key to cancel",
|
||||||
|
_ => "Pending command... (Esc to cancel)"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"🎯 CURSOR-STYLE DEMO: Normal █ | Insert | | Visual blinking█\n\
|
||||||
|
Normal: hjkl/arrows=move, w/b/e=words, 0/$=line, gg/G=first/last\n\
|
||||||
|
i/a/A=insert, v/V=visual, x/X=delete, ?=info\n\
|
||||||
|
F1=demo manual cursor, F2=restore automatic"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppMode::Edit => {
|
||||||
|
"🎯 INSERT MODE - Cursor: | (bar)\n\
|
||||||
|
arrows=move, Ctrl+arrows=words, Backspace/Del=delete\n\
|
||||||
|
Esc=normal, Tab/Shift+Tab=fields"
|
||||||
|
}
|
||||||
|
AppMode::Highlight => {
|
||||||
|
"🎯 VISUAL MODE - Cursor: █ (blinking block)\n\
|
||||||
|
hjkl/arrows=extend selection, w/b/e=word selection\n\
|
||||||
|
Esc=normal"
|
||||||
|
}
|
||||||
|
_ => "🎯 Watch the cursor change automatically!"
|
||||||
|
};
|
||||||
|
|
||||||
|
let help = Paragraph::new(help_text)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("🚀 Automatic Cursor Management"))
|
||||||
|
.style(Style::default().fg(Color::Gray));
|
||||||
|
|
||||||
|
f.render_widget(help, chunks[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Print feature status
|
||||||
|
println!("🎯 Canvas Cursor Auto Demo");
|
||||||
|
println!("✅ cursor-style feature: ENABLED");
|
||||||
|
println!("🚀 Automatic cursor management: ACTIVE");
|
||||||
|
println!("📖 Watch your terminal cursor change based on mode!");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
let data = CursorDemoData::new();
|
||||||
|
let mut editor = AutoCursorFormEditor::new(data);
|
||||||
|
|
||||||
|
// Initialize with normal mode - library automatically sets block cursor
|
||||||
|
editor.set_mode(AppMode::ReadOnly);
|
||||||
|
|
||||||
|
// Demonstrate that CursorManager is available and working
|
||||||
|
CursorManager::update_for_mode(AppMode::ReadOnly)?;
|
||||||
|
|
||||||
|
let res = run_app(&mut terminal, editor);
|
||||||
|
|
||||||
|
// Library automatically resets cursor on FormEditor::drop()
|
||||||
|
// But we can also manually reset if needed
|
||||||
|
CursorManager::reset()?;
|
||||||
|
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(
|
||||||
|
terminal.backend_mut(),
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
DisableMouseCapture
|
||||||
|
)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
|
if let Err(err) = res {
|
||||||
|
println!("{:?}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("🎯 Cursor automatically reset to default!");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
724
canvas/examples/full_canvas_demo.rs
Normal file
724
canvas/examples/full_canvas_demo.rs
Normal file
@@ -0,0 +1,724 @@
|
|||||||
|
// examples/full_canvas_demo.rs
|
||||||
|
//! Demonstrates the FULL potential of the canvas library using the native API
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
use crossterm::{
|
||||||
|
cursor::SetCursorStyle,
|
||||||
|
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_default,
|
||||||
|
modes::{AppMode, ModeManager, HighlightState},
|
||||||
|
},
|
||||||
|
DataProvider, FormEditor,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Update cursor style based on current AppMode
|
||||||
|
fn update_cursor_for_mode(mode: AppMode) -> io::Result<()> {
|
||||||
|
let style = match mode {
|
||||||
|
AppMode::Edit => SetCursorStyle::SteadyBar, // Thin line for insert mode
|
||||||
|
AppMode::ReadOnly => SetCursorStyle::SteadyBlock, // Block for normal mode
|
||||||
|
AppMode::Highlight => SetCursorStyle::BlinkingBlock, // Blinking block for visual mode
|
||||||
|
AppMode::General => SetCursorStyle::SteadyBlock, // Block for general mode
|
||||||
|
AppMode::Command => SetCursorStyle::SteadyUnderScore, // Underscore for command mode
|
||||||
|
};
|
||||||
|
|
||||||
|
execute!(io::stdout(), style)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced FormEditor that adds visual mode and status tracking
|
||||||
|
struct EnhancedFormEditor<D: DataProvider> {
|
||||||
|
editor: FormEditor<D>,
|
||||||
|
highlight_state: HighlightState,
|
||||||
|
has_unsaved_changes: bool,
|
||||||
|
debug_message: String,
|
||||||
|
command_buffer: String, // For multi-key vim commands like "gg"
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<D: DataProvider> EnhancedFormEditor<D> {
|
||||||
|
fn new(data_provider: D) -> Self {
|
||||||
|
Self {
|
||||||
|
editor: FormEditor::new(data_provider),
|
||||||
|
highlight_state: HighlightState::Off,
|
||||||
|
has_unsaved_changes: false,
|
||||||
|
debug_message: "Full Canvas Demo - All features enabled".to_string(),
|
||||||
|
command_buffer: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === COMMAND BUFFER HANDLING ===
|
||||||
|
|
||||||
|
fn clear_command_buffer(&mut self) {
|
||||||
|
self.command_buffer.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_to_command_buffer(&mut self, ch: char) {
|
||||||
|
self.command_buffer.push(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_command_buffer(&self) -> &str {
|
||||||
|
&self.command_buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_pending_command(&self) -> bool {
|
||||||
|
!self.command_buffer.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// === VISUAL/HIGHLIGHT MODE SUPPORT ===
|
||||||
|
|
||||||
|
fn enter_visual_mode(&mut self) {
|
||||||
|
if ModeManager::can_enter_highlight_mode(self.editor.mode()) {
|
||||||
|
self.editor.set_mode(AppMode::Highlight);
|
||||||
|
self.highlight_state = HighlightState::Characterwise {
|
||||||
|
anchor: (
|
||||||
|
self.editor.current_field(),
|
||||||
|
self.editor.cursor_position(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
self.debug_message = "-- VISUAL --".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enter_visual_line_mode(&mut self) {
|
||||||
|
if ModeManager::can_enter_highlight_mode(self.editor.mode()) {
|
||||||
|
self.editor.set_mode(AppMode::Highlight);
|
||||||
|
self.highlight_state =
|
||||||
|
HighlightState::Linewise { anchor_line: self.editor.current_field() };
|
||||||
|
self.debug_message = "-- VISUAL LINE --".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exit_visual_mode(&mut self) {
|
||||||
|
self.highlight_state = HighlightState::Off;
|
||||||
|
if self.editor.mode() == AppMode::Highlight {
|
||||||
|
self.editor.set_mode(AppMode::ReadOnly);
|
||||||
|
self.debug_message = "Visual mode exited".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_visual_selection(&mut self) {
|
||||||
|
if self.editor.mode() == AppMode::Highlight {
|
||||||
|
match &self.highlight_state {
|
||||||
|
HighlightState::Characterwise { anchor: _ } => {
|
||||||
|
self.debug_message = format!(
|
||||||
|
"Visual selection: char {} in field {}",
|
||||||
|
self.editor.cursor_position(),
|
||||||
|
self.editor.current_field()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
HighlightState::Linewise { anchor_line: _ } => {
|
||||||
|
self.debug_message = format!(
|
||||||
|
"Visual line selection: field {}",
|
||||||
|
self.editor.current_field()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === ENHANCED MOVEMENT WITH VISUAL UPDATES ===
|
||||||
|
|
||||||
|
fn move_left(&mut self) {
|
||||||
|
self.editor.move_left();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_right(&mut self) {
|
||||||
|
self.editor.move_right();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_up(&mut self) {
|
||||||
|
self.editor.move_up();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_down(&mut self) {
|
||||||
|
self.editor.move_down();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_word_next(&mut self) {
|
||||||
|
self.editor.move_word_next();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_word_prev(&mut self) {
|
||||||
|
self.editor.move_word_prev();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_word_end(&mut self) {
|
||||||
|
self.editor.move_word_end();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_word_end_prev(&mut self) {
|
||||||
|
self.editor.move_word_end_prev();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_line_start(&mut self) {
|
||||||
|
self.editor.move_line_start();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_line_end(&mut self) {
|
||||||
|
self.editor.move_line_end();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_first_line(&mut self) {
|
||||||
|
self.editor.move_first_line();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_last_line(&mut self) {
|
||||||
|
self.editor.move_last_line();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prev_field(&mut self) {
|
||||||
|
self.editor.prev_field();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_field(&mut self) {
|
||||||
|
self.editor.next_field();
|
||||||
|
self.update_visual_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === DELETE OPERATIONS ===
|
||||||
|
|
||||||
|
fn delete_backward(&mut self) -> anyhow::Result<()> {
|
||||||
|
let result = self.editor.delete_backward();
|
||||||
|
if result.is_ok() {
|
||||||
|
self.has_unsaved_changes = true;
|
||||||
|
self.debug_message = "Deleted character backward".to_string();
|
||||||
|
}
|
||||||
|
Ok(result?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_forward(&mut self) -> anyhow::Result<()> {
|
||||||
|
let result = self.editor.delete_forward();
|
||||||
|
if result.is_ok() {
|
||||||
|
self.has_unsaved_changes = true;
|
||||||
|
self.debug_message = "Deleted character forward".to_string();
|
||||||
|
}
|
||||||
|
Ok(result?)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === MODE TRANSITIONS ===
|
||||||
|
|
||||||
|
fn enter_edit_mode(&mut self) {
|
||||||
|
self.editor.enter_edit_mode();
|
||||||
|
self.debug_message = "-- INSERT --".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exit_edit_mode(&mut self) {
|
||||||
|
self.editor.exit_edit_mode();
|
||||||
|
self.exit_visual_mode();
|
||||||
|
self.debug_message = "".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
|
||||||
|
let result = self.editor.insert_char(ch);
|
||||||
|
if result.is_ok() {
|
||||||
|
self.has_unsaved_changes = true;
|
||||||
|
}
|
||||||
|
Ok(result?)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === DELEGATE TO ORIGINAL EDITOR ===
|
||||||
|
|
||||||
|
fn current_field(&self) -> usize {
|
||||||
|
self.editor.current_field()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cursor_position(&self) -> usize {
|
||||||
|
self.editor.cursor_position()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mode(&self) -> AppMode {
|
||||||
|
self.editor.mode()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_text(&self) -> &str {
|
||||||
|
self.editor.current_text()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data_provider(&self) -> &D {
|
||||||
|
self.editor.data_provider()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ui_state(&self) -> &canvas::EditorState {
|
||||||
|
self.editor.ui_state()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_mode(&mut self, mode: AppMode) {
|
||||||
|
self.editor.set_mode(mode);
|
||||||
|
if mode != AppMode::Highlight {
|
||||||
|
self.exit_visual_mode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === STATUS AND DEBUG ===
|
||||||
|
|
||||||
|
fn set_debug_message(&mut self, msg: String) {
|
||||||
|
self.debug_message = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn debug_message(&self) -> &str {
|
||||||
|
&self.debug_message
|
||||||
|
}
|
||||||
|
|
||||||
|
fn highlight_state(&self) -> &HighlightState {
|
||||||
|
&self.highlight_state
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_unsaved_changes(&self) -> bool {
|
||||||
|
self.has_unsaved_changes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demo form data with interesting text for word movement
|
||||||
|
struct FullDemoData {
|
||||||
|
fields: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FullDemoData {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
fields: vec![
|
||||||
|
("Name".to_string(), "John-Paul McDonald".to_string()),
|
||||||
|
(
|
||||||
|
"Email".to_string(),
|
||||||
|
"user@example-domain.com".to_string(),
|
||||||
|
),
|
||||||
|
("Phone".to_string(), "+1 (555) 123-4567".to_string()),
|
||||||
|
("Address".to_string(), "123 Main St, Apt 4B".to_string()),
|
||||||
|
(
|
||||||
|
"Tags".to_string(),
|
||||||
|
"urgent,important,follow-up".to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Notes".to_string(),
|
||||||
|
"This is a sample note with multiple words, punctuation! And symbols @#$"
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DataProvider for FullDemoData {
|
||||||
|
fn field_count(&self) -> usize {
|
||||||
|
self.fields.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_name(&self, index: usize) -> &str {
|
||||||
|
&self.fields[index].0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_value(&self, index: usize) -> &str {
|
||||||
|
&self.fields[index].1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_field_value(&mut self, index: usize, value: String) {
|
||||||
|
self.fields[index].1 = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_autocomplete(&self, _field_index: usize) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_value(&self, _index: usize) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full vim-like key handling using the native FormEditor API
|
||||||
|
fn handle_key_press(
|
||||||
|
key: KeyCode,
|
||||||
|
modifiers: KeyModifiers,
|
||||||
|
editor: &mut EnhancedFormEditor<FullDemoData>,
|
||||||
|
) -> anyhow::Result<bool> {
|
||||||
|
let old_mode = editor.mode(); // Store mode before processing
|
||||||
|
|
||||||
|
// Quit handling
|
||||||
|
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL))
|
||||||
|
|| (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL))
|
||||||
|
|| key == KeyCode::F(10)
|
||||||
|
{
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
match (old_mode, key, modifiers) {
|
||||||
|
// === MODE TRANSITIONS ===
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('i'), _) => {
|
||||||
|
editor.enter_edit_mode();
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
|
||||||
|
editor.move_right(); // Move after current character
|
||||||
|
editor.enter_edit_mode();
|
||||||
|
editor.set_debug_message("-- INSERT -- (append)".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('A'), _) => {
|
||||||
|
editor.move_line_end();
|
||||||
|
editor.enter_edit_mode();
|
||||||
|
editor.set_debug_message("-- INSERT -- (end of line)".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('o'), _) => {
|
||||||
|
editor.move_line_end();
|
||||||
|
editor.enter_edit_mode();
|
||||||
|
editor.set_debug_message("-- INSERT -- (open line)".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('v'), _) => {
|
||||||
|
editor.enter_visual_mode();
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('V'), _) => {
|
||||||
|
editor.enter_visual_line_mode();
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(_, KeyCode::Esc, _) => {
|
||||||
|
editor.exit_edit_mode();
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === MOVEMENT: VIM-STYLE NAVIGATION ===
|
||||||
|
|
||||||
|
// Basic movement (hjkl and arrows)
|
||||||
|
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('h'), _)
|
||||||
|
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Left, _) => {
|
||||||
|
editor.move_left();
|
||||||
|
editor.set_debug_message("← left".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('l'), _)
|
||||||
|
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Right, _) => {
|
||||||
|
editor.move_right();
|
||||||
|
editor.set_debug_message("→ right".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('j'), _)
|
||||||
|
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Down, _) => {
|
||||||
|
editor.move_down();
|
||||||
|
editor.set_debug_message("↓ next field".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('k'), _)
|
||||||
|
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Up, _) => {
|
||||||
|
editor.move_up();
|
||||||
|
editor.set_debug_message("↑ previous field".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word movement - Full vim word navigation
|
||||||
|
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('w'), _) => {
|
||||||
|
editor.move_word_next();
|
||||||
|
editor.set_debug_message("w: next word start".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('b'), _) => {
|
||||||
|
editor.move_word_prev();
|
||||||
|
editor.set_debug_message("b: previous word start".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('e'), _) => {
|
||||||
|
editor.move_word_end();
|
||||||
|
editor.set_debug_message("e: word end".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('W'), _) => {
|
||||||
|
editor.move_word_end_prev();
|
||||||
|
editor.set_debug_message("W: previous word end".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line movement
|
||||||
|
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('0'), _)
|
||||||
|
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Home, _) => {
|
||||||
|
editor.move_line_start();
|
||||||
|
editor.set_debug_message("0: line start".to_string());
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('$'), _)
|
||||||
|
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::End, _) => {
|
||||||
|
editor.move_line_end();
|
||||||
|
editor.set_debug_message("$: line end".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field/document movement
|
||||||
|
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('g'), _) => {
|
||||||
|
if editor.get_command_buffer() == "g" {
|
||||||
|
// Second 'g' - execute "gg" command
|
||||||
|
editor.move_first_line();
|
||||||
|
editor.set_debug_message("gg: first field".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
} else {
|
||||||
|
// First 'g' - start command buffer
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
editor.add_to_command_buffer('g');
|
||||||
|
editor.set_debug_message("g".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('G'), _) => {
|
||||||
|
editor.move_last_line();
|
||||||
|
editor.set_debug_message("G: last field".to_string());
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === EDIT MODE MOVEMENT ===
|
||||||
|
(AppMode::Edit, KeyCode::Left, m) if m.contains(KeyModifiers::CONTROL) => {
|
||||||
|
editor.move_word_prev();
|
||||||
|
editor.set_debug_message("Ctrl+← word back".to_string());
|
||||||
|
}
|
||||||
|
(AppMode::Edit, KeyCode::Right, m) if m.contains(KeyModifiers::CONTROL) => {
|
||||||
|
editor.move_word_next();
|
||||||
|
editor.set_debug_message("Ctrl+→ word forward".to_string());
|
||||||
|
}
|
||||||
|
(AppMode::Edit, KeyCode::Left, _) => {
|
||||||
|
editor.move_left();
|
||||||
|
}
|
||||||
|
(AppMode::Edit, KeyCode::Right, _) => {
|
||||||
|
editor.move_right();
|
||||||
|
}
|
||||||
|
(AppMode::Edit, KeyCode::Up, _) => {
|
||||||
|
editor.move_up();
|
||||||
|
}
|
||||||
|
(AppMode::Edit, KeyCode::Down, _) => {
|
||||||
|
editor.move_down();
|
||||||
|
}
|
||||||
|
(AppMode::Edit, KeyCode::Home, _) => {
|
||||||
|
editor.move_line_start();
|
||||||
|
}
|
||||||
|
(AppMode::Edit, KeyCode::End, _) => {
|
||||||
|
editor.move_line_end();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === DELETE OPERATIONS ===
|
||||||
|
(AppMode::Edit, KeyCode::Backspace, _) => {
|
||||||
|
editor.delete_backward()?;
|
||||||
|
}
|
||||||
|
(AppMode::Edit, KeyCode::Delete, _) => {
|
||||||
|
editor.delete_forward()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete operations in normal mode (vim x)
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('x'), _) => {
|
||||||
|
editor.delete_forward()?;
|
||||||
|
editor.set_debug_message("x: deleted character".to_string());
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('X'), _) => {
|
||||||
|
editor.delete_backward()?;
|
||||||
|
editor.set_debug_message("X: deleted character backward".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// === TAB NAVIGATION ===
|
||||||
|
(_, KeyCode::Tab, _) => {
|
||||||
|
editor.next_field();
|
||||||
|
editor.set_debug_message("Tab: next field".to_string());
|
||||||
|
}
|
||||||
|
(_, KeyCode::BackTab, _) => {
|
||||||
|
editor.prev_field();
|
||||||
|
editor.set_debug_message("Shift+Tab: previous field".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// === CHARACTER INPUT ===
|
||||||
|
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
|
||||||
|
editor.insert_char(c)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === DEBUG/INFO COMMANDS ===
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
|
||||||
|
editor.set_debug_message(format!(
|
||||||
|
"Field {}/{}, Pos {}, Mode: {:?}",
|
||||||
|
editor.current_field() + 1,
|
||||||
|
editor.data_provider().field_count(),
|
||||||
|
editor.cursor_position(),
|
||||||
|
editor.mode()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {
|
||||||
|
// If we have a pending command and this key doesn't complete it, clear the buffer
|
||||||
|
if editor.has_pending_command() {
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
editor.set_debug_message("Invalid command sequence".to_string());
|
||||||
|
} else {
|
||||||
|
editor.set_debug_message(format!(
|
||||||
|
"Unhandled: {:?} + {:?} in {:?} mode",
|
||||||
|
key, modifiers, old_mode
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cursor if mode changed
|
||||||
|
let new_mode = editor.mode();
|
||||||
|
if old_mode != new_mode {
|
||||||
|
update_cursor_for_mode(new_mode)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_app<B: Backend>(
|
||||||
|
terminal: &mut Terminal<B>,
|
||||||
|
mut editor: EnhancedFormEditor<FullDemoData>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
loop {
|
||||||
|
terminal.draw(|f| ui(f, &editor))?;
|
||||||
|
|
||||||
|
if let Event::Key(key) = event::read()? {
|
||||||
|
match handle_key_press(key.code, key.modifiers, &mut editor) {
|
||||||
|
Ok(should_continue) => {
|
||||||
|
if !should_continue {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
editor.set_debug_message(format!("Error: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ui(f: &mut Frame, editor: &EnhancedFormEditor<FullDemoData>) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Min(8), Constraint::Length(8)])
|
||||||
|
.split(f.area());
|
||||||
|
|
||||||
|
render_enhanced_canvas(f, chunks[0], editor);
|
||||||
|
render_status_and_help(f, chunks[1], editor);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_enhanced_canvas(
|
||||||
|
f: &mut Frame,
|
||||||
|
area: ratatui::layout::Rect,
|
||||||
|
editor: &EnhancedFormEditor<FullDemoData>,
|
||||||
|
) {
|
||||||
|
render_canvas_default(f, area, &editor.editor);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_status_and_help(
|
||||||
|
f: &mut Frame,
|
||||||
|
area: ratatui::layout::Rect,
|
||||||
|
editor: &EnhancedFormEditor<FullDemoData>,
|
||||||
|
) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Length(3), Constraint::Length(5)])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Status bar
|
||||||
|
let mode_text = match editor.mode() {
|
||||||
|
AppMode::Edit => "INSERT",
|
||||||
|
AppMode::ReadOnly => "NORMAL",
|
||||||
|
AppMode::Highlight => match editor.highlight_state() {
|
||||||
|
HighlightState::Characterwise { .. } => "VISUAL",
|
||||||
|
HighlightState::Linewise { .. } => "VISUAL LINE",
|
||||||
|
_ => "VISUAL",
|
||||||
|
},
|
||||||
|
_ => "NORMAL",
|
||||||
|
};
|
||||||
|
|
||||||
|
let status_text = if editor.has_pending_command() {
|
||||||
|
format!("-- {} -- {} [{}]", mode_text, editor.debug_message(), editor.get_command_buffer())
|
||||||
|
} else if editor.has_unsaved_changes() {
|
||||||
|
format!("-- {} -- [Modified] {}", mode_text, editor.debug_message())
|
||||||
|
} else {
|
||||||
|
format!("-- {} -- {}", mode_text, editor.debug_message())
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = Paragraph::new(Line::from(Span::raw(status_text)))
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Status"));
|
||||||
|
|
||||||
|
f.render_widget(status, chunks[0]);
|
||||||
|
|
||||||
|
// Help text
|
||||||
|
let help_text = match editor.mode() {
|
||||||
|
AppMode::ReadOnly => {
|
||||||
|
if editor.has_pending_command() {
|
||||||
|
match editor.get_command_buffer() {
|
||||||
|
"g" => "Press 'g' again for first field, or any other key to cancel",
|
||||||
|
_ => "Pending command... (Esc to cancel)"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"Normal: hjkl/arrows=move, w/b/e=words, 0/$=line, gg/G=first/last, i/a/A=insert, v/V=visual, x/X=delete, ?=info"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppMode::Edit => {
|
||||||
|
"Insert: arrows=move, Ctrl+arrows=words, Backspace/Del=delete, Esc=normal, Tab/Shift+Tab=fields"
|
||||||
|
}
|
||||||
|
AppMode::Highlight => {
|
||||||
|
"Visual: hjkl/arrows=extend selection, w/b/e=word selection, Esc=normal"
|
||||||
|
}
|
||||||
|
_ => "Press ? for help"
|
||||||
|
};
|
||||||
|
|
||||||
|
let help = Paragraph::new(Line::from(Span::raw(help_text)))
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Commands"))
|
||||||
|
.style(Style::default().fg(Color::Gray));
|
||||||
|
|
||||||
|
f.render_widget(help, chunks[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 data = FullDemoData::new();
|
||||||
|
let mut editor = EnhancedFormEditor::new(data);
|
||||||
|
editor.set_mode(AppMode::ReadOnly); // Start in normal mode
|
||||||
|
|
||||||
|
// Set initial cursor style
|
||||||
|
update_cursor_for_mode(editor.mode())?;
|
||||||
|
|
||||||
|
let res = run_app(&mut terminal, editor);
|
||||||
|
|
||||||
|
// Reset cursor style on exit
|
||||||
|
execute!(io::stdout(), SetCursorStyle::DefaultUserShape)?;
|
||||||
|
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(
|
||||||
|
terminal.backend_mut(),
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
DisableMouseCapture
|
||||||
|
)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
|
if let Err(err) = res {
|
||||||
|
println!("{:?}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
620
canvas/integration_patterns.rs
Normal file
620
canvas/integration_patterns.rs
Normal file
@@ -0,0 +1,620 @@
|
|||||||
|
// 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>>;
|
||||||
|
|
||||||
|
// Custom Debug implementation since function pointers don't implement Debug
|
||||||
|
struct ValidatedForm {
|
||||||
|
current_field: usize,
|
||||||
|
cursor_pos: usize,
|
||||||
|
password: String,
|
||||||
|
has_changes: bool,
|
||||||
|
validators: HashMap<usize, Vec<ValidationRule>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for ValidatedForm {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("ValidatedForm")
|
||||||
|
.field("current_field", &self.current_field)
|
||||||
|
.field("cursor_pos", &self.cursor_pos)
|
||||||
|
.field("password", &self.password)
|
||||||
|
.field("has_changes", &self.has_changes)
|
||||||
|
.field("validators", &format!("HashMap with {} entries", self.validators.len()))
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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);
|
||||||
|
}
|
||||||
194
canvas/src/autocomplete/gui.rs
Normal file
194
canvas/src/autocomplete/gui.rs
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
// src/autocomplete/gui.rs
|
||||||
|
//! Autocomplete GUI updated to work with FormEditor
|
||||||
|
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Alignment, Rect},
|
||||||
|
style::{Modifier, Style},
|
||||||
|
widgets::{Block, List, ListItem, ListState, Paragraph}, // Removed Borders
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
use crate::canvas::theme::CanvasTheme;
|
||||||
|
use crate::data_provider::{DataProvider, SuggestionItem};
|
||||||
|
use crate::editor::FormEditor;
|
||||||
|
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
/// Render autocomplete dropdown for FormEditor - call this AFTER rendering canvas
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub fn render_autocomplete_dropdown<T: CanvasTheme, D: DataProvider>(
|
||||||
|
f: &mut Frame,
|
||||||
|
frame_area: Rect,
|
||||||
|
input_rect: Rect,
|
||||||
|
theme: &T,
|
||||||
|
editor: &FormEditor<D>,
|
||||||
|
) {
|
||||||
|
let ui_state = editor.ui_state();
|
||||||
|
|
||||||
|
if !ui_state.is_autocomplete_active() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui_state.autocomplete.is_loading {
|
||||||
|
render_loading_indicator(f, frame_area, input_rect, theme);
|
||||||
|
} else if !editor.suggestions().is_empty() {
|
||||||
|
render_suggestions_dropdown(f, frame_area, input_rect, theme, editor.suggestions(), ui_state.autocomplete.selected_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show loading spinner/text
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
fn render_loading_indicator<T: CanvasTheme>(
|
||||||
|
f: &mut Frame,
|
||||||
|
frame_area: Rect,
|
||||||
|
input_rect: Rect,
|
||||||
|
theme: &T,
|
||||||
|
) {
|
||||||
|
let loading_text = "Loading suggestions...";
|
||||||
|
let loading_width = loading_text.width() as u16 + 4; // +4 for borders and padding
|
||||||
|
let loading_height = 3;
|
||||||
|
|
||||||
|
let dropdown_area = calculate_dropdown_position(
|
||||||
|
input_rect,
|
||||||
|
frame_area,
|
||||||
|
loading_width,
|
||||||
|
loading_height,
|
||||||
|
);
|
||||||
|
|
||||||
|
let loading_block = Block::default()
|
||||||
|
.style(Style::default().bg(theme.bg()));
|
||||||
|
|
||||||
|
let loading_paragraph = Paragraph::new(loading_text)
|
||||||
|
.block(loading_block)
|
||||||
|
.style(Style::default().fg(theme.fg()))
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
|
||||||
|
f.render_widget(loading_paragraph, dropdown_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show actual suggestions list
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
fn render_suggestions_dropdown<T: CanvasTheme>(
|
||||||
|
f: &mut Frame,
|
||||||
|
frame_area: Rect,
|
||||||
|
input_rect: Rect,
|
||||||
|
theme: &T,
|
||||||
|
suggestions: &[SuggestionItem], // Fixed: Removed <String> generic parameter
|
||||||
|
selected_index: Option<usize>,
|
||||||
|
) {
|
||||||
|
let display_texts: Vec<&str> = 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,
|
||||||
|
selected_index,
|
||||||
|
dropdown_dimensions.width,
|
||||||
|
theme,
|
||||||
|
);
|
||||||
|
|
||||||
|
let list = List::new(items).block(dropdown_block);
|
||||||
|
let mut list_state = ListState::default();
|
||||||
|
list_state.select(selected_index);
|
||||||
|
|
||||||
|
f.render_stateful_widget(list, dropdown_area, &mut list_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate dropdown size based on suggestions
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions {
|
||||||
|
let max_width = display_texts
|
||||||
|
.iter()
|
||||||
|
.map(|text| text.width())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0) as u16;
|
||||||
|
|
||||||
|
let horizontal_padding = 2;
|
||||||
|
let width = (max_width + horizontal_padding).max(10);
|
||||||
|
let height = (display_texts.len() as u16).min(5);
|
||||||
|
|
||||||
|
DropdownDimensions { width, height }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Position dropdown to stay in bounds
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
fn calculate_dropdown_position(
|
||||||
|
input_rect: Rect,
|
||||||
|
frame_area: Rect,
|
||||||
|
dropdown_width: u16,
|
||||||
|
dropdown_height: u16,
|
||||||
|
) -> Rect {
|
||||||
|
let mut dropdown_area = Rect {
|
||||||
|
x: input_rect.x,
|
||||||
|
y: input_rect.y + 1, // below input field
|
||||||
|
width: dropdown_width,
|
||||||
|
height: dropdown_height,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep in bounds
|
||||||
|
if dropdown_area.bottom() > frame_area.height {
|
||||||
|
dropdown_area.y = input_rect.y.saturating_sub(dropdown_height);
|
||||||
|
}
|
||||||
|
if dropdown_area.right() > frame_area.width {
|
||||||
|
dropdown_area.x = frame_area.width.saturating_sub(dropdown_width);
|
||||||
|
}
|
||||||
|
dropdown_area.x = dropdown_area.x.max(0);
|
||||||
|
dropdown_area.y = dropdown_area.y.max(0);
|
||||||
|
|
||||||
|
dropdown_area
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create styled list items
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
fn create_suggestion_list_items<'a, T: CanvasTheme>(
|
||||||
|
display_texts: &'a [&'a str],
|
||||||
|
selected_index: Option<usize>,
|
||||||
|
dropdown_width: u16,
|
||||||
|
theme: &T,
|
||||||
|
) -> Vec<ListItem<'a>> {
|
||||||
|
let available_width = dropdown_width;
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
12
canvas/src/autocomplete/mod.rs
Normal file
12
canvas/src/autocomplete/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// src/autocomplete/mod.rs
|
||||||
|
|
||||||
|
pub mod state;
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub mod gui;
|
||||||
|
|
||||||
|
// Re-export the main autocomplete types
|
||||||
|
pub use state::{AutocompleteProvider, SuggestionItem};
|
||||||
|
|
||||||
|
// Re-export GUI functions if available
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub use gui::render_autocomplete_dropdown;
|
||||||
5
canvas/src/autocomplete/state.rs
Normal file
5
canvas/src/autocomplete/state.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// src/autocomplete/state.rs
|
||||||
|
//! Autocomplete provider types
|
||||||
|
|
||||||
|
// Re-export the main types from data_provider
|
||||||
|
pub use crate::data_provider::{AutocompleteProvider, SuggestionItem};
|
||||||
7
canvas/src/canvas/actions/mod.rs
Normal file
7
canvas/src/canvas/actions/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// src/canvas/actions/mod.rs
|
||||||
|
|
||||||
|
pub mod types;
|
||||||
|
pub mod movement;
|
||||||
|
|
||||||
|
// Re-export the main API
|
||||||
|
pub use types::{CanvasAction, ActionResult};
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
171
canvas/src/canvas/actions/types.rs
Normal file
171
canvas/src/canvas/actions/types.rs
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
// src/canvas/actions/types.rs
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
45
canvas/src/canvas/cursor.rs
Normal file
45
canvas/src/canvas/cursor.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// src/canvas/cursor.rs
|
||||||
|
//! Cursor style management for different canvas modes
|
||||||
|
|
||||||
|
#[cfg(feature = "cursor-style")]
|
||||||
|
use crossterm::{cursor::SetCursorStyle, execute};
|
||||||
|
#[cfg(feature = "cursor-style")]
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use crate::canvas::modes::AppMode;
|
||||||
|
|
||||||
|
/// Manages cursor styles based on canvas modes
|
||||||
|
pub struct CursorManager;
|
||||||
|
|
||||||
|
impl CursorManager {
|
||||||
|
/// Update cursor style based on current mode
|
||||||
|
#[cfg(feature = "cursor-style")]
|
||||||
|
pub fn update_for_mode(mode: AppMode) -> io::Result<()> {
|
||||||
|
let style = match mode {
|
||||||
|
AppMode::Edit => SetCursorStyle::SteadyBar, // Thin line for insert
|
||||||
|
AppMode::ReadOnly => SetCursorStyle::SteadyBlock, // Block for normal
|
||||||
|
AppMode::Highlight => SetCursorStyle::BlinkingBlock, // Blinking for visual
|
||||||
|
AppMode::General => SetCursorStyle::SteadyBlock, // Block for general
|
||||||
|
AppMode::Command => SetCursorStyle::SteadyUnderScore, // Underscore for command
|
||||||
|
};
|
||||||
|
|
||||||
|
execute!(io::stdout(), style)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No-op when cursor-style feature is disabled
|
||||||
|
#[cfg(not(feature = "cursor-style"))]
|
||||||
|
pub fn update_for_mode(_mode: AppMode) -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset cursor to default on cleanup
|
||||||
|
#[cfg(feature = "cursor-style")]
|
||||||
|
pub fn reset() -> io::Result<()> {
|
||||||
|
execute!(io::stdout(), SetCursorStyle::DefaultUserShape)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "cursor-style"))]
|
||||||
|
pub fn reset() -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
366
canvas/src/canvas/gui.rs
Normal file
366
canvas/src/canvas/gui.rs
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
// src/canvas/gui.rs
|
||||||
|
//! Canvas GUI updated to work with FormEditor
|
||||||
|
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, BorderType, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
use crate::canvas::theme::{CanvasTheme, DefaultCanvasTheme};
|
||||||
|
use crate::canvas::modes::HighlightState;
|
||||||
|
use crate::data_provider::DataProvider;
|
||||||
|
use crate::editor::FormEditor;
|
||||||
|
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
use std::cmp::{max, min};
|
||||||
|
|
||||||
|
/// Render ONLY the canvas form fields - no autocomplete
|
||||||
|
/// Updated to work with FormEditor instead of CanvasState trait
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub fn render_canvas<T: CanvasTheme, D: DataProvider>(
|
||||||
|
f: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
editor: &FormEditor<D>,
|
||||||
|
theme: &T,
|
||||||
|
) -> Option<Rect> {
|
||||||
|
let ui_state = editor.ui_state();
|
||||||
|
let data_provider = editor.data_provider();
|
||||||
|
|
||||||
|
// Build field information
|
||||||
|
let field_count = data_provider.field_count();
|
||||||
|
let mut fields: Vec<&str> = Vec::with_capacity(field_count);
|
||||||
|
let mut inputs: Vec<String> = Vec::with_capacity(field_count);
|
||||||
|
|
||||||
|
for i in 0..field_count {
|
||||||
|
fields.push(data_provider.field_name(i));
|
||||||
|
inputs.push(data_provider.field_value(i).to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_field_idx = ui_state.current_field();
|
||||||
|
let is_edit_mode = matches!(ui_state.mode(), crate::canvas::modes::AppMode::Edit);
|
||||||
|
|
||||||
|
// For now, create a default highlight state (TODO: get from editor state)
|
||||||
|
let highlight_state = HighlightState::Off;
|
||||||
|
|
||||||
|
render_canvas_fields(
|
||||||
|
f,
|
||||||
|
area,
|
||||||
|
&fields,
|
||||||
|
¤t_field_idx,
|
||||||
|
&inputs,
|
||||||
|
theme,
|
||||||
|
is_edit_mode,
|
||||||
|
&highlight_state,
|
||||||
|
ui_state.cursor_position(),
|
||||||
|
false, // TODO: track unsaved changes in editor
|
||||||
|
|i| {
|
||||||
|
data_provider.display_value(i).unwrap_or(data_provider.field_value(i)).to_string()
|
||||||
|
},
|
||||||
|
|i| data_provider.display_value(i).is_some(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(),
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set default theme if custom not specified
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub fn render_canvas_default<D: DataProvider>(
|
||||||
|
f: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
editor: &FormEditor<D>,
|
||||||
|
) -> Option<Rect> {
|
||||||
|
let theme = DefaultCanvasTheme::default();
|
||||||
|
render_canvas(f, area, editor, &theme)
|
||||||
|
}
|
||||||
19
canvas/src/canvas/mod.rs
Normal file
19
canvas/src/canvas/mod.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// src/canvas/mod.rs
|
||||||
|
|
||||||
|
pub mod actions;
|
||||||
|
pub mod state;
|
||||||
|
pub mod modes;
|
||||||
|
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub mod gui;
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub mod theme;
|
||||||
|
|
||||||
|
#[cfg(feature = "cursor-style")]
|
||||||
|
pub mod cursor;
|
||||||
|
|
||||||
|
// Keep these exports for current functionality
|
||||||
|
pub use modes::{AppMode, ModeManager, HighlightState};
|
||||||
|
|
||||||
|
#[cfg(feature = "cursor-style")]
|
||||||
|
pub use cursor::CursorManager;
|
||||||
15
canvas/src/canvas/modes/highlight.rs
Normal file
15
canvas/src/canvas/modes/highlight.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// src/state/app/highlight.rs
|
||||||
|
// canvas/src/modes/highlight.rs
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum HighlightState {
|
||||||
|
Off,
|
||||||
|
Characterwise { anchor: (usize, usize) }, // (field_index, char_position)
|
||||||
|
Linewise { anchor_line: usize }, // field_index
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HighlightState {
|
||||||
|
fn default() -> Self {
|
||||||
|
HighlightState::Off
|
||||||
|
}
|
||||||
|
}
|
||||||
70
canvas/src/canvas/modes/manager.rs
Normal file
70
canvas/src/canvas/modes/manager.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// src/modes/handlers/mode_manager.rs
|
||||||
|
// canvas/src/modes/manager.rs
|
||||||
|
|
||||||
|
#[cfg(feature = "cursor-style")]
|
||||||
|
use crate::canvas::CursorManager;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AppMode {
|
||||||
|
General, // For intro and admin screens
|
||||||
|
ReadOnly, // Canvas read-only mode
|
||||||
|
Edit, // Canvas edit mode
|
||||||
|
Highlight, // Canvas highlight/visual mode
|
||||||
|
Command, // Command mode overlay
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ModeManager;
|
||||||
|
|
||||||
|
impl ModeManager {
|
||||||
|
// Mode transition rules
|
||||||
|
pub fn can_enter_command_mode(current_mode: AppMode) -> bool {
|
||||||
|
!matches!(current_mode, AppMode::Edit)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_enter_edit_mode(current_mode: AppMode) -> bool {
|
||||||
|
matches!(current_mode, AppMode::ReadOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_enter_read_only_mode(current_mode: AppMode) -> bool {
|
||||||
|
matches!(current_mode, AppMode::Edit | AppMode::Command | AppMode::Highlight)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_enter_highlight_mode(current_mode: AppMode) -> bool {
|
||||||
|
matches!(current_mode, AppMode::ReadOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Transition to new mode with automatic cursor update (when cursor-style feature enabled)
|
||||||
|
pub fn transition_to_mode(current_mode: AppMode, new_mode: AppMode) -> std::io::Result<AppMode> {
|
||||||
|
if current_mode != new_mode {
|
||||||
|
#[cfg(feature = "cursor-style")]
|
||||||
|
{
|
||||||
|
let _ = CursorManager::update_for_mode(new_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(new_mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter highlight mode with cursor styling
|
||||||
|
pub fn enter_highlight_mode_with_cursor(current_mode: AppMode) -> std::io::Result<bool> {
|
||||||
|
if Self::can_enter_highlight_mode(current_mode) {
|
||||||
|
#[cfg(feature = "cursor-style")]
|
||||||
|
{
|
||||||
|
let _ = CursorManager::update_for_mode(AppMode::Highlight);
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exit highlight mode with cursor styling
|
||||||
|
pub fn exit_highlight_mode_with_cursor() -> std::io::Result<AppMode> {
|
||||||
|
let new_mode = AppMode::ReadOnly;
|
||||||
|
#[cfg(feature = "cursor-style")]
|
||||||
|
{
|
||||||
|
let _ = CursorManager::update_for_mode(new_mode);
|
||||||
|
}
|
||||||
|
Ok(new_mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
canvas/src/canvas/modes/mod.rs
Normal file
7
canvas/src/canvas/modes/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// canvas/src/modes/mod.rs
|
||||||
|
|
||||||
|
pub mod highlight;
|
||||||
|
pub mod manager;
|
||||||
|
|
||||||
|
pub use highlight::HighlightState;
|
||||||
|
pub use manager::{AppMode, ModeManager};
|
||||||
137
canvas/src/canvas/state.rs
Normal file
137
canvas/src/canvas/state.rs
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// src/canvas/state.rs
|
||||||
|
//! Library-owned UI state - user never directly modifies this
|
||||||
|
|
||||||
|
use crate::canvas::modes::AppMode;
|
||||||
|
|
||||||
|
/// Library-owned UI state - user never directly modifies this
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EditorState {
|
||||||
|
// Navigation state
|
||||||
|
pub(crate) current_field: usize,
|
||||||
|
pub(crate) cursor_pos: usize,
|
||||||
|
pub(crate) ideal_cursor_column: usize,
|
||||||
|
|
||||||
|
// Mode state
|
||||||
|
pub(crate) current_mode: AppMode,
|
||||||
|
|
||||||
|
// Autocomplete state
|
||||||
|
pub(crate) autocomplete: AutocompleteUIState,
|
||||||
|
|
||||||
|
// Selection state (for vim visual mode)
|
||||||
|
pub(crate) selection: SelectionState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AutocompleteUIState {
|
||||||
|
pub(crate) is_active: bool,
|
||||||
|
pub(crate) is_loading: bool,
|
||||||
|
pub(crate) selected_index: Option<usize>,
|
||||||
|
pub(crate) active_field: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum SelectionState {
|
||||||
|
None,
|
||||||
|
Characterwise { anchor: (usize, usize) },
|
||||||
|
Linewise { anchor_field: usize },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EditorState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
current_field: 0,
|
||||||
|
cursor_pos: 0,
|
||||||
|
ideal_cursor_column: 0,
|
||||||
|
current_mode: AppMode::Edit,
|
||||||
|
autocomplete: AutocompleteUIState {
|
||||||
|
is_active: false,
|
||||||
|
is_loading: false,
|
||||||
|
selected_index: None,
|
||||||
|
active_field: None,
|
||||||
|
},
|
||||||
|
selection: SelectionState::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// READ-ONLY ACCESS: User can fetch UI state for compatibility
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
/// Get current field index (for user's business logic)
|
||||||
|
pub fn current_field(&self) -> usize {
|
||||||
|
self.current_field
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current cursor position (for user's business logic)
|
||||||
|
pub fn cursor_position(&self) -> usize {
|
||||||
|
self.cursor_pos
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get ideal cursor column (for vim-like behavior)
|
||||||
|
pub fn ideal_cursor_column(&self) -> usize { // ADD THIS
|
||||||
|
self.ideal_cursor_column
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current mode (for user's business logic)
|
||||||
|
pub fn mode(&self) -> AppMode {
|
||||||
|
self.current_mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if autocomplete is active (for user's business logic)
|
||||||
|
pub fn is_autocomplete_active(&self) -> bool {
|
||||||
|
self.autocomplete.is_active
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if autocomplete is loading (for user's business logic)
|
||||||
|
pub fn is_autocomplete_loading(&self) -> bool {
|
||||||
|
self.autocomplete.is_loading
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get selection state (for user's business logic)
|
||||||
|
pub fn selection_state(&self) -> &SelectionState {
|
||||||
|
&self.selection
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// INTERNAL MUTATIONS: Only library modifies these
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
pub(crate) fn move_to_field(&mut self, field_index: usize, field_count: usize) {
|
||||||
|
if field_index < field_count {
|
||||||
|
self.current_field = field_index;
|
||||||
|
// Reset cursor to safe position - will be clamped by movement logic
|
||||||
|
self.cursor_pos = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_cursor(&mut self, position: usize, max_position: usize, for_edit_mode: bool) {
|
||||||
|
if for_edit_mode {
|
||||||
|
// Edit mode: can go past end for insertion
|
||||||
|
self.cursor_pos = position.min(max_position);
|
||||||
|
} else {
|
||||||
|
// ReadOnly/Highlight: stay within text bounds
|
||||||
|
self.cursor_pos = position.min(max_position.saturating_sub(1));
|
||||||
|
}
|
||||||
|
self.ideal_cursor_column = self.cursor_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn activate_autocomplete(&mut self, field_index: usize) {
|
||||||
|
self.autocomplete.is_active = true;
|
||||||
|
self.autocomplete.is_loading = true;
|
||||||
|
self.autocomplete.active_field = Some(field_index);
|
||||||
|
self.autocomplete.selected_index = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn deactivate_autocomplete(&mut self) {
|
||||||
|
self.autocomplete.is_active = false;
|
||||||
|
self.autocomplete.is_loading = false;
|
||||||
|
self.autocomplete.active_field = None;
|
||||||
|
self.autocomplete.selected_index = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EditorState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
50
canvas/src/canvas/theme.rs
Normal file
50
canvas/src/canvas/theme.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct DefaultCanvasTheme;
|
||||||
|
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
impl CanvasTheme for DefaultCanvasTheme {
|
||||||
|
fn bg(&self) -> Color {
|
||||||
|
Color::Black
|
||||||
|
}
|
||||||
|
fn fg(&self) -> Color {
|
||||||
|
Color::White
|
||||||
|
}
|
||||||
|
fn border(&self) -> Color {
|
||||||
|
Color::DarkGray
|
||||||
|
}
|
||||||
|
fn accent(&self) -> Color {
|
||||||
|
Color::Cyan
|
||||||
|
}
|
||||||
|
fn secondary(&self) -> Color {
|
||||||
|
Color::Gray
|
||||||
|
}
|
||||||
|
fn highlight(&self) -> Color {
|
||||||
|
Color::Yellow
|
||||||
|
}
|
||||||
|
fn highlight_bg(&self) -> Color {
|
||||||
|
Color::Blue
|
||||||
|
}
|
||||||
|
fn warning(&self) -> Color {
|
||||||
|
Color::Red
|
||||||
|
}
|
||||||
|
}
|
||||||
44
canvas/src/data_provider.rs
Normal file
44
canvas/src/data_provider.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// src/data_provider.rs
|
||||||
|
//! Simplified user interface - only business data, no UI state
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
/// User implements this - only business data, no UI state
|
||||||
|
pub trait DataProvider {
|
||||||
|
/// How many fields in the form
|
||||||
|
fn field_count(&self) -> usize;
|
||||||
|
|
||||||
|
/// Get field label/name
|
||||||
|
fn field_name(&self, index: usize) -> &str;
|
||||||
|
|
||||||
|
/// Get field value
|
||||||
|
fn field_value(&self, index: usize) -> &str;
|
||||||
|
|
||||||
|
/// Set field value (library calls this when text changes)
|
||||||
|
fn set_field_value(&mut self, index: usize, value: String);
|
||||||
|
|
||||||
|
/// Check if field supports autocomplete (optional)
|
||||||
|
fn supports_autocomplete(&self, _field_index: usize) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get display value (for password masking, etc.) - optional
|
||||||
|
fn display_value(&self, _index: usize) -> Option<&str> {
|
||||||
|
None // Default: use actual value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optional: User implements this for autocomplete data
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AutocompleteProvider {
|
||||||
|
/// Fetch autocomplete suggestions (user's business logic)
|
||||||
|
async fn fetch_suggestions(&mut self, field_index: usize, query: &str)
|
||||||
|
-> Result<Vec<SuggestionItem>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SuggestionItem {
|
||||||
|
pub display_text: String,
|
||||||
|
pub value_to_store: String,
|
||||||
|
}
|
||||||
608
canvas/src/editor.rs
Normal file
608
canvas/src/editor.rs
Normal file
@@ -0,0 +1,608 @@
|
|||||||
|
// src/editor.rs
|
||||||
|
//! Main API for the canvas library - FormEditor with library-owned state
|
||||||
|
|
||||||
|
#[cfg(feature = "cursor-style")]
|
||||||
|
use crate::canvas::CursorManager;
|
||||||
|
#[cfg(feature = "cursor-style")]
|
||||||
|
use crossterm;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use crate::canvas::state::EditorState;
|
||||||
|
use crate::data_provider::{DataProvider, AutocompleteProvider, SuggestionItem};
|
||||||
|
use crate::canvas::modes::AppMode;
|
||||||
|
|
||||||
|
/// Main editor that manages UI state internally and delegates data to user
|
||||||
|
pub struct FormEditor<D: DataProvider> {
|
||||||
|
// Library owns all UI state
|
||||||
|
ui_state: EditorState,
|
||||||
|
|
||||||
|
// User owns business data
|
||||||
|
data_provider: D,
|
||||||
|
|
||||||
|
// Autocomplete suggestions (library manages UI, user provides data)
|
||||||
|
pub(crate) suggestions: Vec<SuggestionItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<D: DataProvider> FormEditor<D> {
|
||||||
|
pub fn new(data_provider: D) -> Self {
|
||||||
|
Self {
|
||||||
|
ui_state: EditorState::new(),
|
||||||
|
data_provider,
|
||||||
|
suggestions: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// READ-ONLY ACCESS: User can fetch UI state
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
/// Get current field index (for user's compatibility)
|
||||||
|
pub fn current_field(&self) -> usize {
|
||||||
|
self.ui_state.current_field()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current cursor position (for user's compatibility)
|
||||||
|
pub fn cursor_position(&self) -> usize {
|
||||||
|
self.ui_state.cursor_position()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current mode (for user's mode-dependent logic)
|
||||||
|
pub fn mode(&self) -> AppMode {
|
||||||
|
self.ui_state.mode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if autocomplete is active (for user's logic)
|
||||||
|
pub fn is_autocomplete_active(&self) -> bool {
|
||||||
|
self.ui_state.is_autocomplete_active()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current field text (convenience method)
|
||||||
|
pub fn current_text(&self) -> &str {
|
||||||
|
let field_index = self.ui_state.current_field;
|
||||||
|
if field_index < self.data_provider.field_count() {
|
||||||
|
self.data_provider.field_value(field_index)
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get reference to UI state for rendering
|
||||||
|
pub fn ui_state(&self) -> &EditorState {
|
||||||
|
&self.ui_state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get reference to data provider for rendering
|
||||||
|
pub fn data_provider(&self) -> &D {
|
||||||
|
&self.data_provider
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get autocomplete suggestions for rendering (read-only)
|
||||||
|
pub fn suggestions(&self) -> &[SuggestionItem] {
|
||||||
|
&self.suggestions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// SYNC OPERATIONS: No async needed for basic editing
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
/// Handle character insertion
|
||||||
|
pub fn insert_char(&mut self, ch: char) -> Result<()> {
|
||||||
|
if self.ui_state.current_mode != AppMode::Edit {
|
||||||
|
return Ok(()); // Ignore in non-edit modes
|
||||||
|
}
|
||||||
|
|
||||||
|
let field_index = self.ui_state.current_field;
|
||||||
|
let cursor_pos = self.ui_state.cursor_pos;
|
||||||
|
|
||||||
|
// Get current text from user
|
||||||
|
let mut current_text = self.data_provider.field_value(field_index).to_string();
|
||||||
|
|
||||||
|
// Insert character
|
||||||
|
current_text.insert(cursor_pos, ch);
|
||||||
|
|
||||||
|
// Update user's data
|
||||||
|
self.data_provider.set_field_value(field_index, current_text);
|
||||||
|
|
||||||
|
// Update library's UI state
|
||||||
|
self.ui_state.cursor_pos += 1;
|
||||||
|
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle cursor movement
|
||||||
|
pub fn move_left(&mut self) {
|
||||||
|
if self.ui_state.cursor_pos > 0 {
|
||||||
|
self.ui_state.cursor_pos -= 1;
|
||||||
|
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_right(&mut self) {
|
||||||
|
let current_text = self.current_text();
|
||||||
|
let max_pos = if self.ui_state.current_mode == AppMode::Edit {
|
||||||
|
current_text.len() // Edit mode: can go past end
|
||||||
|
} else {
|
||||||
|
current_text.len().saturating_sub(1) // ReadOnly: stay in bounds
|
||||||
|
};
|
||||||
|
|
||||||
|
if self.ui_state.cursor_pos < max_pos {
|
||||||
|
self.ui_state.cursor_pos += 1;
|
||||||
|
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle field navigation
|
||||||
|
pub fn move_to_next_field(&mut self) {
|
||||||
|
let field_count = self.data_provider.field_count();
|
||||||
|
let next_field = (self.ui_state.current_field + 1) % field_count;
|
||||||
|
self.ui_state.move_to_field(next_field, field_count);
|
||||||
|
|
||||||
|
// Clamp cursor to new field
|
||||||
|
let current_text = self.current_text();
|
||||||
|
let max_pos = current_text.len();
|
||||||
|
self.ui_state.set_cursor(
|
||||||
|
self.ui_state.ideal_cursor_column,
|
||||||
|
max_pos,
|
||||||
|
self.ui_state.current_mode == AppMode::Edit
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change mode (for vim compatibility)
|
||||||
|
pub fn set_mode(&mut self, mode: AppMode) {
|
||||||
|
#[cfg(feature = "cursor-style")]
|
||||||
|
let old_mode = self.ui_state.current_mode;
|
||||||
|
|
||||||
|
self.ui_state.current_mode = mode;
|
||||||
|
|
||||||
|
// Clear autocomplete when changing modes
|
||||||
|
if mode != AppMode::Edit {
|
||||||
|
self.ui_state.deactivate_autocomplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cursor style if mode changed and cursor-style feature is enabled
|
||||||
|
#[cfg(feature = "cursor-style")]
|
||||||
|
if old_mode != mode {
|
||||||
|
let _ = crate::canvas::CursorManager::update_for_mode(mode);
|
||||||
|
|
||||||
|
// IMMEDIATELY update terminal cursor position for the new mode
|
||||||
|
// This prevents flicker by ensuring position and style change atomically
|
||||||
|
if let Ok((x, y)) = crossterm::cursor::position() {
|
||||||
|
let display_pos = self.display_cursor_position();
|
||||||
|
let current_text = self.current_text();
|
||||||
|
let adjusted_x = x.saturating_sub(current_text.len() as u16) + display_pos as u16;
|
||||||
|
let _ = crossterm::execute!(
|
||||||
|
std::io::stdout(),
|
||||||
|
crossterm::cursor::MoveTo(adjusted_x, y)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter edit mode with cursor positioned for append (vim 'a' command)
|
||||||
|
pub fn enter_append_mode(&mut self) {
|
||||||
|
let current_text = self.current_text();
|
||||||
|
|
||||||
|
// Calculate append position: always move right, even at line end
|
||||||
|
let append_pos = if current_text.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
(self.ui_state.cursor_pos + 1).min(current_text.len())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set cursor position for append
|
||||||
|
self.ui_state.cursor_pos = append_pos;
|
||||||
|
self.ui_state.ideal_cursor_column = append_pos;
|
||||||
|
|
||||||
|
// Enter edit mode (which will update cursor style)
|
||||||
|
self.set_mode(AppMode::Edit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// ASYNC OPERATIONS: Only autocomplete needs async
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
/// Trigger autocomplete (async because it fetches data)
|
||||||
|
pub async fn trigger_autocomplete<A>(&mut self, provider: &mut A) -> Result<()>
|
||||||
|
where
|
||||||
|
A: AutocompleteProvider,
|
||||||
|
{
|
||||||
|
let field_index = self.ui_state.current_field;
|
||||||
|
|
||||||
|
if !self.data_provider.supports_autocomplete(field_index) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate autocomplete UI
|
||||||
|
self.ui_state.activate_autocomplete(field_index);
|
||||||
|
|
||||||
|
// Fetch suggestions from user (no conversion needed!)
|
||||||
|
let query = self.current_text();
|
||||||
|
self.suggestions = provider.fetch_suggestions(field_index, query).await?;
|
||||||
|
|
||||||
|
// Update UI state
|
||||||
|
self.ui_state.autocomplete.is_loading = false;
|
||||||
|
if !self.suggestions.is_empty() {
|
||||||
|
self.ui_state.autocomplete.selected_index = Some(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigate autocomplete suggestions
|
||||||
|
pub fn autocomplete_next(&mut self) {
|
||||||
|
if !self.ui_state.autocomplete.is_active || self.suggestions.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = self.ui_state.autocomplete.selected_index.unwrap_or(0);
|
||||||
|
let next = (current + 1) % self.suggestions.len();
|
||||||
|
self.ui_state.autocomplete.selected_index = Some(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply selected autocomplete suggestion
|
||||||
|
pub fn apply_autocomplete(&mut self) -> Option<String> {
|
||||||
|
if let Some(selected_index) = self.ui_state.autocomplete.selected_index {
|
||||||
|
if let Some(suggestion) = self.suggestions.get(selected_index).cloned() {
|
||||||
|
let field_index = self.ui_state.current_field;
|
||||||
|
|
||||||
|
// Apply to user's data
|
||||||
|
self.data_provider.set_field_value(
|
||||||
|
field_index,
|
||||||
|
suggestion.value_to_store.clone()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update cursor position
|
||||||
|
self.ui_state.cursor_pos = suggestion.value_to_store.len();
|
||||||
|
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
||||||
|
|
||||||
|
// Close autocomplete
|
||||||
|
self.ui_state.deactivate_autocomplete();
|
||||||
|
self.suggestions.clear();
|
||||||
|
|
||||||
|
return Some(suggestion.display_text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// ADD THESE MISSING MOVEMENT METHODS
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
/// Move to previous field (vim k / up arrow)
|
||||||
|
pub fn move_up(&mut self) {
|
||||||
|
let field_count = self.data_provider.field_count();
|
||||||
|
if field_count == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_field = self.ui_state.current_field;
|
||||||
|
let new_field = current_field.saturating_sub(1);
|
||||||
|
|
||||||
|
self.ui_state.move_to_field(new_field, field_count);
|
||||||
|
self.clamp_cursor_to_current_field();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to next field (vim j / down arrow)
|
||||||
|
pub fn move_down(&mut self) {
|
||||||
|
let field_count = self.data_provider.field_count();
|
||||||
|
if field_count == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_field = self.ui_state.current_field;
|
||||||
|
let new_field = (current_field + 1).min(field_count - 1);
|
||||||
|
|
||||||
|
self.ui_state.move_to_field(new_field, field_count);
|
||||||
|
self.clamp_cursor_to_current_field();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to first field (vim gg)
|
||||||
|
pub fn move_first_line(&mut self) {
|
||||||
|
let field_count = self.data_provider.field_count();
|
||||||
|
if field_count == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.ui_state.move_to_field(0, field_count);
|
||||||
|
self.clamp_cursor_to_current_field();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to last field (vim G)
|
||||||
|
pub fn move_last_line(&mut self) {
|
||||||
|
let field_count = self.data_provider.field_count();
|
||||||
|
if field_count == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let last_field = field_count - 1;
|
||||||
|
self.ui_state.move_to_field(last_field, field_count);
|
||||||
|
self.clamp_cursor_to_current_field();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to previous field (alternative to move_up)
|
||||||
|
pub fn prev_field(&mut self) {
|
||||||
|
self.move_up();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to next field (alternative to move_down)
|
||||||
|
pub fn next_field(&mut self) {
|
||||||
|
self.move_down();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to start of current field (vim 0)
|
||||||
|
pub fn move_line_start(&mut self) {
|
||||||
|
use crate::canvas::actions::movement::line::line_start_position;
|
||||||
|
let new_pos = line_start_position();
|
||||||
|
self.ui_state.cursor_pos = new_pos;
|
||||||
|
self.ui_state.ideal_cursor_column = new_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to end of current field (vim $)
|
||||||
|
pub fn move_line_end(&mut self) {
|
||||||
|
use crate::canvas::actions::movement::line::line_end_position;
|
||||||
|
let current_text = self.current_text();
|
||||||
|
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||||
|
|
||||||
|
let new_pos = line_end_position(current_text, is_edit_mode);
|
||||||
|
self.ui_state.cursor_pos = new_pos;
|
||||||
|
self.ui_state.ideal_cursor_column = new_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to start of next word (vim w)
|
||||||
|
pub fn move_word_next(&mut self) {
|
||||||
|
use crate::canvas::actions::movement::word::find_next_word_start;
|
||||||
|
let current_text = self.current_text();
|
||||||
|
|
||||||
|
if current_text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_pos = find_next_word_start(current_text, self.ui_state.cursor_pos);
|
||||||
|
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||||
|
|
||||||
|
// Clamp to valid bounds for current mode
|
||||||
|
let final_pos = if is_edit_mode {
|
||||||
|
new_pos.min(current_text.len())
|
||||||
|
} else {
|
||||||
|
new_pos.min(current_text.len().saturating_sub(1))
|
||||||
|
};
|
||||||
|
|
||||||
|
self.ui_state.cursor_pos = final_pos;
|
||||||
|
self.ui_state.ideal_cursor_column = final_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to start of previous word (vim b)
|
||||||
|
pub fn move_word_prev(&mut self) {
|
||||||
|
use crate::canvas::actions::movement::word::find_prev_word_start;
|
||||||
|
let current_text = self.current_text();
|
||||||
|
|
||||||
|
if current_text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_pos = find_prev_word_start(current_text, self.ui_state.cursor_pos);
|
||||||
|
self.ui_state.cursor_pos = new_pos;
|
||||||
|
self.ui_state.ideal_cursor_column = new_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to end of current/next word (vim e)
|
||||||
|
pub fn move_word_end(&mut self) {
|
||||||
|
use crate::canvas::actions::movement::word::find_word_end;
|
||||||
|
let current_text = self.current_text();
|
||||||
|
|
||||||
|
if current_text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_pos = self.ui_state.cursor_pos;
|
||||||
|
let new_pos = find_word_end(current_text, current_pos);
|
||||||
|
|
||||||
|
// If we didn't move, try next word
|
||||||
|
let final_pos = if new_pos == current_pos && current_pos + 1 < current_text.len() {
|
||||||
|
find_word_end(current_text, current_pos + 1)
|
||||||
|
} else {
|
||||||
|
new_pos
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clamp for read-only mode
|
||||||
|
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||||
|
let clamped_pos = if is_edit_mode {
|
||||||
|
final_pos.min(current_text.len())
|
||||||
|
} else {
|
||||||
|
final_pos.min(current_text.len().saturating_sub(1))
|
||||||
|
};
|
||||||
|
|
||||||
|
self.ui_state.cursor_pos = clamped_pos;
|
||||||
|
self.ui_state.ideal_cursor_column = clamped_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to end of previous word (vim ge)
|
||||||
|
pub fn move_word_end_prev(&mut self) {
|
||||||
|
use crate::canvas::actions::movement::word::find_prev_word_end;
|
||||||
|
let current_text = self.current_text();
|
||||||
|
|
||||||
|
if current_text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_pos = find_prev_word_end(current_text, self.ui_state.cursor_pos);
|
||||||
|
self.ui_state.cursor_pos = new_pos;
|
||||||
|
self.ui_state.ideal_cursor_column = new_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete character before cursor (vim x in insert mode / backspace)
|
||||||
|
pub fn delete_backward(&mut self) -> Result<()> {
|
||||||
|
if self.ui_state.current_mode != AppMode::Edit {
|
||||||
|
return Ok(()); // Silently ignore in non-edit modes
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.ui_state.cursor_pos == 0 {
|
||||||
|
return Ok(()); // Nothing to delete
|
||||||
|
}
|
||||||
|
|
||||||
|
let field_index = self.ui_state.current_field;
|
||||||
|
let mut current_text = self.data_provider.field_value(field_index).to_string();
|
||||||
|
|
||||||
|
if self.ui_state.cursor_pos <= current_text.len() {
|
||||||
|
current_text.remove(self.ui_state.cursor_pos - 1);
|
||||||
|
self.data_provider.set_field_value(field_index, current_text);
|
||||||
|
self.ui_state.cursor_pos -= 1;
|
||||||
|
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete character under cursor (vim x / delete key)
|
||||||
|
pub fn delete_forward(&mut self) -> Result<()> {
|
||||||
|
if self.ui_state.current_mode != AppMode::Edit {
|
||||||
|
return Ok(()); // Silently ignore in non-edit modes
|
||||||
|
}
|
||||||
|
|
||||||
|
let field_index = self.ui_state.current_field;
|
||||||
|
let mut current_text = self.data_provider.field_value(field_index).to_string();
|
||||||
|
|
||||||
|
if self.ui_state.cursor_pos < current_text.len() {
|
||||||
|
current_text.remove(self.ui_state.cursor_pos);
|
||||||
|
self.data_provider.set_field_value(field_index, current_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exit edit mode to read-only mode (vim Escape)
|
||||||
|
// TODO this is still flickering, I have no clue how to fix it
|
||||||
|
pub fn exit_edit_mode(&mut self) {
|
||||||
|
// Adjust cursor position when transitioning from edit to normal mode
|
||||||
|
let current_text = self.current_text();
|
||||||
|
if !current_text.is_empty() {
|
||||||
|
// In normal mode, cursor must be ON a character, not after the last one
|
||||||
|
let max_normal_pos = current_text.len().saturating_sub(1);
|
||||||
|
if self.ui_state.cursor_pos > max_normal_pos {
|
||||||
|
self.ui_state.cursor_pos = max_normal_pos;
|
||||||
|
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.set_mode(AppMode::ReadOnly);
|
||||||
|
// Deactivate autocomplete when exiting edit mode
|
||||||
|
self.ui_state.deactivate_autocomplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter edit mode from read-only mode (vim i/a/o)
|
||||||
|
pub fn enter_edit_mode(&mut self) {
|
||||||
|
self.set_mode(AppMode::Edit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// HELPER METHODS
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
/// Clamp cursor position to valid bounds for current field and mode
|
||||||
|
fn clamp_cursor_to_current_field(&mut self) {
|
||||||
|
let current_text = self.current_text();
|
||||||
|
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||||
|
|
||||||
|
use crate::canvas::actions::movement::line::safe_cursor_position;
|
||||||
|
let safe_pos = safe_cursor_position(
|
||||||
|
current_text,
|
||||||
|
self.ui_state.ideal_cursor_column,
|
||||||
|
is_edit_mode
|
||||||
|
);
|
||||||
|
|
||||||
|
self.ui_state.cursor_pos = safe_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Set the value of the current field
|
||||||
|
pub fn set_current_field_value(&mut self, value: String) {
|
||||||
|
let field_index = self.ui_state.current_field;
|
||||||
|
self.data_provider.set_field_value(field_index, value);
|
||||||
|
// Reset cursor to start of field
|
||||||
|
self.ui_state.cursor_pos = 0;
|
||||||
|
self.ui_state.ideal_cursor_column = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the value of a specific field by index
|
||||||
|
pub fn set_field_value(&mut self, field_index: usize, value: String) {
|
||||||
|
if field_index < self.data_provider.field_count() {
|
||||||
|
self.data_provider.set_field_value(field_index, value);
|
||||||
|
// If we're modifying the current field, reset cursor
|
||||||
|
if field_index == self.ui_state.current_field {
|
||||||
|
self.ui_state.cursor_pos = 0;
|
||||||
|
self.ui_state.ideal_cursor_column = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the current field (set to empty string)
|
||||||
|
pub fn clear_current_field(&mut self) {
|
||||||
|
self.set_current_field_value(String::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get mutable access to data provider (for advanced operations)
|
||||||
|
pub fn data_provider_mut(&mut self) -> &mut D {
|
||||||
|
&mut self.data_provider
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set cursor to exact position (for vim-style movements like f, F, t, T)
|
||||||
|
pub fn set_cursor_position(&mut self, position: usize) {
|
||||||
|
let current_text = self.current_text();
|
||||||
|
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||||
|
|
||||||
|
// Clamp to valid bounds for current mode
|
||||||
|
let max_pos = if is_edit_mode {
|
||||||
|
current_text.len() // Edit mode: can go past end
|
||||||
|
} else {
|
||||||
|
current_text.len().saturating_sub(1).max(0) // Read-only: stay within text
|
||||||
|
};
|
||||||
|
|
||||||
|
let clamped_pos = position.min(max_pos);
|
||||||
|
|
||||||
|
// Update cursor position directly
|
||||||
|
self.ui_state.cursor_pos = clamped_pos;
|
||||||
|
self.ui_state.ideal_cursor_column = clamped_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cursor position for display (respects mode-specific positioning rules)
|
||||||
|
pub fn display_cursor_position(&self) -> usize {
|
||||||
|
let current_text = self.current_text();
|
||||||
|
|
||||||
|
match self.ui_state.current_mode {
|
||||||
|
AppMode::Edit => {
|
||||||
|
// Edit mode: cursor can be past end of text
|
||||||
|
self.ui_state.cursor_pos.min(current_text.len())
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Normal/other modes: cursor must be on a character
|
||||||
|
if current_text.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
self.ui_state.cursor_pos.min(current_text.len().saturating_sub(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cleanup cursor style (call this when shutting down)
|
||||||
|
pub fn cleanup_cursor(&self) -> std::io::Result<()> {
|
||||||
|
#[cfg(feature = "cursor-style")]
|
||||||
|
{
|
||||||
|
crate::canvas::CursorManager::reset()
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "cursor-style"))]
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Drop implementation for automatic cleanup
|
||||||
|
impl<D: DataProvider> Drop for FormEditor<D> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Reset cursor to default when FormEditor is dropped
|
||||||
|
let _ = self.cleanup_cursor();
|
||||||
|
}
|
||||||
|
}
|
||||||
40
canvas/src/lib.rs
Normal file
40
canvas/src/lib.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// src/lib.rs
|
||||||
|
|
||||||
|
pub mod canvas;
|
||||||
|
pub mod editor;
|
||||||
|
pub mod data_provider;
|
||||||
|
|
||||||
|
// Only include autocomplete module if feature is enabled
|
||||||
|
#[cfg(feature = "autocomplete")]
|
||||||
|
pub mod autocomplete;
|
||||||
|
|
||||||
|
#[cfg(feature = "cursor-style")]
|
||||||
|
pub use canvas::CursorManager;
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// NEW API: Library-owned state pattern
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
// Main API exports
|
||||||
|
pub use editor::FormEditor;
|
||||||
|
pub use data_provider::{DataProvider, AutocompleteProvider, SuggestionItem};
|
||||||
|
|
||||||
|
// UI state (read-only access for users)
|
||||||
|
pub use canvas::state::EditorState;
|
||||||
|
pub use canvas::modes::AppMode;
|
||||||
|
|
||||||
|
// Actions and results (for users who want to handle actions manually)
|
||||||
|
pub use canvas::actions::{CanvasAction, ActionResult};
|
||||||
|
|
||||||
|
// Theming and GUI
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub use canvas::theme::{CanvasTheme, DefaultCanvasTheme};
|
||||||
|
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub use canvas::gui::render_canvas;
|
||||||
|
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub use canvas::gui::render_canvas_default;
|
||||||
|
|
||||||
|
#[cfg(all(feature = "gui", feature = "autocomplete"))]
|
||||||
|
pub use autocomplete::gui::render_autocomplete_dropdown;
|
||||||
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
|
||||||
@@ -5,29 +5,36 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.98"
|
anyhow = { workspace = true }
|
||||||
async-trait = "0.1.88"
|
async-trait = "0.1.88"
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
|
canvas = { path = "../canvas", features = ["gui"] }
|
||||||
|
|
||||||
|
ratatui = { workspace = true }
|
||||||
|
crossterm = { workspace = true }
|
||||||
prost-types = { workspace = true }
|
prost-types = { workspace = true }
|
||||||
crossterm = "0.28.1"
|
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
lazy_static = "1.5.0"
|
lazy_static = "1.5.0"
|
||||||
prost = "0.13.5"
|
prost = "0.13.5"
|
||||||
ratatui = { version = "0.29.0", features = ["crossterm"] }
|
|
||||||
serde = { version = "1.0.219", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
serde_json = "1.0.140"
|
serde_json = "1.0.140"
|
||||||
time = "0.3.41"
|
time = "0.3.41"
|
||||||
tokio = { version = "1.44.2", features = ["full", "macros"] }
|
tokio = { version = "1.44.2", features = ["full", "macros"] }
|
||||||
toml = "0.8.20"
|
toml = { workspace = true }
|
||||||
tonic = "0.13.0"
|
tonic = "0.13.0"
|
||||||
tracing = "0.1.41"
|
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 = []
|
||||||
ui-debug = []
|
ui-debug = []
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
rstest = "0.25.0"
|
||||||
|
tokio-test = "0.4.4"
|
||||||
|
uuid = { version = "1.17.0", features = ["v4"] }
|
||||||
|
futures = "0.3.31"
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
[keybindings]
|
[keybindings]
|
||||||
|
|
||||||
enter_command_mode = [":", "ctrl+;"]
|
enter_command_mode = [":", "ctrl+;"]
|
||||||
next_buffer = ["ctrl+l"]
|
next_buffer = ["space+b+n"]
|
||||||
previous_buffer = ["ctrl+h"]
|
previous_buffer = ["space+b+p"]
|
||||||
close_buffer = ["ctrl+k"]
|
close_buffer = ["space+b+d"]
|
||||||
|
|
||||||
[keybindings.general]
|
[keybindings.general]
|
||||||
move_up = ["k", "Up"]
|
move_up = ["k", "Up"]
|
||||||
@@ -22,8 +22,6 @@ open_search = ["ctrl+f"]
|
|||||||
[keybindings.common]
|
[keybindings.common]
|
||||||
save = ["ctrl+s"]
|
save = ["ctrl+s"]
|
||||||
quit = ["ctrl+q"]
|
quit = ["ctrl+q"]
|
||||||
# !!!change to space b r in the future and from edit mode
|
|
||||||
revert = ["ctrl+r"]
|
|
||||||
|
|
||||||
force_quit = ["ctrl+shift+q"]
|
force_quit = ["ctrl+shift+q"]
|
||||||
save_and_quit = ["ctrl+shift+s"]
|
save_and_quit = ["ctrl+shift+s"]
|
||||||
@@ -31,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
|
||||||
@@ -40,25 +39,45 @@ enter_edit_mode_after = ["a"]
|
|||||||
previous_entry = ["left","q"]
|
previous_entry = ["left","q"]
|
||||||
next_entry = ["right","1"]
|
next_entry = ["right","1"]
|
||||||
|
|
||||||
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"]
|
||||||
@@ -66,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"]
|
||||||
@@ -93,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,
|
||||||
@@ -77,18 +85,18 @@ pub fn render_add_logic(
|
|||||||
let editor_borrow = add_logic_state.script_content_editor.borrow();
|
let editor_borrow = add_logic_state.script_content_editor.borrow();
|
||||||
editor_borrow.cursor() // Returns (row, col) as (usize, usize)
|
editor_borrow.cursor() // Returns (row, col) as (usize, usize)
|
||||||
};
|
};
|
||||||
|
|
||||||
let (cursor_line, cursor_col) = current_cursor;
|
let (cursor_line, cursor_col) = current_cursor;
|
||||||
|
|
||||||
// Account for TextArea's block borders (1 for each side)
|
// Account for TextArea's block borders (1 for each side)
|
||||||
let block_offset_x = 1;
|
let block_offset_x = 1;
|
||||||
let block_offset_y = 1;
|
let block_offset_y = 1;
|
||||||
|
|
||||||
// Position autocomplete at current cursor position
|
// Position autocomplete at current cursor position
|
||||||
// Add 1 to column to position dropdown right after the cursor
|
// Add 1 to column to position dropdown right after the cursor
|
||||||
let autocomplete_x = cursor_col + 1;
|
let autocomplete_x = cursor_col + 1;
|
||||||
let autocomplete_y = cursor_line;
|
let autocomplete_y = cursor_line;
|
||||||
|
|
||||||
let input_rect = Rect {
|
let input_rect = Rect {
|
||||||
x: (inner_area.x + block_offset_x + autocomplete_x as u16).min(inner_area.right().saturating_sub(20)),
|
x: (inner_area.x + block_offset_x + autocomplete_x as u16).min(inner_area.right().saturating_sub(20)),
|
||||||
y: (inner_area.y + block_offset_y + autocomplete_y as u16).min(inner_area.bottom().saturating_sub(5)),
|
y: (inner_area.y + block_offset_y + autocomplete_y as u16).min(inner_area.bottom().saturating_sub(5)),
|
||||||
@@ -152,40 +160,37 @@ pub fn render_add_logic(
|
|||||||
);
|
);
|
||||||
f.render_widget(profile_text, top_info_area);
|
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
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use crate::config::colors::themes::Theme;
|
|||||||
use crate::state::pages::auth::AuthState;
|
use crate::state::pages::auth::AuthState;
|
||||||
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 common::proto::multieko2::table_definition::ProfileTreeResponse;
|
use common::proto::komp_ac::table_definition::ProfileTreeResponse;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Constraint, Direction, Layout, Rect},
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
style::Style,
|
style::Style,
|
||||||
|
|||||||
@@ -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)])
|
||||||
@@ -83,7 +91,7 @@ pub fn render_login(
|
|||||||
app_state.focused_button_index== login_button_index
|
app_state.focused_button_index== login_button_index
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
};
|
};
|
||||||
let mut login_style = Style::default().fg(theme.fg);
|
let mut login_style = Style::default().fg(theme.fg);
|
||||||
let mut login_border = Style::default().fg(theme.border);
|
let mut login_border = Style::default().fg(theme.border);
|
||||||
if login_active {
|
if login_active {
|
||||||
@@ -105,12 +113,12 @@ 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);
|
||||||
if return_active {
|
if return_active {
|
||||||
@@ -132,17 +140,15 @@ 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,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use crate::config::colors::themes::Theme;
|
use crate::config::colors::themes::Theme;
|
||||||
use crate::state::pages::form::FormState;
|
use crate::state::pages::form::FormState;
|
||||||
use common::proto::multieko2::search::search_response::Hit;
|
use common::proto::komp_ac::search::search_response::Hit;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ pub fn render_status_line(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- The normal status line rendering logic (unchanged) ---
|
// --- The normal status line rendering logic (unchanged) ---
|
||||||
let program_info = format!("multieko2 v{}", env!("CARGO_PKG_VERSION"));
|
let program_info = format!("komp_ac v{}", env!("CARGO_PKG_VERSION"));
|
||||||
let mode_text = if is_edit_mode { "[EDIT]" } else { "[READ-ONLY]" };
|
let mode_text = if is_edit_mode { "[EDIT]" } else { "[READ-ONLY]" };
|
||||||
|
|
||||||
let home_dir = dirs::home_dir()
|
let home_dir = dirs::home_dir()
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
// src/components/form/form.rs
|
// src/components/form/form.rs
|
||||||
use crate::components::common::autocomplete; // <--- ADD THIS IMPORT
|
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 crate::state::pages::canvas_state::CanvasState;
|
use crate::state::pages::form::FormState;
|
||||||
use crate::state::pages::form::FormState; // <--- CHANGE THIS IMPORT
|
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
|
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
|
||||||
style::Style,
|
style::Style,
|
||||||
@@ -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 = render_canvas(
|
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,192 +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;
|
|
||||||
use std::cmp::{max, min};
|
|
||||||
|
|
||||||
pub fn render_canvas(
|
|
||||||
f: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
form_state: &impl CanvasState,
|
|
||||||
fields: &[&str],
|
|
||||||
current_field_idx: &usize,
|
|
||||||
inputs: &[&String],
|
|
||||||
theme: &Theme,
|
|
||||||
is_edit_mode: bool,
|
|
||||||
highlight_state: &HighlightState,
|
|
||||||
) -> Option<Rect> {
|
|
||||||
let columns = Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
|
|
||||||
.split(area);
|
|
||||||
|
|
||||||
let border_style = if form_state.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;
|
|
||||||
let current_cursor_pos = form_state.current_cursor_pos();
|
|
||||||
|
|
||||||
// Use the trait method to get display value
|
|
||||||
let text = form_state.get_display_value_for_field(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]);
|
|
||||||
|
|
||||||
// --- CORRECTED CURSOR POSITIONING LOGIC ---
|
|
||||||
// Use the new generic trait method to check for an override.
|
|
||||||
let cursor_x = if form_state.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 + form_state.current_cursor_pos() as u16
|
|
||||||
};
|
|
||||||
let cursor_y = input_rows[i].y;
|
|
||||||
f.set_cursor_position((cursor_x, cursor_y));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
active_field_input_rect
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ use ratatui::{
|
|||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
use crate::config::colors::themes::Theme;
|
use crate::config::colors::themes::Theme;
|
||||||
use common::proto::multieko2::table_definition::{ProfileTreeResponse};
|
use common::proto::komp_ac::table_definition::{ProfileTreeResponse};
|
||||||
use ratatui::text::{Span, Line};
|
use ratatui::text::{Span, Line};
|
||||||
use crate::components::utils::text::truncate_string;
|
use crate::components::utils::text::truncate_string;
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ pub fn render_intro(f: &mut Frame, intro_state: &IntroState, area: Rect, theme:
|
|||||||
|
|
||||||
// Title
|
// Title
|
||||||
let title = Line::from(vec![
|
let title = Line::from(vec![
|
||||||
Span::styled("multieko2", Style::default().fg(theme.highlight)),
|
Span::styled("komp_ac", Style::default().fg(theme.highlight)),
|
||||||
Span::styled(" v", Style::default().fg(theme.fg)),
|
Span::styled(" v", Style::default().fg(theme.fg)),
|
||||||
Span::styled(env!("CARGO_PKG_VERSION"), Style::default().fg(theme.secondary)),
|
Span::styled(env!("CARGO_PKG_VERSION"), Style::default().fg(theme.secondary)),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -251,47 +251,206 @@ impl Config {
|
|||||||
key: KeyCode,
|
key: KeyCode,
|
||||||
modifiers: KeyModifiers,
|
modifiers: KeyModifiers,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
// For multi-character bindings without modifiers, handle them in matches_key_sequence.
|
// 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('+') {
|
if binding.len() > 1 && !binding.contains('+') {
|
||||||
return match binding.to_lowercase().as_str() {
|
return match binding.to_lowercase().as_str() {
|
||||||
|
// Navigation keys
|
||||||
"left" => key == KeyCode::Left,
|
"left" => key == KeyCode::Left,
|
||||||
"right" => key == KeyCode::Right,
|
"right" => key == KeyCode::Right,
|
||||||
"up" => key == KeyCode::Up,
|
"up" => key == KeyCode::Up,
|
||||||
"down" => key == KeyCode::Down,
|
"down" => key == KeyCode::Down,
|
||||||
"esc" => key == KeyCode::Esc,
|
"home" => key == KeyCode::Home,
|
||||||
"enter" => key == KeyCode::Enter,
|
"end" => key == KeyCode::End,
|
||||||
"delete" => key == KeyCode::Delete,
|
"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,
|
"backspace" => key == KeyCode::Backspace,
|
||||||
|
|
||||||
|
// Tab keys
|
||||||
"tab" => key == KeyCode::Tab,
|
"tab" => key == KeyCode::Tab,
|
||||||
"backtab" => key == KeyCode::BackTab,
|
"backtab" => key == KeyCode::BackTab,
|
||||||
_ => false,
|
|
||||||
|
// 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
|
||||||
|
"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
|
||||||
|
"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),
|
||||||
|
|
||||||
|
// 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 parts: Vec<&str> = binding.split('+').collect();
|
||||||
let mut expected_modifiers = KeyModifiers::empty();
|
let mut expected_modifiers = KeyModifiers::empty();
|
||||||
let mut expected_key = None;
|
let mut expected_key = None;
|
||||||
|
|
||||||
for part in parts {
|
for part in parts {
|
||||||
match part.to_lowercase().as_str() {
|
match part.to_lowercase().as_str() {
|
||||||
"ctrl" => expected_modifiers |= KeyModifiers::CONTROL,
|
// Modifiers
|
||||||
|
"ctrl" | "control" => expected_modifiers |= KeyModifiers::CONTROL,
|
||||||
"shift" => expected_modifiers |= KeyModifiers::SHIFT,
|
"shift" => expected_modifiers |= KeyModifiers::SHIFT,
|
||||||
"alt" => expected_modifiers |= KeyModifiers::ALT,
|
"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),
|
"left" => expected_key = Some(KeyCode::Left),
|
||||||
"right" => expected_key = Some(KeyCode::Right),
|
"right" => expected_key = Some(KeyCode::Right),
|
||||||
"up" => expected_key = Some(KeyCode::Up),
|
"up" => expected_key = Some(KeyCode::Up),
|
||||||
"down" => expected_key = Some(KeyCode::Down),
|
"down" => expected_key = Some(KeyCode::Down),
|
||||||
"esc" => expected_key = Some(KeyCode::Esc),
|
"home" => expected_key = Some(KeyCode::Home),
|
||||||
"enter" => expected_key = Some(KeyCode::Enter),
|
"end" => expected_key = Some(KeyCode::End),
|
||||||
"delete" => expected_key = Some(KeyCode::Delete),
|
"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),
|
"backspace" => expected_key = Some(KeyCode::Backspace),
|
||||||
|
|
||||||
|
// Tab keys
|
||||||
"tab" => expected_key = Some(KeyCode::Tab),
|
"tab" => expected_key = Some(KeyCode::Tab),
|
||||||
"backtab" => expected_key = Some(KeyCode::BackTab),
|
"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),
|
||||||
|
|
||||||
|
// Special characters and colon (legacy support)
|
||||||
":" => expected_key = Some(KeyCode::Char(':')),
|
":" => expected_key = Some(KeyCode::Char(':')),
|
||||||
|
|
||||||
|
// Single character (letters, numbers, punctuation)
|
||||||
part => {
|
part => {
|
||||||
if part.len() == 1 {
|
if part.len() == 1 {
|
||||||
let c = part.chars().next().unwrap();
|
if let Some(c) = part.chars().next() {
|
||||||
expected_key = Some(KeyCode::Char(c));
|
expected_key = Some(KeyCode::Char(c));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,14 +149,17 @@ fn parse_key_part(part: &str) -> Option<ParsedKey> {
|
|||||||
let mut code = None;
|
let mut code = None;
|
||||||
|
|
||||||
if part.contains('+') {
|
if part.contains('+') {
|
||||||
// This handles modifiers like "ctrl+s"
|
// This handles modifiers like "ctrl+s", "super+shift+f5"
|
||||||
let components: Vec<&str> = part.split('+').collect();
|
let components: Vec<&str> = part.split('+').collect();
|
||||||
|
|
||||||
for component in components {
|
for component in components {
|
||||||
match component.to_lowercase().as_str() {
|
match component.to_lowercase().as_str() {
|
||||||
"ctrl" => modifiers |= KeyModifiers::CONTROL,
|
"ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
|
||||||
"shift" => modifiers |= KeyModifiers::SHIFT,
|
"shift" => modifiers |= KeyModifiers::SHIFT,
|
||||||
"alt" => modifiers |= KeyModifiers::ALT,
|
"alt" => modifiers |= KeyModifiers::ALT,
|
||||||
|
"super" | "windows" | "cmd" => modifiers |= KeyModifiers::SUPER,
|
||||||
|
"hyper" => modifiers |= KeyModifiers::HYPER,
|
||||||
|
"meta" => modifiers |= KeyModifiers::META,
|
||||||
_ => {
|
_ => {
|
||||||
// Last component is the key
|
// Last component is the key
|
||||||
code = string_to_keycode(component);
|
code = string_to_keycode(component);
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use tracing::{error, info};
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
pub const APP_NAME: &str = "multieko2_client";
|
pub const APP_NAME: &str = "komp_ac_client";
|
||||||
pub const TOKEN_FILE_NAME: &str = "auth.token";
|
pub const TOKEN_FILE_NAME: &str = "auth.token";
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
|||||||
@@ -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::canvas_state::CanvasState;
|
|
||||||
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 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 crate::state::pages::canvas_state::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,22 +1,20 @@
|
|||||||
// 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;
|
||||||
use crate::state::pages::admin::AdminState;
|
use crate::state::pages::admin::AdminState;
|
||||||
use crate::state::pages::{
|
use crate::state::pages::{
|
||||||
auth::{LoginState, RegisterState},
|
auth::{LoginState, RegisterState},
|
||||||
canvas_state::CanvasState,
|
|
||||||
form::FormState,
|
form::FormState,
|
||||||
};
|
};
|
||||||
|
use canvas::canvas::CanvasState;
|
||||||
|
use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher, canvas::ActionResult};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use common::proto::multieko2::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 {
|
||||||
@@ -74,6 +72,172 @@ async fn trigger_form_autocomplete_search(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn handle_form_edit_with_canvas(
|
||||||
|
key_event: KeyEvent,
|
||||||
|
config: &Config,
|
||||||
|
form_state: &mut FormState,
|
||||||
|
ideal_cursor_column: &mut usize,
|
||||||
|
) -> Result<String> {
|
||||||
|
// Try canvas action from key first
|
||||||
|
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 {
|
||||||
|
Ok(ActionResult::Success(msg)) => {
|
||||||
|
return Ok(msg.unwrap_or_default());
|
||||||
|
}
|
||||||
|
Ok(ActionResult::HandledByFeature(msg)) => {
|
||||||
|
return Ok(msg);
|
||||||
|
}
|
||||||
|
Ok(ActionResult::Error(msg)) => {
|
||||||
|
return Ok(format!("Error: {}", msg));
|
||||||
|
}
|
||||||
|
Ok(ActionResult::RequiresContext(msg)) => {
|
||||||
|
return Ok(format!("Context needed: {}", msg));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// Fall through to try config mapping
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try config-mapped action
|
||||||
|
if let Some(action_str) = config.get_edit_action_for_key(key_event.code, key_event.modifiers) {
|
||||||
|
let canvas_action = CanvasAction::from_string(&action_str);
|
||||||
|
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
|
||||||
|
Ok(ActionResult::Success(msg)) => {
|
||||||
|
return Ok(msg.unwrap_or_default());
|
||||||
|
}
|
||||||
|
Ok(ActionResult::HandledByFeature(msg)) => {
|
||||||
|
return Ok(msg);
|
||||||
|
}
|
||||||
|
Ok(ActionResult::Error(msg)) => {
|
||||||
|
return Ok(format!("Error: {}", msg));
|
||||||
|
}
|
||||||
|
Ok(ActionResult::RequiresContext(msg)) => {
|
||||||
|
return Ok(format!("Context needed: {}", msg));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Ok(format!("Action failed: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(String::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(
|
||||||
@@ -185,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,
|
||||||
@@ -215,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,
|
||||||
@@ -231,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,
|
||||||
@@ -284,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,16 +3,141 @@
|
|||||||
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 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(
|
||||||
|
key_event: KeyEvent,
|
||||||
|
config: &Config,
|
||||||
|
form_state: &mut FormState,
|
||||||
|
ideal_cursor_column: &mut usize,
|
||||||
|
) -> Result<String> {
|
||||||
|
// Try canvas action from key first
|
||||||
|
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 {
|
||||||
|
Ok(ActionResult::Success(msg)) => {
|
||||||
|
return Ok(msg.unwrap_or_default());
|
||||||
|
}
|
||||||
|
Ok(ActionResult::HandledByFeature(msg)) => {
|
||||||
|
return Ok(msg);
|
||||||
|
}
|
||||||
|
Ok(ActionResult::Error(msg)) => {
|
||||||
|
return Ok(format!("Error: {}", msg));
|
||||||
|
}
|
||||||
|
Ok(ActionResult::RequiresContext(msg)) => {
|
||||||
|
return Ok(format!("Context needed: {}", msg));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// Fall through to try config mapping
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try config-mapped action
|
||||||
|
if let Some(action_str) = config.get_read_only_action_for_key(key_event.code, key_event.modifiers) {
|
||||||
|
let canvas_action = CanvasAction::from_string(&action_str);
|
||||||
|
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
|
||||||
|
Ok(ActionResult::Success(msg)) => {
|
||||||
|
return Ok(msg.unwrap_or_default());
|
||||||
|
}
|
||||||
|
Ok(ActionResult::HandledByFeature(msg)) => {
|
||||||
|
return Ok(msg);
|
||||||
|
}
|
||||||
|
Ok(ActionResult::Error(msg)) => {
|
||||||
|
return Ok(format!("Error: {}", msg));
|
||||||
|
}
|
||||||
|
Ok(ActionResult::RequiresContext(msg)) => {
|
||||||
|
return Ok(format!("Context needed: {}", msg));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Ok(format!("Action failed: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(String::new())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn handle_read_only_event(
|
pub async fn handle_read_only_event(
|
||||||
app_state: &mut AppState,
|
app_state: &mut AppState,
|
||||||
key: KeyEvent,
|
key: KeyEvent,
|
||||||
@@ -35,94 +160,75 @@ 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
|
||||||
let target_state: &mut dyn CanvasState = if app_state.ui.show_login { login_state }
|
if app_state.ui.show_login {
|
||||||
else if app_state.ui.show_add_logic { add_logic_state }
|
let current_input = login_state.get_current_input();
|
||||||
else if app_state.ui.show_register { register_state }
|
let current_pos = login_state.current_cursor_pos();
|
||||||
else if app_state.ui.show_add_table { add_table_state }
|
if !current_input.is_empty() && current_pos < current_input.len() {
|
||||||
else { form_state };
|
login_state.set_current_cursor_pos(current_pos + 1);
|
||||||
let current_input = target_state.get_current_input();
|
*ideal_cursor_column = login_state.current_cursor_pos();
|
||||||
let current_pos = target_state.current_cursor_pos();
|
}
|
||||||
|
} else if app_state.ui.show_add_logic {
|
||||||
if !current_input.is_empty() && current_pos < current_input.len() {
|
let current_input = add_logic_state.get_current_input();
|
||||||
target_state.set_current_cursor_pos(current_pos + 1);
|
let current_pos = add_logic_state.current_cursor_pos();
|
||||||
*ideal_cursor_column = target_state.current_cursor_pos();
|
if !current_input.is_empty() && current_pos < current_input.len() {
|
||||||
|
add_logic_state.set_current_cursor_pos(current_pos + 1);
|
||||||
|
*ideal_cursor_column = add_logic_state.current_cursor_pos();
|
||||||
|
}
|
||||||
|
} else if app_state.ui.show_register {
|
||||||
|
let current_input = register_state.get_current_input();
|
||||||
|
let current_pos = register_state.current_cursor_pos();
|
||||||
|
if !current_input.is_empty() && current_pos < current_input.len() {
|
||||||
|
register_state.set_current_cursor_pos(current_pos + 1);
|
||||||
|
*ideal_cursor_column = register_state.current_cursor_pos();
|
||||||
|
}
|
||||||
|
} else if app_state.ui.show_add_table {
|
||||||
|
let current_input = add_table_state.get_current_input();
|
||||||
|
let current_pos = add_table_state.current_cursor_pos();
|
||||||
|
if !current_input.is_empty() && current_pos < current_input.len() {
|
||||||
|
add_table_state.set_current_cursor_pos(current_pos + 1);
|
||||||
|
*ideal_cursor_column = add_table_state.current_cursor_pos();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle FormState
|
||||||
|
let current_input = form_state.get_current_input();
|
||||||
|
let current_pos = form_state.current_cursor_pos();
|
||||||
|
if !current_input.is_empty() && current_pos < current_input.len() {
|
||||||
|
form_state.set_current_cursor_pos(current_pos + 1);
|
||||||
|
*ideal_cursor_column = form_state.current_cursor_pos();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
*edit_mode_cooldown = true;
|
*edit_mode_cooldown = true;
|
||||||
*command_message = "Entering Edit mode (after cursor)".to_string();
|
*command_message = "Entering Edit mode (after cursor)".to_string();
|
||||||
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));
|
||||||
@@ -134,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));
|
||||||
@@ -200,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;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
use crate::config::binds::config::Config;
|
use crate::config::binds::config::Config;
|
||||||
use crate::modes::handlers::event::EventOutcome;
|
use crate::modes::handlers::event::EventOutcome;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use common::proto::multieko2::table_definition::ProfileTreeResponse;
|
use common::proto::komp_ac::table_definition::ProfileTreeResponse;
|
||||||
use crossterm::event::{KeyCode, KeyEvent};
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
|||||||
@@ -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,3 +1,3 @@
|
|||||||
// src/client/modes/handlers.rs
|
// src/modes/handlers.rs
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod mode_manager;
|
pub mod mode_manager;
|
||||||
|
|||||||
@@ -17,17 +17,18 @@ use crate::modes::{
|
|||||||
};
|
};
|
||||||
use crate::services::auth::AuthClient;
|
use crate::services::auth::AuthClient;
|
||||||
use crate::services::grpc_client::GrpcClient;
|
use crate::services::grpc_client::GrpcClient;
|
||||||
|
use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher};
|
||||||
|
use canvas::canvas::CanvasState; // Only need this import now
|
||||||
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,
|
||||||
},
|
},
|
||||||
@@ -42,8 +43,9 @@ use crate::tui::{
|
|||||||
use crate::ui::handlers::context::UiContext;
|
use crate::ui::handlers::context::UiContext;
|
||||||
use crate::ui::handlers::rat_state::UiStateHandler;
|
use crate::ui::handlers::rat_state::UiStateHandler;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use common::proto::multieko2::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;
|
||||||
@@ -85,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>>,
|
||||||
}
|
}
|
||||||
@@ -99,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(),
|
||||||
@@ -118,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,
|
||||||
})
|
})
|
||||||
@@ -132,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,
|
||||||
@@ -195,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();
|
||||||
@@ -210,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 {
|
||||||
@@ -222,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![]);
|
||||||
}
|
}
|
||||||
@@ -231,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;
|
||||||
@@ -260,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,
|
||||||
@@ -573,55 +656,106 @@ impl EventHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
AppMode::ReadOnly => {
|
AppMode::ReadOnly => {
|
||||||
if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise") && ModeManager::can_enter_highlight_mode(current_mode) {
|
// Handle highlight mode transitions
|
||||||
let current_field_index = 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() };
|
if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise")
|
||||||
self.highlight_state = HighlightState::Linewise { anchor_line: current_field_index };
|
&& ModeManager::can_enter_highlight_mode(current_mode)
|
||||||
|
{
|
||||||
|
let current_field_index = Self::get_current_field_for_state(
|
||||||
|
app_state,
|
||||||
|
login_state,
|
||||||
|
register_state,
|
||||||
|
form_state
|
||||||
|
);
|
||||||
|
self.highlight_state = HighlightState::Linewise {
|
||||||
|
anchor_line: current_field_index
|
||||||
|
};
|
||||||
self.command_message = "-- LINE HIGHLIGHT --".to_string();
|
self.command_message = "-- LINE HIGHLIGHT --".to_string();
|
||||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||||
} else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode") && ModeManager::can_enter_highlight_mode(current_mode) {
|
}
|
||||||
let current_field_index = 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() };
|
else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode")
|
||||||
let current_cursor_pos = 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() };
|
&& ModeManager::can_enter_highlight_mode(current_mode)
|
||||||
|
{
|
||||||
|
let current_field_index = Self::get_current_field_for_state(
|
||||||
|
app_state,
|
||||||
|
login_state,
|
||||||
|
register_state,
|
||||||
|
form_state
|
||||||
|
);
|
||||||
|
let current_cursor_pos = Self::get_current_cursor_pos_for_state(
|
||||||
|
app_state,
|
||||||
|
login_state,
|
||||||
|
register_state,
|
||||||
|
form_state
|
||||||
|
);
|
||||||
let anchor = (current_field_index, current_cursor_pos);
|
let anchor = (current_field_index, current_cursor_pos);
|
||||||
self.highlight_state = HighlightState::Characterwise { anchor };
|
self.highlight_state = HighlightState::Characterwise { anchor };
|
||||||
self.command_message = "-- HIGHLIGHT --".to_string();
|
self.command_message = "-- HIGHLIGHT --".to_string();
|
||||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||||
} else if config.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_before") && ModeManager::can_enter_edit_mode(current_mode) {
|
}
|
||||||
|
|
||||||
|
// Handle edit mode transitions
|
||||||
|
else if config.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_before")
|
||||||
|
&& ModeManager::can_enter_edit_mode(current_mode)
|
||||||
|
{
|
||||||
self.is_edit_mode = true;
|
self.is_edit_mode = true;
|
||||||
self.edit_mode_cooldown = true;
|
self.edit_mode_cooldown = true;
|
||||||
self.command_message = "Edit mode".to_string();
|
self.command_message = "Edit mode".to_string();
|
||||||
terminal.set_cursor_style(SetCursorStyle::BlinkingBar)?;
|
terminal.set_cursor_style(SetCursorStyle::BlinkingBar)?;
|
||||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||||
} else if config.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_after") && ModeManager::can_enter_edit_mode(current_mode) {
|
}
|
||||||
let current_input = if app_state.ui.show_login || app_state.ui.show_register { login_state.get_current_input() } else { form_state.get_current_input() };
|
else if config.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_after")
|
||||||
let current_cursor_pos = if app_state.ui.show_login || app_state.ui.show_register { login_state.current_cursor_pos() } else { form_state.current_cursor_pos() };
|
&& ModeManager::can_enter_edit_mode(current_mode)
|
||||||
|
{
|
||||||
|
let current_input = Self::get_current_input_for_state(
|
||||||
|
app_state,
|
||||||
|
login_state,
|
||||||
|
register_state,
|
||||||
|
form_state
|
||||||
|
);
|
||||||
|
let current_cursor_pos = Self::get_cursor_pos_for_mixed_state(
|
||||||
|
app_state,
|
||||||
|
login_state,
|
||||||
|
form_state
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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() {
|
||||||
if app_state.ui.show_login || app_state.ui.show_register {
|
let new_cursor_pos = current_cursor_pos + 1;
|
||||||
login_state.set_current_cursor_pos(current_cursor_pos + 1);
|
Self::set_current_cursor_pos_for_state(
|
||||||
self.ideal_cursor_column = login_state.current_cursor_pos();
|
app_state,
|
||||||
} else {
|
login_state,
|
||||||
form_state.set_current_cursor_pos(current_cursor_pos + 1);
|
register_state,
|
||||||
self.ideal_cursor_column = form_state.current_cursor_pos();
|
form_state,
|
||||||
}
|
new_cursor_pos
|
||||||
|
);
|
||||||
|
self.ideal_cursor_column = Self::get_current_cursor_pos_for_state(
|
||||||
|
app_state,
|
||||||
|
login_state,
|
||||||
|
register_state,
|
||||||
|
form_state
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.is_edit_mode = true;
|
self.is_edit_mode = true;
|
||||||
self.edit_mode_cooldown = true;
|
self.edit_mode_cooldown = true;
|
||||||
app_state.ui.focus_outside_canvas = false;
|
app_state.ui.focus_outside_canvas = false;
|
||||||
self.command_message = "Edit mode (after cursor)".to_string();
|
self.command_message = "Edit mode (after cursor)".to_string();
|
||||||
terminal.set_cursor_style(SetCursorStyle::BlinkingBar)?;
|
terminal.set_cursor_style(SetCursorStyle::BlinkingBar)?;
|
||||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||||
} else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_command_mode") && ModeManager::can_enter_command_mode(current_mode) {
|
}
|
||||||
|
else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_command_mode")
|
||||||
|
&& ModeManager::can_enter_command_mode(current_mode)
|
||||||
|
{
|
||||||
self.command_mode = true;
|
self.command_mode = true;
|
||||||
self.command_input.clear();
|
self.command_input.clear();
|
||||||
self.command_message.clear();
|
self.command_message.clear();
|
||||||
return Ok(EventOutcome::Ok(String::new()));
|
return Ok(EventOutcome::Ok(String::new()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(action) =
|
// Handle common actions (save, quit, etc.)
|
||||||
config.get_common_action(key_code, modifiers)
|
if let Some(action) = config.get_common_action(key_code, modifiers) {
|
||||||
{
|
|
||||||
match action {
|
match action {
|
||||||
"save" | "force_quit" | "save_and_quit"
|
"save" | "force_quit" | "save_and_quit" | "revert" => {
|
||||||
| "revert" => {
|
|
||||||
return common_mode::handle_core_action(
|
return common_mode::handle_core_action(
|
||||||
action,
|
action,
|
||||||
form_state,
|
form_state,
|
||||||
@@ -639,23 +773,35 @@ impl EventHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let (_should_exit, message) =
|
// Try canvas action for form first
|
||||||
read_only::handle_read_only_event(
|
if app_state.ui.show_form {
|
||||||
app_state,
|
if let Ok(Some(canvas_message)) = self.handle_form_canvas_action(
|
||||||
key_event,
|
key_event,
|
||||||
config,
|
|
||||||
form_state,
|
form_state,
|
||||||
login_state,
|
false,
|
||||||
register_state,
|
).await {
|
||||||
&mut admin_state.add_table_state,
|
return Ok(EventOutcome::Ok(canvas_message));
|
||||||
&mut admin_state.add_logic_state,
|
}
|
||||||
&mut self.key_sequence_tracker,
|
}
|
||||||
&mut self.grpc_client, // <-- FIX 1
|
|
||||||
&mut self.command_message,
|
// Fallback to legacy read-only event handling
|
||||||
&mut self.edit_mode_cooldown,
|
let (_should_exit, message) = read_only::handle_read_only_event(
|
||||||
&mut self.ideal_cursor_column,
|
app_state,
|
||||||
)
|
key_event,
|
||||||
.await?;
|
config,
|
||||||
|
form_state,
|
||||||
|
login_state,
|
||||||
|
register_state,
|
||||||
|
&mut admin_state.add_table_state,
|
||||||
|
&mut admin_state.add_logic_state,
|
||||||
|
&mut self.key_sequence_tracker,
|
||||||
|
&mut self.grpc_client,
|
||||||
|
&mut self.command_message,
|
||||||
|
&mut self.edit_mode_cooldown,
|
||||||
|
&mut self.ideal_cursor_column,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
return Ok(EventOutcome::Ok(message));
|
return Ok(EventOutcome::Ok(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,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,
|
||||||
@@ -695,12 +841,10 @@ impl EventHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
AppMode::Edit => {
|
AppMode::Edit => {
|
||||||
if let Some(action) =
|
// Handle common actions (save, quit, etc.)
|
||||||
config.get_common_action(key_code, modifiers)
|
if let Some(action) = config.get_common_action(key_code, modifiers) {
|
||||||
{
|
|
||||||
match action {
|
match action {
|
||||||
"save" | "force_quit" | "save_and_quit"
|
"save" | "force_quit" | "save_and_quit" | "revert" => {
|
||||||
| "revert" => {
|
|
||||||
return common_mode::handle_core_action(
|
return common_mode::handle_core_action(
|
||||||
action,
|
action,
|
||||||
form_state,
|
form_state,
|
||||||
@@ -718,9 +862,24 @@ impl EventHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try canvas action for form first
|
||||||
|
if app_state.ui.show_form {
|
||||||
|
if let Ok(Some(canvas_message)) = self.handle_form_canvas_action(
|
||||||
|
key_event,
|
||||||
|
form_state,
|
||||||
|
true,
|
||||||
|
).await {
|
||||||
|
if !canvas_message.is_empty() {
|
||||||
|
self.command_message = canvas_message.clone();
|
||||||
|
}
|
||||||
|
return Ok(EventOutcome::Ok(canvas_message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle legacy edit events
|
||||||
let mut current_position = form_state.current_position;
|
let mut current_position = form_state.current_position;
|
||||||
let total_count = form_state.total_count;
|
let total_count = form_state.total_count;
|
||||||
// --- MODIFIED: Pass `self` instead of `grpc_client` ---
|
|
||||||
let edit_result = edit::handle_edit_event(
|
let edit_result = edit::handle_edit_event(
|
||||||
key_event,
|
key_event,
|
||||||
config,
|
config,
|
||||||
@@ -739,30 +898,62 @@ impl EventHandler {
|
|||||||
Ok(edit::EditEventOutcome::ExitEditMode) => {
|
Ok(edit::EditEventOutcome::ExitEditMode) => {
|
||||||
self.is_edit_mode = false;
|
self.is_edit_mode = false;
|
||||||
self.edit_mode_cooldown = true;
|
self.edit_mode_cooldown = true;
|
||||||
let has_changes = 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() };
|
|
||||||
self.command_message = if has_changes { "Exited edit mode (unsaved changes remain)".to_string() } else { "Read-only mode".to_string() };
|
// Check for unsaved changes across all states
|
||||||
|
let has_changes = Self::get_has_unsaved_changes_for_state(
|
||||||
|
app_state,
|
||||||
|
login_state,
|
||||||
|
register_state,
|
||||||
|
form_state
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set appropriate message based on changes
|
||||||
|
self.command_message = if has_changes {
|
||||||
|
"Exited edit mode (unsaved changes remain)".to_string()
|
||||||
|
} else {
|
||||||
|
"Read-only mode".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
|
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
|
||||||
let current_input = 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() };
|
|
||||||
let current_cursor_pos = 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() };
|
// Get current input and cursor position
|
||||||
|
let current_input = Self::get_current_input_for_state(
|
||||||
|
app_state,
|
||||||
|
login_state,
|
||||||
|
register_state,
|
||||||
|
form_state
|
||||||
|
);
|
||||||
|
let current_cursor_pos = Self::get_current_cursor_pos_for_state(
|
||||||
|
app_state,
|
||||||
|
login_state,
|
||||||
|
register_state,
|
||||||
|
form_state
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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;
|
||||||
let target_state: &mut dyn CanvasState = if app_state.ui.show_login { login_state } else if app_state.ui.show_register { register_state } else { form_state };
|
Self::set_current_cursor_pos_for_state(
|
||||||
target_state.set_current_cursor_pos(new_pos);
|
app_state,
|
||||||
|
login_state,
|
||||||
|
register_state,
|
||||||
|
form_state,
|
||||||
|
new_pos
|
||||||
|
);
|
||||||
self.ideal_cursor_column = new_pos;
|
self.ideal_cursor_column = new_pos;
|
||||||
}
|
}
|
||||||
return Ok(EventOutcome::Ok(
|
|
||||||
self.command_message.clone(),
|
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(edit::EditEventOutcome::Message(msg)) => {
|
Ok(edit::EditEventOutcome::Message(msg)) => {
|
||||||
if !msg.is_empty() {
|
if !msg.is_empty() {
|
||||||
self.command_message = msg;
|
self.command_message = msg;
|
||||||
}
|
}
|
||||||
self.key_sequence_tracker.reset();
|
self.key_sequence_tracker.reset();
|
||||||
return Ok(EventOutcome::Ok(
|
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||||
self.command_message.clone(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
@@ -792,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,
|
||||||
@@ -906,4 +1097,100 @@ impl EventHandler {
|
|||||||
fn is_processed_command(&self, command: &str) -> bool {
|
fn is_processed_command(&self, command: &str) -> bool {
|
||||||
matches!(command, "w" | "q" | "q!" | "wq" | "r")
|
matches!(command, "w" | "q" | "q!" | "wq" | "r")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_form_canvas_action(
|
||||||
|
&mut self,
|
||||||
|
key_event: KeyEvent,
|
||||||
|
form_state: &mut FormState,
|
||||||
|
is_edit_mode: bool,
|
||||||
|
) -> Result<Option<String>> {
|
||||||
|
let canvas_config = canvas::config::CanvasConfig::load();
|
||||||
|
|
||||||
|
// PRIORITY 1: Handle character insertion in edit mode FIRST
|
||||||
|
if is_edit_mode {
|
||||||
|
if let KeyCode::Char(c) = key_event.code {
|
||||||
|
// Only insert if it's not a special modifier combination
|
||||||
|
if key_event.modifiers.is_empty() || key_event.modifiers == KeyModifiers::SHIFT {
|
||||||
|
let canvas_action = CanvasAction::InsertChar(c);
|
||||||
|
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("Character insertion failed".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRIORITY 2: Handle config-mapped actions for non-character keys
|
||||||
|
let action_str = canvas_config.get_action_for_key(
|
||||||
|
key_event.code,
|
||||||
|
key_event.modifiers,
|
||||||
|
is_edit_mode,
|
||||||
|
form_state.autocomplete_active,
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(action_str) = action_str {
|
||||||
|
// Skip mode transition actions - let the main event handler deal with them
|
||||||
|
if Self::is_mode_transition_action(action_str) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the config-mapped action
|
||||||
|
let canvas_action = CanvasAction::from_string(action_str);
|
||||||
|
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("Canvas action failed".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No action found
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_mode_transition_action(action: &str) -> bool {
|
||||||
|
matches!(action,
|
||||||
|
"exit" |
|
||||||
|
"exit_edit_mode" |
|
||||||
|
"enter_edit_mode_before" |
|
||||||
|
"enter_edit_mode_after" |
|
||||||
|
"enter_command_mode" |
|
||||||
|
"exit_command_mode" |
|
||||||
|
"enter_highlight_mode" |
|
||||||
|
"enter_highlight_mode_linewise" |
|
||||||
|
"exit_highlight_mode" |
|
||||||
|
"save" |
|
||||||
|
"quit" |
|
||||||
|
"force_quit" |
|
||||||
|
"save_and_quit" |
|
||||||
|
"revert" |
|
||||||
|
"enter_decider" |
|
||||||
|
"trigger_autocomplete" |
|
||||||
|
"suggestion_up" |
|
||||||
|
"suggestion_down" |
|
||||||
|
"previous_entry" |
|
||||||
|
"next_entry" |
|
||||||
|
"toggle_sidebar" |
|
||||||
|
"toggle_buffer_list" |
|
||||||
|
"next_buffer" |
|
||||||
|
"previous_buffer" |
|
||||||
|
"close_buffer" |
|
||||||
|
"open_search" |
|
||||||
|
"find_file_palette_toggle"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// src/services/auth.rs
|
// src/services/auth.rs
|
||||||
use tonic::transport::Channel;
|
use tonic::transport::Channel;
|
||||||
use common::proto::multieko2::auth::{
|
use common::proto::komp_ac::auth::{
|
||||||
auth_service_client::AuthServiceClient,
|
auth_service_client::AuthServiceClient,
|
||||||
LoginRequest, LoginResponse,
|
LoginRequest, LoginResponse,
|
||||||
RegisterRequest, AuthResponse,
|
RegisterRequest, AuthResponse,
|
||||||
|
|||||||
@@ -1,25 +1,28 @@
|
|||||||
// src/services/grpc_client.rs
|
// src/services/grpc_client.rs
|
||||||
|
|
||||||
use common::proto::multieko2::common::Empty;
|
use common::proto::komp_ac::common::Empty;
|
||||||
use common::proto::multieko2::table_structure::table_structure_service_client::TableStructureServiceClient;
|
use common::proto::komp_ac::table_structure::table_structure_service_client::TableStructureServiceClient;
|
||||||
use common::proto::multieko2::table_structure::{GetTableStructureRequest, TableStructureResponse};
|
use common::proto::komp_ac::table_structure::{GetTableStructureRequest, TableStructureResponse};
|
||||||
use common::proto::multieko2::table_definition::{
|
use common::proto::komp_ac::table_definition::{
|
||||||
table_definition_client::TableDefinitionClient,
|
table_definition_client::TableDefinitionClient,
|
||||||
PostTableDefinitionRequest, ProfileTreeResponse, TableDefinitionResponse,
|
PostTableDefinitionRequest, ProfileTreeResponse, TableDefinitionResponse,
|
||||||
};
|
};
|
||||||
use common::proto::multieko2::table_script::{
|
use common::proto::komp_ac::table_script::{
|
||||||
table_script_client::TableScriptClient,
|
table_script_client::TableScriptClient,
|
||||||
PostTableScriptRequest, TableScriptResponse,
|
PostTableScriptRequest, TableScriptResponse,
|
||||||
};
|
};
|
||||||
use common::proto::multieko2::tables_data::{
|
use common::proto::komp_ac::tables_data::{
|
||||||
tables_data_client::TablesDataClient,
|
tables_data_client::TablesDataClient,
|
||||||
GetTableDataByPositionRequest,
|
GetTableDataByPositionRequest,
|
||||||
|
GetTableDataRequest, // ADD THIS
|
||||||
GetTableDataResponse,
|
GetTableDataResponse,
|
||||||
|
DeleteTableDataRequest, // ADD THIS
|
||||||
|
DeleteTableDataResponse, // ADD THIS
|
||||||
GetTableDataCountRequest,
|
GetTableDataCountRequest,
|
||||||
PostTableDataRequest, PostTableDataResponse, PutTableDataRequest,
|
PostTableDataRequest, PostTableDataResponse, PutTableDataRequest,
|
||||||
PutTableDataResponse,
|
PutTableDataResponse,
|
||||||
};
|
};
|
||||||
use common::proto::multieko2::search::{
|
use common::proto::komp_ac::search::{
|
||||||
searcher_client::SearcherClient, SearchRequest, SearchResponse,
|
searcher_client::SearcherClient, SearchRequest, SearchResponse,
|
||||||
};
|
};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -116,7 +119,7 @@ impl GrpcClient {
|
|||||||
Ok(response.into_inner())
|
Ok(response.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW Methods for TablesData service
|
// Existing TablesData methods
|
||||||
pub async fn get_table_data_count(
|
pub async fn get_table_data_count(
|
||||||
&mut self,
|
&mut self,
|
||||||
profile_name: String,
|
profile_name: String,
|
||||||
@@ -135,7 +138,7 @@ impl GrpcClient {
|
|||||||
Ok(response.into_inner().count as u64)
|
Ok(response.into_inner().count as u64)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_table_data_by_position(
|
pub async fn get_table_data_by_position(
|
||||||
&mut self,
|
&mut self,
|
||||||
profile_name: String,
|
profile_name: String,
|
||||||
table_name: String,
|
table_name: String,
|
||||||
@@ -155,18 +158,58 @@ pub async fn get_table_data_by_position(
|
|||||||
Ok(response.into_inner())
|
Ok(response.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ADD THIS: Missing get_table_data method
|
||||||
|
pub async fn get_table_data(
|
||||||
|
&mut self,
|
||||||
|
profile_name: String,
|
||||||
|
table_name: String,
|
||||||
|
id: i64,
|
||||||
|
) -> Result<GetTableDataResponse> {
|
||||||
|
let grpc_request = GetTableDataRequest {
|
||||||
|
profile_name,
|
||||||
|
table_name,
|
||||||
|
id,
|
||||||
|
};
|
||||||
|
let request = tonic::Request::new(grpc_request);
|
||||||
|
let response = self
|
||||||
|
.tables_data_client
|
||||||
|
.get_table_data(request)
|
||||||
|
.await
|
||||||
|
.context("gRPC GetTableData call failed")?;
|
||||||
|
Ok(response.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADD THIS: Missing delete_table_data method
|
||||||
|
pub async fn delete_table_data(
|
||||||
|
&mut self,
|
||||||
|
profile_name: String,
|
||||||
|
table_name: String,
|
||||||
|
record_id: i64,
|
||||||
|
) -> Result<DeleteTableDataResponse> {
|
||||||
|
let grpc_request = DeleteTableDataRequest {
|
||||||
|
profile_name,
|
||||||
|
table_name,
|
||||||
|
record_id,
|
||||||
|
};
|
||||||
|
let request = tonic::Request::new(grpc_request);
|
||||||
|
let response = self
|
||||||
|
.tables_data_client
|
||||||
|
.delete_table_data(request)
|
||||||
|
.await
|
||||||
|
.context("gRPC DeleteTableData call failed")?;
|
||||||
|
Ok(response.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn post_table_data(
|
pub async fn post_table_data(
|
||||||
&mut self,
|
&mut self,
|
||||||
profile_name: String,
|
profile_name: String,
|
||||||
table_name: String,
|
table_name: String,
|
||||||
// CHANGE THIS: Accept the pre-converted data
|
|
||||||
data: HashMap<String, Value>,
|
data: HashMap<String, Value>,
|
||||||
) -> Result<PostTableDataResponse> {
|
) -> Result<PostTableDataResponse> {
|
||||||
// The conversion logic is now gone from here.
|
|
||||||
let grpc_request = PostTableDataRequest {
|
let grpc_request = PostTableDataRequest {
|
||||||
profile_name,
|
profile_name,
|
||||||
table_name,
|
table_name,
|
||||||
data, // This is now the correct type
|
data,
|
||||||
};
|
};
|
||||||
let request = tonic::Request::new(grpc_request);
|
let request = tonic::Request::new(grpc_request);
|
||||||
let response = self
|
let response = self
|
||||||
@@ -182,15 +225,13 @@ pub async fn get_table_data_by_position(
|
|||||||
profile_name: String,
|
profile_name: String,
|
||||||
table_name: String,
|
table_name: String,
|
||||||
id: i64,
|
id: i64,
|
||||||
// CHANGE THIS: Accept the pre-converted data
|
|
||||||
data: HashMap<String, Value>,
|
data: HashMap<String, Value>,
|
||||||
) -> Result<PutTableDataResponse> {
|
) -> Result<PutTableDataResponse> {
|
||||||
// The conversion logic is now gone from here.
|
|
||||||
let grpc_request = PutTableDataRequest {
|
let grpc_request = PutTableDataRequest {
|
||||||
profile_name,
|
profile_name,
|
||||||
table_name,
|
table_name,
|
||||||
id,
|
id,
|
||||||
data, // This is now the correct type
|
data,
|
||||||
};
|
};
|
||||||
let request = tonic::Request::new(grpc_request);
|
let request = tonic::Request::new(grpc_request);
|
||||||
let response = self
|
let response = self
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ impl UiService {
|
|||||||
pub async fn initialize_add_logic_table_data(
|
pub async fn initialize_add_logic_table_data(
|
||||||
grpc_client: &mut GrpcClient,
|
grpc_client: &mut GrpcClient,
|
||||||
add_logic_state: &mut AddLogicState,
|
add_logic_state: &mut AddLogicState,
|
||||||
profile_tree: &common::proto::multieko2::table_definition::ProfileTreeResponse,
|
profile_tree: &common::proto::komp_ac::table_definition::ProfileTreeResponse,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let profile_name_clone_opt = Some(add_logic_state.profile_name.clone());
|
let profile_name_clone_opt = Some(add_logic_state.profile_name.clone());
|
||||||
let table_name_opt_clone = add_logic_state.selected_table_name.clone();
|
let table_name_opt_clone = add_logic_state.selected_table_name.clone();
|
||||||
@@ -176,7 +176,7 @@ impl UiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// REFACTOR THIS FUNCTION
|
// TODO REFACTOR (maybe)
|
||||||
pub async fn initialize_app_state_and_form(
|
pub async fn initialize_app_state_and_form(
|
||||||
grpc_client: &mut GrpcClient,
|
grpc_client: &mut GrpcClient,
|
||||||
app_state: &mut AppState,
|
app_state: &mut AppState,
|
||||||
@@ -185,28 +185,27 @@ impl UiService {
|
|||||||
.get_profile_tree()
|
.get_profile_tree()
|
||||||
.await
|
.await
|
||||||
.context("Failed to get profile tree")?;
|
.context("Failed to get profile tree")?;
|
||||||
|
|
||||||
app_state.profile_tree = profile_tree;
|
app_state.profile_tree = profile_tree;
|
||||||
|
|
||||||
let initial_profile_name = app_state
|
// Find first profile that contains tables
|
||||||
|
let (initial_profile_name, initial_table_name) = app_state
|
||||||
.profile_tree
|
.profile_tree
|
||||||
.profiles
|
.profiles
|
||||||
.first()
|
.iter()
|
||||||
.map(|p| p.name.clone())
|
.find(|profile| !profile.tables.is_empty())
|
||||||
.unwrap_or_else(|| "default".to_string());
|
.and_then(|profile| {
|
||||||
|
profile.tables.first().map(|table| {
|
||||||
let initial_table_name = app_state
|
(profile.name.clone(), table.name.clone())
|
||||||
.profile_tree
|
})
|
||||||
.profiles
|
})
|
||||||
.first()
|
.ok_or_else(|| anyhow!("No profiles with tables found. Create a table first."))?;
|
||||||
.and_then(|p| p.tables.first().map(|t| t.name.clone()))
|
|
||||||
.unwrap_or_else(|| "2025_company_data1".to_string());
|
|
||||||
|
|
||||||
app_state.set_current_view_table(
|
app_state.set_current_view_table(
|
||||||
initial_profile_name.clone(),
|
initial_profile_name.clone(),
|
||||||
initial_table_name.clone(),
|
initial_table_name.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// NOW, just call our new central function. This avoids code duplication.
|
|
||||||
let form_state = Self::load_table_view(
|
let form_state = Self::load_table_view(
|
||||||
grpc_client,
|
grpc_client,
|
||||||
app_state,
|
app_state,
|
||||||
@@ -215,7 +214,6 @@ impl UiService {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// The field names for the UI are derived from the new form_state
|
|
||||||
let field_names = form_state.fields.iter().map(|f| f.display_name.clone()).collect();
|
let field_names = form_state.fields.iter().map(|f| f.display_name.clone()).collect();
|
||||||
|
|
||||||
Ok((initial_profile_name, initial_table_name, field_names))
|
Ok((initial_profile_name, initial_table_name, field_names))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// src/state/app/search.rs
|
// src/state/app/search.rs
|
||||||
|
|
||||||
use common::proto::multieko2::search::search_response::Hit;
|
use common::proto::komp_ac::search::search_response::Hit;
|
||||||
|
|
||||||
/// Holds the complete state for the search palette.
|
/// Holds the complete state for the search palette.
|
||||||
pub struct SearchState {
|
pub struct SearchState {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// src/state/app/state.rs
|
// src/state/app/state.rs
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use common::proto::multieko2::table_definition::ProfileTreeResponse;
|
use common::proto::komp_ac::table_definition::ProfileTreeResponse;
|
||||||
// NEW: Import the types we need for the cache
|
// NEW: Import the types we need for the cache
|
||||||
use common::proto::multieko2::table_structure::TableStructureResponse;
|
use common::proto::komp_ac::table_structure::TableStructureResponse;
|
||||||
use crate::modes::handlers::mode_manager::AppMode;
|
use crate::modes::handlers::mode_manager::AppMode;
|
||||||
use crate::state::app::search::SearchState;
|
use crate::state::app::search::SearchState;
|
||||||
use crate::ui::handlers::context::DialogPurpose;
|
use crate::ui::handlers::context::DialogPurpose;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -50,10 +50,11 @@ pub struct AddLogicState {
|
|||||||
pub script_editor_trigger_position: Option<(usize, usize)>, // (line, column)
|
pub script_editor_trigger_position: Option<(usize, usize)>, // (line, column)
|
||||||
pub all_table_names: Vec<String>,
|
pub all_table_names: Vec<String>,
|
||||||
pub script_editor_filter_text: String,
|
pub script_editor_filter_text: String,
|
||||||
|
|
||||||
// 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 {
|
||||||
@@ -88,9 +89,10 @@ impl AddLogicState {
|
|||||||
script_editor_trigger_position: None,
|
script_editor_trigger_position: None,
|
||||||
all_table_names: Vec::new(),
|
all_table_names: Vec::new(),
|
||||||
script_editor_filter_text: String::new(),
|
script_editor_filter_text: String::new(),
|
||||||
|
|
||||||
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +183,7 @@ impl AddLogicState {
|
|||||||
}
|
}
|
||||||
self.same_profile_table_names.contains(&suggestion.to_string())
|
self.same_profile_table_names.contains(&suggestion.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets table columns for autocomplete suggestions
|
/// Sets table columns for autocomplete suggestions
|
||||||
pub fn set_table_columns(&mut self, columns: Vec<String>) {
|
pub fn set_table_columns(&mut self, columns: Vec<String>) {
|
||||||
self.table_columns_for_suggestions = columns.clone();
|
self.table_columns_for_suggestions = columns.clone();
|
||||||
@@ -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::multieko2::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,9 +1,8 @@
|
|||||||
// 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 crate::state::pages::canvas_state::CanvasState;
|
use common::proto::komp_ac::search::search_response::Hit;
|
||||||
use common::proto::multieko2::search::search_response::Hit;
|
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -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();
|
||||||
@@ -209,66 +218,103 @@ impl FormState {
|
|||||||
self.link_display_map.clear();
|
self.link_display_map.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn deactivate_autocomplete(&mut self) {
|
// NEW: Keep the rich suggestions methods for compatibility
|
||||||
self.autocomplete_active = false;
|
pub fn get_rich_suggestions(&self) -> Option<&[Hit]> {
|
||||||
self.autocomplete_suggestions.clear();
|
if self.autocomplete_active {
|
||||||
self.selected_suggestion_index = None;
|
Some(&self.autocomplete_suggestions)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn activate_rich_suggestions(&mut self, suggestions: Vec<Hit>) {
|
||||||
|
self.autocomplete_suggestions = suggestions;
|
||||||
|
self.autocomplete_active = !self.autocomplete_suggestions.is_empty();
|
||||||
|
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 {
|
||||||
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 {
|
||||||
self.current_cursor_pos
|
self.current_cursor_pos
|
||||||
}
|
}
|
||||||
|
|
||||||
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 inputs(&self) -> Vec<&String> {
|
||||||
self.values.iter().collect()
|
self.values.iter().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_current_input(&self) -> &str {
|
fn get_current_input(&self) -> &str {
|
||||||
FormState::get_current_input(self)
|
FormState::get_current_input(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_current_input_mut(&mut self) -> &mut String {
|
fn get_current_input_mut(&mut self) -> &mut String {
|
||||||
FormState::get_current_input_mut(self)
|
FormState::get_current_input_mut(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fields(&self) -> Vec<&str> {
|
fn fields(&self) -> Vec<&str> {
|
||||||
self.fields
|
self.fields
|
||||||
.iter()
|
.iter()
|
||||||
.map(|f| f.display_name.as_str())
|
.map(|f| f.display_name.as_str())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_current_field(&mut self, index: usize) {
|
fn set_current_field(&mut self, index: usize) {
|
||||||
if index < self.fields.len() {
|
if index < self.fields.len() {
|
||||||
self.current_field = index;
|
self.current_field = index;
|
||||||
}
|
}
|
||||||
self.deactivate_autocomplete();
|
self.deactivate_autocomplete();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_current_cursor_pos(&mut self, pos: usize) {
|
fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||||
self.current_cursor_pos = pos;
|
self.current_cursor_pos = pos;
|
||||||
}
|
}
|
||||||
|
|
||||||
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]> {
|
|
||||||
None
|
// --- FEATURE-SPECIFIC ACTION HANDLING ---
|
||||||
}
|
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||||
fn get_rich_suggestions(&self) -> Option<&[Hit]> {
|
match action {
|
||||||
if self.autocomplete_active {
|
CanvasAction::SelectSuggestion => {
|
||||||
Some(&self.autocomplete_suggestions)
|
if let Some(selected_idx) = self.selected_suggestion_index {
|
||||||
} else {
|
if let Some(hit) = self.autocomplete_suggestions.get(selected_idx).cloned() {
|
||||||
None
|
// Extract the value from the selected suggestion
|
||||||
}
|
if let Ok(content_map) = serde_json::from_str::<HashMap<String, serde_json::Value>>(&hit.content_json) {
|
||||||
}
|
let current_field_def = &self.fields[self.current_field];
|
||||||
fn get_selected_suggestion_index(&self) -> Option<usize> {
|
if let Some(value) = content_map.get(¤t_field_def.data_key) {
|
||||||
if self.autocomplete_active {
|
let new_value = json_value_to_string(value);
|
||||||
self.selected_suggestion_index
|
let display_name = self.get_display_name_for_hit(&hit);
|
||||||
} else {
|
*self.get_current_input_mut() = new_value.clone();
|
||||||
None
|
self.set_current_cursor_pos(new_value.len());
|
||||||
|
self.set_has_unsaved_changes(true);
|
||||||
|
self.deactivate_autocomplete();
|
||||||
|
return Some(format!("Selected: {}", display_name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
_ => None, // Let canvas handle other actions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,8 +328,11 @@ impl CanvasState for FormState {
|
|||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- IMPLEMENT THE NEW TRAIT METHOD ---
|
|
||||||
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use crate::state::pages::add_table::{
|
|||||||
};
|
};
|
||||||
use crate::services::GrpcClient;
|
use crate::services::GrpcClient;
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use common::proto::multieko2::table_definition::{
|
use common::proto::komp_ac::table_definition::{
|
||||||
PostTableDefinitionRequest,
|
PostTableDefinitionRequest,
|
||||||
ColumnDefinition as ProtoColumnDefinition,
|
ColumnDefinition as ProtoColumnDefinition,
|
||||||
TableLink as ProtoTableLink,
|
TableLink as ProtoTableLink,
|
||||||
|
|||||||
@@ -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::multieko2::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::multieko2::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::canvas_state::CanvasState;
|
|
||||||
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::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(
|
||||||
@@ -200,13 +208,15 @@ 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 &&
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// src/utils/data_converter.rs
|
// src/utils/data_converter.rs
|
||||||
|
|
||||||
use common::proto::multieko2::table_structure::TableStructureResponse;
|
use common::proto::komp_ac::table_structure::TableStructureResponse;
|
||||||
use prost_types::{value::Kind, NullValue, Value};
|
use prost_types::{value::Kind, NullValue, Value};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
|||||||
262
client/tests/form/gui/form_tests.rs
Normal file
262
client/tests/form/gui/form_tests.rs
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
// client/tests/form_tests.rs
|
||||||
|
use rstest::{fixture, rstest};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use client::state::pages::form::{FormState, FieldDefinition};
|
||||||
|
use canvas::canvas::CanvasState;
|
||||||
|
|
||||||
|
#[fixture]
|
||||||
|
fn test_form_state() -> FormState {
|
||||||
|
let fields = vec![
|
||||||
|
FieldDefinition {
|
||||||
|
display_name: "Company".to_string(),
|
||||||
|
data_key: "firma".to_string(),
|
||||||
|
is_link: false,
|
||||||
|
link_target_table: None,
|
||||||
|
},
|
||||||
|
FieldDefinition {
|
||||||
|
display_name: "Phone".to_string(),
|
||||||
|
data_key: "telefon".to_string(),
|
||||||
|
is_link: false,
|
||||||
|
link_target_table: None,
|
||||||
|
},
|
||||||
|
FieldDefinition {
|
||||||
|
display_name: "Email".to_string(),
|
||||||
|
data_key: "email".to_string(),
|
||||||
|
is_link: false,
|
||||||
|
link_target_table: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
FormState::new("test_profile".to_string(), "test_table".to_string(), fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[fixture]
|
||||||
|
fn test_form_data() -> HashMap<String, String> {
|
||||||
|
let mut data = HashMap::new();
|
||||||
|
data.insert("firma".to_string(), "Test Company".to_string());
|
||||||
|
data.insert("telefon".to_string(), "+421123456789".to_string());
|
||||||
|
data.insert("email".to_string(), "test@example.com".to_string());
|
||||||
|
data
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_form_state_creation(test_form_state: FormState) {
|
||||||
|
assert_eq!(test_form_state.profile_name, "test_profile");
|
||||||
|
assert_eq!(test_form_state.table_name, "test_table");
|
||||||
|
assert_eq!(test_form_state.fields.len(), 3);
|
||||||
|
assert_eq!(test_form_state.current_field(), 0);
|
||||||
|
assert!(!test_form_state.has_unsaved_changes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_form_field_navigation(mut test_form_state: FormState) {
|
||||||
|
// Test initial field
|
||||||
|
assert_eq!(test_form_state.current_field(), 0);
|
||||||
|
|
||||||
|
// Test navigation to next field
|
||||||
|
test_form_state.set_current_field(1);
|
||||||
|
assert_eq!(test_form_state.current_field(), 1);
|
||||||
|
|
||||||
|
// Test navigation to last field
|
||||||
|
test_form_state.set_current_field(2);
|
||||||
|
assert_eq!(test_form_state.current_field(), 2);
|
||||||
|
|
||||||
|
// Test invalid field (should not crash)
|
||||||
|
test_form_state.set_current_field(999);
|
||||||
|
assert_eq!(test_form_state.current_field(), 2); // Should stay at valid field
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_form_data_entry(mut test_form_state: FormState) {
|
||||||
|
// Test entering data in first field
|
||||||
|
*test_form_state.get_current_input_mut() = "Test Company".to_string();
|
||||||
|
test_form_state.set_has_unsaved_changes(true);
|
||||||
|
|
||||||
|
assert_eq!(test_form_state.get_current_input(), "Test Company");
|
||||||
|
assert!(test_form_state.has_unsaved_changes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_form_field_switching_with_data(mut test_form_state: FormState) {
|
||||||
|
// Enter data in first field
|
||||||
|
*test_form_state.get_current_input_mut() = "Company Name".to_string();
|
||||||
|
|
||||||
|
// Switch to second field
|
||||||
|
test_form_state.set_current_field(1);
|
||||||
|
*test_form_state.get_current_input_mut() = "+421123456789".to_string();
|
||||||
|
|
||||||
|
// Switch back to first field
|
||||||
|
test_form_state.set_current_field(0);
|
||||||
|
assert_eq!(test_form_state.get_current_input(), "Company Name");
|
||||||
|
|
||||||
|
// Switch to second field again
|
||||||
|
test_form_state.set_current_field(1);
|
||||||
|
assert_eq!(test_form_state.get_current_input(), "+421123456789");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_form_reset_functionality(mut test_form_state: FormState) {
|
||||||
|
// Add some data
|
||||||
|
test_form_state.set_current_field(0);
|
||||||
|
*test_form_state.get_current_input_mut() = "Test Company".to_string();
|
||||||
|
test_form_state.set_current_field(1);
|
||||||
|
*test_form_state.get_current_input_mut() = "+421123456789".to_string();
|
||||||
|
test_form_state.set_has_unsaved_changes(true);
|
||||||
|
test_form_state.id = 123;
|
||||||
|
test_form_state.current_position = 5;
|
||||||
|
|
||||||
|
// Reset the form
|
||||||
|
test_form_state.reset_to_empty();
|
||||||
|
|
||||||
|
// Verify reset
|
||||||
|
assert_eq!(test_form_state.id, 0);
|
||||||
|
assert!(!test_form_state.has_unsaved_changes());
|
||||||
|
assert_eq!(test_form_state.current_field(), 0);
|
||||||
|
|
||||||
|
// Check all fields are empty
|
||||||
|
for i in 0..test_form_state.fields.len() {
|
||||||
|
test_form_state.set_current_field(i);
|
||||||
|
assert!(test_form_state.get_current_input().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_form_update_from_response(mut test_form_state: FormState, test_form_data: HashMap<String, String>) {
|
||||||
|
let position = 3;
|
||||||
|
|
||||||
|
// Update form with response data
|
||||||
|
test_form_state.update_from_response(&test_form_data, position);
|
||||||
|
|
||||||
|
// Verify data was loaded
|
||||||
|
assert_eq!(test_form_state.current_position, position);
|
||||||
|
assert!(!test_form_state.has_unsaved_changes());
|
||||||
|
assert_eq!(test_form_state.current_field(), 0);
|
||||||
|
|
||||||
|
// Check field values
|
||||||
|
test_form_state.set_current_field(0);
|
||||||
|
assert_eq!(test_form_state.get_current_input(), "Test Company");
|
||||||
|
|
||||||
|
test_form_state.set_current_field(1);
|
||||||
|
assert_eq!(test_form_state.get_current_input(), "+421123456789");
|
||||||
|
|
||||||
|
test_form_state.set_current_field(2);
|
||||||
|
assert_eq!(test_form_state.get_current_input(), "test@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_form_cursor_position(mut test_form_state: FormState) {
|
||||||
|
// Test initial cursor position
|
||||||
|
assert_eq!(test_form_state.current_cursor_pos(), 0);
|
||||||
|
|
||||||
|
// Add some text
|
||||||
|
*test_form_state.get_current_input_mut() = "Test Company".to_string();
|
||||||
|
|
||||||
|
// Test cursor positioning
|
||||||
|
test_form_state.set_current_cursor_pos(5);
|
||||||
|
assert_eq!(test_form_state.current_cursor_pos(), 5);
|
||||||
|
|
||||||
|
// Test cursor bounds
|
||||||
|
test_form_state.set_current_cursor_pos(999);
|
||||||
|
// Should be clamped to text length
|
||||||
|
assert!(test_form_state.current_cursor_pos() <= "Test Company".len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_form_field_display_names(test_form_state: FormState) {
|
||||||
|
let field_names = test_form_state.fields();
|
||||||
|
|
||||||
|
assert_eq!(field_names.len(), 3);
|
||||||
|
assert_eq!(field_names[0], "Company");
|
||||||
|
assert_eq!(field_names[1], "Phone");
|
||||||
|
assert_eq!(field_names[2], "Email");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_form_inputs_vector(mut test_form_state: FormState) {
|
||||||
|
// Add data to fields
|
||||||
|
test_form_state.set_current_field(0);
|
||||||
|
*test_form_state.get_current_input_mut() = "Company A".to_string();
|
||||||
|
|
||||||
|
test_form_state.set_current_field(1);
|
||||||
|
*test_form_state.get_current_input_mut() = "123456789".to_string();
|
||||||
|
|
||||||
|
test_form_state.set_current_field(2);
|
||||||
|
*test_form_state.get_current_input_mut() = "test@test.com".to_string();
|
||||||
|
|
||||||
|
// Get inputs vector
|
||||||
|
let inputs = test_form_state.inputs();
|
||||||
|
|
||||||
|
assert_eq!(inputs.len(), 3);
|
||||||
|
assert_eq!(inputs[0], "Company A");
|
||||||
|
assert_eq!(inputs[1], "123456789");
|
||||||
|
assert_eq!(inputs[2], "test@test.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_form_position_management(mut test_form_state: FormState) {
|
||||||
|
// Test initial position
|
||||||
|
assert_eq!(test_form_state.current_position, 1);
|
||||||
|
assert_eq!(test_form_state.total_count, 0);
|
||||||
|
|
||||||
|
// Set some values
|
||||||
|
test_form_state.total_count = 10;
|
||||||
|
test_form_state.current_position = 5;
|
||||||
|
|
||||||
|
assert_eq!(test_form_state.current_position, 5);
|
||||||
|
assert_eq!(test_form_state.total_count, 10);
|
||||||
|
|
||||||
|
// Test reset affects position
|
||||||
|
test_form_state.reset_to_empty();
|
||||||
|
assert_eq!(test_form_state.current_position, 11); // total_count + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_form_autocomplete_state(mut test_form_state: FormState) {
|
||||||
|
// Test initial autocomplete state
|
||||||
|
assert!(!test_form_state.autocomplete_active);
|
||||||
|
assert!(test_form_state.autocomplete_suggestions.is_empty());
|
||||||
|
assert!(test_form_state.selected_suggestion_index.is_none());
|
||||||
|
|
||||||
|
// Test deactivating autocomplete
|
||||||
|
test_form_state.autocomplete_active = true;
|
||||||
|
test_form_state.deactivate_autocomplete();
|
||||||
|
|
||||||
|
assert!(!test_form_state.autocomplete_active);
|
||||||
|
assert!(test_form_state.autocomplete_suggestions.is_empty());
|
||||||
|
assert!(test_form_state.selected_suggestion_index.is_none());
|
||||||
|
assert!(!test_form_state.autocomplete_loading);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_form_empty_data_handling(mut test_form_state: FormState) {
|
||||||
|
let empty_data = HashMap::new();
|
||||||
|
|
||||||
|
// Update with empty data
|
||||||
|
test_form_state.update_from_response(&empty_data, 1);
|
||||||
|
|
||||||
|
// All fields should be empty
|
||||||
|
for i in 0..test_form_state.fields.len() {
|
||||||
|
test_form_state.set_current_field(i);
|
||||||
|
assert!(test_form_state.get_current_input().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_form_partial_data_handling(mut test_form_state: FormState) {
|
||||||
|
let mut partial_data = HashMap::new();
|
||||||
|
partial_data.insert("firma".to_string(), "Partial Company".to_string());
|
||||||
|
// Intentionally missing telefon and email
|
||||||
|
|
||||||
|
test_form_state.update_from_response(&partial_data, 1);
|
||||||
|
|
||||||
|
// First field should have data
|
||||||
|
test_form_state.set_current_field(0);
|
||||||
|
assert_eq!(test_form_state.get_current_input(), "Partial Company");
|
||||||
|
|
||||||
|
// Other fields should be empty
|
||||||
|
test_form_state.set_current_field(1);
|
||||||
|
assert!(test_form_state.get_current_input().is_empty());
|
||||||
|
|
||||||
|
test_form_state.set_current_field(2);
|
||||||
|
assert!(test_form_state.get_current_input().is_empty());
|
||||||
|
}
|
||||||
1
client/tests/form/gui/mod.rs
Normal file
1
client/tests/form/gui/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod form_tests;
|
||||||
2
client/tests/form/mod.rs
Normal file
2
client/tests/form/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod gui;
|
||||||
|
pub mod requests;
|
||||||
1019
client/tests/form/requests/form_request_tests.rs
Normal file
1019
client/tests/form/requests/form_request_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
267
client/tests/form/requests/form_request_tests2.rs
Normal file
267
client/tests/form/requests/form_request_tests2.rs
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
// ========================================================================
|
||||||
|
// ROBUST WORKFLOW AND INTEGRATION TESTS
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_partial_update_preserves_other_fields(
|
||||||
|
#[future] populated_test_context: FormTestContext,
|
||||||
|
) {
|
||||||
|
let mut context = populated_test_context.await;
|
||||||
|
skip_if_backend_unavailable!();
|
||||||
|
|
||||||
|
// 1. Create a record with multiple fields
|
||||||
|
let mut initial_data = context.create_test_form_data();
|
||||||
|
let original_email = "preserve.this@email.com";
|
||||||
|
initial_data.insert(
|
||||||
|
"email".to_string(),
|
||||||
|
create_string_value(original_email),
|
||||||
|
);
|
||||||
|
|
||||||
|
let post_res = context
|
||||||
|
.client
|
||||||
|
.post_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
initial_data,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Setup: Failed to create record for partial update test");
|
||||||
|
let created_id = post_res.inserted_id;
|
||||||
|
println!("Partial Update Test: Created record ID {}", created_id);
|
||||||
|
|
||||||
|
// 2. Update only ONE field
|
||||||
|
let mut partial_update = HashMap::new();
|
||||||
|
let updated_firma = "Partially Updated Inc.";
|
||||||
|
partial_update.insert(
|
||||||
|
"firma".to_string(),
|
||||||
|
create_string_value(updated_firma),
|
||||||
|
);
|
||||||
|
|
||||||
|
context
|
||||||
|
.client
|
||||||
|
.put_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
created_id,
|
||||||
|
partial_update,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Partial update failed");
|
||||||
|
println!("Partial Update Test: Updated only 'firma' field");
|
||||||
|
|
||||||
|
// 3. Get the record back and verify ALL fields
|
||||||
|
let get_res = context
|
||||||
|
.client
|
||||||
|
.get_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
created_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Failed to get record after partial update");
|
||||||
|
|
||||||
|
let final_data = get_res.data;
|
||||||
|
assert_eq!(
|
||||||
|
final_data.get("firma").unwrap(),
|
||||||
|
updated_firma,
|
||||||
|
"The 'firma' field should be updated"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
final_data.get("email").unwrap(),
|
||||||
|
original_email,
|
||||||
|
"The 'email' field should have been preserved"
|
||||||
|
);
|
||||||
|
println!("Partial Update Test: Verified other fields were preserved. OK.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_data_edge_cases_and_unicode(
|
||||||
|
#[future] form_test_context: FormTestContext,
|
||||||
|
) {
|
||||||
|
let mut context = form_test_context.await;
|
||||||
|
skip_if_backend_unavailable!();
|
||||||
|
|
||||||
|
let edge_case_strings = vec![
|
||||||
|
("Unicode", "José María González, Москва, 北京市"),
|
||||||
|
("Emoji", "🚀 Tech Company 🌟"),
|
||||||
|
("Quotes", "Quote\"Test'Apostrophe"),
|
||||||
|
("Symbols", "Price: $1,000.50 (50% off!)"),
|
||||||
|
("Empty", ""),
|
||||||
|
("Whitespace", " "),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (case_name, test_string) in edge_case_strings {
|
||||||
|
let mut data = HashMap::new();
|
||||||
|
data.insert("firma".to_string(), create_string_value(test_string));
|
||||||
|
data.insert(
|
||||||
|
"kz".to_string(),
|
||||||
|
create_string_value(&format!("EDGE-{}", case_name)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let post_res = context
|
||||||
|
.client
|
||||||
|
.post_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect(&format!("POST should succeed for case: {}", case_name));
|
||||||
|
let created_id = post_res.inserted_id;
|
||||||
|
|
||||||
|
let get_res = context
|
||||||
|
.client
|
||||||
|
.get_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
created_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect(&format!(
|
||||||
|
"GET should succeed for case: {}",
|
||||||
|
case_name
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
get_res.data.get("firma").unwrap(),
|
||||||
|
test_string,
|
||||||
|
"Data should be identical after round-trip for case: {}",
|
||||||
|
case_name
|
||||||
|
);
|
||||||
|
println!("Edge Case Test: '{}' passed.", case_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_numeric_and_null_edge_cases(
|
||||||
|
#[future] form_test_context: FormTestContext,
|
||||||
|
) {
|
||||||
|
let mut context = form_test_context.await;
|
||||||
|
skip_if_backend_unavailable!();
|
||||||
|
|
||||||
|
// 1. Test NULL value
|
||||||
|
let mut null_data = HashMap::new();
|
||||||
|
null_data.insert(
|
||||||
|
"firma".to_string(),
|
||||||
|
create_string_value("Company With Null Phone"),
|
||||||
|
);
|
||||||
|
null_data.insert("telefon".to_string(), create_null_value());
|
||||||
|
let post_res_null = context
|
||||||
|
.client
|
||||||
|
.post_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
null_data,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("POST with NULL value should succeed");
|
||||||
|
let get_res_null = context
|
||||||
|
.client
|
||||||
|
.get_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
post_res_null.inserted_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// Depending on DB, NULL may come back as empty string or be absent.
|
||||||
|
// The important part is that the operation doesn't fail.
|
||||||
|
assert!(
|
||||||
|
get_res_null.data.get("telefon").unwrap_or(&"".to_string()).is_empty(),
|
||||||
|
"NULL value should result in an empty or absent field"
|
||||||
|
);
|
||||||
|
println!("Edge Case Test: NULL value handled correctly. OK.");
|
||||||
|
|
||||||
|
// 2. Test Zero value for a numeric field (assuming 'age' is numeric)
|
||||||
|
let mut zero_data = HashMap::new();
|
||||||
|
zero_data.insert(
|
||||||
|
"firma".to_string(),
|
||||||
|
create_string_value("Newborn Company"),
|
||||||
|
);
|
||||||
|
// Assuming 'age' is a field in your actual table definition
|
||||||
|
// zero_data.insert("age".to_string(), create_number_value(0.0));
|
||||||
|
// let post_res_zero = context.client.post_table_data(...).await.expect("POST with zero should succeed");
|
||||||
|
// ... then get and verify it's "0"
|
||||||
|
println!("Edge Case Test: Zero value test skipped (uncomment if 'age' field exists).");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_concurrent_updates_on_same_record(
|
||||||
|
#[future] populated_test_context: FormTestContext,
|
||||||
|
) {
|
||||||
|
let mut context = populated_test_context.await;
|
||||||
|
skip_if_backend_unavailable!();
|
||||||
|
|
||||||
|
// 1. Create a single record to be updated by all tasks
|
||||||
|
let initial_data = context.create_minimal_form_data();
|
||||||
|
let post_res = context
|
||||||
|
.client
|
||||||
|
.post_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
initial_data,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Setup: Failed to create record for concurrency test");
|
||||||
|
let record_id = post_res.inserted_id;
|
||||||
|
println!("Concurrency Test: Target record ID is {}", record_id);
|
||||||
|
|
||||||
|
// 2. Spawn multiple concurrent UPDATE operations
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
let num_concurrent_tasks = 5;
|
||||||
|
let mut final_values = Vec::new();
|
||||||
|
|
||||||
|
for i in 0..num_concurrent_tasks {
|
||||||
|
let mut client_clone = context.client.clone();
|
||||||
|
let profile_name = context.profile_name.clone();
|
||||||
|
let table_name = context.table_name.clone();
|
||||||
|
let final_value = format!("Concurrent Update {}", i);
|
||||||
|
final_values.push(final_value.clone());
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
let mut update_data = HashMap::new();
|
||||||
|
update_data.insert(
|
||||||
|
"firma".to_string(),
|
||||||
|
create_string_value(&final_value),
|
||||||
|
);
|
||||||
|
client_clone
|
||||||
|
.put_table_data(profile_name, table_name, record_id, update_data)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
handles.push(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Wait for all tasks to complete and check for panics
|
||||||
|
let results = futures::future::join_all(handles).await;
|
||||||
|
assert!(
|
||||||
|
results.iter().all(|r| r.is_ok()),
|
||||||
|
"No concurrent task should panic"
|
||||||
|
);
|
||||||
|
println!("Concurrency Test: All update tasks completed without panicking.");
|
||||||
|
|
||||||
|
// 4. Get the final state of the record
|
||||||
|
let final_get_res = context
|
||||||
|
.client
|
||||||
|
.get_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
record_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Should be able to get the record after concurrent updates");
|
||||||
|
|
||||||
|
let final_firma = final_get_res.data.get("firma").unwrap();
|
||||||
|
assert!(
|
||||||
|
final_values.contains(final_firma),
|
||||||
|
"The final state '{}' must be one of the states set by the tasks",
|
||||||
|
final_firma
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Concurrency Test: Final state is '{}', which is a valid outcome. OK.",
|
||||||
|
final_firma
|
||||||
|
);
|
||||||
|
}
|
||||||
727
client/tests/form/requests/form_request_tests3.rs
Normal file
727
client/tests/form/requests/form_request_tests3.rs
Normal file
@@ -0,0 +1,727 @@
|
|||||||
|
// form_request_tests3.rs - Comprehensive and Robust Testing
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// STEEL SCRIPT VALIDATION TESTS (HIGHEST PRIORITY)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_steel_script_validation_success(#[future] form_test_context: FormTestContext) {
|
||||||
|
let mut context = form_test_context.await;
|
||||||
|
skip_if_backend_unavailable!();
|
||||||
|
|
||||||
|
// Test with data that should pass script validation
|
||||||
|
// Assuming there's a script that validates 'kz' field to start with "KZ" and be 5 chars
|
||||||
|
let mut valid_data = HashMap::new();
|
||||||
|
valid_data.insert("firma".to_string(), create_string_value("Script Test Company"));
|
||||||
|
valid_data.insert("kz".to_string(), create_string_value("KZ123"));
|
||||||
|
valid_data.insert("telefon".to_string(), create_string_value("+421123456789"));
|
||||||
|
|
||||||
|
let result = context.client.post_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
valid_data,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(response) => {
|
||||||
|
assert!(response.success, "Valid data should pass script validation");
|
||||||
|
println!("Script Validation Test: Valid data passed - ID {}", response.inserted_id);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if let Some(status) = e.downcast_ref::<Status>() {
|
||||||
|
if status.code() == tonic::Code::Unavailable {
|
||||||
|
println!("Script validation test skipped - backend not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If there are no scripts configured, this might still work
|
||||||
|
println!("Script validation test: {}", status.message());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_steel_script_validation_failure(#[future] form_test_context: FormTestContext) {
|
||||||
|
let mut context = form_test_context.await;
|
||||||
|
skip_if_backend_unavailable!();
|
||||||
|
|
||||||
|
// Test with data that should fail script validation
|
||||||
|
let invalid_script_data = vec![
|
||||||
|
("TooShort", "KZ12"), // Too short
|
||||||
|
("TooLong", "KZ12345"), // Too long
|
||||||
|
("WrongPrefix", "AB123"), // Wrong prefix
|
||||||
|
("NoPrefix", "12345"), // No prefix
|
||||||
|
("Empty", ""), // Empty
|
||||||
|
];
|
||||||
|
|
||||||
|
for (test_case, invalid_kz) in invalid_script_data {
|
||||||
|
let mut invalid_data = HashMap::new();
|
||||||
|
invalid_data.insert("firma".to_string(), create_string_value("Script Fail Company"));
|
||||||
|
invalid_data.insert("kz".to_string(), create_string_value(invalid_kz));
|
||||||
|
|
||||||
|
let result = context.client.post_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
invalid_data,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => {
|
||||||
|
println!("Script Validation Test: {} passed (no validation script configured)", test_case);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if let Some(status) = e.downcast_ref::<Status>() {
|
||||||
|
assert_eq!(status.code(), tonic::Code::InvalidArgument,
|
||||||
|
"Script validation failure should return InvalidArgument for case: {}", test_case);
|
||||||
|
println!("Script Validation Test: {} correctly failed - {}", test_case, status.message());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_steel_script_validation_on_update(#[future] form_test_context: FormTestContext) {
|
||||||
|
let mut context = form_test_context.await;
|
||||||
|
skip_if_backend_unavailable!();
|
||||||
|
|
||||||
|
// 1. Create a valid record first
|
||||||
|
let mut initial_data = HashMap::new();
|
||||||
|
initial_data.insert("firma".to_string(), create_string_value("Update Script Test"));
|
||||||
|
initial_data.insert("kz".to_string(), create_string_value("KZ123"));
|
||||||
|
|
||||||
|
let post_result = context.client.post_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
initial_data,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
if let Ok(post_response) = post_result {
|
||||||
|
let record_id = post_response.inserted_id;
|
||||||
|
|
||||||
|
// 2. Try to update with invalid data
|
||||||
|
let mut invalid_update = HashMap::new();
|
||||||
|
invalid_update.insert("kz".to_string(), create_string_value("INVALID"));
|
||||||
|
|
||||||
|
let update_result = context.client.put_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
record_id,
|
||||||
|
invalid_update,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
match update_result {
|
||||||
|
Ok(_) => {
|
||||||
|
println!("Script Validation on Update: No validation script configured for updates");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if let Some(status) = e.downcast_ref::<Status>() {
|
||||||
|
assert_eq!(status.code(), tonic::Code::InvalidArgument,
|
||||||
|
"Update with invalid data should fail script validation");
|
||||||
|
println!("Script Validation on Update: Correctly rejected invalid update");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// COMPREHENSIVE DATA TYPE TESTS
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_boolean_data_type(#[future] form_test_context: FormTestContext) {
|
||||||
|
let mut context = form_test_context.await;
|
||||||
|
skip_if_backend_unavailable!();
|
||||||
|
|
||||||
|
// Test valid boolean values
|
||||||
|
let boolean_test_cases = vec![
|
||||||
|
("true", true),
|
||||||
|
("false", false),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (case_name, bool_value) in boolean_test_cases {
|
||||||
|
let mut data = HashMap::new();
|
||||||
|
data.insert("firma".to_string(), create_string_value("Boolean Test Company"));
|
||||||
|
// Assuming there's a boolean field called 'active'
|
||||||
|
data.insert("active".to_string(), create_bool_value(bool_value));
|
||||||
|
|
||||||
|
let result = context.client.post_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
data,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(response) => {
|
||||||
|
println!("Boolean Test: {} value succeeded", case_name);
|
||||||
|
|
||||||
|
// Verify the value round-trip
|
||||||
|
if let Ok(get_response) = context.client.get_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
response.inserted_id,
|
||||||
|
).await {
|
||||||
|
if let Some(retrieved_value) = get_response.data.get("active") {
|
||||||
|
println!("Boolean Test: {} round-trip value: {}", case_name, retrieved_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("Boolean Test: {} failed (field may not exist): {}", case_name, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_numeric_data_types(#[future] form_test_context: FormTestContext) {
|
||||||
|
let mut context = form_test_context.await;
|
||||||
|
skip_if_backend_unavailable!();
|
||||||
|
|
||||||
|
// Test various numeric values
|
||||||
|
let numeric_test_cases = vec![
|
||||||
|
("Zero", 0.0),
|
||||||
|
("Positive", 123.45),
|
||||||
|
("Negative", -67.89),
|
||||||
|
("Large", 999999.99),
|
||||||
|
("SmallDecimal", 0.01),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (case_name, numeric_value) in numeric_test_cases {
|
||||||
|
let mut data = HashMap::new();
|
||||||
|
data.insert("firma".to_string(), create_string_value("Numeric Test Company"));
|
||||||
|
// Assuming there's a numeric field called 'price' or 'amount'
|
||||||
|
data.insert("amount".to_string(), create_number_value(numeric_value));
|
||||||
|
|
||||||
|
let result = context.client.post_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
data,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(response) => {
|
||||||
|
println!("Numeric Test: {} ({}) succeeded", case_name, numeric_value);
|
||||||
|
|
||||||
|
// Verify round-trip
|
||||||
|
if let Ok(get_response) = context.client.get_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
response.inserted_id,
|
||||||
|
).await {
|
||||||
|
if let Some(retrieved_value) = get_response.data.get("amount") {
|
||||||
|
println!("Numeric Test: {} round-trip value: {}", case_name, retrieved_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("Numeric Test: {} failed (field may not exist): {}", case_name, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_timestamp_data_type(#[future] form_test_context: FormTestContext) {
|
||||||
|
let mut context = form_test_context.await;
|
||||||
|
skip_if_backend_unavailable!();
|
||||||
|
|
||||||
|
// Test various timestamp formats
|
||||||
|
let timestamp_test_cases = vec![
|
||||||
|
("ISO8601", "2024-01-15T10:30:00Z"),
|
||||||
|
("WithTimezone", "2024-01-15T10:30:00+01:00"),
|
||||||
|
("WithMilliseconds", "2024-01-15T10:30:00.123Z"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (case_name, timestamp_str) in timestamp_test_cases {
|
||||||
|
let mut data = HashMap::new();
|
||||||
|
data.insert("firma".to_string(), create_string_value("Timestamp Test Company"));
|
||||||
|
// Assuming there's a timestamp field called 'created_at'
|
||||||
|
data.insert("created_at".to_string(), create_string_value(timestamp_str));
|
||||||
|
|
||||||
|
let result = context.client.post_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
data,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(response) => {
|
||||||
|
println!("Timestamp Test: {} succeeded", case_name);
|
||||||
|
|
||||||
|
// Verify round-trip
|
||||||
|
if let Ok(get_response) = context.client.get_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
response.inserted_id,
|
||||||
|
).await {
|
||||||
|
if let Some(retrieved_value) = get_response.data.get("created_at") {
|
||||||
|
println!("Timestamp Test: {} round-trip value: {}", case_name, retrieved_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("Timestamp Test: {} failed (field may not exist): {}", case_name, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_invalid_data_types(#[future] form_test_context: FormTestContext) {
|
||||||
|
let mut context = form_test_context.await;
|
||||||
|
skip_if_backend_unavailable!();
|
||||||
|
|
||||||
|
// Test invalid data type combinations
|
||||||
|
let invalid_type_cases = vec![
|
||||||
|
("StringForNumber", "amount", create_string_value("not-a-number")),
|
||||||
|
("NumberForBoolean", "active", create_number_value(123.0)),
|
||||||
|
("StringForBoolean", "active", create_string_value("maybe")),
|
||||||
|
("InvalidTimestamp", "created_at", create_string_value("not-a-date")),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (case_name, field_name, invalid_value) in invalid_type_cases {
|
||||||
|
let mut data = HashMap::new();
|
||||||
|
data.insert("firma".to_string(), create_string_value("Invalid Type Test"));
|
||||||
|
data.insert(field_name.to_string(), invalid_value);
|
||||||
|
|
||||||
|
let result = context.client.post_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
data,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => {
|
||||||
|
println!("Invalid Type Test: {} passed (no type validation or field doesn't exist)", case_name);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if let Some(status) = e.downcast_ref::<Status>() {
|
||||||
|
assert_eq!(status.code(), tonic::Code::InvalidArgument,
|
||||||
|
"Invalid data type should return InvalidArgument for case: {}", case_name);
|
||||||
|
println!("Invalid Type Test: {} correctly rejected - {}", case_name, status.message());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// FOREIGN KEY RELATIONSHIP TESTS
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_foreign_key_valid_relationship(#[future] form_test_context: FormTestContext) {
|
||||||
|
let mut context = form_test_context.await;
|
||||||
|
skip_if_backend_unavailable!();
|
||||||
|
|
||||||
|
// 1. Create a parent record first (e.g., company)
|
||||||
|
let mut parent_data = HashMap::new();
|
||||||
|
parent_data.insert("firma".to_string(), create_string_value("Parent Company"));
|
||||||
|
|
||||||
|
let parent_result = context.client.post_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
"companies".to_string(), // Assuming companies table exists
|
||||||
|
parent_data,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
if let Ok(parent_response) = parent_result {
|
||||||
|
let parent_id = parent_response.inserted_id;
|
||||||
|
|
||||||
|
// 2. Create a child record that references the parent
|
||||||
|
let mut child_data = HashMap::new();
|
||||||
|
child_data.insert("name".to_string(), create_string_value("Child Record"));
|
||||||
|
child_data.insert("company_id".to_string(), create_number_value(parent_id as f64));
|
||||||
|
|
||||||
|
let child_result = context.client.post_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
"contacts".to_string(), // Assuming contacts table exists
|
||||||
|
child_data,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
match child_result {
|
||||||
|
Ok(child_response) => {
|
||||||
|
assert!(child_response.success, "Valid foreign key relationship should succeed");
|
||||||
|
println!("Foreign Key Test: Valid relationship created - Parent ID: {}, Child ID: {}",
|
||||||
|
parent_id, child_response.inserted_id);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("Foreign Key Test: Failed (tables may not exist or no FK constraint): {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("Foreign Key Test: Could not create parent record");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_foreign_key_invalid_relationship(#[future] form_test_context: FormTestContext) {
|
||||||
|
let mut context = form_test_context.await;
|
||||||
|
skip_if_backend_unavailable!();
|
||||||
|
|
||||||
|
// Try to create a child record with non-existent parent ID
|
||||||
|
let mut invalid_child_data = HashMap::new();
|
||||||
|
invalid_child_data.insert("name".to_string(), create_string_value("Orphan Record"));
|
||||||
|
invalid_child_data.insert("company_id".to_string(), create_number_value(99999.0)); // Non-existent ID
|
||||||
|
|
||||||
|
let result = context.client.post_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
"contacts".to_string(),
|
||||||
|
invalid_child_data,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => {
|
||||||
|
println!("Foreign Key Test: Invalid relationship passed (no FK constraint configured)");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if let Some(status) = e.downcast_ref::<Status>() {
|
||||||
|
// Could be InvalidArgument or NotFound depending on implementation
|
||||||
|
assert!(matches!(status.code(), tonic::Code::InvalidArgument | tonic::Code::NotFound),
|
||||||
|
"Invalid foreign key should return InvalidArgument or NotFound");
|
||||||
|
println!("Foreign Key Test: Invalid relationship correctly rejected - {}", status.message());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// DELETED RECORD INTERACTION TESTS
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_update_deleted_record_behavior(#[future] form_test_context: FormTestContext) {
|
||||||
|
let mut context = form_test_context.await;
|
||||||
|
skip_if_backend_unavailable!();
|
||||||
|
|
||||||
|
// 1. Create a record
|
||||||
|
let initial_data = context.create_test_form_data();
|
||||||
|
let post_result = context.client.post_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
initial_data,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
if let Ok(post_response) = post_result {
|
||||||
|
let record_id = post_response.inserted_id;
|
||||||
|
println!("Deleted Record Test: Created record ID {}", record_id);
|
||||||
|
|
||||||
|
// 2. Delete the record (soft delete)
|
||||||
|
let delete_result = context.client.delete_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
record_id,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
assert!(delete_result.is_ok(), "Delete should succeed");
|
||||||
|
println!("Deleted Record Test: Soft-deleted record {}", record_id);
|
||||||
|
|
||||||
|
// 3. Try to UPDATE the deleted record
|
||||||
|
let mut update_data = HashMap::new();
|
||||||
|
update_data.insert("firma".to_string(), create_string_value("Updated Deleted Record"));
|
||||||
|
|
||||||
|
let update_result = context.client.put_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
record_id,
|
||||||
|
update_data,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
match update_result {
|
||||||
|
Ok(_) => {
|
||||||
|
// This might be a bug - updating deleted records should probably fail
|
||||||
|
println!("Deleted Record Test: UPDATE on deleted record succeeded (potential bug?)");
|
||||||
|
|
||||||
|
// Check if the record is still considered deleted
|
||||||
|
let get_result = context.client.get_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
record_id,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
if get_result.is_err() {
|
||||||
|
println!("Deleted Record Test: Record still appears deleted after update");
|
||||||
|
} else {
|
||||||
|
println!("Deleted Record Test: Record appears to be undeleted after update");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if let Some(status) = e.downcast_ref::<Status>() {
|
||||||
|
assert_eq!(status.code(), tonic::Code::NotFound,
|
||||||
|
"UPDATE on deleted record should return NotFound");
|
||||||
|
println!("Deleted Record Test: UPDATE correctly rejected on deleted record");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_delete_already_deleted_record(#[future] form_test_context: FormTestContext) {
|
||||||
|
let mut context = form_test_context.await;
|
||||||
|
skip_if_backend_unavailable!();
|
||||||
|
|
||||||
|
// 1. Create and delete a record
|
||||||
|
let initial_data = context.create_test_form_data();
|
||||||
|
let post_result = context.client.post_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
initial_data,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
if let Ok(post_response) = post_result {
|
||||||
|
let record_id = post_response.inserted_id;
|
||||||
|
|
||||||
|
// First deletion
|
||||||
|
let delete_result1 = context.client.delete_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
record_id,
|
||||||
|
).await;
|
||||||
|
assert!(delete_result1.is_ok(), "First delete should succeed");
|
||||||
|
|
||||||
|
// Second deletion (idempotent)
|
||||||
|
let delete_result2 = context.client.delete_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
record_id,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
assert!(delete_result2.is_ok(), "Second delete should succeed (idempotent)");
|
||||||
|
if let Ok(response) = delete_result2 {
|
||||||
|
assert!(response.success, "Delete should report success even for already-deleted record");
|
||||||
|
}
|
||||||
|
println!("Double Delete Test: Both deletions succeeded (idempotent behavior)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// VALIDATION AND BOUNDARY TESTS
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_large_data_handling(#[future] form_test_context: FormTestContext) {
|
||||||
|
let mut context = form_test_context.await;
|
||||||
|
skip_if_backend_unavailable!();
|
||||||
|
|
||||||
|
// Test with very large string values
|
||||||
|
let large_string = "A".repeat(10000); // 10KB string
|
||||||
|
let very_large_string = "B".repeat(100000); // 100KB string
|
||||||
|
|
||||||
|
let test_cases = vec![
|
||||||
|
("Large", large_string),
|
||||||
|
("VeryLarge", very_large_string),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (case_name, large_value) in test_cases {
|
||||||
|
let mut data = HashMap::new();
|
||||||
|
data.insert("firma".to_string(), create_string_value(&large_value));
|
||||||
|
|
||||||
|
let result = context.client.post_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
data,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(response) => {
|
||||||
|
println!("Large Data Test: {} string handled successfully", case_name);
|
||||||
|
|
||||||
|
// Verify round-trip
|
||||||
|
if let Ok(get_response) = context.client.get_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
response.inserted_id,
|
||||||
|
).await {
|
||||||
|
if let Some(retrieved_value) = get_response.data.get("firma") {
|
||||||
|
assert_eq!(retrieved_value.len(), large_value.len(),
|
||||||
|
"Large string should survive round-trip for case: {}", case_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("Large Data Test: {} failed (may hit size limits): {}", case_name, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_sql_injection_attempts(#[future] form_test_context: FormTestContext) {
|
||||||
|
let mut context = form_test_context.await;
|
||||||
|
skip_if_backend_unavailable!();
|
||||||
|
|
||||||
|
// Test potential SQL injection strings
|
||||||
|
let injection_attempts = vec![
|
||||||
|
("SingleQuote", "'; DROP TABLE users; --"),
|
||||||
|
("DoubleQuote", "\"; DROP TABLE users; --"),
|
||||||
|
("Union", "' UNION SELECT * FROM users --"),
|
||||||
|
("Comment", "/* malicious comment */"),
|
||||||
|
("Semicolon", "; DELETE FROM users;"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (case_name, injection_string) in injection_attempts {
|
||||||
|
let mut data = HashMap::new();
|
||||||
|
data.insert("firma".to_string(), create_string_value(injection_string));
|
||||||
|
data.insert("kz".to_string(), create_string_value("KZ123"));
|
||||||
|
|
||||||
|
let result = context.client.post_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
data,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(response) => {
|
||||||
|
println!("SQL Injection Test: {} handled safely (parameterized queries)", case_name);
|
||||||
|
|
||||||
|
// Verify the malicious string was stored as-is (not executed)
|
||||||
|
if let Ok(get_response) = context.client.get_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
response.inserted_id,
|
||||||
|
).await {
|
||||||
|
if let Some(retrieved_value) = get_response.data.get("firma") {
|
||||||
|
assert_eq!(retrieved_value, injection_string,
|
||||||
|
"Injection string should be stored literally for case: {}", case_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("SQL Injection Test: {} rejected: {}", case_name, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_concurrent_operations_with_same_data(#[future] form_test_context: FormTestContext) {
|
||||||
|
let context = form_test_context.await;
|
||||||
|
skip_if_backend_unavailable!();
|
||||||
|
|
||||||
|
// Test multiple concurrent operations with identical data
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
let num_tasks = 10;
|
||||||
|
|
||||||
|
for i in 0..num_tasks {
|
||||||
|
let mut context_clone = context.clone();
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
let mut data = HashMap::new();
|
||||||
|
data.insert("firma".to_string(), create_string_value("Concurrent Identical"));
|
||||||
|
data.insert("kz".to_string(), create_string_value(&format!("SAME{:02}", i)));
|
||||||
|
|
||||||
|
context_clone.client.post_table_data(
|
||||||
|
context_clone.profile_name,
|
||||||
|
context_clone.table_name,
|
||||||
|
data,
|
||||||
|
).await
|
||||||
|
});
|
||||||
|
handles.push(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all to complete
|
||||||
|
let mut success_count = 0;
|
||||||
|
let mut inserted_ids = Vec::new();
|
||||||
|
|
||||||
|
for (i, handle) in handles.into_iter().enumerate() {
|
||||||
|
match handle.await {
|
||||||
|
Ok(Ok(response)) => {
|
||||||
|
success_count += 1;
|
||||||
|
inserted_ids.push(response.inserted_id);
|
||||||
|
println!("Concurrent Identical Data: Task {} succeeded with ID {}", i, response.inserted_id);
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
println!("Concurrent Identical Data: Task {} failed: {}", i, e);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("Concurrent Identical Data: Task {} panicked: {}", i, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(success_count > 0, "At least some concurrent operations should succeed");
|
||||||
|
|
||||||
|
// Verify all IDs are unique
|
||||||
|
let unique_ids: std::collections::HashSet<_> = inserted_ids.iter().collect();
|
||||||
|
assert_eq!(unique_ids.len(), inserted_ids.len(), "All inserted IDs should be unique");
|
||||||
|
|
||||||
|
println!("Concurrent Identical Data: {}/{} operations succeeded with unique IDs",
|
||||||
|
success_count, num_tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// PERFORMANCE AND STRESS TESTS
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bulk_operations_performance(#[future] form_test_context: FormTestContext) {
|
||||||
|
let mut context = form_test_context.await;
|
||||||
|
skip_if_backend_unavailable!();
|
||||||
|
|
||||||
|
let operation_count = 50;
|
||||||
|
let start_time = std::time::Instant::now();
|
||||||
|
|
||||||
|
let mut successful_operations = 0;
|
||||||
|
let mut created_ids = Vec::new();
|
||||||
|
|
||||||
|
// Bulk create
|
||||||
|
for i in 0..operation_count {
|
||||||
|
let mut data = HashMap::new();
|
||||||
|
data.insert("firma".to_string(), create_string_value(&format!("Bulk Company {}", i)));
|
||||||
|
data.insert("kz".to_string(), create_string_value(&format!("BLK{:02}", i)));
|
||||||
|
|
||||||
|
if let Ok(response) = context.client.post_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
data,
|
||||||
|
).await {
|
||||||
|
successful_operations += 1;
|
||||||
|
created_ids.push(response.inserted_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let create_duration = start_time.elapsed();
|
||||||
|
println!("Bulk Performance: Created {} records in {:?}", successful_operations, create_duration);
|
||||||
|
|
||||||
|
// Bulk read
|
||||||
|
let read_start = std::time::Instant::now();
|
||||||
|
let mut successful_reads = 0;
|
||||||
|
|
||||||
|
for &record_id in &created_ids {
|
||||||
|
if context.client.get_table_data(
|
||||||
|
context.profile_name.clone(),
|
||||||
|
context.table_name.clone(),
|
||||||
|
record_id,
|
||||||
|
).await.is_ok() {
|
||||||
|
successful_reads += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let read_duration = read_start.elapsed();
|
||||||
|
println!("Bulk Performance: Read {} records in {:?}", successful_reads, read_duration);
|
||||||
|
|
||||||
|
// Performance assertions
|
||||||
|
assert!(successful_operations > operation_count * 8 / 10,
|
||||||
|
"At least 80% of operations should succeed");
|
||||||
|
assert!(create_duration.as_secs() < 60,
|
||||||
|
"Bulk operations should complete in reasonable time");
|
||||||
|
|
||||||
|
println!("Bulk Performance Test: {}/{} creates, {}/{} reads successful",
|
||||||
|
successful_operations, operation_count, successful_reads, created_ids.len());
|
||||||
|
}
|
||||||
1
client/tests/form/requests/mod.rs
Normal file
1
client/tests/form/requests/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod form_request_tests;
|
||||||
3
client/tests/mod.rs
Normal file
3
client/tests/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// tests/mod.rs
|
||||||
|
|
||||||
|
pub mod form;
|
||||||
@@ -15,6 +15,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
"proto/tables_data.proto",
|
"proto/tables_data.proto",
|
||||||
"proto/table_script.proto",
|
"proto/table_script.proto",
|
||||||
"proto/search.proto",
|
"proto/search.proto",
|
||||||
|
"proto/search2.proto",
|
||||||
],
|
],
|
||||||
&["proto"],
|
&["proto"],
|
||||||
)?;
|
)?;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// proto/adresar.proto
|
// proto/adresar.proto
|
||||||
syntax = "proto3";
|
syntax = "proto3";
|
||||||
package multieko2.adresar;
|
package komp_ac.adresar;
|
||||||
|
|
||||||
import "common.proto";
|
import "common.proto";
|
||||||
// import "table_structure.proto";
|
// import "table_structure.proto";
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user