Compare commits

..

4 Commits

Author SHA1 Message Date
Priec
dff320d534 autocomplete to suggestions 2025-08-07 12:08:02 +02:00
Priec
96cde3ca0d working examples 4 and 5 2025-08-07 00:23:45 +02:00
Priec
6ba0124779 feature5 implementation is full now 2025-08-07 00:03:11 +02:00
Priec
34c68858a3 feature4 implemented and working properly well 2025-08-06 23:16:04 +02:00
25 changed files with 2629 additions and 362 deletions

View File

@@ -2,7 +2,7 @@
## Overview ## 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 ## Key Changes
@@ -10,7 +10,7 @@ This guide covers the migration from the legacy canvas library structure to the
``` ```
# Old Structure (LEGACY) # Old Structure (LEGACY)
src/ src/
├── state.rs # Mixed canvas + autocomplete ├── state.rs # Mixed canvas + suggestions
├── actions/edit.rs # Mixed concerns ├── actions/edit.rs # Mixed concerns
├── gui/render.rs # Everything together ├── gui/render.rs # Everything together
└── suggestions.rs # Legacy file └── suggestions.rs # Legacy file
@@ -21,9 +21,9 @@ src/
│ ├── state.rs # CanvasState trait only │ ├── state.rs # CanvasState trait only
│ ├── actions/edit.rs # Canvas actions only │ ├── actions/edit.rs # Canvas actions only
│ └── gui.rs # Canvas rendering │ └── gui.rs # Canvas rendering
├── autocomplete/ # Rich autocomplete features ├── suggestions/ # Suggestions dropdown features (not inline autocomplete)
│ ├── state.rs # AutocompleteCanvasState trait │ ├── state.rs # Suggestion provider types
│ ├── types.rs # SuggestionItem, AutocompleteState │ ├── gui.rs # Suggestions dropdown rendering
│ ├── actions.rs # Autocomplete actions │ ├── actions.rs # Autocomplete actions
│ └── gui.rs # Autocomplete dropdown rendering │ └── gui.rs # Autocomplete dropdown rendering
└── dispatcher.rs # Action routing └── dispatcher.rs # Action routing
@@ -31,7 +31,7 @@ src/
### 2. **Trait Separation** ### 2. **Trait Separation**
- **CanvasState**: Core form functionality (navigation, input, validation) - **CanvasState**: Core form functionality (navigation, input, validation)
- **AutocompleteCanvasState**: Optional rich autocomplete features - Suggestions module: Optional dropdown suggestions support
### 3. **Rich Suggestions** ### 3. **Rich Suggestions**
Replaced simple string suggestions with typed, rich suggestion objects. Replaced simple string suggestions with typed, rich suggestion objects.
@@ -93,34 +93,29 @@ impl CanvasState for YourFormState {
### Step 3: Implement Rich Autocomplete (Optional) ### Step 3: Implement Rich Autocomplete (Optional)
**If you want rich autocomplete features:** **If you want suggestions dropdown features:**
```rust ```rust
use canvas::autocomplete::{AutocompleteCanvasState, SuggestionItem, AutocompleteState}; use canvas::{SuggestionItem};
impl AutocompleteCanvasState for YourFormState { impl YourFormState {
type SuggestionData = YourDataType; // e.g., Hit, CustomRecord, etc. fn supports_suggestions(&self, field_index: usize) -> bool {
// Define which fields support suggestions
fn supports_autocomplete(&self, field_index: usize) -> bool {
// Define which fields support autocomplete
matches!(field_index, 2 | 3 | 5) // Example: only certain fields matches!(field_index, 2 | 3 | 5) // Example: only certain fields
} }
fn autocomplete_state(&self) -> Option<&AutocompleteState<Self::SuggestionData>> { // Manage your own suggestion state or rely on FormEditor APIs
Some(&self.autocomplete)
}
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> { // Manage your own suggestion state or rely on FormEditor APIs
Some(&mut self.autocomplete)
}
} }
``` ```
**Add autocomplete field to your state:** **Add suggestions storage to your state (optional, if you need to persist outside the editor):**
```rust ```rust
pub struct YourFormState { pub struct YourFormState {
// ... existing fields // ... 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:** **Old rendering:**
```rust ```rust
// Manual autocomplete rendering // Manual suggestions rendering
if form_state.autocomplete_active { if editor.is_suggestions_active() {
render_autocomplete_dropdown(/* ... */); 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); let active_field_rect = render_canvas(f, area, form_state, theme, edit_mode, highlight_state);
// Optional: Rich autocomplete (if implementing AutocompleteCanvasState) // Suggestions dropdown (if active)
if form_state.is_autocomplete_active() { if editor.is_suggestions_active() {
if let Some(autocomplete_state) = form_state.autocomplete_state() { canvas::suggestions::render_suggestions_dropdown(
canvas::autocomplete::render_autocomplete_dropdown( f, f.area(), active_field_rect.unwrap(), theme, &editor
f, f.area(), active_field_rect.unwrap(), theme, autocomplete_state );
); }
}
} }
``` ```
@@ -181,16 +175,16 @@ form_state.deactivate_suggestions();
# NEW - Option A: Add your own method # NEW - Option A: Add your own method
impl YourFormState { impl YourFormState {
pub fn deactivate_autocomplete(&mut self) { pub fn deactivate_suggestions(&mut self) {
self.autocomplete_active = false; self.autocomplete_active = false;
self.autocomplete_suggestions.clear(); self.autocomplete_suggestions.clear();
self.selected_suggestion_index = None; self.selected_suggestion_index = None;
} }
} }
form_state.deactivate_autocomplete(); editor.ui_state_mut().deactivate_suggestions();
# NEW - Option B: Use rich autocomplete trait # NEW - Option B: Suggestions via editor APIs
form_state.deactivate_autocomplete(); // If implementing AutocompleteCanvasState editor.ui_state_mut().deactivate_suggestions();
``` ```
## Benefits of New Architecture ## Benefits of New Architecture
@@ -217,8 +211,8 @@ let suggestions: Vec<SuggestionItem<UserRecord>> = vec![
- **Display Overrides**: Show friendly text while storing normalized data - **Display Overrides**: Show friendly text while storing normalized data
### 4. **Future-Proof** ### 4. **Future-Proof**
- Easy to add new autocomplete features - Easy to add new suggestion features
- Canvas features don't interfere with autocomplete - Canvas features don't interfere with suggestions
- Modular: Use only what you need - Modular: Use only what you need
## Advanced Features ## Advanced Features
@@ -262,7 +256,7 @@ SuggestionItem::new(user, "John Doe (Manager)".to_string(), "123".to_string());
## Breaking Changes Summary ## Breaking Changes Summary
1. **Import paths changed**: Add `canvas::` or `dispatcher::` prefixes 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 3. **No more simple suggestions**: Use `SuggestionItem` for typed suggestions
4. **Trait split**: `AutocompleteCanvasState` is now separate and optional 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 - [ ] Updated all import paths
- [ ] Removed legacy methods from CanvasState implementation - [ ] Removed legacy methods from CanvasState implementation
- [ ] Added custom autocomplete methods if needed - [ ] Added custom suggestion methods if needed
- [ ] Updated suggestion usage to SuggestionItem - [ ] Updated usage to SuggestionItem
- [ ] Updated rendering calls - [ ] Updated rendering calls
- [ ] Tested form functionality - [ ] Tested form functionality
- [ ] Tested autocomplete functionality (if using) - [ ] Tested suggestions functionality (if using)
## Example: Complete Migration ## Example: Complete Migration
@@ -305,29 +299,25 @@ impl CanvasState for FormState {
**After:** **After:**
```rust ```rust
use canvas::canvas::{CanvasState, CanvasAction}; use canvas::canvas::{CanvasState, CanvasAction};
use canvas::autocomplete::{AutocompleteCanvasState, SuggestionItem}; use canvas::SuggestionItem;
impl CanvasState for FormState { impl CanvasState for FormState {
// Only core canvas methods, no suggestion methods // Only core canvas methods
fn current_field(&self) -> usize { /* ... */ } fn current_field(&self) -> usize { /* ... */ }
fn get_current_input(&self) -> &str { /* ... */ } fn get_current_input(&self) -> &str { /* ... */ }
// ... other core methods only // ... other core methods only
} }
impl AutocompleteCanvasState for FormState { // Use FormEditor + SuggestionsProvider for suggestions dropdown
type SuggestionData = Hit; 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 self.fields[field_index].is_link
} }
fn autocomplete_state(&self) -> Option<&AutocompleteState<Self::SuggestionData>> { // Maintain suggestion state through FormEditor and DataProvider
Some(&self.autocomplete)
}
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> { // Maintain suggestion state through FormEditor and DataProvider
Some(&mut self.autocomplete)
}
} }
``` ```

View File

@@ -30,15 +30,15 @@ tokio-test = "0.4.4"
[features] [features]
default = [] default = []
gui = ["ratatui"] gui = ["ratatui", "crossterm"]
autocomplete = ["tokio"] suggestions = ["tokio"]
cursor-style = ["crossterm"] cursor-style = ["crossterm"]
validation = ["regex"] validation = ["regex"]
[[example]] [[example]]
name = "autocomplete" name = "suggestions"
required-features = ["autocomplete", "gui"] required-features = ["suggestions", "gui", "cursor-style"]
path = "examples/autocomplete.rs" path = "examples/suggestions.rs"
[[example]] [[example]]
name = "canvas_gui_demo" name = "canvas_gui_demo"
@@ -48,3 +48,19 @@ path = "examples/canvas_gui_demo.rs"
[[example]] [[example]]
name = "validation_1" name = "validation_1"
required-features = ["gui", "validation"] required-features = ["gui", "validation"]
[[example]]
name = "validation_2"
required-features = ["gui", "validation"]
[[example]]
name = "validation_3"
required-features = ["gui", "validation"]
[[example]]
name = "validation_4"
required-features = ["gui", "validation"]
[[example]]
name = "validation_5"
required-features = ["gui", "validation"]

View File

@@ -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 - **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 - **Generic Design**: Implement `CanvasState` once, get navigation, editing, and suggestions for free
- **Vim-Like Experience**: Modal editing with familiar keybindings - **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 - **Framework Agnostic**: Works with any TUI framework or raw terminal handling
- **Async Ready**: Full async/await support for modern Rust applications - **Async Ready**: Full async/await support for modern Rust applications
- **Batch Operations**: Execute multiple actions atomically - **Batch Operations**: Execute multiple actions atomically
@@ -144,7 +144,7 @@ pub enum CanvasAction {
## 🔧 Advanced Features ## 🔧 Advanced Features
### Suggestions and Autocomplete ### Suggestions Dropdown (not inline autocomplete)
```rust ```rust
impl CanvasState for MyForm { impl CanvasState for MyForm {
@@ -170,7 +170,7 @@ impl CanvasState for MyForm {
CanvasAction::SelectSuggestion => { CanvasAction::SelectSuggestion => {
if let Some(suggestion) = self.suggestions.get_selected() { if let Some(suggestion) = self.suggestions.get_selected() {
*self.get_current_input_mut() = suggestion.clone(); *self.get_current_input_mut() = suggestion.clone();
self.deactivate_autocomplete(); self.deactivate_suggestions();
Some("Applied suggestion".to_string()) Some("Applied suggestion".to_string())
} }
None None

View File

@@ -346,7 +346,7 @@ impl DataProvider for CursorDemoData {
self.fields[index].1 = value; self.fields[index].1 = value;
} }
fn supports_autocomplete(&self, _field_index: usize) -> bool { fn supports_suggestions(&self, _field_index: usize) -> bool {
false false
} }

View File

@@ -345,7 +345,7 @@ impl DataProvider for FullDemoData {
self.fields[index].1 = value; self.fields[index].1 = value;
} }
fn supports_autocomplete(&self, _field_index: usize) -> bool { fn supports_suggestions(&self, _field_index: usize) -> bool {
false false
} }

View File

@@ -1,5 +1,5 @@
// examples/autocomplete.rs // examples/suggestions.rs
// Run with: cargo run --example autocomplete --features "autocomplete,gui" // Run with: cargo run --example suggestions --features "suggestions,gui"
use std::io; use std::io;
use crossterm::{ use crossterm::{
@@ -22,8 +22,8 @@ use canvas::{
modes::AppMode, modes::AppMode,
theme::CanvasTheme, theme::CanvasTheme,
}, },
autocomplete::gui::render_autocomplete_dropdown, suggestions::gui::render_suggestions_dropdown,
FormEditor, DataProvider, AutocompleteProvider, SuggestionItem, FormEditor, DataProvider, SuggestionsProvider, SuggestionItem,
}; };
use async_trait::async_trait; use async_trait::async_trait;
@@ -108,7 +108,7 @@ impl DataProvider for ContactForm {
} }
} }
fn supports_autocomplete(&self, field_index: usize) -> bool { fn supports_suggestions(&self, field_index: usize) -> bool {
field_index == 1 // Only email field field_index == 1 // Only email field
} }
} }
@@ -120,11 +120,9 @@ impl DataProvider for ContactForm {
struct EmailAutocomplete; struct EmailAutocomplete;
#[async_trait] #[async_trait]
impl AutocompleteProvider for EmailAutocomplete { impl SuggestionsProvider for EmailAutocomplete {
type SuggestionData = EmailSuggestion;
async fn fetch_suggestions(&mut self, _field_index: usize, query: &str) async fn fetch_suggestions(&mut self, _field_index: usize, query: &str)
-> Result<Vec<SuggestionItem<Self::SuggestionData>>> -> Result<Vec<SuggestionItem>>
{ {
// Extract domain part from email // Extract domain part from email
let (email_prefix, domain_part) = if let Some(at_pos) = query.find('@') { let (email_prefix, domain_part) = if let Some(at_pos) = query.find('@') {
@@ -153,10 +151,6 @@ impl AutocompleteProvider for EmailAutocomplete {
if domain.starts_with(&domain_part) || domain_part.is_empty() { if domain.starts_with(&domain_part) || domain_part.is_empty() {
let full_email = format!("{}@{}", email_prefix, domain); let full_email = format!("{}@{}", email_prefix, domain);
results.push(SuggestionItem { results.push(SuggestionItem {
data: EmailSuggestion {
email: full_email.clone(),
provider: provider.to_string(),
},
display_text: format!("{} ({})", full_email, provider), display_text: format!("{} ({})", full_email, provider),
value_to_store: full_email, value_to_store: full_email,
}); });
@@ -175,7 +169,7 @@ impl AutocompleteProvider for EmailAutocomplete {
struct AppState { struct AppState {
editor: FormEditor<ContactForm>, editor: FormEditor<ContactForm>,
autocomplete: EmailAutocomplete, suggestions_provider: EmailAutocomplete,
debug_message: String, debug_message: String,
} }
@@ -190,8 +184,8 @@ impl AppState {
Self { Self {
editor, editor,
autocomplete: EmailAutocomplete, suggestions_provider: EmailAutocomplete,
debug_message: "Type in email field, Tab to trigger autocomplete, Enter to select, Esc to cancel".to_string(), debug_message: "Type in email field, Tab to trigger suggestions, Enter to select, Esc to cancel".to_string(),
} }
} }
} }
@@ -207,14 +201,14 @@ async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut App
// Handle input based on key // Handle input based on key
let result = match key { let result = match key {
// === AUTOCOMPLETE KEYS === // === SUGGESTIONS KEYS ===
KeyCode::Tab => { KeyCode::Tab => {
if state.editor.is_autocomplete_active() { if state.editor.is_suggestions_active() {
state.editor.autocomplete_next(); state.editor.suggestions_next();
Ok("Navigated to next suggestion".to_string()) Ok("Navigated to next suggestion".to_string())
} else if state.editor.data_provider().supports_autocomplete(state.editor.current_field()) { } else if state.editor.data_provider().supports_suggestions(state.editor.current_field()) {
state.editor.trigger_autocomplete(&mut state.autocomplete).await state.editor.trigger_suggestions(&mut state.suggestions_provider).await
.map(|_| "Triggered autocomplete".to_string()) .map(|_| "Triggered suggestions".to_string())
} else { } else {
state.editor.move_to_next_field(); state.editor.move_to_next_field();
Ok("Moved to next field".to_string()) Ok("Moved to next field".to_string())
@@ -222,8 +216,8 @@ async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut App
} }
KeyCode::Enter => { KeyCode::Enter => {
if state.editor.is_autocomplete_active() { if state.editor.is_suggestions_active() {
if let Some(applied) = state.editor.apply_autocomplete() { if let Some(applied) = state.editor.apply_suggestion() {
Ok(format!("Applied: {}", applied)) Ok(format!("Applied: {}", applied))
} else { } else {
Ok("No suggestion to apply".to_string()) Ok("No suggestion to apply".to_string())
@@ -235,9 +229,9 @@ async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut App
} }
KeyCode::Esc => { KeyCode::Esc => {
if state.editor.is_autocomplete_active() { if state.editor.is_suggestions_active() {
// Autocomplete will be cleared automatically by mode change // Suggestions will be cleared automatically by mode change
Ok("Cancelled autocomplete".to_string()) Ok("Cancelled suggestions".to_string())
} else { } else {
// Toggle between edit and readonly mode // Toggle between edit and readonly mode
let new_mode = match state.editor.mode() { let new_mode = match state.editor.mode() {
@@ -324,9 +318,9 @@ fn ui(f: &mut Frame, state: &AppState, theme: &DemoTheme) {
theme, theme,
); );
// Render autocomplete dropdown if active // Render suggestions dropdown if active
if let Some(input_rect) = active_field_rect { if let Some(input_rect) = active_field_rect {
render_autocomplete_dropdown( render_suggestions_dropdown(
f, f,
chunks[0], chunks[0],
input_rect, input_rect,
@@ -336,8 +330,8 @@ fn ui(f: &mut Frame, state: &AppState, theme: &DemoTheme) {
} }
// Status info // Status info
let autocomplete_status = if state.editor.is_autocomplete_active() { let autocomplete_status = if state.editor.is_suggestions_active() {
if state.editor.ui_state().is_autocomplete_loading() { if state.editor.ui_state().is_suggestions_loading() {
"Loading suggestions..." "Loading suggestions..."
} else if !state.editor.suggestions().is_empty() { } else if !state.editor.suggestions().is_empty() {
"Use Tab to navigate, Enter to select, Esc to cancel" "Use Tab to navigate, Enter to select, Esc to cancel"
@@ -345,7 +339,7 @@ fn ui(f: &mut Frame, state: &AppState, theme: &DemoTheme) {
"No suggestions found" "No suggestions found"
} }
} else { } else {
"Tab to trigger autocomplete" "Tab to trigger suggestions"
}; };
let status_lines = vec![ let status_lines = vec![
@@ -354,9 +348,9 @@ fn ui(f: &mut Frame, state: &AppState, theme: &DemoTheme) {
state.editor.current_field() + 1, state.editor.current_field() + 1,
state.editor.data_provider().field_count(), state.editor.data_provider().field_count(),
state.editor.cursor_position()))), state.editor.cursor_position()))),
Line::from(Span::raw(format!("Autocomplete: {}", autocomplete_status))), Line::from(Span::raw(format!("Suggestions: {}", autocomplete_status))),
Line::from(Span::raw(state.debug_message.clone())), Line::from(Span::raw(state.debug_message.clone())),
Line::from(Span::raw("F10: Quit | Tab: Trigger/Navigate autocomplete | Enter: Select | Esc: Cancel/Toggle mode")), Line::from(Span::raw("F10: Quit | Tab: Trigger/Navigate suggestions | Enter: Select | Esc: Cancel/Toggle mode")),
]; ];
let status = Paragraph::new(status_lines) let status = Paragraph::new(status_lines)

View File

@@ -447,7 +447,7 @@ impl DataProvider for ValidationDemoData {
self.fields[index].1 = value; self.fields[index].1 = value;
} }
fn supports_autocomplete(&self, _field_index: usize) -> bool { fn supports_suggestions(&self, _field_index: usize) -> bool {
false false
} }

View File

@@ -0,0 +1,738 @@
/* examples/validation_4.rs
Enhanced Feature 4 Demo: Multiple custom formatters with comprehensive edge cases
Demonstrates:
- Multiple formatter types: PSC, Phone, Credit Card, Date
- Edge case handling: incomplete input, invalid chars, overflow
- Real-time validation feedback and format preview
- Advanced cursor position mapping
- Raw vs formatted data separation
- Error handling and fallback behavior
*/
#![allow(clippy::needless_return)]
#[cfg(not(all(feature = "validation", feature = "gui")))]
compile_error!(
"This example requires the 'validation' and 'gui' features. \
Run with: cargo run --example validation_4 --features \"gui,validation\""
);
use std::io;
use std::sync::Arc;
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
Frame, Terminal,
};
use canvas::{
canvas::{gui::render_canvas_default, modes::AppMode},
DataProvider, FormEditor,
ValidationConfig, ValidationConfigBuilder,
CustomFormatter, FormattingResult,
};
/// PSC (Postal Code) Formatter: "01001" -> "010 01"
struct PSCFormatter;
impl CustomFormatter for PSCFormatter {
fn format(&self, raw: &str) -> FormattingResult {
if raw.is_empty() {
return FormattingResult::success("");
}
// Validate: only digits allowed
if !raw.chars().all(|c| c.is_ascii_digit()) {
return FormattingResult::error("PSC must contain only digits");
}
let len = raw.chars().count();
match len {
0 => FormattingResult::success(""),
1..=3 => FormattingResult::success(raw),
4 => FormattingResult::warning(
format!("{} ", &raw[..3]),
"PSC incomplete (4/5 digits)"
),
5 => {
let formatted = format!("{} {}", &raw[..3], &raw[3..]);
if raw == "00000" {
FormattingResult::warning(formatted, "Invalid PSC: 00000")
} else {
FormattingResult::success(formatted)
}
},
_ => FormattingResult::error("PSC too long (max 5 digits)"),
}
}
}
/// Phone Number Formatter: "1234567890" -> "(123) 456-7890"
struct PhoneFormatter;
impl CustomFormatter for PhoneFormatter {
fn format(&self, raw: &str) -> FormattingResult {
if raw.is_empty() {
return FormattingResult::success("");
}
// Only digits allowed
if !raw.chars().all(|c| c.is_ascii_digit()) {
return FormattingResult::error("Phone must contain only digits");
}
let len = raw.chars().count();
match len {
0 => FormattingResult::success(""),
1..=3 => FormattingResult::success(format!("({})", raw)),
4..=6 => FormattingResult::success(format!("({}) {}", &raw[..3], &raw[3..])),
7..=10 => FormattingResult::success(format!("({}) {}-{}", &raw[..3], &raw[3..6], &raw[6..])),
10 => {
let formatted = format!("({}) {}-{}", &raw[..3], &raw[3..6], &raw[6..]);
FormattingResult::success(formatted)
},
_ => FormattingResult::warning(
format!("({}) {}-{}", &raw[..3], &raw[3..6], &raw[6..10]),
"Phone too long (extra digits ignored)"
),
}
}
}
/// Credit Card Formatter: "1234567890123456" -> "1234 5678 9012 3456"
struct CreditCardFormatter;
impl CustomFormatter for CreditCardFormatter {
fn format(&self, raw: &str) -> FormattingResult {
if raw.is_empty() {
return FormattingResult::success("");
}
if !raw.chars().all(|c| c.is_ascii_digit()) {
return FormattingResult::error("Card number must contain only digits");
}
let mut formatted = String::new();
for (i, ch) in raw.chars().enumerate() {
if i > 0 && i % 4 == 0 {
formatted.push(' ');
}
formatted.push(ch);
}
let len = raw.chars().count();
match len {
0..=15 => FormattingResult::warning(formatted, format!("Card incomplete ({}/16 digits)", len)),
16 => FormattingResult::success(formatted),
_ => FormattingResult::warning(formatted, "Card too long (extra digits shown)"),
}
}
}
/// Date Formatter: "12012024" -> "12/01/2024"
struct DateFormatter;
impl CustomFormatter for DateFormatter {
fn format(&self, raw: &str) -> FormattingResult {
if raw.is_empty() {
return FormattingResult::success("");
}
if !raw.chars().all(|c| c.is_ascii_digit()) {
return FormattingResult::error("Date must contain only digits");
}
let len = raw.len();
match len {
0 => FormattingResult::success(""),
1..=2 => FormattingResult::success(raw.to_string()),
3..=4 => FormattingResult::success(format!("{}/{}", &raw[..2], &raw[2..])),
5..=8 => FormattingResult::success(format!("{}/{}/{}", &raw[..2], &raw[2..4], &raw[4..])),
8 => {
let month = &raw[..2];
let day = &raw[2..4];
let year = &raw[4..];
// Basic validation
let m: u32 = month.parse().unwrap_or(0);
let d: u32 = day.parse().unwrap_or(0);
if m == 0 || m > 12 {
FormattingResult::warning(
format!("{}/{}/{}", month, day, year),
"Invalid month (01-12)"
)
} else if d == 0 || d > 31 {
FormattingResult::warning(
format!("{}/{}/{}", month, day, year),
"Invalid day (01-31)"
)
} else {
FormattingResult::success(format!("{}/{}/{}", month, day, year))
}
},
_ => FormattingResult::error("Date too long (MMDDYYYY format)"),
}
}
}
// Enhanced demo data with multiple formatter types
struct MultiFormatterDemoData {
fields: Vec<(String, String)>,
}
impl MultiFormatterDemoData {
fn new() -> Self {
Self {
fields: vec![
("🏁 PSC (01001)".to_string(), "".to_string()),
("📞 Phone (1234567890)".to_string(), "".to_string()),
("💳 Credit Card (16 digits)".to_string(), "".to_string()),
("📅 Date (12012024)".to_string(), "".to_string()),
("📝 Plain Text".to_string(), "".to_string()),
],
}
}
}
impl DataProvider for MultiFormatterDemoData {
fn field_count(&self) -> usize {
self.fields.len()
}
fn field_name(&self, index: usize) -> &str {
&self.fields[index].0
}
fn field_value(&self, index: usize) -> &str {
&self.fields[index].1
}
fn set_field_value(&mut self, index: usize, value: String) {
self.fields[index].1 = value;
}
#[cfg(feature = "validation")]
fn validation_config(&self, field_index: usize) -> Option<ValidationConfig> {
match field_index {
0 => Some(ValidationConfigBuilder::new()
.with_custom_formatter(Arc::new(PSCFormatter))
.with_max_length(5)
.build()),
1 => Some(ValidationConfigBuilder::new()
.with_custom_formatter(Arc::new(PhoneFormatter))
.with_max_length(12)
.build()),
2 => Some(ValidationConfigBuilder::new()
.with_custom_formatter(Arc::new(CreditCardFormatter))
.with_max_length(20)
.build()),
3 => Some(ValidationConfigBuilder::new()
.with_custom_formatter(Arc::new(DateFormatter))
.with_max_length(8)
.build()),
4 => Some(ValidationConfigBuilder::new()
.with_custom_formatter(Arc::new(DateFormatter))
.with_max_length(8)
.build()),
_ => None, // Plain text field - no formatter
}
}
}
// Enhanced demo editor with comprehensive status tracking
struct EnhancedDemoEditor<D: DataProvider> {
editor: FormEditor<D>,
debug_message: String,
validation_enabled: bool,
show_raw_data: bool,
show_cursor_details: bool,
example_mode: usize,
}
impl<D: DataProvider> EnhancedDemoEditor<D> {
fn new(data_provider: D) -> Self {
let mut editor = FormEditor::new(data_provider);
editor.set_validation_enabled(true);
Self {
editor,
debug_message: "🧩 Enhanced Custom Formatter Demo - Multiple formatters with rich edge cases!".to_string(),
validation_enabled: true,
show_raw_data: false,
show_cursor_details: false,
example_mode: 0,
}
}
// Field type detection
fn current_field_type(&self) -> &'static str {
match self.editor.current_field() {
0 => "PSC",
1 => "Phone",
2 => "Credit Card",
3 => "Date",
_ => "Plain Text",
}
}
fn has_formatter(&self) -> bool {
self.editor.current_field() < 5 // First 5 fields have formatters
}
fn get_input_rules(&self) -> &'static str {
match self.editor.current_field() {
0 => "5 digits only (PSC format)",
1 => "10+ digits (US phone format)",
2 => "16+ digits (credit card)",
3 => "Digits as cents (12345 = $123.45)",
4 => "8 digits MMDDYYYY (date format)",
_ => "Any text (no formatting)",
}
}
fn cycle_example_data(&mut self) {
let examples = [
// PSC examples
vec!["01001", "1234567890", "1234567890123456", "12345", "12012024", "Plain text here"],
// Incomplete examples
vec!["010", "123", "1234", "123", "1201", "More text"],
// Invalid examples (will show error handling)
vec!["0abc1", "12a45", "123abc", "abc", "ab01cd", "Special chars!"],
// Edge cases
vec!["00000", "0000000000", "0000000000000000", "99", "13012024", ""],
];
self.example_mode = (self.example_mode + 1) % examples.len();
let current_examples = &examples[self.example_mode];
for (i, example) in current_examples.iter().enumerate() {
if i < self.editor.data_provider().field_count() {
self.editor.data_provider_mut().set_field_value(i, example.to_string());
}
}
let mode_names = ["Valid Examples", "Incomplete Input", "Invalid Characters", "Edge Cases"];
self.debug_message = format!("📋 Loaded: {}", mode_names[self.example_mode]);
}
// Enhanced status methods
fn toggle_validation(&mut self) {
self.validation_enabled = !self.validation_enabled;
self.editor.set_validation_enabled(self.validation_enabled);
self.debug_message = if self.validation_enabled {
"✅ Custom Formatters ENABLED".to_string()
} else {
"❌ Custom Formatters DISABLED".to_string()
};
}
fn toggle_raw_data_view(&mut self) {
self.show_raw_data = !self.show_raw_data;
self.debug_message = if self.show_raw_data {
"👁️ Showing RAW data focus".to_string()
} else {
"✨ Showing FORMATTED display focus".to_string()
};
}
fn toggle_cursor_details(&mut self) {
self.show_cursor_details = !self.show_cursor_details;
self.debug_message = if self.show_cursor_details {
"📍 Detailed cursor mapping info ON".to_string()
} else {
"📍 Detailed cursor mapping info OFF".to_string()
};
}
fn get_current_field_analysis(&self) -> (String, String, String, Option<String>) {
let raw = self.editor.current_text();
let display = self.editor.current_display_text();
let status = if raw == display {
if self.has_formatter() {
if self.mode() == AppMode::Edit {
"Raw (editing)".to_string()
} else {
"No formatting needed".to_string()
}
} else {
"No formatter".to_string()
}
} else {
"Custom formatted".to_string()
};
let warning = if self.validation_enabled && self.has_formatter() {
// Check if there are any formatting warnings
if raw.len() > 0 {
match self.editor.current_field() {
0 if raw.len() < 5 => Some(format!("PSC incomplete: {}/5", raw.len())),
1 if raw.len() < 10 => Some(format!("Phone incomplete: {}/10", raw.len())),
2 if raw.len() < 16 => Some(format!("Card incomplete: {}/16", raw.len())),
4 if raw.len() < 8 => Some(format!("Date incomplete: {}/8", raw.len())),
_ => None,
}
} else {
None
}
} else {
None
};
(raw.to_string(), display, status, warning)
}
// Delegate methods with enhanced feedback
fn enter_edit_mode(&mut self) {
self.editor.enter_edit_mode();
let field_type = self.current_field_type();
let rules = self.get_input_rules();
self.debug_message = format!("✏️ EDITING {} - {}", field_type, rules);
}
fn exit_edit_mode(&mut self) {
self.editor.exit_edit_mode();
let (raw, display, _, warning) = self.get_current_field_analysis();
if let Some(warn) = warning {
self.debug_message = format!("🔒 NORMAL - {} | ⚠️ {}", self.current_field_type(), warn);
} else if raw != display {
self.debug_message = format!("🔒 NORMAL - {} formatted successfully", self.current_field_type());
} else {
self.debug_message = "🔒 NORMAL MODE".to_string();
}
}
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
let result = self.editor.insert_char(ch);
if result.is_ok() {
let (raw, display, _, _) = self.get_current_field_analysis();
if raw != display && self.validation_enabled {
self.debug_message = format!("✏️ '{}' added - Real-time formatting active", ch);
} else {
self.debug_message = format!("✏️ '{}' added", ch);
}
}
result
}
// Position mapping demo
fn show_position_mapping(&mut self) {
if !self.has_formatter() {
self.debug_message = "📍 No position mapping (plain text field)".to_string();
return;
}
let raw_pos = self.editor.cursor_position();
let display_pos = self.editor.display_cursor_position();
let raw = self.editor.current_text();
let display = self.editor.current_display_text();
if raw_pos != display_pos {
self.debug_message = format!(
"🗺️ Position mapping: Raw[{}]='{}' ↔ Display[{}]='{}'",
raw_pos,
raw.chars().nth(raw_pos).unwrap_or('∅'),
display_pos,
display.chars().nth(display_pos).unwrap_or('∅')
);
} else {
self.debug_message = format!("📍 Cursor at position {} (no mapping needed)", raw_pos);
}
}
// Delegate remaining methods
fn mode(&self) -> AppMode { self.editor.mode() }
fn current_field(&self) -> usize { self.editor.current_field() }
fn cursor_position(&self) -> usize { self.editor.cursor_position() }
fn data_provider(&self) -> &D { self.editor.data_provider() }
fn data_provider_mut(&mut self) -> &mut D { self.editor.data_provider_mut() }
fn ui_state(&self) -> &canvas::EditorState { self.editor.ui_state() }
fn move_up(&mut self) { let _ = self.editor.move_up(); }
fn move_down(&mut self) { let _ = self.editor.move_down(); }
fn move_left(&mut self) { let _ = self.editor.move_left(); }
fn move_right(&mut self) { let _ = self.editor.move_right(); }
fn delete_backward(&mut self) -> anyhow::Result<()> { self.editor.delete_backward() }
fn delete_forward(&mut self) -> anyhow::Result<()> { self.editor.delete_forward() }
fn next_field(&mut self) { let _ = self.editor.next_field(); }
fn prev_field(&mut self) { let _ = self.editor.prev_field(); }
}
// Enhanced key handling
fn handle_key_press(
key: KeyCode,
modifiers: KeyModifiers,
editor: &mut EnhancedDemoEditor<MultiFormatterDemoData>,
) -> anyhow::Result<bool> {
let mode = editor.mode();
// Quit
if matches!(key, KeyCode::F(10)) ||
(key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL)) ||
(key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) {
return Ok(false);
}
match (mode, key, modifiers) {
// Mode transitions
(AppMode::ReadOnly, KeyCode::Char('i'), _) => editor.enter_edit_mode(),
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
editor.editor.enter_append_mode();
editor.debug_message = format!("✏️ APPEND {} - {}", editor.current_field_type(), editor.get_input_rules());
},
(_, KeyCode::Esc, _) => editor.exit_edit_mode(),
// Enhanced demo features
(AppMode::ReadOnly, KeyCode::Char('e'), _) => editor.cycle_example_data(),
(AppMode::ReadOnly, KeyCode::Char('r'), _) => editor.toggle_raw_data_view(),
(AppMode::ReadOnly, KeyCode::Char('c'), _) => editor.toggle_cursor_details(),
(AppMode::ReadOnly, KeyCode::Char('m'), _) => editor.show_position_mapping(),
(AppMode::ReadOnly, KeyCode::F(1), _) => editor.toggle_validation(),
// Movement
(_, KeyCode::Up, _) | (AppMode::ReadOnly, KeyCode::Char('k'), _) => editor.move_up(),
(_, KeyCode::Down, _) | (AppMode::ReadOnly, KeyCode::Char('j'), _) => editor.move_down(),
(_, KeyCode::Left, _) | (AppMode::ReadOnly, KeyCode::Char('h'), _) => editor.move_left(),
(_, KeyCode::Right, _) | (AppMode::ReadOnly, KeyCode::Char('l'), _) => editor.move_right(),
(_, KeyCode::Tab, _) => editor.next_field(),
(_, KeyCode::BackTab, _) => editor.prev_field(),
// Editing
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
editor.insert_char(c)?;
},
(AppMode::Edit, KeyCode::Backspace, _) => { editor.delete_backward()?; },
(AppMode::Edit, KeyCode::Delete, _) => { editor.delete_forward()?; },
// Field analysis
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
let (raw, display, status, warning) = editor.get_current_field_analysis();
let warning_text = warning.map(|w| format!(" ⚠️ {}", w)).unwrap_or_default();
editor.debug_message = format!(
"🔍 Field {}: {} | Raw: '{}' | Display: '{}'{}",
editor.current_field() + 1, status, raw, display, warning_text
);
},
_ => {}
}
Ok(true)
}
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut editor: EnhancedDemoEditor<MultiFormatterDemoData>,
) -> io::Result<()> {
loop {
terminal.draw(|f| ui(f, &editor))?;
if let Event::Key(key) = event::read()? {
match handle_key_press(key.code, key.modifiers, &mut editor) {
Ok(should_continue) => {
if !should_continue {
break;
}
}
Err(e) => {
editor.debug_message = format!("❌ Error: {}", e);
}
}
}
}
Ok(())
}
fn ui(f: &mut Frame, editor: &EnhancedDemoEditor<MultiFormatterDemoData>) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(18)])
.split(f.area());
render_canvas_default(f, chunks[0], &editor.editor);
render_enhanced_status(f, chunks[1], editor);
}
fn render_enhanced_status(
f: &mut Frame,
area: Rect,
editor: &EnhancedDemoEditor<MultiFormatterDemoData>,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Status bar
Constraint::Length(6), // Current field analysis
Constraint::Length(9), // Help
])
.split(area);
// Status bar
let mode_text = match editor.mode() {
AppMode::Edit => "INSERT",
AppMode::ReadOnly => "NORMAL",
_ => "OTHER",
};
let formatter_count = (0..editor.data_provider().field_count())
.filter(|&i| editor.data_provider().validation_config(i).is_some())
.count();
let status_text = format!(
"-- {} -- {} | Formatters: {}/{} active | View: {}{}",
mode_text,
editor.debug_message,
formatter_count,
editor.data_provider().field_count(),
if editor.show_raw_data { "RAW" } else { "DISPLAY" },
if editor.show_cursor_details { " | CURSOR+" } else { "" }
);
let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("🧩 Enhanced Custom Formatter Demo"));
f.render_widget(status, chunks[0]);
// Current field analysis
let (raw, display, status, warning) = editor.get_current_field_analysis();
let field_name = editor.data_provider().field_name(editor.current_field());
let field_type = editor.current_field_type();
let mut analysis_lines = vec![
format!("📝 Current: {} ({})", field_name, field_type),
format!("🔧 Status: {}", status),
];
if editor.show_raw_data || editor.mode() == AppMode::Edit {
analysis_lines.push(format!("💾 Raw Data: '{}'", raw));
analysis_lines.push(format!("✨ Display: '{}'", display));
} else {
analysis_lines.push(format!("✨ User Sees: '{}'", display));
analysis_lines.push(format!("💾 Stored As: '{}'", raw));
}
if editor.show_cursor_details {
analysis_lines.push(format!(
"📍 Cursor: Raw[{}] → Display[{}]",
editor.cursor_position(),
editor.editor.display_cursor_position()
));
}
if let Some(ref warn) = warning {
analysis_lines.push(format!("⚠️ Warning: {}", warn));
}
let analysis_color = if warning.is_some() {
Color::Yellow
} else if raw != display && editor.validation_enabled {
Color::Green
} else {
Color::Gray
};
let analysis = Paragraph::new(analysis_lines.join("\n"))
.block(Block::default().borders(Borders::ALL).title("🔍 Field Analysis"))
.style(Style::default().fg(analysis_color))
.wrap(Wrap { trim: true });
f.render_widget(analysis, chunks[1]);
// Enhanced help
let help_text = match editor.mode() {
AppMode::ReadOnly => {
"🧩 ENHANCED CUSTOM FORMATTER DEMO\n\
\n\
Try these formatters:
• PSC: 01001 → 010 01 | Phone: 1234567890 → (123) 456-7890 | Card: 1234567890123456 → 1234 5678 9012 3456
• Date: 12012024 → 12/01/2024 | Plain: no formatting
\n\
Commands: i=insert, e=cycle examples, r=toggle raw/display, c=cursor details, m=position mapping\n\
Movement: hjkl/arrows, Tab=next field, ?=analyze current field, F1=toggle formatters\n\
Ctrl+C/F10=quit"
}
AppMode::Edit => {
"✏️ INSERT MODE - Real-time formatting as you type!\n\
\n\
Current field rules: {}\n\
• Raw input is authoritative (what gets stored)\n\
• Display formatting updates in real-time (what users see)\n\
• Cursor position is mapped between raw and display\n\
\n\
Esc=normal mode, arrows=navigate, Backspace/Del=delete"
}
_ => "🧩 Enhanced Custom Formatter Demo"
};
let formatted_help = if editor.mode() == AppMode::Edit {
help_text.replace("{}", editor.get_input_rules())
} else {
help_text.to_string()
};
let help = Paragraph::new(formatted_help)
.block(Block::default().borders(Borders::ALL).title("🚀 Enhanced Features & Commands"))
.style(Style::default().fg(Color::Gray))
.wrap(Wrap { trim: true });
f.render_widget(help, chunks[2]);
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🧩 Enhanced Canvas Custom Formatter Demo (Feature 4)");
println!("✅ validation feature: ENABLED");
println!("✅ gui feature: ENABLED");
println!("🧩 Enhanced features:");
println!(" • 5 different custom formatters with edge cases");
println!(" • Real-time format preview and validation");
println!(" • Advanced cursor position mapping");
println!(" • Comprehensive error handling and warnings");
println!(" • Raw vs formatted data separation demos");
println!();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let data = MultiFormatterDemoData::new();
let editor = EnhancedDemoEditor::new(data);
let res = run_app(&mut terminal, editor);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err);
}
println!("🧩 Enhanced custom formatter demo completed!");
println!("🏆 You experienced comprehensive custom formatting with:");
println!(" • Multiple formatter types (PSC, Phone, Credit Card, Date)");
println!(" • Edge case handling (incomplete, invalid, overflow)");
println!(" • Real-time format preview and cursor mapping");
println!(" • Clear separation between raw business data and display formatting");
Ok(())
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -30,12 +30,12 @@ pub enum CanvasAction {
DeleteBackward, DeleteBackward,
DeleteForward, DeleteForward,
// Autocomplete actions // Suggestions actions
TriggerAutocomplete, TriggerSuggestions,
SuggestionUp, SuggestionUp,
SuggestionDown, SuggestionDown,
SelectSuggestion, SelectSuggestion,
ExitSuggestions, ExitSuggestions,
// Custom actions // Custom actions
Custom(String), Custom(String),
@@ -101,7 +101,7 @@ impl CanvasAction {
Self::InsertChar(_c) => "insert character", Self::InsertChar(_c) => "insert character",
Self::DeleteBackward => "delete backward", Self::DeleteBackward => "delete backward",
Self::DeleteForward => "delete forward", Self::DeleteForward => "delete forward",
Self::TriggerAutocomplete => "trigger autocomplete", Self::TriggerSuggestions => "trigger suggestions",
Self::SuggestionUp => "suggestion up", Self::SuggestionUp => "suggestion up",
Self::SuggestionDown => "suggestion down", Self::SuggestionDown => "suggestion down",
Self::SelectSuggestion => "select suggestion", Self::SelectSuggestion => "select suggestion",
@@ -139,10 +139,10 @@ impl CanvasAction {
] ]
} }
/// Get all autocomplete-related actions /// Get all suggestions-related actions
pub fn autocomplete_actions() -> Vec<CanvasAction> { pub fn suggestions_actions() -> Vec<CanvasAction> {
vec![ vec![
Self::TriggerAutocomplete, Self::TriggerSuggestions,
Self::SuggestionUp, Self::SuggestionUp,
Self::SuggestionDown, Self::SuggestionDown,
Self::SelectSuggestion, Self::SelectSuggestion,

View File

@@ -19,7 +19,7 @@ use crate::editor::FormEditor;
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
use std::cmp::{max, min}; 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 /// Updated to work with FormEditor instead of CanvasState trait
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
pub fn render_canvas<T: CanvasTheme, D: DataProvider>( pub fn render_canvas<T: CanvasTheme, D: DataProvider>(
@@ -53,24 +53,10 @@ pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
for i in 0..field_count { for i in 0..field_count {
fields.push(data_provider.field_name(i)); fields.push(data_provider.field_name(i));
// Use display text that applies masks if configured // Use editor-provided effective display text per field (Feature 4/mask aware)
#[cfg(feature = "validation")] #[cfg(feature = "validation")]
{ {
if i == editor.current_field() { inputs.push(editor.display_text_for_field(i));
inputs.push(editor.current_display_text());
} else {
// For non-current fields, we need to apply mask manually
let raw = data_provider.field_value(i);
if let Some(cfg) = editor.ui_state().validation_state().get_field_config(i) {
if let Some(mask) = &cfg.display_mask {
inputs.push(mask.apply_to_display(raw));
} else {
inputs.push(raw.to_string());
}
} else {
inputs.push(raw.to_string());
}
}
} }
#[cfg(not(feature = "validation"))] #[cfg(not(feature = "validation"))]
{ {
@@ -93,23 +79,10 @@ pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
editor.display_cursor_position(), // Use display cursor position for masks editor.display_cursor_position(), // Use display cursor position for masks
false, // TODO: track unsaved changes in editor false, // TODO: track unsaved changes in editor
|i| { |i| {
// Get display value for field i // Get display value for field i using editor logic (Feature 4 + masks)
#[cfg(feature = "validation")] #[cfg(feature = "validation")]
{ {
if i == editor.current_field() { editor.display_text_for_field(i)
editor.current_display_text()
} else {
let raw = data_provider.field_value(i);
if let Some(cfg) = editor.ui_state().validation_state().get_field_config(i) {
if let Some(mask) = &cfg.display_mask {
mask.apply_to_display(raw)
} else {
raw.to_string()
}
} else {
raw.to_string()
}
}
} }
#[cfg(not(feature = "validation"))] #[cfg(not(feature = "validation"))]
{ {
@@ -117,12 +90,21 @@ pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
} }
}, },
|i| { |i| {
// Check if field has display override (mask) // Check if field has display override (custom formatter or mask)
#[cfg(feature = "validation")] #[cfg(feature = "validation")]
{ {
editor.ui_state().validation_state().get_field_config(i) editor.ui_state().validation_state().get_field_config(i)
.and_then(|cfg| cfg.display_mask.as_ref()) .map(|cfg| {
.is_some() // Formatter takes precedence; if present, it's a display override
#[allow(unused_mut)]
let mut has_override = false;
#[cfg(feature = "validation")]
{
has_override = cfg.custom_formatter.is_some();
}
has_override || cfg.display_mask.is_some()
})
.unwrap_or(false)
} }
#[cfg(not(feature = "validation"))] #[cfg(not(feature = "validation"))]
{ {

View File

@@ -14,8 +14,8 @@ pub struct EditorState {
// Mode state // Mode state
pub(crate) current_mode: AppMode, pub(crate) current_mode: AppMode,
// Autocomplete state // Suggestions dropdown state
pub(crate) autocomplete: AutocompleteUIState, pub(crate) suggestions: SuggestionsUIState,
// Selection state (for vim visual mode) // Selection state (for vim visual mode)
pub(crate) selection: SelectionState, pub(crate) selection: SelectionState,
@@ -26,7 +26,7 @@ pub struct EditorState {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AutocompleteUIState { pub struct SuggestionsUIState {
pub(crate) is_active: bool, pub(crate) is_active: bool,
pub(crate) is_loading: bool, pub(crate) is_loading: bool,
pub(crate) selected_index: Option<usize>, pub(crate) selected_index: Option<usize>,
@@ -47,7 +47,7 @@ impl EditorState {
cursor_pos: 0, cursor_pos: 0,
ideal_cursor_column: 0, ideal_cursor_column: 0,
current_mode: AppMode::Edit, current_mode: AppMode::Edit,
autocomplete: AutocompleteUIState { suggestions: SuggestionsUIState {
is_active: false, is_active: false,
is_loading: false, is_loading: false,
selected_index: None, selected_index: None,
@@ -83,14 +83,14 @@ impl EditorState {
self.current_mode self.current_mode
} }
/// Check if autocomplete is active (for user's business logic) /// Check if suggestions dropdown is active (for user's business logic)
pub fn is_autocomplete_active(&self) -> bool { pub fn is_suggestions_active(&self) -> bool {
self.autocomplete.is_active self.suggestions.is_active
} }
/// Check if autocomplete is loading (for user's business logic) /// Check if suggestions dropdown is loading (for user's business logic)
pub fn is_autocomplete_loading(&self) -> bool { pub fn is_suggestions_loading(&self) -> bool {
self.autocomplete.is_loading self.suggestions.is_loading
} }
/// Get selection state (for user's business logic) /// Get selection state (for user's business logic)
@@ -128,18 +128,18 @@ impl EditorState {
self.ideal_cursor_column = self.cursor_pos; self.ideal_cursor_column = self.cursor_pos;
} }
pub(crate) fn activate_autocomplete(&mut self, field_index: usize) { pub(crate) fn activate_suggestions(&mut self, field_index: usize) {
self.autocomplete.is_active = true; self.suggestions.is_active = true;
self.autocomplete.is_loading = true; self.suggestions.is_loading = true;
self.autocomplete.active_field = Some(field_index); self.suggestions.active_field = Some(field_index);
self.autocomplete.selected_index = None; self.suggestions.selected_index = None;
} }
pub(crate) fn deactivate_autocomplete(&mut self) { pub(crate) fn deactivate_suggestions(&mut self) {
self.autocomplete.is_active = false; self.suggestions.is_active = false;
self.autocomplete.is_loading = false; self.suggestions.is_loading = false;
self.autocomplete.active_field = None; self.suggestions.active_field = None;
self.autocomplete.selected_index = None; self.suggestions.selected_index = None;
} }
} }

View File

@@ -18,8 +18,8 @@ pub trait DataProvider {
/// Set field value (library calls this when text changes) /// Set field value (library calls this when text changes)
fn set_field_value(&mut self, index: usize, value: String); fn set_field_value(&mut self, index: usize, value: String);
/// Check if field supports autocomplete (optional) /// Check if field supports suggestions (optional)
fn supports_autocomplete(&self, _field_index: usize) -> bool { fn supports_suggestions(&self, _field_index: usize) -> bool {
false false
} }
@@ -36,10 +36,10 @@ pub trait DataProvider {
} }
} }
/// Optional: User implements this for autocomplete data /// Optional: User implements this for suggestions data
#[async_trait] #[async_trait]
pub trait AutocompleteProvider { pub trait SuggestionsProvider {
/// Fetch autocomplete suggestions (user's business logic) /// Fetch suggestions (user's business logic)
async fn fetch_suggestions(&mut self, field_index: usize, query: &str) async fn fetch_suggestions(&mut self, field_index: usize, query: &str)
-> Result<Vec<SuggestionItem>>; -> Result<Vec<SuggestionItem>>;
} }

View File

@@ -7,7 +7,7 @@ use crate::canvas::CursorManager;
use anyhow::Result; use anyhow::Result;
use crate::canvas::state::EditorState; use crate::canvas::state::EditorState;
use crate::data_provider::{DataProvider, AutocompleteProvider, SuggestionItem}; use crate::data_provider::{DataProvider, SuggestionsProvider, SuggestionItem};
use crate::canvas::modes::AppMode; use crate::canvas::modes::AppMode;
use crate::canvas::state::SelectionState; use crate::canvas::state::SelectionState;
@@ -70,9 +70,9 @@ impl<D: DataProvider> FormEditor<D> {
self.ui_state.mode() self.ui_state.mode()
} }
/// Check if autocomplete is active (for user's logic) /// Check if suggestions dropdown is active (for user's logic)
pub fn is_autocomplete_active(&self) -> bool { pub fn is_suggestions_active(&self) -> bool {
self.ui_state.is_autocomplete_active() self.ui_state.is_suggestions_active()
} }
/// Get current field text (convenience method) /// Get current field text (convenience method)
@@ -85,7 +85,14 @@ impl<D: DataProvider> FormEditor<D> {
} }
} }
/// Get current field text for display, applying mask if configured /// Get current field text for display.
///
/// Policies:
/// - Feature 4 (custom formatter):
/// - While editing the focused field: ALWAYS show raw (no custom formatting).
/// - When not editing the field: show formatted (fallback to raw on error).
/// - Mask-only fields: mask applies even in Edit mode (preserve legacy behavior).
/// - Otherwise: raw.
#[cfg(feature = "validation")] #[cfg(feature = "validation")]
pub fn current_display_text(&self) -> String { pub fn current_display_text(&self) -> String {
let field_index = self.ui_state.current_field; let field_index = self.ui_state.current_field;
@@ -96,10 +103,29 @@ impl<D: DataProvider> FormEditor<D> {
}; };
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) { if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
// 1) Mask-only fields: mask applies even in Edit (legacy behavior)
if cfg.custom_formatter.is_none() {
if let Some(mask) = &cfg.display_mask {
return mask.apply_to_display(raw);
}
}
// 2) Feature 4 fields: raw while editing, formatted otherwise
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;
}
}
// 3) Fallback to mask if present (when formatter didn't produce output)
if let Some(mask) = &cfg.display_mask { if let Some(mask) = &cfg.display_mask {
return mask.apply_to_display(raw); return mask.apply_to_display(raw);
} }
} }
raw.to_string() raw.to_string()
} }
@@ -108,6 +134,71 @@ impl<D: DataProvider> FormEditor<D> {
&self.ui_state &self.ui_state
} }
/// Set external validation state for a field (Feature 5)
#[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);
}
/// Clear external validation state for a field (Feature 5)
#[cfg(feature = "validation")]
pub fn clear_external_validation(&mut self, field_index: usize) {
self.ui_state.validation.clear_external_validation(field_index);
}
/// Get effective display text for any field index.
///
/// Policies:
/// - Feature 4 fields (with custom formatter):
/// - If the field is currently focused AND in Edit mode: return raw (no formatting).
/// - Otherwise: return formatted (fallback to raw on error).
/// - Mask-only fields: mask applies regardless of mode (legacy behavior).
/// - Otherwise: raw.
#[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) {
// Mask-only fields: mask applies even in Edit mode
if cfg.custom_formatter.is_none() {
if let Some(mask) = &cfg.display_mask {
return mask.apply_to_display(raw);
}
}
// Feature 4 fields:
if cfg.custom_formatter.is_some() {
// Focused + Edit -> raw
if field_index == self.ui_state.current_field
&& matches!(self.ui_state.current_mode, AppMode::Edit)
{
return raw.to_string();
}
// Not editing -> formatted
if let Some((formatted, _mapper, _warning)) = cfg.run_custom_formatter(raw) {
return formatted;
}
}
// Fallback to mask if present (in case formatter didn't return output)
if let Some(mask) = &cfg.display_mask {
return mask.apply_to_display(raw);
}
}
raw.to_string()
}
/// Get reference to data provider for rendering /// Get reference to data provider for rendering
pub fn data_provider(&self) -> &D { pub fn data_provider(&self) -> &D {
&self.data_provider &self.data_provider
@@ -489,50 +580,51 @@ impl<D: DataProvider> FormEditor<D> {
} }
// =================================================================== // ===================================================================
// ASYNC OPERATIONS: Only autocomplete needs async // ASYNC OPERATIONS: Only suggestions need async
// =================================================================== // ===================================================================
/// Trigger autocomplete (async because it fetches data) /// Trigger suggestions (async because it fetches data)
pub async fn trigger_autocomplete<A>(&mut self, provider: &mut A) -> Result<()> pub async fn trigger_suggestions<A>(&mut self, provider: &mut A) -> Result<()>
where where
A: AutocompleteProvider, A: SuggestionsProvider,
{ {
let field_index = self.ui_state.current_field; let field_index = self.ui_state.current_field;
if !self.data_provider.supports_autocomplete(field_index) { if !self.data_provider.supports_suggestions(field_index) {
return Ok(()); return Ok(());
} }
// Activate autocomplete UI // Activate suggestions UI
self.ui_state.activate_autocomplete(field_index); self.ui_state.activate_suggestions(field_index);
// Fetch suggestions from user (no conversion needed!) // Fetch suggestions from user (no conversion needed!)
let query = self.current_text(); let query = self.current_text();
self.suggestions = provider.fetch_suggestions(field_index, query).await?; self.suggestions = provider.fetch_suggestions(field_index, query).await?;
// Update UI state // Update UI state
self.ui_state.autocomplete.is_loading = false; self.ui_state.suggestions.is_loading = false;
if !self.suggestions.is_empty() { if !self.suggestions.is_empty() {
self.ui_state.autocomplete.selected_index = Some(0); self.ui_state.suggestions.selected_index = Some(0);
} }
Ok(()) Ok(())
} }
/// Navigate autocomplete suggestions /// Navigate suggestions
pub fn autocomplete_next(&mut self) { pub fn suggestions_next(&mut self) {
if !self.ui_state.autocomplete.is_active || self.suggestions.is_empty() { if !self.ui_state.suggestions.is_active || self.suggestions.is_empty() {
return; return;
} }
let current = self.ui_state.autocomplete.selected_index.unwrap_or(0); let current = self.ui_state.suggestions.selected_index.unwrap_or(0);
let next = (current + 1) % self.suggestions.len(); let next = (current + 1) % self.suggestions.len();
self.ui_state.autocomplete.selected_index = Some(next); self.ui_state.suggestions.selected_index = Some(next);
} }
/// Apply selected autocomplete suggestion /// Apply selected suggestion
pub fn apply_autocomplete(&mut self) -> Option<String> { /// Apply selected suggestion
if let Some(selected_index) = self.ui_state.autocomplete.selected_index { 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() { if let Some(suggestion) = self.suggestions.get(selected_index).cloned() {
let field_index = self.ui_state.current_field; let field_index = self.ui_state.current_field;
@@ -546,8 +638,8 @@ impl<D: DataProvider> FormEditor<D> {
self.ui_state.cursor_pos = suggestion.value_to_store.len(); self.ui_state.cursor_pos = suggestion.value_to_store.len();
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos; self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
// Close autocomplete // Close suggestions
self.ui_state.deactivate_autocomplete(); self.ui_state.deactivate_suggestions();
self.suggestions.clear(); self.suggestions.clear();
// Validate the new content if validation is enabled // Validate the new content if validation is enabled
@@ -860,8 +952,8 @@ impl<D: DataProvider> FormEditor<D> {
} }
self.set_mode(AppMode::ReadOnly); self.set_mode(AppMode::ReadOnly);
// Deactivate autocomplete when exiting edit mode // Deactivate suggestions when exiting edit mode
self.ui_state.deactivate_autocomplete(); self.ui_state.deactivate_suggestions();
Ok(()) Ok(())
} }
@@ -959,7 +1051,7 @@ impl<D: DataProvider> FormEditor<D> {
self.ui_state.ideal_cursor_column = clamped_pos; self.ui_state.ideal_cursor_column = clamped_pos;
} }
/// Get cursor position for display (maps raw cursor to display position with mask) /// Get cursor position for display (maps raw cursor to display position with formatter/mask)
pub fn display_cursor_position(&self) -> usize { pub fn display_cursor_position(&self) -> usize {
let current_text = self.current_text(); let current_text = self.current_text();
let raw_pos = match self.ui_state.current_mode { let raw_pos = match self.ui_state.current_mode {
@@ -977,6 +1069,13 @@ impl<D: DataProvider> FormEditor<D> {
{ {
let field_index = self.ui_state.current_field; let field_index = self.ui_state.current_field;
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) { if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
// Only apply custom formatter cursor mapping when NOT editing
if !matches!(self.ui_state.current_mode, AppMode::Edit) {
if let Some((formatted, mapper, _warning)) = cfg.run_custom_formatter(current_text) {
return mapper.raw_to_formatted(current_text, &formatted, raw_pos);
}
}
// Fallback to display mask
if let Some(mask) = &cfg.display_mask { if let Some(mask) = &cfg.display_mask {
return mask.raw_pos_to_display_pos(self.ui_state.cursor_pos); return mask.raw_pos_to_display_pos(self.ui_state.cursor_pos);
} }

View File

@@ -4,9 +4,9 @@ pub mod canvas;
pub mod editor; pub mod editor;
pub mod data_provider; pub mod data_provider;
// Only include autocomplete module if feature is enabled // Only include suggestions module if feature is enabled
#[cfg(feature = "autocomplete")] #[cfg(feature = "suggestions")]
pub mod autocomplete; pub mod suggestions;
// Only include validation module if feature is enabled // Only include validation module if feature is enabled
#[cfg(feature = "validation")] #[cfg(feature = "validation")]
@@ -21,7 +21,7 @@ pub use canvas::CursorManager;
// Main API exports // Main API exports
pub use editor::FormEditor; pub use editor::FormEditor;
pub use data_provider::{DataProvider, AutocompleteProvider, SuggestionItem}; pub use data_provider::{DataProvider, SuggestionsProvider, SuggestionItem};
// UI state (read-only access for users) // UI state (read-only access for users)
pub use canvas::state::EditorState; pub use canvas::state::EditorState;
@@ -37,6 +37,8 @@ pub use validation::{
CharacterLimits, ValidationConfigBuilder, ValidationState, CharacterLimits, ValidationConfigBuilder, ValidationState,
ValidationSummary, PatternFilters, PositionFilter, PositionRange, CharacterFilter, ValidationSummary, PatternFilters, PositionFilter, PositionRange, CharacterFilter,
DisplayMask, // Simple display mask instead of complex ReservedCharacters DisplayMask, // Simple display mask instead of complex ReservedCharacters
// Feature 4: custom formatting exports
CustomFormatter, FormattingResult, PositionMapper, DefaultPositionMapper,
}; };
// Theming and GUI // Theming and GUI
@@ -49,5 +51,5 @@ pub use canvas::gui::render_canvas;
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
pub use canvas::gui::render_canvas_default; pub use canvas::gui::render_canvas_default;
#[cfg(all(feature = "gui", feature = "autocomplete"))] #[cfg(all(feature = "gui", feature = "suggestions"))]
pub use autocomplete::gui::render_autocomplete_dropdown; pub use suggestions::gui::render_suggestions_dropdown;

View File

@@ -1,5 +1,5 @@
// src/autocomplete/gui.rs // src/suggestions/gui.rs
//! Autocomplete GUI updated to work with FormEditor //! Suggestions dropdown GUI (not inline autocomplete) updated to work with FormEditor
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
use ratatui::{ use ratatui::{
@@ -17,9 +17,9 @@ use crate::editor::FormEditor;
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
use unicode_width::UnicodeWidthStr; 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")] #[cfg(feature = "gui")]
pub fn render_autocomplete_dropdown<T: CanvasTheme, D: DataProvider>( pub fn render_suggestions_dropdown<T: CanvasTheme, D: DataProvider>(
f: &mut Frame, f: &mut Frame,
frame_area: Rect, frame_area: Rect,
input_rect: Rect, input_rect: Rect,
@@ -28,14 +28,14 @@ pub fn render_autocomplete_dropdown<T: CanvasTheme, D: DataProvider>(
) { ) {
let ui_state = editor.ui_state(); let ui_state = editor.ui_state();
if !ui_state.is_autocomplete_active() { if !ui_state.is_suggestions_active() {
return; return;
} }
if ui_state.autocomplete.is_loading { if ui_state.suggestions.is_loading {
render_loading_indicator(f, frame_area, input_rect, theme); render_loading_indicator(f, frame_area, input_rect, theme);
} else if !editor.suggestions().is_empty() { } 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 /// Show actual suggestions list
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
fn render_suggestions_dropdown<T: CanvasTheme>( fn render_suggestions_dropdown_list<T: CanvasTheme>(
f: &mut Frame, f: &mut Frame,
frame_area: Rect, frame_area: Rect,
input_rect: Rect, input_rect: Rect,

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

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

View File

@@ -2,9 +2,12 @@
//! Validation configuration types and builders //! Validation configuration types and builders
use crate::validation::{CharacterLimits, PatternFilters, DisplayMask}; 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 /// Main validation configuration for a field
#[derive(Debug, Clone, Default)] #[derive(Clone, Default)]
pub struct ValidationConfig { pub struct ValidationConfig {
/// Character limit configuration /// Character limit configuration
pub character_limits: Option<CharacterLimits>, pub character_limits: Option<CharacterLimits>,
@@ -15,13 +18,203 @@ pub struct ValidationConfig {
/// User-defined display mask for visual formatting /// User-defined display mask for visual formatting
pub display_mask: Option<DisplayMask>, pub display_mask: Option<DisplayMask>,
/// Future: Custom formatting /// Optional: user-provided custom formatter (feature 4)
pub custom_formatting: Option<()>, // Placeholder for future implementation #[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 /// Future: External validation
pub external_validation: Option<()>, // Placeholder for future implementation 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 /// Builder for creating validation configurations
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct ValidationConfigBuilder { pub struct ValidationConfigBuilder {
@@ -47,24 +240,24 @@ impl ValidationConfigBuilder {
} }
/// Set user-defined display mask for visual formatting /// Set user-defined display mask for visual formatting
/// ///
/// # Examples /// # Examples
/// ``` /// ```
/// use canvas::{ValidationConfigBuilder, DisplayMask}; /// use canvas::{ValidationConfigBuilder, DisplayMask};
/// ///
/// // Phone number with dynamic formatting /// // Phone number with dynamic formatting
/// let phone_mask = DisplayMask::new("(###) ###-####", '#'); /// let phone_mask = DisplayMask::new("(###) ###-####", '#');
/// let config = ValidationConfigBuilder::new() /// let config = ValidationConfigBuilder::new()
/// .with_display_mask(phone_mask) /// .with_display_mask(phone_mask)
/// .build(); /// .build();
/// ///
/// // Date with template formatting /// // Date with template formatting
/// let date_mask = DisplayMask::new("##/##/####", '#') /// let date_mask = DisplayMask::new("##/##/####", '#')
/// .with_template('_'); /// .with_template('_');
/// let config = ValidationConfigBuilder::new() /// let config = ValidationConfigBuilder::new()
/// .with_display_mask(date_mask) /// .with_display_mask(date_mask)
/// .build(); /// .build();
/// ///
/// // Custom business format /// // Custom business format
/// let employee_id = DisplayMask::new("EMP-####-##", '#') /// let employee_id = DisplayMask::new("EMP-####-##", '#')
/// .with_template('•'); /// .with_template('•');
@@ -78,12 +271,30 @@ impl ValidationConfigBuilder {
self 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) /// Set maximum number of characters (convenience method)
pub fn with_max_length(mut self, max_length: usize) -> Self { pub fn with_max_length(mut self, max_length: usize) -> Self {
self.config.character_limits = Some(CharacterLimits::new(max_length)); self.config.character_limits = Some(CharacterLimits::new(max_length));
self 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 /// Build the final validation configuration
pub fn build(self) -> ValidationConfig { pub fn build(self) -> ValidationConfig {
self.config self.config
@@ -134,131 +345,6 @@ impl ValidationResult {
} }
} }
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()
}
/// 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 {
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 {
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 {
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 {
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()
}
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -268,7 +354,7 @@ mod tests {
// User creates their own phone mask // User creates their own phone mask
let phone_mask = DisplayMask::new("(###) ###-####", '#'); let phone_mask = DisplayMask::new("(###) ###-####", '#');
let config = ValidationConfig::with_mask(phone_mask); let config = ValidationConfig::with_mask(phone_mask);
// has_validation should be true because mask is configured // has_validation should be true because mask is configured
assert!(config.has_validation()); assert!(config.has_validation());

View 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"),
}
}
}

View File

@@ -6,6 +6,7 @@ pub mod limits;
pub mod state; pub mod state;
pub mod patterns; pub mod patterns;
pub mod mask; // Simple display mask instead of complex reserved chars 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 // Re-export main types
pub use config::{ValidationConfig, ValidationResult, ValidationConfigBuilder}; pub use config::{ValidationConfig, ValidationResult, ValidationConfigBuilder};
@@ -13,6 +14,17 @@ pub use limits::{CharacterLimits, LimitCheckResult};
pub use state::{ValidationState, ValidationSummary}; pub use state::{ValidationState, ValidationSummary};
pub use patterns::{PatternFilters, PositionFilter, PositionRange, CharacterFilter}; pub use patterns::{PatternFilters, PositionFilter, PositionRange, CharacterFilter};
pub use mask::DisplayMask; // Simple mask instead of ReservedCharacters 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 /// Validation error types
#[derive(Debug, Clone, thiserror::Error)] #[derive(Debug, Clone, thiserror::Error)]

View File

@@ -1,7 +1,7 @@
// src/validation/state.rs // src/validation/state.rs
//! Validation state management //! Validation state management
use crate::validation::{ValidationConfig, ValidationResult}; use crate::validation::{ValidationConfig, ValidationResult, ExternalValidationState};
use std::collections::HashMap; use std::collections::HashMap;
/// Validation state for all fields in a form /// Validation state for all fields in a form
@@ -18,6 +18,9 @@ pub struct ValidationState {
/// Global validation enabled/disabled /// Global validation enabled/disabled
enabled: bool, enabled: bool,
/// External validation results per field (Feature 5)
external_results: HashMap<usize, ExternalValidationState>,
} }
impl ValidationState { impl ValidationState {
@@ -28,6 +31,7 @@ impl ValidationState {
field_results: HashMap::new(), field_results: HashMap::new(),
validated_fields: std::collections::HashSet::new(), validated_fields: std::collections::HashSet::new(),
enabled: true, enabled: true,
external_results: HashMap::new(),
} }
} }
@@ -38,6 +42,7 @@ impl ValidationState {
// Clear all validation results when disabled // Clear all validation results when disabled
self.field_results.clear(); self.field_results.clear();
self.validated_fields.clear(); self.validated_fields.clear();
self.external_results.clear(); // Also clear external results
} }
} }
@@ -48,12 +53,13 @@ impl ValidationState {
/// Set validation configuration for a field /// Set validation configuration for a field
pub fn set_field_config(&mut self, field_index: usize, config: ValidationConfig) { 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); self.field_configs.insert(field_index, config);
} else { } else {
self.field_configs.remove(&field_index); self.field_configs.remove(&field_index);
self.field_results.remove(&field_index); self.field_results.remove(&field_index);
self.validated_fields.remove(&field_index); self.validated_fields.remove(&field_index);
self.external_results.remove(&field_index);
} }
} }
@@ -67,6 +73,30 @@ impl ValidationState {
self.field_configs.remove(&field_index); self.field_configs.remove(&field_index);
self.field_results.remove(&field_index); self.field_results.remove(&field_index);
self.validated_fields.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 /// Validate character insertion for a field
@@ -122,6 +152,18 @@ impl ValidationState {
self.field_results.get(&field_index) 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 /// Check if a field has been validated
pub fn is_field_validated(&self, field_index: usize) -> bool { pub fn is_field_validated(&self, field_index: usize) -> bool {
self.validated_fields.contains(&field_index) self.validated_fields.contains(&field_index)

View File

@@ -27,7 +27,7 @@ show_module() {
# Main modules # Main modules
show_module "canvas" "CANVAS SYSTEM" show_module "canvas" "CANVAS SYSTEM"
show_module "autocomplete" "AUTOCOMPLETE SYSTEM" show_module "suggestions" "SUGGESTIONS SYSTEM"
show_module "config" "CONFIGURATION SYSTEM" show_module "config" "CONFIGURATION SYSTEM"
# Show lib.rs and other root files # Show lib.rs and other root files
@@ -45,7 +45,7 @@ fi
echo -e "\n\033[1;36m==========================================" echo -e "\n\033[1;36m=========================================="
echo "To view specific module documentation:" echo "To view specific module documentation:"
echo " ./view_canvas_docs.sh canvas" 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 " ./view_canvas_docs.sh config"
echo "==========================================\033[0m" echo "==========================================\033[0m"