Compare commits
346 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dff320d534 | ||
|
|
96cde3ca0d | ||
|
|
6ba0124779 | ||
|
|
34c68858a3 | ||
|
|
4c8cfd4f80 | ||
|
|
85c5d7ccf9 | ||
|
|
46a0d2b9db | ||
|
|
c9b4841f67 | ||
|
|
d62cc2add6 | ||
|
|
9c36e76eaa | ||
|
|
abd8cba7a5 | ||
|
|
e6c4cb7e75 | ||
|
|
3d4435bac5 | ||
|
|
4146d0820b | ||
|
|
dbaa32f589 | ||
|
|
2b8eae67b9 | ||
|
|
225bdc2bb6 | ||
|
|
8605ed1547 | ||
|
|
91cecabaca | ||
|
|
d4922233ae | ||
|
|
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 | ||
|
|
625c9b3e09 | ||
|
|
e20623ed53 | ||
|
|
aa9adf7348 | ||
|
|
2e82aba0d1 | ||
|
|
b7a3f0f8d9 | ||
|
|
38c82389f7 | ||
|
|
cb0a2bee17 | ||
|
|
dc99131794 | ||
|
|
5c23f61a10 | ||
|
|
f87e3c03cb | ||
|
|
d346670839 | ||
|
|
560d8b7234 | ||
|
|
b297c2b311 | ||
|
|
d390c567d5 | ||
|
|
029e614b9c | ||
|
|
f9a78e4eec | ||
|
|
d8758f7531 | ||
|
|
4e86ecff84 | ||
|
|
070d091e07 | ||
|
|
7403b3c3f8 | ||
|
|
1b1e7b7205 | ||
|
|
1b8f19f1ce | ||
|
|
2a14eadf34 | ||
|
|
fd36cd5795 | ||
|
|
f4286ac3c9 | ||
|
|
92d5eb4844 | ||
|
|
87b9f6ab87 | ||
|
|
06d98aab5c | ||
|
|
298f56a53c | ||
|
|
714a5f2f1c | ||
|
|
4e29d0084f | ||
|
|
63f1b4da2e | ||
|
|
9477f53432 | ||
|
|
ed786f087c | ||
|
|
8e22ea05ff | ||
|
|
8414657224 | ||
|
|
e25213ed1b | ||
|
|
4843b0778c | ||
|
|
f5fae98c69 | ||
|
|
6faf0a4a31 | ||
|
|
011fafc0ff | ||
|
|
8ebe74484c | ||
|
|
3eb9523103 | ||
|
|
3dfa922b9e | ||
|
|
248d54a30f | ||
|
|
b30fef4ccd | ||
|
|
a9c4527318 | ||
|
|
c31f08d5b8 | ||
|
|
9e0fa9ddb1 | ||
|
|
8fcd28832d | ||
|
|
cccf029464 | ||
|
|
512e7fb9e7 | ||
|
|
0e69df8282 | ||
|
|
eb5532c200 | ||
|
|
49ed1dfe33 | ||
|
|
62d1c3f7f5 | ||
|
|
b49dce3334 | ||
|
|
8ace9bc4d1 | ||
|
|
ce490007ed | ||
|
|
eb96c64e26 | ||
|
|
2ac96a8486 | ||
|
|
b8e6cc22af | ||
|
|
634a01f618 | ||
|
|
6abea062ba | ||
|
|
f50887a326 | ||
|
|
3c0af05a3c | ||
|
|
c9131d4457 | ||
|
|
2af79a3ef2 | ||
|
|
afd9228efa | ||
|
|
495d77fda5 | ||
|
|
679bb3b6ab | ||
|
|
350c522d19 | ||
|
|
4760f42589 | ||
|
|
50d15e321f | ||
|
|
a3e7fd8f0a | ||
|
|
645172747a | ||
|
|
7c4ac1eebc | ||
|
|
4b4301ad49 | ||
|
|
b60e03eb70 | ||
|
|
2c7bda3ff1 | ||
|
|
eeaaa3635b | ||
|
|
e61cbb3956 | ||
|
|
f9841f2ef3 | ||
|
|
dc232b2523 | ||
|
|
b086b3e236 | ||
|
|
387e1a0fe0 | ||
|
|
08e01d41f2 | ||
|
|
f5edf52571 | ||
|
|
02c62213c3 | ||
|
|
d0722fbbbe | ||
|
|
4ec569342d | ||
|
|
9540d9ccb9 | ||
|
|
6b5cbe854b | ||
|
|
59ed52814e | ||
|
|
3488ab4f6b | ||
|
|
6e2fc5349b | ||
|
|
ea88c2686d | ||
|
|
3df4baec92 | ||
|
|
ff74e1aaa1 | ||
|
|
b0c865ab76 | ||
|
|
3dbc086f10 | ||
|
|
e9b4b34fb4 | ||
|
|
668eeee197 | ||
|
|
799d8471c9 | ||
|
|
f77c16dec9 | ||
|
|
45026cac6a | ||
|
|
edf6ab5bca | ||
|
|
462b1f14e2 | ||
|
|
7a8f18b116 | ||
|
|
d255e4abb6 | ||
|
|
b770240f0d | ||
|
|
43b064673b | ||
|
|
bf2726c151 | ||
|
|
f3cd921c76 | ||
|
|
913f6b6b64 | ||
|
|
3463a52960 | ||
|
|
116db3566f | ||
|
|
32210a5f7c | ||
|
|
d8f9372bbd | ||
|
|
6e1997fd9d | ||
|
|
4e7213d1aa | ||
|
|
5afb427bb4 | ||
|
|
685361a11a | ||
|
|
bd7c97ca91 | ||
|
|
81235c67dc | ||
|
|
65e8e03224 | ||
|
|
85eb3adec7 | ||
|
|
5d0f958a68 | ||
|
|
b82f50b76b | ||
|
|
0ab11a9bf9 | ||
|
|
d28c310704 | ||
|
|
2e1d7fdf2b | ||
|
|
82e96f7b86 | ||
|
|
7229e2abbd | ||
|
|
4e35043da0 | ||
|
|
56fe1c2ccc | ||
|
|
a874edf2a1 | ||
|
|
9d55ec3e43 | ||
|
|
05580ac978 | ||
|
|
667eb4809d | ||
|
|
58fdaa8298 | ||
|
|
5478a2ac27 | ||
|
|
ad37990da9 | ||
|
|
66824030f2 | ||
|
|
90ca8cf97c | ||
|
|
3c8ea28da1 | ||
|
|
5c352eb863 | ||
|
|
8c312bc163 | ||
|
|
6fa8b06063 | ||
|
|
2992f122bc | ||
|
|
e507993065 | ||
|
|
6f22aad6f4 | ||
|
|
097264040f | ||
|
|
2c03ee6af0 | ||
|
|
ec596b2ada | ||
|
|
b01ba0b2d9 | ||
|
|
f74a6ef093 | ||
|
|
ee687fafbe | ||
|
|
60ba17cfea | ||
|
|
8b3aa5891e | ||
|
|
3ff9399b81 | ||
|
|
d18f7862ab | ||
|
|
dc6c1ce43c | ||
|
|
8d1adccec6 | ||
|
|
420ce71fb2 | ||
|
|
8e5a269ff0 | ||
|
|
f357d6f0ee | ||
|
|
a0467d17a8 | ||
|
|
ef3ecfc73f | ||
|
|
d3fcb23e22 | ||
|
|
5a029283a1 | ||
|
|
09ccad2bd4 | ||
|
|
bdcc10bd40 | ||
|
|
2a1fafc3f9 | ||
|
|
6010b9a0af | ||
|
|
11e8f87fe6 | ||
|
|
14b81cba19 | ||
|
|
2b37de3b4d | ||
|
|
73d9a6367c | ||
|
|
c90233b56f | ||
|
|
39dcf38462 | ||
|
|
efa27cd2dd | ||
|
|
ca231964f2 | ||
|
|
2bb83cb990 | ||
|
|
305bcfcf62 | ||
|
|
92a9011f27 | ||
|
|
e64cebdfc2 | ||
|
|
0db426d278 | ||
|
|
6bfef1c7a0 | ||
|
|
f50fe788cb | ||
|
|
4db78ecf1b | ||
|
|
f22dd7749f | ||
|
|
75af0c3be1 | ||
|
|
6f36b84f85 | ||
|
|
e723198b72 | ||
|
|
8f74febff1 | ||
|
|
d9bd6f8e1d | ||
|
|
bf55417901 | ||
|
|
9511970a1a | ||
|
|
5c8557b369 | ||
|
|
5e47c53fcf | ||
|
|
f7493a8bc4 | ||
|
|
4f39b93edd | ||
|
|
ff8b4eb0f6 | ||
|
|
e921862a7f | ||
|
|
4d7177f15a | ||
|
|
c5d7f56399 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,2 +1,7 @@
|
||||
/target
|
||||
.env
|
||||
/tantivy_indexes
|
||||
server/tantivy_indexes
|
||||
steel_decimal/tests/property_tests.proptest-regressions
|
||||
.direnv/
|
||||
canvas/*.toml
|
||||
|
||||
1958
Cargo.lock
generated
1958
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
46
Cargo.toml
46
Cargo.toml
@@ -1,19 +1,55 @@
|
||||
[workspace]
|
||||
members = ["client", "server", "common"]
|
||||
members = ["client", "server", "common", "search", "canvas"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
# TODO: idk how to do the name, fix later
|
||||
# name = "Multieko2"
|
||||
version = "0.3.13"
|
||||
# name = "komp_ac"
|
||||
version = "0.4.2"
|
||||
edition = "2021"
|
||||
license = "GPL-3.0-or-later"
|
||||
authors = ["Filip Priečinský <filippriec@gmail.com>"]
|
||||
description = "Poriadny uctovnicky software."
|
||||
readme = "README.md"
|
||||
repository = "https://gitlab.com/filipriec/multieko2"
|
||||
repository = "https://gitlab.com/filipriec/komp_ac"
|
||||
categories = ["command-line-interface"]
|
||||
|
||||
# [workspace.metadata]
|
||||
# TODO:
|
||||
# documentation = "https://docs.rs/accounting-client"`
|
||||
# documentation = "https://docs.rs/accounting-client"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Async and gRPC
|
||||
tokio = { version = "1.44.2", features = ["full"] }
|
||||
tonic = "0.13.0"
|
||||
prost = "0.13.5"
|
||||
async-trait = "0.1.88"
|
||||
prost-types = "0.13.0"
|
||||
|
||||
# Data Handling & Serialization
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.140"
|
||||
time = "0.3.41"
|
||||
|
||||
# Utilities & Error Handling
|
||||
anyhow = "1.0.98"
|
||||
dotenvy = "0.15.7"
|
||||
lazy_static = "1.5.0"
|
||||
tracing = "0.1.41"
|
||||
|
||||
# Search crate
|
||||
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" }
|
||||
|
||||
@@ -14,3 +14,12 @@ Client:
|
||||
cargo watch -x 'run --package client -- client'
|
||||
```
|
||||
|
||||
Client with tracing:
|
||||
```
|
||||
ENABLE_TRACING=1 RUST_LOG=client=debug cargo watch -x 'run --package client -- client'
|
||||
```
|
||||
|
||||
Client with debug that cant be traced
|
||||
```
|
||||
cargo run --package client --features ui-debug -- client
|
||||
```
|
||||
|
||||
1
canvas/.gitignore
vendored
Normal file
1
canvas/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
docs_prompts/
|
||||
324
canvas/CANVAS_MIGRATION.md
Normal file
324
canvas/CANVAS_MIGRATION.md
Normal file
@@ -0,0 +1,324 @@
|
||||
# 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 suggestions features, providing better type safety and maintainability.
|
||||
|
||||
## Key Changes
|
||||
|
||||
### 1. **Modular Architecture**
|
||||
```
|
||||
# Old Structure (LEGACY)
|
||||
src/
|
||||
├── state.rs # Mixed canvas + suggestions
|
||||
├── 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
|
||||
├── suggestions/ # Suggestions dropdown features (not inline autocomplete)
|
||||
│ ├── state.rs # Suggestion provider types
|
||||
│ ├── gui.rs # Suggestions dropdown rendering
|
||||
│ ├── actions.rs # Autocomplete actions
|
||||
│ └── gui.rs # Autocomplete dropdown rendering
|
||||
└── dispatcher.rs # Action routing
|
||||
```
|
||||
|
||||
### 2. **Trait Separation**
|
||||
- **CanvasState**: Core form functionality (navigation, input, validation)
|
||||
- Suggestions module: Optional dropdown suggestions support
|
||||
|
||||
### 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 suggestions dropdown features:**
|
||||
|
||||
```rust
|
||||
use canvas::{SuggestionItem};
|
||||
|
||||
impl YourFormState {
|
||||
fn supports_suggestions(&self, field_index: usize) -> bool {
|
||||
// Define which fields support suggestions
|
||||
matches!(field_index, 2 | 3 | 5) // Example: only certain fields
|
||||
}
|
||||
|
||||
// Manage your own suggestion state or rely on FormEditor APIs
|
||||
|
||||
// Manage your own suggestion state or rely on FormEditor APIs
|
||||
}
|
||||
```
|
||||
|
||||
**Add suggestions storage to your state (optional, if you need to persist outside the editor):**
|
||||
```rust
|
||||
pub struct YourFormState {
|
||||
// ... existing fields
|
||||
// Optional: your own suggestions cache if needed
|
||||
// pub suggestion_cache: Vec<SuggestionItem>,
|
||||
}
|
||||
```
|
||||
|
||||
### 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 suggestions rendering
|
||||
if editor.is_suggestions_active() {
|
||||
suggestions::gui::render_suggestions_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);
|
||||
|
||||
// Suggestions dropdown (if active)
|
||||
if editor.is_suggestions_active() {
|
||||
canvas::suggestions::render_suggestions_dropdown(
|
||||
f, f.area(), active_field_rect.unwrap(), theme, &editor
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 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_suggestions(&mut self) {
|
||||
self.autocomplete_active = false;
|
||||
self.autocomplete_suggestions.clear();
|
||||
self.selected_suggestion_index = None;
|
||||
}
|
||||
}
|
||||
editor.ui_state_mut().deactivate_suggestions();
|
||||
|
||||
# NEW - Option B: Suggestions via editor APIs
|
||||
editor.ui_state_mut().deactivate_suggestions();
|
||||
```
|
||||
|
||||
## 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 suggestion features
|
||||
- Canvas features don't interfere with suggestions
|
||||
- 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 SuggestionItem-based dropdown 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 suggestion methods if needed
|
||||
- [ ] Updated usage to SuggestionItem
|
||||
- [ ] Updated rendering calls
|
||||
- [ ] Tested form functionality
|
||||
- [ ] Tested suggestions 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::SuggestionItem;
|
||||
|
||||
impl CanvasState for FormState {
|
||||
// Only core canvas methods
|
||||
fn current_field(&self) -> usize { /* ... */ }
|
||||
fn get_current_input(&self) -> &str { /* ... */ }
|
||||
// ... other core methods only
|
||||
}
|
||||
|
||||
// Use FormEditor + SuggestionsProvider for suggestions dropdown
|
||||
type SuggestionData = Hit;
|
||||
|
||||
fn supports_suggestions(&self, field_index: usize) -> bool {
|
||||
self.fields[field_index].is_link
|
||||
}
|
||||
|
||||
// Maintain suggestion state through FormEditor and DataProvider
|
||||
|
||||
// Maintain suggestion state through FormEditor and DataProvider
|
||||
}
|
||||
```
|
||||
|
||||
This migration results in cleaner, more maintainable, and more powerful code!
|
||||
66
canvas/Cargo.toml
Normal file
66
canvas/Cargo.toml
Normal file
@@ -0,0 +1,66 @@
|
||||
[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
|
||||
regex = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4.4"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
gui = ["ratatui", "crossterm"]
|
||||
suggestions = ["tokio"]
|
||||
cursor-style = ["crossterm"]
|
||||
validation = ["regex"]
|
||||
|
||||
[[example]]
|
||||
name = "suggestions"
|
||||
required-features = ["suggestions", "gui", "cursor-style"]
|
||||
path = "examples/suggestions.rs"
|
||||
|
||||
[[example]]
|
||||
name = "canvas_gui_demo"
|
||||
required-features = ["gui"]
|
||||
path = "examples/canvas_gui_demo.rs"
|
||||
|
||||
[[example]]
|
||||
name = "validation_1"
|
||||
required-features = ["gui", "validation"]
|
||||
|
||||
[[example]]
|
||||
name = "validation_2"
|
||||
required-features = ["gui", "validation"]
|
||||
|
||||
[[example]]
|
||||
name = "validation_3"
|
||||
required-features = ["gui", "validation"]
|
||||
|
||||
[[example]]
|
||||
name = "validation_4"
|
||||
required-features = ["gui", "validation"]
|
||||
|
||||
[[example]]
|
||||
name = "validation_5"
|
||||
required-features = ["gui", "validation"]
|
||||
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 suggestions dropdown support
|
||||
- **Framework Agnostic**: Works with any TUI framework or raw terminal handling
|
||||
- **Async Ready**: Full async/await support for modern Rust applications
|
||||
- **Batch Operations**: Execute multiple actions atomically
|
||||
- **Extensible**: Custom actions and feature-specific handling
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
Add to your `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
cargo add canvas
|
||||
```
|
||||
|
||||
Implement the `CanvasState` trait:
|
||||
|
||||
```rust
|
||||
use canvas::prelude::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct LoginForm {
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
username: String,
|
||||
password: String,
|
||||
has_changes: bool,
|
||||
}
|
||||
|
||||
impl CanvasState for LoginForm {
|
||||
fn current_field(&self) -> usize { self.current_field }
|
||||
fn current_cursor_pos(&self) -> usize { self.cursor_pos }
|
||||
fn set_current_field(&mut self, index: usize) { self.current_field = index; }
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; }
|
||||
|
||||
fn get_current_input(&self) -> &str {
|
||||
match self.current_field {
|
||||
0 => &self.username,
|
||||
1 => &self.password,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_input_mut(&mut self) -> &mut String {
|
||||
match self.current_field {
|
||||
0 => &mut self.username,
|
||||
1 => &mut self.password,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> { vec![&self.username, &self.password] }
|
||||
fn fields(&self) -> Vec<&str> { vec!["Username", "Password"] }
|
||||
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
|
||||
}
|
||||
```
|
||||
|
||||
Use the type-safe action dispatcher:
|
||||
|
||||
```rust
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut form = LoginForm::new();
|
||||
let mut ideal_cursor = 0;
|
||||
|
||||
// Type a character - compile-time safe!
|
||||
ActionDispatcher::dispatch(
|
||||
CanvasAction::InsertChar('h'),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await?;
|
||||
|
||||
// Move to next field
|
||||
ActionDispatcher::dispatch(
|
||||
CanvasAction::NextField,
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await?;
|
||||
|
||||
// Batch operations
|
||||
let actions = vec![
|
||||
CanvasAction::InsertChar('p'),
|
||||
CanvasAction::InsertChar('a'),
|
||||
CanvasAction::InsertChar('s'),
|
||||
CanvasAction::InsertChar('s'),
|
||||
];
|
||||
|
||||
ActionDispatcher::dispatch_batch(actions, &mut form, &mut ideal_cursor).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 Type-Safe Actions
|
||||
|
||||
The Canvas system uses strongly-typed actions instead of error-prone strings:
|
||||
|
||||
```rust
|
||||
// ✅ Type-safe - impossible to make typos
|
||||
ActionDispatcher::dispatch(CanvasAction::MoveLeft, &mut form, &mut cursor).await?;
|
||||
|
||||
// ❌ Old way - runtime errors waiting to happen
|
||||
execute_edit_action("move_left", key, &mut form, &mut cursor).await?;
|
||||
execute_edit_action("move_leftt", key, &mut form, &mut cursor).await?; // Oops!
|
||||
```
|
||||
|
||||
### Available Actions
|
||||
|
||||
```rust
|
||||
pub enum CanvasAction {
|
||||
// Character input
|
||||
InsertChar(char),
|
||||
|
||||
// Deletion
|
||||
DeleteBackward,
|
||||
DeleteForward,
|
||||
|
||||
// Movement
|
||||
MoveLeft, MoveRight, MoveUp, MoveDown,
|
||||
MoveLineStart, MoveLineEnd,
|
||||
MoveWordNext, MoveWordPrev,
|
||||
|
||||
// Navigation
|
||||
NextField, PrevField,
|
||||
MoveFirstLine, MoveLastLine,
|
||||
|
||||
// Suggestions
|
||||
SuggestionUp, SuggestionDown,
|
||||
SelectSuggestion, ExitSuggestions,
|
||||
|
||||
// Extensibility
|
||||
Custom(String),
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 Advanced Features
|
||||
|
||||
### Suggestions Dropdown (not inline autocomplete)
|
||||
|
||||
```rust
|
||||
impl CanvasState for MyForm {
|
||||
fn get_suggestions(&self) -> Option<&[String]> {
|
||||
if self.suggestions.is_active {
|
||||
Some(&self.suggestions.suggestions)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::InsertChar('@') => {
|
||||
// Trigger email suggestions
|
||||
let suggestions = vec![
|
||||
format!("{}@gmail.com", self.username),
|
||||
format!("{}@company.com", self.username),
|
||||
];
|
||||
self.activate_suggestions(suggestions);
|
||||
None // Let generic handler insert the '@'
|
||||
}
|
||||
CanvasAction::SelectSuggestion => {
|
||||
if let Some(suggestion) = self.suggestions.get_selected() {
|
||||
*self.get_current_input_mut() = suggestion.clone();
|
||||
self.deactivate_suggestions();
|
||||
Some("Applied suggestion".to_string())
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Actions
|
||||
|
||||
```rust
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::Custom(cmd) => match cmd.as_str() {
|
||||
"uppercase" => {
|
||||
*self.get_current_input_mut() = self.get_current_input().to_uppercase();
|
||||
Some("Converted to uppercase".to_string())
|
||||
}
|
||||
"validate_email" => {
|
||||
if self.get_current_input().contains('@') {
|
||||
Some("Email is valid".to_string())
|
||||
} else {
|
||||
Some("Invalid email format".to_string())
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration with TUI Frameworks
|
||||
|
||||
Canvas is framework-agnostic and works with any TUI library:
|
||||
|
||||
```rust
|
||||
// Works with crossterm (see examples)
|
||||
// Works with termion
|
||||
// Works with ratatui/tui-rs
|
||||
// Works with cursive
|
||||
// Works with raw terminal I/O
|
||||
```
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
Canvas follows a clean, layered architecture:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Your Application │
|
||||
├─────────────────────────────────────┤
|
||||
│ ActionDispatcher │ ← High-level API
|
||||
├─────────────────────────────────────┤
|
||||
│ CanvasAction (Type-Safe) │ ← Type safety layer
|
||||
├─────────────────────────────────────┤
|
||||
│ Action Handlers │ ← Core logic
|
||||
├─────────────────────────────────────┤
|
||||
│ CanvasState Trait │ ← Your implementation
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 🤝 Why Canvas?
|
||||
|
||||
### Before Canvas
|
||||
```rust
|
||||
// ❌ Error-prone string actions
|
||||
execute_action("move_left", key, state)?;
|
||||
execute_action("move_leftt", key, state)?; // Runtime error!
|
||||
|
||||
// ❌ Duplicate navigation logic everywhere
|
||||
impl MyLoginForm { /* navigation code */ }
|
||||
impl MyConfigForm { /* same navigation code */ }
|
||||
impl MyDataForm { /* same navigation code again */ }
|
||||
|
||||
// ❌ Manual cursor and field management
|
||||
if key == Key::Tab {
|
||||
current_field = (current_field + 1) % fields.len();
|
||||
cursor_pos = cursor_pos.min(current_input.len());
|
||||
}
|
||||
```
|
||||
|
||||
### With Canvas
|
||||
```rust
|
||||
// ✅ Type-safe actions
|
||||
ActionDispatcher::dispatch(CanvasAction::MoveLeft, state, cursor)?;
|
||||
// Typos are impossible - won't compile!
|
||||
|
||||
// ✅ Implement once, use everywhere
|
||||
impl CanvasState for MyForm { /* minimal implementation */ }
|
||||
// All navigation, editing, suggestions work automatically!
|
||||
|
||||
// ✅ High-level operations
|
||||
ActionDispatcher::dispatch_batch(actions, state, cursor)?;
|
||||
```
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- **API Docs**: `cargo doc --open`
|
||||
- **Examples**: See `examples/` directory
|
||||
- **Migration Guide**: See `CANVAS_MIGRATION.md`
|
||||
|
||||
## 🔄 Migration from String-Based Actions
|
||||
|
||||
Canvas provides backwards compatibility during migration:
|
||||
|
||||
```rust
|
||||
// Legacy support (deprecated)
|
||||
execute_edit_action("move_left", key, state, cursor).await?;
|
||||
|
||||
// New type-safe way
|
||||
ActionDispatcher::dispatch(CanvasAction::MoveLeft, state, cursor).await?;
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
cargo test
|
||||
|
||||
# Run specific example
|
||||
cargo run --example simple_login
|
||||
|
||||
# Check type safety
|
||||
cargo check
|
||||
```
|
||||
|
||||
## 📋 Requirements
|
||||
|
||||
- Rust 1.70+
|
||||
- Terminal with cursor support
|
||||
- Optional: async runtime (tokio) for examples
|
||||
|
||||
## 🤔 FAQ
|
||||
|
||||
**Q: Does Canvas work with [my TUI framework]?**
|
||||
A: Yes! Canvas is framework-agnostic. Just implement `CanvasState` and handle the key events.
|
||||
|
||||
**Q: Can I extend Canvas with custom actions?**
|
||||
A: Absolutely! Use `CanvasAction::Custom("my_action")` or implement `handle_feature_action`.
|
||||
|
||||
**Q: Is Canvas suitable for complex forms?**
|
||||
A: Yes! See the `config_screen` example for validation, suggestions, and multi-field forms.
|
||||
|
||||
**Q: How do I migrate from string-based actions?**
|
||||
A: Canvas provides backwards compatibility. Migrate incrementally using the type-safe APIs.
|
||||
|
||||
## 📄 License
|
||||
|
||||
Licensed under either of:
|
||||
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
|
||||
- MIT License ([LICENSE-MIT](LICENSE-MIT))
|
||||
|
||||
at your option.
|
||||
|
||||
## 🙏 Contributing
|
||||
|
||||
Will write here something later on, too busy rn
|
||||
|
||||
---
|
||||
|
||||
Built with ❤️ for the Rust TUI community
|
||||
792
canvas/examples/canvas_cursor_auto.rs
Normal file
792
canvas/examples/canvas_cursor_auto.rs
Normal file
@@ -0,0 +1,792 @@
|
||||
// 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>,
|
||||
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),
|
||||
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) {
|
||||
// Use the library method instead of manual state setting
|
||||
self.editor.enter_highlight_mode();
|
||||
self.debug_message = "🔥 VISUAL MODE - Cursor: Blinking Block █".to_string();
|
||||
}
|
||||
|
||||
fn enter_visual_line_mode(&mut self) {
|
||||
// Use the library method instead of manual state setting
|
||||
self.editor.enter_highlight_line_mode();
|
||||
self.debug_message = "🔥 VISUAL LINE MODE - Cursor: Blinking Block █".to_string();
|
||||
}
|
||||
|
||||
fn exit_visual_mode(&mut self) {
|
||||
// Use the library method
|
||||
self.editor.exit_highlight_mode();
|
||||
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █".to_string();
|
||||
}
|
||||
|
||||
fn update_visual_selection(&mut self) {
|
||||
if self.editor.is_highlight_mode() {
|
||||
use canvas::canvas::state::SelectionState;
|
||||
match self.editor.selection_state() {
|
||||
SelectionState::Characterwise { anchor } => {
|
||||
self.debug_message = format!(
|
||||
"🎯 Visual selection: anchor=({},{}) current=({},{}) - Cursor: Blinking Block █",
|
||||
anchor.0, anchor.1,
|
||||
self.editor.current_field(),
|
||||
self.editor.cursor_position()
|
||||
);
|
||||
}
|
||||
SelectionState::Linewise { anchor_field } => {
|
||||
self.debug_message = format!(
|
||||
"🎯 Visual LINE selection: anchor={} current={} - Cursor: Blinking Block █",
|
||||
anchor_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 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 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_suggestions(&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();
|
||||
}
|
||||
|
||||
// From Normal Mode: Enter visual modes
|
||||
(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();
|
||||
}
|
||||
|
||||
// From Visual Mode: Switch between visual modes or exit
|
||||
(AppMode::Highlight, KeyCode::Char('v'), _) => {
|
||||
use canvas::canvas::state::SelectionState;
|
||||
match editor.editor.selection_state() {
|
||||
SelectionState::Characterwise { .. } => {
|
||||
// Already in characterwise mode, exit visual mode (vim behavior)
|
||||
editor.exit_visual_mode();
|
||||
editor.set_debug_message("🔒 Exited visual mode".to_string());
|
||||
}
|
||||
_ => {
|
||||
// Switch from linewise to characterwise mode
|
||||
editor.editor.enter_highlight_mode();
|
||||
editor.update_visual_selection();
|
||||
editor.set_debug_message("🔥 Switched to VISUAL mode".to_string());
|
||||
}
|
||||
}
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
(AppMode::Highlight, KeyCode::Char('V'), _) => {
|
||||
use canvas::canvas::state::SelectionState;
|
||||
match editor.editor.selection_state() {
|
||||
SelectionState::Linewise { .. } => {
|
||||
// Already in linewise mode, exit visual mode (vim behavior)
|
||||
editor.exit_visual_mode();
|
||||
editor.set_debug_message("🔒 Exited visual mode".to_string());
|
||||
}
|
||||
_ => {
|
||||
// Switch from characterwise to linewise mode
|
||||
editor.editor.enter_highlight_line_mode();
|
||||
editor.update_visual_selection();
|
||||
editor.set_debug_message("🔥 Switched to VISUAL LINE mode".to_string());
|
||||
}
|
||||
}
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// Escape: Exit any mode back to normal
|
||||
(_, KeyCode::Esc, _) => {
|
||||
match mode {
|
||||
AppMode::Edit => {
|
||||
editor.exit_edit_mode(); // Exit insert mode
|
||||
}
|
||||
AppMode::Highlight => {
|
||||
editor.exit_visual_mode(); // Exit visual mode
|
||||
}
|
||||
_ => {
|
||||
// Already in normal mode, just clear command buffer
|
||||
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 - FIXED VERSION
|
||||
let mode_text = match editor.mode() {
|
||||
AppMode::Edit => "INSERT | (bar cursor)",
|
||||
AppMode::ReadOnly => "NORMAL █ (block cursor)",
|
||||
AppMode::Highlight => {
|
||||
// Use library selection state instead of editor.highlight_state()
|
||||
use canvas::canvas::state::SelectionState;
|
||||
match editor.editor.selection_state() {
|
||||
SelectionState::Characterwise { .. } => "VISUAL █ (blinking block)",
|
||||
SelectionState::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 (no changes needed here)
|
||||
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/b=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_suggestions(&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(())
|
||||
}
|
||||
386
canvas/examples/suggestions.rs
Normal file
386
canvas/examples/suggestions.rs
Normal file
@@ -0,0 +1,386 @@
|
||||
// examples/suggestions.rs
|
||||
// Run with: cargo run --example suggestions --features "suggestions,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,
|
||||
},
|
||||
suggestions::gui::render_suggestions_dropdown,
|
||||
FormEditor, DataProvider, SuggestionsProvider, 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_suggestions(&self, field_index: usize) -> bool {
|
||||
field_index == 1 // Only email field
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// SIMPLE AUTOCOMPLETE PROVIDER - Only data fetching!
|
||||
// ===================================================================
|
||||
|
||||
struct EmailAutocomplete;
|
||||
|
||||
#[async_trait]
|
||||
impl SuggestionsProvider for EmailAutocomplete {
|
||||
async fn fetch_suggestions(&mut self, _field_index: usize, query: &str)
|
||||
-> Result<Vec<SuggestionItem>>
|
||||
{
|
||||
// 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 {
|
||||
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>,
|
||||
suggestions_provider: 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,
|
||||
suggestions_provider: EmailAutocomplete,
|
||||
debug_message: "Type in email field, Tab to trigger suggestions, 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 {
|
||||
// === SUGGESTIONS KEYS ===
|
||||
KeyCode::Tab => {
|
||||
if state.editor.is_suggestions_active() {
|
||||
state.editor.suggestions_next();
|
||||
Ok("Navigated to next suggestion".to_string())
|
||||
} else if state.editor.data_provider().supports_suggestions(state.editor.current_field()) {
|
||||
state.editor.trigger_suggestions(&mut state.suggestions_provider).await
|
||||
.map(|_| "Triggered suggestions".to_string())
|
||||
} else {
|
||||
state.editor.move_to_next_field();
|
||||
Ok("Moved to next field".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
KeyCode::Enter => {
|
||||
if state.editor.is_suggestions_active() {
|
||||
if let Some(applied) = state.editor.apply_suggestion() {
|
||||
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_suggestions_active() {
|
||||
// Suggestions will be cleared automatically by mode change
|
||||
Ok("Cancelled suggestions".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 suggestions dropdown if active
|
||||
if let Some(input_rect) = active_field_rect {
|
||||
render_suggestions_dropdown(
|
||||
f,
|
||||
chunks[0],
|
||||
input_rect,
|
||||
theme,
|
||||
&state.editor,
|
||||
);
|
||||
}
|
||||
|
||||
// Status info
|
||||
let autocomplete_status = if state.editor.is_suggestions_active() {
|
||||
if state.editor.ui_state().is_suggestions_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 suggestions"
|
||||
};
|
||||
|
||||
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!("Suggestions: {}", autocomplete_status))),
|
||||
Line::from(Span::raw(state.debug_message.clone())),
|
||||
Line::from(Span::raw("F10: Quit | Tab: Trigger/Navigate suggestions | 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(())
|
||||
}
|
||||
831
canvas/examples/validation_1.rs
Normal file
831
canvas/examples/validation_1.rs
Normal file
@@ -0,0 +1,831 @@
|
||||
// examples/validation_1.rs
|
||||
//! Demonstrates field validation with the canvas library
|
||||
//!
|
||||
//! This example REQUIRES the `validation` feature to compile.
|
||||
//!
|
||||
//! Run with:
|
||||
//! cargo run --example validation_1 --features "gui,validation"
|
||||
//!
|
||||
//! This will fail without validation:
|
||||
//! cargo run --example validation_1 --features "gui"
|
||||
|
||||
// REQUIRE validation feature - example won't compile without it
|
||||
#[cfg(not(feature = "validation"))]
|
||||
compile_error!(
|
||||
"This example requires the 'validation' feature. \
|
||||
Run with: cargo run --example validation_1 --features \"gui,validation\""
|
||||
);
|
||||
|
||||
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, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
use canvas::{
|
||||
canvas::{
|
||||
gui::render_canvas_default,
|
||||
modes::AppMode,
|
||||
},
|
||||
DataProvider, FormEditor,
|
||||
ValidationConfig, ValidationConfigBuilder, CharacterLimits, ValidationResult,
|
||||
};
|
||||
|
||||
// Import CountMode from the validation module directly
|
||||
use canvas::validation::limits::CountMode;
|
||||
|
||||
// Enhanced FormEditor that demonstrates validation functionality
|
||||
struct ValidationFormEditor<D: DataProvider> {
|
||||
editor: FormEditor<D>,
|
||||
has_unsaved_changes: bool,
|
||||
debug_message: String,
|
||||
command_buffer: String,
|
||||
validation_enabled: bool,
|
||||
field_switch_blocked: bool,
|
||||
block_reason: Option<String>,
|
||||
}
|
||||
|
||||
impl<D: DataProvider> ValidationFormEditor<D> {
|
||||
fn new(data_provider: D) -> Self {
|
||||
let mut editor = FormEditor::new(data_provider);
|
||||
|
||||
// Enable validation by default
|
||||
editor.set_validation_enabled(true);
|
||||
|
||||
Self {
|
||||
editor,
|
||||
has_unsaved_changes: false,
|
||||
debug_message: "🔍 Validation Demo - Try typing in different fields!".to_string(),
|
||||
command_buffer: String::new(),
|
||||
validation_enabled: true,
|
||||
field_switch_blocked: false,
|
||||
block_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
// === 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()
|
||||
}
|
||||
|
||||
// === VALIDATION CONTROL ===
|
||||
fn toggle_validation(&mut self) {
|
||||
self.validation_enabled = !self.validation_enabled;
|
||||
self.editor.set_validation_enabled(self.validation_enabled);
|
||||
|
||||
if self.validation_enabled {
|
||||
self.debug_message = "✅ Validation ENABLED - Try exceeding limits!".to_string();
|
||||
} else {
|
||||
self.debug_message = "❌ Validation DISABLED - No limits enforced".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn check_field_switch_allowed(&self) -> (bool, Option<String>) {
|
||||
if !self.validation_enabled {
|
||||
return (true, None);
|
||||
}
|
||||
|
||||
let can_switch = self.editor.can_switch_fields();
|
||||
let reason = if !can_switch {
|
||||
self.editor.field_switch_block_reason()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
(can_switch, reason)
|
||||
}
|
||||
|
||||
fn get_validation_status(&self) -> String {
|
||||
if !self.validation_enabled {
|
||||
return "❌ DISABLED".to_string();
|
||||
}
|
||||
|
||||
if self.field_switch_blocked {
|
||||
return "🚫 SWITCH BLOCKED".to_string();
|
||||
}
|
||||
|
||||
let summary = self.editor.validation_summary();
|
||||
if summary.has_errors() {
|
||||
format!("❌ {} ERRORS", summary.error_fields)
|
||||
} else if summary.has_warnings() {
|
||||
format!("⚠️ {} WARNINGS", summary.warning_fields)
|
||||
} else if summary.validated_fields > 0 {
|
||||
format!("✅ {} VALID", summary.valid_fields)
|
||||
} else {
|
||||
"🔍 READY".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_current_field(&mut self) {
|
||||
let result = self.editor.validate_current_field();
|
||||
match result {
|
||||
ValidationResult::Valid => {
|
||||
self.debug_message = "✅ Current field is valid!".to_string();
|
||||
}
|
||||
ValidationResult::Warning { message } => {
|
||||
self.debug_message = format!("⚠️ Warning: {}", message);
|
||||
}
|
||||
ValidationResult::Error { message } => {
|
||||
self.debug_message = format!("❌ Error: {}", message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_all_fields(&mut self) {
|
||||
let field_count = self.editor.data_provider().field_count();
|
||||
for i in 0..field_count {
|
||||
self.editor.validate_field(i);
|
||||
}
|
||||
|
||||
let summary = self.editor.validation_summary();
|
||||
self.debug_message = format!(
|
||||
"🔍 Validated all fields: {} valid, {} warnings, {} errors",
|
||||
summary.valid_fields, summary.warning_fields, summary.error_fields
|
||||
);
|
||||
}
|
||||
|
||||
fn clear_validation_results(&mut self) {
|
||||
self.editor.clear_validation_results();
|
||||
self.debug_message = "🧹 Cleared all validation results".to_string();
|
||||
}
|
||||
|
||||
// === ENHANCED MOVEMENT WITH VALIDATION ===
|
||||
fn move_left(&mut self) {
|
||||
self.editor.move_left();
|
||||
self.field_switch_blocked = false;
|
||||
self.block_reason = None;
|
||||
}
|
||||
|
||||
fn move_right(&mut self) {
|
||||
self.editor.move_right();
|
||||
self.field_switch_blocked = false;
|
||||
self.block_reason = None;
|
||||
}
|
||||
|
||||
fn move_up(&mut self) {
|
||||
match self.editor.move_up() {
|
||||
Ok(()) => {
|
||||
self.update_field_validation_status();
|
||||
self.field_switch_blocked = false;
|
||||
self.block_reason = None;
|
||||
}
|
||||
Err(e) => {
|
||||
self.field_switch_blocked = true;
|
||||
self.block_reason = Some(e.to_string());
|
||||
self.debug_message = format!("🚫 Field switch blocked: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
match self.editor.move_down() {
|
||||
Ok(()) => {
|
||||
self.update_field_validation_status();
|
||||
self.field_switch_blocked = false;
|
||||
self.block_reason = None;
|
||||
}
|
||||
Err(e) => {
|
||||
self.field_switch_blocked = true;
|
||||
self.block_reason = Some(e.to_string());
|
||||
self.debug_message = format!("🚫 Field switch blocked: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn move_line_start(&mut self) {
|
||||
self.editor.move_line_start();
|
||||
}
|
||||
|
||||
fn move_line_end(&mut self) {
|
||||
self.editor.move_line_end();
|
||||
}
|
||||
|
||||
fn move_word_next(&mut self) {
|
||||
self.editor.move_word_next();
|
||||
}
|
||||
|
||||
fn move_word_prev(&mut self) {
|
||||
self.editor.move_word_prev();
|
||||
}
|
||||
|
||||
fn move_word_end(&mut self) {
|
||||
self.editor.move_word_end();
|
||||
}
|
||||
|
||||
fn move_first_line(&mut self) {
|
||||
self.editor.move_first_line();
|
||||
}
|
||||
|
||||
fn move_last_line(&mut self) {
|
||||
self.editor.move_last_line();
|
||||
}
|
||||
|
||||
fn update_field_validation_status(&mut self) {
|
||||
if !self.validation_enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(result) = self.editor.current_field_validation() {
|
||||
match result {
|
||||
ValidationResult::Valid => {
|
||||
self.debug_message = format!("Field {}: ✅ Valid", self.editor.current_field() + 1);
|
||||
}
|
||||
ValidationResult::Warning { message } => {
|
||||
self.debug_message = format!("Field {}: ⚠️ {}", self.editor.current_field() + 1, message);
|
||||
}
|
||||
ValidationResult::Error { message } => {
|
||||
self.debug_message = format!("Field {}: ❌ {}", self.editor.current_field() + 1, message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.debug_message = format!("Field {}: 🔍 Not validated yet", self.editor.current_field() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// === MODE TRANSITIONS ===
|
||||
fn enter_edit_mode(&mut self) {
|
||||
self.editor.enter_edit_mode();
|
||||
self.debug_message = "✏️ INSERT MODE - Type to test validation".to_string();
|
||||
}
|
||||
|
||||
fn enter_append_mode(&mut self) {
|
||||
self.editor.enter_append_mode();
|
||||
self.debug_message = "✏️ INSERT (append) - Validation active".to_string();
|
||||
}
|
||||
|
||||
fn exit_edit_mode(&mut self) {
|
||||
self.editor.exit_edit_mode();
|
||||
self.debug_message = "🔒 NORMAL MODE - Press 'v' to validate current field".to_string();
|
||||
self.update_field_validation_status();
|
||||
}
|
||||
|
||||
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;
|
||||
// Show real-time validation feedback
|
||||
if let Some(validation_result) = self.editor.current_field_validation() {
|
||||
match validation_result {
|
||||
ValidationResult::Valid => {
|
||||
// Don't spam with valid messages, just show character count if applicable
|
||||
if let Some(limits) = self.get_current_field_limits() {
|
||||
if let Some(status) = limits.status_text(self.editor.current_text()) {
|
||||
self.debug_message = format!("✏️ {}", status);
|
||||
}
|
||||
}
|
||||
}
|
||||
ValidationResult::Warning { message } => {
|
||||
self.debug_message = format!("⚠️ {}", message);
|
||||
}
|
||||
ValidationResult::Error { message } => {
|
||||
self.debug_message = format!("❌ {}", message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
fn get_current_field_limits(&self) -> Option<&CharacterLimits> {
|
||||
let validation_state = self.editor.validation_state();
|
||||
let config = validation_state.get_field_config(self.editor.current_field())?;
|
||||
config.character_limits.as_ref()
|
||||
}
|
||||
|
||||
// === 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".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".to_string();
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
fn next_field(&mut self) {
|
||||
match self.editor.next_field() {
|
||||
Ok(()) => {
|
||||
self.update_field_validation_status();
|
||||
self.field_switch_blocked = false;
|
||||
self.block_reason = None;
|
||||
}
|
||||
Err(e) => {
|
||||
self.field_switch_blocked = true;
|
||||
self.block_reason = Some(e.to_string());
|
||||
self.debug_message = format!("🚫 Cannot move to next field: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prev_field(&mut self) {
|
||||
match self.editor.prev_field() {
|
||||
Ok(()) => {
|
||||
self.update_field_validation_status();
|
||||
self.field_switch_blocked = false;
|
||||
self.block_reason = None;
|
||||
}
|
||||
Err(e) => {
|
||||
self.field_switch_blocked = true;
|
||||
self.block_reason = Some(e.to_string());
|
||||
self.debug_message = format!("🚫 Cannot move to previous field: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === STATUS AND DEBUG ===
|
||||
fn set_debug_message(&mut self, msg: String) {
|
||||
self.debug_message = msg;
|
||||
}
|
||||
|
||||
fn debug_message(&self) -> &str {
|
||||
&self.debug_message
|
||||
}
|
||||
|
||||
fn has_unsaved_changes(&self) -> bool {
|
||||
self.has_unsaved_changes
|
||||
}
|
||||
}
|
||||
|
||||
// Demo form data with different validation rules
|
||||
struct ValidationDemoData {
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl ValidationDemoData {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
fields: vec![
|
||||
("👤 Name (max 20)".to_string(), "".to_string()),
|
||||
("📧 Email (max 50, warn@40)".to_string(), "".to_string()),
|
||||
("🔑 Password (5-20 chars)".to_string(), "".to_string()),
|
||||
("🔢 ID (min 3, max 10)".to_string(), "".to_string()),
|
||||
("📝 Comment (min 10, max 100)".to_string(), "".to_string()),
|
||||
("🏷️ Tag (max 30, bytes)".to_string(), "".to_string()),
|
||||
("🌍 Unicode (width, min 2)".to_string(), "".to_string()),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DataProvider for ValidationDemoData {
|
||||
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_suggestions(&self, _field_index: usize) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn display_value(&self, _index: usize) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
// 🎯 NEW: Validation configuration per field
|
||||
fn validation_config(&self, field_index: usize) -> Option<ValidationConfig> {
|
||||
match field_index {
|
||||
0 => Some(ValidationConfig::with_max_length(20)), // Name: simple 20 char limit
|
||||
1 => Some(
|
||||
ValidationConfigBuilder::new()
|
||||
.with_character_limits(
|
||||
CharacterLimits::new(50).with_warning_threshold(40)
|
||||
)
|
||||
.build()
|
||||
), // Email: 50 chars with warning at 40
|
||||
2 => Some(
|
||||
ValidationConfigBuilder::new()
|
||||
.with_character_limits(CharacterLimits::new_range(5, 20))
|
||||
.build()
|
||||
), // Password: must be 5-20 characters (blocks field switching if 1-4 chars)
|
||||
3 => Some(
|
||||
ValidationConfigBuilder::new()
|
||||
.with_character_limits(CharacterLimits::new_range(3, 10))
|
||||
.build()
|
||||
), // ID: must be 3-10 characters (blocks field switching if 1-2 chars)
|
||||
4 => Some(
|
||||
ValidationConfigBuilder::new()
|
||||
.with_character_limits(CharacterLimits::new_range(10, 100))
|
||||
.build()
|
||||
), // Comment: must be 10-100 characters (blocks field switching if 1-9 chars)
|
||||
5 => Some(
|
||||
ValidationConfigBuilder::new()
|
||||
.with_character_limits(
|
||||
CharacterLimits::new(30).with_count_mode(CountMode::Bytes)
|
||||
)
|
||||
.build()
|
||||
), // Tag: 30 bytes (useful for UTF-8)
|
||||
6 => Some(
|
||||
ValidationConfigBuilder::new()
|
||||
.with_character_limits(
|
||||
CharacterLimits::new_range(2, 20).with_count_mode(CountMode::DisplayWidth)
|
||||
)
|
||||
.build()
|
||||
), // Unicode: 2-20 display width (useful for CJK characters, blocks if 1 char)
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle key presses with validation-focused commands
|
||||
fn handle_key_press(
|
||||
key: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
editor: &mut ValidationFormEditor<ValidationDemoData>,
|
||||
) -> 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 ===
|
||||
(AppMode::ReadOnly, KeyCode::Char('i'), _) => {
|
||||
editor.enter_edit_mode();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
|
||||
editor.enter_append_mode();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('A'), _) => {
|
||||
editor.move_line_end();
|
||||
editor.enter_edit_mode();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// Escape: Exit edit mode
|
||||
(_, KeyCode::Esc, _) => {
|
||||
if mode == AppMode::Edit {
|
||||
editor.exit_edit_mode();
|
||||
} else {
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
}
|
||||
|
||||
// === VALIDATION COMMANDS ===
|
||||
(AppMode::ReadOnly, KeyCode::Char('v'), _) => {
|
||||
editor.validate_current_field();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('V'), _) => {
|
||||
editor.validate_all_fields();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('c'), _) => {
|
||||
editor.clear_validation_results();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::F(1), _) => {
|
||||
editor.toggle_validation();
|
||||
}
|
||||
|
||||
// === MOVEMENT ===
|
||||
(AppMode::ReadOnly, KeyCode::Char('h'), _) | (AppMode::ReadOnly, KeyCode::Left, _) => {
|
||||
editor.move_left();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('l'), _) | (AppMode::ReadOnly, KeyCode::Right, _) => {
|
||||
editor.move_right();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('j'), _) | (AppMode::ReadOnly, KeyCode::Down, _) => {
|
||||
editor.move_down();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('k'), _) | (AppMode::ReadOnly, KeyCode::Up, _) => {
|
||||
editor.move_up();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// === EDIT MODE MOVEMENT ===
|
||||
(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();
|
||||
}
|
||||
|
||||
// === DELETE OPERATIONS ===
|
||||
(AppMode::Edit, KeyCode::Backspace, _) => {
|
||||
editor.delete_backward()?;
|
||||
}
|
||||
(AppMode::Edit, KeyCode::Delete, _) => {
|
||||
editor.delete_forward()?;
|
||||
}
|
||||
|
||||
// === TAB NAVIGATION ===
|
||||
(_, KeyCode::Tab, _) => {
|
||||
editor.next_field();
|
||||
}
|
||||
(_, KeyCode::BackTab, _) => {
|
||||
editor.prev_field();
|
||||
}
|
||||
|
||||
// === CHARACTER INPUT ===
|
||||
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
|
||||
editor.insert_char(c)?;
|
||||
}
|
||||
|
||||
// === DEBUG/INFO COMMANDS ===
|
||||
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
|
||||
let summary = editor.editor.validation_summary();
|
||||
editor.set_debug_message(format!(
|
||||
"Field {}/{}, Pos {}, Mode: {:?}, Validation: {} fields configured, {} validated",
|
||||
editor.current_field() + 1,
|
||||
editor.data_provider().field_count(),
|
||||
editor.cursor_position(),
|
||||
editor.mode(),
|
||||
summary.total_fields,
|
||||
summary.validated_fields
|
||||
));
|
||||
}
|
||||
|
||||
_ => {
|
||||
if editor.has_pending_command() {
|
||||
editor.clear_command_buffer();
|
||||
editor.set_debug_message("Invalid command sequence".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn run_app<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
mut editor: ValidationFormEditor<ValidationDemoData>,
|
||||
) -> 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: &ValidationFormEditor<ValidationDemoData>) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(8), Constraint::Length(12)])
|
||||
.split(f.area());
|
||||
|
||||
render_enhanced_canvas(f, chunks[0], editor);
|
||||
render_validation_status(f, chunks[1], editor);
|
||||
}
|
||||
|
||||
fn render_enhanced_canvas(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
editor: &ValidationFormEditor<ValidationDemoData>,
|
||||
) {
|
||||
render_canvas_default(f, area, &editor.editor);
|
||||
}
|
||||
|
||||
fn render_validation_status(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
editor: &ValidationFormEditor<ValidationDemoData>,
|
||||
) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Status bar
|
||||
Constraint::Length(4), // Validation summary
|
||||
Constraint::Length(5), // Help
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Status bar with validation information
|
||||
let mode_text = match editor.mode() {
|
||||
AppMode::Edit => "INSERT",
|
||||
AppMode::ReadOnly => "NORMAL",
|
||||
_ => "OTHER",
|
||||
};
|
||||
|
||||
let validation_status = editor.get_validation_status();
|
||||
let status_text = if editor.has_pending_command() {
|
||||
format!("-- {} -- {} [{}] | Validation: {}",
|
||||
mode_text, editor.debug_message(), editor.get_command_buffer(), validation_status)
|
||||
} else if editor.has_unsaved_changes() {
|
||||
format!("-- {} -- [Modified] {} | Validation: {}",
|
||||
mode_text, editor.debug_message(), validation_status)
|
||||
} else {
|
||||
format!("-- {} -- {} | Validation: {}",
|
||||
mode_text, editor.debug_message(), validation_status)
|
||||
};
|
||||
|
||||
let status = Paragraph::new(Line::from(Span::raw(status_text)))
|
||||
.block(Block::default().borders(Borders::ALL).title("🔍 Validation Status"));
|
||||
|
||||
f.render_widget(status, chunks[0]);
|
||||
|
||||
// Validation summary with field switching info
|
||||
let summary = editor.editor.validation_summary();
|
||||
let summary_text = if editor.validation_enabled {
|
||||
let switch_info = if editor.field_switch_blocked {
|
||||
format!("\n🚫 Field switching blocked: {}",
|
||||
editor.block_reason.as_deref().unwrap_or("Unknown reason"))
|
||||
} else {
|
||||
let (can_switch, reason) = editor.check_field_switch_allowed();
|
||||
if !can_switch {
|
||||
format!("\n⚠️ Field switching will be blocked: {}",
|
||||
reason.as_deref().unwrap_or("Unknown reason"))
|
||||
} else {
|
||||
"\n✅ Field switching allowed".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
format!(
|
||||
"📊 Validation Summary: {} fields configured, {} validated{}\n\
|
||||
✅ Valid: {} ⚠️ Warnings: {} ❌ Errors: {} 📈 Progress: {:.0}%",
|
||||
summary.total_fields,
|
||||
summary.validated_fields,
|
||||
switch_info,
|
||||
summary.valid_fields,
|
||||
summary.warning_fields,
|
||||
summary.error_fields,
|
||||
summary.completion_percentage() * 100.0
|
||||
)
|
||||
} else {
|
||||
"❌ Validation is currently DISABLED\nPress F1 to enable validation".to_string()
|
||||
};
|
||||
|
||||
let summary_style = if summary.has_errors() {
|
||||
Style::default().fg(Color::Red)
|
||||
} else if summary.has_warnings() {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default().fg(Color::Green)
|
||||
};
|
||||
|
||||
let validation_summary = Paragraph::new(summary_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("📈 Validation Overview"))
|
||||
.style(summary_style)
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
f.render_widget(validation_summary, chunks[1]);
|
||||
|
||||
// Enhanced help text
|
||||
let help_text = match editor.mode() {
|
||||
AppMode::ReadOnly => {
|
||||
"🔍 VALIDATION DEMO: Different fields have different limits!\n\
|
||||
Fields with MINIMUM requirements will block field switching if too short!\n\
|
||||
Movement: hjkl/arrows=move, Tab/Shift+Tab=fields\n\
|
||||
Edit: i/a/A=insert modes, Esc=normal\n\
|
||||
Validation: v=validate current, V=validate all, c=clear results, F1=toggle\n\
|
||||
?=info, Ctrl+C/Ctrl+Q=quit"
|
||||
}
|
||||
AppMode::Edit => {
|
||||
"✏️ INSERT MODE - Type to test validation limits!\n\
|
||||
Some fields have MINIMUM character requirements!\n\
|
||||
Try typing 1-2 chars in Password/ID/Comment fields, then try to switch!\n\
|
||||
arrows=move, Backspace/Del=delete, Esc=normal, Tab=next field\n\
|
||||
Field switching may be BLOCKED if minimum requirements not met!"
|
||||
}
|
||||
_ => "🔍 Validation Demo Active!"
|
||||
};
|
||||
|
||||
let help = Paragraph::new(help_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("🚀 Validation Commands"))
|
||||
.style(Style::default().fg(Color::Gray))
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
f.render_widget(help, chunks[2]);
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Print feature status
|
||||
println!("🔍 Canvas Validation Demo");
|
||||
println!("✅ validation feature: ENABLED");
|
||||
println!("🚀 Field validation: ACTIVE");
|
||||
println!("🚫 Field switching validation: ACTIVE");
|
||||
println!("📊 Try typing in fields with minimum requirements!");
|
||||
println!(" - Password (min 5): Type 1-4 chars, then try to switch fields");
|
||||
println!(" - ID (min 3): Type 1-2 chars, then try to switch fields");
|
||||
println!(" - Comment (min 10): Type 1-9 chars, then try to switch fields");
|
||||
println!(" - Unicode (min 2): Type 1 char, then try to switch fields");
|
||||
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 = ValidationDemoData::new();
|
||||
let editor = ValidationFormEditor::new(data);
|
||||
|
||||
let res = run_app(&mut terminal, editor);
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(err) = res {
|
||||
println!("{:?}", err);
|
||||
}
|
||||
|
||||
println!("🔍 Validation demo completed!");
|
||||
Ok(())
|
||||
}
|
||||
647
canvas/examples/validation_2.rs
Normal file
647
canvas/examples/validation_2.rs
Normal file
@@ -0,0 +1,647 @@
|
||||
// examples/validation_2.rs
|
||||
//! Advanced TUI Example demonstrating complex pattern filtering edge cases
|
||||
//!
|
||||
//! This example showcases the full potential of the pattern validation system
|
||||
//! with creative real-world scenarios and edge cases.
|
||||
//!
|
||||
//! Run with: cargo run --example validation_advanced_patterns --features "validation,gui"
|
||||
|
||||
// REQUIRE validation and gui features
|
||||
#[cfg(not(all(feature = "validation", feature = "gui")))]
|
||||
compile_error!(
|
||||
"This example requires the 'validation' and 'gui' features. \
|
||||
Run with: cargo run --example validation_advanced_patterns --features \"validation,gui\""
|
||||
);
|
||||
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
use canvas::ValidationResult;
|
||||
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, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
use canvas::{
|
||||
canvas::{
|
||||
gui::render_canvas_default,
|
||||
modes::AppMode,
|
||||
},
|
||||
DataProvider, FormEditor,
|
||||
ValidationConfig, ValidationConfigBuilder, PatternFilters, PositionFilter, PositionRange, CharacterFilter,
|
||||
};
|
||||
|
||||
// Enhanced FormEditor wrapper (keeping the same structure as before)
|
||||
struct AdvancedPatternFormEditor<D: DataProvider> {
|
||||
editor: FormEditor<D>,
|
||||
debug_message: String,
|
||||
command_buffer: String,
|
||||
validation_enabled: bool,
|
||||
field_switch_blocked: bool,
|
||||
block_reason: Option<String>,
|
||||
}
|
||||
|
||||
impl<D: DataProvider> AdvancedPatternFormEditor<D> {
|
||||
fn new(data_provider: D) -> Self {
|
||||
let mut editor = FormEditor::new(data_provider);
|
||||
editor.set_validation_enabled(true);
|
||||
|
||||
Self {
|
||||
editor,
|
||||
debug_message: "🚀 Advanced Pattern Validation - Showcasing edge cases and complex patterns!".to_string(),
|
||||
command_buffer: String::new(),
|
||||
validation_enabled: true,
|
||||
field_switch_blocked: false,
|
||||
block_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ... (keeping all the same methods as before for brevity)
|
||||
// [All the previous methods: clear_command_buffer, add_to_command_buffer, etc.]
|
||||
|
||||
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() }
|
||||
|
||||
fn toggle_validation(&mut self) {
|
||||
self.validation_enabled = !self.validation_enabled;
|
||||
self.editor.set_validation_enabled(self.validation_enabled);
|
||||
if self.validation_enabled {
|
||||
self.debug_message = "✅ Advanced Pattern Validation ENABLED".to_string();
|
||||
} else {
|
||||
self.debug_message = "❌ Advanced Pattern Validation DISABLED".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn move_left(&mut self) { self.editor.move_left(); self.field_switch_blocked = false; self.block_reason = None; }
|
||||
fn move_right(&mut self) { self.editor.move_right(); self.field_switch_blocked = false; self.block_reason = None; }
|
||||
|
||||
fn move_up(&mut self) {
|
||||
match self.editor.move_up() {
|
||||
Ok(()) => { self.update_field_validation_status(); self.field_switch_blocked = false; self.block_reason = None; }
|
||||
Err(e) => { self.field_switch_blocked = true; self.block_reason = Some(e.to_string()); self.debug_message = format!("🚫 Field switch blocked: {}", e); }
|
||||
}
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
match self.editor.move_down() {
|
||||
Ok(()) => { self.update_field_validation_status(); self.field_switch_blocked = false; self.block_reason = None; }
|
||||
Err(e) => { self.field_switch_blocked = true; self.block_reason = Some(e.to_string()); self.debug_message = format!("🚫 Field switch blocked: {}", e); }
|
||||
}
|
||||
}
|
||||
|
||||
fn move_line_start(&mut self) { self.editor.move_line_start(); }
|
||||
fn move_line_end(&mut self) { self.editor.move_line_end(); }
|
||||
|
||||
fn enter_edit_mode(&mut self) {
|
||||
self.editor.enter_edit_mode();
|
||||
self.debug_message = "✏️ INSERT MODE - Testing advanced pattern validation".to_string();
|
||||
}
|
||||
|
||||
fn enter_append_mode(&mut self) {
|
||||
self.editor.enter_append_mode();
|
||||
self.debug_message = "✏️ INSERT (append) - Advanced patterns active".to_string();
|
||||
}
|
||||
|
||||
fn exit_edit_mode(&mut self) {
|
||||
self.editor.exit_edit_mode();
|
||||
self.debug_message = "🔒 NORMAL MODE".to_string();
|
||||
self.update_field_validation_status();
|
||||
}
|
||||
|
||||
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
|
||||
let result = self.editor.insert_char(ch);
|
||||
if result.is_ok() {
|
||||
if let Some(validation_result) = self.editor.current_field_validation() {
|
||||
match validation_result {
|
||||
ValidationResult::Valid => { self.debug_message = "✅ Character accepted".to_string(); }
|
||||
ValidationResult::Warning { message } => { self.debug_message = format!("⚠️ Warning: {}", message); }
|
||||
ValidationResult::Error { message } => { self.debug_message = format!("❌ Pattern violation: {}", message); }
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
fn delete_backward(&mut self) -> anyhow::Result<()> {
|
||||
let result = self.editor.delete_backward();
|
||||
if result.is_ok() { self.debug_message = "⌫ Character deleted".to_string(); }
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
fn delete_forward(&mut self) -> anyhow::Result<()> {
|
||||
let result = self.editor.delete_forward();
|
||||
if result.is_ok() { self.debug_message = "⌦ Character deleted".to_string(); }
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
// Delegate methods
|
||||
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); }
|
||||
|
||||
fn next_field(&mut self) {
|
||||
match self.editor.next_field() {
|
||||
Ok(()) => { self.update_field_validation_status(); self.field_switch_blocked = false; self.block_reason = None; }
|
||||
Err(e) => { self.field_switch_blocked = true; self.block_reason = Some(e.to_string()); self.debug_message = format!("🚫 Cannot move to next field: {}", e); }
|
||||
}
|
||||
}
|
||||
|
||||
fn prev_field(&mut self) {
|
||||
match self.editor.prev_field() {
|
||||
Ok(()) => { self.update_field_validation_status(); self.field_switch_blocked = false; self.block_reason = None; }
|
||||
Err(e) => { self.field_switch_blocked = true; self.block_reason = Some(e.to_string()); self.debug_message = format!("🚫 Cannot move to previous field: {}", e); }
|
||||
}
|
||||
}
|
||||
|
||||
fn set_debug_message(&mut self, msg: String) { self.debug_message = msg; }
|
||||
fn debug_message(&self) -> &str { &self.debug_message }
|
||||
|
||||
fn update_field_validation_status(&mut self) {
|
||||
if !self.validation_enabled { return; }
|
||||
if let Some(result) = self.editor.current_field_validation() {
|
||||
match result {
|
||||
ValidationResult::Valid => { self.debug_message = format!("Field {}: ✅ Pattern valid", self.editor.current_field() + 1); }
|
||||
ValidationResult::Warning { message } => { self.debug_message = format!("Field {}: ⚠️ {}", self.editor.current_field() + 1, message); }
|
||||
ValidationResult::Error { message } => { self.debug_message = format!("Field {}: ❌ {}", self.editor.current_field() + 1, message); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_validation_status(&self) -> String {
|
||||
if !self.validation_enabled { return "❌ DISABLED".to_string(); }
|
||||
if self.field_switch_blocked { return "🚫 SWITCH BLOCKED".to_string(); }
|
||||
let summary = self.editor.validation_summary();
|
||||
if summary.has_errors() { format!("❌ {} ERRORS", summary.error_fields) }
|
||||
else if summary.has_warnings() { format!("⚠️ {} WARNINGS", summary.warning_fields) }
|
||||
else if summary.validated_fields > 0 { format!("✅ {} VALID", summary.valid_fields) }
|
||||
else { "🔍 READY".to_string() }
|
||||
}
|
||||
}
|
||||
|
||||
// Advanced demo form with creative and edge-case-heavy validation patterns
|
||||
struct AdvancedPatternData {
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl AdvancedPatternData {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
fields: vec![
|
||||
("🕐 Time (HH:MM) - 24hr format".to_string(), "".to_string()),
|
||||
("🎨 Hex Color (#RRGGBB) - Web colors".to_string(), "".to_string()),
|
||||
("🌐 IPv4 (XXX.XXX.XXX.XXX) - Network address".to_string(), "".to_string()),
|
||||
("🏷️ Product Code (ABC-123-XYZ) - Mixed format".to_string(), "".to_string()),
|
||||
("📅 Date Code (2024W15) - Year + Week".to_string(), "".to_string()),
|
||||
("🔢 Binary (101010) - Only 0s and 1s".to_string(), "".to_string()),
|
||||
("🎯 Complex ID (A1-B2C-3D4E) - Multi-rule".to_string(), "".to_string()),
|
||||
("🚀 Custom Pattern - Advanced logic".to_string(), "".to_string()),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DataProvider for AdvancedPatternData {
|
||||
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 validation_config(&self, field_index: usize) -> Option<ValidationConfig> {
|
||||
match field_index {
|
||||
0 => {
|
||||
// 🕐 Time (HH:MM) - Hours 00-23, Minutes 00-59
|
||||
// This showcases: Multiple position ranges, exact character matching, custom validation
|
||||
let time_pattern = PatternFilters::new()
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Multiple(vec![0, 1, 3, 4]), // Hours and minutes positions
|
||||
CharacterFilter::Numeric,
|
||||
))
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Single(2), // Colon separator
|
||||
CharacterFilter::Exact(':'),
|
||||
));
|
||||
|
||||
Some(ValidationConfigBuilder::new()
|
||||
.with_pattern_filters(time_pattern)
|
||||
.with_max_length(5) // HH:MM = 5 characters
|
||||
.build())
|
||||
}
|
||||
1 => {
|
||||
// 🎨 Hex Color (#RRGGBB) - Web color format
|
||||
// This showcases: OneOf filter with hex digits, exact character at start
|
||||
let hex_digits = vec!['0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F','a','b','c','d','e','f'];
|
||||
let hex_color_pattern = PatternFilters::new()
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Single(0), // Hash symbol
|
||||
CharacterFilter::Exact('#'),
|
||||
))
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Range(1, 6), // 6 hex digits for RGB
|
||||
CharacterFilter::OneOf(hex_digits),
|
||||
));
|
||||
|
||||
Some(ValidationConfigBuilder::new()
|
||||
.with_pattern_filters(hex_color_pattern)
|
||||
.with_max_length(7) // #RRGGBB = 7 characters
|
||||
.build())
|
||||
}
|
||||
2 => {
|
||||
// 🌐 IPv4 Address (XXX.XXX.XXX.XXX) - Network address
|
||||
// This showcases: Complex pattern with dots at specific positions
|
||||
let ipv4_pattern = PatternFilters::new()
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Multiple(vec![3, 7, 11]), // Dots at specific positions
|
||||
CharacterFilter::Exact('.'),
|
||||
))
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Multiple(vec![0,1,2,4,5,6,8,9,10,12,13,14]), // Number positions
|
||||
CharacterFilter::Numeric,
|
||||
));
|
||||
|
||||
Some(ValidationConfigBuilder::new()
|
||||
.with_pattern_filters(ipv4_pattern)
|
||||
.with_max_length(15) // XXX.XXX.XXX.XXX = up to 15 chars
|
||||
.build())
|
||||
}
|
||||
3 => {
|
||||
// 🏷️ Product Code (ABC-123-XYZ) - Mixed format sections
|
||||
// This showcases: Different rules for different sections
|
||||
let product_code_pattern = PatternFilters::new()
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Range(0, 2), // First 3 positions: letters
|
||||
CharacterFilter::Alphabetic,
|
||||
))
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Multiple(vec![3, 7]), // Dashes
|
||||
CharacterFilter::Exact('-'),
|
||||
))
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Range(4, 6), // Middle 3 positions: numbers
|
||||
CharacterFilter::Numeric,
|
||||
))
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Range(8, 10), // Last 3 positions: letters
|
||||
CharacterFilter::Alphabetic,
|
||||
));
|
||||
|
||||
Some(ValidationConfigBuilder::new()
|
||||
.with_pattern_filters(product_code_pattern)
|
||||
.with_max_length(11) // ABC-123-XYZ = 11 characters
|
||||
.build())
|
||||
}
|
||||
4 => {
|
||||
// 📅 Date Code (2024W15) - Year + Week format
|
||||
// This showcases: From position filtering and mixed patterns
|
||||
let date_code_pattern = PatternFilters::new()
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Range(0, 3), // Year: 4 digits
|
||||
CharacterFilter::Numeric,
|
||||
))
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Single(4), // Week indicator
|
||||
CharacterFilter::Exact('W'),
|
||||
))
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::From(5), // Week number: rest are digits
|
||||
CharacterFilter::Numeric,
|
||||
));
|
||||
|
||||
Some(ValidationConfigBuilder::new()
|
||||
.with_pattern_filters(date_code_pattern)
|
||||
.with_max_length(7) // 2024W15 = 7 characters
|
||||
.build())
|
||||
}
|
||||
5 => {
|
||||
// 🔢 Binary (101010) - Only 0s and 1s
|
||||
// This showcases: OneOf filter with limited character set
|
||||
let binary_pattern = PatternFilters::new()
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::From(0), // All positions
|
||||
CharacterFilter::OneOf(vec!['0', '1']),
|
||||
));
|
||||
|
||||
Some(ValidationConfigBuilder::new()
|
||||
.with_pattern_filters(binary_pattern)
|
||||
.with_max_length(16) // Allow up to 16 binary digits
|
||||
.build())
|
||||
}
|
||||
6 => {
|
||||
// 🎯 Complex ID (A1-B2C-3D4E) - Multiple overlapping rules
|
||||
// This showcases: Complex overlapping patterns and edge cases
|
||||
let complex_id_pattern = PatternFilters::new()
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Multiple(vec![0, 3, 6, 8]), // Letter positions
|
||||
CharacterFilter::Alphabetic,
|
||||
))
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Multiple(vec![1, 4, 7, 9]), // Number positions
|
||||
CharacterFilter::Numeric,
|
||||
))
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Multiple(vec![2, 5]), // Dashes
|
||||
CharacterFilter::Exact('-'),
|
||||
))
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Single(5), // Special case: override dash with letter C
|
||||
CharacterFilter::Alphabetic, // This creates an interesting edge case
|
||||
));
|
||||
|
||||
Some(ValidationConfigBuilder::new()
|
||||
.with_pattern_filters(complex_id_pattern)
|
||||
.with_max_length(10) // A1-B2C-3D4E = 10 characters
|
||||
.build())
|
||||
}
|
||||
7 => {
|
||||
// 🚀 Custom Pattern - Advanced logic with custom function
|
||||
// This showcases: Custom validation function for complex rules
|
||||
let custom_pattern = PatternFilters::new()
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::From(0),
|
||||
CharacterFilter::Custom(Arc::new(|c| {
|
||||
// Advanced rule: Alternating vowels and consonants!
|
||||
// Even positions (0,2,4...): vowels (a,e,i,o,u)
|
||||
// Odd positions (1,3,5...): consonants
|
||||
let vowels = ['a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U'];
|
||||
|
||||
// For demo purposes, we'll just accept alphabetic characters
|
||||
// In real usage, you'd implement the alternating logic based on position
|
||||
c.is_alphabetic()
|
||||
})),
|
||||
));
|
||||
|
||||
Some(ValidationConfigBuilder::new()
|
||||
.with_pattern_filters(custom_pattern)
|
||||
.with_max_length(12) // Allow up to 12 characters
|
||||
.build())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Key handling (same structure as before)
|
||||
fn handle_key_press(
|
||||
key: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
editor: &mut AdvancedPatternFormEditor<AdvancedPatternData>,
|
||||
) -> 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
|
||||
(AppMode::ReadOnly, KeyCode::Char('i'), _) => { editor.enter_edit_mode(); editor.clear_command_buffer(); }
|
||||
(AppMode::ReadOnly, KeyCode::Char('a'), _) => { editor.enter_append_mode(); editor.clear_command_buffer(); }
|
||||
(AppMode::ReadOnly, KeyCode::Char('A'), _) => { editor.move_line_end(); editor.enter_edit_mode(); editor.clear_command_buffer(); }
|
||||
(_, KeyCode::Esc, _) => { if mode == AppMode::Edit { editor.exit_edit_mode(); } else { editor.clear_command_buffer(); } }
|
||||
|
||||
// Validation commands
|
||||
(AppMode::ReadOnly, KeyCode::F(1), _) => { editor.toggle_validation(); }
|
||||
|
||||
// Movement in ReadOnly mode
|
||||
(AppMode::ReadOnly, KeyCode::Char('h'), _) | (AppMode::ReadOnly, KeyCode::Left, _) => { editor.move_left(); editor.clear_command_buffer(); }
|
||||
(AppMode::ReadOnly, KeyCode::Char('l'), _) | (AppMode::ReadOnly, KeyCode::Right, _) => { editor.move_right(); editor.clear_command_buffer(); }
|
||||
(AppMode::ReadOnly, KeyCode::Char('j'), _) | (AppMode::ReadOnly, KeyCode::Down, _) => { editor.move_down(); editor.clear_command_buffer(); }
|
||||
(AppMode::ReadOnly, KeyCode::Char('k'), _) | (AppMode::ReadOnly, KeyCode::Up, _) => { editor.move_up(); editor.clear_command_buffer(); }
|
||||
|
||||
// Movement in Edit mode
|
||||
(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(); }
|
||||
|
||||
// Delete operations
|
||||
(AppMode::Edit, KeyCode::Backspace, _) => { editor.delete_backward()?; }
|
||||
(AppMode::Edit, KeyCode::Delete, _) => { editor.delete_forward()?; }
|
||||
|
||||
// Tab navigation
|
||||
(_, KeyCode::Tab, _) => { editor.next_field(); }
|
||||
(_, KeyCode::BackTab, _) => { editor.prev_field(); }
|
||||
|
||||
// Character input
|
||||
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
|
||||
editor.insert_char(c)?;
|
||||
}
|
||||
|
||||
// Debug info
|
||||
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
|
||||
let summary = editor.editor.validation_summary();
|
||||
editor.set_debug_message(format!(
|
||||
"Field {}/{}, Pos {}, Mode: {:?}, Advanced patterns: {} configured",
|
||||
editor.current_field() + 1,
|
||||
editor.data_provider().field_count(),
|
||||
editor.cursor_position(),
|
||||
editor.mode(),
|
||||
summary.total_fields
|
||||
));
|
||||
}
|
||||
|
||||
_ => {
|
||||
if editor.has_pending_command() {
|
||||
editor.clear_command_buffer();
|
||||
editor.set_debug_message("Invalid command sequence".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn run_app<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
mut editor: AdvancedPatternFormEditor<AdvancedPatternData>,
|
||||
) -> 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: &AdvancedPatternFormEditor<AdvancedPatternData>) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(8), Constraint::Length(15)])
|
||||
.split(f.area());
|
||||
|
||||
render_canvas_default(f, chunks[0], &editor.editor);
|
||||
render_advanced_validation_status(f, chunks[1], editor);
|
||||
}
|
||||
|
||||
fn render_advanced_validation_status(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
editor: &AdvancedPatternFormEditor<AdvancedPatternData>,
|
||||
) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Status bar
|
||||
Constraint::Length(5), // Validation summary
|
||||
Constraint::Length(7), // Help
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Status bar
|
||||
let mode_text = match editor.mode() {
|
||||
AppMode::Edit => "INSERT",
|
||||
AppMode::ReadOnly => "NORMAL",
|
||||
_ => "OTHER",
|
||||
};
|
||||
|
||||
let validation_status = editor.get_validation_status();
|
||||
let status_text = format!("-- {} -- {} | Advanced Patterns: {}", mode_text, editor.debug_message(), validation_status);
|
||||
|
||||
let status = Paragraph::new(Line::from(Span::raw(status_text)))
|
||||
.block(Block::default().borders(Borders::ALL).title("🚀 Advanced Pattern Validation"));
|
||||
|
||||
f.render_widget(status, chunks[0]);
|
||||
|
||||
// Enhanced validation summary
|
||||
let summary = editor.editor.validation_summary();
|
||||
let field_info = match editor.current_field() {
|
||||
0 => "Time format (HH:MM) - Tests exact chars + numeric ranges",
|
||||
1 => "Hex color (#RRGGBB) - Tests OneOf filter with case insensitive",
|
||||
2 => "IPv4 address - Tests complex dot positioning",
|
||||
3 => "Product code (ABC-123-XYZ) - Tests section-based patterns",
|
||||
4 => "Date code (2024W15) - Tests From position filtering",
|
||||
5 => "Binary input - Tests limited character set (0,1 only)",
|
||||
6 => "Complex ID - Tests overlapping/conflicting rules",
|
||||
7 => "Custom pattern - Tests advanced custom validation logic",
|
||||
_ => "Unknown field",
|
||||
};
|
||||
|
||||
let summary_text = if editor.validation_enabled {
|
||||
format!(
|
||||
"📊 Advanced Pattern Summary: {} fields with complex rules\n\
|
||||
Current Field: {}\n\
|
||||
✅ Valid: {} ⚠️ Warnings: {} ❌ Errors: {} 📈 Progress: {:.0}%\n\
|
||||
🎯 Pattern Focus: {}",
|
||||
summary.total_fields,
|
||||
editor.current_field() + 1,
|
||||
summary.valid_fields,
|
||||
summary.warning_fields,
|
||||
summary.error_fields,
|
||||
summary.completion_percentage() * 100.0,
|
||||
field_info
|
||||
)
|
||||
} else {
|
||||
"❌ Advanced pattern validation is DISABLED\nPress F1 to enable and see the magic!".to_string()
|
||||
};
|
||||
|
||||
let summary_style = if summary.has_errors() {
|
||||
Style::default().fg(Color::Red)
|
||||
} else if summary.has_warnings() {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default().fg(Color::Green)
|
||||
};
|
||||
|
||||
let validation_summary = Paragraph::new(summary_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("🎯 Advanced Pattern Analysis"))
|
||||
.style(summary_style)
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
f.render_widget(validation_summary, chunks[1]);
|
||||
|
||||
// Enhanced help text
|
||||
let help_text = match editor.mode() {
|
||||
AppMode::ReadOnly => {
|
||||
"🚀 ADVANCED PATTERN SHOWCASE - Each field demonstrates different edge cases!\n\
|
||||
🕐 Time: Numeric+exact chars 🎨 Hex: OneOf with case-insensitive 🌐 IPv4: Complex positioning\n\
|
||||
🏷️ Product: Multi-section rules 📅 Date: From-position filtering 🔢 Binary: Limited charset\n\
|
||||
🎯 Complex: Overlapping rules 🚀 Custom: Advanced logic functions\n\
|
||||
\n\
|
||||
Movement: hjkl/arrows=move, Tab/Shift+Tab=fields, i/a=insert, F1=toggle, ?=info"
|
||||
}
|
||||
AppMode::Edit => {
|
||||
"✏️ INSERT MODE - Testing advanced pattern validation!\n\
|
||||
Each character is validated against complex rules in real-time\n\
|
||||
Try entering invalid characters to see detailed error messages\n\
|
||||
arrows=move, Backspace/Del=delete, Esc=normal, Tab=next field"
|
||||
}
|
||||
_ => "🚀 Advanced Pattern Validation Active!"
|
||||
};
|
||||
|
||||
let help = Paragraph::new(help_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("🎯 Advanced Pattern Commands & Info"))
|
||||
.style(Style::default().fg(Color::Gray))
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
f.render_widget(help, chunks[2]);
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("🚀 Canvas Advanced Pattern Validation Demo");
|
||||
println!("✅ validation feature: ENABLED");
|
||||
println!("✅ gui feature: ENABLED");
|
||||
println!("🎯 Advanced pattern filtering: ACTIVE");
|
||||
println!("🧪 Edge cases and complex patterns: READY");
|
||||
println!("💡 Each field showcases different validation capabilities!");
|
||||
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 = AdvancedPatternData::new();
|
||||
let editor = AdvancedPatternFormEditor::new(data);
|
||||
|
||||
let res = run_app(&mut terminal, editor);
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(err) = res {
|
||||
println!("{:?}", err);
|
||||
}
|
||||
|
||||
println!("🚀 Advanced pattern validation demo completed!");
|
||||
println!("🎯 Hope you enjoyed seeing all the edge cases in action!");
|
||||
Ok(())
|
||||
}
|
||||
712
canvas/examples/validation_3.rs
Normal file
712
canvas/examples/validation_3.rs
Normal file
@@ -0,0 +1,712 @@
|
||||
// examples/validation_3.rs
|
||||
//! Comprehensive Display Mask Features Demo
|
||||
//!
|
||||
//! This example showcases the full power of the display mask system (Feature 3)
|
||||
//! demonstrating visual formatting that keeps business logic clean.
|
||||
//!
|
||||
//! Key Features Demonstrated:
|
||||
//! - Dynamic vs Template display modes
|
||||
//! - Custom patterns for different data types
|
||||
//! - Custom input characters and separators
|
||||
//! - Custom placeholder characters
|
||||
//! - Real-time visual formatting with clean raw data
|
||||
//! - Cursor movement through formatted displays
|
||||
//! - 🔥 CRITICAL: Perfect mask/character-limit coordination to prevent invisible character bugs
|
||||
//!
|
||||
//! ⚠️ IMPORTANT BUG PREVENTION:
|
||||
//! This example demonstrates the CORRECT way to configure masks with character limits.
|
||||
//! Each mask's input position count EXACTLY matches its character limit to prevent
|
||||
//! the critical bug where users can type more characters than they can see.
|
||||
//!
|
||||
//! Run with: cargo run --example validation_3 --features "gui,validation"
|
||||
|
||||
// REQUIRE validation and gui features for mask functionality
|
||||
#[cfg(not(all(feature = "validation", feature = "gui")))]
|
||||
compile_error!(
|
||||
"This example requires the 'validation' and 'gui' features. \
|
||||
Run with: cargo run --example validation_3 --features \"gui,validation\""
|
||||
);
|
||||
|
||||
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, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
use canvas::{
|
||||
canvas::{
|
||||
gui::render_canvas_default,
|
||||
modes::AppMode,
|
||||
},
|
||||
DataProvider, FormEditor,
|
||||
ValidationConfig, ValidationConfigBuilder, DisplayMask,
|
||||
validation::mask::MaskDisplayMode,
|
||||
};
|
||||
|
||||
// Enhanced FormEditor wrapper for mask demonstration
|
||||
struct MaskDemoFormEditor<D: DataProvider> {
|
||||
editor: FormEditor<D>,
|
||||
debug_message: String,
|
||||
command_buffer: String,
|
||||
validation_enabled: bool,
|
||||
show_raw_data: bool,
|
||||
}
|
||||
|
||||
impl<D: DataProvider> MaskDemoFormEditor<D> {
|
||||
fn new(data_provider: D) -> Self {
|
||||
let mut editor = FormEditor::new(data_provider);
|
||||
editor.set_validation_enabled(true);
|
||||
|
||||
Self {
|
||||
editor,
|
||||
debug_message: "🎭 Display Mask Demo - Visual formatting with clean business logic!".to_string(),
|
||||
command_buffer: String::new(),
|
||||
validation_enabled: true,
|
||||
show_raw_data: false,
|
||||
}
|
||||
}
|
||||
|
||||
// === 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() }
|
||||
|
||||
// === MASK CONTROL ===
|
||||
fn toggle_validation(&mut self) {
|
||||
self.validation_enabled = !self.validation_enabled;
|
||||
self.editor.set_validation_enabled(self.validation_enabled);
|
||||
if self.validation_enabled {
|
||||
self.debug_message = "✅ Display Masks ENABLED - See visual formatting in action!".to_string();
|
||||
} else {
|
||||
self.debug_message = "❌ Display Masks DISABLED - Raw text only".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_raw_data_view(&mut self) {
|
||||
self.show_raw_data = !self.show_raw_data;
|
||||
if self.show_raw_data {
|
||||
self.debug_message = "👁️ Showing RAW business data (what's actually stored)".to_string();
|
||||
} else {
|
||||
self.debug_message = "🎭 Showing FORMATTED display (what users see)".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_field_info(&self) -> (String, String, String) {
|
||||
let field_index = self.editor.current_field();
|
||||
let raw_data = self.editor.current_text();
|
||||
let display_data = if self.validation_enabled {
|
||||
self.editor.current_display_text()
|
||||
} else {
|
||||
raw_data.to_string()
|
||||
};
|
||||
|
||||
let mask_info = if let Some(config) = self.editor.validation_state().get_field_config(field_index) {
|
||||
if let Some(mask) = &config.display_mask {
|
||||
format!("Pattern: '{}', Mode: {:?}",
|
||||
mask.pattern(),
|
||||
mask.display_mode())
|
||||
} else {
|
||||
"No mask configured".to_string()
|
||||
}
|
||||
} else {
|
||||
"No validation config".to_string()
|
||||
};
|
||||
|
||||
(raw_data.to_string(), display_data, mask_info)
|
||||
}
|
||||
|
||||
// === ENHANCED MOVEMENT WITH MASK AWARENESS ===
|
||||
fn move_left(&mut self) {
|
||||
self.editor.move_left();
|
||||
self.update_cursor_info();
|
||||
}
|
||||
|
||||
fn move_right(&mut self) {
|
||||
self.editor.move_right();
|
||||
self.update_cursor_info();
|
||||
}
|
||||
|
||||
fn move_up(&mut self) {
|
||||
match self.editor.move_up() {
|
||||
Ok(()) => { self.update_field_info(); }
|
||||
Err(e) => { self.debug_message = format!("🚫 Field switch blocked: {}", e); }
|
||||
}
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
match self.editor.move_down() {
|
||||
Ok(()) => { self.update_field_info(); }
|
||||
Err(e) => { self.debug_message = format!("🚫 Field switch blocked: {}", e); }
|
||||
}
|
||||
}
|
||||
|
||||
fn move_line_start(&mut self) {
|
||||
self.editor.move_line_start();
|
||||
self.update_cursor_info();
|
||||
}
|
||||
|
||||
fn move_line_end(&mut self) {
|
||||
self.editor.move_line_end();
|
||||
self.update_cursor_info();
|
||||
}
|
||||
|
||||
fn update_cursor_info(&mut self) {
|
||||
if self.validation_enabled {
|
||||
let raw_pos = self.editor.cursor_position();
|
||||
let display_pos = self.editor.display_cursor_position();
|
||||
if raw_pos != display_pos {
|
||||
self.debug_message = format!("📍 Cursor: Raw pos {} → Display pos {} (mask active)", raw_pos, display_pos);
|
||||
} else {
|
||||
self.debug_message = format!("📍 Cursor at position {} (no mask offset)", raw_pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_field_info(&mut self) {
|
||||
let field_name = self.editor.data_provider().field_name(self.editor.current_field());
|
||||
self.debug_message = format!("📝 Switched to: {}", field_name);
|
||||
}
|
||||
|
||||
// === MODE TRANSITIONS ===
|
||||
fn enter_edit_mode(&mut self) {
|
||||
self.editor.enter_edit_mode();
|
||||
self.debug_message = "✏️ INSERT MODE - Type to see mask formatting in real-time".to_string();
|
||||
}
|
||||
|
||||
fn enter_append_mode(&mut self) {
|
||||
self.editor.enter_append_mode();
|
||||
self.debug_message = "✏️ INSERT (append) - Mask formatting active".to_string();
|
||||
}
|
||||
|
||||
fn exit_edit_mode(&mut self) {
|
||||
self.editor.exit_edit_mode();
|
||||
self.debug_message = "🔒 NORMAL MODE - Press 'r' to see raw data, 'm' for mask info".to_string();
|
||||
}
|
||||
|
||||
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
|
||||
let result = self.editor.insert_char(ch);
|
||||
if result.is_ok() {
|
||||
let (raw, display, _) = self.get_current_field_info();
|
||||
if raw != display {
|
||||
self.debug_message = format!("✏️ Added '{}': Raw='{}' Display='{}'", ch, raw, display);
|
||||
} else {
|
||||
self.debug_message = format!("✏️ Added '{}': '{}'", ch, raw);
|
||||
}
|
||||
}
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
// === DELETE OPERATIONS ===
|
||||
fn delete_backward(&mut self) -> anyhow::Result<()> {
|
||||
let result = self.editor.delete_backward();
|
||||
if result.is_ok() {
|
||||
self.debug_message = "⌫ Character deleted".to_string();
|
||||
self.update_cursor_info();
|
||||
}
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
fn delete_forward(&mut self) -> anyhow::Result<()> {
|
||||
let result = self.editor.delete_forward();
|
||||
if result.is_ok() {
|
||||
self.debug_message = "⌦ Character deleted".to_string();
|
||||
self.update_cursor_info();
|
||||
}
|
||||
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); }
|
||||
|
||||
fn next_field(&mut self) {
|
||||
match self.editor.next_field() {
|
||||
Ok(()) => { self.update_field_info(); }
|
||||
Err(e) => { self.debug_message = format!("🚫 Cannot move to next field: {}", e); }
|
||||
}
|
||||
}
|
||||
|
||||
fn prev_field(&mut self) {
|
||||
match self.editor.prev_field() {
|
||||
Ok(()) => { self.update_field_info(); }
|
||||
Err(e) => { self.debug_message = format!("🚫 Cannot move to previous field: {}", e); }
|
||||
}
|
||||
}
|
||||
|
||||
// === STATUS AND DEBUG ===
|
||||
fn set_debug_message(&mut self, msg: String) { self.debug_message = msg; }
|
||||
fn debug_message(&self) -> &str { &self.debug_message }
|
||||
|
||||
fn show_mask_details(&mut self) {
|
||||
let (raw, display, mask_info) = self.get_current_field_info();
|
||||
self.debug_message = format!("🔍 Field {}: {} | Raw: '{}' Display: '{}'",
|
||||
self.current_field() + 1, mask_info, raw, display);
|
||||
}
|
||||
|
||||
fn get_mask_status(&self) -> String {
|
||||
if !self.validation_enabled {
|
||||
return "❌ DISABLED".to_string();
|
||||
}
|
||||
|
||||
let field_count = self.editor.data_provider().field_count();
|
||||
let mut mask_count = 0;
|
||||
for i in 0..field_count {
|
||||
if let Some(config) = self.editor.validation_state().get_field_config(i) {
|
||||
if config.display_mask.is_some() {
|
||||
mask_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
format!("🎭 {} MASKS", mask_count)
|
||||
}
|
||||
}
|
||||
|
||||
// Demo data with comprehensive mask examples
|
||||
struct MaskDemoData {
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl MaskDemoData {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
fields: vec![
|
||||
("📞 Phone (Dynamic)".to_string(), "".to_string()),
|
||||
("📞 Phone (Template)".to_string(), "".to_string()),
|
||||
("📅 Date US (MM/DD/YYYY)".to_string(), "".to_string()),
|
||||
("📅 Date EU (DD.MM.YYYY)".to_string(), "".to_string()),
|
||||
("📅 Date ISO (YYYY-MM-DD)".to_string(), "".to_string()),
|
||||
("🏛️ SSN (XXX-XX-XXXX)".to_string(), "".to_string()),
|
||||
("💳 Credit Card".to_string(), "".to_string()),
|
||||
("🏢 Employee ID (EMP-####)".to_string(), "".to_string()),
|
||||
("📦 Product Code (ABC###XYZ)".to_string(), "".to_string()),
|
||||
("🌈 Custom Separators".to_string(), "".to_string()),
|
||||
("⭐ Custom Placeholders".to_string(), "".to_string()),
|
||||
("🎯 Mixed Input Chars".to_string(), "".to_string()),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DataProvider for MaskDemoData {
|
||||
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 validation_config(&self, field_index: usize) -> Option<ValidationConfig> {
|
||||
match field_index {
|
||||
0 => {
|
||||
// 📞 Phone (Dynamic) - FIXED: Perfect mask/limit coordination
|
||||
let phone_mask = DisplayMask::new("(###) ###-####", '#');
|
||||
Some(ValidationConfigBuilder::new()
|
||||
.with_display_mask(phone_mask)
|
||||
.with_max_length(10) // ✅ CRITICAL: Exactly matches 10 input positions
|
||||
.build())
|
||||
}
|
||||
1 => {
|
||||
// 📞 Phone (Template) - FIXED: Perfect mask/limit coordination
|
||||
let phone_template = DisplayMask::new("(###) ###-####", '#')
|
||||
.with_template('_');
|
||||
Some(ValidationConfigBuilder::new()
|
||||
.with_display_mask(phone_template)
|
||||
.with_max_length(10) // ✅ CRITICAL: Exactly matches 10 input positions
|
||||
.build())
|
||||
}
|
||||
2 => {
|
||||
// 📅 Date US (MM/DD/YYYY) - American date format
|
||||
let us_date = DisplayMask::new("##/##/####", '#');
|
||||
Some(ValidationConfig::with_mask(us_date))
|
||||
}
|
||||
3 => {
|
||||
// 📅 Date EU (DD.MM.YYYY) - European date format with dots
|
||||
let eu_date = DisplayMask::new("##.##.####", '#')
|
||||
.with_template('•');
|
||||
Some(ValidationConfig::with_mask(eu_date))
|
||||
}
|
||||
4 => {
|
||||
// 📅 Date ISO (YYYY-MM-DD) - ISO date format
|
||||
let iso_date = DisplayMask::new("####-##-##", '#')
|
||||
.with_template('-');
|
||||
Some(ValidationConfig::with_mask(iso_date))
|
||||
}
|
||||
5 => {
|
||||
// 🏛️ SSN using custom input character 'X' - FIXED: Perfect coordination
|
||||
let ssn_mask = DisplayMask::new("XXX-XX-XXXX", 'X');
|
||||
Some(ValidationConfigBuilder::new()
|
||||
.with_display_mask(ssn_mask)
|
||||
.with_max_length(9) // ✅ CRITICAL: Exactly matches 9 input positions
|
||||
.build())
|
||||
}
|
||||
6 => {
|
||||
// 💳 Credit Card (16 digits with spaces) - FIXED: Perfect coordination
|
||||
let cc_mask = DisplayMask::new("#### #### #### ####", '#')
|
||||
.with_template('•');
|
||||
Some(ValidationConfigBuilder::new()
|
||||
.with_display_mask(cc_mask)
|
||||
.with_max_length(16) // ✅ CRITICAL: Exactly matches 16 input positions
|
||||
.build())
|
||||
}
|
||||
7 => {
|
||||
// 🏢 Employee ID with business prefix
|
||||
let emp_id = DisplayMask::new("EMP-####", '#');
|
||||
Some(ValidationConfig::with_mask(emp_id))
|
||||
}
|
||||
8 => {
|
||||
// 📦 Product Code with mixed letters and numbers
|
||||
let product_code = DisplayMask::new("ABC###XYZ", '#');
|
||||
Some(ValidationConfig::with_mask(product_code))
|
||||
}
|
||||
9 => {
|
||||
// 🌈 Custom Separators - Using | and ~ as separators
|
||||
let custom_sep = DisplayMask::new("##|##~####", '#')
|
||||
.with_template('?');
|
||||
Some(ValidationConfig::with_mask(custom_sep))
|
||||
}
|
||||
10 => {
|
||||
// ⭐ Custom Placeholders - Using different placeholder characters
|
||||
let custom_placeholder = DisplayMask::new("##-##-##", '#')
|
||||
.with_template('★');
|
||||
Some(ValidationConfig::with_mask(custom_placeholder))
|
||||
}
|
||||
11 => {
|
||||
// 🎯 Mixed Input Characters - Using 'N' for numbers
|
||||
let mixed_input = DisplayMask::new("ID:NNN-NNN", 'N');
|
||||
Some(ValidationConfig::with_mask(mixed_input))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced key handling with mask-specific commands
|
||||
fn handle_key_press(
|
||||
key: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
editor: &mut MaskDemoFormEditor<MaskDemoData>,
|
||||
) -> 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 ===
|
||||
(AppMode::ReadOnly, KeyCode::Char('i'), _) => {
|
||||
editor.enter_edit_mode();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
|
||||
editor.enter_append_mode();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('A'), _) => {
|
||||
editor.move_line_end();
|
||||
editor.enter_edit_mode();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// Escape: Exit edit mode
|
||||
(_, KeyCode::Esc, _) => {
|
||||
if mode == AppMode::Edit {
|
||||
editor.exit_edit_mode();
|
||||
} else {
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
}
|
||||
|
||||
// === MASK SPECIFIC COMMANDS ===
|
||||
(AppMode::ReadOnly, KeyCode::Char('m'), _) => {
|
||||
editor.show_mask_details();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('r'), _) => {
|
||||
editor.toggle_raw_data_view();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::F(1), _) => {
|
||||
editor.toggle_validation();
|
||||
}
|
||||
|
||||
// === MOVEMENT ===
|
||||
(AppMode::ReadOnly, KeyCode::Char('h'), _) | (AppMode::ReadOnly, KeyCode::Left, _) => {
|
||||
editor.move_left();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('l'), _) | (AppMode::ReadOnly, KeyCode::Right, _) => {
|
||||
editor.move_right();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('j'), _) | (AppMode::ReadOnly, KeyCode::Down, _) => {
|
||||
editor.move_down();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('k'), _) | (AppMode::ReadOnly, KeyCode::Up, _) => {
|
||||
editor.move_up();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// Line movement
|
||||
(AppMode::ReadOnly, KeyCode::Char('0'), _) => {
|
||||
editor.move_line_start();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('$'), _) => {
|
||||
editor.move_line_end();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// === EDIT MODE MOVEMENT ===
|
||||
(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(); }
|
||||
|
||||
// === DELETE OPERATIONS ===
|
||||
(AppMode::Edit, KeyCode::Backspace, _) => { editor.delete_backward()?; }
|
||||
(AppMode::Edit, KeyCode::Delete, _) => { editor.delete_forward()?; }
|
||||
|
||||
// === TAB NAVIGATION ===
|
||||
(_, KeyCode::Tab, _) => { editor.next_field(); }
|
||||
(_, KeyCode::BackTab, _) => { editor.prev_field(); }
|
||||
|
||||
// === CHARACTER INPUT ===
|
||||
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
|
||||
editor.insert_char(c)?;
|
||||
}
|
||||
|
||||
// === DEBUG/INFO COMMANDS ===
|
||||
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
|
||||
let (raw, display, mask_info) = editor.get_current_field_info();
|
||||
editor.set_debug_message(format!(
|
||||
"Field {}/{}, Cursor {}, {}, Raw: '{}', Display: '{}'",
|
||||
editor.current_field() + 1,
|
||||
editor.data_provider().field_count(),
|
||||
editor.cursor_position(),
|
||||
mask_info,
|
||||
raw,
|
||||
display
|
||||
));
|
||||
}
|
||||
|
||||
_ => {
|
||||
if editor.has_pending_command() {
|
||||
editor.clear_command_buffer();
|
||||
editor.set_debug_message("Invalid command sequence".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn run_app<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
mut editor: MaskDemoFormEditor<MaskDemoData>,
|
||||
) -> 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: &MaskDemoFormEditor<MaskDemoData>) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(8), Constraint::Length(16)])
|
||||
.split(f.area());
|
||||
|
||||
render_enhanced_canvas(f, chunks[0], editor);
|
||||
render_mask_status(f, chunks[1], editor);
|
||||
}
|
||||
|
||||
fn render_enhanced_canvas(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
editor: &MaskDemoFormEditor<MaskDemoData>,
|
||||
) {
|
||||
render_canvas_default(f, area, &editor.editor);
|
||||
}
|
||||
|
||||
fn render_mask_status(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
editor: &MaskDemoFormEditor<MaskDemoData>,
|
||||
) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Status bar
|
||||
Constraint::Length(6), // Data comparison
|
||||
Constraint::Length(7), // Help
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Status bar with mask information
|
||||
let mode_text = match editor.mode() {
|
||||
AppMode::Edit => "INSERT",
|
||||
AppMode::ReadOnly => "NORMAL",
|
||||
_ => "OTHER",
|
||||
};
|
||||
|
||||
let mask_status = editor.get_mask_status();
|
||||
let status_text = format!("-- {} -- {} | Masks: {} | View: {}",
|
||||
mode_text,
|
||||
editor.debug_message(),
|
||||
mask_status,
|
||||
if editor.show_raw_data { "RAW" } else { "FORMATTED" });
|
||||
|
||||
let status = Paragraph::new(Line::from(Span::raw(status_text)))
|
||||
.block(Block::default().borders(Borders::ALL).title("🎭 Display Mask Demo"));
|
||||
|
||||
f.render_widget(status, chunks[0]);
|
||||
|
||||
// Data comparison showing raw vs display
|
||||
let (raw_data, display_data, mask_info) = editor.get_current_field_info();
|
||||
let field_name = editor.data_provider().field_name(editor.current_field());
|
||||
|
||||
let comparison_text = format!(
|
||||
"📝 Current Field: {}\n\
|
||||
🔧 Mask Config: {}\n\
|
||||
\n\
|
||||
💾 Raw Business Data: '{}' ← What's actually stored in your database\n\
|
||||
🎭 Formatted Display: '{}' ← What users see in the interface\n\
|
||||
📍 Cursor: Raw pos {} → Display pos {}",
|
||||
field_name,
|
||||
mask_info,
|
||||
raw_data,
|
||||
display_data,
|
||||
editor.cursor_position(),
|
||||
editor.editor.display_cursor_position()
|
||||
);
|
||||
|
||||
let comparison_style = if raw_data != display_data {
|
||||
Style::default().fg(Color::Green) // Green when mask is active
|
||||
} else {
|
||||
Style::default().fg(Color::Gray) // Gray when no formatting
|
||||
};
|
||||
|
||||
let data_comparison = Paragraph::new(comparison_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("📊 Raw Data vs Display Formatting"))
|
||||
.style(comparison_style)
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
f.render_widget(data_comparison, chunks[1]);
|
||||
|
||||
// Enhanced help text
|
||||
let help_text = match editor.mode() {
|
||||
AppMode::ReadOnly => {
|
||||
"🎭 MASK DEMO: See how visual formatting keeps business logic clean!\n\
|
||||
\n\
|
||||
📱 Try different fields to see various mask patterns:\n\
|
||||
• Dynamic vs Template modes • Custom separators • Different input chars\n\
|
||||
\n\
|
||||
Commands: i/a=insert, m=mask details, r=toggle raw/display view\n\
|
||||
Movement: hjkl/arrows=move, 0=$=line start/end, Tab=next field, F1=toggle masks\n\
|
||||
?=detailed info, Ctrl+C=quit"
|
||||
}
|
||||
AppMode::Edit => {
|
||||
"✏️ INSERT MODE - Type to see real-time mask formatting!\n\
|
||||
\n\
|
||||
🔥 Key Features in Action:\n\
|
||||
• Separators auto-appear as you type • Cursor skips over separators\n\
|
||||
• Template fields show placeholders • Raw data stays clean for business logic\n\
|
||||
\n\
|
||||
arrows=move through mask, Backspace/Del=delete, Esc=normal, Tab=next field\n\
|
||||
Notice how cursor position maps between raw data and display!"
|
||||
}
|
||||
_ => "🎭 Display Mask Demo Active!"
|
||||
};
|
||||
|
||||
let help = Paragraph::new(help_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("🚀 Mask Features & Commands"))
|
||||
.style(Style::default().fg(Color::Gray))
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
f.render_widget(help, chunks[2]);
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Print feature status
|
||||
println!("🎭 Canvas Display Mask Demo (Feature 3)");
|
||||
println!("✅ validation feature: ENABLED");
|
||||
println!("✅ gui feature: ENABLED");
|
||||
println!("🎭 Display masks: ACTIVE");
|
||||
println!("🔥 Key Benefits Demonstrated:");
|
||||
println!(" • Clean separation: Visual formatting ≠ Business logic");
|
||||
println!(" • User-friendly: Pretty displays with automatic cursor handling");
|
||||
println!(" • Flexible: Custom patterns, separators, and placeholders");
|
||||
println!(" • Transparent: Library handles all complexity, API stays simple");
|
||||
println!();
|
||||
println!("💡 Try typing in different fields to see mask magic!");
|
||||
println!(" 📞 Phone fields show dynamic vs template modes");
|
||||
println!(" 📅 Date fields show different regional formats");
|
||||
println!(" 💳 Credit card shows spaced formatting");
|
||||
println!(" ⭐ Custom fields show advanced separator/placeholder options");
|
||||
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 = MaskDemoData::new();
|
||||
let editor = MaskDemoFormEditor::new(data);
|
||||
|
||||
let res = run_app(&mut terminal, editor);
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(err) = res {
|
||||
println!("{:?}", err);
|
||||
}
|
||||
|
||||
println!("🎭 Display mask demo completed!");
|
||||
println!("🏆 You've seen how masks provide beautiful UX while keeping business logic clean!");
|
||||
Ok(())
|
||||
}
|
||||
738
canvas/examples/validation_4.rs
Normal file
738
canvas/examples/validation_4.rs
Normal file
@@ -0,0 +1,738 @@
|
||||
/* examples/validation_4.rs
|
||||
Enhanced Feature 4 Demo: Multiple custom formatters with comprehensive edge cases
|
||||
|
||||
Demonstrates:
|
||||
- Multiple formatter types: PSC, Phone, Credit Card, Date
|
||||
- Edge case handling: incomplete input, invalid chars, overflow
|
||||
- Real-time validation feedback and format preview
|
||||
- Advanced cursor position mapping
|
||||
- Raw vs formatted data separation
|
||||
- Error handling and fallback behavior
|
||||
|
||||
*/
|
||||
|
||||
#![allow(clippy::needless_return)]
|
||||
|
||||
#[cfg(not(all(feature = "validation", feature = "gui")))]
|
||||
compile_error!(
|
||||
"This example requires the 'validation' and 'gui' features. \
|
||||
Run with: cargo run --example validation_4 --features \"gui,validation\""
|
||||
);
|
||||
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
|
||||
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, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
use canvas::{
|
||||
canvas::{gui::render_canvas_default, modes::AppMode},
|
||||
DataProvider, FormEditor,
|
||||
ValidationConfig, ValidationConfigBuilder,
|
||||
CustomFormatter, FormattingResult,
|
||||
};
|
||||
|
||||
/// PSC (Postal Code) Formatter: "01001" -> "010 01"
|
||||
struct PSCFormatter;
|
||||
|
||||
impl CustomFormatter for PSCFormatter {
|
||||
fn format(&self, raw: &str) -> FormattingResult {
|
||||
if raw.is_empty() {
|
||||
return FormattingResult::success("");
|
||||
}
|
||||
|
||||
// Validate: only digits allowed
|
||||
if !raw.chars().all(|c| c.is_ascii_digit()) {
|
||||
return FormattingResult::error("PSC must contain only digits");
|
||||
}
|
||||
|
||||
let len = raw.chars().count();
|
||||
match len {
|
||||
0 => FormattingResult::success(""),
|
||||
1..=3 => FormattingResult::success(raw),
|
||||
4 => FormattingResult::warning(
|
||||
format!("{} ", &raw[..3]),
|
||||
"PSC incomplete (4/5 digits)"
|
||||
),
|
||||
5 => {
|
||||
let formatted = format!("{} {}", &raw[..3], &raw[3..]);
|
||||
if raw == "00000" {
|
||||
FormattingResult::warning(formatted, "Invalid PSC: 00000")
|
||||
} else {
|
||||
FormattingResult::success(formatted)
|
||||
}
|
||||
},
|
||||
_ => FormattingResult::error("PSC too long (max 5 digits)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Phone Number Formatter: "1234567890" -> "(123) 456-7890"
|
||||
struct PhoneFormatter;
|
||||
|
||||
impl CustomFormatter for PhoneFormatter {
|
||||
fn format(&self, raw: &str) -> FormattingResult {
|
||||
if raw.is_empty() {
|
||||
return FormattingResult::success("");
|
||||
}
|
||||
|
||||
// Only digits allowed
|
||||
if !raw.chars().all(|c| c.is_ascii_digit()) {
|
||||
return FormattingResult::error("Phone must contain only digits");
|
||||
}
|
||||
|
||||
let len = raw.chars().count();
|
||||
match len {
|
||||
0 => FormattingResult::success(""),
|
||||
1..=3 => FormattingResult::success(format!("({})", raw)),
|
||||
4..=6 => FormattingResult::success(format!("({}) {}", &raw[..3], &raw[3..])),
|
||||
7..=10 => FormattingResult::success(format!("({}) {}-{}", &raw[..3], &raw[3..6], &raw[6..])),
|
||||
10 => {
|
||||
let formatted = format!("({}) {}-{}", &raw[..3], &raw[3..6], &raw[6..]);
|
||||
FormattingResult::success(formatted)
|
||||
},
|
||||
_ => FormattingResult::warning(
|
||||
format!("({}) {}-{}", &raw[..3], &raw[3..6], &raw[6..10]),
|
||||
"Phone too long (extra digits ignored)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Credit Card Formatter: "1234567890123456" -> "1234 5678 9012 3456"
|
||||
struct CreditCardFormatter;
|
||||
|
||||
impl CustomFormatter for CreditCardFormatter {
|
||||
fn format(&self, raw: &str) -> FormattingResult {
|
||||
if raw.is_empty() {
|
||||
return FormattingResult::success("");
|
||||
}
|
||||
|
||||
if !raw.chars().all(|c| c.is_ascii_digit()) {
|
||||
return FormattingResult::error("Card number must contain only digits");
|
||||
}
|
||||
|
||||
let mut formatted = String::new();
|
||||
for (i, ch) in raw.chars().enumerate() {
|
||||
if i > 0 && i % 4 == 0 {
|
||||
formatted.push(' ');
|
||||
}
|
||||
formatted.push(ch);
|
||||
}
|
||||
|
||||
let len = raw.chars().count();
|
||||
match len {
|
||||
0..=15 => FormattingResult::warning(formatted, format!("Card incomplete ({}/16 digits)", len)),
|
||||
16 => FormattingResult::success(formatted),
|
||||
_ => FormattingResult::warning(formatted, "Card too long (extra digits shown)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/// Date Formatter: "12012024" -> "12/01/2024"
|
||||
struct DateFormatter;
|
||||
|
||||
impl CustomFormatter for DateFormatter {
|
||||
fn format(&self, raw: &str) -> FormattingResult {
|
||||
if raw.is_empty() {
|
||||
return FormattingResult::success("");
|
||||
}
|
||||
|
||||
if !raw.chars().all(|c| c.is_ascii_digit()) {
|
||||
return FormattingResult::error("Date must contain only digits");
|
||||
}
|
||||
|
||||
let len = raw.len();
|
||||
match len {
|
||||
0 => FormattingResult::success(""),
|
||||
1..=2 => FormattingResult::success(raw.to_string()),
|
||||
3..=4 => FormattingResult::success(format!("{}/{}", &raw[..2], &raw[2..])),
|
||||
5..=8 => FormattingResult::success(format!("{}/{}/{}", &raw[..2], &raw[2..4], &raw[4..])),
|
||||
8 => {
|
||||
let month = &raw[..2];
|
||||
let day = &raw[2..4];
|
||||
let year = &raw[4..];
|
||||
|
||||
// Basic validation
|
||||
let m: u32 = month.parse().unwrap_or(0);
|
||||
let d: u32 = day.parse().unwrap_or(0);
|
||||
|
||||
if m == 0 || m > 12 {
|
||||
FormattingResult::warning(
|
||||
format!("{}/{}/{}", month, day, year),
|
||||
"Invalid month (01-12)"
|
||||
)
|
||||
} else if d == 0 || d > 31 {
|
||||
FormattingResult::warning(
|
||||
format!("{}/{}/{}", month, day, year),
|
||||
"Invalid day (01-31)"
|
||||
)
|
||||
} else {
|
||||
FormattingResult::success(format!("{}/{}/{}", month, day, year))
|
||||
}
|
||||
},
|
||||
_ => FormattingResult::error("Date too long (MMDDYYYY format)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced demo data with multiple formatter types
|
||||
struct MultiFormatterDemoData {
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl MultiFormatterDemoData {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
fields: vec![
|
||||
("🏁 PSC (01001)".to_string(), "".to_string()),
|
||||
("📞 Phone (1234567890)".to_string(), "".to_string()),
|
||||
("💳 Credit Card (16 digits)".to_string(), "".to_string()),
|
||||
("📅 Date (12012024)".to_string(), "".to_string()),
|
||||
("📝 Plain Text".to_string(), "".to_string()),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DataProvider for MultiFormatterDemoData {
|
||||
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;
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
fn validation_config(&self, field_index: usize) -> Option<ValidationConfig> {
|
||||
match field_index {
|
||||
0 => Some(ValidationConfigBuilder::new()
|
||||
.with_custom_formatter(Arc::new(PSCFormatter))
|
||||
.with_max_length(5)
|
||||
.build()),
|
||||
1 => Some(ValidationConfigBuilder::new()
|
||||
.with_custom_formatter(Arc::new(PhoneFormatter))
|
||||
.with_max_length(12)
|
||||
.build()),
|
||||
2 => Some(ValidationConfigBuilder::new()
|
||||
.with_custom_formatter(Arc::new(CreditCardFormatter))
|
||||
.with_max_length(20)
|
||||
.build()),
|
||||
3 => Some(ValidationConfigBuilder::new()
|
||||
.with_custom_formatter(Arc::new(DateFormatter))
|
||||
.with_max_length(8)
|
||||
.build()),
|
||||
4 => Some(ValidationConfigBuilder::new()
|
||||
.with_custom_formatter(Arc::new(DateFormatter))
|
||||
.with_max_length(8)
|
||||
.build()),
|
||||
_ => None, // Plain text field - no formatter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced demo editor with comprehensive status tracking
|
||||
struct EnhancedDemoEditor<D: DataProvider> {
|
||||
editor: FormEditor<D>,
|
||||
debug_message: String,
|
||||
validation_enabled: bool,
|
||||
show_raw_data: bool,
|
||||
show_cursor_details: bool,
|
||||
example_mode: usize,
|
||||
}
|
||||
|
||||
impl<D: DataProvider> EnhancedDemoEditor<D> {
|
||||
fn new(data_provider: D) -> Self {
|
||||
let mut editor = FormEditor::new(data_provider);
|
||||
editor.set_validation_enabled(true);
|
||||
|
||||
Self {
|
||||
editor,
|
||||
debug_message: "🧩 Enhanced Custom Formatter Demo - Multiple formatters with rich edge cases!".to_string(),
|
||||
validation_enabled: true,
|
||||
show_raw_data: false,
|
||||
show_cursor_details: false,
|
||||
example_mode: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Field type detection
|
||||
fn current_field_type(&self) -> &'static str {
|
||||
match self.editor.current_field() {
|
||||
0 => "PSC",
|
||||
1 => "Phone",
|
||||
2 => "Credit Card",
|
||||
3 => "Date",
|
||||
_ => "Plain Text",
|
||||
}
|
||||
}
|
||||
|
||||
fn has_formatter(&self) -> bool {
|
||||
self.editor.current_field() < 5 // First 5 fields have formatters
|
||||
}
|
||||
|
||||
fn get_input_rules(&self) -> &'static str {
|
||||
match self.editor.current_field() {
|
||||
0 => "5 digits only (PSC format)",
|
||||
1 => "10+ digits (US phone format)",
|
||||
2 => "16+ digits (credit card)",
|
||||
3 => "Digits as cents (12345 = $123.45)",
|
||||
4 => "8 digits MMDDYYYY (date format)",
|
||||
_ => "Any text (no formatting)",
|
||||
}
|
||||
}
|
||||
|
||||
fn cycle_example_data(&mut self) {
|
||||
let examples = [
|
||||
// PSC examples
|
||||
vec!["01001", "1234567890", "1234567890123456", "12345", "12012024", "Plain text here"],
|
||||
// Incomplete examples
|
||||
vec!["010", "123", "1234", "123", "1201", "More text"],
|
||||
// Invalid examples (will show error handling)
|
||||
vec!["0abc1", "12a45", "123abc", "abc", "ab01cd", "Special chars!"],
|
||||
// Edge cases
|
||||
vec!["00000", "0000000000", "0000000000000000", "99", "13012024", ""],
|
||||
];
|
||||
|
||||
self.example_mode = (self.example_mode + 1) % examples.len();
|
||||
let current_examples = &examples[self.example_mode];
|
||||
|
||||
for (i, example) in current_examples.iter().enumerate() {
|
||||
if i < self.editor.data_provider().field_count() {
|
||||
self.editor.data_provider_mut().set_field_value(i, example.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mode_names = ["Valid Examples", "Incomplete Input", "Invalid Characters", "Edge Cases"];
|
||||
self.debug_message = format!("📋 Loaded: {}", mode_names[self.example_mode]);
|
||||
}
|
||||
|
||||
// Enhanced status methods
|
||||
fn toggle_validation(&mut self) {
|
||||
self.validation_enabled = !self.validation_enabled;
|
||||
self.editor.set_validation_enabled(self.validation_enabled);
|
||||
self.debug_message = if self.validation_enabled {
|
||||
"✅ Custom Formatters ENABLED".to_string()
|
||||
} else {
|
||||
"❌ Custom Formatters DISABLED".to_string()
|
||||
};
|
||||
}
|
||||
|
||||
fn toggle_raw_data_view(&mut self) {
|
||||
self.show_raw_data = !self.show_raw_data;
|
||||
self.debug_message = if self.show_raw_data {
|
||||
"👁️ Showing RAW data focus".to_string()
|
||||
} else {
|
||||
"✨ Showing FORMATTED display focus".to_string()
|
||||
};
|
||||
}
|
||||
|
||||
fn toggle_cursor_details(&mut self) {
|
||||
self.show_cursor_details = !self.show_cursor_details;
|
||||
self.debug_message = if self.show_cursor_details {
|
||||
"📍 Detailed cursor mapping info ON".to_string()
|
||||
} else {
|
||||
"📍 Detailed cursor mapping info OFF".to_string()
|
||||
};
|
||||
}
|
||||
|
||||
fn get_current_field_analysis(&self) -> (String, String, String, Option<String>) {
|
||||
let raw = self.editor.current_text();
|
||||
let display = self.editor.current_display_text();
|
||||
|
||||
let status = if raw == display {
|
||||
if self.has_formatter() {
|
||||
if self.mode() == AppMode::Edit {
|
||||
"Raw (editing)".to_string()
|
||||
} else {
|
||||
"No formatting needed".to_string()
|
||||
}
|
||||
} else {
|
||||
"No formatter".to_string()
|
||||
}
|
||||
} else {
|
||||
"Custom formatted".to_string()
|
||||
};
|
||||
|
||||
let warning = if self.validation_enabled && self.has_formatter() {
|
||||
// Check if there are any formatting warnings
|
||||
if raw.len() > 0 {
|
||||
match self.editor.current_field() {
|
||||
0 if raw.len() < 5 => Some(format!("PSC incomplete: {}/5", raw.len())),
|
||||
1 if raw.len() < 10 => Some(format!("Phone incomplete: {}/10", raw.len())),
|
||||
2 if raw.len() < 16 => Some(format!("Card incomplete: {}/16", raw.len())),
|
||||
4 if raw.len() < 8 => Some(format!("Date incomplete: {}/8", raw.len())),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
(raw.to_string(), display, status, warning)
|
||||
}
|
||||
|
||||
// Delegate methods with enhanced feedback
|
||||
fn enter_edit_mode(&mut self) {
|
||||
self.editor.enter_edit_mode();
|
||||
let field_type = self.current_field_type();
|
||||
let rules = self.get_input_rules();
|
||||
self.debug_message = format!("✏️ EDITING {} - {}", field_type, rules);
|
||||
}
|
||||
|
||||
fn exit_edit_mode(&mut self) {
|
||||
self.editor.exit_edit_mode();
|
||||
let (raw, display, _, warning) = self.get_current_field_analysis();
|
||||
if let Some(warn) = warning {
|
||||
self.debug_message = format!("🔒 NORMAL - {} | ⚠️ {}", self.current_field_type(), warn);
|
||||
} else if raw != display {
|
||||
self.debug_message = format!("🔒 NORMAL - {} formatted successfully", self.current_field_type());
|
||||
} else {
|
||||
self.debug_message = "🔒 NORMAL MODE".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
|
||||
let result = self.editor.insert_char(ch);
|
||||
if result.is_ok() {
|
||||
let (raw, display, _, _) = self.get_current_field_analysis();
|
||||
if raw != display && self.validation_enabled {
|
||||
self.debug_message = format!("✏️ '{}' added - Real-time formatting active", ch);
|
||||
} else {
|
||||
self.debug_message = format!("✏️ '{}' added", ch);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// Position mapping demo
|
||||
fn show_position_mapping(&mut self) {
|
||||
if !self.has_formatter() {
|
||||
self.debug_message = "📍 No position mapping (plain text field)".to_string();
|
||||
return;
|
||||
}
|
||||
|
||||
let raw_pos = self.editor.cursor_position();
|
||||
let display_pos = self.editor.display_cursor_position();
|
||||
let raw = self.editor.current_text();
|
||||
let display = self.editor.current_display_text();
|
||||
|
||||
if raw_pos != display_pos {
|
||||
self.debug_message = format!(
|
||||
"🗺️ Position mapping: Raw[{}]='{}' ↔ Display[{}]='{}'",
|
||||
raw_pos,
|
||||
raw.chars().nth(raw_pos).unwrap_or('∅'),
|
||||
display_pos,
|
||||
display.chars().nth(display_pos).unwrap_or('∅')
|
||||
);
|
||||
} else {
|
||||
self.debug_message = format!("📍 Cursor at position {} (no mapping needed)", raw_pos);
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate remaining methods
|
||||
fn mode(&self) -> AppMode { self.editor.mode() }
|
||||
fn current_field(&self) -> usize { self.editor.current_field() }
|
||||
fn cursor_position(&self) -> usize { self.editor.cursor_position() }
|
||||
fn data_provider(&self) -> &D { self.editor.data_provider() }
|
||||
fn data_provider_mut(&mut self) -> &mut D { self.editor.data_provider_mut() }
|
||||
fn ui_state(&self) -> &canvas::EditorState { self.editor.ui_state() }
|
||||
|
||||
fn move_up(&mut self) { let _ = self.editor.move_up(); }
|
||||
fn move_down(&mut self) { let _ = self.editor.move_down(); }
|
||||
fn move_left(&mut self) { let _ = self.editor.move_left(); }
|
||||
fn move_right(&mut self) { let _ = self.editor.move_right(); }
|
||||
fn delete_backward(&mut self) -> anyhow::Result<()> { self.editor.delete_backward() }
|
||||
fn delete_forward(&mut self) -> anyhow::Result<()> { self.editor.delete_forward() }
|
||||
fn next_field(&mut self) { let _ = self.editor.next_field(); }
|
||||
fn prev_field(&mut self) { let _ = self.editor.prev_field(); }
|
||||
}
|
||||
|
||||
// Enhanced key handling
|
||||
fn handle_key_press(
|
||||
key: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
editor: &mut EnhancedDemoEditor<MultiFormatterDemoData>,
|
||||
) -> anyhow::Result<bool> {
|
||||
let mode = editor.mode();
|
||||
|
||||
// Quit
|
||||
if matches!(key, KeyCode::F(10)) ||
|
||||
(key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL)) ||
|
||||
(key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
match (mode, key, modifiers) {
|
||||
// Mode transitions
|
||||
(AppMode::ReadOnly, KeyCode::Char('i'), _) => editor.enter_edit_mode(),
|
||||
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
|
||||
editor.editor.enter_append_mode();
|
||||
editor.debug_message = format!("✏️ APPEND {} - {}", editor.current_field_type(), editor.get_input_rules());
|
||||
},
|
||||
(_, KeyCode::Esc, _) => editor.exit_edit_mode(),
|
||||
|
||||
// Enhanced demo features
|
||||
(AppMode::ReadOnly, KeyCode::Char('e'), _) => editor.cycle_example_data(),
|
||||
(AppMode::ReadOnly, KeyCode::Char('r'), _) => editor.toggle_raw_data_view(),
|
||||
(AppMode::ReadOnly, KeyCode::Char('c'), _) => editor.toggle_cursor_details(),
|
||||
(AppMode::ReadOnly, KeyCode::Char('m'), _) => editor.show_position_mapping(),
|
||||
(AppMode::ReadOnly, KeyCode::F(1), _) => editor.toggle_validation(),
|
||||
|
||||
// Movement
|
||||
(_, KeyCode::Up, _) | (AppMode::ReadOnly, KeyCode::Char('k'), _) => editor.move_up(),
|
||||
(_, KeyCode::Down, _) | (AppMode::ReadOnly, KeyCode::Char('j'), _) => editor.move_down(),
|
||||
(_, KeyCode::Left, _) | (AppMode::ReadOnly, KeyCode::Char('h'), _) => editor.move_left(),
|
||||
(_, KeyCode::Right, _) | (AppMode::ReadOnly, KeyCode::Char('l'), _) => editor.move_right(),
|
||||
(_, KeyCode::Tab, _) => editor.next_field(),
|
||||
(_, KeyCode::BackTab, _) => editor.prev_field(),
|
||||
|
||||
// Editing
|
||||
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
|
||||
editor.insert_char(c)?;
|
||||
},
|
||||
(AppMode::Edit, KeyCode::Backspace, _) => { editor.delete_backward()?; },
|
||||
(AppMode::Edit, KeyCode::Delete, _) => { editor.delete_forward()?; },
|
||||
|
||||
// Field analysis
|
||||
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
|
||||
let (raw, display, status, warning) = editor.get_current_field_analysis();
|
||||
let warning_text = warning.map(|w| format!(" ⚠️ {}", w)).unwrap_or_default();
|
||||
editor.debug_message = format!(
|
||||
"🔍 Field {}: {} | Raw: '{}' | Display: '{}'{}",
|
||||
editor.current_field() + 1, status, raw, display, warning_text
|
||||
);
|
||||
},
|
||||
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn run_app<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
mut editor: EnhancedDemoEditor<MultiFormatterDemoData>,
|
||||
) -> 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.debug_message = format!("❌ Error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ui(f: &mut Frame, editor: &EnhancedDemoEditor<MultiFormatterDemoData>) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(8), Constraint::Length(18)])
|
||||
.split(f.area());
|
||||
|
||||
render_canvas_default(f, chunks[0], &editor.editor);
|
||||
render_enhanced_status(f, chunks[1], editor);
|
||||
}
|
||||
|
||||
fn render_enhanced_status(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
editor: &EnhancedDemoEditor<MultiFormatterDemoData>,
|
||||
) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Status bar
|
||||
Constraint::Length(6), // Current field analysis
|
||||
Constraint::Length(9), // Help
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Status bar
|
||||
let mode_text = match editor.mode() {
|
||||
AppMode::Edit => "INSERT",
|
||||
AppMode::ReadOnly => "NORMAL",
|
||||
_ => "OTHER",
|
||||
};
|
||||
|
||||
let formatter_count = (0..editor.data_provider().field_count())
|
||||
.filter(|&i| editor.data_provider().validation_config(i).is_some())
|
||||
.count();
|
||||
|
||||
let status_text = format!(
|
||||
"-- {} -- {} | Formatters: {}/{} active | View: {}{}",
|
||||
mode_text,
|
||||
editor.debug_message,
|
||||
formatter_count,
|
||||
editor.data_provider().field_count(),
|
||||
if editor.show_raw_data { "RAW" } else { "DISPLAY" },
|
||||
if editor.show_cursor_details { " | CURSOR+" } else { "" }
|
||||
);
|
||||
|
||||
let status = Paragraph::new(Line::from(Span::raw(status_text)))
|
||||
.block(Block::default().borders(Borders::ALL).title("🧩 Enhanced Custom Formatter Demo"));
|
||||
|
||||
f.render_widget(status, chunks[0]);
|
||||
|
||||
// Current field analysis
|
||||
let (raw, display, status, warning) = editor.get_current_field_analysis();
|
||||
let field_name = editor.data_provider().field_name(editor.current_field());
|
||||
let field_type = editor.current_field_type();
|
||||
|
||||
let mut analysis_lines = vec![
|
||||
format!("📝 Current: {} ({})", field_name, field_type),
|
||||
format!("🔧 Status: {}", status),
|
||||
];
|
||||
|
||||
if editor.show_raw_data || editor.mode() == AppMode::Edit {
|
||||
analysis_lines.push(format!("💾 Raw Data: '{}'", raw));
|
||||
analysis_lines.push(format!("✨ Display: '{}'", display));
|
||||
} else {
|
||||
analysis_lines.push(format!("✨ User Sees: '{}'", display));
|
||||
analysis_lines.push(format!("💾 Stored As: '{}'", raw));
|
||||
}
|
||||
|
||||
if editor.show_cursor_details {
|
||||
analysis_lines.push(format!(
|
||||
"📍 Cursor: Raw[{}] → Display[{}]",
|
||||
editor.cursor_position(),
|
||||
editor.editor.display_cursor_position()
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(ref warn) = warning {
|
||||
analysis_lines.push(format!("⚠️ Warning: {}", warn));
|
||||
}
|
||||
|
||||
let analysis_color = if warning.is_some() {
|
||||
Color::Yellow
|
||||
} else if raw != display && editor.validation_enabled {
|
||||
Color::Green
|
||||
} else {
|
||||
Color::Gray
|
||||
};
|
||||
|
||||
let analysis = Paragraph::new(analysis_lines.join("\n"))
|
||||
.block(Block::default().borders(Borders::ALL).title("🔍 Field Analysis"))
|
||||
.style(Style::default().fg(analysis_color))
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
f.render_widget(analysis, chunks[1]);
|
||||
|
||||
// Enhanced help
|
||||
let help_text = match editor.mode() {
|
||||
AppMode::ReadOnly => {
|
||||
"🧩 ENHANCED CUSTOM FORMATTER DEMO\n\
|
||||
\n\
|
||||
Try these formatters:
|
||||
• PSC: 01001 → 010 01 | Phone: 1234567890 → (123) 456-7890 | Card: 1234567890123456 → 1234 5678 9012 3456
|
||||
• Date: 12012024 → 12/01/2024 | Plain: no formatting
|
||||
\n\
|
||||
Commands: i=insert, e=cycle examples, r=toggle raw/display, c=cursor details, m=position mapping\n\
|
||||
Movement: hjkl/arrows, Tab=next field, ?=analyze current field, F1=toggle formatters\n\
|
||||
Ctrl+C/F10=quit"
|
||||
}
|
||||
AppMode::Edit => {
|
||||
"✏️ INSERT MODE - Real-time formatting as you type!\n\
|
||||
\n\
|
||||
Current field rules: {}\n\
|
||||
• Raw input is authoritative (what gets stored)\n\
|
||||
• Display formatting updates in real-time (what users see)\n\
|
||||
• Cursor position is mapped between raw and display\n\
|
||||
\n\
|
||||
Esc=normal mode, arrows=navigate, Backspace/Del=delete"
|
||||
}
|
||||
_ => "🧩 Enhanced Custom Formatter Demo"
|
||||
};
|
||||
|
||||
let formatted_help = if editor.mode() == AppMode::Edit {
|
||||
help_text.replace("{}", editor.get_input_rules())
|
||||
} else {
|
||||
help_text.to_string()
|
||||
};
|
||||
|
||||
let help = Paragraph::new(formatted_help)
|
||||
.block(Block::default().borders(Borders::ALL).title("🚀 Enhanced Features & Commands"))
|
||||
.style(Style::default().fg(Color::Gray))
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
f.render_widget(help, chunks[2]);
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("🧩 Enhanced Canvas Custom Formatter Demo (Feature 4)");
|
||||
println!("✅ validation feature: ENABLED");
|
||||
println!("✅ gui feature: ENABLED");
|
||||
println!("🧩 Enhanced features:");
|
||||
println!(" • 5 different custom formatters with edge cases");
|
||||
println!(" • Real-time format preview and validation");
|
||||
println!(" • Advanced cursor position mapping");
|
||||
println!(" • Comprehensive error handling and warnings");
|
||||
println!(" • Raw vs formatted data separation demos");
|
||||
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 = MultiFormatterDemoData::new();
|
||||
let editor = EnhancedDemoEditor::new(data);
|
||||
|
||||
let res = run_app(&mut terminal, editor);
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(err) = res {
|
||||
println!("{:?}", err);
|
||||
}
|
||||
|
||||
println!("🧩 Enhanced custom formatter demo completed!");
|
||||
println!("🏆 You experienced comprehensive custom formatting with:");
|
||||
println!(" • Multiple formatter types (PSC, Phone, Credit Card, Date)");
|
||||
println!(" • Edge case handling (incomplete, invalid, overflow)");
|
||||
println!(" • Real-time format preview and cursor mapping");
|
||||
println!(" • Clear separation between raw business data and display formatting");
|
||||
Ok(())
|
||||
}
|
||||
1089
canvas/examples/validation_5.rs
Normal file
1089
canvas/examples/validation_5.rs
Normal file
File diff suppressed because it is too large
Load Diff
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);
|
||||
}
|
||||
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,
|
||||
|
||||
// Suggestions actions
|
||||
TriggerSuggestions,
|
||||
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::TriggerSuggestions => "trigger suggestions",
|
||||
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 suggestions-related actions
|
||||
pub fn suggestions_actions() -> Vec<CanvasAction> {
|
||||
vec![
|
||||
Self::TriggerSuggestions,
|
||||
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(())
|
||||
}
|
||||
}
|
||||
472
canvas/src/canvas/gui.rs
Normal file
472
canvas/src/canvas/gui.rs
Normal file
@@ -0,0 +1,472 @@
|
||||
// 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 suggestions rendering here
|
||||
/// 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> {
|
||||
// Convert SelectionState to HighlightState
|
||||
let highlight_state = convert_selection_to_highlight(editor.ui_state().selection_state());
|
||||
render_canvas_with_highlight(f, area, editor, theme, &highlight_state)
|
||||
}
|
||||
|
||||
/// Render canvas with explicit highlight state (for advanced use)
|
||||
#[cfg(feature = "gui")]
|
||||
pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
editor: &FormEditor<D>,
|
||||
theme: &T,
|
||||
highlight_state: &HighlightState,
|
||||
) -> 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));
|
||||
|
||||
// Use editor-provided effective display text per field (Feature 4/mask aware)
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
inputs.push(editor.display_text_for_field(i));
|
||||
}
|
||||
#[cfg(not(feature = "validation"))]
|
||||
{
|
||||
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);
|
||||
|
||||
render_canvas_fields(
|
||||
f,
|
||||
area,
|
||||
&fields,
|
||||
¤t_field_idx,
|
||||
&inputs,
|
||||
theme,
|
||||
is_edit_mode,
|
||||
highlight_state,
|
||||
editor.display_cursor_position(), // Use display cursor position for masks
|
||||
false, // TODO: track unsaved changes in editor
|
||||
|i| {
|
||||
// Get display value for field i using editor logic (Feature 4 + masks)
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
editor.display_text_for_field(i)
|
||||
}
|
||||
#[cfg(not(feature = "validation"))]
|
||||
{
|
||||
data_provider.field_value(i).to_string()
|
||||
}
|
||||
},
|
||||
|i| {
|
||||
// Check if field has display override (custom formatter or mask)
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
editor.ui_state().validation_state().get_field_config(i)
|
||||
.map(|cfg| {
|
||||
// Formatter takes precedence; if present, it's a display override
|
||||
#[allow(unused_mut)]
|
||||
let mut has_override = false;
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
has_override = cfg.custom_formatter.is_some();
|
||||
}
|
||||
has_override || cfg.display_mask.is_some()
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
#[cfg(not(feature = "validation"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Convert SelectionState to HighlightState for rendering
|
||||
#[cfg(feature = "gui")]
|
||||
fn convert_selection_to_highlight(selection: &crate::canvas::state::SelectionState) -> HighlightState {
|
||||
use crate::canvas::state::SelectionState;
|
||||
|
||||
match selection {
|
||||
SelectionState::None => HighlightState::Off,
|
||||
SelectionState::Characterwise { anchor } => HighlightState::Characterwise { anchor: *anchor },
|
||||
SelectionState::Linewise { anchor_field } => HighlightState::Linewise { anchor_line: *anchor_field },
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
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 - PROPER VIM-LIKE VERSION
|
||||
#[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);
|
||||
|
||||
// Vim-like styling:
|
||||
// - Selected text: contrasting color + background (like vim visual selection)
|
||||
// - All other text: normal color (no special colors for active fields, etc.)
|
||||
let highlight_style = Style::default()
|
||||
.fg(theme.highlight()) // ✅ Contrasting text color for selected text
|
||||
.bg(theme.highlight_bg()) // ✅ Background for selected text
|
||||
.add_modifier(Modifier::BOLD);
|
||||
|
||||
let normal_style = Style::default().fg(theme.fg()); // ✅ Normal text color everywhere else
|
||||
|
||||
if field_index >= start_field && field_index <= end_field {
|
||||
if start_field == end_field {
|
||||
// Single field selection
|
||||
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), // Normal text color
|
||||
Span::styled(highlighted, highlight_style), // Contrasting color + background
|
||||
Span::styled(after, normal_style), // Normal text color
|
||||
])
|
||||
} else {
|
||||
// Multi-field selection
|
||||
if field_index == anchor_field {
|
||||
if anchor_field < *current_field_idx {
|
||||
let clamped_start = anchor_char.min(text_len);
|
||||
let before: String = text.chars().take(clamped_start).collect();
|
||||
let highlighted: String = text.chars().skip(clamped_start).collect();
|
||||
|
||||
Line::from(vec![
|
||||
Span::styled(before, normal_style),
|
||||
Span::styled(highlighted, highlight_style),
|
||||
])
|
||||
} else {
|
||||
let clamped_end = anchor_char.min(text_len);
|
||||
let highlighted: String = text.chars().take(clamped_end + 1).collect();
|
||||
let after: String = text.chars().skip(clamped_end + 1).collect();
|
||||
|
||||
Line::from(vec![
|
||||
Span::styled(highlighted, highlight_style),
|
||||
Span::styled(after, normal_style),
|
||||
])
|
||||
}
|
||||
} else if field_index == *current_field_idx {
|
||||
if anchor_field < *current_field_idx {
|
||||
let clamped_end = current_cursor_pos.min(text_len);
|
||||
let highlighted: String = text.chars().take(clamped_end + 1).collect();
|
||||
let after: String = text.chars().skip(clamped_end + 1).collect();
|
||||
|
||||
Line::from(vec![
|
||||
Span::styled(highlighted, highlight_style),
|
||||
Span::styled(after, normal_style),
|
||||
])
|
||||
} else {
|
||||
let clamped_start = current_cursor_pos.min(text_len);
|
||||
let before: String = text.chars().take(clamped_start).collect();
|
||||
let highlighted: String = text.chars().skip(clamped_start).collect();
|
||||
|
||||
Line::from(vec![
|
||||
Span::styled(before, normal_style),
|
||||
Span::styled(highlighted, highlight_style),
|
||||
])
|
||||
}
|
||||
} else {
|
||||
// Middle field: highlight entire field
|
||||
Line::from(Span::styled(text, highlight_style))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Outside selection: always normal text color (no special active field color)
|
||||
Line::from(Span::styled(text, normal_style))
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply linewise highlighting - PROPER VIM-LIKE VERSION
|
||||
#[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);
|
||||
|
||||
// Vim-like styling:
|
||||
// - Selected lines: contrasting text color + background
|
||||
// - All other lines: normal text color (no special active field color)
|
||||
let highlight_style = Style::default()
|
||||
.fg(theme.highlight()) // ✅ Contrasting text color for selected text
|
||||
.bg(theme.highlight_bg()) // ✅ Background for selected text
|
||||
.add_modifier(Modifier::BOLD);
|
||||
|
||||
let normal_style = Style::default().fg(theme.fg()); // ✅ Normal text color everywhere else
|
||||
|
||||
if field_index >= start_field && field_index <= end_field {
|
||||
// Selected line: contrasting text color + background
|
||||
Line::from(Span::styled(text, highlight_style))
|
||||
} else {
|
||||
// Normal line: normal text color (no special active field color)
|
||||
Line::from(Span::styled(text, normal_style))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
) {
|
||||
// BUG FIX: Use the correct display cursor position, not end of text
|
||||
let cursor_x = field_rect.x + current_cursor_pos as u16;
|
||||
let cursor_y = field_rect.y;
|
||||
|
||||
// SAFETY: Ensure cursor doesn't go beyond field bounds
|
||||
let max_cursor_x = field_rect.x + field_rect.width.saturating_sub(1);
|
||||
let safe_cursor_x = cursor_x.min(max_cursor_x);
|
||||
|
||||
f.set_cursor_position((safe_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};
|
||||
150
canvas/src/canvas/state.rs
Normal file
150
canvas/src/canvas/state.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
// 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,
|
||||
|
||||
// Suggestions dropdown state
|
||||
pub(crate) suggestions: SuggestionsUIState,
|
||||
|
||||
// Selection state (for vim visual mode)
|
||||
pub(crate) selection: SelectionState,
|
||||
|
||||
// Validation state (only available with validation feature)
|
||||
#[cfg(feature = "validation")]
|
||||
pub(crate) validation: crate::validation::ValidationState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SuggestionsUIState {
|
||||
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,
|
||||
suggestions: SuggestionsUIState {
|
||||
is_active: false,
|
||||
is_loading: false,
|
||||
selected_index: None,
|
||||
active_field: None,
|
||||
},
|
||||
selection: SelectionState::None,
|
||||
#[cfg(feature = "validation")]
|
||||
validation: crate::validation::ValidationState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// 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 {
|
||||
self.ideal_cursor_column
|
||||
}
|
||||
|
||||
/// Get current mode (for user's business logic)
|
||||
pub fn mode(&self) -> AppMode {
|
||||
self.current_mode
|
||||
}
|
||||
|
||||
/// Check if suggestions dropdown is active (for user's business logic)
|
||||
pub fn is_suggestions_active(&self) -> bool {
|
||||
self.suggestions.is_active
|
||||
}
|
||||
|
||||
/// Check if suggestions dropdown is loading (for user's business logic)
|
||||
pub fn is_suggestions_loading(&self) -> bool {
|
||||
self.suggestions.is_loading
|
||||
}
|
||||
|
||||
/// Get selection state (for user's business logic)
|
||||
pub fn selection_state(&self) -> &SelectionState {
|
||||
&self.selection
|
||||
}
|
||||
|
||||
/// Get validation state (for user's business logic)
|
||||
/// Only available when the 'validation' feature is enabled
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn validation_state(&self) -> &crate::validation::ValidationState {
|
||||
&self.validation
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// 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_suggestions(&mut self, field_index: usize) {
|
||||
self.suggestions.is_active = true;
|
||||
self.suggestions.is_loading = true;
|
||||
self.suggestions.active_field = Some(field_index);
|
||||
self.suggestions.selected_index = None;
|
||||
}
|
||||
|
||||
pub(crate) fn deactivate_suggestions(&mut self) {
|
||||
self.suggestions.is_active = false;
|
||||
self.suggestions.is_loading = false;
|
||||
self.suggestions.active_field = None;
|
||||
self.suggestions.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
|
||||
}
|
||||
}
|
||||
51
canvas/src/data_provider.rs
Normal file
51
canvas/src/data_provider.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
// 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 suggestions (optional)
|
||||
fn supports_suggestions(&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
|
||||
}
|
||||
|
||||
/// Get validation configuration for a field (optional)
|
||||
/// Only available when the 'validation' feature is enabled
|
||||
#[cfg(feature = "validation")]
|
||||
fn validation_config(&self, _field_index: usize) -> Option<crate::validation::ValidationConfig> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional: User implements this for suggestions data
|
||||
#[async_trait]
|
||||
pub trait SuggestionsProvider {
|
||||
/// Fetch 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,
|
||||
}
|
||||
1203
canvas/src/editor.rs
Normal file
1203
canvas/src/editor.rs
Normal file
File diff suppressed because it is too large
Load Diff
55
canvas/src/lib.rs
Normal file
55
canvas/src/lib.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
// src/lib.rs
|
||||
|
||||
pub mod canvas;
|
||||
pub mod editor;
|
||||
pub mod data_provider;
|
||||
|
||||
// Only include suggestions module if feature is enabled
|
||||
#[cfg(feature = "suggestions")]
|
||||
pub mod suggestions;
|
||||
|
||||
// Only include validation module if feature is enabled
|
||||
#[cfg(feature = "validation")]
|
||||
pub mod validation;
|
||||
|
||||
#[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, SuggestionsProvider, 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};
|
||||
|
||||
// Validation exports (only when validation feature is enabled)
|
||||
#[cfg(feature = "validation")]
|
||||
pub use validation::{
|
||||
ValidationConfig, ValidationResult, ValidationError,
|
||||
CharacterLimits, ValidationConfigBuilder, ValidationState,
|
||||
ValidationSummary, PatternFilters, PositionFilter, PositionRange, CharacterFilter,
|
||||
DisplayMask, // Simple display mask instead of complex ReservedCharacters
|
||||
// Feature 4: custom formatting exports
|
||||
CustomFormatter, FormattingResult, PositionMapper, DefaultPositionMapper,
|
||||
};
|
||||
|
||||
// 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 = "suggestions"))]
|
||||
pub use suggestions::gui::render_suggestions_dropdown;
|
||||
194
canvas/src/suggestions/gui.rs
Normal file
194
canvas/src/suggestions/gui.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
// src/suggestions/gui.rs
|
||||
//! Suggestions dropdown GUI (not inline autocomplete) 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 suggestions dropdown for FormEditor - call this AFTER rendering canvas
|
||||
#[cfg(feature = "gui")]
|
||||
pub fn render_suggestions_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_suggestions_active() {
|
||||
return;
|
||||
}
|
||||
|
||||
if ui_state.suggestions.is_loading {
|
||||
render_loading_indicator(f, frame_area, input_rect, theme);
|
||||
} else if !editor.suggestions().is_empty() {
|
||||
render_suggestions_dropdown_list(f, frame_area, input_rect, theme, editor.suggestions(), ui_state.suggestions.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_list<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/suggestions/mod.rs
Normal file
12
canvas/src/suggestions/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
// src/suggestions/mod.rs
|
||||
|
||||
pub mod state;
|
||||
#[cfg(feature = "gui")]
|
||||
pub mod gui;
|
||||
|
||||
// Re-export the main suggestion types
|
||||
pub use state::{SuggestionsProvider, SuggestionItem};
|
||||
|
||||
// Re-export GUI functions if available
|
||||
#[cfg(feature = "gui")]
|
||||
pub use gui::render_suggestions_dropdown;
|
||||
5
canvas/src/suggestions/state.rs
Normal file
5
canvas/src/suggestions/state.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
// src/suggestions/state.rs
|
||||
//! Suggestions provider types (for dropdown suggestions, not real inline autocomplete)
|
||||
|
||||
// Re-export the main types from data_provider
|
||||
pub use crate::data_provider::{SuggestionsProvider, SuggestionItem};
|
||||
447
canvas/src/validation/config.rs
Normal file
447
canvas/src/validation/config.rs
Normal file
@@ -0,0 +1,447 @@
|
||||
// src/validation/config.rs
|
||||
//! Validation configuration types and builders
|
||||
|
||||
use crate::validation::{CharacterLimits, PatternFilters, DisplayMask};
|
||||
#[cfg(feature = "validation")]
|
||||
use crate::validation::{CustomFormatter, FormattingResult, PositionMapper};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Main validation configuration for a field
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ValidationConfig {
|
||||
/// Character limit configuration
|
||||
pub character_limits: Option<CharacterLimits>,
|
||||
|
||||
/// Pattern filtering configuration
|
||||
pub pattern_filters: Option<PatternFilters>,
|
||||
|
||||
/// User-defined display mask for visual formatting
|
||||
pub display_mask: Option<DisplayMask>,
|
||||
|
||||
/// Optional: user-provided custom formatter (feature 4)
|
||||
#[cfg(feature = "validation")]
|
||||
pub custom_formatter: Option<Arc<dyn CustomFormatter + Send + Sync>>,
|
||||
|
||||
/// Enable external validation indicator UI (feature 5)
|
||||
pub external_validation_enabled: bool,
|
||||
|
||||
/// Future: External validation
|
||||
pub external_validation: Option<()>, // Placeholder for future implementation
|
||||
}
|
||||
|
||||
/// Manual Debug to avoid requiring Debug on dyn CustomFormatter
|
||||
impl std::fmt::Debug for ValidationConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let mut ds = f.debug_struct("ValidationConfig");
|
||||
ds.field("character_limits", &self.character_limits)
|
||||
.field("pattern_filters", &self.pattern_filters)
|
||||
.field("display_mask", &self.display_mask)
|
||||
// Do not print the formatter itself to avoid requiring Debug
|
||||
.field(
|
||||
"custom_formatter",
|
||||
&{
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
if self.custom_formatter.is_some() { &"Some(<CustomFormatter>)" } else { &"None" }
|
||||
}
|
||||
#[cfg(not(feature = "validation"))]
|
||||
{
|
||||
&"N/A"
|
||||
}
|
||||
},
|
||||
)
|
||||
.field("external_validation_enabled", &self.external_validation_enabled)
|
||||
.field("external_validation", &self.external_validation)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ FIXED: Move function from struct definition to impl block
|
||||
impl ValidationConfig {
|
||||
/// If a custom formatter is configured, run it and return the formatted text,
|
||||
/// the position mapper and an optional warning message.
|
||||
///
|
||||
/// Returns None when no custom formatter is configured.
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn run_custom_formatter(
|
||||
&self,
|
||||
raw: &str,
|
||||
) -> Option<(String, Arc<dyn PositionMapper>, Option<String>)> {
|
||||
let formatter = self.custom_formatter.as_ref()?;
|
||||
match formatter.format(raw) {
|
||||
FormattingResult::Success { formatted, mapper } => {
|
||||
Some((formatted, mapper, None))
|
||||
}
|
||||
FormattingResult::Warning { formatted, message, mapper } => {
|
||||
Some((formatted, mapper, Some(message)))
|
||||
}
|
||||
FormattingResult::Error { .. } => None, // Fall back to raw display
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new empty validation configuration
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Create a configuration with just character limits
|
||||
pub fn with_max_length(max_length: usize) -> Self {
|
||||
ValidationConfigBuilder::new()
|
||||
.with_max_length(max_length)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Create a configuration with pattern filters
|
||||
pub fn with_patterns(patterns: PatternFilters) -> Self {
|
||||
ValidationConfigBuilder::new()
|
||||
.with_pattern_filters(patterns)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Create a configuration with user-defined display mask
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use canvas::{ValidationConfig, DisplayMask};
|
||||
///
|
||||
/// let phone_mask = DisplayMask::new("(###) ###-####", '#');
|
||||
/// let config = ValidationConfig::with_mask(phone_mask);
|
||||
/// ```
|
||||
pub fn with_mask(mask: DisplayMask) -> Self {
|
||||
ValidationConfigBuilder::new()
|
||||
.with_display_mask(mask)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Validate a character insertion at a specific position (raw text space).
|
||||
///
|
||||
/// Note: Display masks are visual-only and do not participate in validation.
|
||||
/// Editor logic is responsible for skipping mask separator positions; here we
|
||||
/// only validate the raw insertion against limits and patterns.
|
||||
pub fn validate_char_insertion(
|
||||
&self,
|
||||
current_text: &str,
|
||||
position: usize,
|
||||
character: char,
|
||||
) -> ValidationResult {
|
||||
// Character limits validation
|
||||
if let Some(ref limits) = self.character_limits {
|
||||
// ✅ FIXED: Explicit return type annotation
|
||||
if let Some(result) = limits.validate_insertion(current_text, position, character) {
|
||||
if !result.is_acceptable() {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern filters validation
|
||||
if let Some(ref patterns) = self.pattern_filters {
|
||||
// ✅ FIXED: Explicit error handling
|
||||
if let Err(message) = patterns.validate_char_at_position(position, character) {
|
||||
return ValidationResult::error(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Future: Add other validation types here
|
||||
|
||||
ValidationResult::Valid
|
||||
}
|
||||
|
||||
/// Validate the current text content (raw text space)
|
||||
pub fn validate_content(&self, text: &str) -> ValidationResult {
|
||||
// Character limits validation
|
||||
if let Some(ref limits) = self.character_limits {
|
||||
// ✅ FIXED: Explicit return type annotation
|
||||
if let Some(result) = limits.validate_content(text) {
|
||||
if !result.is_acceptable() {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern filters validation
|
||||
if let Some(ref patterns) = self.pattern_filters {
|
||||
// ✅ FIXED: Explicit error handling
|
||||
if let Err(message) = patterns.validate_text(text) {
|
||||
return ValidationResult::error(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Future: Add other validation types here
|
||||
|
||||
ValidationResult::Valid
|
||||
}
|
||||
|
||||
/// Check if any validation rules are configured
|
||||
pub fn has_validation(&self) -> bool {
|
||||
self.character_limits.is_some()
|
||||
|| self.pattern_filters.is_some()
|
||||
|| self.display_mask.is_some()
|
||||
|| {
|
||||
#[cfg(feature = "validation")]
|
||||
{ self.custom_formatter.is_some() }
|
||||
#[cfg(not(feature = "validation"))]
|
||||
{ false }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allows_field_switch(&self, text: &str) -> bool {
|
||||
// Character limits validation
|
||||
if let Some(ref limits) = self.character_limits {
|
||||
// ✅ FIXED: Direct boolean return
|
||||
if !limits.allows_field_switch(text) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Future: Add other validation types here
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Get reason why field switching is blocked (if any)
|
||||
pub fn field_switch_block_reason(&self, text: &str) -> Option<String> {
|
||||
// Character limits validation
|
||||
if let Some(ref limits) = self.character_limits {
|
||||
// ✅ FIXED: Direct option return
|
||||
if let Some(reason) = limits.field_switch_block_reason(text) {
|
||||
return Some(reason);
|
||||
}
|
||||
}
|
||||
|
||||
// Future: Add other validation types here
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for creating validation configurations
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ValidationConfigBuilder {
|
||||
config: ValidationConfig,
|
||||
}
|
||||
|
||||
impl ValidationConfigBuilder {
|
||||
/// Create a new validation config builder
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Set character limits for the field
|
||||
pub fn with_character_limits(mut self, limits: CharacterLimits) -> Self {
|
||||
self.config.character_limits = Some(limits);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set pattern filters for the field
|
||||
pub fn with_pattern_filters(mut self, filters: PatternFilters) -> Self {
|
||||
self.config.pattern_filters = Some(filters);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set user-defined display mask for visual formatting
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use canvas::{ValidationConfigBuilder, DisplayMask};
|
||||
///
|
||||
/// // Phone number with dynamic formatting
|
||||
/// let phone_mask = DisplayMask::new("(###) ###-####", '#');
|
||||
/// let config = ValidationConfigBuilder::new()
|
||||
/// .with_display_mask(phone_mask)
|
||||
/// .build();
|
||||
///
|
||||
/// // Date with template formatting
|
||||
/// let date_mask = DisplayMask::new("##/##/####", '#')
|
||||
/// .with_template('_');
|
||||
/// let config = ValidationConfigBuilder::new()
|
||||
/// .with_display_mask(date_mask)
|
||||
/// .build();
|
||||
///
|
||||
/// // Custom business format
|
||||
/// let employee_id = DisplayMask::new("EMP-####-##", '#')
|
||||
/// .with_template('•');
|
||||
/// let config = ValidationConfigBuilder::new()
|
||||
/// .with_display_mask(employee_id)
|
||||
/// .with_max_length(6) // Only store the 6 digits
|
||||
/// .build();
|
||||
/// ```
|
||||
pub fn with_display_mask(mut self, mask: DisplayMask) -> Self {
|
||||
self.config.display_mask = Some(mask);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set optional custom formatter (feature 4)
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn with_custom_formatter<F>(mut self, formatter: Arc<F>) -> Self
|
||||
where
|
||||
F: CustomFormatter + Send + Sync + 'static,
|
||||
{
|
||||
self.config.custom_formatter = Some(formatter);
|
||||
// When custom formatter is present, it takes precedence over display mask.
|
||||
self.config.display_mask = None;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set maximum number of characters (convenience method)
|
||||
pub fn with_max_length(mut self, max_length: usize) -> Self {
|
||||
self.config.character_limits = Some(CharacterLimits::new(max_length));
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable or disable external validation indicator UI (feature 5)
|
||||
pub fn with_external_validation_enabled(mut self, enabled: bool) -> Self {
|
||||
self.config.external_validation_enabled = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the final validation configuration
|
||||
pub fn build(self) -> ValidationConfig {
|
||||
self.config
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a validation operation
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ValidationResult {
|
||||
/// Validation passed
|
||||
Valid,
|
||||
|
||||
/// Validation failed with warning (input still accepted)
|
||||
Warning { message: String },
|
||||
|
||||
/// Validation failed with error (input rejected)
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
impl ValidationResult {
|
||||
/// Check if the validation result allows the input
|
||||
pub fn is_acceptable(&self) -> bool {
|
||||
matches!(self, ValidationResult::Valid | ValidationResult::Warning { .. })
|
||||
}
|
||||
|
||||
/// Check if the validation result is an error
|
||||
pub fn is_error(&self) -> bool {
|
||||
matches!(self, ValidationResult::Error { .. })
|
||||
}
|
||||
|
||||
/// Get the message if there is one
|
||||
pub fn message(&self) -> Option<&str> {
|
||||
match self {
|
||||
ValidationResult::Valid => None,
|
||||
ValidationResult::Warning { message } => Some(message),
|
||||
ValidationResult::Error { message } => Some(message),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a warning result
|
||||
pub fn warning(message: impl Into<String>) -> Self {
|
||||
ValidationResult::Warning { message: message.into() }
|
||||
}
|
||||
|
||||
/// Create an error result
|
||||
pub fn error(message: impl Into<String>) -> Self {
|
||||
ValidationResult::Error { message: message.into() }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_config_with_user_defined_mask() {
|
||||
// User creates their own phone mask
|
||||
let phone_mask = DisplayMask::new("(###) ###-####", '#');
|
||||
let config = ValidationConfig::with_mask(phone_mask);
|
||||
|
||||
// has_validation should be true because mask is configured
|
||||
assert!(config.has_validation());
|
||||
|
||||
// Display mask is visual only; validation still focuses on raw content
|
||||
let result = config.validate_char_insertion("123", 3, '4');
|
||||
assert!(result.is_acceptable());
|
||||
|
||||
// Content validation unaffected by mask
|
||||
let result = config.validate_content("1234567890");
|
||||
assert!(result.is_acceptable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validation_config_builder() {
|
||||
let config = ValidationConfigBuilder::new()
|
||||
.with_max_length(10)
|
||||
.build();
|
||||
|
||||
assert!(config.character_limits.is_some());
|
||||
assert_eq!(config.character_limits.unwrap().max_length(), Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_builder_with_user_mask() {
|
||||
// User defines custom format
|
||||
let custom_mask = DisplayMask::new("##-##-##", '#').with_template('_');
|
||||
let config = ValidationConfigBuilder::new()
|
||||
.with_display_mask(custom_mask)
|
||||
.with_max_length(6)
|
||||
.build();
|
||||
|
||||
assert!(config.has_validation());
|
||||
assert!(config.character_limits.is_some());
|
||||
assert!(config.display_mask.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validation_result() {
|
||||
let valid = ValidationResult::Valid;
|
||||
assert!(valid.is_acceptable());
|
||||
assert!(!valid.is_error());
|
||||
assert_eq!(valid.message(), None);
|
||||
|
||||
let warning = ValidationResult::warning("Too long");
|
||||
assert!(warning.is_acceptable());
|
||||
assert!(!warning.is_error());
|
||||
assert_eq!(warning.message(), Some("Too long"));
|
||||
|
||||
let error = ValidationResult::error("Invalid");
|
||||
assert!(!error.is_acceptable());
|
||||
assert!(error.is_error());
|
||||
assert_eq!(error.message(), Some("Invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_with_max_length() {
|
||||
let config = ValidationConfig::with_max_length(5);
|
||||
assert!(config.has_validation());
|
||||
|
||||
// Test valid insertion
|
||||
let result = config.validate_char_insertion("test", 4, 'x');
|
||||
assert!(result.is_acceptable());
|
||||
|
||||
// Test invalid insertion (would exceed limit)
|
||||
let result = config.validate_char_insertion("tests", 5, 'x');
|
||||
assert!(!result.is_acceptable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_with_patterns() {
|
||||
use crate::validation::{PatternFilters, PositionFilter, PositionRange, CharacterFilter};
|
||||
|
||||
let patterns = PatternFilters::new()
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Range(0, 1),
|
||||
CharacterFilter::Alphabetic,
|
||||
));
|
||||
|
||||
let config = ValidationConfig::with_patterns(patterns);
|
||||
assert!(config.has_validation());
|
||||
|
||||
// Test valid pattern insertion
|
||||
let result = config.validate_char_insertion("", 0, 'A');
|
||||
assert!(result.is_acceptable());
|
||||
|
||||
// Test invalid pattern insertion
|
||||
let result = config.validate_char_insertion("", 0, '1');
|
||||
assert!(!result.is_acceptable());
|
||||
}
|
||||
}
|
||||
217
canvas/src/validation/formatting.rs
Normal file
217
canvas/src/validation/formatting.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
/* canvas/src/validation/formatting.rs
|
||||
Add new formatting module with CustomFormatter, PositionMapper, DefaultPositionMapper, and FormattingResult
|
||||
*/
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Bidirectional mapping between raw input positions and formatted display positions.
|
||||
///
|
||||
/// The library uses this to keep cursor/selection behavior intuitive when the UI
|
||||
/// shows a formatted transformation (e.g., "01001" -> "010 01") while the editor
|
||||
/// still stores raw text.
|
||||
pub trait PositionMapper: Send + Sync {
|
||||
/// Map a raw cursor position to a formatted cursor position.
|
||||
///
|
||||
/// raw_pos is an index into the raw text (0..=raw.len() in char positions).
|
||||
/// Implementations should return a position within 0..=formatted.len() (in char positions).
|
||||
fn raw_to_formatted(&self, raw: &str, formatted: &str, raw_pos: usize) -> usize;
|
||||
|
||||
/// Map a formatted cursor position to a raw cursor position.
|
||||
///
|
||||
/// formatted_pos is an index into the formatted text (0..=formatted.len()).
|
||||
/// Implementations should return a position within 0..=raw.len() (in char positions).
|
||||
fn formatted_to_raw(&self, raw: &str, formatted: &str, formatted_pos: usize) -> usize;
|
||||
}
|
||||
|
||||
/// A reasonable default mapper that works for "insert separators" style formatting,
|
||||
/// such as grouping digits or adding dashes/spaces.
|
||||
///
|
||||
/// Heuristic:
|
||||
/// - Treat letters and digits (is_alphanumeric) in the formatted string as user-entered characters
|
||||
/// corresponding to raw characters, in order.
|
||||
/// - Treat any non-alphanumeric characters as purely visual separators.
|
||||
/// - Raw positions are mapped by counting alphanumeric characters in the formatted string.
|
||||
/// - If the formatted contains fewer alphanumeric characters than the raw (shouldn't happen
|
||||
/// for plain grouping), we cap at the end of the formatted string.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct DefaultPositionMapper;
|
||||
|
||||
impl PositionMapper for DefaultPositionMapper {
|
||||
fn raw_to_formatted(&self, raw: &str, formatted: &str, raw_pos: usize) -> usize {
|
||||
// Convert to char indices for correctness in presence of UTF-8
|
||||
let raw_len = raw.chars().count();
|
||||
let clamped_raw_pos = raw_pos.min(raw_len);
|
||||
|
||||
// Count alphanumerics in formatted, find the index where we've seen `clamped_raw_pos` of them.
|
||||
let mut seen_user_chars = 0usize;
|
||||
for (idx, ch) in formatted.char_indices() {
|
||||
if ch.is_alphanumeric() {
|
||||
if seen_user_chars == clamped_raw_pos {
|
||||
// Cursor is positioned before this user character in the formatted view
|
||||
return idx;
|
||||
}
|
||||
seen_user_chars += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// If we consumed all alphanumeric chars and still haven't reached clamped_raw_pos,
|
||||
// place cursor at the end of the formatted string.
|
||||
formatted.len()
|
||||
}
|
||||
|
||||
fn formatted_to_raw(&self, raw: &str, formatted: &str, formatted_pos: usize) -> usize {
|
||||
let clamped_fmt_pos = formatted_pos.min(formatted.len());
|
||||
|
||||
// Count alphanumerics in formatted up to formatted_pos.
|
||||
let mut seen_user_chars = 0usize;
|
||||
for (idx, ch) in formatted.char_indices() {
|
||||
if idx >= clamped_fmt_pos {
|
||||
break;
|
||||
}
|
||||
if ch.is_alphanumeric() {
|
||||
seen_user_chars += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Map to raw position by clamping to raw char count
|
||||
let raw_len = raw.chars().count();
|
||||
seen_user_chars.min(raw_len)
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of invoking a custom formatter on the raw input.
|
||||
///
|
||||
/// Success variants carry the formatted string and a position mapper to translate
|
||||
/// between raw and formatted cursor positions. If you don't provide a custom mapper,
|
||||
/// the library will fall back to DefaultPositionMapper.
|
||||
pub enum FormattingResult {
|
||||
/// Successfully produced a formatted display value and a position mapper.
|
||||
Success {
|
||||
formatted: String,
|
||||
/// Mapper to convert cursor positions between raw and formatted representations.
|
||||
mapper: Arc<dyn PositionMapper>,
|
||||
},
|
||||
/// Successfully produced a formatted value, but with a non-fatal warning message
|
||||
/// that can be shown in the UI (e.g., "incomplete value").
|
||||
Warning {
|
||||
formatted: String,
|
||||
message: String,
|
||||
mapper: Arc<dyn PositionMapper>,
|
||||
},
|
||||
/// Failed to produce a formatted display. The library will typically fall back to raw.
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl FormattingResult {
|
||||
/// Convenience to create a success result using the default mapper.
|
||||
pub fn success(formatted: impl Into<String>) -> Self {
|
||||
FormattingResult::Success {
|
||||
formatted: formatted.into(),
|
||||
mapper: Arc::new(DefaultPositionMapper::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience to create a warning result using the default mapper.
|
||||
pub fn warning(formatted: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
FormattingResult::Warning {
|
||||
formatted: formatted.into(),
|
||||
message: message.into(),
|
||||
mapper: Arc::new(DefaultPositionMapper::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience to create a success result with a custom mapper.
|
||||
pub fn success_with_mapper(
|
||||
formatted: impl Into<String>,
|
||||
mapper: Arc<dyn PositionMapper>,
|
||||
) -> Self {
|
||||
FormattingResult::Success {
|
||||
formatted: formatted.into(),
|
||||
mapper,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience to create a warning result with a custom mapper.
|
||||
pub fn warning_with_mapper(
|
||||
formatted: impl Into<String>,
|
||||
message: impl Into<String>,
|
||||
mapper: Arc<dyn PositionMapper>,
|
||||
) -> Self {
|
||||
FormattingResult::Warning {
|
||||
formatted: formatted.into(),
|
||||
message: message.into(),
|
||||
mapper,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience to create an error result.
|
||||
pub fn error(message: impl Into<String>) -> Self {
|
||||
FormattingResult::Error {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A user-implemented formatter that turns raw input into a formatted display string,
|
||||
/// optionally providing a custom cursor position mapper.
|
||||
///
|
||||
/// Notes:
|
||||
/// - The library will keep raw input authoritative for editing and validation.
|
||||
/// - The formatted value is only used for display.
|
||||
/// - If formatting fails, return Error; the library will show the raw value.
|
||||
/// - For common grouping (spaces/dashes), you can return Success/Warning and rely
|
||||
/// on DefaultPositionMapper, or provide your own mapper for advanced cases
|
||||
/// (reordering, compression, locale-specific rules, etc.).
|
||||
pub trait CustomFormatter: Send + Sync {
|
||||
fn format(&self, raw: &str) -> FormattingResult;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct GroupEvery3;
|
||||
impl CustomFormatter for GroupEvery3 {
|
||||
fn format(&self, raw: &str) -> FormattingResult {
|
||||
let mut out = String::new();
|
||||
for (i, ch) in raw.chars().enumerate() {
|
||||
if i > 0 && i % 3 == 0 {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
FormattingResult::success(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_mapper_roundtrip_basic() {
|
||||
let mapper = DefaultPositionMapper::default();
|
||||
let raw = "01001";
|
||||
let formatted = "010 01";
|
||||
|
||||
// raw_to_formatted monotonicity and bounds
|
||||
for rp in 0..=raw.chars().count() {
|
||||
let fp = mapper.raw_to_formatted(raw, formatted, rp);
|
||||
assert!(fp <= formatted.len());
|
||||
}
|
||||
|
||||
// formatted_to_raw bounds
|
||||
for fp in 0..=formatted.len() {
|
||||
let rp = mapper.formatted_to_raw(raw, formatted, fp);
|
||||
assert!(rp <= raw.chars().count());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatter_groups_every_3() {
|
||||
let f = GroupEvery3;
|
||||
match f.format("1234567") {
|
||||
FormattingResult::Success { formatted, .. } => {
|
||||
assert_eq!(formatted, "123 456 7");
|
||||
}
|
||||
_ => panic!("expected success"),
|
||||
}
|
||||
}
|
||||
}
|
||||
424
canvas/src/validation/limits.rs
Normal file
424
canvas/src/validation/limits.rs
Normal file
@@ -0,0 +1,424 @@
|
||||
// src/validation/limits.rs
|
||||
//! Character limits validation implementation
|
||||
|
||||
use crate::validation::ValidationResult;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// Character limits configuration for a field
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CharacterLimits {
|
||||
/// Maximum number of characters allowed (None = unlimited)
|
||||
max_length: Option<usize>,
|
||||
|
||||
/// Minimum number of characters required (None = no minimum)
|
||||
min_length: Option<usize>,
|
||||
|
||||
/// Warning threshold (warn when approaching max limit)
|
||||
warning_threshold: Option<usize>,
|
||||
|
||||
/// Count mode: characters vs display width
|
||||
count_mode: CountMode,
|
||||
}
|
||||
|
||||
/// How to count characters for limit checking
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub enum CountMode {
|
||||
/// Count actual characters (default)
|
||||
Characters,
|
||||
|
||||
/// Count display width (useful for CJK characters)
|
||||
DisplayWidth,
|
||||
|
||||
/// Count bytes (rarely used, but available)
|
||||
Bytes,
|
||||
}
|
||||
|
||||
impl Default for CountMode {
|
||||
fn default() -> Self {
|
||||
CountMode::Characters
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a character limit check
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LimitCheckResult {
|
||||
/// Within limits
|
||||
Ok,
|
||||
|
||||
/// Approaching limit (warning)
|
||||
Warning { current: usize, max: usize },
|
||||
|
||||
/// At or exceeding limit (error)
|
||||
Exceeded { current: usize, max: usize },
|
||||
|
||||
/// Below minimum length
|
||||
TooShort { current: usize, min: usize },
|
||||
}
|
||||
|
||||
impl CharacterLimits {
|
||||
/// Create new character limits with just max length
|
||||
pub fn new(max_length: usize) -> Self {
|
||||
Self {
|
||||
max_length: Some(max_length),
|
||||
min_length: None,
|
||||
warning_threshold: None,
|
||||
count_mode: CountMode::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create new character limits with min and max
|
||||
pub fn new_range(min_length: usize, max_length: usize) -> Self {
|
||||
Self {
|
||||
max_length: Some(max_length),
|
||||
min_length: Some(min_length),
|
||||
warning_threshold: None,
|
||||
count_mode: CountMode::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set warning threshold (when to show warning before hitting limit)
|
||||
pub fn with_warning_threshold(mut self, threshold: usize) -> Self {
|
||||
self.warning_threshold = Some(threshold);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set count mode (characters vs display width vs bytes)
|
||||
pub fn with_count_mode(mut self, mode: CountMode) -> Self {
|
||||
self.count_mode = mode;
|
||||
self
|
||||
}
|
||||
|
||||
/// Get maximum length
|
||||
pub fn max_length(&self) -> Option<usize> {
|
||||
self.max_length
|
||||
}
|
||||
|
||||
/// Get minimum length
|
||||
pub fn min_length(&self) -> Option<usize> {
|
||||
self.min_length
|
||||
}
|
||||
|
||||
/// Get warning threshold
|
||||
pub fn warning_threshold(&self) -> Option<usize> {
|
||||
self.warning_threshold
|
||||
}
|
||||
|
||||
/// Get count mode
|
||||
pub fn count_mode(&self) -> CountMode {
|
||||
self.count_mode
|
||||
}
|
||||
|
||||
/// Count characters/width/bytes according to the configured mode
|
||||
fn count(&self, text: &str) -> usize {
|
||||
match self.count_mode {
|
||||
CountMode::Characters => text.chars().count(),
|
||||
CountMode::DisplayWidth => text.width(),
|
||||
CountMode::Bytes => text.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if inserting a character would exceed limits
|
||||
pub fn validate_insertion(
|
||||
&self,
|
||||
current_text: &str,
|
||||
_position: usize,
|
||||
character: char,
|
||||
) -> Option<ValidationResult> {
|
||||
let current_count = self.count(current_text);
|
||||
let char_count = match self.count_mode {
|
||||
CountMode::Characters => 1,
|
||||
CountMode::DisplayWidth => {
|
||||
let char_str = character.to_string();
|
||||
char_str.width()
|
||||
},
|
||||
CountMode::Bytes => character.len_utf8(),
|
||||
};
|
||||
let new_count = current_count + char_count;
|
||||
|
||||
// Check max length
|
||||
if let Some(max) = self.max_length {
|
||||
if new_count > max {
|
||||
return Some(ValidationResult::error(format!(
|
||||
"Character limit exceeded: {}/{}",
|
||||
new_count,
|
||||
max
|
||||
)));
|
||||
}
|
||||
|
||||
// Check warning threshold
|
||||
if let Some(warning_threshold) = self.warning_threshold {
|
||||
if new_count >= warning_threshold && current_count < warning_threshold {
|
||||
return Some(ValidationResult::warning(format!(
|
||||
"Approaching character limit: {}/{}",
|
||||
new_count,
|
||||
max
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None // No validation issues
|
||||
}
|
||||
|
||||
/// Validate the current content
|
||||
pub fn validate_content(&self, text: &str) -> Option<ValidationResult> {
|
||||
let count = self.count(text);
|
||||
|
||||
// Check minimum length
|
||||
if let Some(min) = self.min_length {
|
||||
if count < min {
|
||||
return Some(ValidationResult::warning(format!(
|
||||
"Minimum length not met: {}/{}",
|
||||
count,
|
||||
min
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Check maximum length
|
||||
if let Some(max) = self.max_length {
|
||||
if count > max {
|
||||
return Some(ValidationResult::error(format!(
|
||||
"Character limit exceeded: {}/{}",
|
||||
count,
|
||||
max
|
||||
)));
|
||||
}
|
||||
|
||||
// Check warning threshold
|
||||
if let Some(warning_threshold) = self.warning_threshold {
|
||||
if count >= warning_threshold {
|
||||
return Some(ValidationResult::warning(format!(
|
||||
"Approaching character limit: {}/{}",
|
||||
count,
|
||||
max
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None // No validation issues
|
||||
}
|
||||
|
||||
/// Get the current status of the text against limits
|
||||
pub fn check_limits(&self, text: &str) -> LimitCheckResult {
|
||||
let count = self.count(text);
|
||||
|
||||
// Check max length first
|
||||
if let Some(max) = self.max_length {
|
||||
if count > max {
|
||||
return LimitCheckResult::Exceeded { current: count, max };
|
||||
}
|
||||
|
||||
// Check warning threshold
|
||||
if let Some(warning_threshold) = self.warning_threshold {
|
||||
if count >= warning_threshold {
|
||||
return LimitCheckResult::Warning { current: count, max };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check min length
|
||||
if let Some(min) = self.min_length {
|
||||
if count < min {
|
||||
return LimitCheckResult::TooShort { current: count, min };
|
||||
}
|
||||
}
|
||||
|
||||
LimitCheckResult::Ok
|
||||
}
|
||||
|
||||
/// Get a human-readable status string
|
||||
pub fn status_text(&self, text: &str) -> Option<String> {
|
||||
match self.check_limits(text) {
|
||||
LimitCheckResult::Ok => {
|
||||
// Show current/max if we have a max limit
|
||||
if let Some(max) = self.max_length {
|
||||
Some(format!("{}/{}", self.count(text), max))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
LimitCheckResult::Warning { current, max } => {
|
||||
Some(format!("{}/{} (approaching limit)", current, max))
|
||||
},
|
||||
LimitCheckResult::Exceeded { current, max } => {
|
||||
Some(format!("{}/{} (exceeded)", current, max))
|
||||
},
|
||||
LimitCheckResult::TooShort { current, min } => {
|
||||
Some(format!("{}/{} minimum", current, min))
|
||||
},
|
||||
}
|
||||
}
|
||||
pub fn allows_field_switch(&self, text: &str) -> bool {
|
||||
if let Some(min) = self.min_length {
|
||||
let count = self.count(text);
|
||||
// Allow switching if field is empty OR meets minimum requirement
|
||||
count == 0 || count >= min
|
||||
} else {
|
||||
true // No minimum requirement, always allow switching
|
||||
}
|
||||
}
|
||||
|
||||
/// Get reason why field switching is not allowed (if any)
|
||||
pub fn field_switch_block_reason(&self, text: &str) -> Option<String> {
|
||||
if let Some(min) = self.min_length {
|
||||
let count = self.count(text);
|
||||
if count > 0 && count < min {
|
||||
return Some(format!(
|
||||
"Field must be empty or have at least {} characters (currently: {})",
|
||||
min, count
|
||||
));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CharacterLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_length: Some(30), // Default 30 character limit as specified
|
||||
min_length: None,
|
||||
warning_threshold: None,
|
||||
count_mode: CountMode::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_character_limits_creation() {
|
||||
let limits = CharacterLimits::new(10);
|
||||
assert_eq!(limits.max_length(), Some(10));
|
||||
assert_eq!(limits.min_length(), None);
|
||||
|
||||
let range_limits = CharacterLimits::new_range(5, 15);
|
||||
assert_eq!(range_limits.min_length(), Some(5));
|
||||
assert_eq!(range_limits.max_length(), Some(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_limits() {
|
||||
let limits = CharacterLimits::default();
|
||||
assert_eq!(limits.max_length(), Some(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_character_counting() {
|
||||
let limits = CharacterLimits::new(5);
|
||||
|
||||
// Test character mode (default)
|
||||
assert_eq!(limits.count("hello"), 5);
|
||||
assert_eq!(limits.count("héllo"), 5); // Accented character counts as 1
|
||||
|
||||
// Test display width mode
|
||||
let limits = limits.with_count_mode(CountMode::DisplayWidth);
|
||||
assert_eq!(limits.count("hello"), 5);
|
||||
|
||||
// Test bytes mode
|
||||
let limits = limits.with_count_mode(CountMode::Bytes);
|
||||
assert_eq!(limits.count("hello"), 5);
|
||||
assert_eq!(limits.count("héllo"), 6); // é takes 2 bytes in UTF-8
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insertion_validation() {
|
||||
let limits = CharacterLimits::new(5);
|
||||
|
||||
// Valid insertion
|
||||
let result = limits.validate_insertion("test", 4, 'x');
|
||||
assert!(result.is_none()); // No validation issues
|
||||
|
||||
// Invalid insertion (would exceed limit)
|
||||
let result = limits.validate_insertion("tests", 5, 'x');
|
||||
assert!(result.is_some());
|
||||
assert!(!result.unwrap().is_acceptable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_validation() {
|
||||
let limits = CharacterLimits::new_range(3, 10);
|
||||
|
||||
// Too short
|
||||
let result = limits.validate_content("hi");
|
||||
assert!(result.is_some());
|
||||
assert!(result.unwrap().is_acceptable()); // Warning, not error
|
||||
|
||||
// Just right
|
||||
let result = limits.validate_content("hello");
|
||||
assert!(result.is_none());
|
||||
|
||||
// Too long
|
||||
let result = limits.validate_content("hello world!");
|
||||
assert!(result.is_some());
|
||||
assert!(!result.unwrap().is_acceptable()); // Error
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_warning_threshold() {
|
||||
let limits = CharacterLimits::new(10).with_warning_threshold(8);
|
||||
|
||||
// Below warning threshold
|
||||
let result = limits.validate_insertion("1234567", 7, 'x');
|
||||
assert!(result.is_none());
|
||||
|
||||
// At warning threshold
|
||||
let result = limits.validate_insertion("1234567", 7, 'x');
|
||||
assert!(result.is_none()); // This brings us to 8 chars
|
||||
|
||||
let result = limits.validate_insertion("12345678", 8, 'x');
|
||||
assert!(result.is_some());
|
||||
assert!(result.unwrap().is_acceptable()); // Warning, not error
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_text() {
|
||||
let limits = CharacterLimits::new(10);
|
||||
|
||||
assert_eq!(limits.status_text("hello"), Some("5/10".to_string()));
|
||||
|
||||
let limits = limits.with_warning_threshold(8);
|
||||
assert_eq!(limits.status_text("12345678"), Some("8/10 (approaching limit)".to_string()));
|
||||
assert_eq!(limits.status_text("1234567890x"), Some("11/10 (exceeded)".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_field_switch_blocking() {
|
||||
let limits = CharacterLimits::new_range(3, 10);
|
||||
|
||||
// Empty field: should allow switching
|
||||
assert!(limits.allows_field_switch(""));
|
||||
assert!(limits.field_switch_block_reason("").is_none());
|
||||
|
||||
// Field with content below minimum: should block switching
|
||||
assert!(!limits.allows_field_switch("hi"));
|
||||
assert!(limits.field_switch_block_reason("hi").is_some());
|
||||
assert!(limits.field_switch_block_reason("hi").unwrap().contains("at least 3 characters"));
|
||||
|
||||
// Field meeting minimum: should allow switching
|
||||
assert!(limits.allows_field_switch("hello"));
|
||||
assert!(limits.field_switch_block_reason("hello").is_none());
|
||||
|
||||
// Field exceeding maximum: should still allow switching (validation shows error but doesn't block)
|
||||
assert!(limits.allows_field_switch("this is way too long"));
|
||||
assert!(limits.field_switch_block_reason("this is way too long").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_field_switch_no_minimum() {
|
||||
let limits = CharacterLimits::new(10); // Only max, no minimum
|
||||
|
||||
// Should always allow switching when there's no minimum
|
||||
assert!(limits.allows_field_switch(""));
|
||||
assert!(limits.allows_field_switch("a"));
|
||||
assert!(limits.allows_field_switch("hello"));
|
||||
|
||||
assert!(limits.field_switch_block_reason("").is_none());
|
||||
assert!(limits.field_switch_block_reason("a").is_none());
|
||||
}
|
||||
}
|
||||
333
canvas/src/validation/mask.rs
Normal file
333
canvas/src/validation/mask.rs
Normal file
@@ -0,0 +1,333 @@
|
||||
// src/validation/mask.rs
|
||||
//! Pure display mask system - user-defined patterns only
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MaskDisplayMode {
|
||||
/// Only show separators as user types
|
||||
/// Example: "" → "", "123" → "123", "12345" → "(123) 45"
|
||||
Dynamic,
|
||||
|
||||
/// Show full template with placeholders from start
|
||||
/// Example: "" → "(___) ___-____", "123" → "(123) ___-____"
|
||||
Template {
|
||||
/// Character to use as placeholder for empty input positions
|
||||
placeholder: char
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for MaskDisplayMode {
|
||||
fn default() -> Self {
|
||||
MaskDisplayMode::Dynamic
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DisplayMask {
|
||||
/// Mask pattern like "##-##-####" where # = input position, others are visual separators
|
||||
pattern: String,
|
||||
/// Character used to represent input positions (usually '#')
|
||||
input_char: char,
|
||||
/// How to display the mask (dynamic vs template)
|
||||
display_mode: MaskDisplayMode,
|
||||
}
|
||||
|
||||
impl DisplayMask {
|
||||
/// Create a new display mask with dynamic mode (current behavior)
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `pattern` - The mask pattern (e.g., "##-##-####", "(###) ###-####")
|
||||
/// * `input_char` - Character representing input positions (usually '#')
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// // Phone number format
|
||||
/// let phone_mask = DisplayMask::new("(###) ###-####", '#');
|
||||
///
|
||||
/// // Date format
|
||||
/// let date_mask = DisplayMask::new("##/##/####", '#');
|
||||
///
|
||||
/// // Custom business format
|
||||
/// let employee_id = DisplayMask::new("EMP-####-##", '#');
|
||||
/// ```
|
||||
pub fn new(pattern: impl Into<String>, input_char: char) -> Self {
|
||||
Self {
|
||||
pattern: pattern.into(),
|
||||
input_char,
|
||||
display_mode: MaskDisplayMode::Dynamic,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the display mode for this mask
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// let dynamic_mask = DisplayMask::new("##-##", '#')
|
||||
/// .with_mode(MaskDisplayMode::Dynamic);
|
||||
///
|
||||
/// let template_mask = DisplayMask::new("##-##", '#')
|
||||
/// .with_mode(MaskDisplayMode::Template { placeholder: '_' });
|
||||
/// ```
|
||||
pub fn with_mode(mut self, mode: MaskDisplayMode) -> Self {
|
||||
self.display_mode = mode;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set template mode with custom placeholder
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// let phone_template = DisplayMask::new("(###) ###-####", '#')
|
||||
/// .with_template('_'); // Shows "(___) ___-____" when empty
|
||||
///
|
||||
/// let date_dots = DisplayMask::new("##/##/####", '#')
|
||||
/// .with_template('•'); // Shows "••/••/••••" when empty
|
||||
/// ```
|
||||
pub fn with_template(self, placeholder: char) -> Self {
|
||||
self.with_mode(MaskDisplayMode::Template { placeholder })
|
||||
}
|
||||
|
||||
/// Apply mask to raw input, showing visual separators and handling display mode
|
||||
pub fn apply_to_display(&self, raw_input: &str) -> String {
|
||||
match &self.display_mode {
|
||||
MaskDisplayMode::Dynamic => self.apply_dynamic(raw_input),
|
||||
MaskDisplayMode::Template { placeholder } => self.apply_template(raw_input, *placeholder),
|
||||
}
|
||||
}
|
||||
|
||||
/// Dynamic mode - only show separators as user types
|
||||
fn apply_dynamic(&self, raw_input: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut raw_chars = raw_input.chars();
|
||||
|
||||
for pattern_char in self.pattern.chars() {
|
||||
if pattern_char == self.input_char {
|
||||
// Input position - take from raw input
|
||||
if let Some(input_char) = raw_chars.next() {
|
||||
result.push(input_char);
|
||||
} else {
|
||||
// No more input - stop here in dynamic mode
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Visual separator - always show
|
||||
result.push(pattern_char);
|
||||
}
|
||||
}
|
||||
|
||||
// Append any remaining raw characters that don't fit the pattern
|
||||
for remaining_char in raw_chars {
|
||||
result.push(remaining_char);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Template mode - show full pattern with placeholders
|
||||
fn apply_template(&self, raw_input: &str, placeholder: char) -> String {
|
||||
let mut result = String::new();
|
||||
let mut raw_chars = raw_input.chars().peekable();
|
||||
|
||||
for pattern_char in self.pattern.chars() {
|
||||
if pattern_char == self.input_char {
|
||||
// Input position - take from raw input or use placeholder
|
||||
if let Some(input_char) = raw_chars.next() {
|
||||
result.push(input_char);
|
||||
} else {
|
||||
// No more input - use placeholder to show template
|
||||
result.push(placeholder);
|
||||
}
|
||||
} else {
|
||||
// Visual separator - always show in template mode
|
||||
result.push(pattern_char);
|
||||
}
|
||||
}
|
||||
|
||||
// In template mode, we don't append extra characters beyond the pattern
|
||||
// This keeps the template consistent
|
||||
result
|
||||
}
|
||||
|
||||
/// Check if a display position should accept cursor/input
|
||||
pub fn is_input_position(&self, display_position: usize) -> bool {
|
||||
self.pattern.chars()
|
||||
.nth(display_position)
|
||||
.map(|c| c == self.input_char)
|
||||
.unwrap_or(true) // Beyond pattern = accept input
|
||||
}
|
||||
|
||||
/// Map display position to raw position
|
||||
pub fn display_pos_to_raw_pos(&self, display_pos: usize) -> usize {
|
||||
let mut raw_pos = 0;
|
||||
|
||||
for (i, pattern_char) in self.pattern.chars().enumerate() {
|
||||
if i >= display_pos {
|
||||
break;
|
||||
}
|
||||
if pattern_char == self.input_char {
|
||||
raw_pos += 1;
|
||||
}
|
||||
}
|
||||
|
||||
raw_pos
|
||||
}
|
||||
|
||||
/// Map raw position to display position
|
||||
pub fn raw_pos_to_display_pos(&self, raw_pos: usize) -> usize {
|
||||
let mut input_positions_seen = 0;
|
||||
|
||||
for (display_pos, pattern_char) in self.pattern.chars().enumerate() {
|
||||
if pattern_char == self.input_char {
|
||||
if input_positions_seen == raw_pos {
|
||||
return display_pos;
|
||||
}
|
||||
input_positions_seen += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Beyond pattern, return position after pattern
|
||||
self.pattern.len() + (raw_pos - input_positions_seen)
|
||||
}
|
||||
|
||||
/// Find next input position at or after the given display position
|
||||
pub fn next_input_position(&self, display_pos: usize) -> usize {
|
||||
for (i, pattern_char) in self.pattern.chars().enumerate().skip(display_pos) {
|
||||
if pattern_char == self.input_char {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
// Beyond pattern = all positions are input positions
|
||||
display_pos.max(self.pattern.len())
|
||||
}
|
||||
|
||||
/// Find previous input position at or before the given display position
|
||||
pub fn prev_input_position(&self, display_pos: usize) -> Option<usize> {
|
||||
// Collect pattern chars with indices first, then search backwards
|
||||
let pattern_chars: Vec<(usize, char)> = self.pattern.chars().enumerate().collect();
|
||||
|
||||
// Search backwards from display_pos
|
||||
for &(i, pattern_char) in pattern_chars.iter().rev() {
|
||||
if i <= display_pos && pattern_char == self.input_char {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get the display mode
|
||||
pub fn display_mode(&self) -> &MaskDisplayMode {
|
||||
&self.display_mode
|
||||
}
|
||||
|
||||
/// Check if this mask uses template mode
|
||||
pub fn is_template_mode(&self) -> bool {
|
||||
matches!(self.display_mode, MaskDisplayMode::Template { .. })
|
||||
}
|
||||
|
||||
/// Get the pattern string
|
||||
pub fn pattern(&self) -> &str {
|
||||
&self.pattern
|
||||
}
|
||||
|
||||
/// Get the position of the first input character in the pattern
|
||||
pub fn first_input_position(&self) -> usize {
|
||||
for (pos, ch) in self.pattern.chars().enumerate() {
|
||||
if ch == self.input_char {
|
||||
return pos;
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DisplayMask {
|
||||
fn default() -> Self {
|
||||
Self::new("", '#')
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_user_defined_phone_mask() {
|
||||
// User creates their own phone mask
|
||||
let dynamic = DisplayMask::new("(###) ###-####", '#');
|
||||
let template = DisplayMask::new("(###) ###-####", '#').with_template('_');
|
||||
|
||||
// Dynamic mode
|
||||
assert_eq!(dynamic.apply_to_display(""), "");
|
||||
assert_eq!(dynamic.apply_to_display("1234567890"), "(123) 456-7890");
|
||||
|
||||
// Template mode
|
||||
assert_eq!(template.apply_to_display(""), "(___) ___-____");
|
||||
assert_eq!(template.apply_to_display("123"), "(123) ___-____");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_defined_date_mask() {
|
||||
// User creates their own date formats
|
||||
let us_date = DisplayMask::new("##/##/####", '#');
|
||||
let eu_date = DisplayMask::new("##.##.####", '#');
|
||||
let iso_date = DisplayMask::new("####-##-##", '#');
|
||||
|
||||
assert_eq!(us_date.apply_to_display("12252024"), "12/25/2024");
|
||||
assert_eq!(eu_date.apply_to_display("25122024"), "25.12.2024");
|
||||
assert_eq!(iso_date.apply_to_display("20241225"), "2024-12-25");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_defined_business_formats() {
|
||||
// User creates custom business formats
|
||||
let employee_id = DisplayMask::new("EMP-####-##", '#');
|
||||
let product_code = DisplayMask::new("###-###-###", '#');
|
||||
let invoice = DisplayMask::new("INV####/##", '#');
|
||||
|
||||
assert_eq!(employee_id.apply_to_display("123456"), "EMP-1234-56");
|
||||
assert_eq!(product_code.apply_to_display("123456789"), "123-456-789");
|
||||
assert_eq!(invoice.apply_to_display("123456"), "INV1234/56");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_input_characters() {
|
||||
// User can define their own input character
|
||||
let mask_with_x = DisplayMask::new("XXX-XX-XXXX", 'X');
|
||||
let mask_with_hash = DisplayMask::new("###-##-####", '#');
|
||||
let mask_with_n = DisplayMask::new("NNN-NN-NNNN", 'N');
|
||||
|
||||
assert_eq!(mask_with_x.apply_to_display("123456789"), "123-45-6789");
|
||||
assert_eq!(mask_with_hash.apply_to_display("123456789"), "123-45-6789");
|
||||
assert_eq!(mask_with_n.apply_to_display("123456789"), "123-45-6789");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_placeholders() {
|
||||
// User can define custom placeholder characters
|
||||
let underscores = DisplayMask::new("##-##", '#').with_template('_');
|
||||
let dots = DisplayMask::new("##-##", '#').with_template('•');
|
||||
let dashes = DisplayMask::new("##-##", '#').with_template('-');
|
||||
|
||||
assert_eq!(underscores.apply_to_display(""), "__-__");
|
||||
assert_eq!(dots.apply_to_display(""), "••-••");
|
||||
assert_eq!(dashes.apply_to_display(""), "---"); // Note: dashes blend with separator
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_position_mapping_user_patterns() {
|
||||
let custom = DisplayMask::new("ABC-###-XYZ", '#');
|
||||
|
||||
// Position mapping should work correctly with any pattern
|
||||
assert_eq!(custom.raw_pos_to_display_pos(0), 4); // First # at position 4
|
||||
assert_eq!(custom.raw_pos_to_display_pos(1), 5); // Second # at position 5
|
||||
assert_eq!(custom.raw_pos_to_display_pos(2), 6); // Third # at position 6
|
||||
|
||||
assert_eq!(custom.display_pos_to_raw_pos(4), 0); // Position 4 -> first input
|
||||
assert_eq!(custom.display_pos_to_raw_pos(5), 1); // Position 5 -> second input
|
||||
assert_eq!(custom.display_pos_to_raw_pos(6), 2); // Position 6 -> third input
|
||||
|
||||
assert!(!custom.is_input_position(0)); // A
|
||||
assert!(!custom.is_input_position(3)); // -
|
||||
assert!(custom.is_input_position(4)); // #
|
||||
assert!(!custom.is_input_position(8)); // Y
|
||||
}
|
||||
}
|
||||
40
canvas/src/validation/mod.rs
Normal file
40
canvas/src/validation/mod.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
// src/validation/mod.rs
|
||||
|
||||
// Core validation modules
|
||||
pub mod config;
|
||||
pub mod limits;
|
||||
pub mod state;
|
||||
pub mod patterns;
|
||||
pub mod mask; // Simple display mask instead of complex reserved chars
|
||||
pub mod formatting; // Custom formatter and position mapping (feature 4)
|
||||
|
||||
// Re-export main types
|
||||
pub use config::{ValidationConfig, ValidationResult, ValidationConfigBuilder};
|
||||
pub use limits::{CharacterLimits, LimitCheckResult};
|
||||
pub use state::{ValidationState, ValidationSummary};
|
||||
pub use patterns::{PatternFilters, PositionFilter, PositionRange, CharacterFilter};
|
||||
pub use mask::DisplayMask; // Simple mask instead of ReservedCharacters
|
||||
pub use formatting::{CustomFormatter, FormattingResult, PositionMapper, DefaultPositionMapper};
|
||||
|
||||
/// External validation UI state (Feature 5)
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ExternalValidationState {
|
||||
NotValidated,
|
||||
Validating,
|
||||
Valid(Option<String>),
|
||||
Invalid { message: String, suggestion: Option<String> },
|
||||
Warning { message: String },
|
||||
}
|
||||
|
||||
/// Validation error types
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum ValidationError {
|
||||
#[error("Character limit exceeded: {message}")]
|
||||
LimitExceeded { message: String },
|
||||
|
||||
#[error("Pattern validation failed: {message}")]
|
||||
PatternFailed { message: String },
|
||||
|
||||
#[error("Custom validation failed: {message}")]
|
||||
CustomFailed { message: String },
|
||||
}
|
||||
326
canvas/src/validation/patterns.rs
Normal file
326
canvas/src/validation/patterns.rs
Normal file
@@ -0,0 +1,326 @@
|
||||
// src/validation/patterns.rs
|
||||
//! Position-based pattern filtering for validation
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A filter that applies to specific character positions in a field
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PositionFilter {
|
||||
/// Which positions this filter applies to
|
||||
pub positions: PositionRange,
|
||||
/// What type of character filter to apply
|
||||
pub filter: CharacterFilter,
|
||||
}
|
||||
|
||||
/// Defines which character positions a filter applies to
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum PositionRange {
|
||||
/// Single position (e.g., position 3 only)
|
||||
Single(usize),
|
||||
/// Range of positions (e.g., positions 0-2, inclusive)
|
||||
Range(usize, usize),
|
||||
/// From position onwards (e.g., position 4 and beyond)
|
||||
From(usize),
|
||||
/// Multiple specific positions (e.g., positions 0, 2, 5)
|
||||
Multiple(Vec<usize>),
|
||||
}
|
||||
|
||||
/// Types of character filters that can be applied
|
||||
pub enum CharacterFilter {
|
||||
/// Allow only alphabetic characters (a-z, A-Z)
|
||||
Alphabetic,
|
||||
/// Allow only numeric characters (0-9)
|
||||
Numeric,
|
||||
/// Allow alphanumeric characters (a-z, A-Z, 0-9)
|
||||
Alphanumeric,
|
||||
/// Allow only exact character match
|
||||
Exact(char),
|
||||
/// Allow any character from the provided set
|
||||
OneOf(Vec<char>),
|
||||
/// Custom user-defined filter function
|
||||
Custom(Arc<dyn Fn(char) -> bool + Send + Sync>),
|
||||
}
|
||||
|
||||
// Manual implementations for Debug and Clone
|
||||
impl std::fmt::Debug for CharacterFilter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CharacterFilter::Alphabetic => write!(f, "Alphabetic"),
|
||||
CharacterFilter::Numeric => write!(f, "Numeric"),
|
||||
CharacterFilter::Alphanumeric => write!(f, "Alphanumeric"),
|
||||
CharacterFilter::Exact(ch) => write!(f, "Exact('{}')", ch),
|
||||
CharacterFilter::OneOf(chars) => write!(f, "OneOf({:?})", chars),
|
||||
CharacterFilter::Custom(_) => write!(f, "Custom(<function>)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for CharacterFilter {
|
||||
fn clone(&self) -> Self {
|
||||
match self {
|
||||
CharacterFilter::Alphabetic => CharacterFilter::Alphabetic,
|
||||
CharacterFilter::Numeric => CharacterFilter::Numeric,
|
||||
CharacterFilter::Alphanumeric => CharacterFilter::Alphanumeric,
|
||||
CharacterFilter::Exact(ch) => CharacterFilter::Exact(*ch),
|
||||
CharacterFilter::OneOf(chars) => CharacterFilter::OneOf(chars.clone()),
|
||||
CharacterFilter::Custom(func) => CharacterFilter::Custom(Arc::clone(func)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PositionRange {
|
||||
/// Check if a position is included in this range
|
||||
pub fn contains(&self, position: usize) -> bool {
|
||||
match self {
|
||||
PositionRange::Single(pos) => position == *pos,
|
||||
PositionRange::Range(start, end) => position >= *start && position <= *end,
|
||||
PositionRange::From(start) => position >= *start,
|
||||
PositionRange::Multiple(positions) => positions.contains(&position),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all positions up to a given length that this range covers
|
||||
pub fn positions_up_to(&self, max_length: usize) -> Vec<usize> {
|
||||
match self {
|
||||
PositionRange::Single(pos) => {
|
||||
if *pos < max_length { vec![*pos] } else { vec![] }
|
||||
},
|
||||
PositionRange::Range(start, end) => {
|
||||
let actual_end = (*end).min(max_length.saturating_sub(1));
|
||||
if *start <= actual_end {
|
||||
(*start..=actual_end).collect()
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
},
|
||||
PositionRange::From(start) => {
|
||||
if *start < max_length {
|
||||
(*start..max_length).collect()
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
},
|
||||
PositionRange::Multiple(positions) => {
|
||||
positions.iter()
|
||||
.filter(|&&pos| pos < max_length)
|
||||
.copied()
|
||||
.collect()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CharacterFilter {
|
||||
/// Test if a character passes this filter
|
||||
pub fn accepts(&self, ch: char) -> bool {
|
||||
match self {
|
||||
CharacterFilter::Alphabetic => ch.is_alphabetic(),
|
||||
CharacterFilter::Numeric => ch.is_numeric(),
|
||||
CharacterFilter::Alphanumeric => ch.is_alphanumeric(),
|
||||
CharacterFilter::Exact(expected) => ch == *expected,
|
||||
CharacterFilter::OneOf(chars) => chars.contains(&ch),
|
||||
CharacterFilter::Custom(func) => func(ch),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a human-readable description of this filter
|
||||
pub fn description(&self) -> String {
|
||||
match self {
|
||||
CharacterFilter::Alphabetic => "alphabetic characters (a-z, A-Z)".to_string(),
|
||||
CharacterFilter::Numeric => "numeric characters (0-9)".to_string(),
|
||||
CharacterFilter::Alphanumeric => "alphanumeric characters (a-z, A-Z, 0-9)".to_string(),
|
||||
CharacterFilter::Exact(ch) => format!("exactly '{}'", ch),
|
||||
CharacterFilter::OneOf(chars) => {
|
||||
let char_list: String = chars.iter().collect();
|
||||
format!("one of: {}", char_list)
|
||||
},
|
||||
CharacterFilter::Custom(_) => "custom filter".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PositionFilter {
|
||||
/// Create a new position filter
|
||||
pub fn new(positions: PositionRange, filter: CharacterFilter) -> Self {
|
||||
Self { positions, filter }
|
||||
}
|
||||
|
||||
/// Validate a character at a specific position
|
||||
pub fn validate_position(&self, position: usize, character: char) -> bool {
|
||||
if self.positions.contains(position) {
|
||||
self.filter.accepts(character)
|
||||
} else {
|
||||
true // Position not covered by this filter, allow any character
|
||||
}
|
||||
}
|
||||
|
||||
/// Get error message for invalid character at position
|
||||
pub fn error_message(&self, position: usize, character: char) -> Option<String> {
|
||||
if self.positions.contains(position) && !self.filter.accepts(character) {
|
||||
Some(format!(
|
||||
"Position {} requires {} but got '{}'",
|
||||
position,
|
||||
self.filter.description(),
|
||||
character
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A collection of position filters for a field
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PatternFilters {
|
||||
filters: Vec<PositionFilter>,
|
||||
}
|
||||
|
||||
impl PatternFilters {
|
||||
/// Create empty pattern filters
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Add a position filter
|
||||
pub fn add_filter(mut self, filter: PositionFilter) -> Self {
|
||||
self.filters.push(filter);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add multiple filters
|
||||
pub fn add_filters(mut self, filters: Vec<PositionFilter>) -> Self {
|
||||
self.filters.extend(filters);
|
||||
self
|
||||
}
|
||||
|
||||
/// Validate a character at a specific position against all applicable filters
|
||||
pub fn validate_char_at_position(&self, position: usize, character: char) -> Result<(), String> {
|
||||
for filter in &self.filters {
|
||||
if let Some(error) = filter.error_message(position, character) {
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate entire text against all filters
|
||||
pub fn validate_text(&self, text: &str) -> Result<(), String> {
|
||||
for (position, character) in text.char_indices() {
|
||||
if let Err(error) = self.validate_char_at_position(position, character) {
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if any filters are configured
|
||||
pub fn has_filters(&self) -> bool {
|
||||
!self.filters.is_empty()
|
||||
}
|
||||
|
||||
/// Get all configured filters
|
||||
pub fn filters(&self) -> &[PositionFilter] {
|
||||
&self.filters
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_position_range_contains() {
|
||||
assert!(PositionRange::Single(3).contains(3));
|
||||
assert!(!PositionRange::Single(3).contains(2));
|
||||
|
||||
assert!(PositionRange::Range(1, 4).contains(3));
|
||||
assert!(!PositionRange::Range(1, 4).contains(5));
|
||||
|
||||
assert!(PositionRange::From(2).contains(5));
|
||||
assert!(!PositionRange::From(2).contains(1));
|
||||
|
||||
assert!(PositionRange::Multiple(vec![0, 2, 5]).contains(2));
|
||||
assert!(!PositionRange::Multiple(vec![0, 2, 5]).contains(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_position_range_positions_up_to() {
|
||||
assert_eq!(PositionRange::Single(3).positions_up_to(5), vec![3]);
|
||||
assert_eq!(PositionRange::Single(5).positions_up_to(3), vec![]);
|
||||
|
||||
assert_eq!(PositionRange::Range(1, 3).positions_up_to(5), vec![1, 2, 3]);
|
||||
assert_eq!(PositionRange::Range(1, 5).positions_up_to(3), vec![1, 2]);
|
||||
|
||||
assert_eq!(PositionRange::From(2).positions_up_to(5), vec![2, 3, 4]);
|
||||
|
||||
assert_eq!(PositionRange::Multiple(vec![0, 2, 5]).positions_up_to(4), vec![0, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_character_filter_accepts() {
|
||||
assert!(CharacterFilter::Alphabetic.accepts('a'));
|
||||
assert!(CharacterFilter::Alphabetic.accepts('Z'));
|
||||
assert!(!CharacterFilter::Alphabetic.accepts('1'));
|
||||
|
||||
assert!(CharacterFilter::Numeric.accepts('5'));
|
||||
assert!(!CharacterFilter::Numeric.accepts('a'));
|
||||
|
||||
assert!(CharacterFilter::Alphanumeric.accepts('a'));
|
||||
assert!(CharacterFilter::Alphanumeric.accepts('5'));
|
||||
assert!(!CharacterFilter::Alphanumeric.accepts('-'));
|
||||
|
||||
assert!(CharacterFilter::Exact('x').accepts('x'));
|
||||
assert!(!CharacterFilter::Exact('x').accepts('y'));
|
||||
|
||||
assert!(CharacterFilter::OneOf(vec!['a', 'b', 'c']).accepts('b'));
|
||||
assert!(!CharacterFilter::OneOf(vec!['a', 'b', 'c']).accepts('d'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_position_filter_validation() {
|
||||
let filter = PositionFilter::new(
|
||||
PositionRange::Range(0, 1),
|
||||
CharacterFilter::Alphabetic,
|
||||
);
|
||||
|
||||
assert!(filter.validate_position(0, 'A'));
|
||||
assert!(filter.validate_position(1, 'b'));
|
||||
assert!(!filter.validate_position(0, '1'));
|
||||
assert!(filter.validate_position(2, '1')); // Position 2 not covered, allow anything
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pattern_filters_validation() {
|
||||
let patterns = PatternFilters::new()
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Range(0, 1),
|
||||
CharacterFilter::Alphabetic,
|
||||
))
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Range(2, 4),
|
||||
CharacterFilter::Numeric,
|
||||
));
|
||||
|
||||
// Valid pattern: AB123
|
||||
assert!(patterns.validate_text("AB123").is_ok());
|
||||
|
||||
// Invalid: number in alphabetic position
|
||||
assert!(patterns.validate_text("A1123").is_err());
|
||||
|
||||
// Invalid: letter in numeric position
|
||||
assert!(patterns.validate_text("AB1A3").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_filter() {
|
||||
let pattern = PatternFilters::new()
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::From(0),
|
||||
CharacterFilter::Custom(Arc::new(|c| c.is_lowercase())),
|
||||
));
|
||||
|
||||
assert!(pattern.validate_text("hello").is_ok());
|
||||
assert!(pattern.validate_text("Hello").is_err()); // Uppercase not allowed
|
||||
}
|
||||
}
|
||||
441
canvas/src/validation/state.rs
Normal file
441
canvas/src/validation/state.rs
Normal file
@@ -0,0 +1,441 @@
|
||||
// src/validation/state.rs
|
||||
//! Validation state management
|
||||
|
||||
use crate::validation::{ValidationConfig, ValidationResult, ExternalValidationState};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Validation state for all fields in a form
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ValidationState {
|
||||
/// Validation configurations per field index
|
||||
field_configs: HashMap<usize, ValidationConfig>,
|
||||
|
||||
/// Current validation results per field index
|
||||
field_results: HashMap<usize, ValidationResult>,
|
||||
|
||||
/// Track which fields have been validated
|
||||
validated_fields: std::collections::HashSet<usize>,
|
||||
|
||||
/// Global validation enabled/disabled
|
||||
enabled: bool,
|
||||
|
||||
/// External validation results per field (Feature 5)
|
||||
external_results: HashMap<usize, ExternalValidationState>,
|
||||
}
|
||||
|
||||
impl ValidationState {
|
||||
/// Create a new validation state
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
field_configs: HashMap::new(),
|
||||
field_results: HashMap::new(),
|
||||
validated_fields: std::collections::HashSet::new(),
|
||||
enabled: true,
|
||||
external_results: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable validation globally
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
if !enabled {
|
||||
// Clear all validation results when disabled
|
||||
self.field_results.clear();
|
||||
self.validated_fields.clear();
|
||||
self.external_results.clear(); // Also clear external results
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if validation is enabled
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
/// Set validation configuration for a field
|
||||
pub fn set_field_config(&mut self, field_index: usize, config: ValidationConfig) {
|
||||
if config.has_validation() || config.external_validation_enabled {
|
||||
self.field_configs.insert(field_index, config);
|
||||
} else {
|
||||
self.field_configs.remove(&field_index);
|
||||
self.field_results.remove(&field_index);
|
||||
self.validated_fields.remove(&field_index);
|
||||
self.external_results.remove(&field_index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get validation configuration for a field
|
||||
pub fn get_field_config(&self, field_index: usize) -> Option<&ValidationConfig> {
|
||||
self.field_configs.get(&field_index)
|
||||
}
|
||||
|
||||
/// Remove validation configuration for a field
|
||||
pub fn remove_field_config(&mut self, field_index: usize) {
|
||||
self.field_configs.remove(&field_index);
|
||||
self.field_results.remove(&field_index);
|
||||
self.validated_fields.remove(&field_index);
|
||||
self.external_results.remove(&field_index);
|
||||
}
|
||||
|
||||
/// Set external validation state for a field (Feature 5)
|
||||
pub fn set_external_validation(&mut self, field_index: usize, state: ExternalValidationState) {
|
||||
self.external_results.insert(field_index, state);
|
||||
}
|
||||
|
||||
/// Get current external validation state for a field
|
||||
pub fn get_external_validation(&self, field_index: usize) -> ExternalValidationState {
|
||||
self.external_results
|
||||
.get(&field_index)
|
||||
.cloned()
|
||||
.unwrap_or(ExternalValidationState::NotValidated)
|
||||
}
|
||||
|
||||
/// Clear external validation state for a field
|
||||
pub fn clear_external_validation(&mut self, field_index: usize) {
|
||||
self.external_results.remove(&field_index);
|
||||
}
|
||||
|
||||
/// Clear all external validation states
|
||||
pub fn clear_all_external_validation(&mut self) {
|
||||
self.external_results.clear();
|
||||
}
|
||||
|
||||
/// Validate character insertion for a field
|
||||
pub fn validate_char_insertion(
|
||||
&mut self,
|
||||
field_index: usize,
|
||||
current_text: &str,
|
||||
position: usize,
|
||||
character: char,
|
||||
) -> ValidationResult {
|
||||
if !self.enabled {
|
||||
return ValidationResult::Valid;
|
||||
}
|
||||
|
||||
if let Some(config) = self.field_configs.get(&field_index) {
|
||||
let result = config.validate_char_insertion(current_text, position, character);
|
||||
|
||||
// Store the validation result
|
||||
self.field_results.insert(field_index, result.clone());
|
||||
self.validated_fields.insert(field_index);
|
||||
|
||||
result
|
||||
} else {
|
||||
ValidationResult::Valid
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate field content
|
||||
pub fn validate_field_content(
|
||||
&mut self,
|
||||
field_index: usize,
|
||||
text: &str,
|
||||
) -> ValidationResult {
|
||||
if !self.enabled {
|
||||
return ValidationResult::Valid;
|
||||
}
|
||||
|
||||
if let Some(config) = self.field_configs.get(&field_index) {
|
||||
let result = config.validate_content(text);
|
||||
|
||||
// Store the validation result
|
||||
self.field_results.insert(field_index, result.clone());
|
||||
self.validated_fields.insert(field_index);
|
||||
|
||||
result
|
||||
} else {
|
||||
ValidationResult::Valid
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current validation result for a field
|
||||
pub fn get_field_result(&self, field_index: usize) -> Option<&ValidationResult> {
|
||||
self.field_results.get(&field_index)
|
||||
}
|
||||
|
||||
/// Get formatted display for a field if a custom formatter is configured.
|
||||
/// Returns (formatted_text, position_mapper, optional_warning_message).
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn formatted_for(
|
||||
&self,
|
||||
field_index: usize,
|
||||
raw: &str,
|
||||
) -> Option<(String, std::sync::Arc<dyn crate::validation::PositionMapper>, Option<String>)> {
|
||||
let config = self.field_configs.get(&field_index)?;
|
||||
config.run_custom_formatter(raw)
|
||||
}
|
||||
|
||||
/// Check if a field has been validated
|
||||
pub fn is_field_validated(&self, field_index: usize) -> bool {
|
||||
self.validated_fields.contains(&field_index)
|
||||
}
|
||||
|
||||
/// Clear validation result for a field
|
||||
pub fn clear_field_result(&mut self, field_index: usize) {
|
||||
self.field_results.remove(&field_index);
|
||||
self.validated_fields.remove(&field_index);
|
||||
}
|
||||
|
||||
/// Clear all validation results
|
||||
pub fn clear_all_results(&mut self) {
|
||||
self.field_results.clear();
|
||||
self.validated_fields.clear();
|
||||
}
|
||||
|
||||
/// Get all field indices that have validation configured
|
||||
pub fn validated_field_indices(&self) -> impl Iterator<Item = usize> + '_ {
|
||||
self.field_configs.keys().copied()
|
||||
}
|
||||
|
||||
/// Get all field indices with validation errors
|
||||
pub fn fields_with_errors(&self) -> impl Iterator<Item = usize> + '_ {
|
||||
self.field_results
|
||||
.iter()
|
||||
.filter(|(_, result)| result.is_error())
|
||||
.map(|(index, _)| *index)
|
||||
}
|
||||
|
||||
/// Get all field indices with validation warnings
|
||||
pub fn fields_with_warnings(&self) -> impl Iterator<Item = usize> + '_ {
|
||||
self.field_results
|
||||
.iter()
|
||||
.filter(|(_, result)| matches!(result, ValidationResult::Warning { .. }))
|
||||
.map(|(index, _)| *index)
|
||||
}
|
||||
|
||||
/// Check if any field has validation errors
|
||||
pub fn has_errors(&self) -> bool {
|
||||
self.field_results.values().any(|result| result.is_error())
|
||||
}
|
||||
|
||||
/// Check if any field has validation warnings
|
||||
pub fn has_warnings(&self) -> bool {
|
||||
self.field_results.values().any(|result| matches!(result, ValidationResult::Warning { .. }))
|
||||
}
|
||||
|
||||
/// Get total count of fields with validation configured
|
||||
pub fn validated_field_count(&self) -> usize {
|
||||
self.field_configs.len()
|
||||
}
|
||||
|
||||
/// Check if field switching is allowed for a specific field
|
||||
pub fn allows_field_switch(&self, field_index: usize, text: &str) -> bool {
|
||||
if !self.enabled {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(config) = self.field_configs.get(&field_index) {
|
||||
config.allows_field_switch(text)
|
||||
} else {
|
||||
true // No validation configured, allow switching
|
||||
}
|
||||
}
|
||||
|
||||
/// Get reason why field switching is blocked (if any)
|
||||
pub fn field_switch_block_reason(&self, field_index: usize, text: &str) -> Option<String> {
|
||||
if !self.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(config) = self.field_configs.get(&field_index) {
|
||||
config.field_switch_block_reason(text)
|
||||
} else {
|
||||
None // No validation configured
|
||||
}
|
||||
}
|
||||
pub fn summary(&self) -> ValidationSummary {
|
||||
let total_validated = self.validated_fields.len();
|
||||
let errors = self.fields_with_errors().count();
|
||||
let warnings = self.fields_with_warnings().count();
|
||||
let valid = total_validated - errors - warnings;
|
||||
|
||||
ValidationSummary {
|
||||
total_fields: self.field_configs.len(),
|
||||
validated_fields: total_validated,
|
||||
valid_fields: valid,
|
||||
warning_fields: warnings,
|
||||
error_fields: errors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Summary of validation state across all fields
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ValidationSummary {
|
||||
/// Total number of fields with validation configured
|
||||
pub total_fields: usize,
|
||||
|
||||
/// Number of fields that have been validated
|
||||
pub validated_fields: usize,
|
||||
|
||||
/// Number of fields with valid validation results
|
||||
pub valid_fields: usize,
|
||||
|
||||
/// Number of fields with warnings
|
||||
pub warning_fields: usize,
|
||||
|
||||
/// Number of fields with errors
|
||||
pub error_fields: usize,
|
||||
}
|
||||
|
||||
impl ValidationSummary {
|
||||
/// Check if all configured fields are valid
|
||||
pub fn is_all_valid(&self) -> bool {
|
||||
self.error_fields == 0 && self.validated_fields == self.total_fields
|
||||
}
|
||||
|
||||
/// Check if there are any errors
|
||||
pub fn has_errors(&self) -> bool {
|
||||
self.error_fields > 0
|
||||
}
|
||||
|
||||
/// Check if there are any warnings
|
||||
pub fn has_warnings(&self) -> bool {
|
||||
self.warning_fields > 0
|
||||
}
|
||||
|
||||
/// Get completion percentage (validated fields / total fields)
|
||||
pub fn completion_percentage(&self) -> f32 {
|
||||
if self.total_fields == 0 {
|
||||
1.0
|
||||
} else {
|
||||
self.validated_fields as f32 / self.total_fields as f32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::validation::{CharacterLimits, ValidationConfigBuilder};
|
||||
|
||||
#[test]
|
||||
fn test_validation_state_creation() {
|
||||
let state = ValidationState::new();
|
||||
assert!(state.is_enabled());
|
||||
assert_eq!(state.validated_field_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enable_disable() {
|
||||
let mut state = ValidationState::new();
|
||||
|
||||
// Add some validation config
|
||||
let config = ValidationConfigBuilder::new()
|
||||
.with_max_length(10)
|
||||
.build();
|
||||
state.set_field_config(0, config);
|
||||
|
||||
// Validate something
|
||||
let result = state.validate_field_content(0, "test");
|
||||
assert!(result.is_acceptable());
|
||||
assert!(state.is_field_validated(0));
|
||||
|
||||
// Disable validation
|
||||
state.set_enabled(false);
|
||||
assert!(!state.is_enabled());
|
||||
assert!(!state.is_field_validated(0)); // Should be cleared
|
||||
|
||||
// Validation should now return valid regardless
|
||||
let result = state.validate_field_content(0, "this is way too long for the limit");
|
||||
assert!(result.is_acceptable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_field_config_management() {
|
||||
let mut state = ValidationState::new();
|
||||
|
||||
let config = ValidationConfigBuilder::new()
|
||||
.with_max_length(5)
|
||||
.build();
|
||||
|
||||
// Set config
|
||||
state.set_field_config(0, config);
|
||||
assert_eq!(state.validated_field_count(), 1);
|
||||
assert!(state.get_field_config(0).is_some());
|
||||
|
||||
// Remove config
|
||||
state.remove_field_config(0);
|
||||
assert_eq!(state.validated_field_count(), 0);
|
||||
assert!(state.get_field_config(0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_character_insertion_validation() {
|
||||
let mut state = ValidationState::new();
|
||||
|
||||
let config = ValidationConfigBuilder::new()
|
||||
.with_max_length(5)
|
||||
.build();
|
||||
state.set_field_config(0, config);
|
||||
|
||||
// Valid insertion
|
||||
let result = state.validate_char_insertion(0, "test", 4, 'x');
|
||||
assert!(result.is_acceptable());
|
||||
|
||||
// Invalid insertion
|
||||
let result = state.validate_char_insertion(0, "tests", 5, 'x');
|
||||
assert!(!result.is_acceptable());
|
||||
|
||||
// Check that result was stored
|
||||
assert!(state.is_field_validated(0));
|
||||
let stored_result = state.get_field_result(0);
|
||||
assert!(stored_result.is_some());
|
||||
assert!(!stored_result.unwrap().is_acceptable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validation_summary() {
|
||||
let mut state = ValidationState::new();
|
||||
|
||||
// Configure two fields
|
||||
let config1 = ValidationConfigBuilder::new().with_max_length(5).build();
|
||||
let config2 = ValidationConfigBuilder::new().with_max_length(10).build();
|
||||
state.set_field_config(0, config1);
|
||||
state.set_field_config(1, config2);
|
||||
|
||||
// Validate field 0 (valid)
|
||||
state.validate_field_content(0, "test");
|
||||
|
||||
// Validate field 1 (error)
|
||||
state.validate_field_content(1, "this is too long");
|
||||
|
||||
let summary = state.summary();
|
||||
assert_eq!(summary.total_fields, 2);
|
||||
assert_eq!(summary.validated_fields, 2);
|
||||
assert_eq!(summary.valid_fields, 1);
|
||||
assert_eq!(summary.error_fields, 1);
|
||||
assert_eq!(summary.warning_fields, 0);
|
||||
|
||||
assert!(!summary.is_all_valid());
|
||||
assert!(summary.has_errors());
|
||||
assert!(!summary.has_warnings());
|
||||
assert_eq!(summary.completion_percentage(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_and_warning_tracking() {
|
||||
let mut state = ValidationState::new();
|
||||
|
||||
let config = ValidationConfigBuilder::new()
|
||||
.with_character_limits(
|
||||
CharacterLimits::new_range(3, 10).with_warning_threshold(8)
|
||||
)
|
||||
.build();
|
||||
state.set_field_config(0, config);
|
||||
|
||||
// Too short (warning)
|
||||
state.validate_field_content(0, "hi");
|
||||
assert!(state.has_warnings());
|
||||
assert!(!state.has_errors());
|
||||
|
||||
// Just right
|
||||
state.validate_field_content(0, "hello");
|
||||
assert!(!state.has_warnings());
|
||||
assert!(!state.has_errors());
|
||||
|
||||
// Too long (error)
|
||||
state.validate_field_content(0, "hello world!");
|
||||
assert!(!state.has_warnings());
|
||||
assert!(state.has_errors());
|
||||
}
|
||||
}
|
||||
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 "suggestions" "SUGGESTIONS 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 suggestions"
|
||||
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,20 +5,36 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
async-trait = "0.1.88"
|
||||
common = { path = "../common" }
|
||||
canvas = { path = "../canvas", features = ["gui"] }
|
||||
|
||||
crossterm = "0.28.1"
|
||||
ratatui = { workspace = true }
|
||||
crossterm = { workspace = true }
|
||||
prost-types = { workspace = true }
|
||||
dirs = "6.0.0"
|
||||
dotenvy = "0.15.7"
|
||||
lazy_static = "1.5.0"
|
||||
prost = "0.13.5"
|
||||
ratatui = "0.29.0"
|
||||
serde = { version = "1.0.218", features = ["derive"] }
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.140"
|
||||
time = "0.3.41"
|
||||
tokio = { version = "1.43.0", features = ["full", "macros"] }
|
||||
toml = "0.8.20"
|
||||
tonic = "0.12.3"
|
||||
tokio = { version = "1.44.2", features = ["full", "macros"] }
|
||||
toml = { workspace = true }
|
||||
tonic = "0.13.0"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.19"
|
||||
tui-textarea = { version = "0.7.0", features = ["crossterm", "ratatui", "search"] }
|
||||
unicode-segmentation = "1.12.0"
|
||||
unicode-width = "0.2.0"
|
||||
unicode-width.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
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,8 +2,9 @@
|
||||
[keybindings]
|
||||
|
||||
enter_command_mode = [":", "ctrl+;"]
|
||||
next_buffer = ["ctrl+l"]
|
||||
previous_buffer = ["ctrl+h"]
|
||||
next_buffer = ["space+b+n"]
|
||||
previous_buffer = ["space+b+p"]
|
||||
close_buffer = ["space+b+d"]
|
||||
|
||||
[keybindings.general]
|
||||
move_up = ["k", "Up"]
|
||||
@@ -15,12 +16,12 @@ toggle_sidebar = ["ctrl+t"]
|
||||
toggle_buffer_list = ["ctrl+b"]
|
||||
next_field = ["Tab"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
exit_table_scroll = ["esc"]
|
||||
open_search = ["ctrl+f"]
|
||||
|
||||
[keybindings.common]
|
||||
save = ["ctrl+s"]
|
||||
quit = ["ctrl+q"]
|
||||
# !!!change to space b r in the future and from edit mode
|
||||
revert = ["ctrl+r"]
|
||||
|
||||
force_quit = ["ctrl+shift+q"]
|
||||
save_and_quit = ["ctrl+shift+s"]
|
||||
@@ -28,6 +29,7 @@ move_up = ["Up"]
|
||||
move_down = ["Down"]
|
||||
toggle_sidebar = ["ctrl+t"]
|
||||
toggle_buffer_list = ["ctrl+b"]
|
||||
revert = ["space+b+r"]
|
||||
|
||||
# MODE SPECIFIC
|
||||
# READ ONLY MODE
|
||||
@@ -37,39 +39,75 @@ enter_edit_mode_after = ["a"]
|
||||
previous_entry = ["left","q"]
|
||||
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_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]
|
||||
exit_highlight_mode = ["esc"]
|
||||
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]
|
||||
# BIG CHANGES NOW EXIT HANDLES EITHER IF THOSE
|
||||
# exit_edit_mode = ["esc","ctrl+e"]
|
||||
# exit_suggestion_mode = ["esc"]
|
||||
# select_suggestion = ["enter"]
|
||||
# next_field = ["enter"]
|
||||
enter_decider = ["enter"]
|
||||
exit = ["esc", "ctrl+e"]
|
||||
delete_char_forward = ["delete"]
|
||||
delete_char_backward = ["backspace"]
|
||||
next_field = ["enter"]
|
||||
prev_field = ["shift+enter"]
|
||||
move_left = ["left"]
|
||||
move_right = ["right"]
|
||||
suggestion_down = ["ctrl+n", "tab"]
|
||||
suggestion_up = ["ctrl+p", "shift+tab"]
|
||||
select_suggestion = ["enter"]
|
||||
|
||||
### 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]
|
||||
exit_command_mode = ["ctrl+g", "esc"]
|
||||
@@ -80,7 +118,17 @@ quit = ["q"]
|
||||
force_quit = ["q!"]
|
||||
save_and_quit = ["wq"]
|
||||
revert = ["r"]
|
||||
find_file_palette_toggle = ["ff"]
|
||||
|
||||
[editor]
|
||||
keybinding_mode = "vim" # Options: "default", "vim", "emacs"
|
||||
|
||||
[colors]
|
||||
theme = "dark"
|
||||
# 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`
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
pub mod admin_panel;
|
||||
pub mod admin_panel_admin;
|
||||
pub mod add_table;
|
||||
pub mod add_logic;
|
||||
|
||||
pub use admin_panel::*;
|
||||
pub use admin_panel_admin::*;
|
||||
pub use add_table::*;
|
||||
pub use add_logic::*;
|
||||
|
||||
318
client/src/components/admin/add_logic.rs
Normal file
318
client/src/components/admin/add_logic.rs
Normal file
@@ -0,0 +1,318 @@
|
||||
// src/components/admin/add_logic.rs
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::add_logic::{AddLogicFocus, AddLogicState};
|
||||
use canvas::canvas::{render_canvas, CanvasState, HighlightState as CanvasHighlightState}; // Use canvas library
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, BorderType, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use crate::components::common::{dialog, autocomplete}; // Added autocomplete
|
||||
use crate::config::binds::config::EditorKeybindingMode;
|
||||
|
||||
// Helper function to convert between HighlightState types
|
||||
fn convert_highlight_state(local: &HighlightState) -> CanvasHighlightState {
|
||||
match local {
|
||||
HighlightState::Off => CanvasHighlightState::Off,
|
||||
HighlightState::Characterwise { anchor } => CanvasHighlightState::Characterwise { anchor: *anchor },
|
||||
HighlightState::Linewise { anchor_line } => CanvasHighlightState::Linewise { anchor_line: *anchor_line },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_add_logic(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
app_state: &AppState,
|
||||
add_logic_state: &mut AddLogicState,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
) {
|
||||
let main_block = Block::default()
|
||||
.title(" Add New Logic Script ")
|
||||
.title_alignment(Alignment::Center)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(theme.border))
|
||||
.style(Style::default().bg(theme.bg));
|
||||
let inner_area = main_block.inner(area);
|
||||
f.render_widget(main_block, area);
|
||||
|
||||
// Handle full-screen script editing
|
||||
if add_logic_state.current_focus == AddLogicFocus::InsideScriptContent {
|
||||
let mut editor_ref = add_logic_state.script_content_editor.borrow_mut();
|
||||
let border_style_color = if is_edit_mode { theme.highlight } else { theme.secondary };
|
||||
let border_style = Style::default().fg(border_style_color);
|
||||
|
||||
editor_ref.set_cursor_line_style(Style::default());
|
||||
editor_ref.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
|
||||
let script_title_hint = match add_logic_state.editor_keybinding_mode {
|
||||
EditorKeybindingMode::Vim => {
|
||||
let vim_mode_status = crate::components::common::text_editor::TextEditor::get_vim_mode_status(&add_logic_state.vim_state);
|
||||
format!("Script {}", vim_mode_status)
|
||||
}
|
||||
EditorKeybindingMode::Emacs | EditorKeybindingMode::Default => {
|
||||
if is_edit_mode {
|
||||
"Script (Editing)".to_string()
|
||||
} else {
|
||||
"Script".to_string()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
editor_ref.set_block(
|
||||
Block::default()
|
||||
.title(Span::styled(script_title_hint, Style::default().fg(theme.fg)))
|
||||
.title_alignment(Alignment::Center)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(border_style),
|
||||
);
|
||||
f.render_widget(&*editor_ref, inner_area);
|
||||
|
||||
// Drop the editor borrow before accessing autocomplete state
|
||||
drop(editor_ref);
|
||||
|
||||
// === SCRIPT EDITOR AUTOCOMPLETE RENDERING ===
|
||||
if add_logic_state.script_editor_autocomplete_active && !add_logic_state.script_editor_suggestions.is_empty() {
|
||||
// Get the current cursor position from textarea
|
||||
let current_cursor = {
|
||||
let editor_borrow = add_logic_state.script_content_editor.borrow();
|
||||
editor_borrow.cursor() // Returns (row, col) as (usize, usize)
|
||||
};
|
||||
|
||||
let (cursor_line, cursor_col) = current_cursor;
|
||||
|
||||
// Account for TextArea's block borders (1 for each side)
|
||||
let block_offset_x = 1;
|
||||
let block_offset_y = 1;
|
||||
|
||||
// Position autocomplete at current cursor position
|
||||
// Add 1 to column to position dropdown right after the cursor
|
||||
let autocomplete_x = cursor_col + 1;
|
||||
let autocomplete_y = cursor_line;
|
||||
|
||||
let input_rect = Rect {
|
||||
x: (inner_area.x + block_offset_x + autocomplete_x as u16).min(inner_area.right().saturating_sub(20)),
|
||||
y: (inner_area.y + block_offset_y + autocomplete_y as u16).min(inner_area.bottom().saturating_sub(5)),
|
||||
width: 1, // Minimum width for positioning
|
||||
height: 1,
|
||||
};
|
||||
|
||||
// Render autocomplete dropdown
|
||||
autocomplete::render_autocomplete_dropdown(
|
||||
f,
|
||||
input_rect,
|
||||
f.area(), // Full frame area for clamping
|
||||
theme,
|
||||
&add_logic_state.script_editor_suggestions,
|
||||
add_logic_state.script_editor_selected_suggestion_index,
|
||||
);
|
||||
}
|
||||
|
||||
return; // Exit early for fullscreen mode
|
||||
}
|
||||
|
||||
// Regular layout with preview
|
||||
let main_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Top info
|
||||
Constraint::Length(9), // Canvas for 3 inputs (each 1 line + 1 padding = 2 lines * 3 + 2 border = 8, +1 for good measure)
|
||||
Constraint::Min(5), // Script preview
|
||||
Constraint::Length(3), // Buttons
|
||||
])
|
||||
.split(inner_area);
|
||||
|
||||
let top_info_area = main_chunks[0];
|
||||
let canvas_area = main_chunks[1];
|
||||
let script_content_area = main_chunks[2];
|
||||
let buttons_area = main_chunks[3];
|
||||
|
||||
// Top info
|
||||
let profile_text = Paragraph::new(vec![
|
||||
Line::from(Span::styled(
|
||||
format!("Profile: {}", add_logic_state.profile_name),
|
||||
Style::default().fg(theme.fg),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(
|
||||
"Table: {}",
|
||||
add_logic_state
|
||||
.selected_table_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| add_logic_state.selected_table_id
|
||||
.map(|id| format!("ID {}", id))
|
||||
.unwrap_or_else(|| "Global (Not Selected)".to_string()))
|
||||
),
|
||||
Style::default().fg(theme.fg),
|
||||
)),
|
||||
])
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::BOTTOM)
|
||||
.border_style(Style::default().fg(theme.secondary)),
|
||||
);
|
||||
f.render_widget(profile_text, top_info_area);
|
||||
|
||||
// Canvas - USING CANVAS LIBRARY
|
||||
let focus_on_canvas_inputs = matches!(
|
||||
add_logic_state.current_focus,
|
||||
AddLogicFocus::InputLogicName
|
||||
| AddLogicFocus::InputTargetColumn
|
||||
| AddLogicFocus::InputDescription
|
||||
);
|
||||
|
||||
let canvas_highlight_state = convert_highlight_state(highlight_state);
|
||||
let active_field_rect = render_canvas(
|
||||
f,
|
||||
canvas_area,
|
||||
add_logic_state, // AddLogicState implements CanvasState
|
||||
theme, // Theme implements CanvasTheme
|
||||
is_edit_mode && focus_on_canvas_inputs,
|
||||
&canvas_highlight_state,
|
||||
);
|
||||
|
||||
// --- Render Autocomplete for Target Column ---
|
||||
// `is_edit_mode` here refers to the general edit mode of the EventHandler
|
||||
if is_edit_mode && add_logic_state.current_field() == 1 { // Target Column field
|
||||
if add_logic_state.in_target_column_suggestion_mode && add_logic_state.show_target_column_suggestions {
|
||||
if !add_logic_state.target_column_suggestions.is_empty() {
|
||||
if let Some(input_rect) = active_field_rect {
|
||||
autocomplete::render_autocomplete_dropdown(
|
||||
f,
|
||||
input_rect,
|
||||
f.area(), // Full frame area for clamping
|
||||
theme,
|
||||
&add_logic_state.target_column_suggestions,
|
||||
add_logic_state.selected_target_column_suggestion_index,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Script content preview
|
||||
{
|
||||
let mut editor_ref = add_logic_state.script_content_editor.borrow_mut();
|
||||
editor_ref.set_cursor_line_style(Style::default());
|
||||
|
||||
let is_script_preview_focused = add_logic_state.current_focus == AddLogicFocus::ScriptContentPreview;
|
||||
|
||||
if is_script_preview_focused {
|
||||
editor_ref.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
} else {
|
||||
let underscore_cursor_style = Style::default()
|
||||
.add_modifier(Modifier::UNDERLINED)
|
||||
.fg(theme.secondary);
|
||||
editor_ref.set_cursor_style(underscore_cursor_style);
|
||||
}
|
||||
|
||||
let border_style_color = if is_script_preview_focused {
|
||||
theme.highlight
|
||||
} else {
|
||||
theme.secondary
|
||||
};
|
||||
|
||||
let title_text = "Script Preview"; // Title doesn't need to change based on focus here
|
||||
|
||||
let title_style = if is_script_preview_focused {
|
||||
Style::default().fg(theme.highlight).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.fg)
|
||||
};
|
||||
|
||||
editor_ref.set_block(
|
||||
Block::default()
|
||||
.title(Span::styled(title_text, title_style))
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(border_style_color)),
|
||||
);
|
||||
f.render_widget(&*editor_ref, script_content_area);
|
||||
}
|
||||
|
||||
// Buttons
|
||||
let get_button_style = |button_focus: AddLogicFocus, current_focus_state: AddLogicFocus| {
|
||||
let is_focused = current_focus_state == button_focus;
|
||||
let base_style = Style::default().fg(if is_focused {
|
||||
theme.highlight
|
||||
} else {
|
||||
theme.secondary
|
||||
});
|
||||
if is_focused {
|
||||
base_style.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
base_style
|
||||
}
|
||||
};
|
||||
|
||||
let get_button_border_style = |is_focused: bool, current_theme: &Theme| {
|
||||
if is_focused {
|
||||
Style::default().fg(current_theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(current_theme.secondary)
|
||||
}
|
||||
};
|
||||
|
||||
let button_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(50),
|
||||
Constraint::Percentage(50),
|
||||
])
|
||||
.split(buttons_area);
|
||||
|
||||
let save_button = Paragraph::new(" Save Logic ")
|
||||
.style(get_button_style(
|
||||
AddLogicFocus::SaveButton,
|
||||
add_logic_state.current_focus,
|
||||
))
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(get_button_border_style(
|
||||
add_logic_state.current_focus == AddLogicFocus::SaveButton,
|
||||
theme,
|
||||
)),
|
||||
);
|
||||
f.render_widget(save_button, button_chunks[0]);
|
||||
|
||||
let cancel_button = Paragraph::new(" Cancel ")
|
||||
.style(get_button_style(
|
||||
AddLogicFocus::CancelButton,
|
||||
add_logic_state.current_focus,
|
||||
))
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(get_button_border_style(
|
||||
add_logic_state.current_focus == AddLogicFocus::CancelButton,
|
||||
theme,
|
||||
)),
|
||||
);
|
||||
f.render_widget(cancel_button, button_chunks[1]);
|
||||
|
||||
// Dialog
|
||||
if app_state.ui.dialog.dialog_show {
|
||||
dialog::render_dialog(
|
||||
f,
|
||||
f.area(),
|
||||
theme,
|
||||
&app_state.ui.dialog.dialog_title,
|
||||
&app_state.ui.dialog.dialog_message,
|
||||
&app_state.ui.dialog.dialog_buttons,
|
||||
app_state.ui.dialog.dialog_active_button_index,
|
||||
app_state.ui.dialog.is_loading,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,54 @@
|
||||
// src/components/admin/add_table.rs
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::add_table::{AddTableFocus, AddTableState};
|
||||
use canvas::canvas::{render_canvas, CanvasState, HighlightState as CanvasHighlightState};
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Style, Stylize},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, BorderType, Borders, Paragraph},
|
||||
widgets::{Block, BorderType, Borders, Cell, Paragraph, Row, Table},
|
||||
Frame,
|
||||
};
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use crate::components::handlers::canvas::render_canvas;
|
||||
use crate::state::pages::add_table::AddTableState;
|
||||
use crate::components::common::dialog;
|
||||
|
||||
/// Renders a placeholder page for adding tables.
|
||||
// Helper function to convert between HighlightState types
|
||||
fn convert_highlight_state(local: &HighlightState) -> CanvasHighlightState {
|
||||
match local {
|
||||
HighlightState::Off => CanvasHighlightState::Off,
|
||||
HighlightState::Characterwise { anchor } => CanvasHighlightState::Characterwise { anchor: *anchor },
|
||||
HighlightState::Linewise { anchor_line } => CanvasHighlightState::Linewise { anchor_line: *anchor_line },
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the Add New Table page layout, structuring the display of table information,
|
||||
/// input fields, and action buttons. Adapts layout based on terminal width.
|
||||
pub fn render_add_table(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
_app_state: &AppState,
|
||||
app_state: &AppState,
|
||||
add_table_state: &mut AddTableState,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
is_edit_mode: bool, // Determines if canvas inputs are in edit mode
|
||||
highlight_state: &HighlightState, // For text highlighting in canvas
|
||||
) {
|
||||
// Main block for the whole page
|
||||
// --- Configuration ---
|
||||
// Threshold width to switch between wide and narrow layouts
|
||||
const NARROW_LAYOUT_THRESHOLD: u16 = 120; // Adjust this value as needed
|
||||
|
||||
// --- State Checks ---
|
||||
let focus_on_canvas_inputs = matches!(
|
||||
add_table_state.current_focus,
|
||||
AddTableFocus::InputTableName
|
||||
| AddTableFocus::InputColumnName
|
||||
| AddTableFocus::InputColumnType
|
||||
);
|
||||
|
||||
// --- Main Page Block ---
|
||||
let main_block = Block::default()
|
||||
.title(" Add New Table ")
|
||||
.title_alignment(Alignment::Center)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(theme.border))
|
||||
@@ -33,148 +56,522 @@ pub fn render_add_table(
|
||||
let inner_area = main_block.inner(area);
|
||||
f.render_widget(main_block, area);
|
||||
|
||||
// Split the inner area horizontally: Left info pane | Right input/action pane
|
||||
let horizontal_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(50), // Left Pane
|
||||
Constraint::Percentage(50), // Right Pane
|
||||
].as_ref())
|
||||
.split(inner_area);
|
||||
// --- Fullscreen Columns Table Check (Narrow Screens Only) ---
|
||||
if area.width < NARROW_LAYOUT_THRESHOLD && add_table_state.current_focus == AddTableFocus::InsideColumnsTable {
|
||||
// Render ONLY the columns table taking the full inner area
|
||||
let columns_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen
|
||||
let column_rows: Vec<Row<'_>> = add_table_state
|
||||
.columns
|
||||
.iter()
|
||||
.map(|col_def| {
|
||||
Row::new(vec![
|
||||
Cell::from(if col_def.selected { "[*]" } else { "[ ]" }),
|
||||
Cell::from(col_def.name.clone()),
|
||||
Cell::from(col_def.data_type.clone()),
|
||||
])
|
||||
.style(Style::default().fg(theme.fg))
|
||||
})
|
||||
.collect();
|
||||
let header_cells = ["Sel", "Name", "Type"]
|
||||
.iter()
|
||||
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
|
||||
let header = Row::new(header_cells).height(1).bottom_margin(1);
|
||||
let columns_table = Table::new(column_rows, [Constraint::Length(5), Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.header(header)
|
||||
.block(
|
||||
Block::default()
|
||||
.title(Span::styled(" Columns (Fullscreen) ", theme.fg)) // Indicate fullscreen
|
||||
.title_alignment(Alignment::Center)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(columns_border_style),
|
||||
)
|
||||
.row_highlight_style(
|
||||
Style::default()
|
||||
.add_modifier(Modifier::REVERSED)
|
||||
.fg(theme.highlight),
|
||||
)
|
||||
.highlight_symbol(" > "); // Use the inside symbol
|
||||
f.render_stateful_widget(columns_table, inner_area, &mut add_table_state.column_table_state);
|
||||
return; // IMPORTANT: Stop rendering here for fullscreen mode
|
||||
}
|
||||
|
||||
let left_pane = horizontal_chunks[0];
|
||||
let right_pane = horizontal_chunks[1];
|
||||
// --- Fullscreen Indexes Table Check ---
|
||||
if add_table_state.current_focus == AddTableFocus::InsideIndexesTable { // Remove width check
|
||||
// Render ONLY the indexes table taking the full inner area
|
||||
let indexes_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen
|
||||
let index_rows: Vec<Row<'_>> = add_table_state
|
||||
.indexes
|
||||
.iter()
|
||||
.map(|index_def| {
|
||||
Row::new(vec![
|
||||
Cell::from(if index_def.selected { "[*]" } else { "[ ]" }),
|
||||
Cell::from(index_def.name.clone()),
|
||||
])
|
||||
.style(Style::default().fg(theme.fg))
|
||||
})
|
||||
.collect();
|
||||
let index_header_cells = ["Sel", "Column Name"]
|
||||
.iter()
|
||||
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
|
||||
let index_header = Row::new(index_header_cells).height(1).bottom_margin(1);
|
||||
let indexes_table = Table::new(index_rows, [Constraint::Length(5), Constraint::Percentage(95)])
|
||||
.header(index_header)
|
||||
.block(
|
||||
Block::default()
|
||||
.title(Span::styled(" Indexes (Fullscreen) ", theme.fg)) // Indicate fullscreen
|
||||
.title_alignment(Alignment::Center)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(indexes_border_style),
|
||||
)
|
||||
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED).fg(theme.highlight))
|
||||
.highlight_symbol(" > "); // Use the inside symbol
|
||||
f.render_stateful_widget(indexes_table, inner_area, &mut add_table_state.index_table_state);
|
||||
return; // IMPORTANT: Stop rendering here for fullscreen mode
|
||||
}
|
||||
|
||||
// --- Left Pane ---
|
||||
let left_vertical_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Profile & Table Name header
|
||||
Constraint::Min(5), // Columns section (expandable)
|
||||
Constraint::Length(1), // Separator
|
||||
Constraint::Min(3), // Indexes section (expandable)
|
||||
Constraint::Length(1), // Separator
|
||||
Constraint::Min(3), // Links section (expandable)
|
||||
].as_ref())
|
||||
.split(left_pane);
|
||||
// --- Fullscreen Links Table Check ---
|
||||
if add_table_state.current_focus == AddTableFocus::InsideLinksTable {
|
||||
// Render ONLY the links table taking the full inner area
|
||||
let links_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen
|
||||
let link_rows: Vec<Row<'_>> = add_table_state
|
||||
.links
|
||||
.iter()
|
||||
.map(|link_def| {
|
||||
Row::new(vec![
|
||||
Cell::from(if link_def.selected { "[*]" } else { "[ ]" }), // Selection first
|
||||
Cell::from(link_def.linked_table_name.clone()), // Table name second
|
||||
])
|
||||
.style(Style::default().fg(theme.fg))
|
||||
})
|
||||
.collect();
|
||||
let link_header_cells = ["Sel", "Available Table"]
|
||||
|
||||
// Profile & Table Name section
|
||||
let profile_text = Paragraph::new(vec![
|
||||
Line::from(Span::styled("profile: default", theme.fg)), // Placeholder
|
||||
Line::from(Span::styled("table name: [tablename]", theme.fg)), // Placeholder
|
||||
])
|
||||
.iter()
|
||||
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
|
||||
let link_header = Row::new(link_header_cells).height(1).bottom_margin(1);
|
||||
let links_table = Table::new(link_rows, [Constraint::Length(5), Constraint::Percentage(95)])
|
||||
.header(link_header)
|
||||
.block(
|
||||
Block::default()
|
||||
.title(Span::styled(" Links (Fullscreen) ", theme.fg)) // Indicate fullscreen
|
||||
.title_alignment(Alignment::Center)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(links_border_style),
|
||||
)
|
||||
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED).fg(theme.highlight))
|
||||
.highlight_symbol(" > "); // Use the inside symbol
|
||||
f.render_stateful_widget(links_table, inner_area, &mut add_table_state.link_table_state);
|
||||
return; // IMPORTANT: Stop rendering here for fullscreen mode
|
||||
}
|
||||
|
||||
// --- Area Variable Declarations ---
|
||||
let top_info_area: Rect;
|
||||
let columns_area: Rect;
|
||||
let canvas_area: Rect;
|
||||
let add_button_area: Rect;
|
||||
let indexes_area: Rect;
|
||||
let links_area: Rect;
|
||||
let bottom_buttons_area: Rect;
|
||||
|
||||
// --- Layout Decision ---
|
||||
if area.width >= NARROW_LAYOUT_THRESHOLD {
|
||||
// --- WIDE Layout (Based on first screenshot) ---
|
||||
let main_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Top Info (Profile/Table Name) - Increased to 3 lines
|
||||
Constraint::Min(10), // Middle Area (Columns | Right Pane)
|
||||
Constraint::Length(3), // Bottom Buttons
|
||||
])
|
||||
.split(inner_area);
|
||||
|
||||
top_info_area = main_chunks[0];
|
||||
let middle_area = main_chunks[1];
|
||||
bottom_buttons_area = main_chunks[2];
|
||||
|
||||
// Split Middle Horizontally
|
||||
let middle_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(50), // Left: Columns Table
|
||||
Constraint::Percentage(50), // Right: Inputs etc.
|
||||
])
|
||||
.split(middle_area);
|
||||
|
||||
columns_area = middle_chunks[0];
|
||||
let right_pane_area = middle_chunks[1];
|
||||
|
||||
// Split Right Pane Vertically
|
||||
let right_pane_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(5), // Input Canvas Area
|
||||
Constraint::Length(3), // Add Button Area
|
||||
Constraint::Min(5), // Indexes & Links Area
|
||||
])
|
||||
.split(right_pane_area);
|
||||
|
||||
canvas_area = right_pane_chunks[0];
|
||||
add_button_area = right_pane_chunks[1];
|
||||
let indexes_links_area = right_pane_chunks[2];
|
||||
|
||||
// Split Indexes/Links Horizontally
|
||||
let indexes_links_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(50), // Indexes Table
|
||||
Constraint::Percentage(50), // Links Table
|
||||
])
|
||||
.split(indexes_links_area);
|
||||
indexes_area = indexes_links_chunks[0];
|
||||
links_area = indexes_links_chunks[1];
|
||||
|
||||
// --- Top Info Rendering (Wide - 2 lines) ---
|
||||
let profile_text = Paragraph::new(vec![
|
||||
Line::from(Span::styled(
|
||||
format!("Profile: {}", add_table_state.profile_name),
|
||||
theme.fg,
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!("Table name: {}", add_table_state.table_name),
|
||||
theme.fg,
|
||||
)),
|
||||
])
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::BOTTOM)
|
||||
.border_style(Style::default().fg(theme.secondary)),
|
||||
);
|
||||
f.render_widget(profile_text, top_info_area);
|
||||
} else {
|
||||
// --- NARROW Layout (Based on second screenshot) ---
|
||||
let main_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // Top: Profile & Table Name (Single Row)
|
||||
Constraint::Length(5), // Column Definition Input Canvas Area
|
||||
Constraint::Length(3), // Add Button Area
|
||||
Constraint::Min(5), // Columns Table Area
|
||||
Constraint::Min(5), // Indexes & Links Area
|
||||
Constraint::Length(3), // Bottom: Save/Cancel Buttons
|
||||
])
|
||||
.split(inner_area);
|
||||
|
||||
top_info_area = main_chunks[0];
|
||||
canvas_area = main_chunks[1];
|
||||
add_button_area = main_chunks[2];
|
||||
columns_area = main_chunks[3];
|
||||
let indexes_links_area = main_chunks[4];
|
||||
bottom_buttons_area = main_chunks[5];
|
||||
|
||||
// Split Indexes/Links Horizontally
|
||||
let indexes_links_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(50), // Indexes Table
|
||||
Constraint::Percentage(50), // Links Table
|
||||
])
|
||||
.split(indexes_links_area);
|
||||
indexes_area = indexes_links_chunks[0];
|
||||
links_area = indexes_links_chunks[1];
|
||||
|
||||
// --- Top Info Rendering (Narrow - 1 line) ---
|
||||
let top_info_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(50),
|
||||
Constraint::Percentage(50),
|
||||
])
|
||||
.split(top_info_area);
|
||||
|
||||
let profile_text = Paragraph::new(Span::styled(
|
||||
format!("Profile: {}", add_table_state.profile_name),
|
||||
theme.fg,
|
||||
))
|
||||
.alignment(Alignment::Left);
|
||||
f.render_widget(profile_text, top_info_chunks[0]);
|
||||
|
||||
let table_name_text = Paragraph::new(Span::styled(
|
||||
format!("Table: {}", add_table_state.table_name),
|
||||
theme.fg,
|
||||
))
|
||||
.alignment(Alignment::Left);
|
||||
f.render_widget(table_name_text, top_info_chunks[1]);
|
||||
}
|
||||
|
||||
// --- Common Widget Rendering (Uses calculated areas) ---
|
||||
|
||||
// --- Columns Table Rendering ---
|
||||
let columns_focused = matches!(add_table_state.current_focus, AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable);
|
||||
let columns_border_style = if columns_focused {
|
||||
Style::default().fg(theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(theme.secondary)
|
||||
};
|
||||
let column_rows: Vec<Row<'_>> = add_table_state
|
||||
.columns
|
||||
.iter()
|
||||
.map(|col_def| {
|
||||
Row::new(vec![
|
||||
Cell::from(if col_def.selected { "[*]" } else { "[ ]" }),
|
||||
Cell::from(col_def.name.clone()),
|
||||
Cell::from(col_def.data_type.clone()),
|
||||
])
|
||||
.style(Style::default().fg(theme.fg))
|
||||
})
|
||||
.collect();
|
||||
let header_cells = ["Sel", "Name", "Type"]
|
||||
.iter()
|
||||
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
|
||||
let header = Row::new(header_cells).height(1).bottom_margin(1);
|
||||
let columns_table = Table::new(
|
||||
column_rows,
|
||||
[ // Define constraints for 3 columns: Sel, Name, Type
|
||||
Constraint::Length(5),
|
||||
Constraint::Percentage(60),
|
||||
Constraint::Percentage(35),
|
||||
],
|
||||
)
|
||||
.header(header)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::BOTTOM)
|
||||
.border_style(Style::default().fg(theme.secondary)),
|
||||
.title(Span::styled(" Columns ", theme.fg))
|
||||
.title_alignment(Alignment::Center)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(columns_border_style),
|
||||
)
|
||||
.row_highlight_style(
|
||||
Style::default()
|
||||
.add_modifier(Modifier::REVERSED)
|
||||
.fg(theme.highlight),
|
||||
)
|
||||
.highlight_symbol(" > ");
|
||||
f.render_stateful_widget(
|
||||
columns_table,
|
||||
columns_area,
|
||||
&mut add_table_state.column_table_state,
|
||||
);
|
||||
f.render_widget(profile_text, left_vertical_chunks[0]);
|
||||
|
||||
// Columns section
|
||||
let columns_text = Paragraph::new(vec![
|
||||
Line::from(Span::styled("Name Type", theme.accent)), // Header
|
||||
])
|
||||
.block(Block::default().title(Span::styled(" Columns ", theme.fg)));
|
||||
f.render_widget(columns_text, left_vertical_chunks[1]);
|
||||
|
||||
// Indexes section
|
||||
let indexes_text = Paragraph::new(vec![
|
||||
Line::from(Span::styled("Column name", theme.accent)), // Header
|
||||
])
|
||||
.block(
|
||||
Block::default()
|
||||
.title(Span::styled(" Indexes ", theme.fg))
|
||||
.borders(Borders::TOP) // Separator from Columns
|
||||
.border_style(Style::default().fg(theme.secondary)),
|
||||
);
|
||||
f.render_widget(indexes_text, left_vertical_chunks[3]);
|
||||
|
||||
// Links section
|
||||
let links_text = Paragraph::new(vec![
|
||||
Line::from(Span::styled("Linked table Required", theme.accent)), // Header
|
||||
])
|
||||
.block(
|
||||
Block::default()
|
||||
.title(Span::styled(" Links ", theme.fg))
|
||||
.borders(Borders::TOP) // Separator from Indexes
|
||||
.border_style(Style::default().fg(theme.secondary)),
|
||||
);
|
||||
f.render_widget(links_text, left_vertical_chunks[5]);
|
||||
|
||||
// --- Right Pane ---
|
||||
let right_vertical_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(5), // Area for render_canvas (3 fields + 2 border)
|
||||
Constraint::Length(3), // Add Button Area
|
||||
Constraint::Min(1), // Spacer
|
||||
Constraint::Length(3), // Save/Cancel buttons area
|
||||
].as_ref())
|
||||
.split(right_pane);
|
||||
|
||||
let canvas_area = right_vertical_chunks[0];
|
||||
let add_button_area = right_vertical_chunks[1];
|
||||
let bottom_buttons_area = right_vertical_chunks[3];
|
||||
|
||||
// --- Use render_canvas for Inputs ---
|
||||
// --- Canvas Rendering (Column Definition Input) - USING CANVAS LIBRARY ---
|
||||
let canvas_highlight_state = convert_highlight_state(highlight_state);
|
||||
let _active_field_rect = render_canvas(
|
||||
f,
|
||||
canvas_area,
|
||||
add_table_state,
|
||||
&[
|
||||
"Table name",
|
||||
"Name",
|
||||
"Type",
|
||||
],
|
||||
&add_table_state.current_field(),
|
||||
&add_table_state.inputs().iter().map(|s| *s).collect::<Vec<&String>>(),
|
||||
theme,
|
||||
is_edit_mode,
|
||||
highlight_state,
|
||||
add_table_state, // AddTableState implements CanvasState
|
||||
theme, // Theme implements CanvasTheme
|
||||
is_edit_mode && focus_on_canvas_inputs,
|
||||
&canvas_highlight_state,
|
||||
);
|
||||
|
||||
// Add Button (Placeholder)
|
||||
// --- Button Style Helpers ---
|
||||
let get_button_style = |button_focus: AddTableFocus, current_focus| {
|
||||
// Only handles text style (FG + Bold) now, no BG
|
||||
let is_focused = current_focus == button_focus;
|
||||
let base_style = Style::default().fg(if is_focused {
|
||||
theme.highlight // Highlighted text color
|
||||
} else {
|
||||
theme.secondary // Normal text color
|
||||
});
|
||||
if is_focused {
|
||||
base_style.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
base_style
|
||||
}
|
||||
};
|
||||
// Updated signature to accept bool and theme
|
||||
let get_button_border_style = |is_focused: bool, theme: &Theme| {
|
||||
if is_focused {
|
||||
Style::default().fg(theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(theme.secondary)
|
||||
}
|
||||
};
|
||||
|
||||
// --- Add Button Rendering ---
|
||||
// Determine if the add button is focused
|
||||
let is_add_button_focused = add_table_state.current_focus == AddTableFocus::AddColumnButton;
|
||||
|
||||
// Create the Add button Paragraph widget
|
||||
let add_button = Paragraph::new(" Add ")
|
||||
.style(Style::default().fg(theme.secondary))
|
||||
.style(get_button_style(AddTableFocus::AddColumnButton, add_table_state.current_focus)) // Use existing closure
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(theme.secondary)),
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(get_button_border_style(is_add_button_focused, theme)), // Pass bool and theme
|
||||
);
|
||||
|
||||
// Render the button in its designated area
|
||||
f.render_widget(add_button, add_button_area);
|
||||
|
||||
// Bottom Buttons Area (Save, Cancel)
|
||||
// --- Indexes Table Rendering ---
|
||||
let indexes_focused = matches!(add_table_state.current_focus, AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable);
|
||||
let indexes_border_style = if indexes_focused {
|
||||
Style::default().fg(theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(theme.secondary)
|
||||
};
|
||||
let index_rows: Vec<Row<'_>> = add_table_state
|
||||
.indexes
|
||||
.iter()
|
||||
.map(|index_def| { // Use index_def now
|
||||
Row::new(vec![
|
||||
Cell::from(if index_def.selected { "[*]" } else { "[ ]" }), // Display selection
|
||||
Cell::from(index_def.name.clone()),
|
||||
])
|
||||
.style(Style::default().fg(theme.fg))
|
||||
})
|
||||
.collect();
|
||||
let index_header_cells = ["Sel", "Column Name"]
|
||||
.iter()
|
||||
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
|
||||
let index_header = Row::new(index_header_cells).height(1).bottom_margin(1);
|
||||
let indexes_table =
|
||||
Table::new(index_rows, [Constraint::Length(5), Constraint::Percentage(95)])
|
||||
.header(index_header)
|
||||
.block(
|
||||
Block::default()
|
||||
.title(Span::styled(" Indexes ", theme.fg))
|
||||
.title_alignment(Alignment::Center)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(indexes_border_style),
|
||||
)
|
||||
.row_highlight_style(
|
||||
Style::default()
|
||||
.add_modifier(Modifier::REVERSED)
|
||||
.fg(theme.highlight),
|
||||
)
|
||||
.highlight_symbol(" > ");
|
||||
f.render_stateful_widget(
|
||||
indexes_table,
|
||||
indexes_area,
|
||||
&mut add_table_state.index_table_state,
|
||||
);
|
||||
|
||||
// --- Links Table Rendering ---
|
||||
let links_focused = matches!(add_table_state.current_focus, AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable);
|
||||
let links_border_style = if links_focused {
|
||||
Style::default().fg(theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(theme.secondary)
|
||||
};
|
||||
let link_rows: Vec<Row<'_>> = add_table_state
|
||||
.links
|
||||
.iter()
|
||||
.map(|link_def| {
|
||||
Row::new(vec![
|
||||
Cell::from(if link_def.selected { "[*]" } else { "[ ]" }),
|
||||
Cell::from(link_def.linked_table_name.clone()),
|
||||
])
|
||||
.style(Style::default().fg(theme.fg))
|
||||
})
|
||||
.collect();
|
||||
let link_header_cells = ["Sel", "Available Table"]
|
||||
.iter()
|
||||
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
|
||||
let link_header = Row::new(link_header_cells).height(1).bottom_margin(1);
|
||||
let links_table =
|
||||
Table::new(link_rows, [Constraint::Length(5), Constraint::Percentage(95)])
|
||||
.header(link_header)
|
||||
.block(
|
||||
Block::default()
|
||||
.title(Span::styled(" Links ", theme.fg))
|
||||
.title_alignment(Alignment::Center)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(links_border_style),
|
||||
)
|
||||
.row_highlight_style(
|
||||
Style::default()
|
||||
.add_modifier(Modifier::REVERSED)
|
||||
.fg(theme.highlight),
|
||||
)
|
||||
.highlight_symbol(" > ");
|
||||
f.render_stateful_widget(
|
||||
links_table,
|
||||
links_area,
|
||||
&mut add_table_state.link_table_state,
|
||||
);
|
||||
|
||||
// --- Save/Cancel Buttons Rendering ---
|
||||
let bottom_button_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(50), // Save Button
|
||||
Constraint::Percentage(50), // Cancel Button
|
||||
].as_ref())
|
||||
Constraint::Percentage(33), // Save Button
|
||||
Constraint::Percentage(34), // Delete Button
|
||||
Constraint::Percentage(33), // Cancel Button
|
||||
])
|
||||
.split(bottom_buttons_area);
|
||||
|
||||
// Save Button (Placeholder)
|
||||
let save_button = Paragraph::new(" Save table ")
|
||||
.style(Style::default().fg(theme.secondary))
|
||||
.style(get_button_style(
|
||||
AddTableFocus::SaveButton,
|
||||
add_table_state.current_focus,
|
||||
))
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(theme.secondary)),
|
||||
.border_style(get_button_border_style(
|
||||
add_table_state.current_focus == AddTableFocus::SaveButton, // Pass bool
|
||||
theme,
|
||||
)),
|
||||
);
|
||||
f.render_widget(save_button, bottom_button_chunks[0]);
|
||||
|
||||
// Cancel Button (Placeholder)
|
||||
let cancel_button = Paragraph::new(" Cancel ")
|
||||
.style(Style::default().fg(theme.secondary))
|
||||
let delete_button = Paragraph::new(" Delete Selected ")
|
||||
.style(get_button_style(
|
||||
AddTableFocus::DeleteSelectedButton,
|
||||
add_table_state.current_focus,
|
||||
))
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(theme.secondary)),
|
||||
.border_style(get_button_border_style(
|
||||
add_table_state.current_focus == AddTableFocus::DeleteSelectedButton, // Pass bool
|
||||
theme,
|
||||
)),
|
||||
);
|
||||
f.render_widget(cancel_button, bottom_button_chunks[1]);
|
||||
f.render_widget(delete_button, bottom_button_chunks[1]);
|
||||
|
||||
let cancel_button = Paragraph::new(" Cancel ")
|
||||
.style(get_button_style(
|
||||
AddTableFocus::CancelButton,
|
||||
add_table_state.current_focus,
|
||||
))
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(get_button_border_style(
|
||||
add_table_state.current_focus == AddTableFocus::CancelButton, // Pass bool
|
||||
theme,
|
||||
)),
|
||||
);
|
||||
f.render_widget(cancel_button, bottom_button_chunks[2]);
|
||||
|
||||
// --- DIALOG ---
|
||||
// Render the dialog overlay if it's active
|
||||
if app_state.ui.dialog.dialog_show {
|
||||
dialog::render_dialog(
|
||||
f,
|
||||
f.area(), // Render over the whole frame area
|
||||
theme,
|
||||
&app_state.ui.dialog.dialog_title,
|
||||
&app_state.ui.dialog.dialog_message,
|
||||
&app_state.ui.dialog.dialog_buttons,
|
||||
app_state.ui.dialog.dialog_active_button_index,
|
||||
app_state.ui.dialog.is_loading,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ use crate::config::colors::themes::Theme;
|
||||
use crate::state::pages::auth::AuthState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::admin::AdminState;
|
||||
use common::proto::multieko2::table_definition::ProfileTreeResponse;
|
||||
use common::proto::komp_ac::table_definition::ProfileTreeResponse;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::Style,
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, BorderType, Borders, List, ListItem, Paragraph, Wrap},
|
||||
|
||||
@@ -6,12 +6,11 @@ use crate::state::app::state::AppState;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::Style,
|
||||
text::{Line, Span, Text}, // Added Text
|
||||
widgets::{Block, BorderType, Borders, List, ListItem, ListState, Paragraph},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, BorderType, Borders, List, ListItem, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Renders the view specific to admin users with a three-pane layout.
|
||||
pub fn render_admin_panel_admin(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
@@ -19,15 +18,13 @@ pub fn render_admin_panel_admin(
|
||||
admin_state: &mut AdminState,
|
||||
theme: &Theme,
|
||||
) {
|
||||
// Split vertically: Top for panes, Bottom for buttons
|
||||
let main_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(0), Constraint::Length(1)].as_ref()) // 1 line for buttons
|
||||
.constraints([Constraint::Min(0), Constraint::Length(1)].as_ref())
|
||||
.split(area);
|
||||
let panes_area = main_chunks[0];
|
||||
let buttons_area = main_chunks[1];
|
||||
|
||||
// Split the top area into three panes: Profiles | Tables | Dependencies
|
||||
let pane_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
@@ -35,208 +32,148 @@ pub fn render_admin_panel_admin(
|
||||
Constraint::Percentage(40), // Tables
|
||||
Constraint::Percentage(35), // Dependencies
|
||||
].as_ref())
|
||||
.split(panes_area); // Use the whole area directly
|
||||
.split(panes_area);
|
||||
|
||||
let profiles_pane = pane_chunks[0];
|
||||
let tables_pane = pane_chunks[1];
|
||||
let deps_pane = pane_chunks[2];
|
||||
|
||||
// --- Profiles Pane (Left) ---
|
||||
let profile_focus = admin_state.current_focus == AdminFocus::Profiles;
|
||||
let profile_border_style = if profile_focus {
|
||||
let profile_pane_has_focus = matches!(admin_state.current_focus, AdminFocus::ProfilesPane | AdminFocus::InsideProfilesList);
|
||||
let profile_border_style = if profile_pane_has_focus {
|
||||
Style::default().fg(theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(theme.border)
|
||||
};
|
||||
|
||||
// Block for the profiles pane
|
||||
let profiles_block = Block::default()
|
||||
.title(" Profiles ")
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(profile_border_style);
|
||||
let profiles_inner_area = profiles_block.inner(profiles_pane); // Get inner area for list
|
||||
f.render_widget(profiles_block, profiles_pane); // Render the block itself
|
||||
|
||||
// Create profile list items
|
||||
let profile_list_items: Vec<ListItem> = app_state
|
||||
.profile_tree
|
||||
.profiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, profile)| {
|
||||
// Check persistent selection for prefix, navigation state for style/highlight
|
||||
let is_selected = admin_state.selected_profile_index == Some(idx); // Use persistent state for [*]
|
||||
let is_navigated = admin_state.profile_list_state.selected() == Some(idx); // Use nav state for highlight/>
|
||||
let prefix = if is_selected { "[*] " } else { "[ ] " };
|
||||
let style = if is_selected { // Style based on selection too
|
||||
Style::default().fg(theme.highlight).add_modifier(ratatui::style::Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.fg)
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(prefix, style),
|
||||
Span::styled(&profile.name, style)
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Build and render profile list inside the block's inner area
|
||||
let profiles_block = Block::default().title(" Profiles ").borders(Borders::ALL).border_type(BorderType::Rounded).border_style(profile_border_style);
|
||||
let profiles_inner_area = profiles_block.inner(profiles_pane);
|
||||
f.render_widget(profiles_block, profiles_pane);
|
||||
let profile_list_items: Vec<ListItem> = app_state.profile_tree.profiles.iter().enumerate().map(|(idx, profile)| {
|
||||
let is_persistently_selected = admin_state.selected_profile_index == Some(idx);
|
||||
let is_nav_highlighted = admin_state.profile_list_state.selected() == Some(idx) && admin_state.current_focus == AdminFocus::InsideProfilesList;
|
||||
let prefix = if is_persistently_selected { "[*] " } else { "[ ] " };
|
||||
let item_style = if is_nav_highlighted { Style::default().fg(theme.highlight).add_modifier(ratatui::style::Modifier::BOLD) }
|
||||
else if is_persistently_selected { Style::default().fg(theme.accent) }
|
||||
else { Style::default().fg(theme.fg) };
|
||||
ListItem::new(Line::from(vec![Span::styled(prefix, item_style), Span::styled(&profile.name, item_style)]))
|
||||
}).collect();
|
||||
let profile_list = List::new(profile_list_items)
|
||||
// Highlight style depends on focus AND navigation state
|
||||
.highlight_style(if profile_focus { // Use focus state
|
||||
Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default()
|
||||
})
|
||||
.highlight_symbol(if profile_focus { "> " } else { " " });
|
||||
|
||||
.highlight_style(if admin_state.current_focus == AdminFocus::InsideProfilesList { Style::default().add_modifier(ratatui::style::Modifier::REVERSED) } else { Style::default() })
|
||||
.highlight_symbol(if admin_state.current_focus == AdminFocus::InsideProfilesList { "> " } else { " " });
|
||||
f.render_stateful_widget(profile_list, profiles_inner_area, &mut admin_state.profile_list_state);
|
||||
|
||||
|
||||
// --- Tables Pane (Middle) ---
|
||||
let table_focus = admin_state.current_focus == AdminFocus::Tables;
|
||||
let table_border_style = if table_focus {
|
||||
Style::default().fg(theme.highlight)
|
||||
let table_pane_has_focus = matches!(admin_state.current_focus, AdminFocus::Tables | AdminFocus::InsideTablesList);
|
||||
let table_border_style = if table_pane_has_focus { Style::default().fg(theme.highlight) } else { Style::default().fg(theme.border) };
|
||||
|
||||
let profile_to_display_tables_for_idx: Option<usize>;
|
||||
if admin_state.current_focus == AdminFocus::InsideProfilesList {
|
||||
profile_to_display_tables_for_idx = admin_state.profile_list_state.selected();
|
||||
} else {
|
||||
Style::default().fg(theme.border)
|
||||
};
|
||||
profile_to_display_tables_for_idx = admin_state.selected_profile_index
|
||||
.or_else(|| admin_state.profile_list_state.selected());
|
||||
}
|
||||
let tables_pane_title_profile_name = profile_to_display_tables_for_idx
|
||||
.and_then(|idx| app_state.profile_tree.profiles.get(idx))
|
||||
.map_or("None Selected", |p| p.name.as_str());
|
||||
let tables_block = Block::default().title(format!(" Tables (Profile: {}) ", tables_pane_title_profile_name)).borders(Borders::ALL).border_type(BorderType::Rounded).border_style(table_border_style);
|
||||
let tables_inner_area = tables_block.inner(tables_pane);
|
||||
f.render_widget(tables_block, tables_pane);
|
||||
|
||||
// Get selected profile information
|
||||
let navigated_profile_idx = admin_state.profile_list_state.selected(); // Use nav state for display
|
||||
let selected_profile_name = app_state
|
||||
.profile_tree
|
||||
.profiles
|
||||
.get(navigated_profile_idx.unwrap_or(usize::MAX)) // Use nav state for title
|
||||
.map_or("None", |p| &p.name);
|
||||
|
||||
// Block for the tables pane
|
||||
let tables_block = Block::default()
|
||||
.title(format!(" Tables (Profile: {}) ", selected_profile_name))
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(table_border_style);
|
||||
let tables_inner_area = tables_block.inner(tables_pane); // Get inner area for list
|
||||
f.render_widget(tables_block, tables_pane); // Render the block itself
|
||||
|
||||
// Create table list items and get dependencies for the selected table
|
||||
let (table_list_items, selected_table_deps): (Vec<ListItem>, Vec<String>) = if let Some(
|
||||
profile, // Get profile based on NAVIGATED profile index
|
||||
) = navigated_profile_idx.and_then(|idx| app_state.profile_tree.profiles.get(idx)) {
|
||||
let items: Vec<ListItem> = profile
|
||||
.tables
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, table)| { // Renamed i to idx for clarity
|
||||
// Check persistent selection for prefix, navigation state for style/highlight
|
||||
let is_selected = admin_state.selected_table_index == Some(idx); // Use persistent state for [*]
|
||||
let is_navigated = admin_state.table_list_state.selected() == Some(idx); // Use nav state for highlight/>
|
||||
let prefix = if is_selected { "[*] " } else { "[ ] " };
|
||||
let style = if is_navigated { // Style based on navigation highlight
|
||||
Style::default().fg(theme.highlight).add_modifier(ratatui::style::Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.fg)
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(prefix, style),
|
||||
Span::styled(&table.name, style),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Get dependencies only for the PERSISTENTLY selected table in the PERSISTENTLY selected profile
|
||||
let chosen_profile_idx = admin_state.selected_profile_index; // Use persistent profile selection
|
||||
let deps = chosen_profile_idx // Start with the chosen profile index
|
||||
.and_then(|p_idx| app_state.profile_tree.profiles.get(p_idx)) // Get the chosen profile
|
||||
.and_then(|p| admin_state.selected_table_index.and_then(|t_idx| p.tables.get(t_idx))) // Get the chosen table using its index
|
||||
.map_or(Vec::new(), |t| t.depends_on.clone()); // If found, clone its depends_on, otherwise return empty Vec
|
||||
|
||||
(items, deps)
|
||||
} else {
|
||||
// Default when no profile is selected
|
||||
(vec![ListItem::new("Select a profile to see tables")], vec![])
|
||||
};
|
||||
|
||||
// Build and render table list inside the block's inner area
|
||||
let table_list = List::new(table_list_items)
|
||||
// Highlight style depends on focus AND navigation state
|
||||
.highlight_style(if table_focus { // Use focus state
|
||||
Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
|
||||
let table_list_items_for_display: Vec<ListItem> =
|
||||
if let Some(profile_data_for_tables) = profile_to_display_tables_for_idx
|
||||
.and_then(|idx| app_state.profile_tree.profiles.get(idx)) {
|
||||
profile_data_for_tables.tables.iter().enumerate().map(|(idx, table)| {
|
||||
let is_table_persistently_selected = admin_state.selected_table_index == Some(idx) &&
|
||||
profile_to_display_tables_for_idx == admin_state.selected_profile_index;
|
||||
let is_table_nav_highlighted = admin_state.table_list_state.selected() == Some(idx) &&
|
||||
admin_state.current_focus == AdminFocus::InsideTablesList;
|
||||
let prefix = if is_table_persistently_selected { "[*] " } else { "[ ] " };
|
||||
let style = if is_table_nav_highlighted { Style::default().fg(theme.highlight).add_modifier(ratatui::style::Modifier::BOLD) }
|
||||
else if is_table_persistently_selected { Style::default().fg(theme.accent) }
|
||||
else { Style::default().fg(theme.fg) };
|
||||
ListItem::new(Line::from(vec![Span::styled(prefix, style), Span::styled(&table.name, style)]))
|
||||
}).collect()
|
||||
} else {
|
||||
Style::default()
|
||||
})
|
||||
.highlight_symbol(if table_focus { "> " } else { " " }); // Focus indicator
|
||||
|
||||
vec![ListItem::new("Select a profile to see tables")]
|
||||
};
|
||||
let table_list = List::new(table_list_items_for_display)
|
||||
.highlight_style(if admin_state.current_focus == AdminFocus::InsideTablesList { Style::default().add_modifier(ratatui::style::Modifier::REVERSED) } else { Style::default() })
|
||||
.highlight_symbol(if admin_state.current_focus == AdminFocus::InsideTablesList { "> " } else { " " });
|
||||
f.render_stateful_widget(table_list, tables_inner_area, &mut admin_state.table_list_state);
|
||||
|
||||
// --- Dependencies Pane (Right) ---
|
||||
// Get name based on PERSISTENT selections
|
||||
let chosen_profile_idx = admin_state.selected_profile_index; // Use persistent profile selection
|
||||
let selected_table_name = chosen_profile_idx
|
||||
.and_then(|p_idx| app_state.profile_tree.profiles.get(p_idx))
|
||||
.and_then(|p| admin_state.selected_table_index.and_then(|t_idx| p.tables.get(t_idx))) // Use persistent table selection
|
||||
.map_or("N/A", |t| &t.name); // Get name of the selected table
|
||||
|
||||
// Block for the dependencies pane
|
||||
// --- Dependencies Pane (Right) ---
|
||||
let mut deps_pane_title_table_name = "N/A".to_string();
|
||||
let dependencies_to_display: Vec<String>;
|
||||
|
||||
if admin_state.current_focus == AdminFocus::InsideTablesList {
|
||||
// If navigating tables, show dependencies for the '>' highlighted table.
|
||||
// The profile context is `profile_to_display_tables_for_idx` (from Tables pane logic).
|
||||
if let Some(p_idx_for_current_tables) = profile_to_display_tables_for_idx {
|
||||
if let Some(current_profile_showing_tables) = app_state.profile_tree.profiles.get(p_idx_for_current_tables) {
|
||||
if let Some(table_nav_idx) = admin_state.table_list_state.selected() { // The '>' highlighted table
|
||||
if let Some(navigated_table) = current_profile_showing_tables.tables.get(table_nav_idx) {
|
||||
deps_pane_title_table_name = navigated_table.name.clone();
|
||||
dependencies_to_display = navigated_table.depends_on.clone();
|
||||
} else {
|
||||
dependencies_to_display = Vec::new(); // Navigated table index out of bounds
|
||||
}
|
||||
} else {
|
||||
dependencies_to_display = Vec::new(); // No table navigated with '>'
|
||||
}
|
||||
} else {
|
||||
dependencies_to_display = Vec::new(); // Profile for tables out of bounds
|
||||
}
|
||||
} else {
|
||||
dependencies_to_display = Vec::new(); // No profile active for table display
|
||||
}
|
||||
} else {
|
||||
// Otherwise, show dependencies for the '[*]' persistently selected table & profile.
|
||||
if let Some(p_idx) = admin_state.selected_profile_index { // Must be a persistently selected profile
|
||||
if let Some(selected_profile) = app_state.profile_tree.profiles.get(p_idx) {
|
||||
if let Some(t_idx) = admin_state.selected_table_index { // Must be a persistently selected table
|
||||
if let Some(selected_table) = selected_profile.tables.get(t_idx) {
|
||||
deps_pane_title_table_name = selected_table.name.clone();
|
||||
dependencies_to_display = selected_table.depends_on.clone();
|
||||
} else { dependencies_to_display = Vec::new(); }
|
||||
} else { dependencies_to_display = Vec::new(); }
|
||||
} else { dependencies_to_display = Vec::new(); }
|
||||
} else { dependencies_to_display = Vec::new(); }
|
||||
}
|
||||
|
||||
let deps_block = Block::default()
|
||||
.title(format!(" Dependencies (Table: {}) ", selected_table_name))
|
||||
.title(format!(" Dependencies (Table: {}) ", deps_pane_title_table_name))
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(theme.border)); // No focus highlight for deps pane
|
||||
let deps_inner_area = deps_block.inner(deps_pane); // Get inner area for content
|
||||
f.render_widget(deps_block, deps_pane); // Render the block itself
|
||||
.border_style(Style::default().fg(theme.border));
|
||||
let deps_inner_area = deps_block.inner(deps_pane);
|
||||
f.render_widget(deps_block, deps_pane);
|
||||
|
||||
// Prepare content for the dependencies paragraph
|
||||
let mut deps_content = Text::default();
|
||||
deps_content.lines.push(Line::from(Span::styled(
|
||||
"Depends On:",
|
||||
Style::default().fg(theme.accent), // Use accent color for the label
|
||||
Style::default().fg(theme.accent),
|
||||
)));
|
||||
|
||||
if !selected_table_deps.is_empty() {
|
||||
for dep in selected_table_deps {
|
||||
// List each dependency
|
||||
if !dependencies_to_display.is_empty() {
|
||||
for dep in dependencies_to_display {
|
||||
deps_content.lines.push(Line::from(Span::styled(format!("- {}", dep), theme.fg)));
|
||||
}
|
||||
} else {
|
||||
// Indicate if there are no dependencies
|
||||
deps_content.lines.push(Line::from(Span::styled(" None", theme.secondary)));
|
||||
}
|
||||
|
||||
// Build and render dependencies paragraph inside the block's inner area
|
||||
let deps_paragraph = Paragraph::new(deps_content);
|
||||
f.render_widget(deps_paragraph, deps_inner_area);
|
||||
|
||||
// --- Buttons Row ---
|
||||
let button_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(33),
|
||||
Constraint::Percentage(34),
|
||||
Constraint::Percentage(33),
|
||||
].as_ref())
|
||||
.split(buttons_area);
|
||||
|
||||
let button_chunks = Layout::default().direction(Direction::Horizontal).constraints([Constraint::Percentage(33), Constraint::Percentage(34), Constraint::Percentage(33)].as_ref()).split(buttons_area);
|
||||
let btn_base_style = Style::default().fg(theme.secondary);
|
||||
|
||||
// Define the helper closure to get style based on focus
|
||||
let get_btn_style = |button_focus: AdminFocus| {
|
||||
if admin_state.current_focus == button_focus {
|
||||
// Apply highlight style if this button is focused
|
||||
btn_base_style.add_modifier(ratatui::style::Modifier::REVERSED)
|
||||
} else {
|
||||
btn_base_style // Use base style otherwise
|
||||
}
|
||||
};
|
||||
let btn1 = Paragraph::new("Add Logic")
|
||||
.style(get_btn_style(AdminFocus::Button1))
|
||||
.alignment(Alignment::Center);
|
||||
let btn2 = Paragraph::new("Add Table")
|
||||
.style(get_btn_style(AdminFocus::Button2))
|
||||
.alignment(Alignment::Center);
|
||||
let btn3 = Paragraph::new("Change Table")
|
||||
.style(get_btn_style(AdminFocus::Button3))
|
||||
.alignment(Alignment::Center);
|
||||
|
||||
let get_btn_style = |button_focus: AdminFocus| { if admin_state.current_focus == button_focus { btn_base_style.add_modifier(ratatui::style::Modifier::REVERSED) } else { btn_base_style } };
|
||||
let btn1 = Paragraph::new("Add Logic").style(get_btn_style(AdminFocus::Button1)).alignment(Alignment::Center);
|
||||
let btn2 = Paragraph::new("Add Table").style(get_btn_style(AdminFocus::Button2)).alignment(Alignment::Center);
|
||||
let btn3 = Paragraph::new("Change Table").style(get_btn_style(AdminFocus::Button3)).alignment(Alignment::Center);
|
||||
f.render_widget(btn1, button_chunks[0]);
|
||||
f.render_widget(btn2, button_chunks[1]);
|
||||
f.render_widget(btn3, button_chunks[2]);
|
||||
|
||||
@@ -13,6 +13,16 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use canvas::canvas::{render_canvas, HighlightState as CanvasHighlightState}; // Use canvas library's render function
|
||||
|
||||
// Helper function to convert between HighlightState types
|
||||
fn convert_highlight_state(local: &HighlightState) -> CanvasHighlightState {
|
||||
match local {
|
||||
HighlightState::Off => CanvasHighlightState::Off,
|
||||
HighlightState::Characterwise { anchor } => CanvasHighlightState::Characterwise { anchor: *anchor },
|
||||
HighlightState::Linewise { anchor_line } => CanvasHighlightState::Linewise { anchor_line: *anchor_line },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_login(
|
||||
f: &mut Frame,
|
||||
@@ -48,17 +58,15 @@ pub fn render_login(
|
||||
])
|
||||
.split(inner_area);
|
||||
|
||||
// --- FORM RENDERING ---
|
||||
crate::components::handlers::canvas::render_canvas(
|
||||
// --- FORM RENDERING (Using canvas library directly) ---
|
||||
let canvas_highlight_state = convert_highlight_state(highlight_state);
|
||||
render_canvas(
|
||||
f,
|
||||
chunks[0],
|
||||
login_state,
|
||||
&["Username/Email", "Password"],
|
||||
&login_state.current_field,
|
||||
&[&login_state.username, &login_state.password],
|
||||
theme,
|
||||
login_state, // LoginState implements CanvasState
|
||||
theme, // Theme implements CanvasTheme
|
||||
is_edit_mode,
|
||||
highlight_state,
|
||||
&canvas_highlight_state,
|
||||
);
|
||||
|
||||
// --- ERROR MESSAGE ---
|
||||
@@ -71,7 +79,7 @@ pub fn render_login(
|
||||
);
|
||||
}
|
||||
|
||||
// --- BUTTONS ---
|
||||
// --- BUTTONS (unchanged) ---
|
||||
let button_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
@@ -83,7 +91,7 @@ pub fn render_login(
|
||||
app_state.focused_button_index== login_button_index
|
||||
} else {
|
||||
false
|
||||
};
|
||||
};
|
||||
let mut login_style = Style::default().fg(theme.fg);
|
||||
let mut login_border = Style::default().fg(theme.border);
|
||||
if login_active {
|
||||
@@ -105,12 +113,12 @@ pub fn render_login(
|
||||
);
|
||||
|
||||
// Return Button
|
||||
let return_button_index = 1; // Assuming Return is the second general element
|
||||
let return_button_index = 1;
|
||||
let return_active = if app_state.ui.focus_outside_canvas {
|
||||
app_state.focused_button_index== return_button_index
|
||||
} else {
|
||||
false // Not active if focus is in canvas or other modes
|
||||
};
|
||||
false
|
||||
};
|
||||
let mut return_style = Style::default().fg(theme.fg);
|
||||
let mut return_border = Style::default().fg(theme.border);
|
||||
if return_active {
|
||||
@@ -132,17 +140,16 @@ pub fn render_login(
|
||||
);
|
||||
|
||||
// --- DIALOG ---
|
||||
// Check the correct field name for showing the dialog
|
||||
if app_state.ui.dialog.dialog_show {
|
||||
// Pass all 7 arguments correctly
|
||||
dialog::render_dialog(
|
||||
f,
|
||||
f.area(),
|
||||
theme,
|
||||
&app_state.ui.dialog.dialog_title,
|
||||
&app_state.ui.dialog.dialog_message,
|
||||
&app_state.ui.dialog.dialog_buttons, // Pass buttons slice
|
||||
app_state.ui.dialog.dialog_active_button_index, // Pass active index
|
||||
&app_state.ui.dialog.dialog_buttons,
|
||||
app_state.ui.dialog.dialog_active_button_index,
|
||||
app_state.ui.dialog.is_loading,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
use crate::{
|
||||
config::colors::themes::Theme,
|
||||
state::pages::auth::RegisterState, // Use RegisterState
|
||||
components::common::{dialog, autocomplete},
|
||||
state::pages::auth::RegisterState,
|
||||
components::common::dialog,
|
||||
state::app::state::AppState,
|
||||
state::pages::canvas_state::CanvasState,
|
||||
modes::handlers::mode_manager::AppMode,
|
||||
};
|
||||
use ratatui::{
|
||||
@@ -15,12 +14,24 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use canvas::canvas::{render_canvas, HighlightState as CanvasHighlightState}; // Use canvas library's render function
|
||||
use canvas::autocomplete::gui::render_autocomplete_dropdown;
|
||||
use canvas::autocomplete::AutocompleteCanvasState;
|
||||
|
||||
// Helper function to convert between HighlightState types
|
||||
fn convert_highlight_state(local: &HighlightState) -> CanvasHighlightState {
|
||||
match local {
|
||||
HighlightState::Off => CanvasHighlightState::Off,
|
||||
HighlightState::Characterwise { anchor } => CanvasHighlightState::Characterwise { anchor: *anchor },
|
||||
HighlightState::Linewise { anchor_line } => CanvasHighlightState::Linewise { anchor_line: *anchor_line },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_register(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
state: &RegisterState, // Use RegisterState
|
||||
state: &RegisterState,
|
||||
app_state: &AppState,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
@@ -29,7 +40,7 @@ pub fn render_register(
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Plain)
|
||||
.border_style(Style::default().fg(theme.border))
|
||||
.title(" Register ") // Update title
|
||||
.title(" Register ")
|
||||
.style(Style::default().bg(theme.bg));
|
||||
|
||||
f.render_widget(block, area);
|
||||
@@ -39,7 +50,6 @@ pub fn render_register(
|
||||
vertical: 1,
|
||||
});
|
||||
|
||||
// Adjust constraints for 4 fields + error + buttons
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
@@ -50,23 +60,15 @@ pub fn render_register(
|
||||
])
|
||||
.split(inner_area);
|
||||
|
||||
// --- FORM RENDERING (Using render_canvas) ---
|
||||
let active_field_rect = crate::components::handlers::canvas::render_canvas(
|
||||
// --- FORM RENDERING (Using canvas library directly) ---
|
||||
let canvas_highlight_state = convert_highlight_state(highlight_state);
|
||||
let input_rect = render_canvas(
|
||||
f,
|
||||
chunks[0], // Area for the canvas
|
||||
state, // The state object (RegisterState)
|
||||
&[ // Field labels
|
||||
"Username",
|
||||
"Email*",
|
||||
"Password*",
|
||||
"Confirm Password",
|
||||
"Role* (Tab)",
|
||||
],
|
||||
&state.current_field(), // Pass current field index
|
||||
&state.inputs().iter().map(|s| *s).collect::<Vec<&String>>(), // Pass inputs directly
|
||||
theme,
|
||||
chunks[0],
|
||||
state, // RegisterState implements CanvasState
|
||||
theme, // Theme implements CanvasTheme
|
||||
is_edit_mode,
|
||||
highlight_state,
|
||||
&canvas_highlight_state,
|
||||
);
|
||||
|
||||
// --- HELP TEXT ---
|
||||
@@ -75,7 +77,6 @@ pub fn render_register(
|
||||
.alignment(Alignment::Center);
|
||||
f.render_widget(help_text, chunks[1]);
|
||||
|
||||
|
||||
// --- ERROR MESSAGE ---
|
||||
if let Some(err) = &state.error_message {
|
||||
f.render_widget(
|
||||
@@ -107,7 +108,7 @@ pub fn render_register(
|
||||
}
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new("Register") // Update button text
|
||||
Paragraph::new("Register")
|
||||
.style(register_style)
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
@@ -119,7 +120,7 @@ pub fn render_register(
|
||||
button_chunks[0],
|
||||
);
|
||||
|
||||
// Return Button (logic remains similar)
|
||||
// Return Button
|
||||
let return_button_index = 1;
|
||||
let return_active = if app_state.ui.focus_outside_canvas {
|
||||
app_state.focused_button_index== return_button_index
|
||||
@@ -146,19 +147,22 @@ pub fn render_register(
|
||||
button_chunks[1],
|
||||
);
|
||||
|
||||
// --- Render Autocomplete Dropdown (Draw AFTER buttons) ---
|
||||
// --- AUTOCOMPLETE DROPDOWN (Using canvas library directly) ---
|
||||
if app_state.current_mode == AppMode::Edit {
|
||||
if let Some(suggestions) = state.get_suggestions() {
|
||||
let selected = state.get_selected_suggestion_index();
|
||||
if !suggestions.is_empty() {
|
||||
if let Some(input_rect) = active_field_rect {
|
||||
autocomplete::render_autocomplete_dropdown(f, input_rect, f.size(), theme, suggestions, selected);
|
||||
}
|
||||
if let Some(autocomplete_state) = state.autocomplete_state() {
|
||||
if let Some(input_rect) = input_rect {
|
||||
render_autocomplete_dropdown(
|
||||
f,
|
||||
f.area(), // Frame area
|
||||
input_rect, // Current input field rect
|
||||
theme, // Theme implements CanvasTheme
|
||||
autocomplete_state,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- DIALOG --- (Keep dialog logic)
|
||||
// --- DIALOG ---
|
||||
if app_state.ui.dialog.dialog_show {
|
||||
dialog::render_dialog(
|
||||
f,
|
||||
@@ -168,6 +172,7 @@ pub fn render_register(
|
||||
&app_state.ui.dialog.dialog_message,
|
||||
&app_state.ui.dialog.dialog_buttons,
|
||||
app_state.ui.dialog.dialog_active_button_index,
|
||||
app_state.ui.dialog.is_loading,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
// src/components/common.rs
|
||||
pub mod command_line;
|
||||
pub mod status_line;
|
||||
pub mod text_editor;
|
||||
pub mod background;
|
||||
pub mod dialog;
|
||||
pub mod autocomplete;
|
||||
pub mod search_palette;
|
||||
pub mod find_file_palette;
|
||||
|
||||
pub use command_line::*;
|
||||
pub use status_line::*;
|
||||
pub use text_editor::*;
|
||||
pub use background::*;
|
||||
pub use dialog::*;
|
||||
pub use autocomplete::*;
|
||||
pub use search_palette::*;
|
||||
pub use find_file_palette::*;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// src/components/common/autocomplete.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::pages::form::FormState;
|
||||
use common::proto::komp_ac::search::search_response::Hit;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
@@ -9,7 +11,8 @@ use ratatui::{
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// Renders an opaque dropdown list for autocomplete suggestions.
|
||||
/// Renders an opaque dropdown list for simple string-based suggestions.
|
||||
/// THIS IS THE RESTORED FUNCTION.
|
||||
pub fn render_autocomplete_dropdown(
|
||||
f: &mut Frame,
|
||||
input_rect: Rect,
|
||||
@@ -21,39 +24,32 @@ pub fn render_autocomplete_dropdown(
|
||||
if suggestions.is_empty() {
|
||||
return;
|
||||
}
|
||||
// --- Calculate Dropdown Size & Position ---
|
||||
let max_suggestion_width = suggestions.iter().map(|s| s.width()).max().unwrap_or(0) as u16;
|
||||
let max_suggestion_width =
|
||||
suggestions.iter().map(|s| s.width()).max().unwrap_or(0) as u16;
|
||||
let horizontal_padding: u16 = 2;
|
||||
let dropdown_width = (max_suggestion_width + horizontal_padding).max(10);
|
||||
let dropdown_height = (suggestions.len() as u16).min(5);
|
||||
|
||||
let mut dropdown_area = Rect {
|
||||
x: input_rect.x, // Align horizontally with input
|
||||
y: input_rect.y + 1, // Position directly below input
|
||||
x: input_rect.x,
|
||||
y: input_rect.y + 1,
|
||||
width: dropdown_width,
|
||||
height: dropdown_height,
|
||||
};
|
||||
|
||||
// --- Clamping Logic (prevent rendering off-screen) ---
|
||||
// Clamp vertically (if it goes below the frame)
|
||||
if dropdown_area.bottom() > frame_area.height {
|
||||
dropdown_area.y = input_rect.y.saturating_sub(dropdown_height); // Try rendering above
|
||||
dropdown_area.y = input_rect.y.saturating_sub(dropdown_height);
|
||||
}
|
||||
// Clamp horizontally (if it goes past the right edge)
|
||||
if dropdown_area.right() > frame_area.width {
|
||||
dropdown_area.x = frame_area.width.saturating_sub(dropdown_width);
|
||||
}
|
||||
// Ensure x is not negative (if clamping pushes it left)
|
||||
dropdown_area.x = dropdown_area.x.max(0);
|
||||
// Ensure y is not negative (if clamping pushes it up)
|
||||
dropdown_area.y = dropdown_area.y.max(0);
|
||||
// --- End Clamping ---
|
||||
|
||||
// Render a solid background block first to ensure opacity
|
||||
let background_block = Block::default().style(Style::default().bg(Color::DarkGray));
|
||||
let background_block =
|
||||
Block::default().style(Style::default().bg(Color::DarkGray));
|
||||
f.render_widget(background_block, dropdown_area);
|
||||
|
||||
// Create list items, ensuring each has a defined background
|
||||
let items: Vec<ListItem> = suggestions
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -61,30 +57,97 @@ pub fn render_autocomplete_dropdown(
|
||||
let is_selected = selected_index == Some(i);
|
||||
let s_width = s.width() as u16;
|
||||
let padding_needed = dropdown_width.saturating_sub(s_width);
|
||||
let padded_s = format!("{}{}", s, " ".repeat(padding_needed as usize));
|
||||
let padded_s =
|
||||
format!("{}{}", s, " ".repeat(padding_needed as usize));
|
||||
|
||||
ListItem::new(padded_s).style(if is_selected {
|
||||
Style::default()
|
||||
.fg(theme.bg) // Text color on highlight
|
||||
.bg(theme.highlight) // Highlight background
|
||||
.fg(theme.bg)
|
||||
.bg(theme.highlight)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
// Style for non-selected items (matching background block)
|
||||
Style::default()
|
||||
.fg(theme.fg) // Text color on gray
|
||||
.bg(Color::DarkGray) // Explicit gray background
|
||||
Style::default().fg(theme.fg).bg(Color::DarkGray)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Create the list widget (without its own block)
|
||||
let list = List::new(items);
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(selected_index);
|
||||
|
||||
// State for managing selection highlight (still needed for logic)
|
||||
let mut profile_list_state = ListState::default();
|
||||
profile_list_state.select(selected_index);
|
||||
|
||||
// Render the list statefully *over* the background block
|
||||
f.render_stateful_widget(list, dropdown_area, &mut profile_list_state);
|
||||
f.render_stateful_widget(list, dropdown_area, &mut list_state);
|
||||
}
|
||||
|
||||
/// Renders an opaque dropdown list for rich `Hit`-based suggestions.
|
||||
/// RENAMED from render_rich_autocomplete_dropdown
|
||||
pub fn render_hit_autocomplete_dropdown(
|
||||
f: &mut Frame,
|
||||
input_rect: Rect,
|
||||
frame_area: Rect,
|
||||
theme: &Theme,
|
||||
suggestions: &[Hit],
|
||||
selected_index: Option<usize>,
|
||||
form_state: &FormState,
|
||||
) {
|
||||
if suggestions.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let display_names: Vec<String> = suggestions
|
||||
.iter()
|
||||
.map(|hit| form_state.get_display_name_for_hit(hit))
|
||||
.collect();
|
||||
|
||||
let max_suggestion_width =
|
||||
display_names.iter().map(|s| s.width()).max().unwrap_or(0) as u16;
|
||||
let horizontal_padding: u16 = 2;
|
||||
let dropdown_width = (max_suggestion_width + horizontal_padding).max(10);
|
||||
let dropdown_height = (suggestions.len() as u16).min(5);
|
||||
|
||||
let mut dropdown_area = Rect {
|
||||
x: input_rect.x,
|
||||
y: input_rect.y + 1,
|
||||
width: dropdown_width,
|
||||
height: dropdown_height,
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
let background_block =
|
||||
Block::default().style(Style::default().bg(Color::DarkGray));
|
||||
f.render_widget(background_block, dropdown_area);
|
||||
|
||||
let items: Vec<ListItem> = display_names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, s)| {
|
||||
let is_selected = selected_index == Some(i);
|
||||
let s_width = s.width() as u16;
|
||||
let padding_needed = dropdown_width.saturating_sub(s_width);
|
||||
let padded_s =
|
||||
format!("{}{}", s, " ".repeat(padding_needed as usize));
|
||||
|
||||
ListItem::new(padded_s).style(if is_selected {
|
||||
Style::default()
|
||||
.fg(theme.bg)
|
||||
.bg(theme.highlight)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.fg).bg(Color::DarkGray)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items);
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(selected_index);
|
||||
|
||||
f.render_stateful_widget(list, dropdown_area, &mut list_state);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// src/client/components/command_line.rs
|
||||
// src/components/common/command_line.rs
|
||||
|
||||
use ratatui::{
|
||||
widgets::{Block, Paragraph},
|
||||
style::Style,
|
||||
@@ -6,30 +7,63 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
use crate::config::colors::themes::Theme;
|
||||
use unicode_width::UnicodeWidthStr; // Import for width calculation
|
||||
|
||||
pub fn render_command_line(f: &mut Frame, area: Rect, input: &str, active: bool, theme: &Theme, message: &str) {
|
||||
let prompt = if active {
|
||||
":"
|
||||
} else {
|
||||
""
|
||||
pub fn render_command_line(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
input: &str, // This is event_handler.command_input
|
||||
active: bool, // This is event_handler.command_mode
|
||||
theme: &Theme,
|
||||
message: &str, // This is event_handler.command_message
|
||||
) {
|
||||
// Original logic for determining display_text
|
||||
let display_text = if !active {
|
||||
// If not in normal command mode, but there's a message (e.g. from Find File palette closing)
|
||||
// Or if command mode is off and message is empty (render minimally)
|
||||
if message.is_empty() {
|
||||
"".to_string() // Render an empty string, background will cover
|
||||
} else {
|
||||
message.to_string()
|
||||
}
|
||||
} else { // active is true (normal command mode)
|
||||
let prompt = ":";
|
||||
if message.is_empty() || message == ":" {
|
||||
format!("{}{}", prompt, input)
|
||||
} else {
|
||||
if input.is_empty() { // If command was just executed, input is cleared, show message
|
||||
message.to_string()
|
||||
} else { // Show input and message
|
||||
format!("{}{} | {}", prompt, input, message)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Combine the prompt, input, and message
|
||||
let display_text = if message.is_empty() {
|
||||
format!("{}{}", prompt, input)
|
||||
let content_width = UnicodeWidthStr::width(display_text.as_str());
|
||||
let available_width = area.width as usize;
|
||||
let padding_needed = available_width.saturating_sub(content_width);
|
||||
|
||||
let display_text_padded = if padding_needed > 0 {
|
||||
format!("{}{}", display_text, " ".repeat(padding_needed))
|
||||
} else {
|
||||
format!("{}{} | {}", prompt, input, message)
|
||||
// If text is too long, ratatui's Paragraph will handle truncation.
|
||||
// We could also truncate here if specific behavior is needed:
|
||||
// display_text.chars().take(available_width).collect::<String>()
|
||||
display_text
|
||||
};
|
||||
|
||||
let style = if active {
|
||||
// Determine style based on active state, but apply to the whole paragraph
|
||||
let text_style = if active {
|
||||
Style::default().fg(theme.accent)
|
||||
} else {
|
||||
// If not active, but there's a message, use default foreground.
|
||||
// If message is also empty, this style won't matter much for empty text.
|
||||
Style::default().fg(theme.fg)
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(display_text)
|
||||
.block(Block::default().style(Style::default().bg(theme.bg)))
|
||||
.style(style);
|
||||
let paragraph = Paragraph::new(display_text_padded)
|
||||
.block(Block::default().style(Style::default().bg(theme.bg))) // Block ensures bg for whole area
|
||||
.style(text_style); // Style for the text itself
|
||||
|
||||
f.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ pub fn render_dialog(
|
||||
dialog_message: &str,
|
||||
dialog_buttons: &[String],
|
||||
dialog_active_button_index: usize,
|
||||
is_loading: bool,
|
||||
) {
|
||||
// Calculate required height based on the actual number of lines in the message
|
||||
let message_lines: Vec<_> = dialog_message.lines().collect();
|
||||
@@ -63,27 +64,36 @@ pub fn render_dialog(
|
||||
vertical: 1, // Top/Bottom padding inside border
|
||||
});
|
||||
|
||||
// Layout for Message and Buttons based on actual message height
|
||||
let mut constraints = vec![
|
||||
// Allocate space for message, ensuring at least 1 line height
|
||||
Constraint::Length(message_height.max(1)), // Use actual calculated height
|
||||
];
|
||||
if button_row_height > 0 {
|
||||
constraints.push(Constraint::Length(button_row_height));
|
||||
}
|
||||
if is_loading {
|
||||
// --- Loading State ---
|
||||
let loading_text = Paragraph::new(dialog_message) // Use the message passed for loading
|
||||
.style(Style::default().fg(theme.fg).add_modifier(Modifier::ITALIC))
|
||||
.alignment(Alignment::Center);
|
||||
// Render loading message centered in the inner area
|
||||
f.render_widget(loading_text, inner_area);
|
||||
} else {
|
||||
// --- Normal State (Message + Buttons) ---
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(constraints)
|
||||
.split(inner_area);
|
||||
// Layout for Message and Buttons based on actual message height
|
||||
let mut constraints = vec![
|
||||
// Allocate space for message, ensuring at least 1 line height
|
||||
Constraint::Length(message_height.max(1)), // Use actual calculated height
|
||||
];
|
||||
if button_row_height > 0 {
|
||||
constraints.push(Constraint::Length(button_row_height));
|
||||
}
|
||||
|
||||
// Render Message
|
||||
let available_width = inner_area.width as usize;
|
||||
let ellipsis = "...";
|
||||
let ellipsis_width = UnicodeWidthStr::width(ellipsis);
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(constraints)
|
||||
.split(inner_area);
|
||||
|
||||
let processed_lines: Vec<Line> =
|
||||
message_lines
|
||||
// Render Message
|
||||
let available_width = inner_area.width as usize;
|
||||
let ellipsis = "...";
|
||||
let ellipsis_width = UnicodeWidthStr::width(ellipsis);
|
||||
|
||||
let processed_lines: Vec<Line> = message_lines
|
||||
.into_iter()
|
||||
.map(|line| {
|
||||
let line_width = UnicodeWidthStr::width(line);
|
||||
@@ -91,81 +101,83 @@ pub fn render_dialog(
|
||||
// Truncate with ellipsis
|
||||
let mut truncated_len = 0;
|
||||
let mut current_width = 0;
|
||||
// Iterate over graphemes to handle multi-byte characters correctly
|
||||
for (idx, grapheme) in line.grapheme_indices(true) {
|
||||
let grapheme_width = UnicodeWidthStr::width(grapheme);
|
||||
if current_width + grapheme_width > available_width.saturating_sub(ellipsis_width) {
|
||||
break; // Stop before exceeding width needed for text + ellipsis
|
||||
if current_width + grapheme_width
|
||||
> available_width.saturating_sub(ellipsis_width)
|
||||
{
|
||||
break;
|
||||
}
|
||||
current_width += grapheme_width;
|
||||
truncated_len = idx + grapheme.len(); // Store the byte index of the end of the last fitting grapheme
|
||||
truncated_len = idx + grapheme.len();
|
||||
}
|
||||
let truncated_line = format!("{}{}", &line[..truncated_len], ellipsis);
|
||||
Line::from(Span::styled(truncated_line, Style::default().fg(theme.fg)))
|
||||
let truncated_line =
|
||||
format!("{}{}", &line[..truncated_len], ellipsis);
|
||||
Line::from(Span::styled(
|
||||
truncated_line,
|
||||
Style::default().fg(theme.fg),
|
||||
))
|
||||
} else {
|
||||
// Line fits, use it as is
|
||||
Line::from(Span::styled(line, Style::default().fg(theme.fg)))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
.collect();
|
||||
|
||||
let message_paragraph =
|
||||
Paragraph::new(Text::from(processed_lines)).alignment(Alignment::Center);
|
||||
// Render message in the first chunk
|
||||
f.render_widget(message_paragraph, chunks[0]);
|
||||
let message_paragraph =
|
||||
Paragraph::new(Text::from(processed_lines)).alignment(Alignment::Center);
|
||||
f.render_widget(message_paragraph, chunks[0]); // Render message in the first chunk
|
||||
|
||||
// Render Buttons if they exist and there's a chunk for them
|
||||
if !dialog_buttons.is_empty() && chunks.len() > 1 {
|
||||
let button_area = chunks[1];
|
||||
let button_count = dialog_buttons.len();
|
||||
// Render Buttons if they exist and there's a chunk for them
|
||||
if !dialog_buttons.is_empty() && chunks.len() > 1 {
|
||||
let button_area = chunks[1];
|
||||
let button_count = dialog_buttons.len();
|
||||
|
||||
// Use Ratio for potentially more even distribution with few buttons
|
||||
let button_constraints = std::iter::repeat(Constraint::Ratio(
|
||||
1,
|
||||
button_count as u32,
|
||||
))
|
||||
.take(button_count)
|
||||
.collect::<Vec<_>>();
|
||||
let button_constraints = std::iter::repeat(Constraint::Ratio(
|
||||
1,
|
||||
button_count as u32,
|
||||
))
|
||||
.take(button_count)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let button_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints(button_constraints)
|
||||
.horizontal_margin(1) // Add space between buttons
|
||||
.split(button_area);
|
||||
let button_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints(button_constraints)
|
||||
.horizontal_margin(1) // Add space between buttons
|
||||
.split(button_area);
|
||||
|
||||
for (i, button_label) in dialog_buttons.iter().enumerate() {
|
||||
// Ensure we don't try to render into a non-existent chunk
|
||||
if i >= button_chunks.len() {
|
||||
break;
|
||||
}
|
||||
for (i, button_label) in dialog_buttons.iter().enumerate() {
|
||||
if i >= button_chunks.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let is_active = i == dialog_active_button_index;
|
||||
let (button_style, border_style) = if is_active {
|
||||
(
|
||||
Style::default()
|
||||
let is_active = i == dialog_active_button_index;
|
||||
let (button_style, border_style) = if is_active {
|
||||
(
|
||||
Style::default()
|
||||
.fg(theme.highlight)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(theme.accent), // Highlight border
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Style::default().fg(theme.fg),
|
||||
Style::default().fg(theme.border), // Normal border
|
||||
)
|
||||
};
|
||||
Style::default().fg(theme.accent),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Style::default().fg(theme.fg),
|
||||
Style::default().fg(theme.border),
|
||||
)
|
||||
};
|
||||
|
||||
let button_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Plain)
|
||||
.border_style(border_style);
|
||||
let button_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Plain)
|
||||
.border_style(border_style);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(button_label.as_str())
|
||||
f.render_widget(
|
||||
Paragraph::new(button_label.as_str())
|
||||
.block(button_block)
|
||||
.style(button_style)
|
||||
.alignment(Alignment::Center),
|
||||
button_chunks[i],
|
||||
);
|
||||
button_chunks[i],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
142
client/src/components/common/find_file_palette.rs
Normal file
142
client/src/components/common/find_file_palette.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
// src/components/common/find_file_palette.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::modes::general::command_navigation::NavigationState; // Corrected path
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::Style,
|
||||
widgets::{Block, List, ListItem, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
const PALETTE_MAX_VISIBLE_OPTIONS: usize = 15;
|
||||
const PADDING_CHAR: &str = " ";
|
||||
|
||||
pub fn render_find_file_palette(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
navigation_state: &NavigationState,
|
||||
) {
|
||||
let palette_display_input = navigation_state.get_display_input(); // Use the new method
|
||||
|
||||
let num_total_filtered = navigation_state.filtered_options.len();
|
||||
let current_selected_list_idx = navigation_state.selected_index;
|
||||
|
||||
let mut display_start_offset = 0;
|
||||
if num_total_filtered > PALETTE_MAX_VISIBLE_OPTIONS {
|
||||
if let Some(sel_idx) = current_selected_list_idx {
|
||||
if sel_idx >= display_start_offset + PALETTE_MAX_VISIBLE_OPTIONS {
|
||||
display_start_offset = sel_idx - PALETTE_MAX_VISIBLE_OPTIONS + 1;
|
||||
} else if sel_idx < display_start_offset {
|
||||
display_start_offset = sel_idx;
|
||||
}
|
||||
display_start_offset = display_start_offset
|
||||
.min(num_total_filtered.saturating_sub(PALETTE_MAX_VISIBLE_OPTIONS));
|
||||
}
|
||||
}
|
||||
display_start_offset = display_start_offset.max(0);
|
||||
|
||||
let display_end_offset = (display_start_offset + PALETTE_MAX_VISIBLE_OPTIONS)
|
||||
.min(num_total_filtered);
|
||||
|
||||
// navigation_state.filtered_options is Vec<(usize, String)>
|
||||
// We only need the String part for display.
|
||||
let visible_options_slice: Vec<&String> = if num_total_filtered > 0 {
|
||||
navigation_state.filtered_options
|
||||
[display_start_offset..display_end_offset]
|
||||
.iter()
|
||||
.map(|(_, opt_str)| opt_str)
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // For palette input line
|
||||
Constraint::Min(0), // For options list, take remaining space
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Ensure list_area height does not exceed PALETTE_MAX_VISIBLE_OPTIONS
|
||||
let list_area_height = std::cmp::min(chunks[1].height, PALETTE_MAX_VISIBLE_OPTIONS as u16);
|
||||
let final_list_area = Rect::new(chunks[1].x, chunks[1].y, chunks[1].width, list_area_height);
|
||||
|
||||
|
||||
let input_area = chunks[0];
|
||||
// let list_area = chunks[1]; // Use final_list_area
|
||||
|
||||
let prompt_prefix = match navigation_state.navigation_type {
|
||||
crate::modes::general::command_navigation::NavigationType::FindFile => "Find File: ",
|
||||
crate::modes::general::command_navigation::NavigationType::TableTree => "Table Path: ",
|
||||
};
|
||||
let base_prompt_text = format!("{}{}", prompt_prefix, palette_display_input);
|
||||
let prompt_text_width = UnicodeWidthStr::width(base_prompt_text.as_str());
|
||||
let input_area_width = input_area.width as usize;
|
||||
let input_padding_needed =
|
||||
input_area_width.saturating_sub(prompt_text_width);
|
||||
|
||||
let padded_prompt_text = if input_padding_needed > 0 {
|
||||
format!(
|
||||
"{}{}",
|
||||
base_prompt_text,
|
||||
PADDING_CHAR.repeat(input_padding_needed)
|
||||
)
|
||||
} else {
|
||||
base_prompt_text
|
||||
};
|
||||
|
||||
let input_paragraph = Paragraph::new(padded_prompt_text)
|
||||
.style(Style::default().fg(theme.accent).bg(theme.bg));
|
||||
f.render_widget(input_paragraph, input_area);
|
||||
|
||||
let mut display_list_items: Vec<ListItem> =
|
||||
Vec::with_capacity(PALETTE_MAX_VISIBLE_OPTIONS);
|
||||
|
||||
for (idx_in_visible_slice, opt_str) in
|
||||
visible_options_slice.iter().enumerate()
|
||||
{
|
||||
// The selected_index in navigation_state is relative to the full filtered_options list.
|
||||
// We need to check if the current item (from the visible slice) corresponds to the selected_index.
|
||||
let original_filtered_idx = display_start_offset + idx_in_visible_slice;
|
||||
let is_selected =
|
||||
current_selected_list_idx == Some(original_filtered_idx);
|
||||
|
||||
let style = if is_selected {
|
||||
Style::default().fg(theme.bg).bg(theme.accent)
|
||||
} else {
|
||||
Style::default().fg(theme.fg).bg(theme.bg)
|
||||
};
|
||||
|
||||
let opt_width = opt_str.width() as u16;
|
||||
let list_item_width = final_list_area.width;
|
||||
let padding_amount = list_item_width.saturating_sub(opt_width);
|
||||
let padded_opt_str = format!(
|
||||
"{}{}",
|
||||
opt_str,
|
||||
PADDING_CHAR.repeat(padding_amount as usize)
|
||||
);
|
||||
display_list_items.push(ListItem::new(padded_opt_str).style(style));
|
||||
}
|
||||
|
||||
// Fill remaining lines in the list area to maintain fixed height appearance
|
||||
let num_rendered_options = display_list_items.len();
|
||||
if num_rendered_options < PALETTE_MAX_VISIBLE_OPTIONS && (final_list_area.height as usize) > num_rendered_options {
|
||||
for _ in num_rendered_options..(final_list_area.height as usize) {
|
||||
let empty_padded_str =
|
||||
PADDING_CHAR.repeat(final_list_area.width as usize);
|
||||
display_list_items.push(
|
||||
ListItem::new(empty_padded_str)
|
||||
.style(Style::default().fg(theme.bg).bg(theme.bg)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let options_list_widget = List::new(display_list_items)
|
||||
.block(Block::default().style(Style::default().bg(theme.bg)));
|
||||
f.render_widget(options_list_widget, final_list_area);
|
||||
}
|
||||
121
client/src/components/common/search_palette.rs
Normal file
121
client/src/components/common/search_palette.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
// src/components/common/search_palette.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::search::SearchState;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Renders the search palette dialog over the main UI.
|
||||
pub fn render_search_palette(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
state: &SearchState,
|
||||
) {
|
||||
// --- Dialog Area Calculation ---
|
||||
let height = (area.height as f32 * 0.7).min(30.0) as u16;
|
||||
let width = (area.width as f32 * 0.6).min(100.0) as u16;
|
||||
let dialog_area = Rect {
|
||||
x: area.x + (area.width - width) / 2,
|
||||
y: area.y + (area.height - height) / 4,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
|
||||
f.render_widget(Clear, dialog_area); // Clear background
|
||||
|
||||
let block = Block::default()
|
||||
.title(format!(" Search in '{}' ", state.table_name))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme.accent));
|
||||
f.render_widget(block.clone(), dialog_area);
|
||||
|
||||
// --- Inner Layout (Input + Results) ---
|
||||
let inner_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.margin(1)
|
||||
.constraints([
|
||||
Constraint::Length(3), // For input box
|
||||
Constraint::Min(0), // For results list
|
||||
])
|
||||
.split(dialog_area);
|
||||
|
||||
// --- Render Input Box ---
|
||||
let input_block = Block::default()
|
||||
.title("Query")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme.border));
|
||||
let input_text = Paragraph::new(state.input.as_str())
|
||||
.block(input_block)
|
||||
.style(Style::default().fg(theme.fg));
|
||||
f.render_widget(input_text, inner_chunks[0]);
|
||||
// Set cursor position
|
||||
f.set_cursor_position((
|
||||
inner_chunks[0].x + state.cursor_position as u16 + 1,
|
||||
inner_chunks[0].y + 1,
|
||||
));
|
||||
|
||||
// --- Render Results List ---
|
||||
if state.is_loading {
|
||||
let loading_p = Paragraph::new("Searching...")
|
||||
.style(Style::default().fg(theme.fg).add_modifier(Modifier::ITALIC));
|
||||
f.render_widget(loading_p, inner_chunks[1]);
|
||||
} else {
|
||||
let list_items: Vec<ListItem> = state
|
||||
.results
|
||||
.iter()
|
||||
.map(|hit| {
|
||||
// Parse the JSON string to make it readable
|
||||
let content_summary = match serde_json::from_str::<
|
||||
serde_json::Value,
|
||||
>(&hit.content_json)
|
||||
{
|
||||
Ok(json) => {
|
||||
if let Some(obj) = json.as_object() {
|
||||
// Create a summary from the first few non-null string values
|
||||
obj.values()
|
||||
.filter_map(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.take(3)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" | ")
|
||||
} else {
|
||||
"Non-object JSON".to_string()
|
||||
}
|
||||
}
|
||||
Err(_) => "Invalid JSON content".to_string(),
|
||||
};
|
||||
|
||||
let line = Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{:<4.2} ", hit.score),
|
||||
Style::default().fg(theme.accent),
|
||||
),
|
||||
Span::raw(content_summary),
|
||||
]);
|
||||
ListItem::new(line)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let results_list = List::new(list_items)
|
||||
.block(Block::default().title("Results"))
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.bg(theme.highlight)
|
||||
.fg(theme.bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol(">> ");
|
||||
|
||||
// We need a mutable ListState to render the selection
|
||||
let mut list_state =
|
||||
ratatui::widgets::ListState::default().with_selected(Some(state.selected_index));
|
||||
|
||||
f.render_stateful_widget(results_list, inner_chunks[1], &mut list_state);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
use ratatui::{
|
||||
style::Style,
|
||||
layout::Rect,
|
||||
Frame,
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
// client/src/components/common/status_line.rs
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::state::AppState;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span, Text},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
use ratatui::widgets::Wrap;
|
||||
use std::path::Path;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
pub fn render_status_line(
|
||||
f: &mut Frame,
|
||||
@@ -16,11 +19,41 @@ pub fn render_status_line(
|
||||
theme: &Theme,
|
||||
is_edit_mode: bool,
|
||||
current_fps: f64,
|
||||
app_state: &AppState,
|
||||
) {
|
||||
let program_info = format!("multieko2 v{}", env!("CARGO_PKG_VERSION"));
|
||||
#[cfg(feature = "ui-debug")]
|
||||
{
|
||||
if let Some(debug_state) = &app_state.debug_state {
|
||||
let paragraph = if debug_state.is_error {
|
||||
// --- THIS IS THE CRITICAL LOGIC FOR ERRORS ---
|
||||
// 1. Create a `Text` object, which can contain multiple lines.
|
||||
let error_text = Text::from(debug_state.displayed_message.clone());
|
||||
|
||||
// 2. Create a Paragraph from the Text and TELL IT TO WRAP.
|
||||
Paragraph::new(error_text)
|
||||
.wrap(Wrap { trim: true }) // This line makes the text break into new rows.
|
||||
.style(Style::default().bg(theme.highlight).fg(theme.bg))
|
||||
} else {
|
||||
// --- This is for normal, single-line info messages ---
|
||||
Paragraph::new(debug_state.displayed_message.as_str())
|
||||
.style(Style::default().fg(theme.accent).bg(theme.bg))
|
||||
};
|
||||
f.render_widget(paragraph, area);
|
||||
} else {
|
||||
// Fallback for when debug state is None
|
||||
let paragraph = Paragraph::new("").style(Style::default().bg(theme.bg));
|
||||
f.render_widget(paragraph, area);
|
||||
}
|
||||
return; // Stop here and don't render the normal status line.
|
||||
}
|
||||
|
||||
// --- The normal status line rendering logic (unchanged) ---
|
||||
let program_info = format!("komp_ac v{}", env!("CARGO_PKG_VERSION"));
|
||||
let mode_text = if is_edit_mode { "[EDIT]" } else { "[READ-ONLY]" };
|
||||
|
||||
let home_dir = dirs::home_dir().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let home_dir = dirs::home_dir()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let display_dir = if current_dir.starts_with(&home_dir) {
|
||||
current_dir.replacen(&home_dir, "~", 1)
|
||||
} else {
|
||||
@@ -35,16 +68,30 @@ pub fn render_status_line(
|
||||
let separator = " | ";
|
||||
let separator_width = UnicodeWidthStr::width(separator);
|
||||
|
||||
let fixed_width_with_fps = mode_width + separator_width + separator_width +
|
||||
program_info_width + separator_width + fps_width;
|
||||
let show_fps = fixed_width_with_fps < available_width;
|
||||
let fixed_width_with_fps = mode_width
|
||||
+ separator_width
|
||||
+ separator_width
|
||||
+ program_info_width
|
||||
+ separator_width
|
||||
+ fps_width;
|
||||
|
||||
let show_fps = fixed_width_with_fps <= available_width;
|
||||
|
||||
let remaining_width_for_dir = available_width.saturating_sub(
|
||||
mode_width + separator_width + separator_width + program_info_width +
|
||||
if show_fps { separator_width + fps_width } else { 0 }
|
||||
mode_width
|
||||
+ separator_width
|
||||
+ separator_width
|
||||
+ program_info_width
|
||||
+ (if show_fps {
|
||||
separator_width + fps_width
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
);
|
||||
|
||||
let dir_display_text = if UnicodeWidthStr::width(display_dir.as_str()) <= remaining_width_for_dir {
|
||||
let dir_display_text_str = if UnicodeWidthStr::width(display_dir.as_str())
|
||||
<= remaining_width_for_dir
|
||||
{
|
||||
display_dir
|
||||
} else {
|
||||
let dir_name = Path::new(current_dir)
|
||||
@@ -54,25 +101,55 @@ pub fn render_status_line(
|
||||
if UnicodeWidthStr::width(dir_name) <= remaining_width_for_dir {
|
||||
dir_name.to_string()
|
||||
} else {
|
||||
dir_name.chars().take(remaining_width_for_dir).collect()
|
||||
dir_name
|
||||
.chars()
|
||||
.take(remaining_width_for_dir)
|
||||
.collect::<String>()
|
||||
}
|
||||
};
|
||||
|
||||
let mut spans = vec![
|
||||
let mut current_content_width = mode_width
|
||||
+ separator_width
|
||||
+ UnicodeWidthStr::width(dir_display_text_str.as_str())
|
||||
+ separator_width
|
||||
+ program_info_width;
|
||||
if show_fps {
|
||||
current_content_width += separator_width + fps_width;
|
||||
}
|
||||
|
||||
let mut line_spans = vec![
|
||||
Span::styled(mode_text, Style::default().fg(theme.accent)),
|
||||
Span::styled(" | ", Style::default().fg(theme.border)),
|
||||
Span::styled(dir_display_text, Style::default().fg(theme.fg)),
|
||||
Span::styled(" | ", Style::default().fg(theme.border)),
|
||||
Span::styled(program_info, Style::default().fg(theme.secondary)),
|
||||
Span::styled(separator, Style::default().fg(theme.border)),
|
||||
Span::styled(
|
||||
dir_display_text_str.as_str(),
|
||||
Style::default().fg(theme.fg),
|
||||
),
|
||||
Span::styled(separator, Style::default().fg(theme.border)),
|
||||
Span::styled(
|
||||
program_info.as_str(),
|
||||
Style::default().fg(theme.secondary),
|
||||
),
|
||||
];
|
||||
|
||||
if show_fps {
|
||||
spans.push(Span::styled(" | ", Style::default().fg(theme.border)));
|
||||
spans.push(Span::styled(fps_text, Style::default().fg(theme.secondary)));
|
||||
line_spans
|
||||
.push(Span::styled(separator, Style::default().fg(theme.border)));
|
||||
line_spans.push(Span::styled(
|
||||
fps_text.as_str(),
|
||||
Style::default().fg(theme.secondary),
|
||||
));
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(Line::from(spans))
|
||||
.style(Style::default().bg(theme.bg));
|
||||
let padding_needed = available_width.saturating_sub(current_content_width);
|
||||
if padding_needed > 0 {
|
||||
line_spans.push(Span::styled(
|
||||
" ".repeat(padding_needed),
|
||||
Style::default().bg(theme.bg),
|
||||
));
|
||||
}
|
||||
|
||||
let paragraph =
|
||||
Paragraph::new(Line::from(line_spans)).style(Style::default().bg(theme.bg));
|
||||
|
||||
f.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
331
client/src/components/common/text_editor.rs
Normal file
331
client/src/components/common/text_editor.rs
Normal file
@@ -0,0 +1,331 @@
|
||||
// src/components/common/text_editor.rs
|
||||
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
|
||||
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
|
||||
use ratatui::style::{Color, Style, Modifier};
|
||||
use tui_textarea::{Input, Key, TextArea, CursorMove};
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VimMode {
|
||||
Normal,
|
||||
Insert,
|
||||
Visual,
|
||||
Operator(char),
|
||||
}
|
||||
|
||||
impl VimMode {
|
||||
pub fn cursor_style(&self) -> Style {
|
||||
let color = match self {
|
||||
Self::Normal => Color::Reset,
|
||||
Self::Insert => Color::LightBlue,
|
||||
Self::Visual => Color::LightYellow,
|
||||
Self::Operator(_) => Color::LightGreen,
|
||||
};
|
||||
Style::default().fg(color).add_modifier(Modifier::REVERSED)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for VimMode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
|
||||
match self {
|
||||
Self::Normal => write!(f, "NORMAL"),
|
||||
Self::Insert => write!(f, "INSERT"),
|
||||
Self::Visual => write!(f, "VISUAL"),
|
||||
Self::Operator(c) => write!(f, "OPERATOR({})", c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Transition {
|
||||
Nop,
|
||||
Mode(VimMode),
|
||||
Pending(Input),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VimState {
|
||||
pub mode: VimMode,
|
||||
pub pending: Input,
|
||||
}
|
||||
|
||||
impl Default for VimState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: VimMode::Normal,
|
||||
pending: Input::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VimState {
|
||||
pub fn new(mode: VimMode) -> Self {
|
||||
Self {
|
||||
mode,
|
||||
pending: Input::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_pending(self, pending: Input) -> Self {
|
||||
Self {
|
||||
mode: self.mode,
|
||||
pending,
|
||||
}
|
||||
}
|
||||
|
||||
fn transition(&self, input: Input, textarea: &mut TextArea<'_>) -> Transition {
|
||||
if input.key == Key::Null {
|
||||
return Transition::Nop;
|
||||
}
|
||||
|
||||
match self.mode {
|
||||
VimMode::Normal | VimMode::Visual | VimMode::Operator(_) => {
|
||||
match input {
|
||||
Input { key: Key::Char('h'), .. } => textarea.move_cursor(CursorMove::Back),
|
||||
Input { key: Key::Char('j'), .. } => textarea.move_cursor(CursorMove::Down),
|
||||
Input { key: Key::Char('k'), .. } => textarea.move_cursor(CursorMove::Up),
|
||||
Input { key: Key::Char('l'), .. } => textarea.move_cursor(CursorMove::Forward),
|
||||
Input { key: Key::Char('w'), .. } => textarea.move_cursor(CursorMove::WordForward),
|
||||
Input { key: Key::Char('e'), ctrl: false, .. } => {
|
||||
textarea.move_cursor(CursorMove::WordEnd);
|
||||
if matches!(self.mode, VimMode::Operator(_)) {
|
||||
textarea.move_cursor(CursorMove::Forward);
|
||||
}
|
||||
}
|
||||
Input { key: Key::Char('b'), ctrl: false, .. } => textarea.move_cursor(CursorMove::WordBack),
|
||||
Input { key: Key::Char('^'), .. } => textarea.move_cursor(CursorMove::Head),
|
||||
Input { key: Key::Char('$'), .. } => textarea.move_cursor(CursorMove::End),
|
||||
Input { key: Key::Char('0'), .. } => textarea.move_cursor(CursorMove::Head),
|
||||
Input { key: Key::Char('D'), .. } => {
|
||||
textarea.delete_line_by_end();
|
||||
return Transition::Mode(VimMode::Normal);
|
||||
}
|
||||
Input { key: Key::Char('C'), .. } => {
|
||||
textarea.delete_line_by_end();
|
||||
textarea.cancel_selection();
|
||||
return Transition::Mode(VimMode::Insert);
|
||||
}
|
||||
Input { key: Key::Char('p'), .. } => {
|
||||
textarea.paste();
|
||||
return Transition::Mode(VimMode::Normal);
|
||||
}
|
||||
Input { key: Key::Char('u'), ctrl: false, .. } => {
|
||||
textarea.undo();
|
||||
return Transition::Mode(VimMode::Normal);
|
||||
}
|
||||
Input { key: Key::Char('r'), ctrl: true, .. } => {
|
||||
textarea.redo();
|
||||
return Transition::Mode(VimMode::Normal);
|
||||
}
|
||||
Input { key: Key::Char('x'), .. } => {
|
||||
textarea.delete_next_char();
|
||||
return Transition::Mode(VimMode::Normal);
|
||||
}
|
||||
Input { key: Key::Char('i'), .. } => {
|
||||
textarea.cancel_selection();
|
||||
return Transition::Mode(VimMode::Insert);
|
||||
}
|
||||
Input { key: Key::Char('a'), .. } => {
|
||||
textarea.cancel_selection();
|
||||
textarea.move_cursor(CursorMove::Forward);
|
||||
return Transition::Mode(VimMode::Insert);
|
||||
}
|
||||
Input { key: Key::Char('A'), .. } => {
|
||||
textarea.cancel_selection();
|
||||
textarea.move_cursor(CursorMove::End);
|
||||
return Transition::Mode(VimMode::Insert);
|
||||
}
|
||||
Input { key: Key::Char('o'), .. } => {
|
||||
textarea.move_cursor(CursorMove::End);
|
||||
textarea.insert_newline();
|
||||
return Transition::Mode(VimMode::Insert);
|
||||
}
|
||||
Input { key: Key::Char('O'), .. } => {
|
||||
textarea.move_cursor(CursorMove::Head);
|
||||
textarea.insert_newline();
|
||||
textarea.move_cursor(CursorMove::Up);
|
||||
return Transition::Mode(VimMode::Insert);
|
||||
}
|
||||
Input { key: Key::Char('I'), .. } => {
|
||||
textarea.cancel_selection();
|
||||
textarea.move_cursor(CursorMove::Head);
|
||||
return Transition::Mode(VimMode::Insert);
|
||||
}
|
||||
Input { key: Key::Char('v'), ctrl: false, .. } if self.mode == VimMode::Normal => {
|
||||
textarea.start_selection();
|
||||
return Transition::Mode(VimMode::Visual);
|
||||
}
|
||||
Input { key: Key::Char('V'), ctrl: false, .. } if self.mode == VimMode::Normal => {
|
||||
textarea.move_cursor(CursorMove::Head);
|
||||
textarea.start_selection();
|
||||
textarea.move_cursor(CursorMove::End);
|
||||
return Transition::Mode(VimMode::Visual);
|
||||
}
|
||||
Input { key: Key::Esc, .. } | Input { key: Key::Char('v'), ctrl: false, .. } if self.mode == VimMode::Visual => {
|
||||
textarea.cancel_selection();
|
||||
return Transition::Mode(VimMode::Normal);
|
||||
}
|
||||
Input { key: Key::Char('g'), ctrl: false, .. } if matches!(
|
||||
self.pending,
|
||||
Input { key: Key::Char('g'), ctrl: false, .. }
|
||||
) => {
|
||||
textarea.move_cursor(CursorMove::Top)
|
||||
}
|
||||
Input { key: Key::Char('G'), ctrl: false, .. } => textarea.move_cursor(CursorMove::Bottom),
|
||||
Input { key: Key::Char(c), ctrl: false, .. } if self.mode == VimMode::Operator(c) => {
|
||||
textarea.move_cursor(CursorMove::Head);
|
||||
textarea.start_selection();
|
||||
let cursor = textarea.cursor();
|
||||
textarea.move_cursor(CursorMove::Down);
|
||||
if cursor == textarea.cursor() {
|
||||
textarea.move_cursor(CursorMove::End);
|
||||
}
|
||||
}
|
||||
Input { key: Key::Char(op @ ('y' | 'd' | 'c')), ctrl: false, .. } if self.mode == VimMode::Normal => {
|
||||
textarea.start_selection();
|
||||
return Transition::Mode(VimMode::Operator(op));
|
||||
}
|
||||
Input { key: Key::Char('y'), ctrl: false, .. } if self.mode == VimMode::Visual => {
|
||||
textarea.move_cursor(CursorMove::Forward);
|
||||
textarea.copy();
|
||||
return Transition::Mode(VimMode::Normal);
|
||||
}
|
||||
Input { key: Key::Char('d'), ctrl: false, .. } if self.mode == VimMode::Visual => {
|
||||
textarea.move_cursor(CursorMove::Forward);
|
||||
textarea.cut();
|
||||
return Transition::Mode(VimMode::Normal);
|
||||
}
|
||||
Input { key: Key::Char('c'), ctrl: false, .. } if self.mode == VimMode::Visual => {
|
||||
textarea.move_cursor(CursorMove::Forward);
|
||||
textarea.cut();
|
||||
return Transition::Mode(VimMode::Insert);
|
||||
}
|
||||
// Arrow keys work in normal mode
|
||||
Input { key: Key::Up, .. } => textarea.move_cursor(CursorMove::Up),
|
||||
Input { key: Key::Down, .. } => textarea.move_cursor(CursorMove::Down),
|
||||
Input { key: Key::Left, .. } => textarea.move_cursor(CursorMove::Back),
|
||||
Input { key: Key::Right, .. } => textarea.move_cursor(CursorMove::Forward),
|
||||
input => return Transition::Pending(input),
|
||||
}
|
||||
|
||||
// Handle the pending operator
|
||||
match self.mode {
|
||||
VimMode::Operator('y') => {
|
||||
textarea.copy();
|
||||
Transition::Mode(VimMode::Normal)
|
||||
}
|
||||
VimMode::Operator('d') => {
|
||||
textarea.cut();
|
||||
Transition::Mode(VimMode::Normal)
|
||||
}
|
||||
VimMode::Operator('c') => {
|
||||
textarea.cut();
|
||||
Transition::Mode(VimMode::Insert)
|
||||
}
|
||||
_ => Transition::Nop,
|
||||
}
|
||||
}
|
||||
VimMode::Insert => match input {
|
||||
Input { key: Key::Esc, .. } | Input { key: Key::Char('c'), ctrl: true, .. } => {
|
||||
Transition::Mode(VimMode::Normal)
|
||||
}
|
||||
input => {
|
||||
textarea.input(input);
|
||||
Transition::Mode(VimMode::Insert)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TextEditor;
|
||||
|
||||
impl TextEditor {
|
||||
pub fn new_textarea(editor_config: &EditorConfig) -> TextArea<'static> {
|
||||
let mut textarea = TextArea::default();
|
||||
|
||||
if editor_config.show_line_numbers {
|
||||
textarea.set_line_number_style(Style::default().fg(Color::DarkGray));
|
||||
}
|
||||
|
||||
textarea.set_tab_length(editor_config.tab_width);
|
||||
|
||||
textarea
|
||||
}
|
||||
|
||||
pub fn handle_input(
|
||||
textarea: &mut TextArea<'static>,
|
||||
key_event: KeyEvent,
|
||||
keybinding_mode: &EditorKeybindingMode,
|
||||
vim_state: &mut VimState,
|
||||
) -> bool {
|
||||
match keybinding_mode {
|
||||
EditorKeybindingMode::Vim => {
|
||||
Self::handle_vim_input(textarea, key_event, vim_state)
|
||||
}
|
||||
_ => {
|
||||
let tui_input: Input = key_event.into();
|
||||
textarea.input(tui_input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_vim_input(
|
||||
textarea: &mut TextArea<'static>,
|
||||
key_event: KeyEvent,
|
||||
vim_state: &mut VimState,
|
||||
) -> bool {
|
||||
let input = Self::convert_key_event_to_input(key_event);
|
||||
|
||||
*vim_state = match vim_state.transition(input, textarea) {
|
||||
Transition::Mode(mode) if vim_state.mode != mode => {
|
||||
// Update cursor style based on mode
|
||||
textarea.set_cursor_style(mode.cursor_style());
|
||||
VimState::new(mode)
|
||||
}
|
||||
Transition::Nop | Transition::Mode(_) => vim_state.clone(),
|
||||
Transition::Pending(input) => vim_state.clone().with_pending(input),
|
||||
};
|
||||
|
||||
true // Always consider input as handled in vim mode
|
||||
}
|
||||
|
||||
fn convert_key_event_to_input(key_event: KeyEvent) -> Input {
|
||||
let key = match key_event.code {
|
||||
KeyCode::Char(c) => Key::Char(c),
|
||||
KeyCode::Enter => Key::Enter,
|
||||
KeyCode::Left => Key::Left,
|
||||
KeyCode::Right => Key::Right,
|
||||
KeyCode::Up => Key::Up,
|
||||
KeyCode::Down => Key::Down,
|
||||
KeyCode::Backspace => Key::Backspace,
|
||||
KeyCode::Delete => Key::Delete,
|
||||
KeyCode::Home => Key::Home,
|
||||
KeyCode::End => Key::End,
|
||||
KeyCode::PageUp => Key::PageUp,
|
||||
KeyCode::PageDown => Key::PageDown,
|
||||
KeyCode::Tab => Key::Tab,
|
||||
KeyCode::Esc => Key::Esc,
|
||||
_ => Key::Null,
|
||||
};
|
||||
|
||||
Input {
|
||||
key,
|
||||
ctrl: key_event.modifiers.contains(KeyModifiers::CONTROL),
|
||||
alt: key_event.modifiers.contains(KeyModifiers::ALT),
|
||||
shift: key_event.modifiers.contains(KeyModifiers::SHIFT),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_vim_mode_status(vim_state: &VimState) -> String {
|
||||
vim_state.mode.to_string()
|
||||
}
|
||||
|
||||
pub fn is_vim_insert_mode(vim_state: &VimState) -> bool {
|
||||
matches!(vim_state.mode, VimMode::Insert)
|
||||
}
|
||||
|
||||
pub fn is_vim_normal_mode(vim_state: &VimState) -> bool {
|
||||
matches!(vim_state.mode, VimMode::Normal)
|
||||
}
|
||||
}
|
||||
@@ -1,69 +1,98 @@
|
||||
// src/components/form/form.rs
|
||||
use crate::components::common::autocomplete;
|
||||
use crate::config::colors::themes::Theme;
|
||||
use canvas::canvas::{CanvasState, render_canvas, HighlightState};
|
||||
use crate::state::pages::form::FormState;
|
||||
use ratatui::{
|
||||
widgets::{Paragraph, Block, Borders},
|
||||
layout::{Layout, Constraint, Direction, Rect, Margin, Alignment},
|
||||
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
|
||||
style::Style,
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use crate::components::handlers::canvas::render_canvas;
|
||||
|
||||
pub fn render_form(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
form_state: &impl CanvasState,
|
||||
form_state: &FormState,
|
||||
fields: &[&str],
|
||||
current_field: &usize,
|
||||
current_field_idx: &usize,
|
||||
inputs: &[&String],
|
||||
table_name: &str,
|
||||
theme: &Theme,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
total_count: u64,
|
||||
current_position: u64,
|
||||
) {
|
||||
// Create Adresar card
|
||||
let card_title = format!(" {} ", table_name);
|
||||
|
||||
let adresar_card = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme.border))
|
||||
.title(" Adresar ")
|
||||
.title(card_title)
|
||||
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||||
|
||||
f.render_widget(adresar_card, area);
|
||||
|
||||
// Define inner area
|
||||
let inner_area = area.inner(Margin {
|
||||
horizontal: 1,
|
||||
vertical: 1,
|
||||
});
|
||||
|
||||
// Create main layout
|
||||
let main_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.constraints([Constraint::Length(1), Constraint::Min(1)])
|
||||
.split(inner_area);
|
||||
|
||||
// Render count/position
|
||||
let count_position_text = format!("Total: {} | Position: {}", total_count, current_position);
|
||||
let count_position_text = if total_count == 0 && current_position == 1 {
|
||||
"Total: 0 | New Entry".to_string()
|
||||
} else if current_position > total_count && total_count > 0 {
|
||||
format!("Total: {} | New Entry ({})", total_count, current_position)
|
||||
} else if total_count == 0 && current_position > 1 {
|
||||
format!("Total: 0 | New Entry ({})", current_position)
|
||||
} else {
|
||||
format!(
|
||||
"Total: {} | Position: {}/{}",
|
||||
total_count, current_position, total_count
|
||||
)
|
||||
};
|
||||
|
||||
let count_para = Paragraph::new(count_position_text)
|
||||
.style(Style::default().fg(theme.fg))
|
||||
.alignment(Alignment::Left);
|
||||
f.render_widget(count_para, main_layout[0]);
|
||||
|
||||
// Delegate input handling to canvas
|
||||
render_canvas(
|
||||
// Use the canvas library's render_canvas function
|
||||
let active_field_rect = render_canvas(
|
||||
f,
|
||||
main_layout[1],
|
||||
form_state,
|
||||
fields,
|
||||
current_field,
|
||||
inputs,
|
||||
theme,
|
||||
is_edit_mode,
|
||||
highlight_state,
|
||||
);
|
||||
|
||||
// --- RENDER RICH AUTOCOMPLETE ONLY ---
|
||||
if form_state.autocomplete_active {
|
||||
if let Some(active_rect) = active_field_rect {
|
||||
// Get selected index directly from form_state
|
||||
let selected_index = form_state.selected_suggestion_index;
|
||||
|
||||
// Only render rich suggestions (your Hit objects)
|
||||
if let Some(rich_suggestions) = form_state.get_rich_suggestions() {
|
||||
if !rich_suggestions.is_empty() {
|
||||
autocomplete::render_hit_autocomplete_dropdown(
|
||||
f,
|
||||
active_rect,
|
||||
f.area(),
|
||||
theme,
|
||||
rich_suggestions,
|
||||
selected_index,
|
||||
form_state,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Removed simple suggestions - we only use rich ones now!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// src/components/handlers.rs
|
||||
pub mod canvas;
|
||||
pub mod sidebar;
|
||||
pub mod buffer_list;
|
||||
|
||||
pub use canvas::*;
|
||||
pub use sidebar::*;
|
||||
pub use buffer_list::*;
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::buffer::BufferState;
|
||||
use crate::state::app::state::AppState; // Add this import
|
||||
use ratatui::{
|
||||
layout::{Alignment, Rect},
|
||||
style::{Style, Stylize},
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
@@ -17,6 +18,7 @@ pub fn render_buffer_list(
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
buffer_state: &BufferState,
|
||||
app_state: &AppState,
|
||||
) {
|
||||
// --- Style Definitions ---
|
||||
let active_style = Style::default()
|
||||
@@ -37,6 +39,8 @@ pub fn render_buffer_list(
|
||||
let mut spans = Vec::new();
|
||||
let mut current_width = 0;
|
||||
|
||||
let current_table_name = app_state.current_view_table_name.as_deref();
|
||||
|
||||
for (original_index, view) in buffer_state.history.iter().enumerate() {
|
||||
// Filter: Only process views matching the active layer
|
||||
if get_view_layer(view) != active_layer {
|
||||
@@ -44,7 +48,7 @@ pub fn render_buffer_list(
|
||||
}
|
||||
|
||||
let is_active = original_index == buffer_state.active_index;
|
||||
let buffer_name = view.display_name();
|
||||
let buffer_name = view.display_name_with_context(current_table_name);
|
||||
let buffer_text = format!(" {} ", buffer_name);
|
||||
let text_width = UnicodeWidthStr::width(buffer_text.as_str());
|
||||
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
// src/components/handlers/canvas.rs
|
||||
use ratatui::{
|
||||
widgets::{Paragraph, Block, Borders},
|
||||
layout::{Layout, Constraint, Direction, Rect},
|
||||
style::{Style, Modifier},
|
||||
text::{Line, Span},
|
||||
Frame,
|
||||
prelude::Alignment,
|
||||
};
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use crate::state::app::highlight::HighlightState; // Ensure correct import path
|
||||
use std::cmp::{min, max};
|
||||
|
||||
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, // Using the enum state
|
||||
) -> Option<Rect> {
|
||||
// ... (setup code remains the same) ...
|
||||
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;
|
||||
|
||||
// Render labels
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Render inputs and cursor
|
||||
for (i, input) in inputs.iter().enumerate() {
|
||||
let is_active = i == *current_field_idx;
|
||||
let current_cursor_pos = form_state.current_cursor_pos();
|
||||
let text = input.as_str();
|
||||
let text_len = text.chars().count();
|
||||
|
||||
let line: Line;
|
||||
|
||||
// --- Use match on the highlight_state enum ---
|
||||
match highlight_state {
|
||||
HighlightState::Off => {
|
||||
// Not in highlight mode, render normally
|
||||
line = Line::from(Span::styled(
|
||||
text,
|
||||
if is_active { Style::default().fg(theme.highlight) } else { Style::default().fg(theme.fg) }
|
||||
));
|
||||
}
|
||||
HighlightState::Characterwise { anchor } => {
|
||||
// --- Character-wise Highlight Logic ---
|
||||
let (anchor_field, anchor_char) = *anchor;
|
||||
let start_field = min(anchor_field, *current_field_idx);
|
||||
let end_field = max(anchor_field, *current_field_idx);
|
||||
|
||||
// Use start_char and end_char consistently
|
||||
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 {
|
||||
// This line is within the character-wise highlight range
|
||||
if start_field == end_field { // Case 1: Single Line Highlight
|
||||
// Use start_char and end_char here
|
||||
let clamped_start = start_char.min(text_len);
|
||||
let clamped_end = end_char.min(text_len); // Use text_len for slicing logic
|
||||
|
||||
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();
|
||||
// Define 'after' here
|
||||
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), // Use defined 'after'
|
||||
]);
|
||||
} else if i == start_field { // Case 2: Multi-Line Highlight - Start Line
|
||||
// Use start_char here
|
||||
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 { // Case 3: Multi-Line Highlight - End Line (Corrected index)
|
||||
// Use end_char here
|
||||
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 { // Case 4: Multi-Line Highlight - Middle Line (Corrected index)
|
||||
line = Line::from(Span::styled(text, highlight_style)); // Highlight whole line
|
||||
}
|
||||
} else { // Case 5: Line Outside Character-wise Highlight Range
|
||||
line = Line::from(Span::styled(
|
||||
text,
|
||||
// Use normal styling (active or inactive)
|
||||
if is_active { normal_style_in_highlight } else { normal_style_outside }
|
||||
));
|
||||
}
|
||||
}
|
||||
HighlightState::Linewise { anchor_line } => {
|
||||
// --- Linewise Highlight Logic ---
|
||||
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 {
|
||||
// Highlight the entire line
|
||||
line = Line::from(Span::styled(text, highlight_style));
|
||||
} else {
|
||||
// Line outside linewise highlight range
|
||||
line = Line::from(Span::styled(
|
||||
text,
|
||||
// Use normal styling (active or inactive)
|
||||
if is_active { normal_style_in_highlight } else { normal_style_outside }
|
||||
));
|
||||
}
|
||||
}
|
||||
} // End match highlight_state
|
||||
|
||||
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]);
|
||||
let cursor_x = 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,12 +6,17 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
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 crate::components::utils::text::truncate_string;
|
||||
|
||||
// Reduced sidebar width
|
||||
const SIDEBAR_WIDTH: u16 = 12;
|
||||
const SIDEBAR_WIDTH: u16 = 20;
|
||||
|
||||
// --- Icons ---
|
||||
const ICON_PROFILE: &str = "📁";
|
||||
const ICON_TABLE: &str = "📄";
|
||||
|
||||
pub fn calculate_sidebar_layout(show_sidebar: bool, main_content_area: Rect) -> (Option<Rect>, Rect) {
|
||||
if show_sidebar {
|
||||
let chunks = Layout::default()
|
||||
@@ -36,18 +41,54 @@ pub fn render_sidebar(
|
||||
) {
|
||||
let sidebar_block = Block::default().style(Style::default().bg(theme.bg));
|
||||
let mut items = Vec::new();
|
||||
let profile_name_available_width = (SIDEBAR_WIDTH as usize).saturating_sub(3);
|
||||
let table_name_available_width = (SIDEBAR_WIDTH as usize).saturating_sub(5);
|
||||
|
||||
if let Some(profile_name) = selected_profile {
|
||||
// Existing code for when a profile is selected...
|
||||
// Find the selected profile in the tree
|
||||
if let Some(profile) = profile_tree
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|p| &p.name == profile_name)
|
||||
{
|
||||
// Add profile name as header
|
||||
items.push(ListItem::new(Line::from(vec![
|
||||
Span::styled(format!("{} ", ICON_PROFILE), Style::default().fg(theme.accent)),
|
||||
Span::styled(
|
||||
truncate_string(&profile.name, profile_name_available_width),
|
||||
Style::default().fg(theme.highlight)
|
||||
),
|
||||
])));
|
||||
|
||||
// List tables for the selected profile
|
||||
for table in &profile.tables {
|
||||
// Get table name without year prefix to save space
|
||||
let display_name = if table.name.starts_with("2025_") {
|
||||
&table.name[5..] // Skip "2025_" prefix
|
||||
} else {
|
||||
&table.name
|
||||
};
|
||||
items.push(ListItem::new(Line::from(vec![
|
||||
Span::raw(" "), // Indentation
|
||||
Span::styled(format!("{} ", ICON_TABLE), Style::default().fg(theme.secondary)),
|
||||
Span::styled(
|
||||
truncate_string(display_name, table_name_available_width),
|
||||
theme.fg
|
||||
),
|
||||
])));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Show full profile tree when no profile is selected (compact version)
|
||||
for (profile_idx, profile) in profile_tree.profiles.iter().enumerate() {
|
||||
// Profile header - more compact
|
||||
items.push(ListItem::new(Line::from(vec![
|
||||
Span::styled("◆", Style::default().fg(theme.accent)),
|
||||
Span::styled(&profile.name, Style::default().fg(theme.highlight)),
|
||||
Span::styled(format!("{} ", ICON_PROFILE), Style::default().fg(theme.accent)),
|
||||
Span::styled(
|
||||
&profile.name,
|
||||
Style::default().fg(theme.highlight)
|
||||
),
|
||||
])));
|
||||
|
||||
// Tables with compact prefixes
|
||||
for (table_idx, table) in profile.tables.iter().enumerate() {
|
||||
let is_last_table = table_idx == profile.tables.len() - 1;
|
||||
@@ -68,18 +109,18 @@ pub fn render_sidebar(
|
||||
&table.name
|
||||
};
|
||||
|
||||
let mut line = vec![
|
||||
Span::styled(prefix, Style::default().fg(theme.fg)),
|
||||
Span::styled(display_name, Style::default().fg(theme.fg)),
|
||||
];
|
||||
// Adjust available width if dependency arrow is shown
|
||||
let current_table_available_width = if !table.depends_on.is_empty() {
|
||||
table_name_available_width.saturating_sub(1)
|
||||
} else {
|
||||
table_name_available_width
|
||||
};
|
||||
|
||||
// Show a simple indicator for dependencies instead of listing them
|
||||
if !table.depends_on.is_empty() {
|
||||
line.push(Span::styled(
|
||||
"→",
|
||||
Style::default().fg(theme.secondary)
|
||||
));
|
||||
}
|
||||
let line = vec![
|
||||
Span::styled(prefix, Style::default().fg(theme.fg)),
|
||||
Span::styled(format!("{} ", ICON_TABLE), Style::default().fg(theme.secondary)),
|
||||
Span::styled(truncate_string(display_name, current_table_available_width), Style::default().fg(theme.fg)),
|
||||
];
|
||||
|
||||
items.push(ListItem::new(Line::from(line)));
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ pub fn render_intro(f: &mut Frame, intro_state: &IntroState, area: Rect, theme:
|
||||
|
||||
// Title
|
||||
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(env!("CARGO_PKG_VERSION"), Style::default().fg(theme.secondary)),
|
||||
]);
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod admin;
|
||||
pub mod common;
|
||||
pub mod form;
|
||||
pub mod auth;
|
||||
pub mod utils;
|
||||
|
||||
pub use handlers::*;
|
||||
pub use intro::*;
|
||||
@@ -12,3 +13,4 @@ pub use admin::*;
|
||||
pub use common::*;
|
||||
pub use form::*;
|
||||
pub use auth::*;
|
||||
pub use utils::*;
|
||||
|
||||
4
client/src/components/utils.rs
Normal file
4
client/src/components/utils.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
// src/components/utils.rs
|
||||
pub mod text;
|
||||
|
||||
pub use text::*;
|
||||
29
client/src/components/utils/text.rs
Normal file
29
client/src/components/utils/text.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
// src/components/utils/text.rs
|
||||
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
/// Truncates a string to a maximum width, adding an ellipsis if truncated.
|
||||
/// Considers unicode character widths.
|
||||
pub fn truncate_string(s: &str, max_width: usize) -> String {
|
||||
if UnicodeWidthStr::width(s) <= max_width {
|
||||
s.to_string()
|
||||
} else {
|
||||
let ellipsis = "…";
|
||||
let ellipsis_width = UnicodeWidthStr::width(ellipsis);
|
||||
let mut truncated_width = 0;
|
||||
let mut end_byte_index = 0;
|
||||
|
||||
// Iterate over graphemes to handle multi-byte characters correctly
|
||||
for (i, g) in s.grapheme_indices(true) {
|
||||
let char_width = UnicodeWidthStr::width(g);
|
||||
if truncated_width + char_width + ellipsis_width > max_width {
|
||||
break;
|
||||
}
|
||||
truncated_width += char_width;
|
||||
end_byte_index = i + g.len();
|
||||
}
|
||||
|
||||
format!("{}{}", &s[..end_byte_index], ellipsis)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,57 @@
|
||||
// src/config/binds/config.rs
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize}; // Added Serialize for EditorKeybindingMode if needed elsewhere
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use anyhow::{Context, Result};
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
|
||||
// NEW: Editor Keybinding Mode Enum
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum EditorKeybindingMode {
|
||||
#[serde(rename = "default")]
|
||||
Default,
|
||||
#[serde(rename = "vim")]
|
||||
Vim,
|
||||
#[serde(rename = "emacs")]
|
||||
Emacs,
|
||||
}
|
||||
|
||||
impl Default for EditorKeybindingMode {
|
||||
fn default() -> Self {
|
||||
EditorKeybindingMode::Default
|
||||
}
|
||||
}
|
||||
|
||||
// NEW: Editor Configuration Struct
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EditorConfig {
|
||||
#[serde(default)]
|
||||
pub keybinding_mode: EditorKeybindingMode,
|
||||
#[serde(default = "default_show_line_numbers")]
|
||||
pub show_line_numbers: bool,
|
||||
#[serde(default = "default_tab_width")]
|
||||
pub tab_width: u8,
|
||||
}
|
||||
|
||||
fn default_show_line_numbers() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_tab_width() -> u8 {
|
||||
4
|
||||
}
|
||||
|
||||
impl Default for EditorConfig {
|
||||
fn default() -> Self {
|
||||
EditorConfig {
|
||||
keybinding_mode: EditorKeybindingMode::default(),
|
||||
show_line_numbers: default_show_line_numbers(),
|
||||
tab_width: default_tab_width(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct ColorsConfig {
|
||||
#[serde(default = "default_theme")]
|
||||
@@ -21,9 +68,14 @@ pub struct Config {
|
||||
pub keybindings: ModeKeybindings,
|
||||
#[serde(default)]
|
||||
pub colors: ColorsConfig,
|
||||
// NEW: Add editor configuration
|
||||
#[serde(default)]
|
||||
pub editor: EditorConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
// ... (rest of your Config struct and impl Config remains the same)
|
||||
// Make sure ModeKeybindings is also deserializable if it's not already
|
||||
#[derive(Debug, Deserialize, Default)] // Added Default here if not present
|
||||
pub struct ModeKeybindings {
|
||||
#[serde(default)]
|
||||
pub general: HashMap<String, Vec<String>>,
|
||||
@@ -43,16 +95,16 @@ pub struct ModeKeybindings {
|
||||
|
||||
impl Config {
|
||||
/// Loads the configuration from "config.toml" in the client crate directory.
|
||||
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
|
||||
pub fn load() -> Result<Self> {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let config_path = Path::new(manifest_dir).join("config.toml");
|
||||
let config_str = std::fs::read_to_string(&config_path)
|
||||
.map_err(|e| format!("Failed to read config file at {:?}: {}", config_path, e))?;
|
||||
let config: Config = toml::from_str(&config_str)?;
|
||||
.with_context(|| format!("Failed to read config file at {:?}", config_path))?;
|
||||
let config: Config = toml::from_str(&config_str)
|
||||
.with_context(|| format!("Failed to parse config file: {}. Check for syntax errors or missing fields like an empty [editor] section if you added it.", config_str))?; // Enhanced error message
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
|
||||
pub fn get_general_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
||||
self.get_action_for_key_in_mode(&self.keybindings.general, key, modifiers)
|
||||
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers))
|
||||
@@ -199,47 +251,206 @@ impl Config {
|
||||
key: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
) -> 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('+') {
|
||||
return match binding.to_lowercase().as_str() {
|
||||
// Navigation keys
|
||||
"left" => key == KeyCode::Left,
|
||||
"right" => key == KeyCode::Right,
|
||||
"up" => key == KeyCode::Up,
|
||||
"down" => key == KeyCode::Down,
|
||||
"esc" => key == KeyCode::Esc,
|
||||
"enter" => key == KeyCode::Enter,
|
||||
"delete" => key == KeyCode::Delete,
|
||||
"home" => key == KeyCode::Home,
|
||||
"end" => key == KeyCode::End,
|
||||
"pageup" | "pgup" => key == KeyCode::PageUp,
|
||||
"pagedown" | "pgdn" => key == KeyCode::PageDown,
|
||||
|
||||
// Editing keys
|
||||
"insert" | "ins" => key == KeyCode::Insert,
|
||||
"delete" | "del" => key == KeyCode::Delete,
|
||||
"backspace" => key == KeyCode::Backspace,
|
||||
|
||||
// Tab keys
|
||||
"tab" => key == KeyCode::Tab,
|
||||
"backtab" => key == KeyCode::BackTab,
|
||||
_ => 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 mut expected_modifiers = KeyModifiers::empty();
|
||||
let mut expected_key = None;
|
||||
|
||||
for part in parts {
|
||||
match part.to_lowercase().as_str() {
|
||||
"ctrl" => expected_modifiers |= KeyModifiers::CONTROL,
|
||||
// Modifiers
|
||||
"ctrl" | "control" => expected_modifiers |= KeyModifiers::CONTROL,
|
||||
"shift" => expected_modifiers |= KeyModifiers::SHIFT,
|
||||
"alt" => expected_modifiers |= KeyModifiers::ALT,
|
||||
"super" | "windows" | "cmd" => expected_modifiers |= KeyModifiers::SUPER,
|
||||
"hyper" => expected_modifiers |= KeyModifiers::HYPER,
|
||||
"meta" => expected_modifiers |= KeyModifiers::META,
|
||||
|
||||
// Navigation keys
|
||||
"left" => expected_key = Some(KeyCode::Left),
|
||||
"right" => expected_key = Some(KeyCode::Right),
|
||||
"up" => expected_key = Some(KeyCode::Up),
|
||||
"down" => expected_key = Some(KeyCode::Down),
|
||||
"esc" => expected_key = Some(KeyCode::Esc),
|
||||
"enter" => expected_key = Some(KeyCode::Enter),
|
||||
"delete" => expected_key = Some(KeyCode::Delete),
|
||||
"home" => expected_key = Some(KeyCode::Home),
|
||||
"end" => expected_key = Some(KeyCode::End),
|
||||
"pageup" | "pgup" => expected_key = Some(KeyCode::PageUp),
|
||||
"pagedown" | "pgdn" => expected_key = Some(KeyCode::PageDown),
|
||||
|
||||
// Editing keys
|
||||
"insert" | "ins" => expected_key = Some(KeyCode::Insert),
|
||||
"delete" | "del" => expected_key = Some(KeyCode::Delete),
|
||||
"backspace" => expected_key = Some(KeyCode::Backspace),
|
||||
|
||||
// Tab keys
|
||||
"tab" => expected_key = Some(KeyCode::Tab),
|
||||
"backtab" => expected_key = Some(KeyCode::BackTab),
|
||||
|
||||
// Special keys
|
||||
"enter" | "return" => expected_key = Some(KeyCode::Enter),
|
||||
"escape" | "esc" => expected_key = Some(KeyCode::Esc),
|
||||
"space" => expected_key = Some(KeyCode::Char(' ')),
|
||||
|
||||
// Function keys
|
||||
"f1" => expected_key = Some(KeyCode::F(1)),
|
||||
"f2" => expected_key = Some(KeyCode::F(2)),
|
||||
"f3" => expected_key = Some(KeyCode::F(3)),
|
||||
"f4" => expected_key = Some(KeyCode::F(4)),
|
||||
"f5" => expected_key = Some(KeyCode::F(5)),
|
||||
"f6" => expected_key = Some(KeyCode::F(6)),
|
||||
"f7" => expected_key = Some(KeyCode::F(7)),
|
||||
"f8" => expected_key = Some(KeyCode::F(8)),
|
||||
"f9" => expected_key = Some(KeyCode::F(9)),
|
||||
"f10" => expected_key = Some(KeyCode::F(10)),
|
||||
"f11" => expected_key = Some(KeyCode::F(11)),
|
||||
"f12" => expected_key = Some(KeyCode::F(12)),
|
||||
"f13" => expected_key = Some(KeyCode::F(13)),
|
||||
"f14" => expected_key = Some(KeyCode::F(14)),
|
||||
"f15" => expected_key = Some(KeyCode::F(15)),
|
||||
"f16" => expected_key = Some(KeyCode::F(16)),
|
||||
"f17" => expected_key = Some(KeyCode::F(17)),
|
||||
"f18" => expected_key = Some(KeyCode::F(18)),
|
||||
"f19" => expected_key = Some(KeyCode::F(19)),
|
||||
"f20" => expected_key = Some(KeyCode::F(20)),
|
||||
"f21" => expected_key = Some(KeyCode::F(21)),
|
||||
"f22" => expected_key = Some(KeyCode::F(22)),
|
||||
"f23" => expected_key = Some(KeyCode::F(23)),
|
||||
"f24" => expected_key = Some(KeyCode::F(24)),
|
||||
|
||||
// Lock keys
|
||||
"capslock" => expected_key = Some(KeyCode::CapsLock),
|
||||
"scrolllock" => expected_key = Some(KeyCode::ScrollLock),
|
||||
"numlock" => expected_key = Some(KeyCode::NumLock),
|
||||
|
||||
// System keys
|
||||
"printscreen" => expected_key = Some(KeyCode::PrintScreen),
|
||||
"pause" => expected_key = Some(KeyCode::Pause),
|
||||
"menu" => expected_key = Some(KeyCode::Menu),
|
||||
"keypadbegin" => expected_key = Some(KeyCode::KeypadBegin),
|
||||
|
||||
// Special characters and colon (legacy support)
|
||||
":" => expected_key = Some(KeyCode::Char(':')),
|
||||
|
||||
// Single character (letters, numbers, punctuation)
|
||||
part => {
|
||||
if part.len() == 1 {
|
||||
let c = part.chars().next().unwrap();
|
||||
expected_key = Some(KeyCode::Char(c));
|
||||
if let Some(c) = part.chars().next() {
|
||||
expected_key = Some(KeyCode::Char(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,14 +149,17 @@ fn parse_key_part(part: &str) -> Option<ParsedKey> {
|
||||
let mut code = None;
|
||||
|
||||
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();
|
||||
|
||||
for component in components {
|
||||
match component.to_lowercase().as_str() {
|
||||
"ctrl" => modifiers |= KeyModifiers::CONTROL,
|
||||
"ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
|
||||
"shift" => modifiers |= KeyModifiers::SHIFT,
|
||||
"alt" => modifiers |= KeyModifiers::ALT,
|
||||
"super" | "windows" | "cmd" => modifiers |= KeyModifiers::SUPER,
|
||||
"hyper" => modifiers |= KeyModifiers::HYPER,
|
||||
"meta" => modifiers |= KeyModifiers::META,
|
||||
_ => {
|
||||
// Last component is the key
|
||||
code = string_to_keycode(component);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// src/client/themes/colors.rs
|
||||
// src/config/colors/themes.rs
|
||||
use ratatui::style::Color;
|
||||
use canvas::canvas::CanvasTheme;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Theme {
|
||||
@@ -74,3 +75,37 @@ impl Default for Theme {
|
||||
Self::light() // Default to light theme
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasTheme for Theme {
|
||||
fn bg(&self) -> Color {
|
||||
self.bg
|
||||
}
|
||||
|
||||
fn fg(&self) -> Color {
|
||||
self.fg
|
||||
}
|
||||
|
||||
fn border(&self) -> Color {
|
||||
self.border
|
||||
}
|
||||
|
||||
fn accent(&self) -> Color {
|
||||
self.accent
|
||||
}
|
||||
|
||||
fn secondary(&self) -> Color {
|
||||
self.secondary
|
||||
}
|
||||
|
||||
fn highlight(&self) -> Color {
|
||||
self.highlight
|
||||
}
|
||||
|
||||
fn highlight_bg(&self) -> Color {
|
||||
self.highlight_bg
|
||||
}
|
||||
|
||||
fn warning(&self) -> Color {
|
||||
self.warning
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
|
||||
pub mod binds;
|
||||
pub mod colors;
|
||||
pub mod storage;
|
||||
|
||||
4
client/src/config/storage.rs
Normal file
4
client/src/config/storage.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
// src/config/storage.rs
|
||||
pub mod storage;
|
||||
|
||||
pub use storage::*;
|
||||
101
client/src/config/storage/storage.rs
Normal file
101
client/src/config/storage/storage.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
// src/config/storage/storage.rs
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, File};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{error, info};
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
pub const APP_NAME: &str = "komp_ac_client";
|
||||
pub const TOKEN_FILE_NAME: &str = "auth.token";
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct StoredAuthData {
|
||||
pub access_token: String,
|
||||
pub user_id: String,
|
||||
pub role: String,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
pub fn get_token_storage_path() -> Result<PathBuf> {
|
||||
let state_dir = dirs::state_dir()
|
||||
.or_else(|| dirs::home_dir().map(|home| home.join(".local").join("state")))
|
||||
.ok_or_else(|| anyhow::anyhow!("Could not determine state directory"))?;
|
||||
|
||||
let app_state_dir = state_dir.join(APP_NAME);
|
||||
fs::create_dir_all(&app_state_dir)
|
||||
.with_context(|| format!("Failed to create app state directory at {:?}", app_state_dir))?;
|
||||
|
||||
Ok(app_state_dir.join(TOKEN_FILE_NAME))
|
||||
}
|
||||
|
||||
pub fn save_auth_data(data: &StoredAuthData) -> Result<()> {
|
||||
let path = get_token_storage_path()?;
|
||||
|
||||
let json_data = serde_json::to_string(data)
|
||||
.context("Failed to serialize auth data")?;
|
||||
|
||||
let mut file = File::create(&path)
|
||||
.with_context(|| format!("Failed to create token file at {:?}", path))?;
|
||||
|
||||
file.write_all(json_data.as_bytes())
|
||||
.context("Failed to write token data to file")?;
|
||||
|
||||
// Set file permissions to 600 (owner read/write only) on Unix
|
||||
#[cfg(unix)]
|
||||
{
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))
|
||||
.context("Failed to set token file permissions")?;
|
||||
}
|
||||
|
||||
info!("Auth data saved to {:?}", path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_auth_data() -> Result<Option<StoredAuthData>> {
|
||||
let path = get_token_storage_path()?;
|
||||
|
||||
if !path.exists() {
|
||||
info!("Token file not found at {:?}", path);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let json_data = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read token file at {:?}", path))?;
|
||||
|
||||
if json_data.trim().is_empty() {
|
||||
info!("Token file is empty at {:?}", path);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
match serde_json::from_str::<StoredAuthData>(&json_data) {
|
||||
Ok(data) => {
|
||||
info!("Auth data loaded from {:?}", path);
|
||||
Ok(Some(data))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to deserialize token data from {:?}: {}. Deleting corrupt file.", path, e);
|
||||
if let Err(del_e) = fs::remove_file(&path) {
|
||||
error!("Failed to delete corrupt token file: {}", del_e);
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_auth_data() -> Result<()> {
|
||||
let path = get_token_storage_path()?;
|
||||
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)
|
||||
.with_context(|| format!("Failed to delete token file at {:?}", path))?;
|
||||
info!("Token file deleted from {:?}", path);
|
||||
} else {
|
||||
info!("Token file not found for deletion at {:?}", path);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -6,8 +6,8 @@ use crate::state::app::buffer::AppView;
|
||||
pub fn get_view_layer(view: &AppView) -> u8 {
|
||||
match view {
|
||||
AppView::Intro => 1,
|
||||
AppView::Login | AppView::Register | AppView::Admin | AppView::AddTable => 2,
|
||||
AppView::Form(_) | AppView::Scratch => 3,
|
||||
AppView::Login | AppView::Register | AppView::Admin | AppView::AddTable | AppView::AddLogic => 2,
|
||||
AppView::Form | AppView::Scratch => 3,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
// src/functions/modes.rs
|
||||
|
||||
pub mod read_only;
|
||||
pub mod edit;
|
||||
pub mod navigation;
|
||||
|
||||
pub use read_only::*;
|
||||
pub use edit::*;
|
||||
pub use navigation::*;
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
// src/functions/modes/edit.rs
|
||||
|
||||
pub mod form_e;
|
||||
pub mod auth_e;
|
||||
@@ -1,483 +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::tui::functions::common::form::{revert, save};
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use std::any::Any;
|
||||
|
||||
pub async fn execute_common_action<S: CanvasState + Any>(
|
||||
action: &str,
|
||||
state: &mut S,
|
||||
grpc_client: &mut GrpcClient,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
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(
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
)
|
||||
.await?;
|
||||
let message = format!("Save successful: {:?}", outcome); // Simple message for now
|
||||
Ok(message)
|
||||
}
|
||||
"revert" => {
|
||||
revert(
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
)
|
||||
.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,
|
||||
grpc_client: &mut GrpcClient,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
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) % 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())
|
||||
}
|
||||
|
||||
// --- 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,440 +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::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;
|
||||
|
||||
pub async fn execute_common_action<S: CanvasState + Any>(
|
||||
action: &str,
|
||||
state: &mut S,
|
||||
grpc_client: &mut GrpcClient,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<EventOutcome, Box<dyn std::error::Error>> {
|
||||
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(
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
).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,
|
||||
current_position,
|
||||
total_count,
|
||||
).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,
|
||||
grpc_client: &mut GrpcClient,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,4 @@
|
||||
|
||||
pub mod admin_nav;
|
||||
pub mod add_table_nav;
|
||||
pub mod add_logic_nav;
|
||||
|
||||
440
client/src/functions/modes/navigation/add_logic_nav.rs
Normal file
440
client/src/functions/modes/navigation/add_logic_nav.rs
Normal file
@@ -0,0 +1,440 @@
|
||||
// src/functions/modes/navigation/add_logic_nav.rs
|
||||
use crate::config::binds::config::{Config, EditorKeybindingMode};
|
||||
use crate::state::{
|
||||
app::state::AppState,
|
||||
pages::add_logic::{AddLogicFocus, AddLogicState},
|
||||
app::buffer::AppView,
|
||||
app::buffer::BufferState,
|
||||
};
|
||||
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
|
||||
use crate::services::GrpcClient;
|
||||
use tokio::sync::mpsc;
|
||||
use anyhow::Result;
|
||||
use crate::components::common::text_editor::TextEditor;
|
||||
use crate::services::ui_service::UiService;
|
||||
use tui_textarea::CursorMove; // Ensure this import is present
|
||||
|
||||
pub type SaveLogicResultSender = mpsc::Sender<Result<String>>;
|
||||
|
||||
pub fn handle_add_logic_navigation(
|
||||
key_event: KeyEvent,
|
||||
config: &Config,
|
||||
app_state: &mut AppState,
|
||||
add_logic_state: &mut AddLogicState,
|
||||
is_edit_mode: &mut bool,
|
||||
buffer_state: &mut BufferState,
|
||||
grpc_client: GrpcClient,
|
||||
_save_logic_sender: SaveLogicResultSender, // Marked as unused
|
||||
command_message: &mut String,
|
||||
) -> bool {
|
||||
// === FULLSCREEN SCRIPT EDITING - COMPLETE ISOLATION ===
|
||||
if add_logic_state.current_focus == AddLogicFocus::InsideScriptContent {
|
||||
// === AUTOCOMPLETE HANDLING ===
|
||||
if add_logic_state.script_editor_autocomplete_active {
|
||||
match key_event.code {
|
||||
// ... (Char, Backspace, Tab, Down, Up cases remain the same) ...
|
||||
KeyCode::Char(c) if c.is_alphanumeric() || c == '_' => {
|
||||
add_logic_state.script_editor_filter_text.push(c);
|
||||
add_logic_state.update_script_editor_suggestions();
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
*command_message = format!("Filtering: @{}", add_logic_state.script_editor_filter_text);
|
||||
return true;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if !add_logic_state.script_editor_filter_text.is_empty() {
|
||||
add_logic_state.script_editor_filter_text.pop();
|
||||
add_logic_state.update_script_editor_suggestions();
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
*command_message = if add_logic_state.script_editor_filter_text.is_empty() {
|
||||
"Autocomplete: @".to_string()
|
||||
} else {
|
||||
format!("Filtering: @{}", add_logic_state.script_editor_filter_text)
|
||||
};
|
||||
} else {
|
||||
let should_deactivate = if let Some((trigger_line, trigger_col)) = add_logic_state.script_editor_trigger_position {
|
||||
let current_cursor = {
|
||||
let editor_borrow = add_logic_state.script_content_editor.borrow();
|
||||
editor_borrow.cursor()
|
||||
};
|
||||
current_cursor.0 == trigger_line && current_cursor.1 == trigger_col + 1
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if should_deactivate {
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
*command_message = "Autocomplete cancelled".to_string();
|
||||
}
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Down => {
|
||||
if !add_logic_state.script_editor_suggestions.is_empty() {
|
||||
let current = add_logic_state.script_editor_selected_suggestion_index.unwrap_or(0);
|
||||
let next = (current + 1) % add_logic_state.script_editor_suggestions.len();
|
||||
add_logic_state.script_editor_selected_suggestion_index = Some(next);
|
||||
*command_message = format!("Selected: {}", add_logic_state.script_editor_suggestions[next]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Up => {
|
||||
if !add_logic_state.script_editor_suggestions.is_empty() {
|
||||
let current = add_logic_state.script_editor_selected_suggestion_index.unwrap_or(0);
|
||||
let prev = if current == 0 {
|
||||
add_logic_state.script_editor_suggestions.len() - 1
|
||||
} else {
|
||||
current - 1
|
||||
};
|
||||
add_logic_state.script_editor_selected_suggestion_index = Some(prev);
|
||||
*command_message = format!("Selected: {}", add_logic_state.script_editor_suggestions[prev]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(selected_idx) = add_logic_state.script_editor_selected_suggestion_index {
|
||||
if let Some(suggestion) = add_logic_state.script_editor_suggestions.get(selected_idx).cloned() {
|
||||
let trigger_pos = add_logic_state.script_editor_trigger_position;
|
||||
let filter_len = add_logic_state.script_editor_filter_text.len();
|
||||
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
add_logic_state.has_unsaved_changes = true;
|
||||
|
||||
if let Some(pos) = trigger_pos {
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
|
||||
if suggestion == "sql" {
|
||||
replace_autocomplete_text(&mut editor_borrow, pos, filter_len, "sql");
|
||||
editor_borrow.insert_str("('')");
|
||||
// Move cursor back twice to be between the single quotes
|
||||
editor_borrow.move_cursor(CursorMove::Back); // Before ')'
|
||||
editor_borrow.move_cursor(CursorMove::Back); // Before ''' (inside '')
|
||||
*command_message = "Inserted: @sql('')".to_string();
|
||||
} else {
|
||||
let is_table_selection = add_logic_state.is_table_name_suggestion(&suggestion);
|
||||
replace_autocomplete_text(&mut editor_borrow, pos, filter_len, &suggestion);
|
||||
|
||||
if is_table_selection {
|
||||
editor_borrow.insert_str(".");
|
||||
let new_cursor = editor_borrow.cursor();
|
||||
drop(editor_borrow); // Release borrow before calling add_logic_state methods
|
||||
|
||||
add_logic_state.script_editor_trigger_position = Some(new_cursor);
|
||||
add_logic_state.script_editor_autocomplete_active = true;
|
||||
add_logic_state.script_editor_filter_text.clear();
|
||||
add_logic_state.trigger_column_autocomplete_for_table(suggestion.clone());
|
||||
|
||||
let profile_name = add_logic_state.profile_name.clone();
|
||||
let table_name_for_fetch = suggestion.clone();
|
||||
let mut client_clone = grpc_client.clone();
|
||||
tokio::spawn(async move {
|
||||
match UiService::fetch_columns_for_table(&mut client_clone, &profile_name, &table_name_for_fetch).await {
|
||||
Ok(_columns) => {
|
||||
// Result handled by main UI loop
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to fetch columns for {}.{}: {}", profile_name, table_name_for_fetch, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
*command_message = format!("Selected table '{}', fetching columns...", suggestion);
|
||||
} else {
|
||||
*command_message = format!("Inserted: {}", suggestion);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
*command_message = "Autocomplete cancelled".to_string();
|
||||
}
|
||||
_ => {
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
*command_message = "Autocomplete cancelled".to_string();
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if key_event.code == KeyCode::Char('@') && key_event.modifiers == KeyModifiers::NONE {
|
||||
let should_trigger = match add_logic_state.editor_keybinding_mode {
|
||||
EditorKeybindingMode::Vim => *is_edit_mode,
|
||||
_ => true,
|
||||
};
|
||||
if should_trigger {
|
||||
let cursor_before = {
|
||||
let editor_borrow = add_logic_state.script_content_editor.borrow();
|
||||
editor_borrow.cursor()
|
||||
};
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
add_logic_state.script_editor_trigger_position = Some(cursor_before);
|
||||
add_logic_state.script_editor_autocomplete_active = true;
|
||||
add_logic_state.script_editor_filter_text.clear();
|
||||
add_logic_state.update_script_editor_suggestions();
|
||||
add_logic_state.has_unsaved_changes = true;
|
||||
*command_message = "Autocomplete: @ (Tab/↑↓ to navigate, Enter to select, Esc to cancel)".to_string();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if key_event.code == KeyCode::Esc && key_event.modifiers == KeyModifiers::NONE {
|
||||
match add_logic_state.editor_keybinding_mode {
|
||||
EditorKeybindingMode::Vim => {
|
||||
if *is_edit_mode {
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
if TextEditor::is_vim_normal_mode(&add_logic_state.vim_state) {
|
||||
*is_edit_mode = false;
|
||||
*command_message = "VIM: Normal Mode. Esc again to exit script.".to_string();
|
||||
}
|
||||
} else {
|
||||
add_logic_state.current_focus = AddLogicFocus::ScriptContentPreview;
|
||||
app_state.ui.focus_outside_canvas = true;
|
||||
*is_edit_mode = false;
|
||||
*command_message = "Exited script editing.".to_string();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if *is_edit_mode {
|
||||
*is_edit_mode = false;
|
||||
*command_message = "Exited script edit. Esc again to exit script.".to_string();
|
||||
} else {
|
||||
add_logic_state.current_focus = AddLogicFocus::ScriptContentPreview;
|
||||
app_state.ui.focus_outside_canvas = true;
|
||||
*is_edit_mode = false;
|
||||
*command_message = "Exited script editing.".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let changed = {
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
)
|
||||
};
|
||||
if changed {
|
||||
add_logic_state.has_unsaved_changes = true;
|
||||
}
|
||||
if add_logic_state.editor_keybinding_mode == EditorKeybindingMode::Vim {
|
||||
*is_edit_mode = !TextEditor::is_vim_normal_mode(&add_logic_state.vim_state);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let action = config.get_general_action(key_event.code, key_event.modifiers);
|
||||
let current_focus = add_logic_state.current_focus;
|
||||
let mut handled = true;
|
||||
let mut new_focus = current_focus;
|
||||
|
||||
match action.as_deref() {
|
||||
Some("exit_table_scroll") => {
|
||||
handled = false;
|
||||
}
|
||||
Some("move_up") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName => {}
|
||||
AddLogicFocus::InputTargetColumn => new_focus = AddLogicFocus::InputLogicName,
|
||||
AddLogicFocus::InputDescription => new_focus = AddLogicFocus::InputTargetColumn,
|
||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::ScriptContentPreview,
|
||||
AddLogicFocus::CancelButton => new_focus = AddLogicFocus::SaveButton,
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("move_down") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName => new_focus = AddLogicFocus::InputTargetColumn,
|
||||
AddLogicFocus::InputTargetColumn => new_focus = AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::InputDescription => {
|
||||
add_logic_state.last_canvas_field = 2;
|
||||
new_focus = AddLogicFocus::ScriptContentPreview;
|
||||
},
|
||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::SaveButton,
|
||||
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::CancelButton,
|
||||
AddLogicFocus::CancelButton => {}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("next_option") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription =>
|
||||
{ new_focus = AddLogicFocus::ScriptContentPreview; }
|
||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::SaveButton,
|
||||
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::CancelButton,
|
||||
AddLogicFocus::CancelButton => { }
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("previous_option") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription =>
|
||||
{ }
|
||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::ScriptContentPreview,
|
||||
AddLogicFocus::CancelButton => new_focus = AddLogicFocus::SaveButton,
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("next_field") => {
|
||||
new_focus = match current_focus {
|
||||
AddLogicFocus::InputLogicName => AddLogicFocus::InputTargetColumn,
|
||||
AddLogicFocus::InputTargetColumn => AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::InputDescription => AddLogicFocus::ScriptContentPreview,
|
||||
AddLogicFocus::ScriptContentPreview => AddLogicFocus::SaveButton,
|
||||
AddLogicFocus::SaveButton => AddLogicFocus::CancelButton,
|
||||
AddLogicFocus::CancelButton => AddLogicFocus::InputLogicName,
|
||||
_ => current_focus,
|
||||
};
|
||||
}
|
||||
Some("prev_field") => {
|
||||
new_focus = match current_focus {
|
||||
AddLogicFocus::InputLogicName => AddLogicFocus::CancelButton,
|
||||
AddLogicFocus::InputTargetColumn => AddLogicFocus::InputLogicName,
|
||||
AddLogicFocus::InputDescription => AddLogicFocus::InputTargetColumn,
|
||||
AddLogicFocus::ScriptContentPreview => AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::SaveButton => AddLogicFocus::ScriptContentPreview,
|
||||
AddLogicFocus::CancelButton => AddLogicFocus::SaveButton,
|
||||
_ => current_focus,
|
||||
};
|
||||
}
|
||||
Some("select") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::ScriptContentPreview => {
|
||||
new_focus = AddLogicFocus::InsideScriptContent;
|
||||
*is_edit_mode = false;
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
let mode_hint = match add_logic_state.editor_keybinding_mode {
|
||||
EditorKeybindingMode::Vim => "VIM mode - 'i'/'a'/'o' to edit",
|
||||
_ => "Enter/Ctrl+E to edit",
|
||||
};
|
||||
*command_message = format!("Fullscreen script editing. {} or Esc to exit.", mode_hint);
|
||||
}
|
||||
AddLogicFocus::SaveButton => {
|
||||
*command_message = "Save logic action".to_string();
|
||||
}
|
||||
AddLogicFocus::CancelButton => {
|
||||
buffer_state.update_history(AppView::Admin);
|
||||
app_state.ui.show_add_logic = false;
|
||||
*command_message = "Cancelled Add Logic".to_string();
|
||||
*is_edit_mode = false;
|
||||
}
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription => {
|
||||
*is_edit_mode = !*is_edit_mode;
|
||||
*command_message = format!("Field edit mode: {}", if *is_edit_mode { "ON" } else { "OFF" });
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("toggle_edit_mode") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription => {
|
||||
*is_edit_mode = !*is_edit_mode;
|
||||
*command_message = format!("Canvas field edit mode: {}", if *is_edit_mode { "ON" } else { "OFF" });
|
||||
}
|
||||
_ => {
|
||||
*command_message = "Cannot toggle edit mode here.".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
|
||||
if handled && current_focus != new_focus {
|
||||
add_logic_state.current_focus = new_focus;
|
||||
let new_is_canvas_input_focus = matches!(new_focus,
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription
|
||||
);
|
||||
if new_is_canvas_input_focus {
|
||||
*is_edit_mode = false;
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
} else {
|
||||
app_state.ui.focus_outside_canvas = true;
|
||||
if matches!(new_focus, AddLogicFocus::ScriptContentPreview) {
|
||||
*is_edit_mode = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
handled
|
||||
}
|
||||
|
||||
fn replace_autocomplete_text(
|
||||
editor: &mut tui_textarea::TextArea,
|
||||
trigger_pos: (usize, usize),
|
||||
filter_len: usize,
|
||||
replacement: &str,
|
||||
) {
|
||||
// use tui_textarea::CursorMove; // Already imported at the top of the module
|
||||
let filter_start_pos = (trigger_pos.0, trigger_pos.1 + 1);
|
||||
editor.move_cursor(CursorMove::Jump(filter_start_pos.0 as u16, filter_start_pos.1 as u16));
|
||||
for _ in 0..filter_len {
|
||||
editor.delete_next_char();
|
||||
}
|
||||
editor.insert_str(replacement);
|
||||
}
|
||||
@@ -5,256 +5,201 @@ use crate::state::{
|
||||
pages::add_table::{AddTableFocus, AddTableState},
|
||||
};
|
||||
use crossterm::event::{KeyEvent};
|
||||
use ratatui::widgets::TableState; // Import TableState
|
||||
use ratatui::widgets::TableState;
|
||||
use crate::tui::functions::common::add_table::{handle_add_column_action, handle_save_table_action};
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
use crate::services::GrpcClient;
|
||||
use tokio::sync::mpsc;
|
||||
use anyhow::Result;
|
||||
|
||||
pub type SaveTableResultSender = mpsc::Sender<Result<String>>;
|
||||
|
||||
fn navigate_table_up(table_state: &mut TableState, item_count: usize) -> bool {
|
||||
if item_count == 0 { return false; }
|
||||
let current_selection = table_state.selected();
|
||||
match current_selection {
|
||||
Some(index) => {
|
||||
if index > 0 { table_state.select(Some(index - 1)); true }
|
||||
else { false }
|
||||
}
|
||||
None => { table_state.select(Some(0)); true }
|
||||
}
|
||||
}
|
||||
|
||||
fn navigate_table_down(table_state: &mut TableState, item_count: usize) -> bool {
|
||||
if item_count == 0 { return false; }
|
||||
let current_selection = table_state.selected();
|
||||
match current_selection {
|
||||
Some(index) => {
|
||||
if index < item_count - 1 { table_state.select(Some(index + 1)); true }
|
||||
else { false }
|
||||
}
|
||||
None => { table_state.select(Some(0)); true }
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles navigation events specifically for the Add Table view.
|
||||
/// Returns true if the event was handled, false otherwise.
|
||||
pub fn handle_add_table_navigation(
|
||||
key: KeyEvent,
|
||||
config: &Config,
|
||||
_app_state: &AppState, // Keep for potential future use (e.g., checking permissions)
|
||||
app_state: &mut AppState,
|
||||
add_table_state: &mut AddTableState,
|
||||
grpc_client: GrpcClient,
|
||||
save_result_sender: SaveTableResultSender,
|
||||
command_message: &mut String,
|
||||
) -> bool {
|
||||
let action = config.get_general_action(key.code, key.modifiers);
|
||||
let current_focus = add_table_state.current_focus;
|
||||
let mut handled = true; // Assume handled unless logic determines otherwise
|
||||
let mut handled = true;
|
||||
let mut new_focus = current_focus;
|
||||
|
||||
if matches!(current_focus, AddTableFocus::InsideColumnsTable | AddTableFocus::InsideIndexesTable | AddTableFocus::InsideLinksTable) {
|
||||
if matches!(action.as_deref(), Some("next_option") | Some("previous_option")) {
|
||||
*command_message = "Press Esc to exit table item navigation first.".to_string();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
match action.as_deref() {
|
||||
// --- Vertical Navigation (Up/Down) ---
|
||||
Some("exit_table_scroll") => {
|
||||
match current_focus {
|
||||
AddTableFocus::InsideColumnsTable => {
|
||||
add_table_state.column_table_state.select(None);
|
||||
new_focus = AddTableFocus::ColumnsTable;
|
||||
// *command_message = "Exited Columns Table".to_string(); // Minimal change: remove message
|
||||
}
|
||||
AddTableFocus::InsideIndexesTable => {
|
||||
add_table_state.index_table_state.select(None);
|
||||
new_focus = AddTableFocus::IndexesTable;
|
||||
// *command_message = "Exited Indexes Table".to_string();
|
||||
}
|
||||
AddTableFocus::InsideLinksTable => {
|
||||
add_table_state.link_table_state.select(None);
|
||||
new_focus = AddTableFocus::LinksTable;
|
||||
// *command_message = "Exited Links Table".to_string();
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("move_up") => {
|
||||
let mut new_focus = current_focus; // Start with current focus
|
||||
match current_focus {
|
||||
AddTableFocus::InputTableName => new_focus = AddTableFocus::CancelButton, // Wrap top
|
||||
AddTableFocus::InputTableName => {
|
||||
// MINIMAL CHANGE: Do nothing, new_focus remains current_focus
|
||||
// *command_message = "At top of form.".to_string(); // Remove message
|
||||
}
|
||||
AddTableFocus::InputColumnName => new_focus = AddTableFocus::InputTableName,
|
||||
AddTableFocus::InputColumnType => new_focus = AddTableFocus::InputColumnName,
|
||||
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::InputColumnType,
|
||||
AddTableFocus::ColumnsTable => {
|
||||
if !navigate_table_up(&mut add_table_state.column_table_state, add_table_state.columns.len()) {
|
||||
new_focus = AddTableFocus::AddColumnButton; // Move focus up if at table top
|
||||
}
|
||||
// Keep focus on table while navigating within it
|
||||
}
|
||||
AddTableFocus::IndexesTable => {
|
||||
if !navigate_table_up(&mut add_table_state.index_table_state, add_table_state.indexes.len()) {
|
||||
new_focus = AddTableFocus::ColumnsTable; // Move focus up
|
||||
}
|
||||
}
|
||||
AddTableFocus::LinksTable => {
|
||||
if !navigate_table_up(&mut add_table_state.link_table_state, add_table_state.links.len()) {
|
||||
new_focus = AddTableFocus::IndexesTable; // Move focus up
|
||||
}
|
||||
}
|
||||
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::AddColumnButton,
|
||||
AddTableFocus::IndexesTable => new_focus = AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::LinksTable => new_focus = AddTableFocus::IndexesTable,
|
||||
AddTableFocus::InsideColumnsTable => { navigate_table_up(&mut add_table_state.column_table_state, add_table_state.columns.len()); }
|
||||
AddTableFocus::InsideIndexesTable => { navigate_table_up(&mut add_table_state.index_table_state, add_table_state.indexes.len()); }
|
||||
AddTableFocus::InsideLinksTable => { navigate_table_up(&mut add_table_state.link_table_state, add_table_state.links.len()); }
|
||||
AddTableFocus::SaveButton => new_focus = AddTableFocus::LinksTable,
|
||||
AddTableFocus::CancelButton => new_focus = AddTableFocus::SaveButton,
|
||||
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::SaveButton,
|
||||
AddTableFocus::CancelButton => new_focus = AddTableFocus::DeleteSelectedButton,
|
||||
}
|
||||
add_table_state.current_focus = new_focus;
|
||||
*command_message = format!("Focus set to {:?}", add_table_state.current_focus);
|
||||
}
|
||||
Some("move_down") => {
|
||||
let mut new_focus = current_focus; // Start with current focus
|
||||
match current_focus {
|
||||
AddTableFocus::InputTableName => new_focus = AddTableFocus::InputColumnName,
|
||||
AddTableFocus::InputColumnName => new_focus = AddTableFocus::InputColumnType,
|
||||
AddTableFocus::InputColumnType => new_focus = AddTableFocus::AddColumnButton,
|
||||
AddTableFocus::InputColumnType => {
|
||||
add_table_state.last_canvas_field = 2;
|
||||
new_focus = AddTableFocus::AddColumnButton;
|
||||
},
|
||||
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::ColumnsTable => {
|
||||
if !navigate_table_down(&mut add_table_state.column_table_state, add_table_state.columns.len()) {
|
||||
new_focus = AddTableFocus::IndexesTable; // Move focus down if at table bottom
|
||||
}
|
||||
// Keep focus on table while navigating within it
|
||||
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::IndexesTable,
|
||||
AddTableFocus::IndexesTable => new_focus = AddTableFocus::LinksTable,
|
||||
AddTableFocus::LinksTable => new_focus = AddTableFocus::SaveButton,
|
||||
AddTableFocus::InsideColumnsTable => { navigate_table_down(&mut add_table_state.column_table_state, add_table_state.columns.len()); }
|
||||
AddTableFocus::InsideIndexesTable => { navigate_table_down(&mut add_table_state.index_table_state, add_table_state.indexes.len()); }
|
||||
AddTableFocus::InsideLinksTable => { navigate_table_down(&mut add_table_state.link_table_state, add_table_state.links.len()); }
|
||||
AddTableFocus::SaveButton => new_focus = AddTableFocus::DeleteSelectedButton,
|
||||
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::CancelButton,
|
||||
AddTableFocus::CancelButton => {
|
||||
// MINIMAL CHANGE: Do nothing, new_focus remains current_focus
|
||||
// *command_message = "At bottom of form.".to_string(); // Remove message
|
||||
}
|
||||
AddTableFocus::IndexesTable => {
|
||||
if !navigate_table_down(&mut add_table_state.index_table_state, add_table_state.indexes.len()) {
|
||||
new_focus = AddTableFocus::LinksTable; // Move focus down
|
||||
}
|
||||
}
|
||||
AddTableFocus::LinksTable => {
|
||||
if !navigate_table_down(&mut add_table_state.link_table_state, add_table_state.links.len()) {
|
||||
new_focus = AddTableFocus::SaveButton; // Move focus down
|
||||
}
|
||||
}
|
||||
AddTableFocus::SaveButton => new_focus = AddTableFocus::CancelButton,
|
||||
AddTableFocus::CancelButton => new_focus = AddTableFocus::InputTableName, // Wrap bottom
|
||||
}
|
||||
add_table_state.current_focus = new_focus;
|
||||
*command_message = format!("Focus set to {:?}", add_table_state.current_focus);
|
||||
}
|
||||
|
||||
// --- Horizontal Navigation (Left/Right) ---
|
||||
Some("next_option") => { // 'l' or Right
|
||||
add_table_state.current_focus = match current_focus {
|
||||
AddTableFocus::SaveButton => AddTableFocus::CancelButton,
|
||||
_ => current_focus, // No change for others yet
|
||||
Some("next_option") => { // This logic should already be non-wrapping
|
||||
match current_focus {
|
||||
AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType =>
|
||||
{ new_focus = AddTableFocus::AddColumnButton; }
|
||||
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::IndexesTable,
|
||||
AddTableFocus::IndexesTable => new_focus = AddTableFocus::LinksTable,
|
||||
AddTableFocus::LinksTable => new_focus = AddTableFocus::SaveButton,
|
||||
AddTableFocus::SaveButton => new_focus = AddTableFocus::DeleteSelectedButton,
|
||||
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::CancelButton,
|
||||
AddTableFocus::CancelButton => { /* *command_message = "At last focusable area.".to_string(); */ } // No change in focus
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("previous_option") => { // This logic should already be non-wrapping
|
||||
match current_focus {
|
||||
AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType =>
|
||||
{ /* *command_message = "At first focusable area.".to_string(); */ } // No change in focus
|
||||
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::InputColumnType,
|
||||
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::AddColumnButton,
|
||||
AddTableFocus::IndexesTable => new_focus = AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::LinksTable => new_focus = AddTableFocus::IndexesTable,
|
||||
AddTableFocus::SaveButton => new_focus = AddTableFocus::LinksTable,
|
||||
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::SaveButton,
|
||||
AddTableFocus::CancelButton => new_focus = AddTableFocus::DeleteSelectedButton,
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("next_field") => {
|
||||
new_focus = match current_focus {
|
||||
AddTableFocus::InputTableName => AddTableFocus::InputColumnName, AddTableFocus::InputColumnName => AddTableFocus::InputColumnType, AddTableFocus::InputColumnType => AddTableFocus::AddColumnButton, AddTableFocus::AddColumnButton => AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable => AddTableFocus::IndexesTable, AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable => AddTableFocus::LinksTable, AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable => AddTableFocus::SaveButton,
|
||||
AddTableFocus::SaveButton => AddTableFocus::DeleteSelectedButton, AddTableFocus::DeleteSelectedButton => AddTableFocus::CancelButton, AddTableFocus::CancelButton => AddTableFocus::InputTableName,
|
||||
};
|
||||
*command_message = format!("Focus set to {:?}", add_table_state.current_focus);
|
||||
}
|
||||
Some("previous_option") => { // 'h' or Left
|
||||
add_table_state.current_focus = match current_focus {
|
||||
AddTableFocus::CancelButton => AddTableFocus::SaveButton,
|
||||
_ => current_focus, // No change for others yet
|
||||
Some("prev_field") => {
|
||||
new_focus = match current_focus {
|
||||
AddTableFocus::InputTableName => AddTableFocus::CancelButton, AddTableFocus::InputColumnName => AddTableFocus::InputTableName, AddTableFocus::InputColumnType => AddTableFocus::InputColumnName, AddTableFocus::AddColumnButton => AddTableFocus::InputColumnType,
|
||||
AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable => AddTableFocus::AddColumnButton, AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable => AddTableFocus::ColumnsTable, AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable => AddTableFocus::IndexesTable,
|
||||
AddTableFocus::SaveButton => AddTableFocus::LinksTable, AddTableFocus::DeleteSelectedButton => AddTableFocus::SaveButton, AddTableFocus::CancelButton => AddTableFocus::DeleteSelectedButton,
|
||||
};
|
||||
*command_message = format!("Focus set to {:?}", add_table_state.current_focus);
|
||||
}
|
||||
|
||||
// --- Tab / Shift+Tab Navigation ---
|
||||
Some("next_field") => { // Tab
|
||||
add_table_state.current_focus = match current_focus {
|
||||
AddTableFocus::InputTableName => AddTableFocus::InputColumnName,
|
||||
AddTableFocus::InputColumnName => AddTableFocus::InputColumnType,
|
||||
AddTableFocus::InputColumnType => AddTableFocus::AddColumnButton,
|
||||
AddTableFocus::AddColumnButton => AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::ColumnsTable => AddTableFocus::IndexesTable,
|
||||
AddTableFocus::IndexesTable => AddTableFocus::LinksTable,
|
||||
AddTableFocus::LinksTable => AddTableFocus::SaveButton,
|
||||
AddTableFocus::SaveButton => AddTableFocus::CancelButton,
|
||||
AddTableFocus::CancelButton => AddTableFocus::InputTableName, // Wrap
|
||||
};
|
||||
*command_message = format!("Focus set to {:?}", add_table_state.current_focus);
|
||||
}
|
||||
Some("prev_field") => { // Shift+Tab
|
||||
add_table_state.current_focus = match current_focus {
|
||||
AddTableFocus::InputTableName => AddTableFocus::CancelButton, // Wrap
|
||||
AddTableFocus::InputColumnName => AddTableFocus::InputTableName,
|
||||
AddTableFocus::InputColumnType => AddTableFocus::InputColumnName,
|
||||
AddTableFocus::AddColumnButton => AddTableFocus::InputColumnType,
|
||||
AddTableFocus::ColumnsTable => AddTableFocus::AddColumnButton,
|
||||
AddTableFocus::IndexesTable => AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::LinksTable => AddTableFocus::IndexesTable,
|
||||
AddTableFocus::SaveButton => AddTableFocus::LinksTable,
|
||||
AddTableFocus::CancelButton => AddTableFocus::SaveButton,
|
||||
};
|
||||
*command_message = format!("Focus set to {:?}", add_table_state.current_focus);
|
||||
}
|
||||
|
||||
// --- Selection ---
|
||||
Some("select") => {
|
||||
match current_focus {
|
||||
AddTableFocus::AddColumnButton => {
|
||||
*command_message = "Action: Add Column (Not Implemented)".to_string();
|
||||
// TODO: Implement logic to add column based on inputs
|
||||
// Clear input fields, add to columns list, mark unsaved changes
|
||||
// add_table_state.add_column(); // Example method call
|
||||
}
|
||||
AddTableFocus::SaveButton => {
|
||||
*command_message = "Action: Save Table (Not Implemented)".to_string();
|
||||
// TODO: Implement logic to save table (e.g., call API)
|
||||
// Mark changes as saved
|
||||
// add_table_state.save_table(); // Example method call
|
||||
}
|
||||
AddTableFocus::CancelButton => {
|
||||
*command_message = "Action: Cancel Add Table".to_string();
|
||||
// TODO: Implement logic to navigate back (e.g., update AppView history)
|
||||
// Maybe show a confirmation dialog if there are unsaved changes
|
||||
// buffer_state.go_back(); // Example call
|
||||
}
|
||||
// Selecting input fields usually means entering Edit mode (handled elsewhere)
|
||||
// Selecting tables might mean focusing on them for editing/deletion (TODO)
|
||||
AddTableFocus::ColumnsTable => {
|
||||
if let Some(index) = add_table_state.column_table_state.selected() {
|
||||
*command_message = format!("Selected column index {}", index);
|
||||
// TODO: Add logic for editing/deleting selected column
|
||||
} else {
|
||||
*command_message = "No column selected".to_string();
|
||||
}
|
||||
}
|
||||
AddTableFocus::IndexesTable => {
|
||||
if let Some(index) = add_table_state.index_table_state.selected() {
|
||||
*command_message = format!("Selected index index {}", index);
|
||||
// TODO: Add logic for editing/deleting selected index
|
||||
} else {
|
||||
*command_message = "No index selected".to_string();
|
||||
}
|
||||
}
|
||||
AddTableFocus::LinksTable => {
|
||||
if let Some(index) = add_table_state.link_table_state.selected() {
|
||||
*command_message = format!("Selected link index {}", index);
|
||||
// TODO: Add logic for editing/deleting selected link
|
||||
} else {
|
||||
*command_message = "No link selected".to_string();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// For InputTableName, InputColumnName, InputColumnType,
|
||||
// the main event loop should handle 'select' by potentially
|
||||
// switching to Edit mode if not already in it.
|
||||
// We don't need specific logic here for that.
|
||||
*command_message = format!("Select on {:?}", current_focus);
|
||||
handled = false; // Let main loop handle edit mode toggle maybe
|
||||
}
|
||||
AddTableFocus::ColumnsTable => { new_focus = AddTableFocus::InsideColumnsTable; if add_table_state.column_table_state.selected().is_none() && !add_table_state.columns.is_empty() { add_table_state.column_table_state.select(Some(0)); } /* Message removed */ }
|
||||
AddTableFocus::IndexesTable => { new_focus = AddTableFocus::InsideIndexesTable; if add_table_state.index_table_state.selected().is_none() && !add_table_state.indexes.is_empty() { add_table_state.index_table_state.select(Some(0)); } /* Message removed */ }
|
||||
AddTableFocus::LinksTable => { new_focus = AddTableFocus::InsideLinksTable; if add_table_state.link_table_state.selected().is_none() && !add_table_state.links.is_empty() { add_table_state.link_table_state.select(Some(0)); } /* Message removed */ }
|
||||
AddTableFocus::InsideColumnsTable => { if let Some(index) = add_table_state.column_table_state.selected() { if let Some(col) = add_table_state.columns.get_mut(index) { col.selected = !col.selected; add_table_state.has_unsaved_changes = true; /* Message removed */ }} /* else { Message removed } */ }
|
||||
AddTableFocus::InsideIndexesTable => { if let Some(index) = add_table_state.index_table_state.selected() { if let Some(idx_def) = add_table_state.indexes.get_mut(index) { idx_def.selected = !idx_def.selected; add_table_state.has_unsaved_changes = true; /* Message removed */ }} /* else { Message removed } */ }
|
||||
AddTableFocus::InsideLinksTable => { if let Some(index) = add_table_state.link_table_state.selected() { if let Some(link) = add_table_state.links.get_mut(index) { link.selected = !link.selected; add_table_state.has_unsaved_changes = true; /* Message removed */ }} /* else { Message removed } */ }
|
||||
AddTableFocus::AddColumnButton => { if let Some(focus_after_add) = handle_add_column_action(add_table_state, command_message) { new_focus = focus_after_add; } else { /* Message already set by handle_add_column_action */ }}
|
||||
AddTableFocus::SaveButton => { if add_table_state.table_name.is_empty() { *command_message = "Cannot save: Table name is empty.".to_string(); } else if add_table_state.columns.is_empty() { *command_message = "Cannot save: No columns defined.".to_string(); } else { *command_message = "Saving table...".to_string(); app_state.show_loading_dialog("Saving", "Please wait..."); let mut client_clone = grpc_client.clone(); let state_clone = add_table_state.clone(); let sender_clone = save_result_sender.clone(); tokio::spawn(async move { let result = handle_save_table_action(&mut client_clone, &state_clone).await; let _ = sender_clone.send(result).await; }); }}
|
||||
AddTableFocus::DeleteSelectedButton => { let columns_to_delete: Vec<(usize, String, String)> = add_table_state.columns.iter().enumerate().filter(|(_, col)| col.selected).map(|(index, col)| (index, col.name.clone(), col.data_type.clone())).collect(); if columns_to_delete.is_empty() { *command_message = "No columns selected for deletion.".to_string(); } else { let column_details: String = columns_to_delete.iter().map(|(index, name, dtype)| format!("{}. {} ({})", index + 1, name, dtype)).collect::<Vec<String>>().join("\n"); let message = format!("Delete the following columns?\n\n{}", column_details); app_state.show_dialog("Confirm Deletion", &message, vec!["Confirm".to_string(), "Cancel".to_string()], DialogPurpose::ConfirmDeleteColumns); }}
|
||||
AddTableFocus::CancelButton => { *command_message = "Action: Cancel Add Table (Not Implemented)".to_string(); }
|
||||
_ => { handled = false; }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Other General Keys (Ignore for add_table nav) ---
|
||||
Some("toggle_sidebar") | Some("toggle_buffer_list") => {
|
||||
handled = false; // Let global handler manage these
|
||||
}
|
||||
|
||||
// --- No matching action ---
|
||||
_ => handled = false, // Event not handled by add_table navigation
|
||||
_ => handled = false,
|
||||
}
|
||||
|
||||
// If focus changed TO a table, select the first row if nothing is selected
|
||||
if handled && current_focus != add_table_state.current_focus {
|
||||
match add_table_state.current_focus {
|
||||
AddTableFocus::ColumnsTable if add_table_state.column_table_state.selected().is_none() && !add_table_state.columns.is_empty() => {
|
||||
add_table_state.column_table_state.select(Some(0));
|
||||
}
|
||||
AddTableFocus::IndexesTable if add_table_state.index_table_state.selected().is_none() && !add_table_state.indexes.is_empty() => {
|
||||
add_table_state.index_table_state.select(Some(0));
|
||||
}
|
||||
AddTableFocus::LinksTable if add_table_state.link_table_state.selected().is_none() && !add_table_state.links.is_empty() => {
|
||||
add_table_state.link_table_state.select(Some(0));
|
||||
}
|
||||
_ => {} // No action needed for other focus states
|
||||
if handled && current_focus != new_focus {
|
||||
add_table_state.current_focus = new_focus;
|
||||
// Minimal change: Command message update logic can be simplified or removed if not desired
|
||||
// For now, let's keep it minimal and only update if it was truly a focus change,
|
||||
// and not a boundary message.
|
||||
if !command_message.starts_with("At ") && current_focus != new_focus { // Avoid overwriting boundary messages
|
||||
// *command_message = format!("Focus: {:?}", add_table_state.current_focus); // Optional: restore if needed
|
||||
}
|
||||
|
||||
|
||||
let new_is_canvas_input_focus = matches!(new_focus,
|
||||
AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType
|
||||
);
|
||||
app_state.ui.focus_outside_canvas = !new_is_canvas_input_focus;
|
||||
}
|
||||
// If not handled, command_message remains as it was (e.g., from a deeper function call or previous event)
|
||||
// or can be cleared if that's the desired default. For minimal change, we leave it.
|
||||
|
||||
handled
|
||||
}
|
||||
|
||||
|
||||
// Helper function for navigating up within a table state
|
||||
// Returns true if navigation happened within the table, false if it reached the top
|
||||
fn navigate_table_up(table_state: &mut TableState, item_count: usize) -> bool {
|
||||
if item_count == 0 { return false; } // Cannot navigate empty table
|
||||
let current_selection = table_state.selected();
|
||||
match current_selection {
|
||||
Some(index) => {
|
||||
if index > 0 {
|
||||
table_state.select(Some(index - 1));
|
||||
true // Moved up within table
|
||||
} else {
|
||||
false // Was at the top
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// If nothing selected, moving up could select the last item
|
||||
table_state.select(Some(item_count - 1));
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for navigating down within a table state
|
||||
// Returns true if navigation happened within the table, false if it reached the bottom
|
||||
fn navigate_table_down(table_state: &mut TableState, item_count: usize) -> bool {
|
||||
if item_count == 0 { return false; } // Cannot navigate empty table
|
||||
let current_selection = table_state.selected();
|
||||
match current_selection {
|
||||
Some(index) => {
|
||||
if index < item_count - 1 {
|
||||
table_state.select(Some(index + 1));
|
||||
true // Moved down within table
|
||||
} else {
|
||||
false // Was at the bottom
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// If nothing selected, moving down could select the first item
|
||||
table_state.select(Some(0));
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,183 +1,351 @@
|
||||
// src/functions/modes/navigation/admin_nav.rs
|
||||
|
||||
use crate::state::pages::admin::{AdminFocus, AdminState};
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::state::{
|
||||
app::state::AppState,
|
||||
pages::admin::{AdminFocus, AdminState},
|
||||
};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use crate::state::app::buffer::AppView;
|
||||
use crate::state::app::buffer::BufferState;
|
||||
use crate::state::app::buffer::{BufferState, AppView};
|
||||
use crate::state::pages::add_table::{AddTableState, LinkDefinition};
|
||||
use ratatui::widgets::ListState;
|
||||
use crate::state::pages::add_logic::{AddLogicState, AddLogicFocus}; // Added AddLogicFocus import
|
||||
|
||||
// Helper functions list_select_next and list_select_previous remain the same
|
||||
fn list_select_next(list_state: &mut ListState, item_count: usize) {
|
||||
if item_count == 0 {
|
||||
list_state.select(None);
|
||||
return;
|
||||
}
|
||||
let i = match list_state.selected() {
|
||||
Some(i) => if i >= item_count - 1 { 0 } else { i + 1 },
|
||||
None => 0,
|
||||
};
|
||||
list_state.select(Some(i));
|
||||
}
|
||||
|
||||
fn list_select_previous(list_state: &mut ListState, item_count: usize) {
|
||||
if item_count == 0 {
|
||||
list_state.select(None);
|
||||
return;
|
||||
}
|
||||
let i = match list_state.selected() {
|
||||
Some(i) => if i == 0 { item_count - 1 } else { i - 1 },
|
||||
None => if item_count > 0 { item_count - 1 } else { 0 },
|
||||
};
|
||||
list_state.select(Some(i));
|
||||
}
|
||||
|
||||
/// Handles navigation events specifically for the Admin Panel view.
|
||||
/// Returns true if the event was handled, false otherwise.
|
||||
pub fn handle_admin_navigation(
|
||||
key: KeyEvent,
|
||||
key: crossterm::event::KeyEvent,
|
||||
config: &Config,
|
||||
app_state: &AppState,
|
||||
app_state: &mut AppState,
|
||||
admin_state: &mut AdminState,
|
||||
buffer_state: &mut BufferState,
|
||||
command_message: &mut String,
|
||||
) -> bool {
|
||||
let action = config.get_general_action(key.code, key.modifiers);
|
||||
let action = config.get_general_action(key.code, key.modifiers).map(String::from);
|
||||
let current_focus = admin_state.current_focus;
|
||||
let profile_count = app_state.profile_tree.profiles.len();
|
||||
let mut handled = false;
|
||||
|
||||
match action {
|
||||
// --- Vertical Navigation (Up/Down) ---
|
||||
Some("move_up") => {
|
||||
match current_focus {
|
||||
AdminFocus::Profiles => {
|
||||
if profile_count > 0 {
|
||||
// Updates navigation state, resets table state
|
||||
admin_state.previous_profile(profile_count);
|
||||
*command_message = "Navigated profiles".to_string();
|
||||
}
|
||||
}
|
||||
AdminFocus::Tables => {
|
||||
// Updates table navigation state
|
||||
if let Some(nav_profile_idx) = admin_state.profile_list_state.selected() {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(nav_profile_idx) {
|
||||
let table_count = profile.tables.len();
|
||||
if table_count > 0 {
|
||||
admin_state.previous_table(table_count);
|
||||
*command_message = "Navigated tables".to_string();
|
||||
}
|
||||
match current_focus {
|
||||
AdminFocus::ProfilesPane => {
|
||||
match action.as_deref() {
|
||||
Some("select") => {
|
||||
admin_state.current_focus = AdminFocus::InsideProfilesList;
|
||||
if !app_state.profile_tree.profiles.is_empty() {
|
||||
if admin_state.profile_list_state.selected().is_none() {
|
||||
admin_state.profile_list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
*command_message = "Navigating profiles. Use Up/Down. Esc to exit.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
AdminFocus::Button1 | AdminFocus::Button2 | AdminFocus::Button3 => {}
|
||||
Some("next_option") | Some("move_down") => {
|
||||
admin_state.current_focus = AdminFocus::Tables;
|
||||
*command_message = "Focus: Tables Pane".to_string();
|
||||
handled = true;
|
||||
}
|
||||
Some("previous_option") | Some("move_up") => {
|
||||
// No wrap-around: Stay on ProfilesPane if trying to go "before" it
|
||||
*command_message = "At first focusable pane.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
true // Event handled
|
||||
}
|
||||
Some("move_down") => {
|
||||
match current_focus {
|
||||
AdminFocus::Profiles => {
|
||||
|
||||
AdminFocus::InsideProfilesList => {
|
||||
match action.as_deref() {
|
||||
Some("move_up") => {
|
||||
if profile_count > 0 {
|
||||
// Updates navigation state, resets table state
|
||||
admin_state.next_profile(profile_count);
|
||||
*command_message = "Navigated profiles".to_string();
|
||||
list_select_previous(&mut admin_state.profile_list_state, profile_count);
|
||||
*command_message = "".to_string();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
AdminFocus::Tables => {
|
||||
if let Some(nav_profile_idx) = admin_state.profile_list_state.selected() {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(nav_profile_idx) {
|
||||
let table_count = profile.tables.len();
|
||||
if table_count > 0 {
|
||||
admin_state.next_table(table_count);
|
||||
*command_message = "Navigated tables".to_string();
|
||||
Some("move_down") => {
|
||||
if profile_count > 0 {
|
||||
list_select_next(&mut admin_state.profile_list_state, profile_count);
|
||||
*command_message = "".to_string();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
Some("select") => {
|
||||
admin_state.selected_profile_index = admin_state.profile_list_state.selected();
|
||||
admin_state.selected_table_index = None; // Deselect table when profile changes
|
||||
if let Some(profile_idx) = admin_state.selected_profile_index {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(profile_idx) {
|
||||
if !profile.tables.is_empty() {
|
||||
admin_state.table_list_state.select(Some(0)); // Auto-select first table for nav
|
||||
} else {
|
||||
admin_state.table_list_state.select(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AdminFocus::Button1 | AdminFocus::Button2 | AdminFocus::Button3 => {}
|
||||
}
|
||||
true // Event handled
|
||||
}
|
||||
|
||||
// --- Horizontal Navigation (Focus Change) ---
|
||||
Some("next_option") | Some("previous_option") => {
|
||||
let old_focus = admin_state.current_focus;
|
||||
let is_next = action == Some("next_option"); // Check if 'l' or 'h'
|
||||
|
||||
admin_state.current_focus = match old_focus {
|
||||
AdminFocus::Profiles => if is_next { AdminFocus::Tables } else { AdminFocus::Button3 }, // P -> T (l) or P -> B3 (h)
|
||||
AdminFocus::Tables => if is_next { AdminFocus::Button1 } else { AdminFocus::Profiles }, // T -> B1 (l) or T -> P (h)
|
||||
AdminFocus::Button1 => if is_next { AdminFocus::Button2 } else { AdminFocus::Tables }, // B1 -> B2 (l) or B1 -> T (h)
|
||||
AdminFocus::Button2 => if is_next { AdminFocus::Button3 } else { AdminFocus::Button1 }, // B2 -> B3 (l) or B2 -> B1 (h)
|
||||
AdminFocus::Button3 => if is_next { AdminFocus::Profiles } else { AdminFocus::Button2 }, // B3 -> P (l) or B3 -> B2 (h)
|
||||
};
|
||||
|
||||
let new_focus = admin_state.current_focus;
|
||||
*command_message = format!("Focus set to {:?}", new_focus);
|
||||
// Auto-select first item only when moving from Profiles to Tables via 'l'
|
||||
if old_focus == AdminFocus::Profiles && new_focus == AdminFocus::Tables && is_next {
|
||||
if let Some(profile_idx) = admin_state.profile_list_state.selected() {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(profile_idx) {
|
||||
if !profile.tables.is_empty() {
|
||||
admin_state.table_list_state.select(Some(0));
|
||||
} else {
|
||||
admin_state.table_list_state.select(None);
|
||||
}
|
||||
} else {
|
||||
admin_state.table_list_state.select(None);
|
||||
}
|
||||
} else {
|
||||
admin_state.table_list_state.select(None);
|
||||
}
|
||||
}
|
||||
// Clear table nav selection if moving away from Tables
|
||||
if old_focus == AdminFocus::Tables && new_focus != AdminFocus::Tables {
|
||||
admin_state.table_list_state.select(None);
|
||||
}
|
||||
// Clear profile nav selection if moving away from Profiles
|
||||
if old_focus == AdminFocus::Profiles && new_focus != AdminFocus::Profiles {
|
||||
// Maybe keep profile nav highlight? Let's try clearing it.
|
||||
// admin_state.profile_list_state.select(None); // Optional: clear profile nav highlight
|
||||
}
|
||||
|
||||
true // Event handled
|
||||
}
|
||||
|
||||
// --- Selection ---
|
||||
Some("select") => {
|
||||
match current_focus {
|
||||
AdminFocus::Profiles => {
|
||||
// Set the persistent selection to the currently navigated item
|
||||
if let Some(nav_idx) = admin_state.profile_list_state.selected() {
|
||||
admin_state.selected_profile_index = Some(nav_idx); // Set persistent selection
|
||||
|
||||
// Move focus to Tables (like pressing 'l')
|
||||
admin_state.current_focus = AdminFocus::Tables;
|
||||
|
||||
// Select the first table for navigation highlight
|
||||
admin_state.table_list_state.select(None); // Clear table nav first
|
||||
admin_state.selected_table_index = None; // Clear persistent table selection
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(nav_idx) {
|
||||
if !profile.tables.is_empty() {
|
||||
// Set table nav highlight
|
||||
admin_state.table_list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
*command_message = format!("Selected profile idx {}, focus on Tables", nav_idx);
|
||||
} else {
|
||||
*command_message = "No profile selected".to_string();
|
||||
admin_state.table_list_state.select(None);
|
||||
}
|
||||
*command_message = format!(
|
||||
"Profile '{}' set as active.",
|
||||
admin_state.get_selected_profile_name().unwrap_or(&"N/A".to_string())
|
||||
);
|
||||
handled = true;
|
||||
}
|
||||
Some("exit_table_scroll") => {
|
||||
admin_state.current_focus = AdminFocus::ProfilesPane;
|
||||
*command_message = "Focus: Profiles Pane".to_string();
|
||||
handled = true;
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
|
||||
AdminFocus::Tables => {
|
||||
match action.as_deref() {
|
||||
Some("select") => {
|
||||
admin_state.current_focus = AdminFocus::InsideTablesList;
|
||||
let current_profile_idx = admin_state.selected_profile_index
|
||||
.or_else(|| admin_state.profile_list_state.selected());
|
||||
if let Some(profile_idx) = current_profile_idx {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(profile_idx) {
|
||||
if !profile.tables.is_empty() {
|
||||
if admin_state.table_list_state.selected().is_none() {
|
||||
admin_state.table_list_state.select(Some(0));
|
||||
}
|
||||
} else {
|
||||
admin_state.table_list_state.select(None);
|
||||
}
|
||||
} else {
|
||||
admin_state.table_list_state.select(None);
|
||||
}
|
||||
} else {
|
||||
admin_state.table_list_state.select(None);
|
||||
*command_message = "Select a profile first to view its tables.".to_string();
|
||||
}
|
||||
if admin_state.current_focus == AdminFocus::InsideTablesList && !admin_state.table_list_state.selected().is_none() {
|
||||
*command_message = "Navigating tables. Use Up/Down. Esc to exit.".to_string();
|
||||
} else if admin_state.table_list_state.selected().is_none() {
|
||||
if current_profile_idx.is_none() {
|
||||
*command_message = "No profile selected to view tables.".to_string();
|
||||
} else {
|
||||
*command_message = "No tables in selected profile.".to_string();
|
||||
}
|
||||
admin_state.current_focus = AdminFocus::Tables; // Stay in Tables pane if no tables to enter
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
Some("previous_option") | Some("move_up") => {
|
||||
admin_state.current_focus = AdminFocus::ProfilesPane;
|
||||
*command_message = "Focus: Profiles Pane".to_string();
|
||||
handled = true;
|
||||
}
|
||||
Some("next_option") | Some("move_down") => {
|
||||
admin_state.current_focus = AdminFocus::Button1;
|
||||
*command_message = "Focus: Add Logic Button".to_string();
|
||||
handled = true;
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
|
||||
AdminFocus::InsideTablesList => {
|
||||
match action.as_deref() {
|
||||
Some("move_up") => {
|
||||
let current_profile_idx = admin_state.selected_profile_index
|
||||
.or_else(|| admin_state.profile_list_state.selected());
|
||||
if let Some(p_idx) = current_profile_idx {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
|
||||
if !profile.tables.is_empty() {
|
||||
list_select_previous(&mut admin_state.table_list_state, profile.tables.len());
|
||||
*command_message = "".to_string();
|
||||
handled = true;
|
||||
} else {
|
||||
*command_message = "No tables to navigate.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
*command_message = "No active profile for tables.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
AdminFocus::Tables => {
|
||||
// Set the persistent selection to the currently navigated item
|
||||
if let Some(nav_idx) = admin_state.table_list_state.selected() {
|
||||
admin_state.selected_table_index = Some(nav_idx); // Set persistent selection
|
||||
*command_message = format!("Selected table index {}", nav_idx);
|
||||
} else {
|
||||
*command_message = "No table highlighted".to_string();
|
||||
}
|
||||
// We don't change focus here for now.
|
||||
Some("move_down") => {
|
||||
let current_profile_idx = admin_state.selected_profile_index
|
||||
.or_else(|| admin_state.profile_list_state.selected());
|
||||
if let Some(p_idx) = current_profile_idx {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
|
||||
if !profile.tables.is_empty() {
|
||||
list_select_next(&mut admin_state.table_list_state, profile.tables.len());
|
||||
*command_message = "".to_string();
|
||||
handled = true;
|
||||
} else {
|
||||
*command_message = "No tables to navigate.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
*command_message = "No active profile for tables.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
AdminFocus::Button1 => {
|
||||
*command_message = "Action: Add Logic (Not Implemented)".to_string();
|
||||
// TODO: Trigger action for Button 1
|
||||
Some("select") => { // This is for persistently selecting a table with [*]
|
||||
admin_state.selected_table_index = admin_state.table_list_state.selected();
|
||||
let table_name = admin_state.selected_profile_index
|
||||
.and_then(|p_idx| app_state.profile_tree.profiles.get(p_idx))
|
||||
.and_then(|p| admin_state.selected_table_index.and_then(|t_idx| p.tables.get(t_idx)))
|
||||
.map_or("N/A", |t| t.name.as_str());
|
||||
*command_message = format!("Table '{}' set as active.", table_name);
|
||||
handled = true;
|
||||
}
|
||||
AdminFocus::Button2 => {
|
||||
buffer_state.update_history(AppView::AddTable);
|
||||
*command_message = "Navigating to Add Table page...".to_string();
|
||||
}
|
||||
AdminFocus::Button3 => {
|
||||
*command_message = "Action: Change Table (Not Implemented)".to_string();
|
||||
// TODO: Trigger action for Button 3
|
||||
Some("exit_table_scroll") => {
|
||||
admin_state.current_focus = AdminFocus::Tables;
|
||||
*command_message = "Focus: Tables Pane".to_string();
|
||||
handled = true;
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
true // Event handled
|
||||
}
|
||||
|
||||
// --- Other General Keys (Ignore for admin nav) ---
|
||||
Some("toggle_sidebar") | Some("toggle_buffer_list") | Some("next_field") | Some("prev_field") => {
|
||||
// These are handled globally or not applicable here.
|
||||
false
|
||||
AdminFocus::Button1 => { // Add Logic Button
|
||||
match action.as_deref() {
|
||||
Some("select") => { // Typically "Enter" key
|
||||
if let Some(p_idx) = admin_state.selected_profile_index {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
|
||||
if let Some(t_idx) = admin_state.selected_table_index {
|
||||
if let Some(table) = profile.tables.get(t_idx) {
|
||||
// Both profile and table are selected, proceed
|
||||
admin_state.add_logic_state = AddLogicState {
|
||||
profile_name: profile.name.clone(),
|
||||
selected_table_name: Some(table.name.clone()),
|
||||
selected_table_id: Some(table.id), // If you have table IDs
|
||||
editor_keybinding_mode: config.editor.keybinding_mode.clone(),
|
||||
current_focus: AddLogicFocus::default(),
|
||||
..AddLogicState::default()
|
||||
};
|
||||
|
||||
// Store table info for later fetching
|
||||
app_state.pending_table_structure_fetch = Some((
|
||||
profile.name.clone(),
|
||||
table.name.clone()
|
||||
));
|
||||
|
||||
buffer_state.update_history(AppView::AddLogic);
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
*command_message = format!(
|
||||
"Opening Add Logic for table '{}' in profile '{}'...",
|
||||
table.name, profile.name
|
||||
);
|
||||
} else {
|
||||
*command_message = "Error: Selected table data not found.".to_string();
|
||||
}
|
||||
} else {
|
||||
*command_message = "Select a table first!".to_string();
|
||||
}
|
||||
} else {
|
||||
*command_message = "Error: Selected profile data not found.".to_string();
|
||||
}
|
||||
} else {
|
||||
*command_message = "Select a profile first!".to_string();
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
Some("previous_option") | Some("move_up") => {
|
||||
admin_state.current_focus = AdminFocus::Tables;
|
||||
*command_message = "Focus: Tables Pane".to_string();
|
||||
handled = true;
|
||||
}
|
||||
Some("next_option") | Some("move_down") => {
|
||||
admin_state.current_focus = AdminFocus::Button2;
|
||||
*command_message = "Focus: Add Table Button".to_string();
|
||||
handled = true;
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
|
||||
// --- No matching action ---
|
||||
_ => false, // Event not handled by admin navigation
|
||||
AdminFocus::Button2 => { // Add Table Button
|
||||
match action.as_deref() {
|
||||
Some("select") => {
|
||||
if let Some(p_idx) = admin_state.selected_profile_index {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
|
||||
let selected_profile_name = profile.name.clone();
|
||||
// Prepare links from the selected profile's existing tables
|
||||
let available_links: Vec<LinkDefinition> = profile.tables.iter()
|
||||
.map(|table| LinkDefinition {
|
||||
linked_table_name: table.name.clone(),
|
||||
is_required: false, // Default, can be changed in AddTable screen
|
||||
selected: false,
|
||||
}).collect();
|
||||
|
||||
admin_state.add_table_state = AddTableState {
|
||||
profile_name: selected_profile_name,
|
||||
links: available_links,
|
||||
..AddTableState::default() // Reset other fields
|
||||
};
|
||||
buffer_state.update_history(AppView::AddTable);
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
*command_message = format!("Opening Add Table for profile '{}'...", admin_state.add_table_state.profile_name);
|
||||
handled = true;
|
||||
} else {
|
||||
*command_message = "Error: Selected profile index out of bounds.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
} else {
|
||||
*command_message = "Please select a profile ([*]) first to add a table.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
Some("previous_option") | Some("move_up") => {
|
||||
admin_state.current_focus = AdminFocus::Button1;
|
||||
*command_message = "Focus: Add Logic Button".to_string();
|
||||
handled = true;
|
||||
}
|
||||
Some("next_option") | Some("move_down") => {
|
||||
admin_state.current_focus = AdminFocus::Button3;
|
||||
*command_message = "Focus: Change Table Button".to_string();
|
||||
handled = true;
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
|
||||
AdminFocus::Button3 => { // Change Table Button
|
||||
match action.as_deref() {
|
||||
Some("select") => {
|
||||
// Future: Logic to load selected table into AddTableState for editing
|
||||
*command_message = "Action: Change Table (Not Implemented)".to_string();
|
||||
handled = true;
|
||||
}
|
||||
Some("previous_option") | Some("move_up") => {
|
||||
admin_state.current_focus = AdminFocus::Button2;
|
||||
*command_message = "Focus: Add Table Button".to_string();
|
||||
handled = true;
|
||||
}
|
||||
Some("next_option") | Some("move_down") => {
|
||||
// No wrap-around: Stay on Button3 if trying to go "after" it
|
||||
*command_message = "At last focusable button.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
}
|
||||
handled
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
// src/functions/modes/read_only.rs
|
||||
|
||||
pub mod auth_ro;
|
||||
pub mod form_ro;
|
||||
@@ -1,367 +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 std::error::Error;
|
||||
|
||||
#[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, Box<dyn Error>> {
|
||||
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_next_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let next_start = find_next_word_start(text, current_pos);
|
||||
|
||||
if next_start >= chars.len() {
|
||||
return chars.len().saturating_sub(1);
|
||||
}
|
||||
|
||||
let mut pos = next_start;
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
|
||||
while pos < chars.len() && get_char_type(chars[pos]) == word_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos.saturating_sub(1).min(chars.len().saturating_sub(1))
|
||||
}
|
||||
|
||||
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 original_pos = pos;
|
||||
|
||||
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,353 +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 std::error::Error;
|
||||
|
||||
#[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, Box<dyn Error>> {
|
||||
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_next_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let next_start = find_next_word_start(text, current_pos);
|
||||
|
||||
if next_start >= chars.len() {
|
||||
return chars.len().saturating_sub(1);
|
||||
}
|
||||
|
||||
let mut pos = next_start;
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
|
||||
while pos < chars.len() && get_char_type(chars[pos]) == word_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos.saturating_sub(1).min(chars.len().saturating_sub(1))
|
||||
}
|
||||
|
||||
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 original_pos = pos;
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub mod components;
|
||||
pub mod modes;
|
||||
pub mod functions;
|
||||
pub mod services;
|
||||
pub mod utils;
|
||||
|
||||
pub use ui::run_ui;
|
||||
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
// client/src/main.rs
|
||||
use client::run_ui;
|
||||
#[cfg(feature = "ui-debug")]
|
||||
use client::utils::debug_logger::UiDebugWriter;
|
||||
use dotenvy::dotenv;
|
||||
use std::error::Error;
|
||||
use anyhow::Result;
|
||||
use tracing_subscriber;
|
||||
use std::env;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn Error>> {
|
||||
async fn main() -> Result<()> {
|
||||
#[cfg(feature = "ui-debug")]
|
||||
{
|
||||
// If ui-debug is on, set up our custom writer.
|
||||
let writer = UiDebugWriter::new();
|
||||
tracing_subscriber::fmt()
|
||||
.with_level(false) // Don't show INFO, ERROR, etc.
|
||||
.with_target(false) // Don't show the module path.
|
||||
.without_time() // This is the correct and simpler method.
|
||||
.with_writer(move || writer.clone())
|
||||
.init();
|
||||
}
|
||||
#[cfg(not(feature = "ui-debug"))]
|
||||
{
|
||||
if env::var("ENABLE_TRACING").is_ok() {
|
||||
tracing_subscriber::fmt::init();
|
||||
}
|
||||
}
|
||||
|
||||
dotenv().ok();
|
||||
run_ui().await
|
||||
}
|
||||
|
||||
@@ -7,10 +7,11 @@ use crate::services::grpc_client::GrpcClient;
|
||||
use crate::services::auth::AuthClient;
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use crate::tui::functions::common::form::SaveOutcome;
|
||||
use anyhow::{Context, Result};
|
||||
use crate::tui::functions::common::{
|
||||
form::{save as form_save, revert as form_revert},
|
||||
login::{save as login_save, revert as login_revert},
|
||||
register::{save as register_save, revert as register_revert},
|
||||
register::{revert as register_revert},
|
||||
};
|
||||
|
||||
pub async fn handle_core_action(
|
||||
@@ -23,24 +24,18 @@ pub async fn handle_core_action(
|
||||
auth_client: &mut AuthClient,
|
||||
terminal: &mut TerminalCore,
|
||||
app_state: &mut AppState,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<EventOutcome, Box<dyn std::error::Error>> {
|
||||
) -> Result<EventOutcome> {
|
||||
match action {
|
||||
"save" => {
|
||||
if app_state.ui.show_login {
|
||||
let message = login_save(auth_state, login_state, auth_client, app_state).await?;
|
||||
Ok(EventOutcome::Ok(message))
|
||||
} else if app_state.ui.show_register {
|
||||
let message = register_save(register_state, auth_client, app_state).await?;
|
||||
let message = login_save(auth_state, login_state, auth_client, app_state).await.context("Login save action failed")?;
|
||||
Ok(EventOutcome::Ok(message))
|
||||
} else {
|
||||
let save_outcome = form_save(
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
).await?;
|
||||
).await.context("Register save action failed")?;
|
||||
let message = match save_outcome {
|
||||
SaveOutcome::NoChange => "No changes to save.".to_string(),
|
||||
SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
|
||||
@@ -55,15 +50,12 @@ pub async fn handle_core_action(
|
||||
},
|
||||
"save_and_quit" => {
|
||||
let message = if app_state.ui.show_login {
|
||||
login_save(auth_state, login_state, auth_client, app_state).await?
|
||||
} else if app_state.ui.show_register {
|
||||
register_save(register_state, auth_client, app_state).await?
|
||||
login_save(auth_state, login_state, auth_client, app_state).await.context("Login save n quit action failed")?
|
||||
} else {
|
||||
let save_outcome = form_save(
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
).await?;
|
||||
match save_outcome {
|
||||
SaveOutcome::NoChange => "No changes to save.".to_string(),
|
||||
@@ -85,9 +77,7 @@ pub async fn handle_core_action(
|
||||
let message = form_revert(
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
).await?;
|
||||
).await.context("Form revert x action failed")?;
|
||||
Ok(EventOutcome::Ok(message))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,234 +1,500 @@
|
||||
// src/modes/canvas/edit.rs
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::modes::handlers::event::EventHandler;
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::pages::{auth::{LoginState, RegisterState}, canvas_state::CanvasState};
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use crate::functions::modes::edit::{auth_e, form_e};
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::admin::AdminState;
|
||||
use crate::state::pages::{
|
||||
auth::{LoginState, RegisterState},
|
||||
form::FormState,
|
||||
};
|
||||
use canvas::canvas::CanvasState;
|
||||
use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher, canvas::ActionResult};
|
||||
use anyhow::Result;
|
||||
use common::proto::komp_ac::search::search_response::Hit;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
// Removed duplicate/unused imports
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EditEventOutcome {
|
||||
Message(String), // Return a message, stay in Edit mode
|
||||
ExitEditMode, // Signal to exit Edit mode
|
||||
Message(String),
|
||||
ExitEditMode,
|
||||
}
|
||||
|
||||
/// Helper function to spawn a non-blocking search task for autocomplete.
|
||||
async fn trigger_form_autocomplete_search(
|
||||
form_state: &mut FormState,
|
||||
grpc_client: &mut GrpcClient,
|
||||
sender: mpsc::UnboundedSender<Vec<Hit>>,
|
||||
) {
|
||||
if let Some(field_def) = form_state.fields.get(form_state.current_field) {
|
||||
if field_def.is_link {
|
||||
if let Some(target_table) = &field_def.link_target_table {
|
||||
// 1. Update state for immediate UI feedback
|
||||
form_state.autocomplete_loading = true;
|
||||
form_state.autocomplete_active = true;
|
||||
form_state.autocomplete_suggestions.clear();
|
||||
form_state.selected_suggestion_index = None;
|
||||
|
||||
// 2. Clone everything needed for the background task
|
||||
let query = form_state.get_current_input().to_string();
|
||||
let table_to_search = target_table.clone();
|
||||
let mut grpc_client_clone = grpc_client.clone();
|
||||
|
||||
info!(
|
||||
"[Autocomplete] Spawning search in '{}' for query: '{}'",
|
||||
table_to_search, query
|
||||
);
|
||||
|
||||
// 3. Spawn the non-blocking task
|
||||
tokio::spawn(async move {
|
||||
match grpc_client_clone
|
||||
.search_table(table_to_search, query)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
// Send results back through the channel
|
||||
let _ = sender.send(response.hits);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"[Autocomplete] Search failed: {:?}",
|
||||
e
|
||||
);
|
||||
// Send an empty vec on error so the UI can stop loading
|
||||
let _ = sender.send(vec![]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)]
|
||||
pub async fn handle_edit_event(
|
||||
key: KeyEvent,
|
||||
config: &Config,
|
||||
form_state: &mut FormState,
|
||||
login_state: &mut LoginState,
|
||||
register_state: &mut RegisterState,
|
||||
ideal_cursor_column: &mut usize,
|
||||
// command_message: &mut String, // Removed as messages are returned
|
||||
admin_state: &mut AdminState,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
grpc_client: &mut GrpcClient,
|
||||
event_handler: &mut EventHandler,
|
||||
app_state: &AppState,
|
||||
) -> Result<EditEventOutcome, Box<dyn std::error::Error>> {
|
||||
|
||||
// Global command mode check
|
||||
if let Some("enter_command_mode") = config.get_action_for_key_in_mode(
|
||||
&config.keybindings.global,
|
||||
key.code,
|
||||
key.modifiers
|
||||
) {
|
||||
return Ok(EditEventOutcome::Message("Switching to Command Mode...".to_string()));
|
||||
}
|
||||
|
||||
// Common actions (save/revert)
|
||||
if let Some(action) = config.get_action_for_key_in_mode(
|
||||
&config.keybindings.common,
|
||||
key.code,
|
||||
key.modifiers
|
||||
) {
|
||||
if matches!(action, "save" | "revert") {
|
||||
// Ensure all branches result in Result<String, Error> before the final Ok(...) wrap
|
||||
let message_string: String = if app_state.ui.show_login {
|
||||
auth_e::execute_common_action(
|
||||
action,
|
||||
login_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count
|
||||
).await? // Results in String on success
|
||||
} else if app_state.ui.show_register {
|
||||
auth_e::execute_common_action(
|
||||
action,
|
||||
register_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count
|
||||
).await? // Results in String on success
|
||||
} else {
|
||||
let outcome = form_e::execute_common_action(
|
||||
action,
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count
|
||||
).await?; // This returns EventOutcome on success
|
||||
|
||||
// Extract the message string from the EventOutcome
|
||||
match outcome {
|
||||
EventOutcome::Ok(msg) => msg,
|
||||
EventOutcome::DataSaved(_, msg) => msg,
|
||||
_ => format!("Unexpected outcome from common action: {:?}", outcome),
|
||||
) -> Result<EditEventOutcome> {
|
||||
// --- AUTOCOMPLETE-SPECIFIC KEY HANDLING ---
|
||||
if app_state.ui.show_form && form_state.autocomplete_active {
|
||||
if let Some(action) =
|
||||
config.get_edit_action_for_key(key.code, key.modifiers)
|
||||
{
|
||||
match action {
|
||||
"suggestion_down" => {
|
||||
if !form_state.autocomplete_suggestions.is_empty() {
|
||||
let current =
|
||||
form_state.selected_suggestion_index.unwrap_or(0);
|
||||
let next = (current + 1)
|
||||
% form_state.autocomplete_suggestions.len();
|
||||
form_state.selected_suggestion_index = Some(next);
|
||||
}
|
||||
return Ok(EditEventOutcome::Message(String::new()));
|
||||
}
|
||||
};
|
||||
// Wrap the resulting String message
|
||||
return Ok(EditEventOutcome::Message(message_string));
|
||||
"suggestion_up" => {
|
||||
if !form_state.autocomplete_suggestions.is_empty() {
|
||||
let current =
|
||||
form_state.selected_suggestion_index.unwrap_or(0);
|
||||
let prev = if current == 0 {
|
||||
form_state.autocomplete_suggestions.len() - 1
|
||||
} else {
|
||||
current - 1
|
||||
};
|
||||
form_state.selected_suggestion_index = Some(prev);
|
||||
}
|
||||
return Ok(EditEventOutcome::Message(String::new()));
|
||||
}
|
||||
"exit" => {
|
||||
form_state.deactivate_autocomplete();
|
||||
return Ok(EditEventOutcome::Message(
|
||||
"Autocomplete cancelled".to_string(),
|
||||
));
|
||||
}
|
||||
"enter_decider" => {
|
||||
if let Some(selected_idx) =
|
||||
form_state.selected_suggestion_index
|
||||
{
|
||||
if let Some(selection) = form_state
|
||||
.autocomplete_suggestions
|
||||
.get(selected_idx)
|
||||
.cloned()
|
||||
{
|
||||
// --- THIS IS THE CORE LOGIC CHANGE ---
|
||||
|
||||
// 1. Get the friendly display name for the UI
|
||||
let display_name =
|
||||
form_state.get_display_name_for_hit(&selection);
|
||||
|
||||
// 2. Store the REAL ID in the form's values
|
||||
let current_input =
|
||||
form_state.get_current_input_mut();
|
||||
*current_input = selection.id.to_string();
|
||||
|
||||
// 3. Set the persistent display override in the map
|
||||
form_state.link_display_map.insert(
|
||||
form_state.current_field,
|
||||
display_name,
|
||||
);
|
||||
|
||||
// 4. Finalize state
|
||||
form_state.deactivate_autocomplete();
|
||||
form_state.set_has_unsaved_changes(true);
|
||||
return Ok(EditEventOutcome::Message(
|
||||
"Selection made".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
form_state.deactivate_autocomplete();
|
||||
// Fall through to default 'enter' behavior
|
||||
}
|
||||
_ => {} // Let other keys fall through to the live search logic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Edit-specific actions
|
||||
if let Some(action) = config.get_edit_action_for_key(key.code, key.modifiers) {
|
||||
if action == "exit" {
|
||||
if app_state.ui.show_register && register_state.in_suggestion_mode {
|
||||
// Call the action, get Result<String, Error>
|
||||
let msg = auth_e::execute_edit_action(
|
||||
"exit_suggestion_mode",
|
||||
// --- LIVE AUTOCOMPLETE TRIGGER LOGIC ---
|
||||
let mut trigger_search = false;
|
||||
|
||||
if app_state.ui.show_form {
|
||||
// Manual trigger
|
||||
if let Some("trigger_autocomplete") =
|
||||
config.get_edit_action_for_key(key.code, key.modifiers)
|
||||
{
|
||||
if !form_state.autocomplete_active {
|
||||
trigger_search = true;
|
||||
}
|
||||
}
|
||||
// Live search trigger while typing
|
||||
else if form_state.autocomplete_active {
|
||||
if let KeyCode::Char(_) | KeyCode::Backspace = key.code {
|
||||
let action = if let KeyCode::Backspace = key.code {
|
||||
"delete_char_backward"
|
||||
} else {
|
||||
"insert_char"
|
||||
};
|
||||
// FIXED: Use canvas library instead of form_e::execute_edit_action
|
||||
execute_canvas_action(
|
||||
action,
|
||||
key,
|
||||
register_state,
|
||||
ideal_cursor_column,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
).await?; // Results in String on success
|
||||
// Wrap the String message
|
||||
return Ok(EditEventOutcome::Message(msg));
|
||||
} else {
|
||||
// Signal exit
|
||||
return Ok(EditEventOutcome::ExitEditMode);
|
||||
form_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?;
|
||||
trigger_search = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for role field suggestions
|
||||
if app_state.ui.show_register && register_state.current_field() == 4 {
|
||||
if !register_state.in_suggestion_mode && key.code == KeyCode::Tab && key.modifiers == KeyModifiers::NONE {
|
||||
register_state.update_role_suggestions();
|
||||
if !register_state.role_suggestions.is_empty() {
|
||||
register_state.in_suggestion_mode = true;
|
||||
register_state.selected_suggestion_index = Some(0);
|
||||
return Ok(EditEventOutcome::Message("Suggestions shown".to_string()));
|
||||
} else { // Added else here for clarity
|
||||
return Ok(EditEventOutcome::Message("No suggestions available".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute other edit actions
|
||||
let msg = if app_state.ui.show_login {
|
||||
auth_e::execute_edit_action(
|
||||
action,
|
||||
key,
|
||||
login_state,
|
||||
ideal_cursor_column,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count
|
||||
).await? // Results in String
|
||||
} else if app_state.ui.show_register {
|
||||
auth_e::execute_edit_action(
|
||||
action,
|
||||
key,
|
||||
register_state,
|
||||
ideal_cursor_column,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count
|
||||
).await? // Results in String
|
||||
} else {
|
||||
form_e::execute_edit_action(
|
||||
action,
|
||||
key,
|
||||
form_state,
|
||||
ideal_cursor_column,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count
|
||||
).await? // Results in String
|
||||
};
|
||||
// Wrap the resulting String message
|
||||
return Ok(EditEventOutcome::Message(msg));
|
||||
}
|
||||
|
||||
// Character insertion
|
||||
if let KeyCode::Char(_) = key.code {
|
||||
if app_state.ui.show_register && register_state.in_suggestion_mode {
|
||||
register_state.in_suggestion_mode = false;
|
||||
register_state.show_role_suggestions = false;
|
||||
register_state.selected_suggestion_index = None;
|
||||
}
|
||||
|
||||
// Execute insert_char action
|
||||
let msg = if app_state.ui.show_login {
|
||||
auth_e::execute_edit_action(
|
||||
"insert_char",
|
||||
key,
|
||||
login_state,
|
||||
ideal_cursor_column,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count
|
||||
).await? // Results in String
|
||||
} else if app_state.ui.show_register {
|
||||
auth_e::execute_edit_action(
|
||||
"insert_char",
|
||||
key,
|
||||
register_state,
|
||||
ideal_cursor_column,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count
|
||||
).await? // Results in String
|
||||
} else {
|
||||
form_e::execute_edit_action(
|
||||
"insert_char",
|
||||
key,
|
||||
form_state,
|
||||
ideal_cursor_column,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count
|
||||
).await? // Results in String
|
||||
};
|
||||
// Wrap the resulting String message
|
||||
return Ok(EditEventOutcome::Message(msg));
|
||||
if trigger_search {
|
||||
trigger_form_autocomplete_search(
|
||||
form_state,
|
||||
&mut event_handler.grpc_client,
|
||||
event_handler.autocomplete_result_sender.clone(),
|
||||
)
|
||||
.await;
|
||||
return Ok(EditEventOutcome::Message("Searching...".to_string()));
|
||||
}
|
||||
|
||||
// Handle Backspace/Delete
|
||||
if matches!(key.code, KeyCode::Backspace | KeyCode::Delete) {
|
||||
if app_state.ui.show_register && register_state.in_suggestion_mode {
|
||||
register_state.in_suggestion_mode = false;
|
||||
register_state.show_role_suggestions = false;
|
||||
register_state.selected_suggestion_index = None;
|
||||
// --- GENERAL EDIT MODE EVENT HANDLING (IF NOT AUTOCOMPLETE) ---
|
||||
|
||||
if let Some(action_str) =
|
||||
config.get_edit_action_for_key(key.code, key.modifiers)
|
||||
{
|
||||
// Handle Enter key (next field)
|
||||
if action_str == "enter_decider" {
|
||||
// FIXED: Use canvas library instead of form_e::execute_edit_action
|
||||
let msg = execute_canvas_action(
|
||||
"next_field",
|
||||
key,
|
||||
form_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?;
|
||||
return Ok(EditEventOutcome::Message(msg));
|
||||
}
|
||||
|
||||
let action_str = if key.code == KeyCode::Backspace { "backspace" } else { "delete_char" };
|
||||
// Ensure both branches result in a String *before* wrapping
|
||||
let result_msg: String = if app_state.ui.show_register {
|
||||
auth_e::execute_edit_action(
|
||||
// Handle exiting edit mode
|
||||
if action_str == "exit" {
|
||||
return Ok(EditEventOutcome::ExitEditMode);
|
||||
}
|
||||
|
||||
// Handle all other edit actions - NOW USING CANVAS LIBRARY
|
||||
let msg = if app_state.ui.show_login {
|
||||
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
|
||||
handle_canvas_state_edit(
|
||||
key,
|
||||
config,
|
||||
login_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_add_table {
|
||||
// NEW: Use unified canvas handler instead of add_table_e::execute_edit_action
|
||||
handle_canvas_state_edit(
|
||||
key,
|
||||
config,
|
||||
&mut admin_state.add_table_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_add_logic {
|
||||
// NEW: Use unified canvas handler instead of add_logic_e::execute_edit_action
|
||||
handle_canvas_state_edit(
|
||||
key,
|
||||
config,
|
||||
&mut admin_state.add_logic_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_register {
|
||||
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
|
||||
handle_canvas_state_edit(
|
||||
key,
|
||||
config,
|
||||
register_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
// FIXED: Use canvas library instead of form_e::execute_edit_action
|
||||
execute_canvas_action(
|
||||
action_str,
|
||||
key,
|
||||
register_state,
|
||||
ideal_cursor_column,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count
|
||||
).await? // Results in String
|
||||
} else {
|
||||
// Return String directly, not Ok(String)
|
||||
"Action not applicable here".to_string()
|
||||
}; // Semicolon here ends the if/else expression
|
||||
|
||||
// Wrap the resulting String message
|
||||
return Ok(EditEventOutcome::Message(result_msg));
|
||||
form_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
return Ok(EditEventOutcome::Message(msg));
|
||||
}
|
||||
|
||||
// Default return if no other handler matched
|
||||
Ok(EditEventOutcome::Message("".to_string()))
|
||||
}
|
||||
// --- FALLBACK FOR CHARACTER INSERTION (IF NO OTHER BINDING MATCHED) ---
|
||||
if let KeyCode::Char(_) = key.code {
|
||||
let msg = if app_state.ui.show_login {
|
||||
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
|
||||
handle_canvas_state_edit(
|
||||
key,
|
||||
config,
|
||||
login_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_add_table {
|
||||
// NEW: Use unified canvas handler instead of add_table_e::execute_edit_action
|
||||
handle_canvas_state_edit(
|
||||
key,
|
||||
config,
|
||||
&mut admin_state.add_table_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_add_logic {
|
||||
// NEW: Use unified canvas handler instead of add_logic_e::execute_edit_action
|
||||
handle_canvas_state_edit(
|
||||
key,
|
||||
config,
|
||||
&mut admin_state.add_logic_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_register {
|
||||
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
|
||||
handle_canvas_state_edit(
|
||||
key,
|
||||
config,
|
||||
register_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
// FIXED: Use canvas library instead of form_e::execute_edit_action
|
||||
execute_canvas_action(
|
||||
"insert_char",
|
||||
key,
|
||||
form_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
return Ok(EditEventOutcome::Message(msg));
|
||||
}
|
||||
|
||||
Ok(EditEventOutcome::Message(String::new())) // No action taken
|
||||
}
|
||||
|
||||
@@ -3,69 +3,64 @@
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::config::binds::key_sequences::KeySequenceTracker;
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::pages::{canvas_state::CanvasState, auth::RegisterState};
|
||||
use crate::state::pages::auth::LoginState;
|
||||
use crate::state::pages::auth::RegisterState;
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::state::pages::add_logic::AddLogicState;
|
||||
use crate::state::pages::add_table::AddTableState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::functions::modes::read_only::{auth_ro, form_ro};
|
||||
use canvas::{canvas::{CanvasAction, CanvasState, ActionResult}, dispatcher::ActionDispatcher};
|
||||
use crossterm::event::KeyEvent;
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn handle_read_only_event(
|
||||
app_state: &mut AppState,
|
||||
key: KeyEvent,
|
||||
config: &Config,
|
||||
/// 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,
|
||||
key_sequence_tracker: &mut KeySequenceTracker,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
grpc_client: &mut GrpcClient,
|
||||
command_message: &mut String,
|
||||
edit_mode_cooldown: &mut bool,
|
||||
add_table_state: &mut AddTableState,
|
||||
add_logic_state: &mut AddLogicState,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<(bool, String), Box<dyn std::error::Error>> {
|
||||
if config.is_enter_edit_mode_before(key.code, key.modifiers) {
|
||||
*edit_mode_cooldown = true;
|
||||
*command_message = "Entering Edit mode".to_string();
|
||||
return Ok((false, command_message.clone()));
|
||||
}
|
||||
|
||||
if config.is_enter_edit_mode_after(key.code, key.modifiers) {
|
||||
let (current_input, current_pos) = if app_state.ui.show_login { // Check Login first
|
||||
(
|
||||
login_state.get_current_input(),
|
||||
login_state.current_cursor_pos(),
|
||||
)
|
||||
} else if app_state.ui.show_register { // Then check Register
|
||||
(
|
||||
register_state.get_current_input(),
|
||||
register_state.current_cursor_pos(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
form_state.get_current_input(),
|
||||
form_state.current_cursor_pos(),
|
||||
) // Default to Form
|
||||
};
|
||||
|
||||
if !current_input.is_empty() && current_pos < current_input.len() {
|
||||
if app_state.ui.show_login {
|
||||
login_state.set_current_cursor_pos(current_pos + 1);
|
||||
*ideal_cursor_column = login_state.current_cursor_pos();
|
||||
} else if app_state.ui.show_register {
|
||||
register_state.set_current_cursor_pos(current_pos + 1);
|
||||
*ideal_cursor_column = register_state.current_cursor_pos();
|
||||
} else { // Default to Form
|
||||
form_state.set_current_cursor_pos(current_pos + 1);
|
||||
*ideal_cursor_column = form_state.current_cursor_pos();
|
||||
}
|
||||
}
|
||||
*edit_mode_cooldown = true;
|
||||
*command_message = "Entering Edit mode (after cursor)".to_string();
|
||||
return Ok((false, command_message.clone()));
|
||||
) -> 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",
|
||||
@@ -74,56 +69,166 @@ pub async fn handle_read_only_event(
|
||||
"previous_entry",
|
||||
"next_entry",
|
||||
];
|
||||
// Add context actions specific to register if needed, otherwise reuse login/form ones
|
||||
const CONTEXT_ACTIONS_REGISTER: &[&str] = &[
|
||||
// Add actions like "next_field", "prev_field" if handled differently than general read-only
|
||||
];
|
||||
|
||||
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(
|
||||
app_state: &mut AppState,
|
||||
key: KeyEvent,
|
||||
config: &Config,
|
||||
form_state: &mut FormState,
|
||||
login_state: &mut LoginState,
|
||||
register_state: &mut RegisterState,
|
||||
add_table_state: &mut AddTableState,
|
||||
add_logic_state: &mut AddLogicState,
|
||||
key_sequence_tracker: &mut KeySequenceTracker,
|
||||
grpc_client: &mut GrpcClient,
|
||||
command_message: &mut String,
|
||||
edit_mode_cooldown: &mut bool,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<(bool, String)> {
|
||||
if config.is_enter_edit_mode_before(key.code, key.modifiers) {
|
||||
*edit_mode_cooldown = true;
|
||||
*command_message = "Entering Edit mode".to_string();
|
||||
return Ok((false, command_message.clone()));
|
||||
}
|
||||
|
||||
if config.is_enter_edit_mode_after(key.code, key.modifiers) {
|
||||
// Determine target state to adjust cursor - all states now use CanvasState trait
|
||||
if app_state.ui.show_login {
|
||||
let current_input = login_state.get_current_input();
|
||||
let current_pos = login_state.current_cursor_pos();
|
||||
if !current_input.is_empty() && current_pos < current_input.len() {
|
||||
login_state.set_current_cursor_pos(current_pos + 1);
|
||||
*ideal_cursor_column = login_state.current_cursor_pos();
|
||||
}
|
||||
} else if app_state.ui.show_add_logic {
|
||||
let current_input = add_logic_state.get_current_input();
|
||||
let current_pos = add_logic_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;
|
||||
*command_message = "Entering Edit mode (after cursor)".to_string();
|
||||
return Ok((false, command_message.clone()));
|
||||
}
|
||||
|
||||
if key.modifiers.is_empty() {
|
||||
key_sequence_tracker.add_key(key.code);
|
||||
let sequence = key_sequence_tracker.get_sequence();
|
||||
|
||||
if let Some(action) = config.matches_key_sequence_generalized(&sequence) {
|
||||
let result = if app_state.ui.show_form && CONTEXT_ACTIONS_FORM.contains(&action) {
|
||||
crate::tui::functions::form::handle_action(
|
||||
action,
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) { // Handle login context actions
|
||||
crate::tui::functions::login::handle_action(action).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?
|
||||
if let Some(action) = config.matches_key_sequence_generalized(&sequence).as_deref() {
|
||||
// Try context-specific actions first, otherwise use canvas dispatch
|
||||
let result = if let Some(context_result) = handle_context_action(
|
||||
action,
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
ideal_cursor_column,
|
||||
).await? {
|
||||
context_result
|
||||
} else {
|
||||
form_ro::execute_action(
|
||||
dispatch_to_active_state(
|
||||
action,
|
||||
app_state,
|
||||
form_state,
|
||||
login_state,
|
||||
register_state,
|
||||
add_table_state,
|
||||
add_logic_state,
|
||||
ideal_cursor_column,
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
)
|
||||
.await?
|
||||
).await
|
||||
};
|
||||
key_sequence_tracker.reset();
|
||||
return Ok((false, result));
|
||||
@@ -134,47 +239,27 @@ pub async fn handle_read_only_event(
|
||||
}
|
||||
|
||||
if sequence.len() == 1 && !config.is_key_sequence_prefix(&sequence) {
|
||||
if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers) {
|
||||
let result = if app_state.ui.show_form && CONTEXT_ACTIONS_FORM.contains(&action) {
|
||||
crate::tui::functions::form::handle_action(
|
||||
action,
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) { // Handle login context actions
|
||||
crate::tui::functions::login::handle_action(action).await?
|
||||
} else if app_state.ui.show_register /* && CONTEXT_ACTIONS_REGISTER.contains(&action) */ { // Handle register general actions
|
||||
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 { // Handle login general actions
|
||||
auth_ro::execute_action(
|
||||
action,
|
||||
app_state,
|
||||
login_state,
|
||||
ideal_cursor_column,
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
)
|
||||
.await?
|
||||
if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers).as_deref() {
|
||||
// Try context-specific actions first, otherwise use canvas dispatch
|
||||
let result = if let Some(context_result) = handle_context_action(
|
||||
action,
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
ideal_cursor_column,
|
||||
).await? {
|
||||
context_result
|
||||
} else {
|
||||
form_ro::execute_action(
|
||||
dispatch_to_active_state(
|
||||
action,
|
||||
app_state,
|
||||
form_state,
|
||||
login_state,
|
||||
register_state,
|
||||
add_table_state,
|
||||
add_logic_state,
|
||||
ideal_cursor_column,
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
)
|
||||
.await?
|
||||
).await
|
||||
};
|
||||
key_sequence_tracker.reset();
|
||||
return Ok((false, result));
|
||||
@@ -184,47 +269,27 @@ pub async fn handle_read_only_event(
|
||||
} else {
|
||||
key_sequence_tracker.reset();
|
||||
|
||||
if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers) {
|
||||
let result = if app_state.ui.show_form && CONTEXT_ACTIONS_FORM.contains(&action) {
|
||||
crate::tui::functions::form::handle_action(
|
||||
action,
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) { // Handle login context actions
|
||||
crate::tui::functions::login::handle_action(action).await?
|
||||
} else if app_state.ui.show_register /* && CONTEXT_ACTIONS_REGISTER.contains(&action) */ { // Handle register general actions
|
||||
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 { // Handle login general actions
|
||||
auth_ro::execute_action(
|
||||
action,
|
||||
app_state,
|
||||
login_state,
|
||||
ideal_cursor_column,
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
)
|
||||
.await?
|
||||
if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers).as_deref() {
|
||||
// Try context-specific actions first, otherwise use canvas dispatch
|
||||
let result = if let Some(context_result) = handle_context_action(
|
||||
action,
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
ideal_cursor_column,
|
||||
).await? {
|
||||
context_result
|
||||
} else {
|
||||
form_ro::execute_action(
|
||||
dispatch_to_active_state(
|
||||
action,
|
||||
app_state,
|
||||
form_state,
|
||||
login_state,
|
||||
register_state,
|
||||
add_table_state,
|
||||
add_logic_state,
|
||||
ideal_cursor_column,
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
)
|
||||
.await?
|
||||
).await
|
||||
};
|
||||
return Ok((false, result));
|
||||
}
|
||||
|
||||
@@ -10,12 +10,12 @@ use crate::tui::terminal::core::TerminalCore;
|
||||
use crate::tui::functions::common::form::{save, revert};
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use crate::tui::functions::common::form::SaveOutcome;
|
||||
use std::error::Error;
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn handle_command_event(
|
||||
key: KeyEvent,
|
||||
config: &Config,
|
||||
app_state: &AppState,
|
||||
app_state: &mut AppState,
|
||||
login_state: &LoginState,
|
||||
register_state: &RegisterState,
|
||||
form_state: &mut FormState,
|
||||
@@ -26,7 +26,7 @@ pub async fn handle_command_event(
|
||||
terminal: &mut TerminalCore,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<EventOutcome, Box<dyn Error>> {
|
||||
) -> Result<EventOutcome> {
|
||||
// Exit command mode (via configurable keybinding)
|
||||
if config.is_exit_command_mode(key.code, key.modifiers) {
|
||||
command_input.clear();
|
||||
@@ -74,7 +74,7 @@ pub async fn handle_command_event(
|
||||
async fn process_command(
|
||||
config: &Config,
|
||||
form_state: &mut FormState,
|
||||
app_state: &AppState,
|
||||
app_state: &mut AppState,
|
||||
login_state: &LoginState,
|
||||
register_state: &RegisterState,
|
||||
command_input: &mut String,
|
||||
@@ -84,7 +84,7 @@ async fn process_command(
|
||||
terminal: &mut TerminalCore,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<EventOutcome, Box<dyn Error>> {
|
||||
) -> Result<EventOutcome> {
|
||||
// Clone the trimmed command to avoid borrow issues
|
||||
let command = command_input.trim().to_string();
|
||||
if command.is_empty() {
|
||||
@@ -117,10 +117,9 @@ async fn process_command(
|
||||
},
|
||||
"save" => {
|
||||
let outcome = save(
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
).await?;
|
||||
let message = match outcome {
|
||||
SaveOutcome::CreatedNew(_) => "New entry created".to_string(),
|
||||
@@ -134,8 +133,6 @@ async fn process_command(
|
||||
let message = revert(
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
).await?;
|
||||
command_input.clear();
|
||||
Ok(EventOutcome::Ok(message))
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
use crate::tui::terminal::core::TerminalCore;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::{form::FormState, auth::LoginState, auth::RegisterState};
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use canvas::canvas::CanvasState;
|
||||
use anyhow::Result;
|
||||
|
||||
pub struct CommandHandler;
|
||||
|
||||
@@ -19,7 +20,7 @@ impl CommandHandler {
|
||||
form_state: &FormState,
|
||||
login_state: &LoginState,
|
||||
register_state: &RegisterState,
|
||||
) -> Result<(bool, String), Box<dyn std::error::Error>> {
|
||||
) -> Result<(bool, String)> {
|
||||
match action {
|
||||
"quit" => self.handle_quit(terminal, app_state, form_state, login_state, register_state).await,
|
||||
"force_quit" => self.handle_force_quit(terminal).await,
|
||||
@@ -35,7 +36,7 @@ impl CommandHandler {
|
||||
form_state: &FormState,
|
||||
login_state: &LoginState,
|
||||
register_state: &RegisterState,
|
||||
) -> Result<(bool, String), Box<dyn std::error::Error>> {
|
||||
) -> Result<(bool, String)> {
|
||||
// Use actual unsaved changes state instead of is_saved flag
|
||||
let has_unsaved = if app_state.ui.show_login {
|
||||
login_state.has_unsaved_changes()
|
||||
@@ -56,7 +57,7 @@ impl CommandHandler {
|
||||
async fn handle_force_quit(
|
||||
&self,
|
||||
terminal: &mut TerminalCore,
|
||||
) -> Result<(bool, String), Box<dyn std::error::Error>> {
|
||||
) -> Result<(bool, String)> {
|
||||
terminal.cleanup()?;
|
||||
Ok((true, "Force exiting without saving.".into()))
|
||||
}
|
||||
@@ -64,7 +65,7 @@ impl CommandHandler {
|
||||
async fn handle_save_quit(
|
||||
&mut self,
|
||||
terminal: &mut TerminalCore,
|
||||
) -> Result<(bool, String), Box<dyn std::error::Error>> {
|
||||
) -> Result<(bool, String)> {
|
||||
terminal.cleanup()?;
|
||||
Ok((true, "State saved. Exiting.".into()))
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/client/modes/general.rs
|
||||
pub mod navigation;
|
||||
pub mod dialog;
|
||||
pub mod command_navigation;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user