Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e36324af6f | ||
|
|
60cb45dcca | ||
|
|
215be3cf09 | ||
|
|
b2aa966588 | ||
|
|
67512ac151 | ||
|
|
3f5dedbd6e | ||
|
|
ce07105eea | ||
|
|
587470c48b | ||
|
|
3227d341ed | ||
|
|
2b16a80ef8 | ||
|
|
8b742bbe09 | ||
|
|
189d3d2fc5 | ||
|
|
082093ea17 | ||
|
|
280f314100 | ||
|
|
163a6262c8 | ||
|
|
e8a564aed3 | ||
|
|
53464dfcbf | ||
|
|
b364a6606d | ||
|
|
f09e476bb6 | ||
|
|
e2c9cc4347 | ||
|
|
06106dc31b | ||
|
|
8e3c85991c | ||
|
|
d3e5418221 | ||
|
|
0d0e54032c | ||
|
|
a8de16f66d | ||
|
|
5b2e0e976f | ||
|
|
d601134535 | ||
|
|
dff320d534 | ||
|
|
96cde3ca0d | ||
|
|
6ba0124779 | ||
|
|
34c68858a3 | ||
|
|
4c8cfd4f80 | ||
|
|
85c5d7ccf9 | ||
|
|
46a0d2b9db | ||
|
|
c9b4841f67 | ||
|
|
d62cc2add6 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ server/tantivy_indexes
|
||||
steel_decimal/tests/property_tests.proptest-regressions
|
||||
.direnv/
|
||||
canvas/*.toml
|
||||
.aider*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
This guide covers the migration from the legacy canvas library structure to the new clean, modular architecture. The new design separates core canvas functionality from autocomplete features, providing better type safety and maintainability.
|
||||
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
|
||||
|
||||
@@ -10,7 +10,7 @@ This guide covers the migration from the legacy canvas library structure to the
|
||||
```
|
||||
# Old Structure (LEGACY)
|
||||
src/
|
||||
├── state.rs # Mixed canvas + autocomplete
|
||||
├── state.rs # Mixed canvas + suggestions
|
||||
├── actions/edit.rs # Mixed concerns
|
||||
├── gui/render.rs # Everything together
|
||||
└── suggestions.rs # Legacy file
|
||||
@@ -21,9 +21,9 @@ src/
|
||||
│ ├── state.rs # CanvasState trait only
|
||||
│ ├── actions/edit.rs # Canvas actions only
|
||||
│ └── gui.rs # Canvas rendering
|
||||
├── autocomplete/ # Rich autocomplete features
|
||||
│ ├── state.rs # AutocompleteCanvasState trait
|
||||
│ ├── types.rs # SuggestionItem, AutocompleteState
|
||||
├── 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
|
||||
@@ -31,7 +31,7 @@ src/
|
||||
|
||||
### 2. **Trait Separation**
|
||||
- **CanvasState**: Core form functionality (navigation, input, validation)
|
||||
- **AutocompleteCanvasState**: Optional rich autocomplete features
|
||||
- Suggestions module: Optional dropdown suggestions support
|
||||
|
||||
### 3. **Rich Suggestions**
|
||||
Replaced simple string suggestions with typed, rich suggestion objects.
|
||||
@@ -93,34 +93,29 @@ impl CanvasState for YourFormState {
|
||||
|
||||
### Step 3: Implement Rich Autocomplete (Optional)
|
||||
|
||||
**If you want rich autocomplete features:**
|
||||
**If you want suggestions dropdown features:**
|
||||
|
||||
```rust
|
||||
use canvas::autocomplete::{AutocompleteCanvasState, SuggestionItem, AutocompleteState};
|
||||
use canvas::{SuggestionItem};
|
||||
|
||||
impl AutocompleteCanvasState for YourFormState {
|
||||
type SuggestionData = YourDataType; // e.g., Hit, CustomRecord, etc.
|
||||
|
||||
fn supports_autocomplete(&self, field_index: usize) -> bool {
|
||||
// Define which fields support autocomplete
|
||||
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
|
||||
}
|
||||
|
||||
fn autocomplete_state(&self) -> Option<&AutocompleteState<Self::SuggestionData>> {
|
||||
Some(&self.autocomplete)
|
||||
}
|
||||
// Manage your own suggestion state or rely on FormEditor APIs
|
||||
|
||||
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> {
|
||||
Some(&mut self.autocomplete)
|
||||
}
|
||||
// Manage your own suggestion state or rely on FormEditor APIs
|
||||
}
|
||||
```
|
||||
|
||||
**Add autocomplete field to your state:**
|
||||
**Add suggestions storage to your state (optional, if you need to persist outside the editor):**
|
||||
```rust
|
||||
pub struct YourFormState {
|
||||
// ... existing fields
|
||||
pub autocomplete: AutocompleteState<YourDataType>,
|
||||
// Optional: your own suggestions cache if needed
|
||||
// pub suggestion_cache: Vec<SuggestionItem>,
|
||||
}
|
||||
```
|
||||
|
||||
@@ -149,9 +144,9 @@ form_state.set_autocomplete_suggestions(suggestions);
|
||||
|
||||
**Old rendering:**
|
||||
```rust
|
||||
// Manual autocomplete rendering
|
||||
if form_state.autocomplete_active {
|
||||
render_autocomplete_dropdown(/* ... */);
|
||||
// Manual suggestions rendering
|
||||
if editor.is_suggestions_active() {
|
||||
suggestions::gui::render_suggestions_dropdown(/* ... */);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -162,13 +157,12 @@ use canvas::canvas::render_canvas;
|
||||
|
||||
let active_field_rect = render_canvas(f, area, form_state, theme, edit_mode, highlight_state);
|
||||
|
||||
// Optional: Rich autocomplete (if implementing AutocompleteCanvasState)
|
||||
if form_state.is_autocomplete_active() {
|
||||
if let Some(autocomplete_state) = form_state.autocomplete_state() {
|
||||
canvas::autocomplete::render_autocomplete_dropdown(
|
||||
f, f.area(), active_field_rect.unwrap(), theme, autocomplete_state
|
||||
);
|
||||
}
|
||||
// Suggestions dropdown (if active)
|
||||
if editor.is_suggestions_active() {
|
||||
canvas::suggestions::render_suggestions_dropdown(
|
||||
f, f.area(), active_field_rect.unwrap(), theme, &editor
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -181,16 +175,16 @@ form_state.deactivate_suggestions();
|
||||
|
||||
# NEW - Option A: Add your own method
|
||||
impl YourFormState {
|
||||
pub fn deactivate_autocomplete(&mut self) {
|
||||
pub fn deactivate_suggestions(&mut self) {
|
||||
self.autocomplete_active = false;
|
||||
self.autocomplete_suggestions.clear();
|
||||
self.selected_suggestion_index = None;
|
||||
}
|
||||
}
|
||||
form_state.deactivate_autocomplete();
|
||||
editor.ui_state_mut().deactivate_suggestions();
|
||||
|
||||
# NEW - Option B: Use rich autocomplete trait
|
||||
form_state.deactivate_autocomplete(); // If implementing AutocompleteCanvasState
|
||||
# NEW - Option B: Suggestions via editor APIs
|
||||
editor.ui_state_mut().deactivate_suggestions();
|
||||
```
|
||||
|
||||
## Benefits of New Architecture
|
||||
@@ -217,8 +211,8 @@ let suggestions: Vec<SuggestionItem<UserRecord>> = vec![
|
||||
- **Display Overrides**: Show friendly text while storing normalized data
|
||||
|
||||
### 4. **Future-Proof**
|
||||
- Easy to add new autocomplete features
|
||||
- Canvas features don't interfere with autocomplete
|
||||
- Easy to add new suggestion features
|
||||
- Canvas features don't interfere with suggestions
|
||||
- Modular: Use only what you need
|
||||
|
||||
## Advanced Features
|
||||
@@ -262,7 +256,7 @@ SuggestionItem::new(user, "John Doe (Manager)".to_string(), "123".to_string());
|
||||
## Breaking Changes Summary
|
||||
|
||||
1. **Import paths changed**: Add `canvas::` or `dispatcher::` prefixes
|
||||
2. **Legacy suggestion methods removed**: Replace with rich autocomplete or custom methods
|
||||
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
|
||||
|
||||
@@ -283,11 +277,11 @@ SuggestionItem::new(user, "John Doe (Manager)".to_string(), "123".to_string());
|
||||
|
||||
- [ ] Updated all import paths
|
||||
- [ ] Removed legacy methods from CanvasState implementation
|
||||
- [ ] Added custom autocomplete methods if needed
|
||||
- [ ] Updated suggestion usage to SuggestionItem
|
||||
- [ ] Added custom suggestion methods if needed
|
||||
- [ ] Updated usage to SuggestionItem
|
||||
- [ ] Updated rendering calls
|
||||
- [ ] Tested form functionality
|
||||
- [ ] Tested autocomplete functionality (if using)
|
||||
- [ ] Tested suggestions functionality (if using)
|
||||
|
||||
## Example: Complete Migration
|
||||
|
||||
@@ -305,29 +299,25 @@ impl CanvasState for FormState {
|
||||
**After:**
|
||||
```rust
|
||||
use canvas::canvas::{CanvasState, CanvasAction};
|
||||
use canvas::autocomplete::{AutocompleteCanvasState, SuggestionItem};
|
||||
use canvas::SuggestionItem;
|
||||
|
||||
impl CanvasState for FormState {
|
||||
// Only core canvas methods, no suggestion methods
|
||||
// Only core canvas methods
|
||||
fn current_field(&self) -> usize { /* ... */ }
|
||||
fn get_current_input(&self) -> &str { /* ... */ }
|
||||
// ... other core methods only
|
||||
}
|
||||
|
||||
impl AutocompleteCanvasState for FormState {
|
||||
// Use FormEditor + SuggestionsProvider for suggestions dropdown
|
||||
type SuggestionData = Hit;
|
||||
|
||||
fn supports_autocomplete(&self, field_index: usize) -> bool {
|
||||
fn supports_suggestions(&self, field_index: usize) -> bool {
|
||||
self.fields[field_index].is_link
|
||||
}
|
||||
|
||||
fn autocomplete_state(&self) -> Option<&AutocompleteState<Self::SuggestionData>> {
|
||||
Some(&self.autocomplete)
|
||||
}
|
||||
// Maintain suggestion state through FormEditor and DataProvider
|
||||
|
||||
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> {
|
||||
Some(&mut self.autocomplete)
|
||||
}
|
||||
// Maintain suggestion state through FormEditor and DataProvider
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ crossterm = { workspace = true, optional = true }
|
||||
anyhow.workspace = true
|
||||
tokio = { workspace = true, optional = true }
|
||||
toml = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde.workspace = true
|
||||
unicode-width.workspace = true
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -30,21 +30,53 @@ tokio-test = "0.4.4"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
gui = ["ratatui"]
|
||||
autocomplete = ["tokio"]
|
||||
gui = ["ratatui", "crossterm"]
|
||||
suggestions = ["tokio"]
|
||||
cursor-style = ["crossterm"]
|
||||
validation = ["regex"]
|
||||
computed = []
|
||||
textarea = ["gui"]
|
||||
|
||||
[[example]]
|
||||
name = "autocomplete"
|
||||
required-features = ["autocomplete", "gui"]
|
||||
path = "examples/autocomplete.rs"
|
||||
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"
|
||||
name = "suggestions2"
|
||||
required-features = ["suggestions", "gui", "cursor-style"]
|
||||
path = "examples/suggestions2.rs"
|
||||
|
||||
[[example]]
|
||||
name = "canvas_cursor_auto"
|
||||
required-features = ["gui", "cursor-style"]
|
||||
path = "examples/canvas_cursor_auto.rs"
|
||||
|
||||
[[example]]
|
||||
name = "validation_1"
|
||||
required-features = ["gui", "validation"]
|
||||
required-features = ["gui", "validation", "cursor-style"]
|
||||
|
||||
[[example]]
|
||||
name = "validation_2"
|
||||
required-features = ["gui", "validation", "cursor-style"]
|
||||
|
||||
[[example]]
|
||||
name = "validation_3"
|
||||
required-features = ["gui", "validation", "cursor-style"]
|
||||
|
||||
[[example]]
|
||||
name = "validation_4"
|
||||
required-features = ["gui", "validation", "cursor-style"]
|
||||
|
||||
[[example]]
|
||||
name = "validation_5"
|
||||
required-features = ["gui", "validation", "cursor-style"]
|
||||
|
||||
[[example]]
|
||||
name = "computed_fields"
|
||||
required-features = ["gui", "computed"]
|
||||
|
||||
[[example]]
|
||||
name = "canvas_textarea_cursor_auto"
|
||||
required-features = ["gui", "cursor-style", "textarea"]
|
||||
path = "examples/canvas_textarea_cursor_auto.rs"
|
||||
|
||||
@@ -7,7 +7,7 @@ A reusable, type-safe canvas system for building form-based TUI applications wit
|
||||
- **Type-Safe Actions**: No more string-based action names - everything is compile-time checked
|
||||
- **Generic Design**: Implement `CanvasState` once, get navigation, editing, and suggestions for free
|
||||
- **Vim-Like Experience**: Modal editing with familiar keybindings
|
||||
- **Suggestion System**: Built-in autocomplete and suggestions support
|
||||
- **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
|
||||
@@ -144,7 +144,7 @@ pub enum CanvasAction {
|
||||
|
||||
## 🔧 Advanced Features
|
||||
|
||||
### Suggestions and Autocomplete
|
||||
### Suggestions Dropdown (not inline autocomplete)
|
||||
|
||||
```rust
|
||||
impl CanvasState for MyForm {
|
||||
@@ -170,7 +170,7 @@ impl CanvasState for MyForm {
|
||||
CanvasAction::SelectSuggestion => {
|
||||
if let Some(suggestion) = self.suggestions.get_selected() {
|
||||
*self.get_current_input_mut() = suggestion.clone();
|
||||
self.deactivate_autocomplete();
|
||||
self.deactivate_suggestions();
|
||||
Some("Applied suggestion".to_string())
|
||||
}
|
||||
None
|
||||
|
||||
@@ -1,392 +0,0 @@
|
||||
// examples/autocomplete.rs
|
||||
// Run with: cargo run --example autocomplete --features "autocomplete,gui"
|
||||
|
||||
use std::io;
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{
|
||||
backend::{Backend, CrosstermBackend},
|
||||
layout::{Constraint, Direction, Layout},
|
||||
style::Color,
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
use canvas::{
|
||||
canvas::{
|
||||
gui::render_canvas,
|
||||
modes::AppMode,
|
||||
theme::CanvasTheme,
|
||||
},
|
||||
autocomplete::gui::render_autocomplete_dropdown,
|
||||
FormEditor, DataProvider, AutocompleteProvider, SuggestionItem,
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use anyhow::Result;
|
||||
|
||||
// Simple theme implementation
|
||||
#[derive(Clone)]
|
||||
struct DemoTheme;
|
||||
|
||||
impl CanvasTheme for DemoTheme {
|
||||
fn bg(&self) -> Color { Color::Reset }
|
||||
fn fg(&self) -> Color { Color::White }
|
||||
fn accent(&self) -> Color { Color::Cyan }
|
||||
fn secondary(&self) -> Color { Color::Gray }
|
||||
fn highlight(&self) -> Color { Color::Yellow }
|
||||
fn highlight_bg(&self) -> Color { Color::DarkGray }
|
||||
fn warning(&self) -> Color { Color::Red }
|
||||
fn border(&self) -> Color { Color::Gray }
|
||||
}
|
||||
|
||||
// Custom suggestion data type
|
||||
#[derive(Clone, Debug)]
|
||||
struct EmailSuggestion {
|
||||
email: String,
|
||||
provider: String,
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// SIMPLE DATA PROVIDER - Only business data, no UI concerns!
|
||||
// ===================================================================
|
||||
|
||||
struct ContactForm {
|
||||
// Only business data - no UI state!
|
||||
name: String,
|
||||
email: String,
|
||||
phone: String,
|
||||
city: String,
|
||||
}
|
||||
|
||||
impl ContactForm {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
name: "John Doe".to_string(),
|
||||
email: "john@".to_string(), // Partial email for demo
|
||||
phone: "+1 234 567 8900".to_string(),
|
||||
city: "San Francisco".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simple trait implementation - only 4 methods!
|
||||
impl DataProvider for ContactForm {
|
||||
fn field_count(&self) -> usize { 4 }
|
||||
|
||||
fn field_name(&self, index: usize) -> &str {
|
||||
match index {
|
||||
0 => "Name",
|
||||
1 => "Email",
|
||||
2 => "Phone",
|
||||
3 => "City",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn field_value(&self, index: usize) -> &str {
|
||||
match index {
|
||||
0 => &self.name,
|
||||
1 => &self.email,
|
||||
2 => &self.phone,
|
||||
3 => &self.city,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn set_field_value(&mut self, index: usize, value: String) {
|
||||
match index {
|
||||
0 => self.name = value,
|
||||
1 => self.email = value,
|
||||
2 => self.phone = value,
|
||||
3 => self.city = value,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_autocomplete(&self, field_index: usize) -> bool {
|
||||
field_index == 1 // Only email field
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// SIMPLE AUTOCOMPLETE PROVIDER - Only data fetching!
|
||||
// ===================================================================
|
||||
|
||||
struct EmailAutocomplete;
|
||||
|
||||
#[async_trait]
|
||||
impl AutocompleteProvider for EmailAutocomplete {
|
||||
type SuggestionData = EmailSuggestion;
|
||||
|
||||
async fn fetch_suggestions(&mut self, _field_index: usize, query: &str)
|
||||
-> Result<Vec<SuggestionItem<Self::SuggestionData>>>
|
||||
{
|
||||
// Extract domain part from email
|
||||
let (email_prefix, domain_part) = if let Some(at_pos) = query.find('@') {
|
||||
(query[..at_pos].to_string(), query[at_pos + 1..].to_string())
|
||||
} else {
|
||||
return Ok(Vec::new()); // No @ symbol
|
||||
};
|
||||
|
||||
// Simulate async API call
|
||||
let suggestions = tokio::task::spawn_blocking(move || {
|
||||
// Simulate network delay
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
|
||||
// Mock email suggestions
|
||||
let popular_domains = vec![
|
||||
("gmail.com", "Gmail"),
|
||||
("yahoo.com", "Yahoo Mail"),
|
||||
("outlook.com", "Outlook"),
|
||||
("hotmail.com", "Hotmail"),
|
||||
("company.com", "Company Email"),
|
||||
("university.edu", "University"),
|
||||
];
|
||||
|
||||
let mut results = Vec::new();
|
||||
for (domain, provider) in popular_domains {
|
||||
if domain.starts_with(&domain_part) || domain_part.is_empty() {
|
||||
let full_email = format!("{}@{}", email_prefix, domain);
|
||||
results.push(SuggestionItem {
|
||||
data: EmailSuggestion {
|
||||
email: full_email.clone(),
|
||||
provider: provider.to_string(),
|
||||
},
|
||||
display_text: format!("{} ({})", full_email, provider),
|
||||
value_to_store: full_email,
|
||||
});
|
||||
}
|
||||
}
|
||||
results
|
||||
}).await.unwrap_or_default();
|
||||
|
||||
Ok(suggestions)
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// APPLICATION STATE - Much simpler!
|
||||
// ===================================================================
|
||||
|
||||
struct AppState {
|
||||
editor: FormEditor<ContactForm>,
|
||||
autocomplete: EmailAutocomplete,
|
||||
debug_message: String,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn new() -> Self {
|
||||
let contact_form = ContactForm::new();
|
||||
let mut editor = FormEditor::new(contact_form);
|
||||
|
||||
// Start on email field (index 1) at end of existing text
|
||||
editor.set_mode(AppMode::Edit);
|
||||
// TODO: Add method to set initial field/cursor position
|
||||
|
||||
Self {
|
||||
editor,
|
||||
autocomplete: EmailAutocomplete,
|
||||
debug_message: "Type in email field, Tab to trigger autocomplete, Enter to select, Esc to cancel".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// INPUT HANDLING - Much cleaner!
|
||||
// ===================================================================
|
||||
|
||||
async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut AppState) -> bool {
|
||||
if key == KeyCode::F(10) || (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) {
|
||||
return false; // Quit
|
||||
}
|
||||
|
||||
// Handle input based on key
|
||||
let result = match key {
|
||||
// === AUTOCOMPLETE KEYS ===
|
||||
KeyCode::Tab => {
|
||||
if state.editor.is_autocomplete_active() {
|
||||
state.editor.autocomplete_next();
|
||||
Ok("Navigated to next suggestion".to_string())
|
||||
} else if state.editor.data_provider().supports_autocomplete(state.editor.current_field()) {
|
||||
state.editor.trigger_autocomplete(&mut state.autocomplete).await
|
||||
.map(|_| "Triggered autocomplete".to_string())
|
||||
} else {
|
||||
state.editor.move_to_next_field();
|
||||
Ok("Moved to next field".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
KeyCode::Enter => {
|
||||
if state.editor.is_autocomplete_active() {
|
||||
if let Some(applied) = state.editor.apply_autocomplete() {
|
||||
Ok(format!("Applied: {}", applied))
|
||||
} else {
|
||||
Ok("No suggestion to apply".to_string())
|
||||
}
|
||||
} else {
|
||||
state.editor.move_to_next_field();
|
||||
Ok("Moved to next field".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
KeyCode::Esc => {
|
||||
if state.editor.is_autocomplete_active() {
|
||||
// Autocomplete will be cleared automatically by mode change
|
||||
Ok("Cancelled autocomplete".to_string())
|
||||
} else {
|
||||
// Toggle between edit and readonly mode
|
||||
let new_mode = match state.editor.mode() {
|
||||
AppMode::Edit => AppMode::ReadOnly,
|
||||
_ => AppMode::Edit,
|
||||
};
|
||||
state.editor.set_mode(new_mode);
|
||||
Ok(format!("Switched to {:?} mode", new_mode))
|
||||
}
|
||||
}
|
||||
|
||||
// === MOVEMENT KEYS ===
|
||||
KeyCode::Left => {
|
||||
state.editor.move_left();
|
||||
Ok("Moved left".to_string())
|
||||
}
|
||||
KeyCode::Right => {
|
||||
state.editor.move_right();
|
||||
Ok("Moved right".to_string())
|
||||
}
|
||||
KeyCode::Up => {
|
||||
state.editor.move_to_next_field(); // TODO: Add move_up method
|
||||
Ok("Moved up".to_string())
|
||||
}
|
||||
KeyCode::Down => {
|
||||
state.editor.move_to_next_field(); // TODO: Add move_down method
|
||||
Ok("Moved down".to_string())
|
||||
}
|
||||
|
||||
// === TEXT INPUT ===
|
||||
KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
state.editor.insert_char(c)
|
||||
.map(|_| format!("Inserted '{}'", c))
|
||||
}
|
||||
|
||||
KeyCode::Backspace => {
|
||||
// TODO: Add delete_backward method to FormEditor
|
||||
Ok("Backspace (not implemented yet)".to_string())
|
||||
}
|
||||
|
||||
_ => Ok(format!("Unhandled key: {:?}", key)),
|
||||
};
|
||||
|
||||
// Update debug message
|
||||
match result {
|
||||
Ok(msg) => state.debug_message = msg,
|
||||
Err(e) => state.debug_message = format!("Error: {}", e),
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut state: AppState) -> io::Result<()> {
|
||||
let theme = DemoTheme;
|
||||
|
||||
loop {
|
||||
terminal.draw(|f| ui(f, &state, &theme))?;
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
let should_continue = handle_key_press(key.code, key.modifiers, &mut state).await;
|
||||
if !should_continue {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ui(f: &mut Frame, state: &AppState, theme: &DemoTheme) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(8),
|
||||
Constraint::Length(5),
|
||||
])
|
||||
.split(f.area());
|
||||
|
||||
// Render the canvas form - much simpler!
|
||||
let active_field_rect = render_canvas(
|
||||
f,
|
||||
chunks[0],
|
||||
&state.editor,
|
||||
theme,
|
||||
);
|
||||
|
||||
// Render autocomplete dropdown if active
|
||||
if let Some(input_rect) = active_field_rect {
|
||||
render_autocomplete_dropdown(
|
||||
f,
|
||||
chunks[0],
|
||||
input_rect,
|
||||
theme,
|
||||
&state.editor,
|
||||
);
|
||||
}
|
||||
|
||||
// Status info
|
||||
let autocomplete_status = if state.editor.is_autocomplete_active() {
|
||||
if state.editor.ui_state().is_autocomplete_loading() {
|
||||
"Loading suggestions..."
|
||||
} else if !state.editor.suggestions().is_empty() {
|
||||
"Use Tab to navigate, Enter to select, Esc to cancel"
|
||||
} else {
|
||||
"No suggestions found"
|
||||
}
|
||||
} else {
|
||||
"Tab to trigger autocomplete"
|
||||
};
|
||||
|
||||
let status_lines = vec![
|
||||
Line::from(Span::raw(format!("Mode: {:?} | Field: {}/{} | Cursor: {}",
|
||||
state.editor.mode(),
|
||||
state.editor.current_field() + 1,
|
||||
state.editor.data_provider().field_count(),
|
||||
state.editor.cursor_position()))),
|
||||
Line::from(Span::raw(format!("Autocomplete: {}", autocomplete_status))),
|
||||
Line::from(Span::raw(state.debug_message.clone())),
|
||||
Line::from(Span::raw("F10: Quit | Tab: Trigger/Navigate autocomplete | Enter: Select | Esc: Cancel/Toggle mode")),
|
||||
];
|
||||
|
||||
let status = Paragraph::new(status_lines)
|
||||
.block(Block::default().borders(Borders::ALL).title("Status & Help"));
|
||||
|
||||
f.render_widget(status, chunks[1]);
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let state = AppState::new();
|
||||
let res = run_app(&mut terminal, state).await;
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(err) = res {
|
||||
println!("{:?}", err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -275,7 +275,8 @@ impl<D: DataProvider> AutoCursorFormEditor<D> {
|
||||
}
|
||||
|
||||
fn current_text(&self) -> &str {
|
||||
self.editor.current_text()
|
||||
let field_index = self.editor.current_field();
|
||||
self.editor.data_provider().field_value(field_index)
|
||||
}
|
||||
|
||||
fn data_provider(&self) -> &D {
|
||||
@@ -306,6 +307,42 @@ impl<D: DataProvider> AutoCursorFormEditor<D> {
|
||||
fn has_unsaved_changes(&self) -> bool {
|
||||
self.has_unsaved_changes
|
||||
}
|
||||
|
||||
fn open_line_below(&mut self) -> anyhow::Result<()> {
|
||||
let result = self.editor.open_line_below();
|
||||
if result.is_ok() {
|
||||
self.debug_message = "✏️ INSERT (open line below) - Cursor: Steady Bar |".to_string();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn open_line_above(&mut self) -> anyhow::Result<()> {
|
||||
let result = self.editor.open_line_above();
|
||||
if result.is_ok() {
|
||||
self.debug_message = "✏️ INSERT (open line above) - Cursor: Steady Bar |".to_string();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn move_big_word_next(&mut self) {
|
||||
self.editor.move_big_word_next();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_big_word_prev(&mut self) {
|
||||
self.editor.move_big_word_prev();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_big_word_end(&mut self) {
|
||||
self.editor.move_big_word_end();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_big_word_end_prev(&mut self) {
|
||||
self.editor.move_big_word_end_prev();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
}
|
||||
|
||||
// Demo form data with interesting text for cursor demonstration
|
||||
@@ -346,7 +383,7 @@ impl DataProvider for CursorDemoData {
|
||||
self.fields[index].1 = value;
|
||||
}
|
||||
|
||||
fn supports_autocomplete(&self, _field_index: usize) -> bool {
|
||||
fn supports_suggestions(&self, _field_index: usize) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -389,10 +426,17 @@ fn handle_key_press(
|
||||
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());
|
||||
if let Err(e) = editor.open_line_below() {
|
||||
editor.set_debug_message(format!("Error opening line below: {}", e));
|
||||
}
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('O'), _) => {
|
||||
if let Err(e) = editor.open_line_above() {
|
||||
editor.set_debug_message(format!("Error opening line above: {}", e));
|
||||
}
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
@@ -507,10 +551,40 @@ fn handle_key_press(
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('e'), _) => {
|
||||
editor.move_word_end();
|
||||
editor.set_debug_message("e: word end".to_string());
|
||||
// Check if this is 'ge' command
|
||||
if editor.get_command_buffer() == "g" {
|
||||
editor.move_word_end_prev();
|
||||
editor.set_debug_message("ge: previous word end".to_string());
|
||||
editor.clear_command_buffer();
|
||||
} else {
|
||||
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_big_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_big_word_prev();
|
||||
editor.set_debug_message("B: previous WORD start".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('E'), _) => {
|
||||
// Check if this is 'gE' command
|
||||
if editor.get_command_buffer() == "g" {
|
||||
editor.move_big_word_end_prev();
|
||||
editor.set_debug_message("gE: previous WORD end".to_string());
|
||||
editor.clear_command_buffer();
|
||||
} else {
|
||||
editor.move_big_word_end();
|
||||
editor.set_debug_message("E: WORD end".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
}
|
||||
|
||||
// Line movement
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('0'), _)
|
||||
@@ -721,9 +795,9 @@ fn render_status_and_help(
|
||||
}
|
||||
} 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"
|
||||
Normal: hjkl/arrows=move, w/b/e=words, W/B/E=WORDS, 0/$=line, gg/G=first/last\n\
|
||||
i/a/A/o/O=insert, v/V=visual, x/X=delete, ?=info\n\
|
||||
F1=demo manual cursor, F2=restore automatic"
|
||||
}
|
||||
}
|
||||
AppMode::Edit => {
|
||||
|
||||
652
canvas/examples/canvas_textarea_cursor_auto.rs
Normal file
652
canvas/examples/canvas_textarea_cursor_auto.rs
Normal file
@@ -0,0 +1,652 @@
|
||||
// examples/canvas_textarea_cursor_auto.rs
|
||||
//! Demonstrates automatic cursor management with the textarea widget
|
||||
//!
|
||||
//! This example REQUIRES the `cursor-style` and `textarea` features to compile.
|
||||
//!
|
||||
//! Run with:
|
||||
//! cargo run --example canvas_textarea_cursor_auto --features "gui,cursor-style,textarea"
|
||||
|
||||
// REQUIRE cursor-style and textarea features
|
||||
#[cfg(not(feature = "cursor-style"))]
|
||||
compile_error!(
|
||||
"This example requires the 'cursor-style' feature. \
|
||||
Run with: cargo run --example canvas_textarea_cursor_auto --features \"gui,cursor-style,textarea\""
|
||||
);
|
||||
|
||||
#[cfg(not(feature = "textarea"))]
|
||||
compile_error!(
|
||||
"This example requires the 'textarea' feature. \
|
||||
Run with: cargo run --example canvas_textarea_cursor_auto --features \"gui,cursor-style,textarea\""
|
||||
);
|
||||
|
||||
use std::io;
|
||||
use crossterm::{
|
||||
event::{
|
||||
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, 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::{
|
||||
modes::AppMode,
|
||||
CursorManager, // This import only exists when cursor-style feature is enabled
|
||||
},
|
||||
textarea::{TextArea, TextAreaState},
|
||||
};
|
||||
|
||||
/// Enhanced TextArea that demonstrates automatic cursor management
|
||||
/// Now uses direct FormEditor method calls via Deref!
|
||||
struct AutoCursorTextArea {
|
||||
textarea: TextAreaState,
|
||||
has_unsaved_changes: bool,
|
||||
debug_message: String,
|
||||
command_buffer: String,
|
||||
}
|
||||
|
||||
impl AutoCursorTextArea {
|
||||
fn new() -> Self {
|
||||
let initial_text = "🎯 Automatic Cursor Management Demo\n\
|
||||
Welcome to the textarea cursor demo!\n\
|
||||
\n\
|
||||
Try different modes:\n\
|
||||
• Normal mode: Block cursor █\n\
|
||||
• Insert mode: Bar cursor |\n\
|
||||
\n\
|
||||
Navigation commands:\n\
|
||||
• hjkl or arrow keys: move cursor\n\
|
||||
• i/a/A/o/O: enter insert mode\n\
|
||||
• w/b/e/W/B/E: word movements\n\
|
||||
• Esc: return to normal mode\n\
|
||||
\n\
|
||||
Watch how the terminal cursor changes automatically!\n\
|
||||
This text can be edited when in insert mode.\n\
|
||||
\n\
|
||||
Press ? for help, F1/F2 for manual cursor control demo.";
|
||||
|
||||
let mut textarea = TextAreaState::from_text(initial_text);
|
||||
textarea.set_placeholder("Start typing...");
|
||||
|
||||
Self {
|
||||
textarea,
|
||||
has_unsaved_changes: false,
|
||||
debug_message: "🎯 Automatic Cursor Demo - cursor-style feature enabled!".to_string(),
|
||||
command_buffer: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// === MODE TRANSITIONS WITH AUTOMATIC CURSOR MANAGEMENT ===
|
||||
|
||||
fn enter_insert_mode(&mut self) -> std::io::Result<()> {
|
||||
self.textarea.enter_edit_mode(); // 🎯 Direct FormEditor method call via Deref!
|
||||
CursorManager::update_for_mode(AppMode::Edit)?; // 🎯 Automatic: cursor becomes bar |
|
||||
self.debug_message = "✏️ INSERT MODE - Cursor: Steady Bar |".to_string();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn enter_append_mode(&mut self) -> std::io::Result<()> {
|
||||
self.textarea.enter_append_mode(); // 🎯 Direct FormEditor method call!
|
||||
CursorManager::update_for_mode(AppMode::Edit)?;
|
||||
self.debug_message = "✏️ INSERT (append) - Cursor: Steady Bar |".to_string();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn exit_to_normal_mode(&mut self) -> std::io::Result<()> {
|
||||
self.textarea.exit_edit_mode(); // 🎯 Direct FormEditor method call!
|
||||
CursorManager::update_for_mode(AppMode::ReadOnly)?; // 🎯 Automatic: cursor becomes steady block
|
||||
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █".to_string();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// === MANUAL CURSOR OVERRIDE DEMONSTRATION ===
|
||||
|
||||
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.textarea.mode())?; // 🎯 Direct method call!
|
||||
self.debug_message = "🎯 Restored automatic cursor management".to_string();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// === TEXTAREA OPERATIONS ===
|
||||
|
||||
fn handle_textarea_input(&mut self, key: KeyEvent) {
|
||||
self.textarea.input(key);
|
||||
self.has_unsaved_changes = true;
|
||||
}
|
||||
|
||||
// === MOVEMENT OPERATIONS (using direct FormEditor methods!) ===
|
||||
|
||||
fn move_left(&mut self) {
|
||||
self.textarea.move_left(); // 🎯 Direct FormEditor method call!
|
||||
self.update_debug_for_movement("← left");
|
||||
}
|
||||
|
||||
fn move_right(&mut self) {
|
||||
self.textarea.move_right(); // 🎯 Direct FormEditor method call!
|
||||
self.update_debug_for_movement("→ right");
|
||||
}
|
||||
|
||||
fn move_up(&mut self) {
|
||||
self.textarea.move_up(); // 🎯 Direct FormEditor method call!
|
||||
self.update_debug_for_movement("↑ up");
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
self.textarea.move_down(); // 🎯 Direct FormEditor method call!
|
||||
self.update_debug_for_movement("↓ down");
|
||||
}
|
||||
|
||||
fn move_word_next(&mut self) {
|
||||
self.textarea.move_word_next(); // 🎯 Direct FormEditor method call!
|
||||
self.update_debug_for_movement("w: next word");
|
||||
}
|
||||
|
||||
fn move_word_prev(&mut self) {
|
||||
self.textarea.move_word_prev(); // 🎯 Direct FormEditor method call!
|
||||
self.update_debug_for_movement("b: previous word");
|
||||
}
|
||||
|
||||
fn move_word_end(&mut self) {
|
||||
self.textarea.move_word_end(); // 🎯 Direct FormEditor method call!
|
||||
self.update_debug_for_movement("e: word end");
|
||||
}
|
||||
|
||||
fn move_word_end_prev(&mut self) {
|
||||
self.textarea.move_word_end_prev(); // 🎯 Direct FormEditor method call!
|
||||
self.update_debug_for_movement("ge: previous word end");
|
||||
}
|
||||
|
||||
fn move_line_start(&mut self) {
|
||||
self.textarea.move_line_start(); // 🎯 Direct FormEditor method call!
|
||||
self.update_debug_for_movement("0: line start");
|
||||
}
|
||||
|
||||
fn move_line_end(&mut self) {
|
||||
self.textarea.move_line_end(); // 🎯 Direct FormEditor method call!
|
||||
self.update_debug_for_movement("$: line end");
|
||||
}
|
||||
|
||||
fn move_first_line(&mut self) {
|
||||
self.textarea.move_first_line(); // 🎯 Direct FormEditor method call!
|
||||
self.update_debug_for_movement("gg: first line");
|
||||
}
|
||||
|
||||
fn move_last_line(&mut self) {
|
||||
self.textarea.move_last_line(); // 🎯 Direct FormEditor method call!
|
||||
self.update_debug_for_movement("G: last line");
|
||||
}
|
||||
|
||||
// === BIG WORD MOVEMENTS ===
|
||||
|
||||
fn move_big_word_next(&mut self) {
|
||||
self.textarea.move_big_word_next(); // 🎯 Direct FormEditor method call!
|
||||
self.update_debug_for_movement("W: next WORD");
|
||||
}
|
||||
|
||||
fn move_big_word_prev(&mut self) {
|
||||
self.textarea.move_big_word_prev(); // 🎯 Direct FormEditor method call!
|
||||
self.update_debug_for_movement("B: previous WORD");
|
||||
}
|
||||
|
||||
fn move_big_word_end(&mut self) {
|
||||
self.textarea.move_big_word_end(); // 🎯 Direct FormEditor method call!
|
||||
self.update_debug_for_movement("E: WORD end");
|
||||
}
|
||||
|
||||
fn move_big_word_end_prev(&mut self) {
|
||||
self.textarea.move_big_word_end_prev(); // 🎯 Direct FormEditor method call!
|
||||
self.update_debug_for_movement("gE: previous WORD end");
|
||||
}
|
||||
|
||||
fn update_debug_for_movement(&mut self, action: &str) {
|
||||
self.debug_message = action.to_string();
|
||||
}
|
||||
|
||||
// === DELETE OPERATIONS ===
|
||||
|
||||
fn delete_char_forward(&mut self) {
|
||||
if let Ok(_) = self.textarea.delete_forward() { // 🎯 Direct FormEditor method call!
|
||||
self.has_unsaved_changes = true;
|
||||
self.debug_message = "x: deleted character".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_char_backward(&mut self) {
|
||||
if let Ok(_) = self.textarea.delete_backward() { // 🎯 Direct FormEditor method call!
|
||||
self.has_unsaved_changes = true;
|
||||
self.debug_message = "X: deleted character backward".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// === VIM-STYLE EDITING ===
|
||||
|
||||
fn open_line_below(&mut self) -> anyhow::Result<()> {
|
||||
let result = self.textarea.open_line_below(); // 🎯 Textarea-specific override!
|
||||
if result.is_ok() {
|
||||
CursorManager::update_for_mode(AppMode::Edit)?;
|
||||
self.debug_message = "✏️ INSERT (open line below) - Cursor: Steady Bar |".to_string();
|
||||
self.has_unsaved_changes = true;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn open_line_above(&mut self) -> anyhow::Result<()> {
|
||||
let result = self.textarea.open_line_above(); // 🎯 Textarea-specific override!
|
||||
if result.is_ok() {
|
||||
CursorManager::update_for_mode(AppMode::Edit)?;
|
||||
self.debug_message = "✏️ INSERT (open line above) - Cursor: Steady Bar |".to_string();
|
||||
self.has_unsaved_changes = true;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// === 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()
|
||||
}
|
||||
|
||||
// === GETTERS ===
|
||||
|
||||
fn mode(&self) -> AppMode {
|
||||
self.textarea.mode() // 🎯 Direct FormEditor method call!
|
||||
}
|
||||
|
||||
fn debug_message(&self) -> &str {
|
||||
&self.debug_message
|
||||
}
|
||||
|
||||
fn has_unsaved_changes(&self) -> bool {
|
||||
self.has_unsaved_changes
|
||||
}
|
||||
|
||||
fn set_debug_message(&mut self, msg: String) {
|
||||
self.debug_message = msg;
|
||||
}
|
||||
|
||||
fn get_cursor_info(&self) -> String {
|
||||
format!(
|
||||
"Line {}, Col {}",
|
||||
self.textarea.current_field() + 1, // 🎯 Direct FormEditor method call!
|
||||
self.textarea.cursor_position() + 1 // 🎯 Direct FormEditor method call!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle key press with automatic cursor management
|
||||
fn handle_key_press(
|
||||
key_event: KeyEvent,
|
||||
editor: &mut AutoCursorTextArea,
|
||||
) -> anyhow::Result<bool> {
|
||||
let KeyEvent { code: key, modifiers, .. } = key_event;
|
||||
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_insert_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_insert_mode()?;
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// Vim o/O commands
|
||||
(AppMode::ReadOnly, KeyCode::Char('o'), _) => {
|
||||
if let Err(e) = editor.open_line_below() {
|
||||
editor.set_debug_message(format!("Error opening line below: {}", e));
|
||||
}
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('O'), _) => {
|
||||
if let Err(e) = editor.open_line_above() {
|
||||
editor.set_debug_message(format!("Error opening line above: {}", e));
|
||||
}
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// Escape: Exit any mode back to normal
|
||||
(AppMode::Edit, KeyCode::Esc, _) => {
|
||||
editor.exit_to_normal_mode()?;
|
||||
}
|
||||
|
||||
// === INSERT MODE: Pass to textarea ===
|
||||
(AppMode::Edit, _, _) => {
|
||||
editor.handle_textarea_input(key_event);
|
||||
}
|
||||
|
||||
// === 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 (Normal 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();
|
||||
}
|
||||
|
||||
// Word movement
|
||||
(AppMode::ReadOnly, KeyCode::Char('w'), _) => {
|
||||
editor.move_word_next();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('b'), _) => {
|
||||
editor.move_word_prev();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('e'), _) => {
|
||||
if editor.get_command_buffer() == "g" {
|
||||
editor.move_word_end_prev();
|
||||
editor.clear_command_buffer();
|
||||
} else {
|
||||
editor.move_word_end();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
}
|
||||
|
||||
// Big word movement (vim W/B/E commands)
|
||||
(AppMode::ReadOnly, KeyCode::Char('W'), _) => {
|
||||
editor.move_big_word_next();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('B'), _) => {
|
||||
editor.move_big_word_prev();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('E'), _) => {
|
||||
if editor.get_command_buffer() == "g" {
|
||||
editor.move_big_word_end_prev();
|
||||
editor.clear_command_buffer();
|
||||
} else {
|
||||
editor.move_big_word_end();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
}
|
||||
|
||||
// Line movement
|
||||
(AppMode::ReadOnly, KeyCode::Char('0'), _)
|
||||
| (AppMode::ReadOnly, KeyCode::Home, _) => {
|
||||
editor.move_line_start();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('$'), _)
|
||||
| (AppMode::ReadOnly, KeyCode::End, _) => {
|
||||
editor.move_line_end();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// Document movement with command buffer
|
||||
(AppMode::ReadOnly, KeyCode::Char('g'), _) => {
|
||||
if editor.get_command_buffer() == "g" {
|
||||
editor.move_first_line();
|
||||
editor.clear_command_buffer();
|
||||
} else {
|
||||
editor.clear_command_buffer();
|
||||
editor.add_to_command_buffer('g');
|
||||
editor.set_debug_message("g".to_string());
|
||||
}
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('G'), _) => {
|
||||
editor.move_last_line();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// === DELETE OPERATIONS (Normal mode) ===
|
||||
(AppMode::ReadOnly, KeyCode::Char('x'), _) => {
|
||||
editor.delete_char_forward();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('X'), _) => {
|
||||
editor.delete_char_backward();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// === DEBUG/INFO COMMANDS ===
|
||||
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
|
||||
editor.set_debug_message(format!(
|
||||
"{}, Mode: {:?} - Cursor managed automatically!",
|
||||
editor.get_cursor_info(),
|
||||
mode
|
||||
));
|
||||
editor.clear_command_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, mode
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn run_app<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
mut editor: AutoCursorTextArea,
|
||||
) -> io::Result<()> {
|
||||
loop {
|
||||
terminal.draw(|f| ui(f, &mut editor))?;
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
match handle_key_press(key, &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: &mut AutoCursorTextArea) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(8), Constraint::Length(8)])
|
||||
.split(f.area());
|
||||
|
||||
render_textarea(f, chunks[0], editor);
|
||||
render_status_and_help(f, chunks[1], editor);
|
||||
}
|
||||
|
||||
fn render_textarea(
|
||||
f: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
editor: &mut AutoCursorTextArea,
|
||||
) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("🎯 Textarea with Automatic Cursor Management");
|
||||
|
||||
let textarea_widget = TextArea::default().block(block.clone());
|
||||
|
||||
f.render_stateful_widget(textarea_widget, area, &mut editor.textarea);
|
||||
|
||||
// Set cursor position for terminal cursor
|
||||
// Always show cursor - CursorManager handles the style (block/bar/blinking)
|
||||
let (cx, cy) = editor.textarea.cursor(area, Some(&block));
|
||||
f.set_cursor_position((cx, cy));
|
||||
}
|
||||
|
||||
fn render_status_and_help(
|
||||
f: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
editor: &AutoCursorTextArea,
|
||||
) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Length(5)])
|
||||
.split(area);
|
||||
|
||||
// Status bar with cursor information
|
||||
let mode_text = match editor.mode() {
|
||||
AppMode::Edit => "INSERT | (bar cursor)",
|
||||
AppMode::ReadOnly => "NORMAL █ (block cursor)",
|
||||
AppMode::Highlight => "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(), editor.get_cursor_info())
|
||||
} else {
|
||||
format!("-- {} -- {} | {}", mode_text, editor.debug_message(), editor.get_cursor_info())
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
// 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 line, or any other key to cancel",
|
||||
_ => "Pending command... (Esc to cancel)"
|
||||
}
|
||||
} else {
|
||||
"🎯 CURSOR-STYLE DEMO: Normal █ | Insert | \n\
|
||||
Normal: hjkl/arrows=move, w/b/e=words, W/B/E=WORDS, 0/$=line, g/G=first/last\n\
|
||||
i/a/A/o/O=insert, x/X=delete, ?=info\n\
|
||||
F1=demo manual cursor, F2=restore automatic, Ctrl+Q=quit"
|
||||
}
|
||||
}
|
||||
AppMode::Edit => {
|
||||
"🎯 INSERT MODE - Cursor: | (bar)\n\
|
||||
Type to edit text, arrows=move, Enter=new line\n\
|
||||
Esc=normal mode"
|
||||
}
|
||||
AppMode::Highlight => {
|
||||
"🎯 VISUAL MODE - Cursor: █ (blinking block)\n\
|
||||
hjkl/arrows=extend selection\n\
|
||||
Esc=normal mode"
|
||||
}
|
||||
_ => "🎯 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 Textarea Cursor Auto Demo");
|
||||
println!("✅ cursor-style feature: ENABLED");
|
||||
println!("✅ textarea 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 mut editor = AutoCursorTextArea::new();
|
||||
|
||||
// Initialize with normal mode - library automatically sets block cursor
|
||||
editor.exit_to_normal_mode()?;
|
||||
|
||||
let res = run_app(&mut terminal, editor);
|
||||
|
||||
// Reset cursor on exit
|
||||
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(())
|
||||
}
|
||||
623
canvas/examples/computed_fields.rs
Normal file
623
canvas/examples/computed_fields.rs
Normal file
@@ -0,0 +1,623 @@
|
||||
// examples/computed_fields.rs - COMPLETE WORKING VERSION
|
||||
//! Demonstrates computed fields with the canvas library - Invoice Calculator Example
|
||||
//!
|
||||
//! This example REQUIRES the `computed` feature to compile.
|
||||
//!
|
||||
//! Run with:
|
||||
//! cargo run --example computed_fields --features "gui,computed"
|
||||
|
||||
#[cfg(not(feature = "computed"))]
|
||||
compile_error!(
|
||||
"This example requires the 'computed' feature. \
|
||||
Run with: cargo run --example computed_fields --features \"gui,computed\""
|
||||
);
|
||||
|
||||
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, Modifier},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
use canvas::{
|
||||
canvas::{gui::render_canvas_default, modes::AppMode},
|
||||
DataProvider, FormEditor,
|
||||
computed::{ComputedProvider, ComputedContext},
|
||||
};
|
||||
|
||||
/// Invoice data with computed fields
|
||||
struct InvoiceData {
|
||||
fields: Vec<(String, String)>,
|
||||
computed_indices: std::collections::HashSet<usize>,
|
||||
}
|
||||
|
||||
impl InvoiceData {
|
||||
fn new() -> Self {
|
||||
let mut computed_indices = std::collections::HashSet::new();
|
||||
|
||||
// Mark computed fields (read-only, calculated)
|
||||
computed_indices.insert(4); // Subtotal
|
||||
computed_indices.insert(5); // Tax Amount
|
||||
computed_indices.insert(6); // Total
|
||||
|
||||
Self {
|
||||
fields: vec![
|
||||
("📦 Product Name".to_string(), "".to_string()),
|
||||
("🔢 Quantity".to_string(), "".to_string()),
|
||||
("💰 Unit Price ($)".to_string(), "".to_string()),
|
||||
("📊 Tax Rate (%)".to_string(), "".to_string()),
|
||||
("➕ Subtotal ($)".to_string(), "".to_string()), // COMPUTED
|
||||
("🧾 Tax Amount ($)".to_string(), "".to_string()), // COMPUTED
|
||||
("💳 Total ($)".to_string(), "".to_string()), // COMPUTED
|
||||
("📝 Notes".to_string(), "".to_string()),
|
||||
],
|
||||
computed_indices,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DataProvider for InvoiceData {
|
||||
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) {
|
||||
// 🔥 FIXED: Allow computed fields to be updated for display purposes
|
||||
// The editing protection happens at the editor level, not here
|
||||
self.fields[index].1 = value;
|
||||
}
|
||||
|
||||
fn supports_suggestions(&self, _field_index: usize) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn display_value(&self, _index: usize) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Mark computed fields
|
||||
fn is_computed_field(&self, field_index: usize) -> bool {
|
||||
self.computed_indices.contains(&field_index)
|
||||
}
|
||||
|
||||
/// Get computed field values
|
||||
fn computed_field_value(&self, field_index: usize) -> Option<String> {
|
||||
if self.computed_indices.contains(&field_index) {
|
||||
Some(self.fields[field_index].1.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Invoice calculator - computes totals based on input fields
|
||||
struct InvoiceCalculator;
|
||||
|
||||
impl ComputedProvider for InvoiceCalculator {
|
||||
fn compute_field(&mut self, context: ComputedContext) -> String {
|
||||
// Helper to parse field values safely
|
||||
let parse_field = |index: usize| -> f64 {
|
||||
let value = context.field_values[index].trim();
|
||||
if value.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
value.parse().unwrap_or(0.0)
|
||||
}
|
||||
};
|
||||
|
||||
match context.target_field {
|
||||
4 => {
|
||||
// Subtotal = Quantity × Unit Price
|
||||
let qty = parse_field(1);
|
||||
let price = parse_field(2);
|
||||
let subtotal = qty * price;
|
||||
|
||||
if qty == 0.0 || price == 0.0 {
|
||||
"".to_string() // Show empty if no meaningful calculation
|
||||
} else {
|
||||
format!("{:.2}", subtotal)
|
||||
}
|
||||
}
|
||||
5 => {
|
||||
// Tax Amount = Subtotal × (Tax Rate / 100)
|
||||
let qty = parse_field(1);
|
||||
let price = parse_field(2);
|
||||
let tax_rate = parse_field(3);
|
||||
let subtotal = qty * price;
|
||||
let tax_amount = subtotal * (tax_rate / 100.0);
|
||||
|
||||
if subtotal == 0.0 || tax_rate == 0.0 {
|
||||
"".to_string()
|
||||
} else {
|
||||
format!("{:.2}", tax_amount)
|
||||
}
|
||||
}
|
||||
6 => {
|
||||
// Total = Subtotal + Tax Amount
|
||||
let qty = parse_field(1);
|
||||
let price = parse_field(2);
|
||||
let tax_rate = parse_field(3);
|
||||
let subtotal = qty * price;
|
||||
|
||||
if subtotal == 0.0 {
|
||||
"".to_string()
|
||||
} else {
|
||||
let tax_amount = subtotal * (tax_rate / 100.0);
|
||||
let total = subtotal + tax_amount;
|
||||
format!("{:.2}", total)
|
||||
}
|
||||
}
|
||||
_ => "".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn handles_field(&self, field_index: usize) -> bool {
|
||||
matches!(field_index, 4 | 5 | 6) // Subtotal, Tax Amount, Total
|
||||
}
|
||||
|
||||
fn field_dependencies(&self, field_index: usize) -> Vec<usize> {
|
||||
match field_index {
|
||||
4 => vec![1, 2], // Subtotal depends on Quantity, Unit Price
|
||||
5 => vec![1, 2, 3], // Tax Amount depends on Quantity, Unit Price, Tax Rate
|
||||
6 => vec![1, 2, 3], // Total depends on Quantity, Unit Price, Tax Rate
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enhanced editor with computed fields
|
||||
struct ComputedFieldsEditor<D: DataProvider> {
|
||||
editor: FormEditor<D>,
|
||||
calculator: InvoiceCalculator,
|
||||
debug_message: String,
|
||||
last_computed_values: Vec<String>,
|
||||
}
|
||||
|
||||
impl<D: DataProvider> ComputedFieldsEditor<D> {
|
||||
fn new(data_provider: D) -> Self {
|
||||
let mut editor = FormEditor::new(data_provider);
|
||||
editor.set_computed_provider(InvoiceCalculator);
|
||||
|
||||
let calculator = InvoiceCalculator;
|
||||
let last_computed_values = vec!["".to_string(); 8];
|
||||
|
||||
Self {
|
||||
editor,
|
||||
calculator,
|
||||
debug_message: "💰 Invoice Calculator - Start typing in fields to see calculations!".to_string(),
|
||||
last_computed_values,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_computed_field(&self, field_index: usize) -> bool {
|
||||
self.editor.ui_state().is_computed_field(field_index)
|
||||
}
|
||||
|
||||
fn update_computed_fields(&mut self) {
|
||||
// Trigger recomputation of all computed fields
|
||||
self.editor.recompute_all_fields(&mut self.calculator);
|
||||
|
||||
// 🔥 CRITICAL FIX: Sync computed values to DataProvider so GUI shows them!
|
||||
for i in [4, 5, 6] { // Computed field indices
|
||||
let computed_value = self.editor.effective_field_value(i);
|
||||
self.editor.data_provider_mut().set_field_value(i, computed_value.clone());
|
||||
}
|
||||
|
||||
// Check if values changed to show feedback
|
||||
let mut changed = false;
|
||||
let mut has_calculations = false;
|
||||
|
||||
for i in [4, 5, 6] {
|
||||
let new_value = self.editor.effective_field_value(i);
|
||||
if new_value != self.last_computed_values[i] {
|
||||
changed = true;
|
||||
self.last_computed_values[i] = new_value.clone();
|
||||
}
|
||||
if !new_value.is_empty() {
|
||||
has_calculations = true;
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
if has_calculations {
|
||||
let subtotal = &self.last_computed_values[4];
|
||||
let tax = &self.last_computed_values[5];
|
||||
let total = &self.last_computed_values[6];
|
||||
|
||||
let mut parts = Vec::new();
|
||||
if !subtotal.is_empty() {
|
||||
parts.push(format!("Subtotal=${}", subtotal));
|
||||
}
|
||||
if !tax.is_empty() {
|
||||
parts.push(format!("Tax=${}", tax));
|
||||
}
|
||||
if !total.is_empty() {
|
||||
parts.push(format!("Total=${}", total));
|
||||
}
|
||||
|
||||
if !parts.is_empty() {
|
||||
self.debug_message = format!("🧮 Calculated: {}", parts.join(", "));
|
||||
} else {
|
||||
self.debug_message = "💰 Enter Quantity and Unit Price to see calculations".to_string();
|
||||
}
|
||||
} else {
|
||||
self.debug_message = "💰 Enter Quantity and Unit Price to see calculations".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
|
||||
let current_field = self.editor.current_field();
|
||||
let result = self.editor.insert_char(ch);
|
||||
|
||||
if result.is_ok() && matches!(current_field, 1 | 2 | 3) {
|
||||
self.editor.on_field_changed(&mut self.calculator, current_field);
|
||||
self.update_computed_fields();
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn delete_backward(&mut self) -> anyhow::Result<()> {
|
||||
let current_field = self.editor.current_field();
|
||||
let result = self.editor.delete_backward();
|
||||
|
||||
if result.is_ok() && matches!(current_field, 1 | 2 | 3) {
|
||||
self.editor.on_field_changed(&mut self.calculator, current_field);
|
||||
self.update_computed_fields();
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn delete_forward(&mut self) -> anyhow::Result<()> {
|
||||
let current_field = self.editor.current_field();
|
||||
let result = self.editor.delete_forward();
|
||||
|
||||
if result.is_ok() && matches!(current_field, 1 | 2 | 3) {
|
||||
self.editor.on_field_changed(&mut self.calculator, current_field);
|
||||
self.update_computed_fields();
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn next_field(&mut self) {
|
||||
let old_field = self.editor.current_field();
|
||||
let _ = self.editor.next_field();
|
||||
let new_field = self.editor.current_field();
|
||||
|
||||
if old_field != new_field {
|
||||
let field_name = self.editor.data_provider().field_name(new_field);
|
||||
let field_type = if self.is_computed_field(new_field) {
|
||||
"computed (read-only)"
|
||||
} else {
|
||||
"editable"
|
||||
};
|
||||
self.debug_message = format!("→ {} - {} field", field_name, field_type);
|
||||
}
|
||||
}
|
||||
|
||||
fn prev_field(&mut self) {
|
||||
let old_field = self.editor.current_field();
|
||||
let _ = self.editor.prev_field();
|
||||
let new_field = self.editor.current_field();
|
||||
|
||||
if old_field != new_field {
|
||||
let field_name = self.editor.data_provider().field_name(new_field);
|
||||
let field_type = if self.is_computed_field(new_field) {
|
||||
"computed (read-only)"
|
||||
} else {
|
||||
"editable"
|
||||
};
|
||||
self.debug_message = format!("← {} - {} field", field_name, field_type);
|
||||
}
|
||||
}
|
||||
|
||||
fn enter_edit_mode(&mut self) {
|
||||
let current = self.editor.current_field();
|
||||
|
||||
// Double protection: check both ways
|
||||
if self.editor.data_provider().is_computed_field(current) || self.is_computed_field(current) {
|
||||
let field_name = self.editor.data_provider().field_name(current);
|
||||
self.debug_message = format!(
|
||||
"🚫 {} is computed (read-only) - Press Tab to move to editable fields",
|
||||
field_name
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
self.editor.enter_edit_mode();
|
||||
let field_name = self.editor.data_provider().field_name(current);
|
||||
self.debug_message = format!("✏️ Editing {} - Type to see calculations update", field_name);
|
||||
}
|
||||
|
||||
fn enter_append_mode(&mut self) {
|
||||
let current = self.editor.current_field();
|
||||
|
||||
if self.editor.data_provider().is_computed_field(current) || self.is_computed_field(current) {
|
||||
let field_name = self.editor.data_provider().field_name(current);
|
||||
self.debug_message = format!(
|
||||
"🚫 {} is computed (read-only) - Press Tab to move to editable fields",
|
||||
field_name
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
self.editor.enter_append_mode();
|
||||
let field_name = self.editor.data_provider().field_name(current);
|
||||
self.debug_message = format!("✏️ Appending to {} - Type to see calculations", field_name);
|
||||
}
|
||||
|
||||
fn exit_edit_mode(&mut self) {
|
||||
let current_field = self.editor.current_field();
|
||||
self.editor.exit_edit_mode();
|
||||
|
||||
if matches!(current_field, 1 | 2 | 3) {
|
||||
self.editor.on_field_changed(&mut self.calculator, current_field);
|
||||
self.update_computed_fields();
|
||||
}
|
||||
|
||||
self.debug_message = "🔒 Normal mode - Press 'i' to edit fields".to_string();
|
||||
}
|
||||
|
||||
// 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 {
|
||||
let field_index = self.editor.current_field();
|
||||
self.editor.data_provider().field_value(field_index)
|
||||
}
|
||||
fn data_provider(&self) -> &D { self.editor.data_provider() }
|
||||
fn ui_state(&self) -> &canvas::EditorState { self.editor.ui_state() }
|
||||
fn move_left(&mut self) { self.editor.move_left(); }
|
||||
fn move_right(&mut self) { self.editor.move_right(); }
|
||||
fn move_up(&mut self) { let _ = self.editor.move_up(); }
|
||||
fn move_down(&mut self) { let _ = self.editor.move_down(); }
|
||||
}
|
||||
|
||||
fn handle_key_press(
|
||||
key: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
editor: &mut ComputedFieldsEditor<InvoiceData>,
|
||||
) -> anyhow::Result<bool> {
|
||||
let mode = editor.mode();
|
||||
|
||||
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) {
|
||||
(AppMode::ReadOnly, KeyCode::Char('i'), _) => {
|
||||
editor.enter_edit_mode();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
|
||||
editor.enter_append_mode();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('A'), _) => {
|
||||
editor.editor.move_line_end();
|
||||
editor.enter_edit_mode();
|
||||
}
|
||||
(_, KeyCode::Esc, _) => {
|
||||
if mode == AppMode::Edit {
|
||||
editor.exit_edit_mode();
|
||||
}
|
||||
}
|
||||
|
||||
// Movement
|
||||
(AppMode::ReadOnly, KeyCode::Char('h'), _) | (AppMode::ReadOnly, KeyCode::Left, _) => {
|
||||
editor.move_left();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('l'), _) | (AppMode::ReadOnly, KeyCode::Right, _) => {
|
||||
editor.move_right();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('j'), _) | (AppMode::ReadOnly, KeyCode::Down, _) => {
|
||||
editor.move_down();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('k'), _) | (AppMode::ReadOnly, KeyCode::Up, _) => {
|
||||
editor.move_up();
|
||||
}
|
||||
|
||||
// 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(); }
|
||||
|
||||
// Navigation
|
||||
(_, 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()?;
|
||||
}
|
||||
|
||||
// Debug info
|
||||
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
|
||||
let current = editor.current_field();
|
||||
let field_name = editor.data_provider().field_name(current);
|
||||
let field_type = if editor.is_computed_field(current) {
|
||||
"COMPUTED (read-only)"
|
||||
} else {
|
||||
"EDITABLE"
|
||||
};
|
||||
editor.debug_message = format!(
|
||||
"{} - {} - Position {} - Mode: {:?}",
|
||||
field_name, field_type, editor.cursor_position(), mode
|
||||
);
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn run_app<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
mut editor: ComputedFieldsEditor<InvoiceData>,
|
||||
) -> io::Result<()> {
|
||||
editor.update_computed_fields(); // Initial computation
|
||||
|
||||
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: &ComputedFieldsEditor<InvoiceData>) {
|
||||
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_computed_status(f, chunks[1], editor);
|
||||
}
|
||||
|
||||
fn render_enhanced_canvas(f: &mut Frame, area: Rect, editor: &ComputedFieldsEditor<InvoiceData>) {
|
||||
render_canvas_default(f, area, &editor.editor);
|
||||
}
|
||||
|
||||
fn render_computed_status(f: &mut Frame, area: Rect, editor: &ComputedFieldsEditor<InvoiceData>) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Length(7)])
|
||||
.split(area);
|
||||
|
||||
let mode_text = match editor.mode() {
|
||||
AppMode::Edit => "INSERT",
|
||||
AppMode::ReadOnly => "NORMAL",
|
||||
_ => "OTHER",
|
||||
};
|
||||
|
||||
let current = editor.current_field();
|
||||
let field_status = if editor.is_computed_field(current) {
|
||||
"📊 COMPUTED FIELD (read-only)"
|
||||
} else {
|
||||
"✏️ EDITABLE FIELD"
|
||||
};
|
||||
|
||||
let status_text = format!("-- {} -- {} | {}", mode_text, field_status, editor.debug_message);
|
||||
|
||||
let status = Paragraph::new(Line::from(Span::raw(status_text)))
|
||||
.block(Block::default().borders(Borders::ALL).title("💰 Invoice Calculator"));
|
||||
|
||||
f.render_widget(status, chunks[0]);
|
||||
|
||||
let help_text = match editor.mode() {
|
||||
AppMode::ReadOnly => {
|
||||
"💰 COMPUTED FIELDS DEMO: Real-time invoice calculations!\n\
|
||||
🔢 EDITABLE: Product, Quantity, Unit Price, Tax Rate, Notes\n\
|
||||
📊 COMPUTED: Subtotal, Tax Amount, Total (calculated automatically)\n\
|
||||
\n\
|
||||
🚀 START: Press 'i' to edit Quantity, type '5', Tab to Unit Price, type '19.99'\n\
|
||||
Watch Subtotal and Total appear! Add Tax Rate to see tax calculations.\n\
|
||||
Navigation: Tab/Shift+Tab skips computed fields automatically"
|
||||
}
|
||||
AppMode::Edit => {
|
||||
"✏️ EDIT MODE: Type numbers to see calculations appear!\n\
|
||||
\n\
|
||||
💡 EXAMPLE: Type '5' in Quantity, then Tab to Unit Price and type '19.99'\n\
|
||||
• Subtotal appears: $99.95\n\
|
||||
• Total appears: $99.95\n\
|
||||
• Add Tax Rate (like '10') to see tax: $9.99, Total: $109.94\n\
|
||||
\n\
|
||||
Esc=normal, Tab=next field (auto-skips computed fields)"
|
||||
}
|
||||
_ => "💰 Invoice Calculator with Computed Fields"
|
||||
};
|
||||
|
||||
let help_style = if editor.is_computed_field(editor.current_field()) {
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC)
|
||||
} else {
|
||||
Style::default().fg(Color::Gray)
|
||||
};
|
||||
|
||||
let help = Paragraph::new(help_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("🚀 Try It Now!"))
|
||||
.style(help_style)
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
f.render_widget(help, chunks[1]);
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("💰 Canvas Computed Fields Demo - Invoice Calculator");
|
||||
println!("✅ computed feature: ENABLED");
|
||||
println!("🚀 QUICK TEST:");
|
||||
println!(" 1. Press 'i' to edit Quantity");
|
||||
println!(" 2. Type '5' and press Tab");
|
||||
println!(" 3. Type '19.99' in Unit Price");
|
||||
println!(" 4. Watch Subtotal ($99.95) and Total ($99.95) appear!");
|
||||
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 = InvoiceData::new();
|
||||
let editor = ComputedFieldsEditor::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!("💰 Demo completed! Computed fields should have updated in real-time!");
|
||||
Ok(())
|
||||
}
|
||||
@@ -345,7 +345,7 @@ impl DataProvider for FullDemoData {
|
||||
self.fields[index].1 = value;
|
||||
}
|
||||
|
||||
fn supports_autocomplete(&self, _field_index: usize) -> bool {
|
||||
fn supports_suggestions(&self, _field_index: usize) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
|
||||
1118
canvas/examples/suggestions.rs
Normal file
1118
canvas/examples/suggestions.rs
Normal file
File diff suppressed because it is too large
Load Diff
1079
canvas/examples/suggestions2.rs
Normal file
1079
canvas/examples/suggestions2.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
// examples/validation_1.rs
|
||||
//! Demonstrates field validation with the canvas library
|
||||
//!
|
||||
//! This example REQUIRES the `validation` feature to compile.
|
||||
//! This example REQUIRES the `validation` and `cursor-style` features to compile.
|
||||
//!
|
||||
//! Run with:
|
||||
//! cargo run --example validation_1 --features "gui,validation"
|
||||
@@ -10,10 +10,10 @@
|
||||
//! cargo run --example validation_1 --features "gui"
|
||||
|
||||
// REQUIRE validation feature - example won't compile without it
|
||||
#[cfg(not(feature = "validation"))]
|
||||
#[cfg(not(all(feature = "validation", feature = "cursor-style")))]
|
||||
compile_error!(
|
||||
"This example requires the 'validation' feature. \
|
||||
Run with: cargo run --example validation_1 --features \"gui,validation\""
|
||||
"This example requires the 'validation' and 'cursor-style' features. \
|
||||
Run with: cargo run --example validation_1 --features \"gui,validation,cursor-style\""
|
||||
);
|
||||
|
||||
use std::io;
|
||||
@@ -34,11 +34,11 @@ use ratatui::{
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
use canvas::{
|
||||
canvas::{
|
||||
gui::render_canvas_default,
|
||||
modes::AppMode,
|
||||
CursorManager,
|
||||
},
|
||||
DataProvider, FormEditor,
|
||||
ValidationConfig, ValidationConfigBuilder, CharacterLimits, ValidationResult,
|
||||
@@ -61,10 +61,8 @@ struct ValidationFormEditor<D: DataProvider> {
|
||||
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,
|
||||
@@ -97,7 +95,6 @@ impl<D: DataProvider> ValidationFormEditor<D> {
|
||||
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 {
|
||||
@@ -109,14 +106,12 @@ impl<D: DataProvider> ValidationFormEditor<D> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -124,11 +119,9 @@ impl<D: DataProvider> ValidationFormEditor<D> {
|
||||
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)
|
||||
@@ -161,7 +154,6 @@ impl<D: DataProvider> ValidationFormEditor<D> {
|
||||
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",
|
||||
@@ -249,38 +241,37 @@ impl<D: DataProvider> ValidationFormEditor<D> {
|
||||
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);
|
||||
}
|
||||
let result = self.editor.validate_current_field();
|
||||
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) {
|
||||
// Library will automatically update cursor to bar | in insert mode
|
||||
self.editor.enter_edit_mode();
|
||||
self.debug_message = "✏️ INSERT MODE - Type to test validation".to_string();
|
||||
self.debug_message = "✏️ INSERT MODE - Cursor: Steady Bar | - Type to test validation".to_string();
|
||||
}
|
||||
|
||||
fn enter_append_mode(&mut self) {
|
||||
// Library will automatically update cursor to bar | in insert mode
|
||||
self.editor.enter_append_mode();
|
||||
self.debug_message = "✏️ INSERT (append) - Validation active".to_string();
|
||||
self.debug_message = "✏️ INSERT (append) - Cursor: Steady Bar | - Validation active".to_string();
|
||||
}
|
||||
|
||||
fn exit_edit_mode(&mut self) {
|
||||
// Library will automatically update cursor to block █ in normal mode
|
||||
self.editor.exit_edit_mode();
|
||||
self.debug_message = "🔒 NORMAL MODE - Press 'v' to validate current field".to_string();
|
||||
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █ - Press 'v' to validate current field".to_string();
|
||||
self.update_field_validation_status();
|
||||
}
|
||||
|
||||
@@ -289,22 +280,24 @@ impl<D: DataProvider> ValidationFormEditor<D> {
|
||||
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);
|
||||
}
|
||||
let validation_result = self.editor.validate_current_field();
|
||||
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() {
|
||||
let field_index = self.editor.current_field();
|
||||
if let Some(status) = limits.status_text(
|
||||
self.editor.data_provider().field_value(field_index)
|
||||
) {
|
||||
self.debug_message = format!("✏️ {}", status);
|
||||
}
|
||||
}
|
||||
ValidationResult::Warning { message } => {
|
||||
self.debug_message = format!("⚠️ {}", message);
|
||||
}
|
||||
ValidationResult::Error { message } => {
|
||||
self.debug_message = format!("❌ {}", message);
|
||||
}
|
||||
}
|
||||
ValidationResult::Warning { message } => {
|
||||
self.debug_message = format!("⚠️ {}", message);
|
||||
}
|
||||
ValidationResult::Error { message } => {
|
||||
self.debug_message = format!("❌ {}", message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -350,7 +343,8 @@ impl<D: DataProvider> ValidationFormEditor<D> {
|
||||
}
|
||||
|
||||
fn current_text(&self) -> &str {
|
||||
self.editor.current_text()
|
||||
let field_index = self.editor.current_field();
|
||||
self.editor.data_provider().field_value(field_index)
|
||||
}
|
||||
|
||||
fn data_provider(&self) -> &D {
|
||||
@@ -362,6 +356,7 @@ impl<D: DataProvider> ValidationFormEditor<D> {
|
||||
}
|
||||
|
||||
fn set_mode(&mut self, mode: AppMode) {
|
||||
// Library automatically updates cursor for the mode
|
||||
self.editor.set_mode(mode);
|
||||
}
|
||||
|
||||
@@ -447,7 +442,7 @@ impl DataProvider for ValidationDemoData {
|
||||
self.fields[index].1 = value;
|
||||
}
|
||||
|
||||
fn supports_autocomplete(&self, _field_index: usize) -> bool {
|
||||
fn supports_suggestions(&self, _field_index: usize) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -531,7 +526,6 @@ fn handle_key_press(
|
||||
editor.enter_edit_mode();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// Escape: Exit edit mode
|
||||
(_, KeyCode::Esc, _) => {
|
||||
if mode == AppMode::Edit {
|
||||
@@ -624,7 +618,6 @@ fn handle_key_press(
|
||||
summary.validated_fields
|
||||
));
|
||||
}
|
||||
|
||||
_ => {
|
||||
if editor.has_pending_command() {
|
||||
editor.clear_command_buffer();
|
||||
@@ -656,7 +649,6 @@ fn run_app<B: Backend>(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -694,33 +686,33 @@ fn render_validation_status(
|
||||
|
||||
// Status bar with validation information
|
||||
let mode_text = match editor.mode() {
|
||||
AppMode::Edit => "INSERT",
|
||||
AppMode::ReadOnly => "NORMAL",
|
||||
_ => "OTHER",
|
||||
AppMode::Edit => "INSERT | (bar cursor)",
|
||||
AppMode::ReadOnly => "NORMAL █ (block cursor)",
|
||||
_ => "NORMAL █ (block cursor)",
|
||||
};
|
||||
|
||||
let validation_status = editor.get_validation_status();
|
||||
|
||||
let status_text = if editor.has_pending_command() {
|
||||
format!("-- {} -- {} [{}] | Validation: {}",
|
||||
format!("-- {} -- {} [{}] | Validation: {}",
|
||||
mode_text, editor.debug_message(), editor.get_command_buffer(), validation_status)
|
||||
} else if editor.has_unsaved_changes() {
|
||||
format!("-- {} -- [Modified] {} | Validation: {}",
|
||||
format!("-- {} -- [Modified] {} | Validation: {}",
|
||||
mode_text, editor.debug_message(), validation_status)
|
||||
} else {
|
||||
format!("-- {} -- {} | Validation: {}",
|
||||
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: {}",
|
||||
format!("\n🚫 Field switching blocked: {}",
|
||||
editor.block_reason.as_deref().unwrap_or("Unknown reason"))
|
||||
} else {
|
||||
let (can_switch, reason) = editor.check_field_switch_allowed();
|
||||
@@ -759,34 +751,32 @@ fn render_validation_status(
|
||||
.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\
|
||||
"🎯 CURSOR-STYLE: Normal █ | Insert |\n\
|
||||
🔍 VALIDATION: Different fields have different limits (some block field switching)!\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\
|
||||
"🎯 INSERT MODE - Cursor: | (bar)\n\
|
||||
🔍 Type to test validation limits (some fields have MIN 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!"
|
||||
_ => "🎯 Watch the cursor change automatically while validating!"
|
||||
};
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -810,10 +800,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let data = ValidationDemoData::new();
|
||||
let editor = ValidationFormEditor::new(data);
|
||||
let mut editor = ValidationFormEditor::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(),
|
||||
|
||||
663
canvas/examples/validation_2.rs
Normal file
663
canvas/examples/validation_2.rs
Normal file
@@ -0,0 +1,663 @@
|
||||
// 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,cursor-style"
|
||||
|
||||
// REQUIRE validation, gui and cursor-style features
|
||||
#[cfg(not(all(feature = "validation", feature = "gui", feature = "cursor-style")))]
|
||||
compile_error!(
|
||||
"This example requires the 'validation', 'gui' and 'cursor-style' features. \
|
||||
Run with: cargo run --example validation_advanced_patterns --features \"validation,gui,cursor-style\""
|
||||
);
|
||||
|
||||
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,
|
||||
CursorManager,
|
||||
},
|
||||
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) {
|
||||
// Library will automatically update cursor to bar | in insert mode
|
||||
self.editor.enter_edit_mode();
|
||||
self.debug_message = "✏️ INSERT MODE - Cursor: Steady Bar | - Testing advanced pattern validation".to_string();
|
||||
}
|
||||
|
||||
fn enter_append_mode(&mut self) {
|
||||
// Library will automatically update cursor to bar | in insert mode
|
||||
self.editor.enter_append_mode();
|
||||
self.debug_message = "✏️ INSERT (append) - Cursor: Steady Bar | - Advanced patterns active".to_string();
|
||||
}
|
||||
|
||||
fn exit_edit_mode(&mut self) {
|
||||
// Library will automatically update cursor to block █ in normal mode
|
||||
self.editor.exit_edit_mode();
|
||||
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █".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() {
|
||||
let validation_result = self.editor.validate_current_field();
|
||||
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 {
|
||||
let field_index = self.editor.current_field();
|
||||
self.editor.data_provider().field_value(field_index)
|
||||
}
|
||||
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; }
|
||||
let result = self.editor.validate_current_field();
|
||||
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 | (bar cursor)",
|
||||
AppMode::ReadOnly => "NORMAL █ (block cursor)",
|
||||
_ => "NORMAL █ (block cursor)",
|
||||
};
|
||||
|
||||
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!("✅ cursor-style 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 mut editor = AdvancedPatternFormEditor::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!("🚀 Advanced pattern validation demo completed!");
|
||||
println!("🎯 Hope you enjoyed seeing all the edge cases in action!");
|
||||
Ok(())
|
||||
}
|
||||
735
canvas/examples/validation_3.rs
Normal file
735
canvas/examples/validation_3.rs
Normal file
@@ -0,0 +1,735 @@
|
||||
// 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,cursor-style"
|
||||
|
||||
// REQUIRE validation, gui and cursor-style features for mask functionality
|
||||
#[cfg(not(all(feature = "validation", feature = "gui", feature = "cursor-style")))]
|
||||
compile_error!(
|
||||
"This example requires the 'validation', 'gui' and 'cursor-style' features. \
|
||||
Run with: cargo run --example validation_3 --features \"gui,validation,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, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
use canvas::{
|
||||
canvas::{
|
||||
gui::render_canvas_default,
|
||||
modes::AppMode,
|
||||
CursorManager,
|
||||
},
|
||||
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.data_provider().field_value(field_index);
|
||||
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) {
|
||||
// Library will automatically update cursor to bar | in insert mode
|
||||
self.editor.enter_edit_mode();
|
||||
self.debug_message = "✏️ INSERT MODE - Cursor: Steady Bar | - Type to see mask formatting in real-time".to_string();
|
||||
}
|
||||
|
||||
fn enter_append_mode(&mut self) {
|
||||
// Library will automatically update cursor to bar | in insert mode
|
||||
self.editor.enter_append_mode();
|
||||
self.debug_message = "✏️ INSERT (append) - Cursor: Steady Bar | - Mask formatting active".to_string();
|
||||
}
|
||||
|
||||
fn exit_edit_mode(&mut self) {
|
||||
// Library will automatically update cursor to block █ in normal mode
|
||||
self.editor.exit_edit_mode();
|
||||
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █ - 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 {
|
||||
let field_index = self.editor.current_field();
|
||||
self.editor.data_provider().field_value(field_index)
|
||||
}
|
||||
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) {
|
||||
// Library automatically updates cursor for the mode
|
||||
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 | (bar cursor)",
|
||||
AppMode::ReadOnly => "NORMAL █ (block cursor)",
|
||||
_ => "NORMAL █ (block cursor)",
|
||||
};
|
||||
|
||||
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 => {
|
||||
"🎯 CURSOR-STYLE: Normal █ | Insert |\n\
|
||||
🎭 MASK DEMO: 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 - Cursor: | (bar)\n\
|
||||
✏️ 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!("✅ cursor-style feature: ENABLED");
|
||||
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 mut editor = MaskDemoFormEditor::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);
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
// Library automatically resets cursor on FormEditor::drop()
|
||||
// But we can also manually reset if needed
|
||||
CursorManager::reset()?;
|
||||
|
||||
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(())
|
||||
}
|
||||
755
canvas/examples/validation_4.rs
Normal file
755
canvas/examples/validation_4.rs
Normal file
@@ -0,0 +1,755 @@
|
||||
/* 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", feature = "cursor-style")))]
|
||||
compile_error!(
|
||||
"This example requires the 'validation', 'gui' and 'cursor-style' features. \
|
||||
Run with: cargo run --example validation_4 --features \"gui,validation,cursor-style\""
|
||||
);
|
||||
|
||||
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, CursorManager},
|
||||
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 field_index = self.editor.current_field();
|
||||
let raw = self.editor.data_provider().field_value(field_index);
|
||||
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) {
|
||||
// Library will automatically update cursor to bar | in insert mode
|
||||
self.editor.enter_edit_mode();
|
||||
let field_type = self.current_field_type();
|
||||
let rules = self.get_input_rules();
|
||||
self.debug_message = format!("✏️ INSERT MODE - Cursor: Steady Bar | - {} - {}", field_type, rules);
|
||||
}
|
||||
|
||||
fn exit_edit_mode(&mut self) {
|
||||
// Library will automatically update cursor to block █ in normal mode
|
||||
self.editor.exit_edit_mode();
|
||||
let (raw, display, _, warning) = self.get_current_field_analysis();
|
||||
if let Some(warn) = warning {
|
||||
self.debug_message = format!("🔒 NORMAL - Cursor: Steady Block █ - {} | ⚠️ {}", self.current_field_type(), warn);
|
||||
} else if raw != display {
|
||||
self.debug_message = format!("🔒 NORMAL - Cursor: Steady Block █ - {} formatted successfully", self.current_field_type());
|
||||
} else {
|
||||
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() {
|
||||
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 field_index = self.editor.current_field();
|
||||
let raw = self.editor.data_provider().field_value(field_index);
|
||||
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 | (bar cursor)",
|
||||
AppMode::ReadOnly => "NORMAL █ (block cursor)",
|
||||
_ => "NORMAL █ (block cursor)",
|
||||
};
|
||||
|
||||
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 => {
|
||||
"🎯 CURSOR-STYLE: Normal █ | Insert |\n\
|
||||
🧩 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 - Cursor: | (bar)\n\
|
||||
✏️ 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!("✅ cursor-style 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 mut editor = EnhancedDemoEditor::new(data);
|
||||
|
||||
// Initialize with normal mode - library automatically sets block cursor
|
||||
editor.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);
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
// Library automatically resets cursor on FormEditor::drop()
|
||||
// But we can also manually reset if needed
|
||||
CursorManager::reset()?;
|
||||
|
||||
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(())
|
||||
}
|
||||
1177
canvas/examples/validation_5.rs
Normal file
1177
canvas/examples/validation_5.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -114,7 +114,7 @@ async fn state_machine_example() {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::Custom(cmd) => match cmd.as_str() {
|
||||
"submit" => {
|
||||
@@ -147,7 +147,7 @@ async fn state_machine_example() {
|
||||
println!(" Initial state: {:?}", form.state);
|
||||
|
||||
// Type some text to trigger state change
|
||||
let _result = ActionDispatcher::dispatch(
|
||||
let result = ActionDispatcher::dispatch(
|
||||
CanvasAction::InsertChar('u'),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
@@ -231,7 +231,7 @@ async fn event_driven_example() {
|
||||
self.has_changes = changed;
|
||||
}
|
||||
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::Custom(cmd) => match cmd.as_str() {
|
||||
"validate" => {
|
||||
@@ -384,7 +384,7 @@ async fn validation_pipeline_example() {
|
||||
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> {
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::Custom(cmd) => match cmd.as_str() {
|
||||
"validate" => {
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
// src/autocomplete/mod.rs
|
||||
|
||||
pub mod state;
|
||||
#[cfg(feature = "gui")]
|
||||
pub mod gui;
|
||||
|
||||
// Re-export the main autocomplete types
|
||||
pub use state::{AutocompleteProvider, SuggestionItem};
|
||||
|
||||
// Re-export GUI functions if available
|
||||
#[cfg(feature = "gui")]
|
||||
pub use gui::render_autocomplete_dropdown;
|
||||
@@ -1,5 +0,0 @@
|
||||
// src/autocomplete/state.rs
|
||||
//! Autocomplete provider types
|
||||
|
||||
// Re-export the main types from data_provider
|
||||
pub use crate::data_provider::{AutocompleteProvider, SuggestionItem};
|
||||
@@ -5,6 +5,12 @@ 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 word::{
|
||||
find_next_word_start, find_word_end, find_prev_word_start, find_prev_word_end,
|
||||
find_next_big_word_start, find_prev_big_word_start, find_big_word_end, find_prev_big_word_end,
|
||||
// Add these new exports:
|
||||
find_last_word_start_in_field, find_last_word_end_in_field,
|
||||
find_last_big_word_start_in_field, find_last_big_word_end_in_field,
|
||||
};
|
||||
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};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// src/canvas/actions/movement/word.rs
|
||||
// Replace the entire file with this corrected version:
|
||||
|
||||
#[derive(PartialEq)]
|
||||
#[derive(PartialEq, Copy, Clone)]
|
||||
enum CharType {
|
||||
Whitespace,
|
||||
Alphanumeric,
|
||||
@@ -55,7 +56,7 @@ pub fn find_word_end(text: &str, current_pos: usize) -> usize {
|
||||
|
||||
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 {
|
||||
@@ -107,40 +108,296 @@ pub fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the end of the previous word
|
||||
/// Find the end of the previous word (CORRECTED VERSION for vim's ge command)
|
||||
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;
|
||||
}
|
||||
|
||||
// Find all word end positions using boundary detection
|
||||
let mut word_ends = Vec::new();
|
||||
let mut in_word = false;
|
||||
let mut current_word_type: Option<CharType> = None;
|
||||
|
||||
for (i, &ch) in chars.iter().enumerate() {
|
||||
let char_type = get_char_type(ch);
|
||||
|
||||
match char_type {
|
||||
CharType::Whitespace => {
|
||||
if in_word {
|
||||
// End of a word
|
||||
word_ends.push(i - 1);
|
||||
in_word = false;
|
||||
current_word_type = None;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if !in_word || current_word_type != Some(char_type) {
|
||||
// Start of a new word (or word type change)
|
||||
if in_word {
|
||||
// End the previous word first
|
||||
word_ends.push(i - 1);
|
||||
}
|
||||
in_word = true;
|
||||
current_word_type = Some(char_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the final word end if text doesn't end with whitespace
|
||||
if in_word && !chars.is_empty() {
|
||||
word_ends.push(chars.len() - 1);
|
||||
}
|
||||
|
||||
// Find the largest word end position that's before current_pos
|
||||
for &end_pos in word_ends.iter().rev() {
|
||||
if end_pos < current_pos {
|
||||
return end_pos;
|
||||
}
|
||||
}
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
/// Find the start of the next big_word (whitespace-separated)
|
||||
pub fn find_next_big_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() || current_pos >= chars.len() {
|
||||
return text.chars().count();
|
||||
}
|
||||
|
||||
let mut pos = current_pos;
|
||||
|
||||
// If we're on non-whitespace, skip to end of current big_word
|
||||
while pos < chars.len() && !chars[pos].is_whitespace() {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
// Skip whitespace to find start of next big_word
|
||||
while pos < chars.len() && chars[pos].is_whitespace() {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
/// Find the start of the previous big_word (whitespace-separated)
|
||||
pub fn find_prev_big_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 {
|
||||
while pos > 0 && chars[pos].is_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 {
|
||||
// Find start of current big_word by going back while non-whitespace
|
||||
while pos > 0 && !chars[pos - 1].is_whitespace() {
|
||||
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
|
||||
}
|
||||
pos
|
||||
}
|
||||
|
||||
/// Find the end of the current/next big_word (whitespace-separated)
|
||||
pub fn find_big_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos;
|
||||
|
||||
// If we're on whitespace, skip to start of next big_word
|
||||
while pos < chars.len() && chars[pos].is_whitespace() {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
// If we reached end, return it
|
||||
if pos >= chars.len() {
|
||||
return chars.len();
|
||||
}
|
||||
|
||||
// Find end of current big_word (last non-whitespace char)
|
||||
while pos < chars.len() && !chars[pos].is_whitespace() {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
// Return position of last character in big_word
|
||||
pos.saturating_sub(1)
|
||||
}
|
||||
|
||||
/// Find the end of the previous big_word (whitespace-separated)
|
||||
pub fn find_prev_big_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 && chars[pos].is_whitespace() {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
// If we hit start of text and it's whitespace, return 0
|
||||
if pos == 0 && chars[0].is_whitespace() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Skip back to start of current big_word, then forward to end
|
||||
while pos > 0 && !chars[pos - 1].is_whitespace() {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
// Now find end of this big_word
|
||||
while pos < chars.len() && !chars[pos].is_whitespace() {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
// Return position of last character in big_word
|
||||
pos.saturating_sub(1)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FIELD BOUNDARY HELPER FUNCTIONS (for cross-field movement)
|
||||
// ============================================================================
|
||||
|
||||
/// Find the start of the last word in a field (for cross-field 'b' movement)
|
||||
pub fn find_last_word_start_in_field(text: &str) -> usize {
|
||||
if text.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = chars.len().saturating_sub(1);
|
||||
|
||||
// Skip trailing whitespace
|
||||
while pos > 0 && chars[pos].is_whitespace() {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
// If the whole field is whitespace, return 0
|
||||
if pos == 0 && chars[0].is_whitespace() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Now we're on a non-whitespace character
|
||||
// Find the start of this word by going backwards while chars are the same type
|
||||
let char_type = if chars[pos].is_alphanumeric() { "alnum" } else { "punct" };
|
||||
|
||||
while pos > 0 {
|
||||
let prev_char = chars[pos - 1];
|
||||
let prev_type = if prev_char.is_alphanumeric() {
|
||||
"alnum"
|
||||
} else if prev_char.is_whitespace() {
|
||||
"space"
|
||||
} else {
|
||||
"punct"
|
||||
};
|
||||
|
||||
// Stop if we hit whitespace or different word type
|
||||
if prev_type == "space" || prev_type != char_type {
|
||||
break;
|
||||
}
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
/// Find the end of the last word in a field (for cross-field 'ge' movement)
|
||||
pub fn find_last_word_end_in_field(text: &str) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Start from the end and find the last non-whitespace character
|
||||
let mut pos = chars.len() - 1;
|
||||
|
||||
// Skip trailing whitespace
|
||||
while pos > 0 && chars[pos].is_whitespace() {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
// If the whole field is whitespace, return 0
|
||||
if chars[pos].is_whitespace() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// We're now at the end of the last word
|
||||
pos
|
||||
}
|
||||
|
||||
/// Find the start of the last big_word in a field (for cross-field 'B' movement)
|
||||
pub fn find_last_big_word_start_in_field(text: &str) -> usize {
|
||||
if text.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = chars.len().saturating_sub(1);
|
||||
|
||||
// Skip trailing whitespace
|
||||
while pos > 0 && chars[pos].is_whitespace() {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
// If the whole field is whitespace, return 0
|
||||
if pos == 0 && chars[0].is_whitespace() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Now we're on a non-whitespace character
|
||||
// Find the start of this big_word by going backwards while chars are non-whitespace
|
||||
while pos > 0 {
|
||||
let prev_char = chars[pos - 1];
|
||||
|
||||
// Stop if we hit whitespace (big_word boundary)
|
||||
if prev_char.is_whitespace() {
|
||||
break;
|
||||
}
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
/// Find the end of the last big_word in a field (for cross-field 'gE' movement)
|
||||
pub fn find_last_big_word_end_in_field(text: &str) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = chars.len().saturating_sub(1);
|
||||
|
||||
// Skip trailing whitespace
|
||||
while pos > 0 && chars[pos].is_whitespace() {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
// If the whole field is whitespace, return 0
|
||||
if pos == 0 && chars[0].is_whitespace() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// We're now at the end of the last big_word
|
||||
pos
|
||||
}
|
||||
|
||||
@@ -30,12 +30,12 @@ pub enum CanvasAction {
|
||||
DeleteBackward,
|
||||
DeleteForward,
|
||||
|
||||
// Autocomplete actions
|
||||
TriggerAutocomplete,
|
||||
SuggestionUp,
|
||||
SuggestionDown,
|
||||
SelectSuggestion,
|
||||
ExitSuggestions,
|
||||
// Suggestions actions
|
||||
TriggerSuggestions,
|
||||
SuggestionUp,
|
||||
SuggestionDown,
|
||||
SelectSuggestion,
|
||||
ExitSuggestions,
|
||||
|
||||
// Custom actions
|
||||
Custom(String),
|
||||
@@ -101,7 +101,7 @@ impl CanvasAction {
|
||||
Self::InsertChar(_c) => "insert character",
|
||||
Self::DeleteBackward => "delete backward",
|
||||
Self::DeleteForward => "delete forward",
|
||||
Self::TriggerAutocomplete => "trigger autocomplete",
|
||||
Self::TriggerSuggestions => "trigger suggestions",
|
||||
Self::SuggestionUp => "suggestion up",
|
||||
Self::SuggestionDown => "suggestion down",
|
||||
Self::SelectSuggestion => "select suggestion",
|
||||
@@ -139,10 +139,10 @@ impl CanvasAction {
|
||||
]
|
||||
}
|
||||
|
||||
/// Get all autocomplete-related actions
|
||||
pub fn autocomplete_actions() -> Vec<CanvasAction> {
|
||||
/// Get all suggestions-related actions
|
||||
pub fn suggestions_actions() -> Vec<CanvasAction> {
|
||||
vec![
|
||||
Self::TriggerAutocomplete,
|
||||
Self::TriggerSuggestions,
|
||||
Self::SuggestionUp,
|
||||
Self::SuggestionDown,
|
||||
Self::SelectSuggestion,
|
||||
|
||||
@@ -15,11 +15,12 @@ use crate::canvas::theme::{CanvasTheme, DefaultCanvasTheme};
|
||||
use crate::canvas::modes::HighlightState;
|
||||
use crate::data_provider::DataProvider;
|
||||
use crate::editor::FormEditor;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use std::cmp::{max, min};
|
||||
|
||||
/// Render ONLY the canvas form fields - no autocomplete
|
||||
/// 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>(
|
||||
@@ -52,12 +53,34 @@ pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
|
||||
|
||||
for i in 0..field_count {
|
||||
fields.push(data_provider.field_name(i));
|
||||
inputs.push(data_provider.field_value(i).to_string());
|
||||
|
||||
// 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);
|
||||
|
||||
// Precompute completion for active field
|
||||
#[cfg(feature = "suggestions")]
|
||||
let active_completion = if ui_state.is_suggestions_active()
|
||||
&& ui_state.suggestions.active_field == Some(current_field_idx)
|
||||
{
|
||||
ui_state.suggestions.completion_text.clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "suggestions"))]
|
||||
let active_completion: Option<String> = None;
|
||||
|
||||
render_canvas_fields(
|
||||
f,
|
||||
area,
|
||||
@@ -66,13 +89,35 @@ pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
|
||||
&inputs,
|
||||
theme,
|
||||
is_edit_mode,
|
||||
highlight_state, // Now using the actual highlight state!
|
||||
ui_state.cursor_position(),
|
||||
highlight_state,
|
||||
editor.display_cursor_position(), // Use display cursor position for masks
|
||||
false, // TODO: track unsaved changes in editor
|
||||
|i| {
|
||||
data_provider.display_value(i).unwrap_or(data_provider.field_value(i)).to_string()
|
||||
// Closures for getting display values and overrides
|
||||
#[cfg(feature = "validation")]
|
||||
|field_idx| editor.display_text_for_field(field_idx),
|
||||
#[cfg(not(feature = "validation"))]
|
||||
|field_idx| data_provider.field_value(field_idx).to_string(),
|
||||
// Closure for checking display overrides
|
||||
#[cfg(feature = "validation")]
|
||||
|field_idx| {
|
||||
editor.ui_state().validation_state().get_field_config(field_idx)
|
||||
.map(|cfg| {
|
||||
let has_formatter = cfg.custom_formatter.is_some();
|
||||
let has_mask = cfg.display_mask.is_some();
|
||||
has_formatter || has_mask
|
||||
})
|
||||
.unwrap_or(false)
|
||||
},
|
||||
#[cfg(not(feature = "validation"))]
|
||||
|_field_idx| false,
|
||||
// Closure for providing completion
|
||||
|field_idx| {
|
||||
if field_idx == current_field_idx {
|
||||
active_completion.clone()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
|i| data_provider.display_value(i).is_some(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -90,7 +135,7 @@ fn convert_selection_to_highlight(selection: &crate::canvas::state::SelectionSta
|
||||
|
||||
/// Core canvas field rendering
|
||||
#[cfg(feature = "gui")]
|
||||
fn render_canvas_fields<T: CanvasTheme, F1, F2>(
|
||||
fn render_canvas_fields<T: CanvasTheme, F1, F2, F3>(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
fields: &[&str],
|
||||
@@ -103,10 +148,12 @@ fn render_canvas_fields<T: CanvasTheme, F1, F2>(
|
||||
has_unsaved_changes: bool,
|
||||
get_display_value: F1,
|
||||
has_display_override: F2,
|
||||
get_completion: F3,
|
||||
) -> Option<Rect>
|
||||
where
|
||||
F1: Fn(usize) -> String,
|
||||
F2: Fn(usize) -> bool,
|
||||
F3: Fn(usize) -> Option<String>,
|
||||
{
|
||||
// Create layout
|
||||
let columns = Layout::default()
|
||||
@@ -160,6 +207,7 @@ where
|
||||
current_cursor_pos,
|
||||
get_display_value,
|
||||
has_display_override,
|
||||
get_completion,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -191,7 +239,7 @@ fn render_field_labels<T: CanvasTheme>(
|
||||
|
||||
/// Render field values with highlighting
|
||||
#[cfg(feature = "gui")]
|
||||
fn render_field_values<T: CanvasTheme, F1, F2>(
|
||||
fn render_field_values<T: CanvasTheme, F1, F2, F3>(
|
||||
f: &mut Frame,
|
||||
input_rows: Vec<Rect>,
|
||||
inputs: &[String],
|
||||
@@ -201,35 +249,55 @@ fn render_field_values<T: CanvasTheme, F1, F2>(
|
||||
current_cursor_pos: usize,
|
||||
get_display_value: F1,
|
||||
has_display_override: F2,
|
||||
get_completion: F3,
|
||||
) -> Option<Rect>
|
||||
where
|
||||
F1: Fn(usize) -> String,
|
||||
F2: Fn(usize) -> bool,
|
||||
F3: Fn(usize) -> Option<String>,
|
||||
{
|
||||
let mut active_field_input_rect = None;
|
||||
|
||||
for (i, _input) in inputs.iter().enumerate() {
|
||||
// FIX: Iterate over indices only since we never use the input values directly
|
||||
for i in 0..inputs.len() {
|
||||
let is_active = i == *current_field_idx;
|
||||
let text = get_display_value(i);
|
||||
let typed_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 line = if is_active {
|
||||
// Compose typed + gray completion for the active field
|
||||
let normal_style = Style::default().fg(theme.fg());
|
||||
let gray_style = Style::default().fg(theme.suggestion_gray());
|
||||
|
||||
let mut spans: Vec<Span> = Vec::new();
|
||||
spans.push(Span::styled(typed_text.clone(), normal_style));
|
||||
|
||||
if let Some(completion) = get_completion(i) {
|
||||
if !completion.is_empty() {
|
||||
spans.push(Span::styled(completion, gray_style));
|
||||
}
|
||||
}
|
||||
|
||||
Line::from(spans)
|
||||
} else {
|
||||
// Non-active fields: keep existing highlighting logic
|
||||
apply_highlighting(
|
||||
&typed_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
|
||||
// Set cursor for active field at end of typed text (not after completion)
|
||||
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));
|
||||
set_cursor_position(f, input_rows[i], &typed_text, current_cursor_pos, has_display_override(i));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +343,7 @@ fn apply_characterwise_highlighting<'a, T: CanvasTheme>(
|
||||
current_cursor_pos: usize,
|
||||
anchor: &(usize, usize),
|
||||
theme: &T,
|
||||
is_active: bool,
|
||||
_is_active: bool,
|
||||
) -> Line<'a> {
|
||||
let (anchor_field, anchor_char) = *anchor;
|
||||
let start_field = min(anchor_field, *current_field_idx);
|
||||
@@ -378,7 +446,7 @@ fn apply_linewise_highlighting<'a, T: CanvasTheme>(
|
||||
current_field_idx: &usize,
|
||||
anchor_line: &usize,
|
||||
theme: &T,
|
||||
is_active: bool,
|
||||
_is_active: bool,
|
||||
) -> Line<'a> {
|
||||
let start_field = min(*anchor_line, *current_field_idx);
|
||||
let end_field = max(*anchor_line, *current_field_idx);
|
||||
@@ -409,15 +477,25 @@ fn set_cursor_position(
|
||||
field_rect: Rect,
|
||||
text: &str,
|
||||
current_cursor_pos: usize,
|
||||
has_display_override: bool,
|
||||
_has_display_override: bool,
|
||||
) {
|
||||
let cursor_x = if has_display_override {
|
||||
field_rect.x + text.chars().count() as u16
|
||||
} else {
|
||||
field_rect.x + current_cursor_pos as u16
|
||||
};
|
||||
// Sum display widths of the first current_cursor_pos characters
|
||||
let mut cols: u16 = 0;
|
||||
for (i, ch) in text.chars().enumerate() {
|
||||
if i >= current_cursor_pos {
|
||||
break;
|
||||
}
|
||||
cols = cols.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16);
|
||||
}
|
||||
|
||||
let cursor_x = field_rect.x.saturating_add(cols);
|
||||
let cursor_y = field_rect.y;
|
||||
f.set_cursor_position((cursor_x, cursor_y));
|
||||
|
||||
// Clamp to 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
|
||||
|
||||
@@ -14,23 +14,31 @@ pub struct EditorState {
|
||||
// Mode state
|
||||
pub(crate) current_mode: AppMode,
|
||||
|
||||
// Autocomplete state
|
||||
pub(crate) autocomplete: AutocompleteUIState,
|
||||
// Suggestions dropdown state (only available with suggestions feature)
|
||||
#[cfg(feature = "suggestions")]
|
||||
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,
|
||||
|
||||
/// Computed fields state (only when computed feature is enabled)
|
||||
#[cfg(feature = "computed")]
|
||||
pub(crate) computed: Option<crate::computed::ComputedState>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "suggestions")]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutocompleteUIState {
|
||||
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>,
|
||||
pub(crate) active_query: Option<String>,
|
||||
pub(crate) completion_text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -47,15 +55,20 @@ impl EditorState {
|
||||
cursor_pos: 0,
|
||||
ideal_cursor_column: 0,
|
||||
current_mode: AppMode::Edit,
|
||||
autocomplete: AutocompleteUIState {
|
||||
#[cfg(feature = "suggestions")]
|
||||
suggestions: SuggestionsUIState {
|
||||
is_active: false,
|
||||
is_loading: false,
|
||||
selected_index: None,
|
||||
active_field: None,
|
||||
active_query: None,
|
||||
completion_text: None,
|
||||
},
|
||||
selection: SelectionState::None,
|
||||
#[cfg(feature = "validation")]
|
||||
validation: crate::validation::ValidationState::new(),
|
||||
#[cfg(feature = "computed")]
|
||||
computed: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +81,15 @@ impl EditorState {
|
||||
self.current_field
|
||||
}
|
||||
|
||||
/// Check if field is computed
|
||||
#[cfg(feature = "computed")]
|
||||
pub fn is_computed_field(&self, field_index: usize) -> bool {
|
||||
self.computed
|
||||
.as_ref()
|
||||
.map(|state| state.is_computed_field(field_index))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get current cursor position (for user's business logic)
|
||||
pub fn cursor_position(&self) -> usize {
|
||||
self.cursor_pos
|
||||
@@ -83,21 +105,23 @@ impl EditorState {
|
||||
self.current_mode
|
||||
}
|
||||
|
||||
/// Check if autocomplete is active (for user's business logic)
|
||||
pub fn is_autocomplete_active(&self) -> bool {
|
||||
self.autocomplete.is_active
|
||||
/// Check if suggestions dropdown is active (for user's business logic)
|
||||
#[cfg(feature = "suggestions")]
|
||||
pub fn is_suggestions_active(&self) -> bool {
|
||||
self.suggestions.is_active
|
||||
}
|
||||
|
||||
/// Check if autocomplete is loading (for user's business logic)
|
||||
pub fn is_autocomplete_loading(&self) -> bool {
|
||||
self.autocomplete.is_loading
|
||||
/// Check if suggestions dropdown is loading (for user's business logic)
|
||||
#[cfg(feature = "suggestions")]
|
||||
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")]
|
||||
@@ -117,7 +141,12 @@ impl EditorState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_cursor(&mut self, position: usize, max_position: usize, for_edit_mode: bool) {
|
||||
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);
|
||||
@@ -128,18 +157,26 @@ impl EditorState {
|
||||
self.ideal_cursor_column = self.cursor_pos;
|
||||
}
|
||||
|
||||
pub(crate) fn activate_autocomplete(&mut self, field_index: usize) {
|
||||
self.autocomplete.is_active = true;
|
||||
self.autocomplete.is_loading = true;
|
||||
self.autocomplete.active_field = Some(field_index);
|
||||
self.autocomplete.selected_index = None;
|
||||
/// Explicitly open suggestions — should only be called on Tab
|
||||
#[cfg(feature = "suggestions")]
|
||||
pub(crate) fn open_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.active_query = None;
|
||||
self.suggestions.selected_index = None;
|
||||
self.suggestions.completion_text = None;
|
||||
}
|
||||
|
||||
pub(crate) fn deactivate_autocomplete(&mut self) {
|
||||
self.autocomplete.is_active = false;
|
||||
self.autocomplete.is_loading = false;
|
||||
self.autocomplete.active_field = None;
|
||||
self.autocomplete.selected_index = None;
|
||||
/// Explicitly close suggestions — should be called on Esc or field change
|
||||
#[cfg(feature = "suggestions")]
|
||||
pub(crate) fn close_suggestions(&mut self) {
|
||||
self.suggestions.is_active = false;
|
||||
self.suggestions.is_loading = false;
|
||||
self.suggestions.active_field = None;
|
||||
self.suggestions.active_query = None;
|
||||
self.suggestions.selected_index = None;
|
||||
self.suggestions.completion_text = None;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ pub trait CanvasTheme {
|
||||
fn highlight(&self) -> Color;
|
||||
fn highlight_bg(&self) -> Color;
|
||||
fn warning(&self) -> Color;
|
||||
fn suggestion_gray(&self) -> Color;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,4 +48,7 @@ impl CanvasTheme for DefaultCanvasTheme {
|
||||
fn warning(&self) -> Color {
|
||||
Color::Red
|
||||
}
|
||||
fn suggestion_gray(&self) -> Color {
|
||||
Color::DarkGray
|
||||
}
|
||||
}
|
||||
|
||||
5
canvas/src/computed/mod.rs
Normal file
5
canvas/src/computed/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod provider;
|
||||
pub mod state;
|
||||
|
||||
pub use provider::{ComputedContext, ComputedProvider};
|
||||
pub use state::ComputedState;
|
||||
31
canvas/src/computed/provider.rs
Normal file
31
canvas/src/computed/provider.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
// ================================================================================================
|
||||
// COMPUTED FIELDS - Provider and Context
|
||||
// ================================================================================================
|
||||
|
||||
/// Context information provided to computed field calculations
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ComputedContext<'a> {
|
||||
/// All field values in the form (index -> value)
|
||||
pub field_values: &'a [&'a str],
|
||||
/// The field index being computed
|
||||
pub target_field: usize,
|
||||
/// Current field that user is editing (if any)
|
||||
pub current_field: Option<usize>,
|
||||
}
|
||||
|
||||
/// User implements this to provide computed field logic
|
||||
pub trait ComputedProvider {
|
||||
/// Compute value for a field based on other field values.
|
||||
/// Called automatically when any field changes.
|
||||
fn compute_field(&mut self, context: ComputedContext) -> String;
|
||||
|
||||
/// Check if this provider handles the given field.
|
||||
fn handles_field(&self, field_index: usize) -> bool;
|
||||
|
||||
/// Get list of field dependencies for optimization.
|
||||
/// If field A depends on fields [1, 3], only recompute A when fields 1 or 3 change.
|
||||
/// Default: depend on all fields (always recompute) with a reasonable upper bound.
|
||||
fn field_dependencies(&self, _field_index: usize) -> Vec<usize> {
|
||||
(0..100).collect()
|
||||
}
|
||||
}
|
||||
88
canvas/src/computed/state.rs
Normal file
88
canvas/src/computed/state.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
/* file: canvas/src/computed/state.rs */
|
||||
/*
|
||||
Add computed state module file implementing caching and dependencies
|
||||
*/
|
||||
|
||||
// ================================================================================================
|
||||
// COMPUTED FIELDS - State: caching and dependencies
|
||||
// ================================================================================================
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// Internal state for computed field management
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ComputedState {
|
||||
/// Cached computed values (field_index -> computed_value)
|
||||
computed_values: HashMap<usize, String>,
|
||||
/// Field dependency graph (field_index -> depends_on_fields)
|
||||
dependencies: HashMap<usize, Vec<usize>>,
|
||||
/// Track which fields are computed (display-only)
|
||||
computed_fields: HashSet<usize>,
|
||||
}
|
||||
|
||||
impl ComputedState {
|
||||
/// Create a new, empty computed state
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
computed_values: HashMap::new(),
|
||||
dependencies: HashMap::new(),
|
||||
computed_fields: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a field as computed with its dependencies
|
||||
///
|
||||
/// - `field_index`: the field that is computed (display-only)
|
||||
/// - `dependencies`: indices of fields this computed field depends on
|
||||
pub fn register_computed_field(&mut self, field_index: usize, mut dependencies: Vec<usize>) {
|
||||
// Deduplicate dependencies to keep graph lean
|
||||
dependencies.sort_unstable();
|
||||
dependencies.dedup();
|
||||
|
||||
self.computed_fields.insert(field_index);
|
||||
self.dependencies.insert(field_index, dependencies);
|
||||
}
|
||||
|
||||
/// Check if a field is computed (read-only, skip editing/navigation)
|
||||
pub fn is_computed_field(&self, field_index: usize) -> bool {
|
||||
self.computed_fields.contains(&field_index)
|
||||
}
|
||||
|
||||
/// Get cached computed value for a field, if available
|
||||
pub fn get_computed_value(&self, field_index: usize) -> Option<&String> {
|
||||
self.computed_values.get(&field_index)
|
||||
}
|
||||
|
||||
/// Update cached computed value for a field
|
||||
pub fn set_computed_value(&mut self, field_index: usize, value: String) {
|
||||
self.computed_values.insert(field_index, value);
|
||||
}
|
||||
|
||||
/// Get fields that should be recomputed when `changed_field` changed
|
||||
///
|
||||
/// This scans the dependency graph and returns all computed fields
|
||||
/// that list `changed_field` as a dependency.
|
||||
pub fn fields_to_recompute(&self, changed_field: usize) -> Vec<usize> {
|
||||
self.dependencies
|
||||
.iter()
|
||||
.filter_map(|(field, deps)| {
|
||||
if deps.contains(&changed_field) {
|
||||
Some(*field)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Iterator over all computed field indices
|
||||
pub fn computed_fields(&self) -> impl Iterator<Item = usize> + '_ {
|
||||
self.computed_fields.iter().copied()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ComputedState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
// src/data_provider.rs
|
||||
//! Simplified user interface - only business data, no UI state
|
||||
|
||||
#[cfg(feature = "suggestions")]
|
||||
use anyhow::Result;
|
||||
#[cfg(feature = "suggestions")]
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// User implements this - only business data, no UI state
|
||||
@@ -18,8 +20,8 @@ pub trait DataProvider {
|
||||
/// Set field value (library calls this when text changes)
|
||||
fn set_field_value(&mut self, index: usize, value: String);
|
||||
|
||||
/// Check if field supports autocomplete (optional)
|
||||
fn supports_autocomplete(&self, _field_index: usize) -> bool {
|
||||
/// Check if field supports suggestions (optional)
|
||||
fn supports_suggestions(&self, _field_index: usize) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -27,23 +29,39 @@ pub trait DataProvider {
|
||||
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
|
||||
}
|
||||
|
||||
/// Check if field is computed (display-only, skip in navigation)
|
||||
/// Default: not computed
|
||||
#[cfg(feature = "computed")]
|
||||
fn is_computed_field(&self, _field_index: usize) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Get computed field value if this is a computed field.
|
||||
/// Returns None for regular fields. Default: not computed.
|
||||
#[cfg(feature = "computed")]
|
||||
fn computed_field_value(&self, _field_index: usize) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional: User implements this for autocomplete data
|
||||
/// Optional: User implements this for suggestions data
|
||||
#[cfg(feature = "suggestions")]
|
||||
#[async_trait]
|
||||
pub trait AutocompleteProvider {
|
||||
/// Fetch autocomplete suggestions (user's business logic)
|
||||
pub trait SuggestionsProvider {
|
||||
/// Fetch suggestions (user's business logic)
|
||||
async fn fetch_suggestions(&mut self, field_index: usize, query: &str)
|
||||
-> Result<Vec<SuggestionItem>>;
|
||||
}
|
||||
|
||||
#[cfg(feature = "suggestions")]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SuggestionItem {
|
||||
pub display_text: String,
|
||||
|
||||
@@ -1,950 +0,0 @@
|
||||
// src/editor.rs
|
||||
//! Main API for the canvas library - FormEditor with library-owned state
|
||||
|
||||
#[cfg(feature = "cursor-style")]
|
||||
use crate::canvas::CursorManager;
|
||||
#[cfg(feature = "cursor-style")]
|
||||
use crossterm;
|
||||
|
||||
use anyhow::Result;
|
||||
use crate::canvas::state::EditorState;
|
||||
use crate::data_provider::{DataProvider, AutocompleteProvider, SuggestionItem};
|
||||
use crate::canvas::modes::AppMode;
|
||||
use crate::canvas::state::SelectionState;
|
||||
|
||||
/// Main editor that manages UI state internally and delegates data to user
|
||||
pub struct FormEditor<D: DataProvider> {
|
||||
// Library owns all UI state
|
||||
ui_state: EditorState,
|
||||
|
||||
// User owns business data
|
||||
data_provider: D,
|
||||
|
||||
// Autocomplete suggestions (library manages UI, user provides data)
|
||||
pub(crate) suggestions: Vec<SuggestionItem>,
|
||||
}
|
||||
|
||||
impl<D: DataProvider> FormEditor<D> {
|
||||
pub fn new(data_provider: D) -> Self {
|
||||
let mut editor = Self {
|
||||
ui_state: EditorState::new(),
|
||||
data_provider,
|
||||
suggestions: Vec::new(),
|
||||
};
|
||||
|
||||
// Initialize validation configurations if validation feature is enabled
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
editor.initialize_validation();
|
||||
}
|
||||
|
||||
editor
|
||||
}
|
||||
|
||||
/// Initialize validation configurations from data provider
|
||||
#[cfg(feature = "validation")]
|
||||
fn initialize_validation(&mut self) {
|
||||
let field_count = self.data_provider.field_count();
|
||||
for field_index in 0..field_count {
|
||||
if let Some(config) = self.data_provider.validation_config(field_index) {
|
||||
self.ui_state.validation.set_field_config(field_index, config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// READ-ONLY ACCESS: User can fetch UI state
|
||||
// ===================================================================
|
||||
|
||||
/// Get current field index (for user's compatibility)
|
||||
pub fn current_field(&self) -> usize {
|
||||
self.ui_state.current_field()
|
||||
}
|
||||
|
||||
/// Get current cursor position (for user's compatibility)
|
||||
pub fn cursor_position(&self) -> usize {
|
||||
self.ui_state.cursor_position()
|
||||
}
|
||||
|
||||
/// Get current mode (for user's mode-dependent logic)
|
||||
pub fn mode(&self) -> AppMode {
|
||||
self.ui_state.mode()
|
||||
}
|
||||
|
||||
/// Check if autocomplete is active (for user's logic)
|
||||
pub fn is_autocomplete_active(&self) -> bool {
|
||||
self.ui_state.is_autocomplete_active()
|
||||
}
|
||||
|
||||
/// Get current field text (convenience method)
|
||||
pub fn current_text(&self) -> &str {
|
||||
let field_index = self.ui_state.current_field;
|
||||
if field_index < self.data_provider.field_count() {
|
||||
self.data_provider.field_value(field_index)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
/// Get reference to UI state for rendering
|
||||
pub fn ui_state(&self) -> &EditorState {
|
||||
&self.ui_state
|
||||
}
|
||||
|
||||
/// Get reference to data provider for rendering
|
||||
pub fn data_provider(&self) -> &D {
|
||||
&self.data_provider
|
||||
}
|
||||
|
||||
/// Get autocomplete suggestions for rendering (read-only)
|
||||
pub fn suggestions(&self) -> &[SuggestionItem] {
|
||||
&self.suggestions
|
||||
}
|
||||
|
||||
/// 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.ui_state.validation_state()
|
||||
}
|
||||
|
||||
/// Get validation result for current field
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn current_field_validation(&self) -> Option<&crate::validation::ValidationResult> {
|
||||
self.ui_state.validation.get_field_result(self.ui_state.current_field)
|
||||
}
|
||||
|
||||
/// Get validation result for specific field
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn field_validation(&self, field_index: usize) -> Option<&crate::validation::ValidationResult> {
|
||||
self.ui_state.validation.get_field_result(field_index)
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// SYNC OPERATIONS: No async needed for basic editing
|
||||
// ===================================================================
|
||||
|
||||
/// Handle character insertion
|
||||
pub fn insert_char(&mut self, ch: char) -> Result<()> {
|
||||
if self.ui_state.current_mode != AppMode::Edit {
|
||||
return Ok(()); // Ignore in non-edit modes
|
||||
}
|
||||
|
||||
let field_index = self.ui_state.current_field;
|
||||
let cursor_pos = self.ui_state.cursor_pos;
|
||||
|
||||
// Get current text from user
|
||||
let current_text = self.data_provider.field_value(field_index);
|
||||
|
||||
// Validate character insertion if validation is enabled
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let validation_result = self.ui_state.validation.validate_char_insertion(
|
||||
field_index,
|
||||
current_text,
|
||||
cursor_pos,
|
||||
ch,
|
||||
);
|
||||
|
||||
// Reject input if validation failed with error
|
||||
if !validation_result.is_acceptable() {
|
||||
// Log validation failure for debugging
|
||||
tracing::debug!(
|
||||
"Character insertion rejected for field {}: {:?}",
|
||||
field_index,
|
||||
validation_result
|
||||
);
|
||||
return Ok(()); // Silently reject invalid input
|
||||
}
|
||||
}
|
||||
|
||||
// Insert character
|
||||
let mut new_text = current_text.to_string();
|
||||
new_text.insert(cursor_pos, ch);
|
||||
|
||||
// Update user's data
|
||||
self.data_provider.set_field_value(field_index, new_text);
|
||||
|
||||
// Update library's UI state
|
||||
self.ui_state.cursor_pos += 1;
|
||||
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle cursor movement
|
||||
pub fn move_left(&mut self) {
|
||||
if self.ui_state.cursor_pos > 0 {
|
||||
self.ui_state.cursor_pos -= 1;
|
||||
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_right(&mut self) {
|
||||
let current_text = self.current_text();
|
||||
let max_pos = if self.ui_state.current_mode == AppMode::Edit {
|
||||
current_text.len() // Edit mode: can go past end
|
||||
} else {
|
||||
current_text.len().saturating_sub(1) // ReadOnly: stay in bounds
|
||||
};
|
||||
|
||||
if self.ui_state.cursor_pos < max_pos {
|
||||
self.ui_state.cursor_pos += 1;
|
||||
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle field navigation
|
||||
pub fn move_to_next_field(&mut self) {
|
||||
let field_count = self.data_provider.field_count();
|
||||
let next_field = (self.ui_state.current_field + 1) % field_count;
|
||||
|
||||
// Validate current field content before moving if validation is enabled
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let current_text = self.current_text().to_string(); // Convert to String to avoid borrow conflicts
|
||||
let _validation_result = self.ui_state.validation.validate_field_content(
|
||||
self.ui_state.current_field,
|
||||
¤t_text,
|
||||
);
|
||||
// Note: We don't prevent field switching on validation failure,
|
||||
// just record the validation state
|
||||
}
|
||||
|
||||
self.ui_state.move_to_field(next_field, field_count);
|
||||
|
||||
// Clamp cursor to new field
|
||||
let current_text = self.current_text();
|
||||
let max_pos = current_text.len();
|
||||
self.ui_state.set_cursor(
|
||||
self.ui_state.ideal_cursor_column,
|
||||
max_pos,
|
||||
self.ui_state.current_mode == AppMode::Edit
|
||||
);
|
||||
}
|
||||
|
||||
/// Change mode (for vim compatibility)
|
||||
pub fn set_mode(&mut self, mode: AppMode) {
|
||||
match (self.ui_state.current_mode, mode) {
|
||||
// Entering highlight mode from read-only
|
||||
(AppMode::ReadOnly, AppMode::Highlight) => {
|
||||
self.enter_highlight_mode();
|
||||
}
|
||||
// Exiting highlight mode
|
||||
(AppMode::Highlight, AppMode::ReadOnly) => {
|
||||
self.exit_highlight_mode();
|
||||
}
|
||||
// Other transitions
|
||||
(_, new_mode) => {
|
||||
self.ui_state.current_mode = new_mode;
|
||||
if new_mode != AppMode::Highlight {
|
||||
self.ui_state.selection = SelectionState::None;
|
||||
}
|
||||
|
||||
#[cfg(feature = "cursor-style")]
|
||||
{
|
||||
let _ = CursorManager::update_for_mode(new_mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter edit mode with cursor positioned for append (vim 'a' command)
|
||||
pub fn enter_append_mode(&mut self) {
|
||||
let current_text = self.current_text();
|
||||
|
||||
// Calculate append position: always move right, even at line end
|
||||
let append_pos = if current_text.is_empty() {
|
||||
0
|
||||
} else {
|
||||
(self.ui_state.cursor_pos + 1).min(current_text.len())
|
||||
};
|
||||
|
||||
// Set cursor position for append
|
||||
self.ui_state.cursor_pos = append_pos;
|
||||
self.ui_state.ideal_cursor_column = append_pos;
|
||||
|
||||
// Enter edit mode (which will update cursor style)
|
||||
self.set_mode(AppMode::Edit);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// VALIDATION METHODS (only available with validation feature)
|
||||
// ===================================================================
|
||||
|
||||
/// Enable or disable validation
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn set_validation_enabled(&mut self, enabled: bool) {
|
||||
self.ui_state.validation.set_enabled(enabled);
|
||||
}
|
||||
|
||||
/// Check if validation is enabled
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn is_validation_enabled(&self) -> bool {
|
||||
self.ui_state.validation.is_enabled()
|
||||
}
|
||||
|
||||
/// Set validation configuration for a specific field
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn set_field_validation(&mut self, field_index: usize, config: crate::validation::ValidationConfig) {
|
||||
self.ui_state.validation.set_field_config(field_index, config);
|
||||
}
|
||||
|
||||
/// Remove validation configuration for a specific field
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn remove_field_validation(&mut self, field_index: usize) {
|
||||
self.ui_state.validation.remove_field_config(field_index);
|
||||
}
|
||||
|
||||
/// Manually validate current field content
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn validate_current_field(&mut self) -> crate::validation::ValidationResult {
|
||||
let field_index = self.ui_state.current_field;
|
||||
let current_text = self.current_text().to_string();
|
||||
self.ui_state.validation.validate_field_content(field_index, ¤t_text)
|
||||
}
|
||||
|
||||
/// Manually validate specific field content
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn validate_field(&mut self, field_index: usize) -> Option<crate::validation::ValidationResult> {
|
||||
if field_index < self.data_provider.field_count() {
|
||||
let text = self.data_provider.field_value(field_index).to_string();
|
||||
Some(self.ui_state.validation.validate_field_content(field_index, &text))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear validation results for all fields
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn clear_validation_results(&mut self) {
|
||||
self.ui_state.validation.clear_all_results();
|
||||
}
|
||||
|
||||
/// Get validation summary for all fields
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn validation_summary(&self) -> crate::validation::ValidationSummary {
|
||||
self.ui_state.validation.summary()
|
||||
}
|
||||
|
||||
/// Check if field switching is allowed from current field
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn can_switch_fields(&self) -> bool {
|
||||
let current_text = self.current_text();
|
||||
self.ui_state.validation.allows_field_switch(self.ui_state.current_field, current_text)
|
||||
}
|
||||
|
||||
/// Get reason why field switching is blocked (if any)
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn field_switch_block_reason(&self) -> Option<String> {
|
||||
let current_text = self.current_text();
|
||||
self.ui_state.validation.field_switch_block_reason(self.ui_state.current_field, current_text)
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ASYNC OPERATIONS: Only autocomplete needs async
|
||||
// ===================================================================
|
||||
|
||||
/// Trigger autocomplete (async because it fetches data)
|
||||
pub async fn trigger_autocomplete<A>(&mut self, provider: &mut A) -> Result<()>
|
||||
where
|
||||
A: AutocompleteProvider,
|
||||
{
|
||||
let field_index = self.ui_state.current_field;
|
||||
|
||||
if !self.data_provider.supports_autocomplete(field_index) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Activate autocomplete UI
|
||||
self.ui_state.activate_autocomplete(field_index);
|
||||
|
||||
// Fetch suggestions from user (no conversion needed!)
|
||||
let query = self.current_text();
|
||||
self.suggestions = provider.fetch_suggestions(field_index, query).await?;
|
||||
|
||||
// Update UI state
|
||||
self.ui_state.autocomplete.is_loading = false;
|
||||
if !self.suggestions.is_empty() {
|
||||
self.ui_state.autocomplete.selected_index = Some(0);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Navigate autocomplete suggestions
|
||||
pub fn autocomplete_next(&mut self) {
|
||||
if !self.ui_state.autocomplete.is_active || self.suggestions.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let current = self.ui_state.autocomplete.selected_index.unwrap_or(0);
|
||||
let next = (current + 1) % self.suggestions.len();
|
||||
self.ui_state.autocomplete.selected_index = Some(next);
|
||||
}
|
||||
|
||||
/// Apply selected autocomplete suggestion
|
||||
pub fn apply_autocomplete(&mut self) -> Option<String> {
|
||||
if let Some(selected_index) = self.ui_state.autocomplete.selected_index {
|
||||
if let Some(suggestion) = self.suggestions.get(selected_index).cloned() {
|
||||
let field_index = self.ui_state.current_field;
|
||||
|
||||
// Apply to user's data
|
||||
self.data_provider.set_field_value(
|
||||
field_index,
|
||||
suggestion.value_to_store.clone()
|
||||
);
|
||||
|
||||
// Update cursor position
|
||||
self.ui_state.cursor_pos = suggestion.value_to_store.len();
|
||||
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
||||
|
||||
// Close autocomplete
|
||||
self.ui_state.deactivate_autocomplete();
|
||||
self.suggestions.clear();
|
||||
|
||||
// Validate the new content if validation is enabled
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let _validation_result = self.ui_state.validation.validate_field_content(
|
||||
field_index,
|
||||
&suggestion.value_to_store,
|
||||
);
|
||||
}
|
||||
|
||||
return Some(suggestion.display_text);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// MOVEMENT METHODS (keeping existing implementations)
|
||||
// ===================================================================
|
||||
|
||||
/// Move to previous field (vim k / up arrow)
|
||||
pub fn move_up(&mut self) -> Result<()> {
|
||||
let field_count = self.data_provider.field_count();
|
||||
if field_count == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check if field switching is allowed (minimum character enforcement)
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let current_text = self.current_text();
|
||||
if !self.ui_state.validation.allows_field_switch(self.ui_state.current_field, current_text) {
|
||||
if let Some(reason) = self.ui_state.validation.field_switch_block_reason(self.ui_state.current_field, current_text) {
|
||||
tracing::debug!("Field switch blocked: {}", reason);
|
||||
return Err(anyhow::anyhow!("Cannot switch fields: {}", reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate current field before moving
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let current_text = self.current_text().to_string(); // Convert to String to avoid borrow conflicts
|
||||
let _validation_result = self.ui_state.validation.validate_field_content(
|
||||
self.ui_state.current_field,
|
||||
¤t_text,
|
||||
);
|
||||
}
|
||||
|
||||
let current_field = self.ui_state.current_field;
|
||||
let new_field = current_field.saturating_sub(1);
|
||||
|
||||
self.ui_state.move_to_field(new_field, field_count);
|
||||
self.clamp_cursor_to_current_field();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move to next field (vim j / down arrow)
|
||||
pub fn move_down(&mut self) -> Result<()> {
|
||||
let field_count = self.data_provider.field_count();
|
||||
if field_count == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check if field switching is allowed (minimum character enforcement)
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let current_text = self.current_text();
|
||||
if !self.ui_state.validation.allows_field_switch(self.ui_state.current_field, current_text) {
|
||||
if let Some(reason) = self.ui_state.validation.field_switch_block_reason(self.ui_state.current_field, current_text) {
|
||||
tracing::debug!("Field switch blocked: {}", reason);
|
||||
return Err(anyhow::anyhow!("Cannot switch fields: {}", reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate current field before moving
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let current_text = self.current_text().to_string(); // Convert to String to avoid borrow conflicts
|
||||
let _validation_result = self.ui_state.validation.validate_field_content(
|
||||
self.ui_state.current_field,
|
||||
¤t_text,
|
||||
);
|
||||
}
|
||||
|
||||
let current_field = self.ui_state.current_field;
|
||||
let new_field = (current_field + 1).min(field_count - 1);
|
||||
|
||||
self.ui_state.move_to_field(new_field, field_count);
|
||||
self.clamp_cursor_to_current_field();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move to first field (vim gg)
|
||||
pub fn move_first_line(&mut self) {
|
||||
let field_count = self.data_provider.field_count();
|
||||
if field_count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
self.ui_state.move_to_field(0, field_count);
|
||||
self.clamp_cursor_to_current_field();
|
||||
}
|
||||
|
||||
/// Move to last field (vim G)
|
||||
pub fn move_last_line(&mut self) {
|
||||
let field_count = self.data_provider.field_count();
|
||||
if field_count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let last_field = field_count - 1;
|
||||
self.ui_state.move_to_field(last_field, field_count);
|
||||
self.clamp_cursor_to_current_field();
|
||||
}
|
||||
|
||||
/// Move to previous field (alternative to move_up)
|
||||
pub fn prev_field(&mut self) -> Result<()> {
|
||||
self.move_up()
|
||||
}
|
||||
|
||||
/// Move to next field (alternative to move_down)
|
||||
pub fn next_field(&mut self) -> Result<()> {
|
||||
self.move_down()
|
||||
}
|
||||
|
||||
/// Move to start of current field (vim 0)
|
||||
pub fn move_line_start(&mut self) {
|
||||
use crate::canvas::actions::movement::line::line_start_position;
|
||||
let new_pos = line_start_position();
|
||||
self.ui_state.cursor_pos = new_pos;
|
||||
self.ui_state.ideal_cursor_column = new_pos;
|
||||
}
|
||||
|
||||
/// Move to end of current field (vim $)
|
||||
pub fn move_line_end(&mut self) {
|
||||
use crate::canvas::actions::movement::line::line_end_position;
|
||||
let current_text = self.current_text();
|
||||
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||
|
||||
let new_pos = line_end_position(current_text, is_edit_mode);
|
||||
self.ui_state.cursor_pos = new_pos;
|
||||
self.ui_state.ideal_cursor_column = new_pos;
|
||||
}
|
||||
|
||||
/// Move to start of next word (vim w)
|
||||
pub fn move_word_next(&mut self) {
|
||||
use crate::canvas::actions::movement::word::find_next_word_start;
|
||||
let current_text = self.current_text();
|
||||
|
||||
if current_text.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let new_pos = find_next_word_start(current_text, self.ui_state.cursor_pos);
|
||||
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||
|
||||
// Clamp to valid bounds for current mode
|
||||
let final_pos = if is_edit_mode {
|
||||
new_pos.min(current_text.len())
|
||||
} else {
|
||||
new_pos.min(current_text.len().saturating_sub(1))
|
||||
};
|
||||
|
||||
self.ui_state.cursor_pos = final_pos;
|
||||
self.ui_state.ideal_cursor_column = final_pos;
|
||||
}
|
||||
|
||||
/// Move to start of previous word (vim b)
|
||||
pub fn move_word_prev(&mut self) {
|
||||
use crate::canvas::actions::movement::word::find_prev_word_start;
|
||||
let current_text = self.current_text();
|
||||
|
||||
if current_text.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let new_pos = find_prev_word_start(current_text, self.ui_state.cursor_pos);
|
||||
self.ui_state.cursor_pos = new_pos;
|
||||
self.ui_state.ideal_cursor_column = new_pos;
|
||||
}
|
||||
|
||||
/// Move to end of current/next word (vim e)
|
||||
pub fn move_word_end(&mut self) {
|
||||
use crate::canvas::actions::movement::word::find_word_end;
|
||||
let current_text = self.current_text();
|
||||
|
||||
if current_text.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let current_pos = self.ui_state.cursor_pos;
|
||||
let new_pos = find_word_end(current_text, current_pos);
|
||||
|
||||
// If we didn't move, try next word
|
||||
let final_pos = if new_pos == current_pos && current_pos + 1 < current_text.len() {
|
||||
find_word_end(current_text, current_pos + 1)
|
||||
} else {
|
||||
new_pos
|
||||
};
|
||||
|
||||
// Clamp for read-only mode
|
||||
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||
let clamped_pos = if is_edit_mode {
|
||||
final_pos.min(current_text.len())
|
||||
} else {
|
||||
final_pos.min(current_text.len().saturating_sub(1))
|
||||
};
|
||||
|
||||
self.ui_state.cursor_pos = clamped_pos;
|
||||
self.ui_state.ideal_cursor_column = clamped_pos;
|
||||
}
|
||||
|
||||
/// Move to end of previous word (vim ge)
|
||||
pub fn move_word_end_prev(&mut self) {
|
||||
use crate::canvas::actions::movement::word::find_prev_word_end;
|
||||
let current_text = self.current_text();
|
||||
|
||||
if current_text.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let new_pos = find_prev_word_end(current_text, self.ui_state.cursor_pos);
|
||||
self.ui_state.cursor_pos = new_pos;
|
||||
self.ui_state.ideal_cursor_column = new_pos;
|
||||
}
|
||||
|
||||
/// Delete character before cursor (vim x in insert mode / backspace)
|
||||
pub fn delete_backward(&mut self) -> Result<()> {
|
||||
if self.ui_state.current_mode != AppMode::Edit {
|
||||
return Ok(()); // Silently ignore in non-edit modes
|
||||
}
|
||||
|
||||
if self.ui_state.cursor_pos == 0 {
|
||||
return Ok(()); // Nothing to delete
|
||||
}
|
||||
|
||||
let field_index = self.ui_state.current_field;
|
||||
let mut current_text = self.data_provider.field_value(field_index).to_string();
|
||||
|
||||
if self.ui_state.cursor_pos <= current_text.len() {
|
||||
current_text.remove(self.ui_state.cursor_pos - 1);
|
||||
self.data_provider.set_field_value(field_index, current_text.clone());
|
||||
self.ui_state.cursor_pos -= 1;
|
||||
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
||||
|
||||
// Validate the new content if validation is enabled
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let _validation_result = self.ui_state.validation.validate_field_content(
|
||||
field_index,
|
||||
¤t_text,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete character under cursor (vim x / delete key)
|
||||
pub fn delete_forward(&mut self) -> Result<()> {
|
||||
if self.ui_state.current_mode != AppMode::Edit {
|
||||
return Ok(()); // Silently ignore in non-edit modes
|
||||
}
|
||||
|
||||
let field_index = self.ui_state.current_field;
|
||||
let mut current_text = self.data_provider.field_value(field_index).to_string();
|
||||
|
||||
if self.ui_state.cursor_pos < current_text.len() {
|
||||
current_text.remove(self.ui_state.cursor_pos);
|
||||
self.data_provider.set_field_value(field_index, current_text.clone());
|
||||
|
||||
// Validate the new content if validation is enabled
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let _validation_result = self.ui_state.validation.validate_field_content(
|
||||
field_index,
|
||||
¤t_text,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Exit edit mode to read-only mode (vim Escape)
|
||||
pub fn exit_edit_mode(&mut self) -> Result<()> {
|
||||
// Validate current field content when exiting edit mode
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let current_text = self.current_text();
|
||||
if !self.ui_state.validation.allows_field_switch(self.ui_state.current_field, current_text) {
|
||||
if let Some(reason) = self.ui_state.validation.field_switch_block_reason(self.ui_state.current_field, current_text) {
|
||||
return Err(anyhow::anyhow!("Cannot exit edit mode: {}", reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust cursor position when transitioning from edit to normal mode
|
||||
let current_text = self.current_text();
|
||||
if !current_text.is_empty() {
|
||||
// In normal mode, cursor must be ON a character, not after the last one
|
||||
let max_normal_pos = current_text.len().saturating_sub(1);
|
||||
if self.ui_state.cursor_pos > max_normal_pos {
|
||||
self.ui_state.cursor_pos = max_normal_pos;
|
||||
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
||||
}
|
||||
}
|
||||
|
||||
self.set_mode(AppMode::ReadOnly);
|
||||
// Deactivate autocomplete when exiting edit mode
|
||||
self.ui_state.deactivate_autocomplete();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enter edit mode from read-only mode (vim i/a/o)
|
||||
pub fn enter_edit_mode(&mut self) {
|
||||
self.set_mode(AppMode::Edit);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// HELPER METHODS
|
||||
// ===================================================================
|
||||
|
||||
/// Clamp cursor position to valid bounds for current field and mode
|
||||
fn clamp_cursor_to_current_field(&mut self) {
|
||||
let current_text = self.current_text();
|
||||
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||
|
||||
use crate::canvas::actions::movement::line::safe_cursor_position;
|
||||
let safe_pos = safe_cursor_position(
|
||||
current_text,
|
||||
self.ui_state.ideal_cursor_column,
|
||||
is_edit_mode
|
||||
);
|
||||
|
||||
self.ui_state.cursor_pos = safe_pos;
|
||||
}
|
||||
|
||||
|
||||
/// Set the value of the current field
|
||||
pub fn set_current_field_value(&mut self, value: String) {
|
||||
let field_index = self.ui_state.current_field;
|
||||
self.data_provider.set_field_value(field_index, value.clone());
|
||||
// Reset cursor to start of field
|
||||
self.ui_state.cursor_pos = 0;
|
||||
self.ui_state.ideal_cursor_column = 0;
|
||||
|
||||
// Validate the new content if validation is enabled
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let _validation_result = self.ui_state.validation.validate_field_content(
|
||||
field_index,
|
||||
&value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the value of a specific field by index
|
||||
pub fn set_field_value(&mut self, field_index: usize, value: String) {
|
||||
if field_index < self.data_provider.field_count() {
|
||||
self.data_provider.set_field_value(field_index, value.clone());
|
||||
// If we're modifying the current field, reset cursor
|
||||
if field_index == self.ui_state.current_field {
|
||||
self.ui_state.cursor_pos = 0;
|
||||
self.ui_state.ideal_cursor_column = 0;
|
||||
}
|
||||
|
||||
// Validate the new content if validation is enabled
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let _validation_result = self.ui_state.validation.validate_field_content(
|
||||
field_index,
|
||||
&value,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the current field (set to empty string)
|
||||
pub fn clear_current_field(&mut self) {
|
||||
self.set_current_field_value(String::new());
|
||||
}
|
||||
|
||||
/// Get mutable access to data provider (for advanced operations)
|
||||
pub fn data_provider_mut(&mut self) -> &mut D {
|
||||
&mut self.data_provider
|
||||
}
|
||||
|
||||
/// Set cursor to exact position (for vim-style movements like f, F, t, T)
|
||||
pub fn set_cursor_position(&mut self, position: usize) {
|
||||
let current_text = self.current_text();
|
||||
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||
|
||||
// Clamp to valid bounds for current mode
|
||||
let max_pos = if is_edit_mode {
|
||||
current_text.len() // Edit mode: can go past end
|
||||
} else {
|
||||
current_text.len().saturating_sub(1).max(0) // Read-only: stay within text
|
||||
};
|
||||
|
||||
let clamped_pos = position.min(max_pos);
|
||||
|
||||
// Update cursor position directly
|
||||
self.ui_state.cursor_pos = clamped_pos;
|
||||
self.ui_state.ideal_cursor_column = clamped_pos;
|
||||
}
|
||||
|
||||
/// Get cursor position for display (respects mode-specific positioning rules)
|
||||
pub fn display_cursor_position(&self) -> usize {
|
||||
let current_text = self.current_text();
|
||||
|
||||
match self.ui_state.current_mode {
|
||||
AppMode::Edit => {
|
||||
// Edit mode: cursor can be past end of text
|
||||
self.ui_state.cursor_pos.min(current_text.len())
|
||||
}
|
||||
_ => {
|
||||
// Normal/other modes: cursor must be on a character
|
||||
if current_text.is_empty() {
|
||||
0
|
||||
} else {
|
||||
self.ui_state.cursor_pos.min(current_text.len().saturating_sub(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cleanup cursor style (call this when shutting down)
|
||||
pub fn cleanup_cursor(&self) -> std::io::Result<()> {
|
||||
#[cfg(feature = "cursor-style")]
|
||||
{
|
||||
crate::canvas::CursorManager::reset()
|
||||
}
|
||||
#[cfg(not(feature = "cursor-style"))]
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ===================================================================
|
||||
// HIGHLIGHT MODE
|
||||
// ===================================================================
|
||||
|
||||
/// Enter highlight mode (visual mode)
|
||||
pub fn enter_highlight_mode(&mut self) {
|
||||
if self.ui_state.current_mode == AppMode::ReadOnly {
|
||||
self.ui_state.current_mode = AppMode::Highlight;
|
||||
self.ui_state.selection = SelectionState::Characterwise {
|
||||
anchor: (self.ui_state.current_field, self.ui_state.cursor_pos),
|
||||
};
|
||||
|
||||
#[cfg(feature = "cursor-style")]
|
||||
{
|
||||
let _ = CursorManager::update_for_mode(AppMode::Highlight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter highlight line mode (visual line mode)
|
||||
pub fn enter_highlight_line_mode(&mut self) {
|
||||
if self.ui_state.current_mode == AppMode::ReadOnly {
|
||||
self.ui_state.current_mode = AppMode::Highlight;
|
||||
self.ui_state.selection = SelectionState::Linewise {
|
||||
anchor_field: self.ui_state.current_field,
|
||||
};
|
||||
|
||||
#[cfg(feature = "cursor-style")]
|
||||
{
|
||||
let _ = CursorManager::update_for_mode(AppMode::Highlight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exit highlight mode back to read-only
|
||||
pub fn exit_highlight_mode(&mut self) {
|
||||
if self.ui_state.current_mode == AppMode::Highlight {
|
||||
self.ui_state.current_mode = AppMode::ReadOnly;
|
||||
self.ui_state.selection = SelectionState::None;
|
||||
|
||||
#[cfg(feature = "cursor-style")]
|
||||
{
|
||||
let _ = CursorManager::update_for_mode(AppMode::ReadOnly);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if currently in highlight mode
|
||||
pub fn is_highlight_mode(&self) -> bool {
|
||||
self.ui_state.current_mode == AppMode::Highlight
|
||||
}
|
||||
|
||||
/// Get current selection state
|
||||
pub fn selection_state(&self) -> &SelectionState {
|
||||
&self.ui_state.selection
|
||||
}
|
||||
|
||||
/// Enhanced movement methods that update selection in highlight mode
|
||||
pub fn move_left_with_selection(&mut self) {
|
||||
self.move_left();
|
||||
// Selection anchor stays in place, cursor position updates automatically
|
||||
}
|
||||
|
||||
pub fn move_right_with_selection(&mut self) {
|
||||
self.move_right();
|
||||
// Selection anchor stays in place, cursor position updates automatically
|
||||
}
|
||||
|
||||
pub fn move_up_with_selection(&mut self) {
|
||||
self.move_up();
|
||||
// Selection anchor stays in place, cursor position updates automatically
|
||||
}
|
||||
|
||||
pub fn move_down_with_selection(&mut self) {
|
||||
self.move_down();
|
||||
// Selection anchor stays in place, cursor position updates automatically
|
||||
}
|
||||
|
||||
// Add similar methods for word movement, line movement, etc.
|
||||
pub fn move_word_next_with_selection(&mut self) {
|
||||
self.move_word_next();
|
||||
}
|
||||
|
||||
pub fn move_word_prev_with_selection(&mut self) {
|
||||
self.move_word_prev();
|
||||
}
|
||||
|
||||
pub fn move_line_start_with_selection(&mut self) {
|
||||
self.move_line_start();
|
||||
}
|
||||
|
||||
pub fn move_line_end_with_selection(&mut self) {
|
||||
self.move_line_end();
|
||||
}
|
||||
}
|
||||
|
||||
// Add Drop implementation for automatic cleanup
|
||||
impl<D: DataProvider> Drop for FormEditor<D> {
|
||||
fn drop(&mut self) {
|
||||
// Reset cursor to default when FormEditor is dropped
|
||||
let _ = self.cleanup_cursor();
|
||||
}
|
||||
}
|
||||
111
canvas/src/editor/computed_helpers.rs
Normal file
111
canvas/src/editor/computed_helpers.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
// src/editor/computed_helpers.rs
|
||||
|
||||
use crate::computed::{ComputedContext, ComputedProvider, ComputedState};
|
||||
use crate::editor::FormEditor;
|
||||
use crate::DataProvider;
|
||||
|
||||
impl<D: DataProvider> FormEditor<D> {
|
||||
#[cfg(feature = "computed")]
|
||||
pub fn set_computed_provider<C>(&mut self, mut provider: C)
|
||||
where
|
||||
C: ComputedProvider,
|
||||
{
|
||||
self.ui_state.computed = Some(ComputedState::new());
|
||||
|
||||
let field_count = self.data_provider.field_count();
|
||||
for field_index in 0..field_count {
|
||||
if provider.handles_field(field_index) {
|
||||
let deps = provider.field_dependencies(field_index);
|
||||
if let Some(computed_state) = &mut self.ui_state.computed {
|
||||
computed_state.register_computed_field(field_index, deps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.recompute_all_fields(&mut provider);
|
||||
}
|
||||
|
||||
#[cfg(feature = "computed")]
|
||||
pub fn recompute_fields<C>(
|
||||
&mut self,
|
||||
provider: &mut C,
|
||||
field_indices: &[usize],
|
||||
) where
|
||||
C: ComputedProvider,
|
||||
{
|
||||
if let Some(computed_state) = &mut self.ui_state.computed {
|
||||
let field_values: Vec<String> = (0..self.data_provider.field_count())
|
||||
.map(|i| {
|
||||
if computed_state.is_computed_field(i) {
|
||||
computed_state
|
||||
.get_computed_value(i)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
self.data_provider.field_value(i).to_string()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let field_refs: Vec<&str> =
|
||||
field_values.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
for &field_index in field_indices {
|
||||
if provider.handles_field(field_index) {
|
||||
let context = ComputedContext {
|
||||
field_values: &field_refs,
|
||||
target_field: field_index,
|
||||
current_field: Some(self.ui_state.current_field),
|
||||
};
|
||||
|
||||
let computed_value = provider.compute_field(context);
|
||||
computed_state.set_computed_value(
|
||||
field_index,
|
||||
computed_value,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "computed")]
|
||||
pub fn recompute_all_fields<C>(&mut self, provider: &mut C)
|
||||
where
|
||||
C: ComputedProvider,
|
||||
{
|
||||
if let Some(computed_state) = &self.ui_state.computed {
|
||||
let computed_fields: Vec<usize> =
|
||||
computed_state.computed_fields().collect();
|
||||
self.recompute_fields(provider, &computed_fields);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "computed")]
|
||||
pub fn on_field_changed<C>(
|
||||
&mut self,
|
||||
provider: &mut C,
|
||||
changed_field: usize,
|
||||
) where
|
||||
C: ComputedProvider,
|
||||
{
|
||||
if let Some(computed_state) = &self.ui_state.computed {
|
||||
let fields_to_update =
|
||||
computed_state.fields_to_recompute(changed_field);
|
||||
if !fields_to_update.is_empty() {
|
||||
self.recompute_fields(provider, &fields_to_update);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "computed")]
|
||||
pub fn effective_field_value(&self, field_index: usize) -> String {
|
||||
if let Some(computed_state) = &self.ui_state.computed {
|
||||
if let Some(computed_value) =
|
||||
computed_state.get_computed_value(field_index)
|
||||
{
|
||||
return computed_value.clone();
|
||||
}
|
||||
}
|
||||
self.data_provider.field_value(field_index).to_string()
|
||||
}
|
||||
}
|
||||
122
canvas/src/editor/core.rs
Normal file
122
canvas/src/editor/core.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
// src/editor/core.rs
|
||||
|
||||
#[cfg(feature = "cursor-style")]
|
||||
use crate::canvas::CursorManager;
|
||||
|
||||
use crate::canvas::modes::AppMode;
|
||||
use crate::canvas::state::EditorState;
|
||||
use crate::DataProvider;
|
||||
#[cfg(feature = "suggestions")]
|
||||
use crate::SuggestionItem;
|
||||
|
||||
pub struct FormEditor<D: DataProvider> {
|
||||
pub(crate) ui_state: EditorState,
|
||||
pub(crate) data_provider: D,
|
||||
#[cfg(feature = "suggestions")]
|
||||
pub(crate) suggestions: Vec<SuggestionItem>,
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
pub(crate) external_validation_callback: Option<
|
||||
Box<
|
||||
dyn FnMut(usize, &str) -> crate::validation::ExternalValidationState
|
||||
+ Send
|
||||
+ Sync,
|
||||
>,
|
||||
>,
|
||||
}
|
||||
|
||||
impl<D: DataProvider> FormEditor<D> {
|
||||
// Make helpers visible to sibling modules in this crate
|
||||
pub(crate) fn char_to_byte_index(s: &str, char_idx: usize) -> usize {
|
||||
s.char_indices()
|
||||
.nth(char_idx)
|
||||
.map(|(byte_idx, _)| byte_idx)
|
||||
.unwrap_or_else(|| s.len())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn byte_to_char_index(s: &str, byte_idx: usize) -> usize {
|
||||
s[..byte_idx].chars().count()
|
||||
}
|
||||
|
||||
pub fn new(data_provider: D) -> Self {
|
||||
let editor = Self {
|
||||
ui_state: EditorState::new(),
|
||||
data_provider,
|
||||
#[cfg(feature = "suggestions")]
|
||||
suggestions: Vec::new(),
|
||||
#[cfg(feature = "validation")]
|
||||
external_validation_callback: None,
|
||||
};
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let mut editor = editor;
|
||||
editor.initialize_validation();
|
||||
editor
|
||||
}
|
||||
#[cfg(not(feature = "validation"))]
|
||||
{
|
||||
editor
|
||||
}
|
||||
}
|
||||
|
||||
// Library-internal, used by multiple modules
|
||||
pub(crate) fn current_text(&self) -> &str {
|
||||
let field_index = self.ui_state.current_field;
|
||||
if field_index < self.data_provider.field_count() {
|
||||
self.data_provider.field_value(field_index)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
// Read-only getters
|
||||
pub fn current_field(&self) -> usize {
|
||||
self.ui_state.current_field()
|
||||
}
|
||||
pub fn cursor_position(&self) -> usize {
|
||||
self.ui_state.cursor_position()
|
||||
}
|
||||
pub fn mode(&self) -> AppMode {
|
||||
self.ui_state.mode()
|
||||
}
|
||||
#[cfg(feature = "suggestions")]
|
||||
pub fn is_suggestions_active(&self) -> bool {
|
||||
self.ui_state.is_suggestions_active()
|
||||
}
|
||||
pub fn ui_state(&self) -> &EditorState {
|
||||
&self.ui_state
|
||||
}
|
||||
pub fn data_provider(&self) -> &D {
|
||||
&self.data_provider
|
||||
}
|
||||
pub fn data_provider_mut(&mut self) -> &mut D {
|
||||
&mut self.data_provider
|
||||
}
|
||||
#[cfg(feature = "suggestions")]
|
||||
pub fn suggestions(&self) -> &[SuggestionItem] {
|
||||
&self.suggestions
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn validation_state(&self) -> &crate::validation::ValidationState {
|
||||
self.ui_state.validation_state()
|
||||
}
|
||||
|
||||
// Cursor cleanup
|
||||
#[cfg(feature = "cursor-style")]
|
||||
pub fn cleanup_cursor(&self) -> std::io::Result<()> {
|
||||
CursorManager::reset()
|
||||
}
|
||||
#[cfg(not(feature = "cursor-style"))]
|
||||
pub fn cleanup_cursor(&self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<D: DataProvider> Drop for FormEditor<D> {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.cleanup_cursor();
|
||||
}
|
||||
}
|
||||
123
canvas/src/editor/display.rs
Normal file
123
canvas/src/editor/display.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
// src/editor/display.rs
|
||||
|
||||
use crate::canvas::modes::AppMode;
|
||||
use crate::editor::FormEditor;
|
||||
use crate::DataProvider;
|
||||
|
||||
impl<D: DataProvider> FormEditor<D> {
|
||||
/// Get current field text for display.
|
||||
/// Policies documented in original file.
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn current_display_text(&self) -> String {
|
||||
let field_index = self.ui_state.current_field;
|
||||
let raw = if field_index < self.data_provider.field_count() {
|
||||
self.data_provider.field_value(field_index)
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
|
||||
if cfg.custom_formatter.is_none() {
|
||||
if let Some(mask) = &cfg.display_mask {
|
||||
return mask.apply_to_display(raw);
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.custom_formatter.is_some() {
|
||||
if matches!(self.ui_state.current_mode, AppMode::Edit) {
|
||||
return raw.to_string();
|
||||
}
|
||||
if let Some((formatted, _mapper, _warning)) =
|
||||
cfg.run_custom_formatter(raw)
|
||||
{
|
||||
return formatted;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(mask) = &cfg.display_mask {
|
||||
return mask.apply_to_display(raw);
|
||||
}
|
||||
}
|
||||
|
||||
raw.to_string()
|
||||
}
|
||||
|
||||
/// Get effective display text for any field index (Feature 4 + masks).
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn display_text_for_field(&self, field_index: usize) -> String {
|
||||
let raw = if field_index < self.data_provider.field_count() {
|
||||
self.data_provider.field_value(field_index)
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
|
||||
if cfg.custom_formatter.is_none() {
|
||||
if let Some(mask) = &cfg.display_mask {
|
||||
return mask.apply_to_display(raw);
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.custom_formatter.is_some() {
|
||||
if field_index == self.ui_state.current_field
|
||||
&& matches!(self.ui_state.current_mode, AppMode::Edit)
|
||||
{
|
||||
return raw.to_string();
|
||||
}
|
||||
if let Some((formatted, _mapper, _warning)) =
|
||||
cfg.run_custom_formatter(raw)
|
||||
{
|
||||
return formatted;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(mask) = &cfg.display_mask {
|
||||
return mask.apply_to_display(raw);
|
||||
}
|
||||
}
|
||||
|
||||
raw.to_string()
|
||||
}
|
||||
|
||||
/// Map raw cursor to display position (formatter/mask aware).
|
||||
pub fn display_cursor_position(&self) -> usize {
|
||||
let current_text = self.current_text();
|
||||
let char_count = current_text.chars().count();
|
||||
|
||||
let raw_pos = match self.ui_state.current_mode {
|
||||
AppMode::Edit => self.ui_state.cursor_pos.min(char_count),
|
||||
_ => {
|
||||
if char_count == 0 {
|
||||
0
|
||||
} else {
|
||||
self.ui_state
|
||||
.cursor_pos
|
||||
.min(char_count.saturating_sub(1))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let field_index = self.ui_state.current_field;
|
||||
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
|
||||
if !matches!(self.ui_state.current_mode, AppMode::Edit) {
|
||||
if let Some((formatted, mapper, _)) =
|
||||
cfg.run_custom_formatter(current_text)
|
||||
{
|
||||
return mapper.raw_to_formatted(
|
||||
current_text,
|
||||
&formatted,
|
||||
raw_pos,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(mask) = &cfg.display_mask {
|
||||
return mask.raw_pos_to_display_pos(raw_pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
raw_pos
|
||||
}
|
||||
}
|
||||
348
canvas/src/editor/editing.rs
Normal file
348
canvas/src/editor/editing.rs
Normal file
@@ -0,0 +1,348 @@
|
||||
// src/editor/editing.rs
|
||||
|
||||
use crate::editor::FormEditor;
|
||||
use crate::DataProvider;
|
||||
|
||||
impl<D: DataProvider> FormEditor<D> {
|
||||
/// Open new line below (vim o)
|
||||
pub fn open_line_below(&mut self) -> anyhow::Result<()> {
|
||||
// paste the method body unchanged from editor.rs
|
||||
// (exact code from your VIM COMMANDS: o and O section)
|
||||
let field_count = self.data_provider.field_count();
|
||||
if field_count == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let next_field = (self.ui_state.current_field + 1)
|
||||
.min(field_count.saturating_sub(1));
|
||||
self.transition_to_field(next_field)?;
|
||||
self.ui_state.cursor_pos = 0;
|
||||
self.ui_state.ideal_cursor_column = 0;
|
||||
self.enter_edit_mode();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open new line above (vim O)
|
||||
pub fn open_line_above(&mut self) -> anyhow::Result<()> {
|
||||
let prev_field = self.ui_state.current_field.saturating_sub(1);
|
||||
self.transition_to_field(prev_field)?;
|
||||
self.ui_state.cursor_pos = 0;
|
||||
self.ui_state.ideal_cursor_column = 0;
|
||||
self.enter_edit_mode();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle character insertion (mask/limit-aware)
|
||||
pub fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
|
||||
// paste entire insert_char body unchanged
|
||||
if self.ui_state.current_mode != crate::canvas::modes::AppMode::Edit
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
let field_index = self.ui_state.current_field;
|
||||
#[cfg(feature = "validation")]
|
||||
let raw_cursor_pos = self.ui_state.cursor_pos;
|
||||
#[cfg(feature = "validation")]
|
||||
let current_raw_text = self.data_provider.field_value(field_index);
|
||||
|
||||
#[cfg(not(feature = "validation"))]
|
||||
let field_index = self.ui_state.current_field;
|
||||
#[cfg(not(feature = "validation"))]
|
||||
let raw_cursor_pos = self.ui_state.cursor_pos;
|
||||
#[cfg(not(feature = "validation"))]
|
||||
let current_raw_text = self.data_provider.field_value(field_index);
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
if let Some(cfg) = self.ui_state.validation.get_field_config(
|
||||
field_index,
|
||||
) {
|
||||
if let Some(mask) = &cfg.display_mask {
|
||||
let display_cursor_pos =
|
||||
mask.raw_pos_to_display_pos(raw_cursor_pos);
|
||||
|
||||
let pattern_char_len = mask.pattern().chars().count();
|
||||
if display_cursor_pos >= pattern_char_len {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !mask.is_input_position(display_cursor_pos) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let input_slots = (0..pattern_char_len)
|
||||
.filter(|&pos| mask.is_input_position(pos))
|
||||
.count();
|
||||
if current_raw_text.chars().count() >= input_slots {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let vr = self.ui_state.validation.validate_char_insertion(
|
||||
field_index,
|
||||
current_raw_text,
|
||||
raw_cursor_pos,
|
||||
ch,
|
||||
);
|
||||
if !vr.is_acceptable() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let new_raw_text = {
|
||||
let mut temp = current_raw_text.to_string();
|
||||
let byte_pos = Self::char_to_byte_index(
|
||||
current_raw_text,
|
||||
raw_cursor_pos,
|
||||
);
|
||||
temp.insert(byte_pos, ch);
|
||||
temp
|
||||
};
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
if let Some(cfg) = self.ui_state.validation.get_field_config(
|
||||
field_index,
|
||||
) {
|
||||
if let Some(limits) = &cfg.character_limits {
|
||||
if let Some(result) = limits.validate_content(&new_raw_text)
|
||||
{
|
||||
if !result.is_acceptable() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(mask) = &cfg.display_mask {
|
||||
let pattern_char_len = mask.pattern().chars().count();
|
||||
let input_slots = (0..pattern_char_len)
|
||||
.filter(|&pos| mask.is_input_position(pos))
|
||||
.count();
|
||||
if new_raw_text.chars().count() > input_slots {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.data_provider
|
||||
.set_field_value(field_index, new_raw_text.clone());
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
if let Some(cfg) = self.ui_state.validation.get_field_config(
|
||||
field_index,
|
||||
) {
|
||||
if let Some(mask) = &cfg.display_mask {
|
||||
let new_raw_pos = raw_cursor_pos + 1;
|
||||
let display_pos = mask.raw_pos_to_display_pos(new_raw_pos);
|
||||
let next_input_display =
|
||||
mask.next_input_position(display_pos);
|
||||
let next_raw_pos =
|
||||
mask.display_pos_to_raw_pos(next_input_display);
|
||||
let max_raw = new_raw_text.chars().count();
|
||||
|
||||
self.ui_state.cursor_pos = next_raw_pos.min(max_raw);
|
||||
self.ui_state.ideal_cursor_column =
|
||||
self.ui_state.cursor_pos;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.ui_state.cursor_pos = raw_cursor_pos + 1;
|
||||
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete backward (backspace)
|
||||
pub fn delete_backward(&mut self) -> anyhow::Result<()> {
|
||||
// paste entire delete_backward body unchanged
|
||||
if self.ui_state.current_mode != crate::canvas::modes::AppMode::Edit
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if self.ui_state.cursor_pos == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let field_index = self.ui_state.current_field;
|
||||
let mut current_text =
|
||||
self.data_provider.field_value(field_index).to_string();
|
||||
|
||||
let new_cursor = self.ui_state.cursor_pos.saturating_sub(1);
|
||||
|
||||
let start = Self::char_to_byte_index(
|
||||
¤t_text,
|
||||
self.ui_state.cursor_pos - 1,
|
||||
);
|
||||
let end =
|
||||
Self::char_to_byte_index(¤t_text, self.ui_state.cursor_pos);
|
||||
current_text.replace_range(start..end, "");
|
||||
self.data_provider
|
||||
.set_field_value(field_index, current_text.clone());
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
let mut target_cursor = new_cursor;
|
||||
#[cfg(not(feature = "validation"))]
|
||||
let target_cursor = new_cursor;
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
if let Some(cfg) = self.ui_state.validation.get_field_config(
|
||||
field_index,
|
||||
) {
|
||||
if let Some(mask) = &cfg.display_mask {
|
||||
let display_pos =
|
||||
mask.raw_pos_to_display_pos(new_cursor);
|
||||
if let Some(prev_input) =
|
||||
mask.prev_input_position(display_pos)
|
||||
{
|
||||
target_cursor =
|
||||
mask.display_pos_to_raw_pos(prev_input);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.ui_state.cursor_pos = target_cursor;
|
||||
self.ui_state.ideal_cursor_column = target_cursor;
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let _ = self.ui_state.validation.validate_field_content(
|
||||
field_index,
|
||||
¤t_text,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete forward (Delete key)
|
||||
pub fn delete_forward(&mut self) -> anyhow::Result<()> {
|
||||
// paste entire delete_forward body unchanged
|
||||
if self.ui_state.current_mode != crate::canvas::modes::AppMode::Edit
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let field_index = self.ui_state.current_field;
|
||||
let mut current_text =
|
||||
self.data_provider.field_value(field_index).to_string();
|
||||
|
||||
if self.ui_state.cursor_pos < current_text.chars().count() {
|
||||
let start = Self::char_to_byte_index(
|
||||
¤t_text,
|
||||
self.ui_state.cursor_pos,
|
||||
);
|
||||
let end = Self::char_to_byte_index(
|
||||
¤t_text,
|
||||
self.ui_state.cursor_pos + 1,
|
||||
);
|
||||
current_text.replace_range(start..end, "");
|
||||
self.data_provider
|
||||
.set_field_value(field_index, current_text.clone());
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
let mut target_cursor = self.ui_state.cursor_pos;
|
||||
#[cfg(not(feature = "validation"))]
|
||||
let target_cursor = self.ui_state.cursor_pos;
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
if let Some(cfg) = self.ui_state.validation.get_field_config(
|
||||
field_index,
|
||||
) {
|
||||
if let Some(mask) = &cfg.display_mask {
|
||||
let display_pos =
|
||||
mask.raw_pos_to_display_pos(
|
||||
self.ui_state.cursor_pos,
|
||||
);
|
||||
let next_input =
|
||||
mask.next_input_position(display_pos);
|
||||
target_cursor = mask
|
||||
.display_pos_to_raw_pos(next_input)
|
||||
.min(current_text.chars().count());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.ui_state.cursor_pos = target_cursor;
|
||||
self.ui_state.ideal_cursor_column = target_cursor;
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let _ = self.ui_state.validation.validate_field_content(
|
||||
field_index,
|
||||
¤t_text,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enter edit mode with cursor positioned for append (vim 'a')
|
||||
pub fn enter_append_mode(&mut self) {
|
||||
// paste body unchanged
|
||||
let current_text = self.current_text();
|
||||
|
||||
let char_len = current_text.chars().count();
|
||||
let append_pos = if current_text.is_empty() {
|
||||
0
|
||||
} else {
|
||||
(self.ui_state.cursor_pos + 1).min(char_len)
|
||||
};
|
||||
|
||||
self.ui_state.cursor_pos = append_pos;
|
||||
self.ui_state.ideal_cursor_column = append_pos;
|
||||
|
||||
self.set_mode(crate::canvas::modes::AppMode::Edit);
|
||||
}
|
||||
|
||||
/// Set current field value (validates under feature flag)
|
||||
pub fn set_current_field_value(&mut self, value: String) {
|
||||
let field_index = self.ui_state.current_field;
|
||||
self.data_provider.set_field_value(field_index, value.clone());
|
||||
self.ui_state.cursor_pos = 0;
|
||||
self.ui_state.ideal_cursor_column = 0;
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let _ = self
|
||||
.ui_state
|
||||
.validation
|
||||
.validate_field_content(field_index, &value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set specific field value by index (validates under feature flag)
|
||||
pub fn set_field_value(&mut self, field_index: usize, value: String) {
|
||||
if field_index < self.data_provider.field_count() {
|
||||
self.data_provider
|
||||
.set_field_value(field_index, value.clone());
|
||||
if field_index == self.ui_state.current_field {
|
||||
self.ui_state.cursor_pos = 0;
|
||||
self.ui_state.ideal_cursor_column = 0;
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let _ = self
|
||||
.ui_state
|
||||
.validation
|
||||
.validate_field_content(field_index, &value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the current field
|
||||
pub fn clear_current_field(&mut self) {
|
||||
self.set_current_field_value(String::new());
|
||||
}
|
||||
}
|
||||
21
canvas/src/editor/mod.rs
Normal file
21
canvas/src/editor/mod.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
// src/editor/mod.rs
|
||||
// Only module declarations and re-exports.
|
||||
|
||||
pub mod core;
|
||||
pub mod display;
|
||||
pub mod editing;
|
||||
pub mod movement;
|
||||
pub mod navigation;
|
||||
pub mod mode;
|
||||
|
||||
#[cfg(feature = "suggestions")]
|
||||
pub mod suggestions;
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
pub mod validation_helpers;
|
||||
|
||||
#[cfg(feature = "computed")]
|
||||
pub mod computed_helpers;
|
||||
|
||||
// Re-export the main type
|
||||
pub use core::FormEditor;
|
||||
222
canvas/src/editor/mode.rs
Normal file
222
canvas/src/editor/mode.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
// src/editor/mode.rs
|
||||
|
||||
#[cfg(feature = "cursor-style")]
|
||||
use crate::canvas::CursorManager;
|
||||
|
||||
use crate::canvas::modes::AppMode;
|
||||
use crate::canvas::state::SelectionState;
|
||||
use crate::editor::FormEditor;
|
||||
use crate::DataProvider;
|
||||
|
||||
impl<D: DataProvider> FormEditor<D> {
|
||||
/// Change mode (for vim compatibility)
|
||||
pub fn set_mode(&mut self, mode: AppMode) {
|
||||
match (self.ui_state.current_mode, mode) {
|
||||
(AppMode::ReadOnly, AppMode::Highlight) => {
|
||||
self.enter_highlight_mode();
|
||||
}
|
||||
(AppMode::Highlight, AppMode::ReadOnly) => {
|
||||
self.exit_highlight_mode();
|
||||
}
|
||||
(_, new_mode) => {
|
||||
self.ui_state.current_mode = new_mode;
|
||||
if new_mode != AppMode::Highlight {
|
||||
self.ui_state.selection = SelectionState::None;
|
||||
}
|
||||
|
||||
#[cfg(feature = "cursor-style")]
|
||||
{
|
||||
let _ = CursorManager::update_for_mode(new_mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exit edit mode to read-only mode (vim Escape)
|
||||
pub fn exit_edit_mode(&mut self) -> anyhow::Result<()> {
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let current_text = self.current_text();
|
||||
if !self.ui_state.validation.allows_field_switch(
|
||||
self.ui_state.current_field,
|
||||
current_text,
|
||||
) {
|
||||
if let Some(reason) = self.ui_state.validation
|
||||
.field_switch_block_reason(
|
||||
self.ui_state.current_field,
|
||||
current_text,
|
||||
)
|
||||
{
|
||||
self.ui_state
|
||||
.validation
|
||||
.set_last_switch_block(reason.clone());
|
||||
return Err(anyhow::anyhow!(
|
||||
"Cannot exit edit mode: {}",
|
||||
reason
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let current_text = self.current_text();
|
||||
if !current_text.is_empty() {
|
||||
let max_normal_pos =
|
||||
current_text.chars().count().saturating_sub(1);
|
||||
if self.ui_state.cursor_pos > max_normal_pos {
|
||||
self.ui_state.cursor_pos = max_normal_pos;
|
||||
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let field_index = self.ui_state.current_field;
|
||||
if let Some(cfg) =
|
||||
self.ui_state.validation.get_field_config(field_index)
|
||||
{
|
||||
if cfg.external_validation_enabled {
|
||||
let text = self.current_text().to_string();
|
||||
if !text.is_empty() {
|
||||
self.set_external_validation(
|
||||
field_index,
|
||||
crate::validation::ExternalValidationState::Validating,
|
||||
);
|
||||
if let Some(cb) =
|
||||
self.external_validation_callback.as_mut()
|
||||
{
|
||||
let final_state = cb(field_index, &text);
|
||||
self.set_external_validation(field_index, final_state);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.set_mode(AppMode::ReadOnly);
|
||||
#[cfg(feature = "suggestions")]
|
||||
{
|
||||
self.close_suggestions();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enter edit mode from read-only mode (vim i/a/o)
|
||||
pub fn enter_edit_mode(&mut self) {
|
||||
#[cfg(feature = "computed")]
|
||||
{
|
||||
if let Some(computed_state) = &self.ui_state.computed {
|
||||
if computed_state.is_computed_field(self.ui_state.current_field)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.set_mode(AppMode::Edit);
|
||||
}
|
||||
|
||||
// -------------------- Highlight/Visual mode -------------------------
|
||||
|
||||
pub fn enter_highlight_mode(&mut self) {
|
||||
if self.ui_state.current_mode == AppMode::ReadOnly {
|
||||
self.ui_state.current_mode = AppMode::Highlight;
|
||||
self.ui_state.selection = SelectionState::Characterwise {
|
||||
anchor: (self.ui_state.current_field, self.ui_state.cursor_pos),
|
||||
};
|
||||
|
||||
#[cfg(feature = "cursor-style")]
|
||||
{
|
||||
let _ = CursorManager::update_for_mode(AppMode::Highlight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enter_highlight_line_mode(&mut self) {
|
||||
if self.ui_state.current_mode == AppMode::ReadOnly {
|
||||
self.ui_state.current_mode = AppMode::Highlight;
|
||||
self.ui_state.selection =
|
||||
SelectionState::Linewise { anchor_field: self.ui_state.current_field };
|
||||
|
||||
#[cfg(feature = "cursor-style")]
|
||||
{
|
||||
let _ = CursorManager::update_for_mode(AppMode::Highlight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn exit_highlight_mode(&mut self) {
|
||||
if self.ui_state.current_mode == AppMode::Highlight {
|
||||
self.ui_state.current_mode = AppMode::ReadOnly;
|
||||
self.ui_state.selection = SelectionState::None;
|
||||
|
||||
#[cfg(feature = "cursor-style")]
|
||||
{
|
||||
let _ = CursorManager::update_for_mode(AppMode::ReadOnly);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_highlight_mode(&self) -> bool {
|
||||
self.ui_state.current_mode == AppMode::Highlight
|
||||
}
|
||||
|
||||
pub fn selection_state(&self) -> &SelectionState {
|
||||
&self.ui_state.selection
|
||||
}
|
||||
|
||||
// Visual-mode movements reuse existing movement methods
|
||||
pub fn move_left_with_selection(&mut self) {
|
||||
let _ = self.move_left();
|
||||
}
|
||||
|
||||
pub fn move_right_with_selection(&mut self) {
|
||||
let _ = self.move_right();
|
||||
}
|
||||
|
||||
pub fn move_up_with_selection(&mut self) {
|
||||
let _ = self.move_up();
|
||||
}
|
||||
|
||||
pub fn move_down_with_selection(&mut self) {
|
||||
let _ = self.move_down();
|
||||
}
|
||||
|
||||
pub fn move_word_next_with_selection(&mut self) {
|
||||
self.move_word_next();
|
||||
}
|
||||
|
||||
pub fn move_word_end_with_selection(&mut self) {
|
||||
self.move_word_end();
|
||||
}
|
||||
|
||||
pub fn move_word_prev_with_selection(&mut self) {
|
||||
self.move_word_prev();
|
||||
}
|
||||
|
||||
pub fn move_word_end_prev_with_selection(&mut self) {
|
||||
self.move_word_end_prev();
|
||||
}
|
||||
|
||||
pub fn move_big_word_next_with_selection(&mut self) {
|
||||
self.move_big_word_next();
|
||||
}
|
||||
|
||||
pub fn move_big_word_end_with_selection(&mut self) {
|
||||
self.move_big_word_end();
|
||||
}
|
||||
|
||||
pub fn move_big_word_prev_with_selection(&mut self) {
|
||||
self.move_big_word_prev();
|
||||
}
|
||||
|
||||
pub fn move_big_word_end_prev_with_selection(&mut self) {
|
||||
self.move_big_word_end_prev();
|
||||
}
|
||||
|
||||
pub fn move_line_start_with_selection(&mut self) {
|
||||
self.move_line_start();
|
||||
}
|
||||
|
||||
pub fn move_line_end_with_selection(&mut self) {
|
||||
self.move_line_end();
|
||||
}
|
||||
}
|
||||
690
canvas/src/editor/movement.rs
Normal file
690
canvas/src/editor/movement.rs
Normal file
@@ -0,0 +1,690 @@
|
||||
// src/editor/movement.rs
|
||||
|
||||
use crate::canvas::actions::movement::line::{
|
||||
line_end_position, line_start_position,
|
||||
};
|
||||
use crate::canvas::modes::AppMode;
|
||||
use crate::editor::FormEditor;
|
||||
use crate::DataProvider;
|
||||
use crate::canvas::actions::movement::word::{
|
||||
find_last_big_word_start_in_field, find_last_word_start_in_field,
|
||||
};
|
||||
|
||||
impl<D: DataProvider> FormEditor<D> {
|
||||
/// Move cursor left within current field (mask-aware)
|
||||
pub fn move_left(&mut self) -> anyhow::Result<()> {
|
||||
#[cfg(feature = "validation")]
|
||||
let mut moved = false;
|
||||
#[cfg(not(feature = "validation"))]
|
||||
let moved = false;
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let field_index = self.ui_state.current_field;
|
||||
if let Some(cfg) =
|
||||
self.ui_state.validation.get_field_config(field_index)
|
||||
{
|
||||
if let Some(mask) = &cfg.display_mask {
|
||||
let display_pos =
|
||||
mask.raw_pos_to_display_pos(self.ui_state.cursor_pos);
|
||||
if let Some(prev_input) =
|
||||
mask.prev_input_position(display_pos)
|
||||
{
|
||||
let raw_pos =
|
||||
mask.display_pos_to_raw_pos(prev_input);
|
||||
let max_pos = self.current_text().chars().count();
|
||||
self.ui_state.cursor_pos = raw_pos.min(max_pos);
|
||||
self.ui_state.ideal_cursor_column =
|
||||
self.ui_state.cursor_pos;
|
||||
moved = true;
|
||||
} else {
|
||||
self.ui_state.cursor_pos = 0;
|
||||
self.ui_state.ideal_cursor_column = 0;
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !moved {
|
||||
if self.ui_state.cursor_pos > 0 {
|
||||
self.ui_state.cursor_pos -= 1;
|
||||
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move cursor right within current field (mask-aware)
|
||||
pub fn move_right(&mut self) -> anyhow::Result<()> {
|
||||
#[cfg(feature = "validation")]
|
||||
let mut moved = false;
|
||||
#[cfg(not(feature = "validation"))]
|
||||
let moved = false;
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let field_index = self.ui_state.current_field;
|
||||
if let Some(cfg) =
|
||||
self.ui_state.validation.get_field_config(field_index)
|
||||
{
|
||||
if let Some(mask) = &cfg.display_mask {
|
||||
let display_pos =
|
||||
mask.raw_pos_to_display_pos(self.ui_state.cursor_pos);
|
||||
let next_display_pos = mask.next_input_position(display_pos);
|
||||
let next_pos =
|
||||
mask.display_pos_to_raw_pos(next_display_pos);
|
||||
let max_pos = self.current_text().chars().count();
|
||||
self.ui_state.cursor_pos = next_pos.min(max_pos);
|
||||
self.ui_state.ideal_cursor_column =
|
||||
self.ui_state.cursor_pos;
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !moved {
|
||||
let max_pos = self.current_text().chars().count();
|
||||
if self.ui_state.cursor_pos < max_pos {
|
||||
self.ui_state.cursor_pos += 1;
|
||||
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move to start of current field (vim 0)
|
||||
pub fn move_line_start(&mut self) {
|
||||
let new_pos = line_start_position();
|
||||
self.ui_state.cursor_pos = new_pos;
|
||||
self.ui_state.ideal_cursor_column = new_pos;
|
||||
}
|
||||
|
||||
/// Move to end of current field (vim $)
|
||||
pub fn move_line_end(&mut self) {
|
||||
let current_text = self.current_text();
|
||||
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||
|
||||
let new_pos = line_end_position(current_text, is_edit_mode);
|
||||
self.ui_state.cursor_pos = new_pos;
|
||||
self.ui_state.ideal_cursor_column = new_pos;
|
||||
}
|
||||
|
||||
/// Set cursor to exact position (for f/F/t/T etc.)
|
||||
pub fn set_cursor_position(&mut self, position: usize) {
|
||||
let current_text = self.current_text();
|
||||
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||
|
||||
let char_len = current_text.chars().count();
|
||||
let max_pos = if is_edit_mode {
|
||||
char_len
|
||||
} else {
|
||||
char_len.saturating_sub(1)
|
||||
};
|
||||
|
||||
let clamped_pos = position.min(max_pos);
|
||||
self.ui_state.cursor_pos = clamped_pos;
|
||||
self.ui_state.ideal_cursor_column = clamped_pos;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl<D: DataProvider> FormEditor<D> {
|
||||
/// Move to start of next word (vim w) - can cross field boundaries
|
||||
pub fn move_word_next(&mut self) {
|
||||
use crate::canvas::actions::movement::word::find_next_word_start;
|
||||
let current_text = self.current_text();
|
||||
|
||||
if current_text.is_empty() {
|
||||
// Empty field - try to move to next field
|
||||
if self.move_down().is_ok() {
|
||||
// Successfully moved to next field, try to find first word
|
||||
let new_text = self.current_text();
|
||||
if !new_text.is_empty() {
|
||||
let first_word_pos = if new_text.chars().next().map_or(false, |c| !c.is_whitespace()) {
|
||||
// Field starts with non-whitespace, go to position 0
|
||||
0
|
||||
} else {
|
||||
// Field starts with whitespace, find first word
|
||||
find_next_word_start(new_text, 0)
|
||||
};
|
||||
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||
let char_len = new_text.chars().count();
|
||||
let final_pos = if is_edit_mode {
|
||||
first_word_pos.min(char_len)
|
||||
} else {
|
||||
first_word_pos.min(char_len.saturating_sub(1))
|
||||
};
|
||||
self.ui_state.cursor_pos = final_pos;
|
||||
self.ui_state.ideal_cursor_column = final_pos;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let current_pos = self.ui_state.cursor_pos;
|
||||
let new_pos = find_next_word_start(current_text, current_pos);
|
||||
|
||||
// Check if we've hit the end of the current field
|
||||
if new_pos >= current_text.chars().count() {
|
||||
// At end of field - jump to next field and start from beginning
|
||||
if self.move_down().is_ok() {
|
||||
// Successfully moved to next field
|
||||
let new_text = self.current_text();
|
||||
if new_text.is_empty() {
|
||||
// New field is empty, cursor stays at 0
|
||||
self.ui_state.cursor_pos = 0;
|
||||
self.ui_state.ideal_cursor_column = 0;
|
||||
} else {
|
||||
// Find first word in new field
|
||||
let first_word_pos = if new_text.chars().next().map_or(false, |c| !c.is_whitespace()) {
|
||||
// Field starts with non-whitespace, go to position 0
|
||||
0
|
||||
} else {
|
||||
// Field starts with whitespace, find first word
|
||||
find_next_word_start(new_text, 0)
|
||||
};
|
||||
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||
let char_len = new_text.chars().count();
|
||||
let final_pos = if is_edit_mode {
|
||||
first_word_pos.min(char_len)
|
||||
} else {
|
||||
first_word_pos.min(char_len.saturating_sub(1))
|
||||
};
|
||||
self.ui_state.cursor_pos = final_pos;
|
||||
self.ui_state.ideal_cursor_column = final_pos;
|
||||
}
|
||||
}
|
||||
// If move_down() failed, we stay where we are (at end of last field)
|
||||
} else {
|
||||
// Normal word movement within current field
|
||||
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||
let char_len = current_text.chars().count();
|
||||
let final_pos = if is_edit_mode {
|
||||
new_pos.min(char_len)
|
||||
} else {
|
||||
new_pos.min(char_len.saturating_sub(1))
|
||||
};
|
||||
|
||||
self.ui_state.cursor_pos = final_pos;
|
||||
self.ui_state.ideal_cursor_column = final_pos;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move to start of previous word (vim b) - can cross field boundaries
|
||||
pub fn move_word_prev(&mut self) {
|
||||
use crate::canvas::actions::movement::word::find_prev_word_start;
|
||||
let current_text = self.current_text();
|
||||
|
||||
if current_text.is_empty() {
|
||||
// Empty field - try to move to previous field and find last word
|
||||
let current_field = self.ui_state.current_field;
|
||||
if self.move_up().is_ok() {
|
||||
// Check if we actually moved to a different field
|
||||
if self.ui_state.current_field != current_field {
|
||||
let new_text = self.current_text();
|
||||
if !new_text.is_empty() {
|
||||
let last_word_start = find_last_word_start_in_field(new_text);
|
||||
self.ui_state.cursor_pos = last_word_start;
|
||||
self.ui_state.ideal_cursor_column = last_word_start;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let current_pos = self.ui_state.cursor_pos;
|
||||
|
||||
// Special case: if we're at position 0, jump to previous field
|
||||
if current_pos == 0 {
|
||||
let current_field = self.ui_state.current_field;
|
||||
if self.move_up().is_ok() {
|
||||
// Check if we actually moved to a different field
|
||||
if self.ui_state.current_field != current_field {
|
||||
let new_text = self.current_text();
|
||||
if !new_text.is_empty() {
|
||||
let last_word_start = find_last_word_start_in_field(new_text);
|
||||
self.ui_state.cursor_pos = last_word_start;
|
||||
self.ui_state.ideal_cursor_column = last_word_start;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to find previous word in current field
|
||||
let new_pos = find_prev_word_start(current_text, current_pos);
|
||||
|
||||
// Check if we actually moved
|
||||
if new_pos < current_pos {
|
||||
// Normal word movement within current field - we found a previous word
|
||||
self.ui_state.cursor_pos = new_pos;
|
||||
self.ui_state.ideal_cursor_column = new_pos;
|
||||
} else {
|
||||
// We didn't move (probably at start of first word), try previous field
|
||||
let current_field = self.ui_state.current_field;
|
||||
if self.move_up().is_ok() {
|
||||
// Check if we actually moved to a different field
|
||||
if self.ui_state.current_field != current_field {
|
||||
let new_text = self.current_text();
|
||||
if !new_text.is_empty() {
|
||||
let last_word_start = find_last_word_start_in_field(new_text);
|
||||
self.ui_state.cursor_pos = last_word_start;
|
||||
self.ui_state.ideal_cursor_column = last_word_start;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move to end of current/next word (vim e) - can cross field boundaries
|
||||
pub fn move_word_end(&mut self) {
|
||||
use crate::canvas::actions::movement::word::find_word_end;
|
||||
let current_text = self.current_text();
|
||||
|
||||
if current_text.is_empty() {
|
||||
// Empty field - try to move to next field
|
||||
if self.move_down().is_ok() {
|
||||
// Recursively call move_word_end in the new field
|
||||
self.move_word_end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let current_pos = self.ui_state.cursor_pos;
|
||||
let char_len = current_text.chars().count();
|
||||
let new_pos = find_word_end(current_text, current_pos);
|
||||
|
||||
// Check if we didn't move or hit the end of the field
|
||||
if new_pos == current_pos && current_pos + 1 < char_len {
|
||||
// Try next character and find word end from there
|
||||
let next_pos = find_word_end(current_text, current_pos + 1);
|
||||
if next_pos < char_len {
|
||||
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||
let final_pos = if is_edit_mode {
|
||||
next_pos.min(char_len)
|
||||
} else {
|
||||
next_pos.min(char_len.saturating_sub(1))
|
||||
};
|
||||
self.ui_state.cursor_pos = final_pos;
|
||||
self.ui_state.ideal_cursor_column = final_pos;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If we're at or near the end of the field, try next field
|
||||
if new_pos >= char_len.saturating_sub(1) {
|
||||
if self.move_down().is_ok() {
|
||||
// Position at start and find first word end
|
||||
self.ui_state.cursor_pos = 0;
|
||||
self.ui_state.ideal_cursor_column = 0;
|
||||
self.move_word_end();
|
||||
}
|
||||
} else {
|
||||
// Normal word end movement within current field
|
||||
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||
let final_pos = if is_edit_mode {
|
||||
new_pos.min(char_len)
|
||||
} else {
|
||||
new_pos.min(char_len.saturating_sub(1))
|
||||
};
|
||||
|
||||
self.ui_state.cursor_pos = final_pos;
|
||||
self.ui_state.ideal_cursor_column = final_pos;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move to end of previous word (vim ge) - can cross field boundaries
|
||||
pub fn move_word_end_prev(&mut self) {
|
||||
use crate::canvas::actions::movement::word::{find_prev_word_end, find_last_word_end_in_field};
|
||||
let current_text = self.current_text();
|
||||
|
||||
if current_text.is_empty() {
|
||||
// Empty field - try to move to previous field (but don't recurse)
|
||||
let current_field = self.ui_state.current_field;
|
||||
if self.move_up().is_ok() {
|
||||
// Check if we actually moved to a different field
|
||||
if self.ui_state.current_field != current_field {
|
||||
let new_text = self.current_text();
|
||||
if !new_text.is_empty() {
|
||||
// Find end of last word in the field
|
||||
let last_word_end = find_last_word_end_in_field(new_text);
|
||||
self.ui_state.cursor_pos = last_word_end;
|
||||
self.ui_state.ideal_cursor_column = last_word_end;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let current_pos = self.ui_state.cursor_pos;
|
||||
|
||||
// Special case: if we're at position 0, jump to previous field (but don't recurse)
|
||||
if current_pos == 0 {
|
||||
let current_field = self.ui_state.current_field;
|
||||
if self.move_up().is_ok() {
|
||||
// Check if we actually moved to a different field
|
||||
if self.ui_state.current_field != current_field {
|
||||
let new_text = self.current_text();
|
||||
if !new_text.is_empty() {
|
||||
let last_word_end = find_last_word_end_in_field(new_text);
|
||||
self.ui_state.cursor_pos = last_word_end;
|
||||
self.ui_state.ideal_cursor_column = last_word_end;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let new_pos = find_prev_word_end(current_text, current_pos);
|
||||
|
||||
// Only try to cross fields if we didn't move at all (stayed at same position)
|
||||
if new_pos == current_pos {
|
||||
// We didn't move within the current field, try previous field
|
||||
let current_field = self.ui_state.current_field;
|
||||
if self.move_up().is_ok() {
|
||||
// Check if we actually moved to a different field
|
||||
if self.ui_state.current_field != current_field {
|
||||
let new_text = self.current_text();
|
||||
if !new_text.is_empty() {
|
||||
let last_word_end = find_last_word_end_in_field(new_text);
|
||||
self.ui_state.cursor_pos = last_word_end;
|
||||
self.ui_state.ideal_cursor_column = last_word_end;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal word movement within current field
|
||||
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||
let char_len = current_text.chars().count();
|
||||
let final_pos = if is_edit_mode {
|
||||
new_pos.min(char_len)
|
||||
} else {
|
||||
new_pos.min(char_len.saturating_sub(1))
|
||||
};
|
||||
|
||||
self.ui_state.cursor_pos = final_pos;
|
||||
self.ui_state.ideal_cursor_column = final_pos;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move to start of next big_word (vim W) - can cross field boundaries
|
||||
pub fn move_big_word_next(&mut self) {
|
||||
use crate::canvas::actions::movement::word::find_next_big_word_start;
|
||||
let current_text = self.current_text();
|
||||
|
||||
if current_text.is_empty() {
|
||||
// Empty field - try to move to next field
|
||||
if self.move_down().is_ok() {
|
||||
// Successfully moved to next field, try to find first big_word
|
||||
let new_text = self.current_text();
|
||||
if !new_text.is_empty() {
|
||||
let first_big_word_pos = if new_text.chars().next().map_or(false, |c| !c.is_whitespace()) {
|
||||
// Field starts with non-whitespace, go to position 0
|
||||
0
|
||||
} else {
|
||||
// Field starts with whitespace, find first big_word
|
||||
find_next_big_word_start(new_text, 0)
|
||||
};
|
||||
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||
let char_len = new_text.chars().count();
|
||||
let final_pos = if is_edit_mode {
|
||||
first_big_word_pos.min(char_len)
|
||||
} else {
|
||||
first_big_word_pos.min(char_len.saturating_sub(1))
|
||||
};
|
||||
self.ui_state.cursor_pos = final_pos;
|
||||
self.ui_state.ideal_cursor_column = final_pos;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let current_pos = self.ui_state.cursor_pos;
|
||||
let new_pos = find_next_big_word_start(current_text, current_pos);
|
||||
|
||||
// Check if we've hit the end of the current field
|
||||
if new_pos >= current_text.chars().count() {
|
||||
// At end of field - jump to next field and start from beginning
|
||||
if self.move_down().is_ok() {
|
||||
// Successfully moved to next field
|
||||
let new_text = self.current_text();
|
||||
if new_text.is_empty() {
|
||||
// New field is empty, cursor stays at 0
|
||||
self.ui_state.cursor_pos = 0;
|
||||
self.ui_state.ideal_cursor_column = 0;
|
||||
} else {
|
||||
// Find first big_word in new field
|
||||
let first_big_word_pos = if new_text.chars().next().map_or(false, |c| !c.is_whitespace()) {
|
||||
// Field starts with non-whitespace, go to position 0
|
||||
0
|
||||
} else {
|
||||
// Field starts with whitespace, find first big_word
|
||||
find_next_big_word_start(new_text, 0)
|
||||
};
|
||||
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||
let char_len = new_text.chars().count();
|
||||
let final_pos = if is_edit_mode {
|
||||
first_big_word_pos.min(char_len)
|
||||
} else {
|
||||
first_big_word_pos.min(char_len.saturating_sub(1))
|
||||
};
|
||||
self.ui_state.cursor_pos = final_pos;
|
||||
self.ui_state.ideal_cursor_column = final_pos;
|
||||
}
|
||||
}
|
||||
// If move_down() failed, we stay where we are (at end of last field)
|
||||
} else {
|
||||
// Normal big_word movement within current field
|
||||
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||
let char_len = current_text.chars().count();
|
||||
let final_pos = if is_edit_mode {
|
||||
new_pos.min(char_len)
|
||||
} else {
|
||||
new_pos.min(char_len.saturating_sub(1))
|
||||
};
|
||||
|
||||
self.ui_state.cursor_pos = final_pos;
|
||||
self.ui_state.ideal_cursor_column = final_pos;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move to start of previous big_word (vim B) - can cross field boundaries
|
||||
pub fn move_big_word_prev(&mut self) {
|
||||
use crate::canvas::actions::movement::word::find_prev_big_word_start;
|
||||
let current_text = self.current_text();
|
||||
|
||||
if current_text.is_empty() {
|
||||
// Empty field - try to move to previous field and find last big_word
|
||||
let current_field = self.ui_state.current_field;
|
||||
if self.move_up().is_ok() {
|
||||
// Check if we actually moved to a different field
|
||||
if self.ui_state.current_field != current_field {
|
||||
let new_text = self.current_text();
|
||||
if !new_text.is_empty() {
|
||||
let last_big_word_start = find_last_big_word_start_in_field(new_text);
|
||||
self.ui_state.cursor_pos = last_big_word_start;
|
||||
self.ui_state.ideal_cursor_column = last_big_word_start;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let current_pos = self.ui_state.cursor_pos;
|
||||
|
||||
// Special case: if we're at position 0, jump to previous field
|
||||
if current_pos == 0 {
|
||||
let current_field = self.ui_state.current_field;
|
||||
if self.move_up().is_ok() {
|
||||
// Check if we actually moved to a different field
|
||||
if self.ui_state.current_field != current_field {
|
||||
let new_text = self.current_text();
|
||||
if !new_text.is_empty() {
|
||||
let last_big_word_start = find_last_big_word_start_in_field(new_text);
|
||||
self.ui_state.cursor_pos = last_big_word_start;
|
||||
self.ui_state.ideal_cursor_column = last_big_word_start;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to find previous big_word in current field
|
||||
let new_pos = find_prev_big_word_start(current_text, current_pos);
|
||||
|
||||
// Check if we actually moved
|
||||
if new_pos < current_pos {
|
||||
// Normal big_word movement within current field - we found a previous big_word
|
||||
self.ui_state.cursor_pos = new_pos;
|
||||
self.ui_state.ideal_cursor_column = new_pos;
|
||||
} else {
|
||||
// We didn't move (probably at start of first big_word), try previous field
|
||||
let current_field = self.ui_state.current_field;
|
||||
if self.move_up().is_ok() {
|
||||
// Check if we actually moved to a different field
|
||||
if self.ui_state.current_field != current_field {
|
||||
let new_text = self.current_text();
|
||||
if !new_text.is_empty() {
|
||||
let last_big_word_start = find_last_big_word_start_in_field(new_text);
|
||||
self.ui_state.cursor_pos = last_big_word_start;
|
||||
self.ui_state.ideal_cursor_column = last_big_word_start;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move to end of current/next big_word (vim E) - can cross field boundaries
|
||||
pub fn move_big_word_end(&mut self) {
|
||||
use crate::canvas::actions::movement::word::find_big_word_end;
|
||||
let current_text = self.current_text();
|
||||
|
||||
if current_text.is_empty() {
|
||||
// Empty field - try to move to next field (but don't recurse)
|
||||
if self.move_down().is_ok() {
|
||||
let new_text = self.current_text();
|
||||
if !new_text.is_empty() {
|
||||
// Find first big_word end in new field
|
||||
let first_big_word_end = find_big_word_end(new_text, 0);
|
||||
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||
let char_len = new_text.chars().count();
|
||||
let final_pos = if is_edit_mode {
|
||||
first_big_word_end.min(char_len)
|
||||
} else {
|
||||
first_big_word_end.min(char_len.saturating_sub(1))
|
||||
};
|
||||
self.ui_state.cursor_pos = final_pos;
|
||||
self.ui_state.ideal_cursor_column = final_pos;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let current_pos = self.ui_state.cursor_pos;
|
||||
let char_len = current_text.chars().count();
|
||||
let new_pos = find_big_word_end(current_text, current_pos);
|
||||
|
||||
// Check if we didn't move or hit the end of the field
|
||||
if new_pos == current_pos && current_pos + 1 < char_len {
|
||||
// Try next character and find big_word end from there
|
||||
let next_pos = find_big_word_end(current_text, current_pos + 1);
|
||||
if next_pos < char_len {
|
||||
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||
let final_pos = if is_edit_mode {
|
||||
next_pos.min(char_len)
|
||||
} else {
|
||||
next_pos.min(char_len.saturating_sub(1))
|
||||
};
|
||||
self.ui_state.cursor_pos = final_pos;
|
||||
self.ui_state.ideal_cursor_column = final_pos;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If we're at or near the end of the field, try next field (but don't recurse)
|
||||
if new_pos >= char_len.saturating_sub(1) {
|
||||
if self.move_down().is_ok() {
|
||||
// Find first big_word end in new field
|
||||
let new_text = self.current_text();
|
||||
if !new_text.is_empty() {
|
||||
let first_big_word_end = find_big_word_end(new_text, 0);
|
||||
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||
let new_char_len = new_text.chars().count();
|
||||
let final_pos = if is_edit_mode {
|
||||
first_big_word_end.min(new_char_len)
|
||||
} else {
|
||||
first_big_word_end.min(new_char_len.saturating_sub(1))
|
||||
};
|
||||
self.ui_state.cursor_pos = final_pos;
|
||||
self.ui_state.ideal_cursor_column = final_pos;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal big_word end movement within current field
|
||||
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||
let final_pos = if is_edit_mode {
|
||||
new_pos.min(char_len)
|
||||
} else {
|
||||
new_pos.min(char_len.saturating_sub(1))
|
||||
};
|
||||
|
||||
self.ui_state.cursor_pos = final_pos;
|
||||
self.ui_state.ideal_cursor_column = final_pos;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move to end of previous big_word (vim gE) - can cross field boundaries
|
||||
pub fn move_big_word_end_prev(&mut self) {
|
||||
use crate::canvas::actions::movement::word::{
|
||||
find_prev_big_word_end, find_big_word_end,
|
||||
};
|
||||
|
||||
let current_text = self.current_text();
|
||||
|
||||
if current_text.is_empty() {
|
||||
let current_field = self.ui_state.current_field;
|
||||
if self.move_up().is_ok() {
|
||||
if self.ui_state.current_field != current_field {
|
||||
let new_text = self.current_text();
|
||||
if !new_text.is_empty() {
|
||||
// Find first big_word end in new field
|
||||
let last_big_word_end = find_big_word_end(new_text, 0);
|
||||
self.ui_state.cursor_pos = last_big_word_end;
|
||||
self.ui_state.ideal_cursor_column = last_big_word_end;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let current_pos = self.ui_state.cursor_pos;
|
||||
let new_pos = find_prev_big_word_end(current_text, current_pos);
|
||||
|
||||
// Only try to cross fields if we didn't move at all (stayed at same position)
|
||||
if new_pos == current_pos {
|
||||
let current_field = self.ui_state.current_field;
|
||||
if self.move_up().is_ok() {
|
||||
if self.ui_state.current_field != current_field {
|
||||
let new_text = self.current_text();
|
||||
if !new_text.is_empty() {
|
||||
let last_big_word_end = find_big_word_end(new_text, 0);
|
||||
self.ui_state.cursor_pos = last_big_word_end;
|
||||
self.ui_state.ideal_cursor_column = last_big_word_end;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal big_word movement within current field
|
||||
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||
let char_len = current_text.chars().count();
|
||||
let final_pos = if is_edit_mode {
|
||||
new_pos.min(char_len)
|
||||
} else {
|
||||
new_pos.min(char_len.saturating_sub(1))
|
||||
};
|
||||
self.ui_state.cursor_pos = final_pos;
|
||||
self.ui_state.ideal_cursor_column = final_pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
177
canvas/src/editor/navigation.rs
Normal file
177
canvas/src/editor/navigation.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
// src/editor/navigation.rs
|
||||
|
||||
use crate::canvas::modes::AppMode;
|
||||
use crate::editor::FormEditor;
|
||||
use crate::DataProvider;
|
||||
|
||||
impl<D: DataProvider> FormEditor<D> {
|
||||
/// Centralized field transition logic (unchanged).
|
||||
pub fn transition_to_field(&mut self, new_field: usize) -> anyhow::Result<()> {
|
||||
let field_count = self.data_provider.field_count();
|
||||
if field_count == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let prev_field = self.ui_state.current_field;
|
||||
|
||||
#[cfg(feature = "computed")]
|
||||
let mut target_field = new_field.min(field_count - 1);
|
||||
#[cfg(not(feature = "computed"))]
|
||||
let target_field = new_field.min(field_count - 1);
|
||||
|
||||
#[cfg(feature = "computed")]
|
||||
{
|
||||
if let Some(computed_state) = &self.ui_state.computed {
|
||||
if computed_state.is_computed_field(target_field) {
|
||||
if target_field >= prev_field {
|
||||
for i in (target_field + 1)..field_count {
|
||||
if !computed_state.is_computed_field(i) {
|
||||
target_field = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut i = target_field;
|
||||
loop {
|
||||
if !computed_state.is_computed_field(i) {
|
||||
target_field = i;
|
||||
break;
|
||||
}
|
||||
if i == 0 {
|
||||
break;
|
||||
}
|
||||
i -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if target_field == prev_field {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
self.ui_state.validation.clear_last_switch_block();
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let current_text = self.current_text();
|
||||
if !self
|
||||
.ui_state
|
||||
.validation
|
||||
.allows_field_switch(prev_field, current_text)
|
||||
{
|
||||
if let Some(reason) = self
|
||||
.ui_state
|
||||
.validation
|
||||
.field_switch_block_reason(prev_field, current_text)
|
||||
{
|
||||
self.ui_state
|
||||
.validation
|
||||
.set_last_switch_block(reason.clone());
|
||||
tracing::debug!("Field switch blocked: {}", reason);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Cannot switch fields: {}",
|
||||
reason
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let text =
|
||||
self.data_provider.field_value(prev_field).to_string();
|
||||
let _ = self
|
||||
.ui_state
|
||||
.validation
|
||||
.validate_field_content(prev_field, &text);
|
||||
|
||||
if let Some(cfg) =
|
||||
self.ui_state.validation.get_field_config(prev_field)
|
||||
{
|
||||
if cfg.external_validation_enabled && !text.is_empty() {
|
||||
self.set_external_validation(
|
||||
prev_field,
|
||||
crate::validation::ExternalValidationState::Validating,
|
||||
);
|
||||
|
||||
if let Some(cb) =
|
||||
self.external_validation_callback.as_mut()
|
||||
{
|
||||
let final_state = cb(prev_field, &text);
|
||||
self.set_external_validation(prev_field, final_state);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "computed")]
|
||||
{
|
||||
// Placeholder for recompute hook if needed later
|
||||
}
|
||||
|
||||
self.ui_state.move_to_field(target_field, field_count);
|
||||
|
||||
let current_text = self.current_text();
|
||||
let max_pos = current_text.chars().count();
|
||||
self.ui_state.set_cursor(
|
||||
self.ui_state.ideal_cursor_column,
|
||||
max_pos,
|
||||
self.ui_state.current_mode == AppMode::Edit,
|
||||
);
|
||||
|
||||
// Automatically close suggestions on field switch
|
||||
#[cfg(feature = "suggestions")]
|
||||
{
|
||||
self.close_suggestions();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move to first line (vim gg)
|
||||
pub fn move_first_line(&mut self) -> anyhow::Result<()> {
|
||||
self.transition_to_field(0)
|
||||
}
|
||||
|
||||
/// Move to last line (vim G)
|
||||
pub fn move_last_line(&mut self) -> anyhow::Result<()> {
|
||||
let last_field =
|
||||
self.data_provider.field_count().saturating_sub(1);
|
||||
self.transition_to_field(last_field)
|
||||
}
|
||||
|
||||
/// Move to previous field (vim k / up)
|
||||
pub fn move_up(&mut self) -> anyhow::Result<()> {
|
||||
let new_field = self.ui_state.current_field.saturating_sub(1);
|
||||
self.transition_to_field(new_field)
|
||||
}
|
||||
|
||||
/// Move to next field (vim j / down)
|
||||
pub fn move_down(&mut self) -> anyhow::Result<()> {
|
||||
let new_field = (self.ui_state.current_field + 1)
|
||||
.min(self.data_provider.field_count().saturating_sub(1));
|
||||
self.transition_to_field(new_field)
|
||||
}
|
||||
|
||||
/// Move to next field cyclic
|
||||
pub fn move_to_next_field(&mut self) -> anyhow::Result<()> {
|
||||
let field_count = self.data_provider.field_count();
|
||||
if field_count == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let new_field = (self.ui_state.current_field + 1) % field_count;
|
||||
self.transition_to_field(new_field)
|
||||
}
|
||||
|
||||
/// Aliases
|
||||
pub fn prev_field(&mut self) -> anyhow::Result<()> {
|
||||
self.move_up()
|
||||
}
|
||||
|
||||
pub fn next_field(&mut self) -> anyhow::Result<()> {
|
||||
self.move_down()
|
||||
}
|
||||
}
|
||||
166
canvas/src/editor/suggestions.rs
Normal file
166
canvas/src/editor/suggestions.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
// src/editor/suggestions.rs
|
||||
|
||||
use crate::editor::FormEditor;
|
||||
use crate::{DataProvider, SuggestionItem};
|
||||
|
||||
impl<D: DataProvider> FormEditor<D> {
|
||||
/// Compute inline completion for current selection and text
|
||||
fn compute_current_completion(&self) -> Option<String> {
|
||||
let typed = self.current_text();
|
||||
let idx = self.ui_state.suggestions.selected_index?;
|
||||
let sugg = self.suggestions.get(idx)?;
|
||||
if let Some(rest) = sugg.value_to_store.strip_prefix(typed) {
|
||||
if !rest.is_empty() {
|
||||
return Some(rest.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Update UI state's completion text from current selection
|
||||
pub fn update_inline_completion(&mut self) {
|
||||
self.ui_state.suggestions.completion_text =
|
||||
self.compute_current_completion();
|
||||
}
|
||||
|
||||
/// Open the suggestions UI for `field_index`
|
||||
pub fn open_suggestions(&mut self, field_index: usize) {
|
||||
self.ui_state.open_suggestions(field_index);
|
||||
}
|
||||
|
||||
/// Close suggestions UI and clear current suggestion results
|
||||
pub fn close_suggestions(&mut self) {
|
||||
self.ui_state.close_suggestions();
|
||||
self.suggestions.clear();
|
||||
}
|
||||
|
||||
/// Handle Escape key in ReadOnly mode (closes suggestions if active)
|
||||
pub fn handle_escape_readonly(&mut self) {
|
||||
if self.ui_state.suggestions.is_active {
|
||||
self.close_suggestions();
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------- Non-blocking suggestions API --------------------
|
||||
|
||||
#[cfg(feature = "suggestions")]
|
||||
pub fn start_suggestions(&mut self, field_index: usize) -> Option<String> {
|
||||
if !self.data_provider.supports_suggestions(field_index) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let query = self.current_text().to_string();
|
||||
self.ui_state.open_suggestions(field_index);
|
||||
self.ui_state.suggestions.is_loading = true;
|
||||
self.ui_state.suggestions.active_query = Some(query.clone());
|
||||
self.suggestions.clear();
|
||||
Some(query)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "suggestions"))]
|
||||
pub fn start_suggestions(&mut self, _field_index: usize) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(feature = "suggestions")]
|
||||
pub fn apply_suggestions_result(
|
||||
&mut self,
|
||||
field_index: usize,
|
||||
query: &str,
|
||||
results: Vec<SuggestionItem>,
|
||||
) -> bool {
|
||||
if self.ui_state.suggestions.active_field != Some(field_index) {
|
||||
return false;
|
||||
}
|
||||
if self.ui_state.suggestions.active_query.as_deref() != Some(query) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.ui_state.suggestions.is_loading = false;
|
||||
self.suggestions = results;
|
||||
|
||||
if !self.suggestions.is_empty() {
|
||||
self.ui_state.suggestions.selected_index = Some(0);
|
||||
self.update_inline_completion();
|
||||
} else {
|
||||
self.ui_state.suggestions.selected_index = None;
|
||||
self.ui_state.suggestions.completion_text = None;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "suggestions"))]
|
||||
pub fn apply_suggestions_result(
|
||||
&mut self,
|
||||
_field_index: usize,
|
||||
_query: &str,
|
||||
_results: Vec<SuggestionItem>,
|
||||
) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(feature = "suggestions")]
|
||||
pub fn pending_suggestions_query(&self) -> Option<(usize, String)> {
|
||||
if self.ui_state.suggestions.is_loading {
|
||||
if let (Some(field), Some(query)) = (
|
||||
self.ui_state.suggestions.active_field,
|
||||
&self.ui_state.suggestions.active_query,
|
||||
) {
|
||||
return Some((field, query.clone()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "suggestions"))]
|
||||
pub fn pending_suggestions_query(&self) -> Option<(usize, String)> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn cancel_suggestions(&mut self) {
|
||||
self.close_suggestions();
|
||||
}
|
||||
|
||||
pub fn suggestions_next(&mut self) {
|
||||
if !self.ui_state.suggestions.is_active || self.suggestions.is_empty()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let current = self.ui_state.suggestions.selected_index.unwrap_or(0);
|
||||
let next = (current + 1) % self.suggestions.len();
|
||||
self.ui_state.suggestions.selected_index = Some(next);
|
||||
self.update_inline_completion();
|
||||
}
|
||||
|
||||
pub fn apply_suggestion(&mut self) -> Option<String> {
|
||||
if let Some(selected_index) = self.ui_state.suggestions.selected_index {
|
||||
if let Some(suggestion) = self.suggestions.get(selected_index).cloned()
|
||||
{
|
||||
let field_index = self.ui_state.current_field;
|
||||
|
||||
self.data_provider.set_field_value(
|
||||
field_index,
|
||||
suggestion.value_to_store.clone(),
|
||||
);
|
||||
|
||||
self.ui_state.cursor_pos = suggestion.value_to_store.len();
|
||||
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
||||
|
||||
self.close_suggestions();
|
||||
self.suggestions.clear();
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
let _ = self.ui_state.validation.validate_field_content(
|
||||
field_index,
|
||||
&suggestion.value_to_store,
|
||||
);
|
||||
}
|
||||
|
||||
return Some(suggestion.display_text);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
23
canvas/src/editor/suggestions_stub.rs
Normal file
23
canvas/src/editor/suggestions_stub.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
// src/editor/suggestions_stub.rs
|
||||
// Crate-private no-op methods so internal calls compile when feature is off.
|
||||
|
||||
use crate::editor::FormEditor;
|
||||
use crate::DataProvider;
|
||||
|
||||
impl<D: DataProvider> FormEditor<D> {
|
||||
pub(crate) fn open_suggestions(&mut self, _field_index: usize) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
pub(crate) fn close_suggestions(&mut self) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
pub(crate) fn handle_escape_readonly(&mut self) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_suggestions(&mut self) {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
178
canvas/src/editor/validation_helpers.rs
Normal file
178
canvas/src/editor/validation_helpers.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
// src/editor/validation_helpers.rs
|
||||
|
||||
use crate::editor::FormEditor;
|
||||
use crate::DataProvider;
|
||||
|
||||
impl<D: DataProvider> FormEditor<D> {
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn set_validation_enabled(&mut self, enabled: bool) {
|
||||
self.ui_state.validation.set_enabled(enabled);
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn is_validation_enabled(&self) -> bool {
|
||||
self.ui_state.validation.is_enabled()
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn set_field_validation(
|
||||
&mut self,
|
||||
field_index: usize,
|
||||
config: crate::validation::ValidationConfig,
|
||||
) {
|
||||
self.ui_state
|
||||
.validation
|
||||
.set_field_config(field_index, config);
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn remove_field_validation(&mut self, field_index: usize) {
|
||||
self.ui_state.validation.remove_field_config(field_index);
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn validate_current_field(
|
||||
&mut self,
|
||||
) -> crate::validation::ValidationResult {
|
||||
let field_index = self.ui_state.current_field;
|
||||
let current_text = self.current_text().to_string();
|
||||
self.ui_state
|
||||
.validation
|
||||
.validate_field_content(field_index, ¤t_text)
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn validate_field(
|
||||
&mut self,
|
||||
field_index: usize,
|
||||
) -> Option<crate::validation::ValidationResult> {
|
||||
if field_index < self.data_provider.field_count() {
|
||||
let text =
|
||||
self.data_provider.field_value(field_index).to_string();
|
||||
Some(
|
||||
self.ui_state
|
||||
.validation
|
||||
.validate_field_content(field_index, &text),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn clear_validation_results(&mut self) {
|
||||
self.ui_state.validation.clear_all_results();
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn validation_summary(
|
||||
&self,
|
||||
) -> crate::validation::ValidationSummary {
|
||||
self.ui_state.validation.summary()
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn can_switch_fields(&self) -> bool {
|
||||
let current_text = self.current_text();
|
||||
self.ui_state.validation.allows_field_switch(
|
||||
self.ui_state.current_field,
|
||||
current_text,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn field_switch_block_reason(&self) -> Option<String> {
|
||||
let current_text = self.current_text();
|
||||
self.ui_state.validation.field_switch_block_reason(
|
||||
self.ui_state.current_field,
|
||||
current_text,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn last_switch_block(&self) -> Option<&str> {
|
||||
self.ui_state.validation.last_switch_block()
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn current_limits_status_text(&self) -> Option<String> {
|
||||
let idx = self.ui_state.current_field;
|
||||
if let Some(cfg) = self.ui_state.validation.get_field_config(idx) {
|
||||
if let Some(limits) = &cfg.character_limits {
|
||||
return limits.status_text(self.current_text());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn current_formatter_warning(&self) -> Option<String> {
|
||||
let idx = self.ui_state.current_field;
|
||||
if let Some(cfg) = self.ui_state.validation.get_field_config(idx) {
|
||||
if let Some((_fmt, _mapper, warn)) =
|
||||
cfg.run_custom_formatter(self.current_text())
|
||||
{
|
||||
return warn;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn external_validation_of(
|
||||
&self,
|
||||
field_index: usize,
|
||||
) -> crate::validation::ExternalValidationState {
|
||||
self.ui_state
|
||||
.validation
|
||||
.get_external_validation(field_index)
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn clear_all_external_validation(&mut self) {
|
||||
self.ui_state.validation.clear_all_external_validation();
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn clear_external_validation(&mut self, field_index: usize) {
|
||||
self.ui_state
|
||||
.validation
|
||||
.clear_external_validation(field_index);
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn set_external_validation(
|
||||
&mut self,
|
||||
field_index: usize,
|
||||
state: crate::validation::ExternalValidationState,
|
||||
) {
|
||||
self.ui_state
|
||||
.validation
|
||||
.set_external_validation(field_index, state);
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn set_external_validation_callback<F>(&mut self, callback: F)
|
||||
where
|
||||
F: FnMut(usize, &str) -> crate::validation::ExternalValidationState
|
||||
+ Send
|
||||
+ Sync
|
||||
+ 'static,
|
||||
{
|
||||
self.external_validation_callback = Some(Box::new(callback));
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
pub(crate) fn initialize_validation(&mut self) {
|
||||
let field_count = self.data_provider.field_count();
|
||||
for field_index in 0..field_count {
|
||||
if let Some(config) =
|
||||
self.data_provider.validation_config(field_index)
|
||||
{
|
||||
self.ui_state
|
||||
.validation
|
||||
.set_field_config(field_index, config);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,18 @@ pub mod canvas;
|
||||
pub mod editor;
|
||||
pub mod data_provider;
|
||||
|
||||
// Only include autocomplete module if feature is enabled
|
||||
#[cfg(feature = "autocomplete")]
|
||||
pub mod autocomplete;
|
||||
// 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;
|
||||
|
||||
// Only include computed module if feature is enabled
|
||||
#[cfg(feature = "computed")]
|
||||
pub mod computed;
|
||||
|
||||
#[cfg(feature = "cursor-style")]
|
||||
pub use canvas::CursorManager;
|
||||
|
||||
@@ -21,7 +25,9 @@ pub use canvas::CursorManager;
|
||||
|
||||
// Main API exports
|
||||
pub use editor::FormEditor;
|
||||
pub use data_provider::{DataProvider, AutocompleteProvider, SuggestionItem};
|
||||
pub use data_provider::DataProvider;
|
||||
#[cfg(feature = "suggestions")]
|
||||
pub use data_provider::{SuggestionsProvider, SuggestionItem};
|
||||
|
||||
// UI state (read-only access for users)
|
||||
pub use canvas::state::EditorState;
|
||||
@@ -35,9 +41,16 @@ pub use canvas::actions::{CanvasAction, ActionResult};
|
||||
pub use validation::{
|
||||
ValidationConfig, ValidationResult, ValidationError,
|
||||
CharacterLimits, ValidationConfigBuilder, ValidationState,
|
||||
ValidationSummary,
|
||||
ValidationSummary, PatternFilters, PositionFilter, PositionRange, CharacterFilter,
|
||||
DisplayMask, // Simple display mask instead of complex ReservedCharacters
|
||||
// Feature 4: custom formatting exports
|
||||
CustomFormatter, FormattingResult, PositionMapper, DefaultPositionMapper,
|
||||
};
|
||||
|
||||
// Computed exports (only when computed feature is enabled)
|
||||
#[cfg(feature = "computed")]
|
||||
pub use computed::{ComputedProvider, ComputedContext, ComputedState};
|
||||
|
||||
// Theming and GUI
|
||||
#[cfg(feature = "gui")]
|
||||
pub use canvas::theme::{CanvasTheme, DefaultCanvasTheme};
|
||||
@@ -48,5 +61,13 @@ pub use canvas::gui::render_canvas;
|
||||
#[cfg(feature = "gui")]
|
||||
pub use canvas::gui::render_canvas_default;
|
||||
|
||||
#[cfg(all(feature = "gui", feature = "autocomplete"))]
|
||||
pub use autocomplete::gui::render_autocomplete_dropdown;
|
||||
#[cfg(all(feature = "gui", feature = "suggestions"))]
|
||||
pub use suggestions::gui::render_suggestions_dropdown;
|
||||
|
||||
|
||||
// First-class textarea module and exports
|
||||
#[cfg(feature = "textarea")]
|
||||
pub mod textarea;
|
||||
|
||||
#[cfg(feature = "textarea")]
|
||||
pub use textarea::{TextArea, TextAreaProvider, TextAreaState, TextAreaEditor};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/autocomplete/gui.rs
|
||||
//! Autocomplete GUI updated to work with FormEditor
|
||||
// src/suggestions/gui.rs
|
||||
//! Suggestions dropdown GUI (not inline autocomplete) updated to work with FormEditor
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use ratatui::{
|
||||
@@ -17,9 +17,9 @@ use crate::editor::FormEditor;
|
||||
#[cfg(feature = "gui")]
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// Render autocomplete dropdown for FormEditor - call this AFTER rendering canvas
|
||||
/// Render suggestions dropdown for FormEditor - call this AFTER rendering canvas
|
||||
#[cfg(feature = "gui")]
|
||||
pub fn render_autocomplete_dropdown<T: CanvasTheme, D: DataProvider>(
|
||||
pub fn render_suggestions_dropdown<T: CanvasTheme, D: DataProvider>(
|
||||
f: &mut Frame,
|
||||
frame_area: Rect,
|
||||
input_rect: Rect,
|
||||
@@ -28,14 +28,14 @@ pub fn render_autocomplete_dropdown<T: CanvasTheme, D: DataProvider>(
|
||||
) {
|
||||
let ui_state = editor.ui_state();
|
||||
|
||||
if !ui_state.is_autocomplete_active() {
|
||||
if !ui_state.is_suggestions_active() {
|
||||
return;
|
||||
}
|
||||
|
||||
if ui_state.autocomplete.is_loading {
|
||||
if ui_state.suggestions.is_loading {
|
||||
render_loading_indicator(f, frame_area, input_rect, theme);
|
||||
} else if !editor.suggestions().is_empty() {
|
||||
render_suggestions_dropdown(f, frame_area, input_rect, theme, editor.suggestions(), ui_state.autocomplete.selected_index);
|
||||
render_suggestions_dropdown_list(f, frame_area, input_rect, theme, editor.suggestions(), ui_state.suggestions.selected_index);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ fn render_loading_indicator<T: CanvasTheme>(
|
||||
|
||||
/// Show actual suggestions list
|
||||
#[cfg(feature = "gui")]
|
||||
fn render_suggestions_dropdown<T: CanvasTheme>(
|
||||
fn render_suggestions_dropdown_list<T: CanvasTheme>(
|
||||
f: &mut Frame,
|
||||
frame_area: Rect,
|
||||
input_rect: Rect,
|
||||
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};
|
||||
14
canvas/src/textarea/mod.rs
Normal file
14
canvas/src/textarea/mod.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
// src/textarea/mod.rs
|
||||
// Module routing and re-exports only. No logic here.
|
||||
|
||||
pub mod provider;
|
||||
pub mod state;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
pub mod widget;
|
||||
|
||||
pub use provider::TextAreaProvider;
|
||||
pub use state::{TextAreaEditor, TextAreaState};
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
pub use widget::TextArea;
|
||||
134
canvas/src/textarea/provider.rs
Normal file
134
canvas/src/textarea/provider.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
// src/textarea/provider.rs
|
||||
use crate::DataProvider;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TextAreaProvider {
|
||||
lines: Vec<String>,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl Default for TextAreaProvider {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
lines: vec![String::new()],
|
||||
name: "Text".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TextAreaProvider {
|
||||
pub fn from_text<S: Into<String>>(text: S) -> Self {
|
||||
let text = text.into();
|
||||
let mut lines: Vec<String> =
|
||||
text.split('\n').map(|s| s.to_string()).collect();
|
||||
if lines.is_empty() {
|
||||
lines.push(String::new());
|
||||
}
|
||||
Self {
|
||||
lines,
|
||||
name: "Text".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_text(&self) -> String {
|
||||
self.lines.join("\n")
|
||||
}
|
||||
|
||||
pub fn set_text<S: Into<String>>(&mut self, text: S) {
|
||||
let text = text.into();
|
||||
self.lines = text.split('\n').map(|s| s.to_string()).collect();
|
||||
if self.lines.is_empty() {
|
||||
self.lines.push(String::new());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn line_count(&self) -> usize {
|
||||
self.lines.len()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn char_to_byte_index(s: &str, char_idx: usize) -> usize {
|
||||
s.char_indices()
|
||||
.nth(char_idx)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or_else(|| s.len())
|
||||
}
|
||||
|
||||
pub fn split_line_at(&mut self, line_idx: usize, at_char: usize) -> usize {
|
||||
if line_idx >= self.lines.len() {
|
||||
return self.lines.len().saturating_sub(1);
|
||||
}
|
||||
let line = &mut self.lines[line_idx];
|
||||
let byte_idx = Self::char_to_byte_index(line, at_char);
|
||||
let right = line[byte_idx..].to_string();
|
||||
line.truncate(byte_idx);
|
||||
let insert_at = line_idx + 1;
|
||||
self.lines.insert(insert_at, right);
|
||||
insert_at
|
||||
}
|
||||
|
||||
pub fn join_with_next(&mut self, line_idx: usize) -> Option<usize> {
|
||||
if line_idx + 1 >= self.lines.len() {
|
||||
return None;
|
||||
}
|
||||
let left_len = self.lines[line_idx].chars().count();
|
||||
let right = self.lines.remove(line_idx + 1);
|
||||
self.lines[line_idx].push_str(&right);
|
||||
Some(left_len)
|
||||
}
|
||||
|
||||
pub fn join_with_prev(
|
||||
&mut self,
|
||||
line_idx: usize,
|
||||
) -> Option<(usize, usize)> {
|
||||
if line_idx == 0 || line_idx >= self.lines.len() {
|
||||
return None;
|
||||
}
|
||||
let prev_idx = line_idx - 1;
|
||||
let prev_len = self.lines[prev_idx].chars().count();
|
||||
let curr = self.lines.remove(line_idx);
|
||||
self.lines[prev_idx].push_str(&curr);
|
||||
Some((prev_idx, prev_len))
|
||||
}
|
||||
|
||||
pub fn insert_blank_line_after(&mut self, idx: usize) -> usize {
|
||||
let clamped = idx.min(self.lines.len());
|
||||
let insert_at = if clamped >= self.lines.len() {
|
||||
self.lines.len()
|
||||
} else {
|
||||
clamped + 1
|
||||
};
|
||||
if insert_at == self.lines.len() {
|
||||
self.lines.push(String::new());
|
||||
} else {
|
||||
self.lines.insert(insert_at, String::new());
|
||||
}
|
||||
insert_at
|
||||
}
|
||||
|
||||
pub fn insert_blank_line_before(&mut self, idx: usize) -> usize {
|
||||
let insert_at = idx.min(self.lines.len());
|
||||
self.lines.insert(insert_at, String::new());
|
||||
insert_at
|
||||
}
|
||||
}
|
||||
|
||||
impl DataProvider for TextAreaProvider {
|
||||
fn field_count(&self) -> usize {
|
||||
self.lines.len()
|
||||
}
|
||||
|
||||
fn field_name(&self, _index: usize) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn field_value(&self, index: usize) -> &str {
|
||||
self.lines.get(index).map(|s| s.as_str()).unwrap_or("")
|
||||
}
|
||||
|
||||
fn set_field_value(&mut self, index: usize, value: String) {
|
||||
if index < self.lines.len() {
|
||||
self.lines[index] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
264
canvas/src/textarea/state.rs
Normal file
264
canvas/src/textarea/state.rs
Normal file
@@ -0,0 +1,264 @@
|
||||
// src/textarea/state.rs
|
||||
use std::ops::{Deref, DerefMut};
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
|
||||
use crate::editor::FormEditor;
|
||||
use crate::textarea::provider::TextAreaProvider;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use ratatui::{layout::Rect, widgets::Block};
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
pub type TextAreaEditor = FormEditor<TextAreaProvider>;
|
||||
|
||||
pub struct TextAreaState {
|
||||
pub(crate) editor: TextAreaEditor,
|
||||
pub(crate) scroll_y: u16,
|
||||
pub(crate) wrap: bool,
|
||||
pub(crate) placeholder: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for TextAreaState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
editor: FormEditor::new(TextAreaProvider::default()),
|
||||
scroll_y: 0,
|
||||
wrap: false,
|
||||
placeholder: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expose the entire FormEditor API directly on TextAreaState
|
||||
impl Deref for TextAreaState {
|
||||
type Target = TextAreaEditor;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.editor
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for TextAreaState {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.editor
|
||||
}
|
||||
}
|
||||
|
||||
impl TextAreaState {
|
||||
pub fn from_text<S: Into<String>>(text: S) -> Self {
|
||||
let provider = TextAreaProvider::from_text(text);
|
||||
Self {
|
||||
editor: FormEditor::new(provider),
|
||||
scroll_y: 0,
|
||||
wrap: false,
|
||||
placeholder: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text(&self) -> String {
|
||||
self.editor.data_provider().to_text()
|
||||
}
|
||||
|
||||
pub fn set_text<S: Into<String>>(&mut self, text: S) {
|
||||
self.editor.data_provider_mut().set_text(text);
|
||||
self.editor.ui_state.current_field = 0;
|
||||
self.editor.ui_state.cursor_pos = 0;
|
||||
self.editor.ui_state.ideal_cursor_column = 0;
|
||||
}
|
||||
|
||||
pub fn set_wrap(&mut self, wrap: bool) {
|
||||
self.wrap = wrap;
|
||||
}
|
||||
|
||||
pub fn set_placeholder<S: Into<String>>(&mut self, s: S) {
|
||||
self.placeholder = Some(s.into());
|
||||
}
|
||||
|
||||
// Textarea-specific primitive: split at cursor
|
||||
pub fn insert_newline(&mut self) {
|
||||
let line_idx = self.current_field();
|
||||
let col = self.cursor_position();
|
||||
|
||||
let new_idx = self
|
||||
.editor
|
||||
.data_provider_mut()
|
||||
.split_line_at(line_idx, col);
|
||||
|
||||
let _ = self.transition_to_field(new_idx);
|
||||
self.move_line_start();
|
||||
self.enter_edit_mode();
|
||||
}
|
||||
|
||||
// Textarea-specific primitive: backspace with line join at start-of-line
|
||||
pub fn backspace(&mut self) {
|
||||
let col = self.cursor_position();
|
||||
if col > 0 {
|
||||
let _ = self.delete_backward();
|
||||
return;
|
||||
}
|
||||
|
||||
let line_idx = self.current_field();
|
||||
if line_idx == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some((prev_idx, new_col)) = self
|
||||
.editor
|
||||
.data_provider_mut()
|
||||
.join_with_prev(line_idx)
|
||||
{
|
||||
let _ = self.transition_to_field(prev_idx);
|
||||
self.set_cursor_position(new_col);
|
||||
self.enter_edit_mode();
|
||||
}
|
||||
}
|
||||
|
||||
// Textarea-specific primitive: delete or join with next line at EOL
|
||||
pub fn delete_forward_or_join(&mut self) {
|
||||
let line_idx = self.current_field();
|
||||
let line_len = self.current_text().chars().count();
|
||||
let col = self.cursor_position();
|
||||
|
||||
if col < line_len {
|
||||
let _ = self.delete_forward();
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(new_col) = self
|
||||
.editor
|
||||
.data_provider_mut()
|
||||
.join_with_next(line_idx)
|
||||
{
|
||||
self.set_cursor_position(new_col);
|
||||
self.enter_edit_mode();
|
||||
}
|
||||
}
|
||||
|
||||
// Override for multiline: insert new blank line below and enter insert mode.
|
||||
pub fn open_line_below(&mut self) -> Result<()> {
|
||||
let line_idx = self.current_field();
|
||||
let new_idx = self
|
||||
.editor
|
||||
.data_provider_mut()
|
||||
.insert_blank_line_after(line_idx);
|
||||
|
||||
self.transition_to_field(new_idx)?;
|
||||
self.move_line_start();
|
||||
self.enter_edit_mode();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Override for multiline: insert new blank line above and enter insert mode.
|
||||
pub fn open_line_above(&mut self) -> Result<()> {
|
||||
let line_idx = self.current_field();
|
||||
let new_idx = self
|
||||
.editor
|
||||
.data_provider_mut()
|
||||
.insert_blank_line_before(line_idx);
|
||||
|
||||
self.transition_to_field(new_idx)?;
|
||||
self.move_line_start();
|
||||
self.enter_edit_mode();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Drive from KeyEvent; you can still call all FormEditor methods directly
|
||||
pub fn input(&mut self, key: KeyEvent) {
|
||||
if key.kind != KeyEventKind::Press {
|
||||
return;
|
||||
}
|
||||
|
||||
match (key.code, key.modifiers) {
|
||||
(KeyCode::Enter, _) => self.insert_newline(),
|
||||
(KeyCode::Backspace, _) => self.backspace(),
|
||||
(KeyCode::Delete, _) => self.delete_forward_or_join(),
|
||||
|
||||
(KeyCode::Left, _) => {
|
||||
let _ = self.move_left();
|
||||
}
|
||||
(KeyCode::Right, _) => {
|
||||
let _ = self.move_right();
|
||||
}
|
||||
(KeyCode::Up, _) => {
|
||||
let _ = self.move_up();
|
||||
}
|
||||
(KeyCode::Down, _) => {
|
||||
let _ = self.move_down();
|
||||
}
|
||||
|
||||
(KeyCode::Home, _)
|
||||
| (KeyCode::Char('a'), KeyModifiers::CONTROL) => {
|
||||
self.move_line_start();
|
||||
}
|
||||
(KeyCode::End, _)
|
||||
| (KeyCode::Char('e'), KeyModifiers::CONTROL) => {
|
||||
self.move_line_end();
|
||||
}
|
||||
|
||||
// Optional: word motions
|
||||
(KeyCode::Char('b'), KeyModifiers::ALT) => self.move_word_prev(),
|
||||
(KeyCode::Char('f'), KeyModifiers::ALT) => self.move_word_next(),
|
||||
(KeyCode::Char('e'), KeyModifiers::ALT) => self.move_word_end(),
|
||||
|
||||
// Printable characters
|
||||
(KeyCode::Char(c), m) if m.is_empty() => {
|
||||
self.enter_edit_mode();
|
||||
let _ = self.insert_char(c);
|
||||
}
|
||||
|
||||
// Simple Tab policy
|
||||
(KeyCode::Tab, _) => {
|
||||
self.enter_edit_mode();
|
||||
for _ in 0..4 {
|
||||
let _ = self.insert_char(' ');
|
||||
}
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Cursor helpers for GUI
|
||||
#[cfg(feature = "gui")]
|
||||
pub fn cursor(&self, area: Rect, block: Option<&Block<'_>>) -> (u16, u16) {
|
||||
let inner = if let Some(b) = block { b.inner(area) } else { area };
|
||||
let line_idx = self.current_field() as u16;
|
||||
let y = inner.y + line_idx.saturating_sub(self.scroll_y);
|
||||
|
||||
let current_line = self.current_text();
|
||||
let col = self.display_cursor_position();
|
||||
|
||||
let mut x_off: u16 = 0;
|
||||
for (i, ch) in current_line.chars().enumerate() {
|
||||
if i >= col {
|
||||
break;
|
||||
}
|
||||
x_off = x_off
|
||||
.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16);
|
||||
}
|
||||
let x = inner.x.saturating_add(x_off);
|
||||
(x, y)
|
||||
}
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
pub(crate) fn ensure_visible(
|
||||
&mut self,
|
||||
area: Rect,
|
||||
block: Option<&Block<'_>>,
|
||||
) {
|
||||
let inner = if let Some(b) = block { b.inner(area) } else { area };
|
||||
if inner.height == 0 {
|
||||
return;
|
||||
}
|
||||
let line_idx = self.current_field() as u16;
|
||||
if line_idx < self.scroll_y {
|
||||
self.scroll_y = line_idx;
|
||||
} else if line_idx >= self.scroll_y + inner.height {
|
||||
self.scroll_y = line_idx.saturating_sub(inner.height - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
106
canvas/src/textarea/widget.rs
Normal file
106
canvas/src/textarea/widget.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
// src/textarea/widget.rs
|
||||
#[cfg(feature = "gui")]
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::{Alignment, Rect},
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::{
|
||||
Block, BorderType, Borders, Paragraph, StatefulWidget, Widget, Wrap,
|
||||
},
|
||||
};
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use crate::data_provider::DataProvider; // bring trait into scope
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use crate::textarea::state::TextAreaState;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TextArea<'a> {
|
||||
pub(crate) block: Option<Block<'a>>,
|
||||
pub(crate) style: Style,
|
||||
pub(crate) border_type: BorderType,
|
||||
}
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
impl<'a> Default for TextArea<'a> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
block: Some(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded),
|
||||
),
|
||||
style: Style::default(),
|
||||
border_type: BorderType::Rounded,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
impl<'a> TextArea<'a> {
|
||||
pub fn block(mut self, block: Block<'a>) -> Self {
|
||||
self.block = Some(block);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn style(mut self, style: Style) -> Self {
|
||||
self.style = style;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn border_type(mut self, ty: BorderType) -> Self {
|
||||
self.border_type = ty;
|
||||
if let Some(b) = &mut self.block {
|
||||
*b = b.clone().border_type(ty);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
impl<'a> StatefulWidget for TextArea<'a> {
|
||||
type State = TextAreaState;
|
||||
|
||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||
state.ensure_visible(area, self.block.as_ref());
|
||||
|
||||
let inner = if let Some(b) = &self.block {
|
||||
b.clone().render(area, buf);
|
||||
b.inner(area)
|
||||
} else {
|
||||
area
|
||||
};
|
||||
|
||||
let total = state.editor.data_provider().line_count();
|
||||
let start = state.scroll_y as usize;
|
||||
let end = start
|
||||
.saturating_add(inner.height as usize)
|
||||
.min(total);
|
||||
|
||||
let mut display_lines: Vec<Line> = Vec::with_capacity(end - start);
|
||||
|
||||
if start >= end {
|
||||
if let Some(ph) = &state.placeholder {
|
||||
display_lines.push(Line::from(Span::raw(ph.clone())));
|
||||
}
|
||||
} else {
|
||||
for i in start..end {
|
||||
let s = state.editor.data_provider().field_value(i);
|
||||
display_lines.push(Line::from(Span::raw(s.to_string())));
|
||||
}
|
||||
}
|
||||
|
||||
let mut p = Paragraph::new(display_lines)
|
||||
.alignment(Alignment::Left)
|
||||
.style(self.style);
|
||||
|
||||
if state.wrap {
|
||||
p = p.wrap(Wrap { trim: false });
|
||||
}
|
||||
|
||||
p.render(inner, buf);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,220 @@
|
||||
// src/validation/config.rs
|
||||
//! Validation configuration types and builders
|
||||
|
||||
use crate::validation::CharacterLimits;
|
||||
use serde::{Deserialize, Serialize};
|
||||
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(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ValidationConfig {
|
||||
/// Character limit configuration
|
||||
pub character_limits: Option<CharacterLimits>,
|
||||
|
||||
/// Future: Predefined patterns
|
||||
#[serde(skip)]
|
||||
pub patterns: Option<()>, // Placeholder for future implementation
|
||||
|
||||
/// Future: Reserved characters
|
||||
#[serde(skip)]
|
||||
pub reserved_chars: Option<()>, // Placeholder for future implementation
|
||||
|
||||
/// Future: Custom formatting
|
||||
#[serde(skip)]
|
||||
pub custom_formatting: Option<()>, // Placeholder for future implementation
|
||||
|
||||
|
||||
/// 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
|
||||
#[serde(skip)]
|
||||
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 {
|
||||
@@ -38,19 +226,75 @@ impl ValidationConfigBuilder {
|
||||
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
|
||||
@@ -62,10 +306,10 @@ impl ValidationConfigBuilder {
|
||||
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 },
|
||||
}
|
||||
@@ -75,12 +319,12 @@ impl ValidationResult {
|
||||
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 {
|
||||
@@ -89,147 +333,115 @@ impl ValidationResult {
|
||||
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() }
|
||||
}
|
||||
}
|
||||
|
||||
impl ValidationConfig {
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// Validate a character insertion at a specific position
|
||||
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 {
|
||||
if let Some(result) = limits.validate_insertion(current_text, position, character) {
|
||||
if !result.is_acceptable() {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Future: Add other validation types here
|
||||
|
||||
ValidationResult::Valid
|
||||
}
|
||||
|
||||
/// Validate the current text content
|
||||
pub fn validate_content(&self, text: &str) -> ValidationResult {
|
||||
// Character limits validation
|
||||
if let Some(ref limits) = self.character_limits {
|
||||
if let Some(result) = limits.validate_content(text) {
|
||||
if !result.is_acceptable() {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.patterns.is_some()
|
||||
// || self.reserved_chars.is_some()
|
||||
// || self.custom_formatting.is_some()
|
||||
// || self.external_validation.is_some()
|
||||
}
|
||||
pub fn allows_field_switch(&self, text: &str) -> bool {
|
||||
// Character limits validation
|
||||
if let Some(ref limits) = self.character_limits {
|
||||
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 {
|
||||
if let Some(reason) = limits.field_switch_block_reason(text) {
|
||||
return Some(reason);
|
||||
}
|
||||
}
|
||||
|
||||
// Future: Add other validation types here
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,42 +122,59 @@ impl CharacterLimits {
|
||||
pub fn validate_insertion(
|
||||
&self,
|
||||
current_text: &str,
|
||||
_position: usize,
|
||||
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;
|
||||
// FIX: Actually simulate the insertion at the specified position
|
||||
// This makes the `position` parameter essential to the logic
|
||||
|
||||
// 1. Create the new string by inserting the character at the correct position
|
||||
let mut new_text = String::with_capacity(current_text.len() + character.len_utf8());
|
||||
let mut chars = current_text.chars();
|
||||
|
||||
// Append characters from the original string that come before the insertion point
|
||||
// We clamp the position to be safe
|
||||
let clamped_pos = position.min(current_text.chars().count());
|
||||
for _ in 0..clamped_pos {
|
||||
if let Some(ch) = chars.next() {
|
||||
new_text.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the new character
|
||||
new_text.push(character);
|
||||
|
||||
// Append the rest of the original string
|
||||
for ch in chars {
|
||||
new_text.push(ch);
|
||||
}
|
||||
|
||||
// 2. Now perform all validation on the *actual* resulting text
|
||||
let new_count = self.count(&new_text);
|
||||
let current_count = self.count(current_text);
|
||||
|
||||
// Check max length
|
||||
if let Some(max) = self.max_length {
|
||||
if new_count > max {
|
||||
return Some(ValidationResult::error(format!(
|
||||
"Character limit exceeded: {}/{}",
|
||||
new_count,
|
||||
"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,
|
||||
"Approaching character limit: {}/{}",
|
||||
new_count,
|
||||
max
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
None // No validation issues
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,40 @@
|
||||
//! Validation module for canvas form fields
|
||||
// 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: {current}/{max}")]
|
||||
CharacterLimitExceeded { current: usize, max: usize },
|
||||
#[error("Character limit exceeded: {message}")]
|
||||
LimitExceeded { message: String },
|
||||
|
||||
#[error("Invalid character '{char}' at position {position}")]
|
||||
InvalidCharacter { char: char, position: usize },
|
||||
#[error("Pattern validation failed: {message}")]
|
||||
PatternFailed { message: String },
|
||||
|
||||
#[error("Validation configuration error: {message}")]
|
||||
ConfigurationError { message: String },
|
||||
#[error("Custom validation failed: {message}")]
|
||||
CustomFailed { message: String },
|
||||
}
|
||||
|
||||
/// Result type for validation operations
|
||||
pub type Result<T> = std::result::Result<T, ValidationError>;
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/validation/state.rs
|
||||
//! Validation state management
|
||||
|
||||
use crate::validation::{ValidationConfig, ValidationResult};
|
||||
use crate::validation::{ValidationConfig, ValidationResult, ExternalValidationState};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Validation state for all fields in a form
|
||||
@@ -18,6 +18,11 @@ pub struct ValidationState {
|
||||
|
||||
/// Global validation enabled/disabled
|
||||
enabled: bool,
|
||||
|
||||
/// External validation results per field (Feature 5)
|
||||
external_results: HashMap<usize, ExternalValidationState>,
|
||||
|
||||
last_switch_block: Option<String>,
|
||||
}
|
||||
|
||||
impl ValidationState {
|
||||
@@ -28,6 +33,8 @@ impl ValidationState {
|
||||
field_results: HashMap::new(),
|
||||
validated_fields: std::collections::HashSet::new(),
|
||||
enabled: true,
|
||||
external_results: HashMap::new(),
|
||||
last_switch_block: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +45,7 @@ impl ValidationState {
|
||||
// Clear all validation results when disabled
|
||||
self.field_results.clear();
|
||||
self.validated_fields.clear();
|
||||
self.external_results.clear(); // Also clear external results
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,12 +56,13 @@ impl ValidationState {
|
||||
|
||||
/// Set validation configuration for a field
|
||||
pub fn set_field_config(&mut self, field_index: usize, config: ValidationConfig) {
|
||||
if config.has_validation() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +76,30 @@ impl ValidationState {
|
||||
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
|
||||
@@ -122,6 +155,18 @@ impl ValidationState {
|
||||
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)
|
||||
@@ -214,6 +259,22 @@ impl ValidationState {
|
||||
error_fields: errors,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Set the last switch block reason (for UI convenience)
|
||||
pub fn set_last_switch_block<S: Into<String>>(&mut self, reason: S) {
|
||||
self.last_switch_block = Some(reason.into());
|
||||
}
|
||||
|
||||
/// Clear the last switch block reason
|
||||
pub fn clear_last_switch_block(&mut self) {
|
||||
self.last_switch_block = None;
|
||||
}
|
||||
|
||||
/// Get the last switch block reason (if any)
|
||||
pub fn last_switch_block(&self) -> Option<&str> {
|
||||
self.last_switch_block.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Summary of validation state across all fields
|
||||
|
||||
@@ -27,7 +27,7 @@ show_module() {
|
||||
|
||||
# Main modules
|
||||
show_module "canvas" "CANVAS SYSTEM"
|
||||
show_module "autocomplete" "AUTOCOMPLETE SYSTEM"
|
||||
show_module "suggestions" "SUGGESTIONS SYSTEM"
|
||||
show_module "config" "CONFIGURATION SYSTEM"
|
||||
|
||||
# Show lib.rs and other root files
|
||||
@@ -45,7 +45,7 @@ fi
|
||||
echo -e "\n\033[1;36m=========================================="
|
||||
echo "To view specific module documentation:"
|
||||
echo " ./view_canvas_docs.sh canvas"
|
||||
echo " ./view_canvas_docs.sh autocomplete"
|
||||
echo " ./view_canvas_docs.sh suggestions"
|
||||
echo " ./view_canvas_docs.sh config"
|
||||
echo "==========================================\033[0m"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user