Compare commits

...

15 Commits

35 changed files with 7313 additions and 435 deletions

View File

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

View File

@@ -16,7 +16,7 @@ crossterm = { workspace = true, optional = true }
anyhow.workspace = true
tokio = { workspace = true, optional = true }
toml = { workspace = true }
serde = { workspace = true }
serde.workspace = true
unicode-width.workspace = true
thiserror = { workspace = true }
@@ -30,21 +30,42 @@ tokio-test = "0.4.4"
[features]
default = []
gui = ["ratatui"]
autocomplete = ["tokio"]
gui = ["ratatui", "crossterm"]
suggestions = ["tokio"]
cursor-style = ["crossterm"]
validation = ["regex"]
computed = []
[[example]]
name = "autocomplete"
required-features = ["autocomplete", "gui"]
path = "examples/autocomplete.rs"
name = "suggestions"
required-features = ["suggestions", "gui", "cursor-style"]
path = "examples/suggestions.rs"
[[example]]
name = "canvas_gui_demo"
required-features = ["gui"]
required-features = ["gui", "cursor-style"]
path = "examples/canvas_gui_demo.rs"
[[example]]
name = "validation_1"
required-features = ["gui", "validation"]
[[example]]
name = "validation_2"
required-features = ["gui", "validation"]
[[example]]
name = "validation_3"
required-features = ["gui", "validation"]
[[example]]
name = "validation_4"
required-features = ["gui", "validation"]
[[example]]
name = "validation_5"
required-features = ["gui", "validation"]
[[example]]
name = "computed_fields"
required-features = ["gui", "computed"]

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

View File

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

View File

@@ -0,0 +1,620 @@
// examples/computed_fields.rs - COMPLETE WORKING VERSION
//! Demonstrates computed fields with the canvas library - Invoice Calculator Example
//!
//! This example REQUIRES the `computed` feature to compile.
//!
//! Run with:
//! cargo run --example computed_fields --features "gui,computed"
#[cfg(not(feature = "computed"))]
compile_error!(
"This example requires the 'computed' feature. \
Run with: cargo run --example computed_fields --features \"gui,computed\""
);
use std::io;
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style, Modifier},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
Frame, Terminal,
};
use canvas::{
canvas::{gui::render_canvas_default, modes::AppMode},
DataProvider, FormEditor,
computed::{ComputedProvider, ComputedContext},
};
/// Invoice data with computed fields
struct InvoiceData {
fields: Vec<(String, String)>,
computed_indices: std::collections::HashSet<usize>,
}
impl InvoiceData {
fn new() -> Self {
let mut computed_indices = std::collections::HashSet::new();
// Mark computed fields (read-only, calculated)
computed_indices.insert(4); // Subtotal
computed_indices.insert(5); // Tax Amount
computed_indices.insert(6); // Total
Self {
fields: vec![
("📦 Product Name".to_string(), "".to_string()),
("🔢 Quantity".to_string(), "".to_string()),
("💰 Unit Price ($)".to_string(), "".to_string()),
("📊 Tax Rate (%)".to_string(), "".to_string()),
(" Subtotal ($)".to_string(), "".to_string()), // COMPUTED
("🧾 Tax Amount ($)".to_string(), "".to_string()), // COMPUTED
("💳 Total ($)".to_string(), "".to_string()), // COMPUTED
("📝 Notes".to_string(), "".to_string()),
],
computed_indices,
}
}
}
impl DataProvider for InvoiceData {
fn field_count(&self) -> usize {
self.fields.len()
}
fn field_name(&self, index: usize) -> &str {
&self.fields[index].0
}
fn field_value(&self, index: usize) -> &str {
&self.fields[index].1
}
fn set_field_value(&mut self, index: usize, value: String) {
// 🔥 FIXED: Allow computed fields to be updated for display purposes
// The editing protection happens at the editor level, not here
self.fields[index].1 = value;
}
fn supports_suggestions(&self, _field_index: usize) -> bool {
false
}
fn display_value(&self, _index: usize) -> Option<&str> {
None
}
/// Mark computed fields
fn is_computed_field(&self, field_index: usize) -> bool {
self.computed_indices.contains(&field_index)
}
/// Get computed field values
fn computed_field_value(&self, field_index: usize) -> Option<String> {
if self.computed_indices.contains(&field_index) {
Some(self.fields[field_index].1.clone())
} else {
None
}
}
}
/// Invoice calculator - computes totals based on input fields
struct InvoiceCalculator;
impl ComputedProvider for InvoiceCalculator {
fn compute_field(&mut self, context: ComputedContext) -> String {
// Helper to parse field values safely
let parse_field = |index: usize| -> f64 {
let value = context.field_values[index].trim();
if value.is_empty() {
0.0
} else {
value.parse().unwrap_or(0.0)
}
};
match context.target_field {
4 => {
// Subtotal = Quantity × Unit Price
let qty = parse_field(1);
let price = parse_field(2);
let subtotal = qty * price;
if qty == 0.0 || price == 0.0 {
"".to_string() // Show empty if no meaningful calculation
} else {
format!("{:.2}", subtotal)
}
}
5 => {
// Tax Amount = Subtotal × (Tax Rate / 100)
let qty = parse_field(1);
let price = parse_field(2);
let tax_rate = parse_field(3);
let subtotal = qty * price;
let tax_amount = subtotal * (tax_rate / 100.0);
if subtotal == 0.0 || tax_rate == 0.0 {
"".to_string()
} else {
format!("{:.2}", tax_amount)
}
}
6 => {
// Total = Subtotal + Tax Amount
let qty = parse_field(1);
let price = parse_field(2);
let tax_rate = parse_field(3);
let subtotal = qty * price;
if subtotal == 0.0 {
"".to_string()
} else {
let tax_amount = subtotal * (tax_rate / 100.0);
let total = subtotal + tax_amount;
format!("{:.2}", total)
}
}
_ => "".to_string(),
}
}
fn handles_field(&self, field_index: usize) -> bool {
matches!(field_index, 4 | 5 | 6) // Subtotal, Tax Amount, Total
}
fn field_dependencies(&self, field_index: usize) -> Vec<usize> {
match field_index {
4 => vec![1, 2], // Subtotal depends on Quantity, Unit Price
5 => vec![1, 2, 3], // Tax Amount depends on Quantity, Unit Price, Tax Rate
6 => vec![1, 2, 3], // Total depends on Quantity, Unit Price, Tax Rate
_ => vec![],
}
}
}
/// Enhanced editor with computed fields
struct ComputedFieldsEditor<D: DataProvider> {
editor: FormEditor<D>,
calculator: InvoiceCalculator,
debug_message: String,
last_computed_values: Vec<String>,
}
impl<D: DataProvider> ComputedFieldsEditor<D> {
fn new(data_provider: D) -> Self {
let mut editor = FormEditor::new(data_provider);
editor.set_computed_provider(InvoiceCalculator);
let calculator = InvoiceCalculator;
let last_computed_values = vec!["".to_string(); 8];
Self {
editor,
calculator,
debug_message: "💰 Invoice Calculator - Start typing in fields to see calculations!".to_string(),
last_computed_values,
}
}
fn is_computed_field(&self, field_index: usize) -> bool {
self.editor.ui_state().is_computed_field(field_index)
}
fn update_computed_fields(&mut self) {
// Trigger recomputation of all computed fields
self.editor.recompute_all_fields(&mut self.calculator);
// 🔥 CRITICAL FIX: Sync computed values to DataProvider so GUI shows them!
for i in [4, 5, 6] { // Computed field indices
let computed_value = self.editor.effective_field_value(i);
self.editor.data_provider_mut().set_field_value(i, computed_value.clone());
}
// Check if values changed to show feedback
let mut changed = false;
let mut has_calculations = false;
for i in [4, 5, 6] {
let new_value = self.editor.effective_field_value(i);
if new_value != self.last_computed_values[i] {
changed = true;
self.last_computed_values[i] = new_value.clone();
}
if !new_value.is_empty() {
has_calculations = true;
}
}
if changed {
if has_calculations {
let subtotal = &self.last_computed_values[4];
let tax = &self.last_computed_values[5];
let total = &self.last_computed_values[6];
let mut parts = Vec::new();
if !subtotal.is_empty() {
parts.push(format!("Subtotal=${}", subtotal));
}
if !tax.is_empty() {
parts.push(format!("Tax=${}", tax));
}
if !total.is_empty() {
parts.push(format!("Total=${}", total));
}
if !parts.is_empty() {
self.debug_message = format!("🧮 Calculated: {}", parts.join(", "));
} else {
self.debug_message = "💰 Enter Quantity and Unit Price to see calculations".to_string();
}
} else {
self.debug_message = "💰 Enter Quantity and Unit Price to see calculations".to_string();
}
}
}
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
let current_field = self.editor.current_field();
let result = self.editor.insert_char(ch);
if result.is_ok() && matches!(current_field, 1 | 2 | 3) {
self.editor.on_field_changed(&mut self.calculator, current_field);
self.update_computed_fields();
}
result
}
fn delete_backward(&mut self) -> anyhow::Result<()> {
let current_field = self.editor.current_field();
let result = self.editor.delete_backward();
if result.is_ok() && matches!(current_field, 1 | 2 | 3) {
self.editor.on_field_changed(&mut self.calculator, current_field);
self.update_computed_fields();
}
result
}
fn delete_forward(&mut self) -> anyhow::Result<()> {
let current_field = self.editor.current_field();
let result = self.editor.delete_forward();
if result.is_ok() && matches!(current_field, 1 | 2 | 3) {
self.editor.on_field_changed(&mut self.calculator, current_field);
self.update_computed_fields();
}
result
}
fn next_field(&mut self) {
let old_field = self.editor.current_field();
let _ = self.editor.next_field();
let new_field = self.editor.current_field();
if old_field != new_field {
let field_name = self.editor.data_provider().field_name(new_field);
let field_type = if self.is_computed_field(new_field) {
"computed (read-only)"
} else {
"editable"
};
self.debug_message = format!("{} - {} field", field_name, field_type);
}
}
fn prev_field(&mut self) {
let old_field = self.editor.current_field();
let _ = self.editor.prev_field();
let new_field = self.editor.current_field();
if old_field != new_field {
let field_name = self.editor.data_provider().field_name(new_field);
let field_type = if self.is_computed_field(new_field) {
"computed (read-only)"
} else {
"editable"
};
self.debug_message = format!("{} - {} field", field_name, field_type);
}
}
fn enter_edit_mode(&mut self) {
let current = self.editor.current_field();
// Double protection: check both ways
if self.editor.data_provider().is_computed_field(current) || self.is_computed_field(current) {
let field_name = self.editor.data_provider().field_name(current);
self.debug_message = format!(
"🚫 {} is computed (read-only) - Press Tab to move to editable fields",
field_name
);
return;
}
self.editor.enter_edit_mode();
let field_name = self.editor.data_provider().field_name(current);
self.debug_message = format!("✏️ Editing {} - Type to see calculations update", field_name);
}
fn enter_append_mode(&mut self) {
let current = self.editor.current_field();
if self.editor.data_provider().is_computed_field(current) || self.is_computed_field(current) {
let field_name = self.editor.data_provider().field_name(current);
self.debug_message = format!(
"🚫 {} is computed (read-only) - Press Tab to move to editable fields",
field_name
);
return;
}
self.editor.enter_append_mode();
let field_name = self.editor.data_provider().field_name(current);
self.debug_message = format!("✏️ Appending to {} - Type to see calculations", field_name);
}
fn exit_edit_mode(&mut self) {
let current_field = self.editor.current_field();
self.editor.exit_edit_mode();
if matches!(current_field, 1 | 2 | 3) {
self.editor.on_field_changed(&mut self.calculator, current_field);
self.update_computed_fields();
}
self.debug_message = "🔒 Normal mode - Press 'i' to edit fields".to_string();
}
// Delegate methods
fn current_field(&self) -> usize { self.editor.current_field() }
fn cursor_position(&self) -> usize { self.editor.cursor_position() }
fn mode(&self) -> AppMode { self.editor.mode() }
fn current_text(&self) -> &str { self.editor.current_text() }
fn data_provider(&self) -> &D { self.editor.data_provider() }
fn ui_state(&self) -> &canvas::EditorState { self.editor.ui_state() }
fn move_left(&mut self) { self.editor.move_left(); }
fn move_right(&mut self) { self.editor.move_right(); }
fn move_up(&mut self) { let _ = self.editor.move_up(); }
fn move_down(&mut self) { let _ = self.editor.move_down(); }
}
fn handle_key_press(
key: KeyCode,
modifiers: KeyModifiers,
editor: &mut ComputedFieldsEditor<InvoiceData>,
) -> anyhow::Result<bool> {
let mode = editor.mode();
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL))
|| (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL))
|| key == KeyCode::F(10)
{
return Ok(false);
}
match (mode, key, modifiers) {
(AppMode::ReadOnly, KeyCode::Char('i'), _) => {
editor.enter_edit_mode();
}
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
editor.enter_append_mode();
}
(AppMode::ReadOnly, KeyCode::Char('A'), _) => {
editor.editor.move_line_end();
editor.enter_edit_mode();
}
(_, KeyCode::Esc, _) => {
if mode == AppMode::Edit {
editor.exit_edit_mode();
}
}
// Movement
(AppMode::ReadOnly, KeyCode::Char('h'), _) | (AppMode::ReadOnly, KeyCode::Left, _) => {
editor.move_left();
}
(AppMode::ReadOnly, KeyCode::Char('l'), _) | (AppMode::ReadOnly, KeyCode::Right, _) => {
editor.move_right();
}
(AppMode::ReadOnly, KeyCode::Char('j'), _) | (AppMode::ReadOnly, KeyCode::Down, _) => {
editor.move_down();
}
(AppMode::ReadOnly, KeyCode::Char('k'), _) | (AppMode::ReadOnly, KeyCode::Up, _) => {
editor.move_up();
}
// Edit mode movement
(AppMode::Edit, KeyCode::Left, _) => { editor.move_left(); }
(AppMode::Edit, KeyCode::Right, _) => { editor.move_right(); }
(AppMode::Edit, KeyCode::Up, _) => { editor.move_up(); }
(AppMode::Edit, KeyCode::Down, _) => { editor.move_down(); }
// Navigation
(_, KeyCode::Tab, _) => {
editor.next_field();
}
(_, KeyCode::BackTab, _) => {
editor.prev_field();
}
// Editing
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
editor.insert_char(c)?;
}
(AppMode::Edit, KeyCode::Backspace, _) => {
editor.delete_backward()?;
}
(AppMode::Edit, KeyCode::Delete, _) => {
editor.delete_forward()?;
}
// Debug info
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
let current = editor.current_field();
let field_name = editor.data_provider().field_name(current);
let field_type = if editor.is_computed_field(current) {
"COMPUTED (read-only)"
} else {
"EDITABLE"
};
editor.debug_message = format!(
"{} - {} - Position {} - Mode: {:?}",
field_name, field_type, editor.cursor_position(), mode
);
}
_ => {}
}
Ok(true)
}
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut editor: ComputedFieldsEditor<InvoiceData>,
) -> io::Result<()> {
editor.update_computed_fields(); // Initial computation
loop {
terminal.draw(|f| ui(f, &editor))?;
if let Event::Key(key) = event::read()? {
match handle_key_press(key.code, key.modifiers, &mut editor) {
Ok(should_continue) => {
if !should_continue {
break;
}
}
Err(e) => {
editor.debug_message = format!("Error: {}", e);
}
}
}
}
Ok(())
}
fn ui(f: &mut Frame, editor: &ComputedFieldsEditor<InvoiceData>) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(10)])
.split(f.area());
render_enhanced_canvas(f, chunks[0], editor);
render_computed_status(f, chunks[1], editor);
}
fn render_enhanced_canvas(f: &mut Frame, area: Rect, editor: &ComputedFieldsEditor<InvoiceData>) {
render_canvas_default(f, area, &editor.editor);
}
fn render_computed_status(f: &mut Frame, area: Rect, editor: &ComputedFieldsEditor<InvoiceData>) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Length(7)])
.split(area);
let mode_text = match editor.mode() {
AppMode::Edit => "INSERT",
AppMode::ReadOnly => "NORMAL",
_ => "OTHER",
};
let current = editor.current_field();
let field_status = if editor.is_computed_field(current) {
"📊 COMPUTED FIELD (read-only)"
} else {
"✏️ EDITABLE FIELD"
};
let status_text = format!("-- {} -- {} | {}", mode_text, field_status, editor.debug_message);
let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("💰 Invoice Calculator"));
f.render_widget(status, chunks[0]);
let help_text = match editor.mode() {
AppMode::ReadOnly => {
"💰 COMPUTED FIELDS DEMO: Real-time invoice calculations!\n\
🔢 EDITABLE: Product, Quantity, Unit Price, Tax Rate, Notes\n\
📊 COMPUTED: Subtotal, Tax Amount, Total (calculated automatically)\n\
\n\
🚀 START: Press 'i' to edit Quantity, type '5', Tab to Unit Price, type '19.99'\n\
Watch Subtotal and Total appear! Add Tax Rate to see tax calculations.\n\
Navigation: Tab/Shift+Tab skips computed fields automatically"
}
AppMode::Edit => {
"✏️ EDIT MODE: Type numbers to see calculations appear!\n\
\n\
💡 EXAMPLE: Type '5' in Quantity, then Tab to Unit Price and type '19.99'\n\
• Subtotal appears: $99.95\n\
• Total appears: $99.95\n\
• Add Tax Rate (like '10') to see tax: $9.99, Total: $109.94\n\
\n\
Esc=normal, Tab=next field (auto-skips computed fields)"
}
_ => "💰 Invoice Calculator with Computed Fields"
};
let help_style = if editor.is_computed_field(editor.current_field()) {
Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC)
} else {
Style::default().fg(Color::Gray)
};
let help = Paragraph::new(help_text)
.block(Block::default().borders(Borders::ALL).title("🚀 Try It Now!"))
.style(help_style)
.wrap(Wrap { trim: true });
f.render_widget(help, chunks[1]);
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("💰 Canvas Computed Fields Demo - Invoice Calculator");
println!("✅ computed feature: ENABLED");
println!("🚀 QUICK TEST:");
println!(" 1. Press 'i' to edit Quantity");
println!(" 2. Type '5' and press Tab");
println!(" 3. Type '19.99' in Unit Price");
println!(" 4. Watch Subtotal ($99.95) and Total ($99.95) appear!");
println!();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let data = InvoiceData::new();
let editor = ComputedFieldsEditor::new(data);
let res = run_app(&mut terminal, editor);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err);
}
println!("💰 Demo completed! Computed fields should have updated in real-time!");
Ok(())
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
// examples/validation_1.rs
//! Demonstrates field validation with the canvas library
//!
//! This example REQUIRES the `validation` feature to compile.
//! This example REQUIRES the `validation` and `cursor-style` features to compile.
//!
//! Run with:
//! cargo run --example validation_1 --features "gui,validation"
@@ -10,10 +10,10 @@
//! cargo run --example validation_1 --features "gui"
// REQUIRE validation feature - example won't compile without it
#[cfg(not(feature = "validation"))]
#[cfg(not(all(feature = "validation", feature = "cursor-style")))]
compile_error!(
"This example requires the 'validation' feature. \
Run with: cargo run --example validation_1 --features \"gui,validation\""
"This example requires the 'validation' and 'cursor-style' features. \
Run with: cargo run --example validation_1 --features \"gui,validation,cursor-style\""
);
use std::io;
@@ -39,6 +39,7 @@ use canvas::{
canvas::{
gui::render_canvas_default,
modes::AppMode,
CursorManager,
},
DataProvider, FormEditor,
ValidationConfig, ValidationConfigBuilder, CharacterLimits, ValidationResult,
@@ -269,18 +270,21 @@ impl<D: DataProvider> ValidationFormEditor<D> {
// === MODE TRANSITIONS ===
fn enter_edit_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_edit_mode();
self.debug_message = "✏️ INSERT MODE - Type to test validation".to_string();
self.debug_message = "✏️ INSERT MODE - Cursor: Steady Bar | - Type to test validation".to_string();
}
fn enter_append_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_append_mode();
self.debug_message = "✏️ INSERT (append) - Validation active".to_string();
self.debug_message = "✏️ INSERT (append) - Cursor: Steady Bar | - Validation active".to_string();
}
fn exit_edit_mode(&mut self) {
// Library will automatically update cursor to block █ in normal mode
self.editor.exit_edit_mode();
self.debug_message = "🔒 NORMAL MODE - Press 'v' to validate current field".to_string();
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █ - Press 'v' to validate current field".to_string();
self.update_field_validation_status();
}
@@ -362,6 +366,7 @@ impl<D: DataProvider> ValidationFormEditor<D> {
}
fn set_mode(&mut self, mode: AppMode) {
// Library automatically updates cursor for the mode
self.editor.set_mode(mode);
}
@@ -447,7 +452,7 @@ impl DataProvider for ValidationDemoData {
self.fields[index].1 = value;
}
fn supports_autocomplete(&self, _field_index: usize) -> bool {
fn supports_suggestions(&self, _field_index: usize) -> bool {
false
}
@@ -694,9 +699,9 @@ fn render_validation_status(
// Status bar with validation information
let mode_text = match editor.mode() {
AppMode::Edit => "INSERT",
AppMode::ReadOnly => "NORMAL",
_ => "OTHER",
AppMode::Edit => "INSERT | (bar cursor)",
AppMode::ReadOnly => "NORMAL █ (block cursor)",
_ => "NORMAL █ (block cursor)",
};
let validation_status = editor.get_validation_status();
@@ -765,21 +770,21 @@ fn render_validation_status(
// Enhanced help text
let help_text = match editor.mode() {
AppMode::ReadOnly => {
"🔍 VALIDATION DEMO: Different fields have different limits!\n\
Fields with MINIMUM requirements will block field switching if too short!\n\
"🎯 CURSOR-STYLE: Normal █ | Insert |\n\
🔍 VALIDATION: Different fields have different limits (some block field switching)!\n\
Movement: hjkl/arrows=move, Tab/Shift+Tab=fields\n\
Edit: i/a/A=insert modes, Esc=normal\n\
Validation: v=validate current, V=validate all, c=clear results, F1=toggle\n\
?=info, Ctrl+C/Ctrl+Q=quit"
}
AppMode::Edit => {
"✏️ INSERT MODE - Type to test validation limits!\n\
Some fields have MINIMUM character requirements!\n\
"🎯 INSERT MODE - Cursor: | (bar)\n\
🔍 Type to test validation limits (some fields have MIN requirements)!\n\
Try typing 1-2 chars in Password/ID/Comment fields, then try to switch!\n\
arrows=move, Backspace/Del=delete, Esc=normal, Tab=next field\n\
Field switching may be BLOCKED if minimum requirements not met!"
}
_ => "🔍 Validation Demo Active!"
_ => "🎯 Watch the cursor change automatically while validating!"
};
let help = Paragraph::new(help_text)
@@ -810,10 +815,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut terminal = Terminal::new(backend)?;
let data = ValidationDemoData::new();
let editor = ValidationFormEditor::new(data);
let mut editor = ValidationFormEditor::new(data);
// Initialize with normal mode - library automatically sets block cursor
editor.set_mode(AppMode::ReadOnly);
// Demonstrate that CursorManager is available and working
CursorManager::update_for_mode(AppMode::ReadOnly)?;
let res = run_app(&mut terminal, editor);
// Library automatically resets cursor on FormEditor::drop()
// But we can also manually reset if needed
CursorManager::reset()?;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),

View File

@@ -0,0 +1,662 @@
// examples/validation_2.rs
//! Advanced TUI Example demonstrating complex pattern filtering edge cases
//!
//! This example showcases the full potential of the pattern validation system
//! with creative real-world scenarios and edge cases.
//!
//! Run with: cargo run --example validation_advanced_patterns --features "validation,gui,cursor-style"
// REQUIRE validation, gui and cursor-style features
#[cfg(not(all(feature = "validation", feature = "gui", feature = "cursor-style")))]
compile_error!(
"This example requires the 'validation', 'gui' and 'cursor-style' features. \
Run with: cargo run --example validation_advanced_patterns --features \"validation,gui,cursor-style\""
);
use std::io;
use std::sync::Arc;
use canvas::ValidationResult;
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers,
},
execute,
terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
Frame, Terminal,
};
use canvas::{
canvas::{
gui::render_canvas_default,
modes::AppMode,
CursorManager,
},
DataProvider, FormEditor,
ValidationConfig, ValidationConfigBuilder, PatternFilters, PositionFilter, PositionRange, CharacterFilter,
};
// Enhanced FormEditor wrapper (keeping the same structure as before)
struct AdvancedPatternFormEditor<D: DataProvider> {
editor: FormEditor<D>,
debug_message: String,
command_buffer: String,
validation_enabled: bool,
field_switch_blocked: bool,
block_reason: Option<String>,
}
impl<D: DataProvider> AdvancedPatternFormEditor<D> {
fn new(data_provider: D) -> Self {
let mut editor = FormEditor::new(data_provider);
editor.set_validation_enabled(true);
Self {
editor,
debug_message: "🚀 Advanced Pattern Validation - Showcasing edge cases and complex patterns!".to_string(),
command_buffer: String::new(),
validation_enabled: true,
field_switch_blocked: false,
block_reason: None,
}
}
// ... (keeping all the same methods as before for brevity)
// [All the previous methods: clear_command_buffer, add_to_command_buffer, etc.]
fn clear_command_buffer(&mut self) { self.command_buffer.clear(); }
fn add_to_command_buffer(&mut self, ch: char) { self.command_buffer.push(ch); }
fn get_command_buffer(&self) -> &str { &self.command_buffer }
fn has_pending_command(&self) -> bool { !self.command_buffer.is_empty() }
fn toggle_validation(&mut self) {
self.validation_enabled = !self.validation_enabled;
self.editor.set_validation_enabled(self.validation_enabled);
if self.validation_enabled {
self.debug_message = "✅ Advanced Pattern Validation ENABLED".to_string();
} else {
self.debug_message = "❌ Advanced Pattern Validation DISABLED".to_string();
}
}
fn move_left(&mut self) { self.editor.move_left(); self.field_switch_blocked = false; self.block_reason = None; }
fn move_right(&mut self) { self.editor.move_right(); self.field_switch_blocked = false; self.block_reason = None; }
fn move_up(&mut self) {
match self.editor.move_up() {
Ok(()) => { self.update_field_validation_status(); self.field_switch_blocked = false; self.block_reason = None; }
Err(e) => { self.field_switch_blocked = true; self.block_reason = Some(e.to_string()); self.debug_message = format!("🚫 Field switch blocked: {}", e); }
}
}
fn move_down(&mut self) {
match self.editor.move_down() {
Ok(()) => { self.update_field_validation_status(); self.field_switch_blocked = false; self.block_reason = None; }
Err(e) => { self.field_switch_blocked = true; self.block_reason = Some(e.to_string()); self.debug_message = format!("🚫 Field switch blocked: {}", e); }
}
}
fn move_line_start(&mut self) { self.editor.move_line_start(); }
fn move_line_end(&mut self) { self.editor.move_line_end(); }
fn enter_edit_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_edit_mode();
self.debug_message = "✏️ INSERT MODE - Cursor: Steady Bar | - Testing advanced pattern validation".to_string();
}
fn enter_append_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_append_mode();
self.debug_message = "✏️ INSERT (append) - Cursor: Steady Bar | - Advanced patterns active".to_string();
}
fn exit_edit_mode(&mut self) {
// Library will automatically update cursor to block █ in normal mode
self.editor.exit_edit_mode();
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █".to_string();
self.update_field_validation_status();
}
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
let result = self.editor.insert_char(ch);
if result.is_ok() {
if let Some(validation_result) = self.editor.current_field_validation() {
match validation_result {
ValidationResult::Valid => { self.debug_message = "✅ Character accepted".to_string(); }
ValidationResult::Warning { message } => { self.debug_message = format!("⚠️ Warning: {}", message); }
ValidationResult::Error { message } => { self.debug_message = format!("❌ Pattern violation: {}", message); }
}
}
}
Ok(result?)
}
fn delete_backward(&mut self) -> anyhow::Result<()> {
let result = self.editor.delete_backward();
if result.is_ok() { self.debug_message = "⌫ Character deleted".to_string(); }
Ok(result?)
}
fn delete_forward(&mut self) -> anyhow::Result<()> {
let result = self.editor.delete_forward();
if result.is_ok() { self.debug_message = "⌦ Character deleted".to_string(); }
Ok(result?)
}
// Delegate methods
fn current_field(&self) -> usize { self.editor.current_field() }
fn cursor_position(&self) -> usize { self.editor.cursor_position() }
fn mode(&self) -> AppMode { self.editor.mode() }
fn current_text(&self) -> &str { self.editor.current_text() }
fn data_provider(&self) -> &D { self.editor.data_provider() }
fn ui_state(&self) -> &canvas::EditorState { self.editor.ui_state() }
fn set_mode(&mut self, mode: AppMode) { self.editor.set_mode(mode); }
fn next_field(&mut self) {
match self.editor.next_field() {
Ok(()) => { self.update_field_validation_status(); self.field_switch_blocked = false; self.block_reason = None; }
Err(e) => { self.field_switch_blocked = true; self.block_reason = Some(e.to_string()); self.debug_message = format!("🚫 Cannot move to next field: {}", e); }
}
}
fn prev_field(&mut self) {
match self.editor.prev_field() {
Ok(()) => { self.update_field_validation_status(); self.field_switch_blocked = false; self.block_reason = None; }
Err(e) => { self.field_switch_blocked = true; self.block_reason = Some(e.to_string()); self.debug_message = format!("🚫 Cannot move to previous field: {}", e); }
}
}
fn set_debug_message(&mut self, msg: String) { self.debug_message = msg; }
fn debug_message(&self) -> &str { &self.debug_message }
fn update_field_validation_status(&mut self) {
if !self.validation_enabled { return; }
if let Some(result) = self.editor.current_field_validation() {
match result {
ValidationResult::Valid => { self.debug_message = format!("Field {}: ✅ Pattern valid", self.editor.current_field() + 1); }
ValidationResult::Warning { message } => { self.debug_message = format!("Field {}: ⚠️ {}", self.editor.current_field() + 1, message); }
ValidationResult::Error { message } => { self.debug_message = format!("Field {}: ❌ {}", self.editor.current_field() + 1, message); }
}
}
}
fn get_validation_status(&self) -> String {
if !self.validation_enabled { return "❌ DISABLED".to_string(); }
if self.field_switch_blocked { return "🚫 SWITCH BLOCKED".to_string(); }
let summary = self.editor.validation_summary();
if summary.has_errors() { format!("{} ERRORS", summary.error_fields) }
else if summary.has_warnings() { format!("⚠️ {} WARNINGS", summary.warning_fields) }
else if summary.validated_fields > 0 { format!("{} VALID", summary.valid_fields) }
else { "🔍 READY".to_string() }
}
}
// Advanced demo form with creative and edge-case-heavy validation patterns
struct AdvancedPatternData {
fields: Vec<(String, String)>,
}
impl AdvancedPatternData {
fn new() -> Self {
Self {
fields: vec![
("🕐 Time (HH:MM) - 24hr format".to_string(), "".to_string()),
("🎨 Hex Color (#RRGGBB) - Web colors".to_string(), "".to_string()),
("🌐 IPv4 (XXX.XXX.XXX.XXX) - Network address".to_string(), "".to_string()),
("🏷️ Product Code (ABC-123-XYZ) - Mixed format".to_string(), "".to_string()),
("📅 Date Code (2024W15) - Year + Week".to_string(), "".to_string()),
("🔢 Binary (101010) - Only 0s and 1s".to_string(), "".to_string()),
("🎯 Complex ID (A1-B2C-3D4E) - Multi-rule".to_string(), "".to_string()),
("🚀 Custom Pattern - Advanced logic".to_string(), "".to_string()),
],
}
}
}
impl DataProvider for AdvancedPatternData {
fn field_count(&self) -> usize { self.fields.len() }
fn field_name(&self, index: usize) -> &str { &self.fields[index].0 }
fn field_value(&self, index: usize) -> &str { &self.fields[index].1 }
fn set_field_value(&mut self, index: usize, value: String) { self.fields[index].1 = value; }
fn validation_config(&self, field_index: usize) -> Option<ValidationConfig> {
match field_index {
0 => {
// 🕐 Time (HH:MM) - Hours 00-23, Minutes 00-59
// This showcases: Multiple position ranges, exact character matching, custom validation
let time_pattern = PatternFilters::new()
.add_filter(PositionFilter::new(
PositionRange::Multiple(vec![0, 1, 3, 4]), // Hours and minutes positions
CharacterFilter::Numeric,
))
.add_filter(PositionFilter::new(
PositionRange::Single(2), // Colon separator
CharacterFilter::Exact(':'),
));
Some(ValidationConfigBuilder::new()
.with_pattern_filters(time_pattern)
.with_max_length(5) // HH:MM = 5 characters
.build())
}
1 => {
// 🎨 Hex Color (#RRGGBB) - Web color format
// This showcases: OneOf filter with hex digits, exact character at start
let hex_digits = vec!['0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F','a','b','c','d','e','f'];
let hex_color_pattern = PatternFilters::new()
.add_filter(PositionFilter::new(
PositionRange::Single(0), // Hash symbol
CharacterFilter::Exact('#'),
))
.add_filter(PositionFilter::new(
PositionRange::Range(1, 6), // 6 hex digits for RGB
CharacterFilter::OneOf(hex_digits),
));
Some(ValidationConfigBuilder::new()
.with_pattern_filters(hex_color_pattern)
.with_max_length(7) // #RRGGBB = 7 characters
.build())
}
2 => {
// 🌐 IPv4 Address (XXX.XXX.XXX.XXX) - Network address
// This showcases: Complex pattern with dots at specific positions
let ipv4_pattern = PatternFilters::new()
.add_filter(PositionFilter::new(
PositionRange::Multiple(vec![3, 7, 11]), // Dots at specific positions
CharacterFilter::Exact('.'),
))
.add_filter(PositionFilter::new(
PositionRange::Multiple(vec![0,1,2,4,5,6,8,9,10,12,13,14]), // Number positions
CharacterFilter::Numeric,
));
Some(ValidationConfigBuilder::new()
.with_pattern_filters(ipv4_pattern)
.with_max_length(15) // XXX.XXX.XXX.XXX = up to 15 chars
.build())
}
3 => {
// 🏷️ Product Code (ABC-123-XYZ) - Mixed format sections
// This showcases: Different rules for different sections
let product_code_pattern = PatternFilters::new()
.add_filter(PositionFilter::new(
PositionRange::Range(0, 2), // First 3 positions: letters
CharacterFilter::Alphabetic,
))
.add_filter(PositionFilter::new(
PositionRange::Multiple(vec![3, 7]), // Dashes
CharacterFilter::Exact('-'),
))
.add_filter(PositionFilter::new(
PositionRange::Range(4, 6), // Middle 3 positions: numbers
CharacterFilter::Numeric,
))
.add_filter(PositionFilter::new(
PositionRange::Range(8, 10), // Last 3 positions: letters
CharacterFilter::Alphabetic,
));
Some(ValidationConfigBuilder::new()
.with_pattern_filters(product_code_pattern)
.with_max_length(11) // ABC-123-XYZ = 11 characters
.build())
}
4 => {
// 📅 Date Code (2024W15) - Year + Week format
// This showcases: From position filtering and mixed patterns
let date_code_pattern = PatternFilters::new()
.add_filter(PositionFilter::new(
PositionRange::Range(0, 3), // Year: 4 digits
CharacterFilter::Numeric,
))
.add_filter(PositionFilter::new(
PositionRange::Single(4), // Week indicator
CharacterFilter::Exact('W'),
))
.add_filter(PositionFilter::new(
PositionRange::From(5), // Week number: rest are digits
CharacterFilter::Numeric,
));
Some(ValidationConfigBuilder::new()
.with_pattern_filters(date_code_pattern)
.with_max_length(7) // 2024W15 = 7 characters
.build())
}
5 => {
// 🔢 Binary (101010) - Only 0s and 1s
// This showcases: OneOf filter with limited character set
let binary_pattern = PatternFilters::new()
.add_filter(PositionFilter::new(
PositionRange::From(0), // All positions
CharacterFilter::OneOf(vec!['0', '1']),
));
Some(ValidationConfigBuilder::new()
.with_pattern_filters(binary_pattern)
.with_max_length(16) // Allow up to 16 binary digits
.build())
}
6 => {
// 🎯 Complex ID (A1-B2C-3D4E) - Multiple overlapping rules
// This showcases: Complex overlapping patterns and edge cases
let complex_id_pattern = PatternFilters::new()
.add_filter(PositionFilter::new(
PositionRange::Multiple(vec![0, 3, 6, 8]), // Letter positions
CharacterFilter::Alphabetic,
))
.add_filter(PositionFilter::new(
PositionRange::Multiple(vec![1, 4, 7, 9]), // Number positions
CharacterFilter::Numeric,
))
.add_filter(PositionFilter::new(
PositionRange::Multiple(vec![2, 5]), // Dashes
CharacterFilter::Exact('-'),
))
.add_filter(PositionFilter::new(
PositionRange::Single(5), // Special case: override dash with letter C
CharacterFilter::Alphabetic, // This creates an interesting edge case
));
Some(ValidationConfigBuilder::new()
.with_pattern_filters(complex_id_pattern)
.with_max_length(10) // A1-B2C-3D4E = 10 characters
.build())
}
7 => {
// 🚀 Custom Pattern - Advanced logic with custom function
// This showcases: Custom validation function for complex rules
let custom_pattern = PatternFilters::new()
.add_filter(PositionFilter::new(
PositionRange::From(0),
CharacterFilter::Custom(Arc::new(|c| {
// Advanced rule: Alternating vowels and consonants!
// Even positions (0,2,4...): vowels (a,e,i,o,u)
// Odd positions (1,3,5...): consonants
let vowels = ['a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U'];
// For demo purposes, we'll just accept alphabetic characters
// In real usage, you'd implement the alternating logic based on position
c.is_alphabetic()
})),
));
Some(ValidationConfigBuilder::new()
.with_pattern_filters(custom_pattern)
.with_max_length(12) // Allow up to 12 characters
.build())
}
_ => None,
}
}
}
// Key handling (same structure as before)
fn handle_key_press(
key: KeyCode,
modifiers: KeyModifiers,
editor: &mut AdvancedPatternFormEditor<AdvancedPatternData>,
) -> anyhow::Result<bool> {
let mode = editor.mode();
// Quit handling
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL))
|| (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL))
|| key == KeyCode::F(10)
{
return Ok(false);
}
match (mode, key, modifiers) {
// Mode transitions
(AppMode::ReadOnly, KeyCode::Char('i'), _) => { editor.enter_edit_mode(); editor.clear_command_buffer(); }
(AppMode::ReadOnly, KeyCode::Char('a'), _) => { editor.enter_append_mode(); editor.clear_command_buffer(); }
(AppMode::ReadOnly, KeyCode::Char('A'), _) => { editor.move_line_end(); editor.enter_edit_mode(); editor.clear_command_buffer(); }
(_, KeyCode::Esc, _) => { if mode == AppMode::Edit { editor.exit_edit_mode(); } else { editor.clear_command_buffer(); } }
// Validation commands
(AppMode::ReadOnly, KeyCode::F(1), _) => { editor.toggle_validation(); }
// Movement in ReadOnly mode
(AppMode::ReadOnly, KeyCode::Char('h'), _) | (AppMode::ReadOnly, KeyCode::Left, _) => { editor.move_left(); editor.clear_command_buffer(); }
(AppMode::ReadOnly, KeyCode::Char('l'), _) | (AppMode::ReadOnly, KeyCode::Right, _) => { editor.move_right(); editor.clear_command_buffer(); }
(AppMode::ReadOnly, KeyCode::Char('j'), _) | (AppMode::ReadOnly, KeyCode::Down, _) => { editor.move_down(); editor.clear_command_buffer(); }
(AppMode::ReadOnly, KeyCode::Char('k'), _) | (AppMode::ReadOnly, KeyCode::Up, _) => { editor.move_up(); editor.clear_command_buffer(); }
// Movement in Edit mode
(AppMode::Edit, KeyCode::Left, _) => { editor.move_left(); }
(AppMode::Edit, KeyCode::Right, _) => { editor.move_right(); }
(AppMode::Edit, KeyCode::Up, _) => { editor.move_up(); }
(AppMode::Edit, KeyCode::Down, _) => { editor.move_down(); }
// Delete operations
(AppMode::Edit, KeyCode::Backspace, _) => { editor.delete_backward()?; }
(AppMode::Edit, KeyCode::Delete, _) => { editor.delete_forward()?; }
// Tab navigation
(_, KeyCode::Tab, _) => { editor.next_field(); }
(_, KeyCode::BackTab, _) => { editor.prev_field(); }
// Character input
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
editor.insert_char(c)?;
}
// Debug info
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
let summary = editor.editor.validation_summary();
editor.set_debug_message(format!(
"Field {}/{}, Pos {}, Mode: {:?}, Advanced patterns: {} configured",
editor.current_field() + 1,
editor.data_provider().field_count(),
editor.cursor_position(),
editor.mode(),
summary.total_fields
));
}
_ => {
if editor.has_pending_command() {
editor.clear_command_buffer();
editor.set_debug_message("Invalid command sequence".to_string());
}
}
}
Ok(true)
}
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut editor: AdvancedPatternFormEditor<AdvancedPatternData>,
) -> io::Result<()> {
loop {
terminal.draw(|f| ui(f, &editor))?;
if let Event::Key(key) = event::read()? {
match handle_key_press(key.code, key.modifiers, &mut editor) {
Ok(should_continue) => {
if !should_continue {
break;
}
}
Err(e) => {
editor.set_debug_message(format!("Error: {}", e));
}
}
}
}
Ok(())
}
fn ui(f: &mut Frame, editor: &AdvancedPatternFormEditor<AdvancedPatternData>) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(15)])
.split(f.area());
render_canvas_default(f, chunks[0], &editor.editor);
render_advanced_validation_status(f, chunks[1], editor);
}
fn render_advanced_validation_status(
f: &mut Frame,
area: Rect,
editor: &AdvancedPatternFormEditor<AdvancedPatternData>,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Status bar
Constraint::Length(5), // Validation summary
Constraint::Length(7), // Help
])
.split(area);
// Status bar
let mode_text = match editor.mode() {
AppMode::Edit => "INSERT | (bar cursor)",
AppMode::ReadOnly => "NORMAL █ (block cursor)",
_ => "NORMAL █ (block cursor)",
};
let validation_status = editor.get_validation_status();
let status_text = format!("-- {} -- {} | Advanced Patterns: {}", mode_text, editor.debug_message(), validation_status);
let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("🚀 Advanced Pattern Validation"));
f.render_widget(status, chunks[0]);
// Enhanced validation summary
let summary = editor.editor.validation_summary();
let field_info = match editor.current_field() {
0 => "Time format (HH:MM) - Tests exact chars + numeric ranges",
1 => "Hex color (#RRGGBB) - Tests OneOf filter with case insensitive",
2 => "IPv4 address - Tests complex dot positioning",
3 => "Product code (ABC-123-XYZ) - Tests section-based patterns",
4 => "Date code (2024W15) - Tests From position filtering",
5 => "Binary input - Tests limited character set (0,1 only)",
6 => "Complex ID - Tests overlapping/conflicting rules",
7 => "Custom pattern - Tests advanced custom validation logic",
_ => "Unknown field",
};
let summary_text = if editor.validation_enabled {
format!(
"📊 Advanced Pattern Summary: {} fields with complex rules\n\
Current Field: {}\n\
✅ Valid: {} ⚠️ Warnings: {} ❌ Errors: {} 📈 Progress: {:.0}%\n\
🎯 Pattern Focus: {}",
summary.total_fields,
editor.current_field() + 1,
summary.valid_fields,
summary.warning_fields,
summary.error_fields,
summary.completion_percentage() * 100.0,
field_info
)
} else {
"❌ Advanced pattern validation is DISABLED\nPress F1 to enable and see the magic!".to_string()
};
let summary_style = if summary.has_errors() {
Style::default().fg(Color::Red)
} else if summary.has_warnings() {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Green)
};
let validation_summary = Paragraph::new(summary_text)
.block(Block::default().borders(Borders::ALL).title("🎯 Advanced Pattern Analysis"))
.style(summary_style)
.wrap(Wrap { trim: true });
f.render_widget(validation_summary, chunks[1]);
// Enhanced help text
let help_text = match editor.mode() {
AppMode::ReadOnly => {
"🚀 ADVANCED PATTERN SHOWCASE - Each field demonstrates different edge cases!\n\
🕐 Time: Numeric+exact chars 🎨 Hex: OneOf with case-insensitive 🌐 IPv4: Complex positioning\n\
🏷️ Product: Multi-section rules 📅 Date: From-position filtering 🔢 Binary: Limited charset\n\
🎯 Complex: Overlapping rules 🚀 Custom: Advanced logic functions\n\
\n\
Movement: hjkl/arrows=move, Tab/Shift+Tab=fields, i/a=insert, F1=toggle, ?=info"
}
AppMode::Edit => {
"✏️ INSERT MODE - Testing advanced pattern validation!\n\
Each character is validated against complex rules in real-time\n\
Try entering invalid characters to see detailed error messages\n\
arrows=move, Backspace/Del=delete, Esc=normal, Tab=next field"
}
_ => "🚀 Advanced Pattern Validation Active!"
};
let help = Paragraph::new(help_text)
.block(Block::default().borders(Borders::ALL).title("🎯 Advanced Pattern Commands & Info"))
.style(Style::default().fg(Color::Gray))
.wrap(Wrap { trim: true });
f.render_widget(help, chunks[2]);
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🚀 Canvas Advanced Pattern Validation Demo");
println!("✅ validation feature: ENABLED");
println!("✅ gui feature: ENABLED");
println!("✅ cursor-style feature: ENABLED");
println!("🎯 Advanced pattern filtering: ACTIVE");
println!("🧪 Edge cases and complex patterns: READY");
println!("💡 Each field showcases different validation capabilities!");
println!();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let data = AdvancedPatternData::new();
let mut editor = AdvancedPatternFormEditor::new(data);
// Initialize with normal mode - library automatically sets block cursor
editor.set_mode(AppMode::ReadOnly);
// Demonstrate that CursorManager is available and working
CursorManager::update_for_mode(AppMode::ReadOnly)?;
let res = run_app(&mut terminal, editor);
// Library automatically resets cursor on FormEditor::drop()
// But we can also manually reset if needed
CursorManager::reset()?;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err);
}
println!("🚀 Advanced pattern validation demo completed!");
println!("🎯 Hope you enjoyed seeing all the edge cases in action!");
Ok(())
}

View File

@@ -0,0 +1,732 @@
// examples/validation_3.rs
//! Comprehensive Display Mask Features Demo
//!
//! This example showcases the full power of the display mask system (Feature 3)
//! demonstrating visual formatting that keeps business logic clean.
//!
//! Key Features Demonstrated:
//! - Dynamic vs Template display modes
//! - Custom patterns for different data types
//! - Custom input characters and separators
//! - Custom placeholder characters
//! - Real-time visual formatting with clean raw data
//! - Cursor movement through formatted displays
//! - 🔥 CRITICAL: Perfect mask/character-limit coordination to prevent invisible character bugs
//!
//! ⚠️ IMPORTANT BUG PREVENTION:
//! This example demonstrates the CORRECT way to configure masks with character limits.
//! Each mask's input position count EXACTLY matches its character limit to prevent
//! the critical bug where users can type more characters than they can see.
//!
//! Run with: cargo run --example validation_3 --features "gui,validation,cursor-style"
// REQUIRE validation, gui and cursor-style features for mask functionality
#[cfg(not(all(feature = "validation", feature = "gui", feature = "cursor-style")))]
compile_error!(
"This example requires the 'validation', 'gui' and 'cursor-style' features. \
Run with: cargo run --example validation_3 --features \"gui,validation,cursor-style\""
);
use std::io;
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers,
},
execute,
terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
Frame, Terminal,
};
use canvas::{
canvas::{
gui::render_canvas_default,
modes::AppMode,
CursorManager,
},
DataProvider, FormEditor,
ValidationConfig, ValidationConfigBuilder, DisplayMask,
validation::mask::MaskDisplayMode,
};
// Enhanced FormEditor wrapper for mask demonstration
struct MaskDemoFormEditor<D: DataProvider> {
editor: FormEditor<D>,
debug_message: String,
command_buffer: String,
validation_enabled: bool,
show_raw_data: bool,
}
impl<D: DataProvider> MaskDemoFormEditor<D> {
fn new(data_provider: D) -> Self {
let mut editor = FormEditor::new(data_provider);
editor.set_validation_enabled(true);
Self {
editor,
debug_message: "🎭 Display Mask Demo - Visual formatting with clean business logic!".to_string(),
command_buffer: String::new(),
validation_enabled: true,
show_raw_data: false,
}
}
// === COMMAND BUFFER HANDLING ===
fn clear_command_buffer(&mut self) { self.command_buffer.clear(); }
fn add_to_command_buffer(&mut self, ch: char) { self.command_buffer.push(ch); }
fn get_command_buffer(&self) -> &str { &self.command_buffer }
fn has_pending_command(&self) -> bool { !self.command_buffer.is_empty() }
// === MASK CONTROL ===
fn toggle_validation(&mut self) {
self.validation_enabled = !self.validation_enabled;
self.editor.set_validation_enabled(self.validation_enabled);
if self.validation_enabled {
self.debug_message = "✅ Display Masks ENABLED - See visual formatting in action!".to_string();
} else {
self.debug_message = "❌ Display Masks DISABLED - Raw text only".to_string();
}
}
fn toggle_raw_data_view(&mut self) {
self.show_raw_data = !self.show_raw_data;
if self.show_raw_data {
self.debug_message = "👁️ Showing RAW business data (what's actually stored)".to_string();
} else {
self.debug_message = "🎭 Showing FORMATTED display (what users see)".to_string();
}
}
fn get_current_field_info(&self) -> (String, String, String) {
let field_index = self.editor.current_field();
let raw_data = self.editor.current_text();
let display_data = if self.validation_enabled {
self.editor.current_display_text()
} else {
raw_data.to_string()
};
let mask_info = if let Some(config) = self.editor.validation_state().get_field_config(field_index) {
if let Some(mask) = &config.display_mask {
format!("Pattern: '{}', Mode: {:?}",
mask.pattern(),
mask.display_mode())
} else {
"No mask configured".to_string()
}
} else {
"No validation config".to_string()
};
(raw_data.to_string(), display_data, mask_info)
}
// === ENHANCED MOVEMENT WITH MASK AWARENESS ===
fn move_left(&mut self) {
self.editor.move_left();
self.update_cursor_info();
}
fn move_right(&mut self) {
self.editor.move_right();
self.update_cursor_info();
}
fn move_up(&mut self) {
match self.editor.move_up() {
Ok(()) => { self.update_field_info(); }
Err(e) => { self.debug_message = format!("🚫 Field switch blocked: {}", e); }
}
}
fn move_down(&mut self) {
match self.editor.move_down() {
Ok(()) => { self.update_field_info(); }
Err(e) => { self.debug_message = format!("🚫 Field switch blocked: {}", e); }
}
}
fn move_line_start(&mut self) {
self.editor.move_line_start();
self.update_cursor_info();
}
fn move_line_end(&mut self) {
self.editor.move_line_end();
self.update_cursor_info();
}
fn update_cursor_info(&mut self) {
if self.validation_enabled {
let raw_pos = self.editor.cursor_position();
let display_pos = self.editor.display_cursor_position();
if raw_pos != display_pos {
self.debug_message = format!("📍 Cursor: Raw pos {} → Display pos {} (mask active)", raw_pos, display_pos);
} else {
self.debug_message = format!("📍 Cursor at position {} (no mask offset)", raw_pos);
}
}
}
fn update_field_info(&mut self) {
let field_name = self.editor.data_provider().field_name(self.editor.current_field());
self.debug_message = format!("📝 Switched to: {}", field_name);
}
// === MODE TRANSITIONS ===
fn enter_edit_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_edit_mode();
self.debug_message = "✏️ INSERT MODE - Cursor: Steady Bar | - Type to see mask formatting in real-time".to_string();
}
fn enter_append_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_append_mode();
self.debug_message = "✏️ INSERT (append) - Cursor: Steady Bar | - Mask formatting active".to_string();
}
fn exit_edit_mode(&mut self) {
// Library will automatically update cursor to block █ in normal mode
self.editor.exit_edit_mode();
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █ - Press 'r' to see raw data, 'm' for mask info".to_string();
}
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
let result = self.editor.insert_char(ch);
if result.is_ok() {
let (raw, display, _) = self.get_current_field_info();
if raw != display {
self.debug_message = format!("✏️ Added '{}': Raw='{}' Display='{}'", ch, raw, display);
} else {
self.debug_message = format!("✏️ Added '{}': '{}'", ch, raw);
}
}
Ok(result?)
}
// === DELETE OPERATIONS ===
fn delete_backward(&mut self) -> anyhow::Result<()> {
let result = self.editor.delete_backward();
if result.is_ok() {
self.debug_message = "⌫ Character deleted".to_string();
self.update_cursor_info();
}
Ok(result?)
}
fn delete_forward(&mut self) -> anyhow::Result<()> {
let result = self.editor.delete_forward();
if result.is_ok() {
self.debug_message = "⌦ Character deleted".to_string();
self.update_cursor_info();
}
Ok(result?)
}
// === DELEGATE TO ORIGINAL EDITOR ===
fn current_field(&self) -> usize { self.editor.current_field() }
fn cursor_position(&self) -> usize { self.editor.cursor_position() }
fn mode(&self) -> AppMode { self.editor.mode() }
fn current_text(&self) -> &str { self.editor.current_text() }
fn data_provider(&self) -> &D { self.editor.data_provider() }
fn ui_state(&self) -> &canvas::EditorState { self.editor.ui_state() }
fn set_mode(&mut self, mode: AppMode) {
// Library automatically updates cursor for the mode
self.editor.set_mode(mode);
}
fn next_field(&mut self) {
match self.editor.next_field() {
Ok(()) => { self.update_field_info(); }
Err(e) => { self.debug_message = format!("🚫 Cannot move to next field: {}", e); }
}
}
fn prev_field(&mut self) {
match self.editor.prev_field() {
Ok(()) => { self.update_field_info(); }
Err(e) => { self.debug_message = format!("🚫 Cannot move to previous field: {}", e); }
}
}
// === STATUS AND DEBUG ===
fn set_debug_message(&mut self, msg: String) { self.debug_message = msg; }
fn debug_message(&self) -> &str { &self.debug_message }
fn show_mask_details(&mut self) {
let (raw, display, mask_info) = self.get_current_field_info();
self.debug_message = format!("🔍 Field {}: {} | Raw: '{}' Display: '{}'",
self.current_field() + 1, mask_info, raw, display);
}
fn get_mask_status(&self) -> String {
if !self.validation_enabled {
return "❌ DISABLED".to_string();
}
let field_count = self.editor.data_provider().field_count();
let mut mask_count = 0;
for i in 0..field_count {
if let Some(config) = self.editor.validation_state().get_field_config(i) {
if config.display_mask.is_some() {
mask_count += 1;
}
}
}
format!("🎭 {} MASKS", mask_count)
}
}
// Demo data with comprehensive mask examples
struct MaskDemoData {
fields: Vec<(String, String)>,
}
impl MaskDemoData {
fn new() -> Self {
Self {
fields: vec![
("📞 Phone (Dynamic)".to_string(), "".to_string()),
("📞 Phone (Template)".to_string(), "".to_string()),
("📅 Date US (MM/DD/YYYY)".to_string(), "".to_string()),
("📅 Date EU (DD.MM.YYYY)".to_string(), "".to_string()),
("📅 Date ISO (YYYY-MM-DD)".to_string(), "".to_string()),
("🏛️ SSN (XXX-XX-XXXX)".to_string(), "".to_string()),
("💳 Credit Card".to_string(), "".to_string()),
("🏢 Employee ID (EMP-####)".to_string(), "".to_string()),
("📦 Product Code (ABC###XYZ)".to_string(), "".to_string()),
("🌈 Custom Separators".to_string(), "".to_string()),
("⭐ Custom Placeholders".to_string(), "".to_string()),
("🎯 Mixed Input Chars".to_string(), "".to_string()),
],
}
}
}
impl DataProvider for MaskDemoData {
fn field_count(&self) -> usize { self.fields.len() }
fn field_name(&self, index: usize) -> &str { &self.fields[index].0 }
fn field_value(&self, index: usize) -> &str { &self.fields[index].1 }
fn set_field_value(&mut self, index: usize, value: String) { self.fields[index].1 = value; }
fn validation_config(&self, field_index: usize) -> Option<ValidationConfig> {
match field_index {
0 => {
// 📞 Phone (Dynamic) - FIXED: Perfect mask/limit coordination
let phone_mask = DisplayMask::new("(###) ###-####", '#');
Some(ValidationConfigBuilder::new()
.with_display_mask(phone_mask)
.with_max_length(10) // ✅ CRITICAL: Exactly matches 10 input positions
.build())
}
1 => {
// 📞 Phone (Template) - FIXED: Perfect mask/limit coordination
let phone_template = DisplayMask::new("(###) ###-####", '#')
.with_template('_');
Some(ValidationConfigBuilder::new()
.with_display_mask(phone_template)
.with_max_length(10) // ✅ CRITICAL: Exactly matches 10 input positions
.build())
}
2 => {
// 📅 Date US (MM/DD/YYYY) - American date format
let us_date = DisplayMask::new("##/##/####", '#');
Some(ValidationConfig::with_mask(us_date))
}
3 => {
// 📅 Date EU (DD.MM.YYYY) - European date format with dots
let eu_date = DisplayMask::new("##.##.####", '#')
.with_template('•');
Some(ValidationConfig::with_mask(eu_date))
}
4 => {
// 📅 Date ISO (YYYY-MM-DD) - ISO date format
let iso_date = DisplayMask::new("####-##-##", '#')
.with_template('-');
Some(ValidationConfig::with_mask(iso_date))
}
5 => {
// 🏛️ SSN using custom input character 'X' - FIXED: Perfect coordination
let ssn_mask = DisplayMask::new("XXX-XX-XXXX", 'X');
Some(ValidationConfigBuilder::new()
.with_display_mask(ssn_mask)
.with_max_length(9) // ✅ CRITICAL: Exactly matches 9 input positions
.build())
}
6 => {
// 💳 Credit Card (16 digits with spaces) - FIXED: Perfect coordination
let cc_mask = DisplayMask::new("#### #### #### ####", '#')
.with_template('•');
Some(ValidationConfigBuilder::new()
.with_display_mask(cc_mask)
.with_max_length(16) // ✅ CRITICAL: Exactly matches 16 input positions
.build())
}
7 => {
// 🏢 Employee ID with business prefix
let emp_id = DisplayMask::new("EMP-####", '#');
Some(ValidationConfig::with_mask(emp_id))
}
8 => {
// 📦 Product Code with mixed letters and numbers
let product_code = DisplayMask::new("ABC###XYZ", '#');
Some(ValidationConfig::with_mask(product_code))
}
9 => {
// 🌈 Custom Separators - Using | and ~ as separators
let custom_sep = DisplayMask::new("##|##~####", '#')
.with_template('?');
Some(ValidationConfig::with_mask(custom_sep))
}
10 => {
// ⭐ Custom Placeholders - Using different placeholder characters
let custom_placeholder = DisplayMask::new("##-##-##", '#')
.with_template('★');
Some(ValidationConfig::with_mask(custom_placeholder))
}
11 => {
// 🎯 Mixed Input Characters - Using 'N' for numbers
let mixed_input = DisplayMask::new("ID:NNN-NNN", 'N');
Some(ValidationConfig::with_mask(mixed_input))
}
_ => None,
}
}
}
// Enhanced key handling with mask-specific commands
fn handle_key_press(
key: KeyCode,
modifiers: KeyModifiers,
editor: &mut MaskDemoFormEditor<MaskDemoData>,
) -> anyhow::Result<bool> {
let mode = editor.mode();
// Quit handling
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL))
|| (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL))
|| key == KeyCode::F(10)
{
return Ok(false);
}
match (mode, key, modifiers) {
// === MODE TRANSITIONS ===
(AppMode::ReadOnly, KeyCode::Char('i'), _) => {
editor.enter_edit_mode();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
editor.enter_append_mode();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('A'), _) => {
editor.move_line_end();
editor.enter_edit_mode();
editor.clear_command_buffer();
}
// Escape: Exit edit mode
(_, KeyCode::Esc, _) => {
if mode == AppMode::Edit {
editor.exit_edit_mode();
} else {
editor.clear_command_buffer();
}
}
// === MASK SPECIFIC COMMANDS ===
(AppMode::ReadOnly, KeyCode::Char('m'), _) => {
editor.show_mask_details();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('r'), _) => {
editor.toggle_raw_data_view();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::F(1), _) => {
editor.toggle_validation();
}
// === MOVEMENT ===
(AppMode::ReadOnly, KeyCode::Char('h'), _) | (AppMode::ReadOnly, KeyCode::Left, _) => {
editor.move_left();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('l'), _) | (AppMode::ReadOnly, KeyCode::Right, _) => {
editor.move_right();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('j'), _) | (AppMode::ReadOnly, KeyCode::Down, _) => {
editor.move_down();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('k'), _) | (AppMode::ReadOnly, KeyCode::Up, _) => {
editor.move_up();
editor.clear_command_buffer();
}
// Line movement
(AppMode::ReadOnly, KeyCode::Char('0'), _) => {
editor.move_line_start();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('$'), _) => {
editor.move_line_end();
editor.clear_command_buffer();
}
// === EDIT MODE MOVEMENT ===
(AppMode::Edit, KeyCode::Left, _) => { editor.move_left(); }
(AppMode::Edit, KeyCode::Right, _) => { editor.move_right(); }
(AppMode::Edit, KeyCode::Up, _) => { editor.move_up(); }
(AppMode::Edit, KeyCode::Down, _) => { editor.move_down(); }
// === DELETE OPERATIONS ===
(AppMode::Edit, KeyCode::Backspace, _) => { editor.delete_backward()?; }
(AppMode::Edit, KeyCode::Delete, _) => { editor.delete_forward()?; }
// === TAB NAVIGATION ===
(_, KeyCode::Tab, _) => { editor.next_field(); }
(_, KeyCode::BackTab, _) => { editor.prev_field(); }
// === CHARACTER INPUT ===
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
editor.insert_char(c)?;
}
// === DEBUG/INFO COMMANDS ===
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
let (raw, display, mask_info) = editor.get_current_field_info();
editor.set_debug_message(format!(
"Field {}/{}, Cursor {}, {}, Raw: '{}', Display: '{}'",
editor.current_field() + 1,
editor.data_provider().field_count(),
editor.cursor_position(),
mask_info,
raw,
display
));
}
_ => {
if editor.has_pending_command() {
editor.clear_command_buffer();
editor.set_debug_message("Invalid command sequence".to_string());
}
}
}
Ok(true)
}
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut editor: MaskDemoFormEditor<MaskDemoData>,
) -> io::Result<()> {
loop {
terminal.draw(|f| ui(f, &editor))?;
if let Event::Key(key) = event::read()? {
match handle_key_press(key.code, key.modifiers, &mut editor) {
Ok(should_continue) => {
if !should_continue {
break;
}
}
Err(e) => {
editor.set_debug_message(format!("Error: {}", e));
}
}
}
}
Ok(())
}
fn ui(f: &mut Frame, editor: &MaskDemoFormEditor<MaskDemoData>) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(16)])
.split(f.area());
render_enhanced_canvas(f, chunks[0], editor);
render_mask_status(f, chunks[1], editor);
}
fn render_enhanced_canvas(
f: &mut Frame,
area: Rect,
editor: &MaskDemoFormEditor<MaskDemoData>,
) {
render_canvas_default(f, area, &editor.editor);
}
fn render_mask_status(
f: &mut Frame,
area: Rect,
editor: &MaskDemoFormEditor<MaskDemoData>,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Status bar
Constraint::Length(6), // Data comparison
Constraint::Length(7), // Help
])
.split(area);
// Status bar with mask information
let mode_text = match editor.mode() {
AppMode::Edit => "INSERT | (bar cursor)",
AppMode::ReadOnly => "NORMAL █ (block cursor)",
_ => "NORMAL █ (block cursor)",
};
let mask_status = editor.get_mask_status();
let status_text = format!("-- {} -- {} | Masks: {} | View: {}",
mode_text,
editor.debug_message(),
mask_status,
if editor.show_raw_data { "RAW" } else { "FORMATTED" });
let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("🎭 Display Mask Demo"));
f.render_widget(status, chunks[0]);
// Data comparison showing raw vs display
let (raw_data, display_data, mask_info) = editor.get_current_field_info();
let field_name = editor.data_provider().field_name(editor.current_field());
let comparison_text = format!(
"📝 Current Field: {}\n\
🔧 Mask Config: {}\n\
\n\
💾 Raw Business Data: '{}' ← What's actually stored in your database\n\
🎭 Formatted Display: '{}' ← What users see in the interface\n\
📍 Cursor: Raw pos {} → Display pos {}",
field_name,
mask_info,
raw_data,
display_data,
editor.cursor_position(),
editor.editor.display_cursor_position()
);
let comparison_style = if raw_data != display_data {
Style::default().fg(Color::Green) // Green when mask is active
} else {
Style::default().fg(Color::Gray) // Gray when no formatting
};
let data_comparison = Paragraph::new(comparison_text)
.block(Block::default().borders(Borders::ALL).title("📊 Raw Data vs Display Formatting"))
.style(comparison_style)
.wrap(Wrap { trim: true });
f.render_widget(data_comparison, chunks[1]);
// Enhanced help text
let help_text = match editor.mode() {
AppMode::ReadOnly => {
"🎯 CURSOR-STYLE: Normal █ | Insert |\n\
🎭 MASK DEMO: Visual formatting keeps business logic clean!\n\
\n\
📱 Try different fields to see various mask patterns:\n\
• Dynamic vs Template modes • Custom separators • Different input chars\n\
\n\
Commands: i/a=insert, m=mask details, r=toggle raw/display view\n\
Movement: hjkl/arrows=move, 0=$=line start/end, Tab=next field, F1=toggle masks\n\
?=detailed info, Ctrl+C=quit"
}
AppMode::Edit => {
"🎯 INSERT MODE - Cursor: | (bar)\n\
✏️ Type to see real-time mask formatting!\n\
\n\
🔥 Key Features in Action:\n\
• Separators auto-appear as you type • Cursor skips over separators\n\
• Template fields show placeholders • Raw data stays clean for business logic\n\
\n\
arrows=move through mask, Backspace/Del=delete, Esc=normal, Tab=next field\n\
Notice how cursor position maps between raw data and display!"
}
_ => "🎭 Display Mask Demo Active!"
};
let help = Paragraph::new(help_text)
.block(Block::default().borders(Borders::ALL).title("🚀 Mask Features & Commands"))
.style(Style::default().fg(Color::Gray))
.wrap(Wrap { trim: true });
f.render_widget(help, chunks[2]);
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Print feature status
println!("🎭 Canvas Display Mask Demo (Feature 3)");
println!("✅ validation feature: ENABLED");
println!("✅ gui feature: ENABLED");
println!("🎭 Display masks: ACTIVE");
println!("✅ cursor-style feature: ENABLED");
println!("🔥 Key Benefits Demonstrated:");
println!(" • Clean separation: Visual formatting ≠ Business logic");
println!(" • User-friendly: Pretty displays with automatic cursor handling");
println!(" • Flexible: Custom patterns, separators, and placeholders");
println!(" • Transparent: Library handles all complexity, API stays simple");
println!();
println!("💡 Try typing in different fields to see mask magic!");
println!(" 📞 Phone fields show dynamic vs template modes");
println!(" 📅 Date fields show different regional formats");
println!(" 💳 Credit card shows spaced formatting");
println!(" ⭐ Custom fields show advanced separator/placeholder options");
println!();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let data = MaskDemoData::new();
let mut editor = MaskDemoFormEditor::new(data);
// Initialize with normal mode - library automatically sets block cursor
editor.set_mode(AppMode::ReadOnly);
// Demonstrate that CursorManager is available and working
CursorManager::update_for_mode(AppMode::ReadOnly)?;
let res = run_app(&mut terminal, editor);
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
// Library automatically resets cursor on FormEditor::drop()
// But we can also manually reset if needed
CursorManager::reset()?;
if let Err(err) = res {
println!("{:?}", err);
}
println!("🎭 Display mask demo completed!");
println!("🏆 You've seen how masks provide beautiful UX while keeping business logic clean!");
Ok(())
}

View File

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

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

View File

@@ -19,7 +19,7 @@ use crate::editor::FormEditor;
#[cfg(feature = "gui")]
use std::cmp::{max, min};
/// Render ONLY the canvas form fields - no autocomplete
/// Render ONLY the canvas form fields - no suggestions rendering here
/// Updated to work with FormEditor instead of CanvasState trait
#[cfg(feature = "gui")]
pub fn render_canvas<T: CanvasTheme, D: DataProvider>(
@@ -52,12 +52,30 @@ pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
for i in 0..field_count {
fields.push(data_provider.field_name(i));
inputs.push(data_provider.field_value(i).to_string());
// Use editor-provided effective display text per field (Feature 4/mask aware)
#[cfg(feature = "validation")]
{
inputs.push(editor.display_text_for_field(i));
}
#[cfg(not(feature = "validation"))]
{
inputs.push(data_provider.field_value(i).to_string());
}
}
let current_field_idx = ui_state.current_field();
let is_edit_mode = matches!(ui_state.mode(), crate::canvas::modes::AppMode::Edit);
// Precompute completion for active field
let active_completion = if ui_state.is_suggestions_active()
&& ui_state.suggestions.active_field == Some(current_field_idx)
{
ui_state.suggestions.completion_text.clone()
} else {
None
};
render_canvas_fields(
f,
area,
@@ -66,13 +84,50 @@ pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
&inputs,
theme,
is_edit_mode,
highlight_state, // Now using the actual highlight state!
ui_state.cursor_position(),
highlight_state,
editor.display_cursor_position(), // Use display cursor position for masks
false, // TODO: track unsaved changes in editor
|i| {
data_provider.display_value(i).unwrap_or(data_provider.field_value(i)).to_string()
// Get display value for field i using editor logic (Feature 4 + masks)
#[cfg(feature = "validation")]
{
editor.display_text_for_field(i)
}
#[cfg(not(feature = "validation"))]
{
data_provider.field_value(i).to_string()
}
},
|i| {
// Check if field has display override (custom formatter or mask)
#[cfg(feature = "validation")]
{
editor.ui_state().validation_state().get_field_config(i)
.map(|cfg| {
// Formatter takes precedence; if present, it's a display override
#[allow(unused_mut)]
let mut has_override = false;
#[cfg(feature = "validation")]
{
has_override = cfg.custom_formatter.is_some();
}
has_override || cfg.display_mask.is_some()
})
.unwrap_or(false)
}
#[cfg(not(feature = "validation"))]
{
false
}
},
// NEW: provide completion for the active field
|i| {
if i == current_field_idx {
active_completion.clone()
} else {
None
}
},
|i| data_provider.display_value(i).is_some(),
)
}
@@ -90,7 +145,7 @@ fn convert_selection_to_highlight(selection: &crate::canvas::state::SelectionSta
/// Core canvas field rendering
#[cfg(feature = "gui")]
fn render_canvas_fields<T: CanvasTheme, F1, F2>(
fn render_canvas_fields<T: CanvasTheme, F1, F2, F3>(
f: &mut Frame,
area: Rect,
fields: &[&str],
@@ -103,10 +158,12 @@ fn render_canvas_fields<T: CanvasTheme, F1, F2>(
has_unsaved_changes: bool,
get_display_value: F1,
has_display_override: F2,
get_completion: F3,
) -> Option<Rect>
where
F1: Fn(usize) -> String,
F2: Fn(usize) -> bool,
F3: Fn(usize) -> Option<String>,
{
// Create layout
let columns = Layout::default()
@@ -160,6 +217,7 @@ where
current_cursor_pos,
get_display_value,
has_display_override,
get_completion,
)
}
@@ -191,7 +249,7 @@ fn render_field_labels<T: CanvasTheme>(
/// Render field values with highlighting
#[cfg(feature = "gui")]
fn render_field_values<T: CanvasTheme, F1, F2>(
fn render_field_values<T: CanvasTheme, F1, F2, F3>(
f: &mut Frame,
input_rows: Vec<Rect>,
inputs: &[String],
@@ -201,35 +259,54 @@ fn render_field_values<T: CanvasTheme, F1, F2>(
current_cursor_pos: usize,
get_display_value: F1,
has_display_override: F2,
get_completion: F3,
) -> Option<Rect>
where
F1: Fn(usize) -> String,
F2: Fn(usize) -> bool,
F3: Fn(usize) -> Option<String>,
{
let mut active_field_input_rect = None;
for (i, _input) in inputs.iter().enumerate() {
let is_active = i == *current_field_idx;
let text = get_display_value(i);
let typed_text = get_display_value(i);
// Apply highlighting
let line = apply_highlighting(
&text,
i,
current_field_idx,
current_cursor_pos,
highlight_state,
theme,
is_active,
);
let line = if is_active {
// Compose typed + gray completion for the active field
let normal_style = Style::default().fg(theme.fg());
let gray_style = Style::default().fg(theme.suggestion_gray());
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled(typed_text.clone(), normal_style));
if let Some(completion) = get_completion(i) {
if !completion.is_empty() {
spans.push(Span::styled(completion, gray_style));
}
}
Line::from(spans)
} else {
// Non-active fields: keep existing highlighting logic
apply_highlighting(
&typed_text,
i,
current_field_idx,
current_cursor_pos,
highlight_state,
theme,
is_active,
)
};
let input_display = Paragraph::new(line).alignment(Alignment::Left);
f.render_widget(input_display, input_rows[i]);
// Set cursor for active field
// Set cursor for active field at end of typed text (not after completion)
if is_active {
active_field_input_rect = Some(input_rows[i]);
set_cursor_position(f, input_rows[i], &text, current_cursor_pos, has_display_override(i));
set_cursor_position(f, input_rows[i], &typed_text, current_cursor_pos, has_display_override(i));
}
}
@@ -245,7 +322,7 @@ fn apply_highlighting<'a, T: CanvasTheme>(
current_cursor_pos: usize,
highlight_state: &HighlightState,
theme: &T,
is_active: bool,
_is_active: bool,
) -> Line<'a> {
let text_len = text.chars().count();
@@ -257,10 +334,10 @@ fn apply_highlighting<'a, T: CanvasTheme>(
))
}
HighlightState::Characterwise { anchor } => {
apply_characterwise_highlighting(text, text_len, field_index, current_field_idx, current_cursor_pos, anchor, theme, is_active)
apply_characterwise_highlighting(text, text_len, field_index, current_field_idx, current_cursor_pos, anchor, theme, _is_active)
}
HighlightState::Linewise { anchor_line } => {
apply_linewise_highlighting(text, field_index, current_field_idx, anchor_line, theme, is_active)
apply_linewise_highlighting(text, field_index, current_field_idx, anchor_line, theme, _is_active)
}
}
}
@@ -275,7 +352,7 @@ fn apply_characterwise_highlighting<'a, T: CanvasTheme>(
current_cursor_pos: usize,
anchor: &(usize, usize),
theme: &T,
is_active: bool,
_is_active: bool,
) -> Line<'a> {
let (anchor_field, anchor_char) = *anchor;
let start_field = min(anchor_field, *current_field_idx);
@@ -378,7 +455,7 @@ fn apply_linewise_highlighting<'a, T: CanvasTheme>(
current_field_idx: &usize,
anchor_line: &usize,
theme: &T,
is_active: bool,
_is_active: bool,
) -> Line<'a> {
let start_field = min(*anchor_line, *current_field_idx);
let end_field = max(*anchor_line, *current_field_idx);
@@ -411,13 +488,15 @@ fn set_cursor_position(
current_cursor_pos: usize,
has_display_override: bool,
) {
let cursor_x = if has_display_override {
field_rect.x + text.chars().count() as u16
} else {
field_rect.x + current_cursor_pos as u16
};
// BUG FIX: Use the correct display cursor position, not end of text
let cursor_x = field_rect.x + current_cursor_pos as u16;
let cursor_y = field_rect.y;
f.set_cursor_position((cursor_x, cursor_y));
// SAFETY: Ensure cursor doesn't go beyond field bounds
let max_cursor_x = field_rect.x + field_rect.width.saturating_sub(1);
let safe_cursor_x = cursor_x.min(max_cursor_x);
f.set_cursor_position((safe_cursor_x, cursor_y));
}
/// Set default theme if custom not specified

View File

@@ -14,8 +14,8 @@ pub struct EditorState {
// Mode state
pub(crate) current_mode: AppMode,
// Autocomplete state
pub(crate) autocomplete: AutocompleteUIState,
// Suggestions dropdown state
pub(crate) suggestions: SuggestionsUIState,
// Selection state (for vim visual mode)
pub(crate) selection: SelectionState,
@@ -23,14 +23,19 @@ pub struct EditorState {
// Validation state (only available with validation feature)
#[cfg(feature = "validation")]
pub(crate) validation: crate::validation::ValidationState,
/// Computed fields state (only when computed feature is enabled)
#[cfg(feature = "computed")]
pub(crate) computed: Option<crate::computed::ComputedState>,
}
#[derive(Debug, Clone)]
pub struct AutocompleteUIState {
pub struct SuggestionsUIState {
pub(crate) is_active: bool,
pub(crate) is_loading: bool,
pub(crate) selected_index: Option<usize>,
pub(crate) active_field: Option<usize>,
pub(crate) completion_text: Option<String>,
}
#[derive(Debug, Clone)]
@@ -47,15 +52,18 @@ impl EditorState {
cursor_pos: 0,
ideal_cursor_column: 0,
current_mode: AppMode::Edit,
autocomplete: AutocompleteUIState {
suggestions: SuggestionsUIState {
is_active: false,
is_loading: false,
selected_index: None,
active_field: None,
completion_text: None,
},
selection: SelectionState::None,
#[cfg(feature = "validation")]
validation: crate::validation::ValidationState::new(),
#[cfg(feature = "computed")]
computed: None,
}
}
@@ -68,6 +76,15 @@ impl EditorState {
self.current_field
}
/// Check if field is computed
#[cfg(feature = "computed")]
pub fn is_computed_field(&self, field_index: usize) -> bool {
self.computed
.as_ref()
.map(|state| state.is_computed_field(field_index))
.unwrap_or(false)
}
/// Get current cursor position (for user's business logic)
pub fn cursor_position(&self) -> usize {
self.cursor_pos
@@ -83,14 +100,14 @@ impl EditorState {
self.current_mode
}
/// Check if autocomplete is active (for user's business logic)
pub fn is_autocomplete_active(&self) -> bool {
self.autocomplete.is_active
/// Check if suggestions dropdown is active (for user's business logic)
pub fn is_suggestions_active(&self) -> bool {
self.suggestions.is_active
}
/// Check if autocomplete is loading (for user's business logic)
pub fn is_autocomplete_loading(&self) -> bool {
self.autocomplete.is_loading
/// Check if suggestions dropdown is loading (for user's business logic)
pub fn is_suggestions_loading(&self) -> bool {
self.suggestions.is_loading
}
/// Get selection state (for user's business logic)
@@ -117,7 +134,12 @@ impl EditorState {
}
}
pub(crate) fn set_cursor(&mut self, position: usize, max_position: usize, for_edit_mode: bool) {
pub(crate) fn set_cursor(
&mut self,
position: usize,
max_position: usize,
for_edit_mode: bool,
) {
if for_edit_mode {
// Edit mode: can go past end for insertion
self.cursor_pos = position.min(max_position);
@@ -128,18 +150,40 @@ impl EditorState {
self.ideal_cursor_column = self.cursor_pos;
}
pub(crate) fn activate_autocomplete(&mut self, field_index: usize) {
self.autocomplete.is_active = true;
self.autocomplete.is_loading = true;
self.autocomplete.active_field = Some(field_index);
self.autocomplete.selected_index = None;
/// Legacy internal activation (still used internally if needed)
pub(crate) fn activate_suggestions(&mut self, field_index: usize) {
self.suggestions.is_active = true;
self.suggestions.is_loading = true;
self.suggestions.active_field = Some(field_index);
self.suggestions.selected_index = None;
self.suggestions.completion_text = None;
}
pub(crate) fn deactivate_autocomplete(&mut self) {
self.autocomplete.is_active = false;
self.autocomplete.is_loading = false;
self.autocomplete.active_field = None;
self.autocomplete.selected_index = None;
/// Legacy internal deactivation
pub(crate) fn deactivate_suggestions(&mut self) {
self.suggestions.is_active = false;
self.suggestions.is_loading = false;
self.suggestions.active_field = None;
self.suggestions.selected_index = None;
self.suggestions.completion_text = None;
}
/// Explicitly open suggestions — should only be called on Tab
pub(crate) fn open_suggestions(&mut self, field_index: usize) {
self.suggestions.is_active = true;
self.suggestions.is_loading = true;
self.suggestions.active_field = Some(field_index);
self.suggestions.selected_index = None;
self.suggestions.completion_text = None;
}
/// Explicitly close suggestions — should be called on Esc or field change
pub(crate) fn close_suggestions(&mut self) {
self.suggestions.is_active = false;
self.suggestions.is_loading = false;
self.suggestions.active_field = None;
self.suggestions.selected_index = None;
self.suggestions.completion_text = None;
}
}

View File

@@ -14,6 +14,7 @@ pub trait CanvasTheme {
fn highlight(&self) -> Color;
fn highlight_bg(&self) -> Color;
fn warning(&self) -> Color;
fn suggestion_gray(&self) -> Color;
}
@@ -47,4 +48,7 @@ impl CanvasTheme for DefaultCanvasTheme {
fn warning(&self) -> Color {
Color::Red
}
fn suggestion_gray(&self) -> Color {
Color::DarkGray
}
}

View File

@@ -0,0 +1,5 @@
pub mod provider;
pub mod state;
pub use provider::{ComputedContext, ComputedProvider};
pub use state::ComputedState;

View File

@@ -0,0 +1,31 @@
// ================================================================================================
// COMPUTED FIELDS - Provider and Context
// ================================================================================================
/// Context information provided to computed field calculations
#[derive(Debug, Clone)]
pub struct ComputedContext<'a> {
/// All field values in the form (index -> value)
pub field_values: &'a [&'a str],
/// The field index being computed
pub target_field: usize,
/// Current field that user is editing (if any)
pub current_field: Option<usize>,
}
/// User implements this to provide computed field logic
pub trait ComputedProvider {
/// Compute value for a field based on other field values.
/// Called automatically when any field changes.
fn compute_field(&mut self, context: ComputedContext) -> String;
/// Check if this provider handles the given field.
fn handles_field(&self, field_index: usize) -> bool;
/// Get list of field dependencies for optimization.
/// If field A depends on fields [1, 3], only recompute A when fields 1 or 3 change.
/// Default: depend on all fields (always recompute) with a reasonable upper bound.
fn field_dependencies(&self, _field_index: usize) -> Vec<usize> {
(0..100).collect()
}
}

View File

@@ -0,0 +1,88 @@
/* file: canvas/src/computed/state.rs */
/*
Add computed state module file implementing caching and dependencies
*/
// ================================================================================================
// COMPUTED FIELDS - State: caching and dependencies
// ================================================================================================
use std::collections::{HashMap, HashSet};
/// Internal state for computed field management
#[derive(Debug, Clone)]
pub struct ComputedState {
/// Cached computed values (field_index -> computed_value)
computed_values: HashMap<usize, String>,
/// Field dependency graph (field_index -> depends_on_fields)
dependencies: HashMap<usize, Vec<usize>>,
/// Track which fields are computed (display-only)
computed_fields: HashSet<usize>,
}
impl ComputedState {
/// Create a new, empty computed state
pub fn new() -> Self {
Self {
computed_values: HashMap::new(),
dependencies: HashMap::new(),
computed_fields: HashSet::new(),
}
}
/// Register a field as computed with its dependencies
///
/// - `field_index`: the field that is computed (display-only)
/// - `dependencies`: indices of fields this computed field depends on
pub fn register_computed_field(&mut self, field_index: usize, mut dependencies: Vec<usize>) {
// Deduplicate dependencies to keep graph lean
dependencies.sort_unstable();
dependencies.dedup();
self.computed_fields.insert(field_index);
self.dependencies.insert(field_index, dependencies);
}
/// Check if a field is computed (read-only, skip editing/navigation)
pub fn is_computed_field(&self, field_index: usize) -> bool {
self.computed_fields.contains(&field_index)
}
/// Get cached computed value for a field, if available
pub fn get_computed_value(&self, field_index: usize) -> Option<&String> {
self.computed_values.get(&field_index)
}
/// Update cached computed value for a field
pub fn set_computed_value(&mut self, field_index: usize, value: String) {
self.computed_values.insert(field_index, value);
}
/// Get fields that should be recomputed when `changed_field` changed
///
/// This scans the dependency graph and returns all computed fields
/// that list `changed_field` as a dependency.
pub fn fields_to_recompute(&self, changed_field: usize) -> Vec<usize> {
self.dependencies
.iter()
.filter_map(|(field, deps)| {
if deps.contains(&changed_field) {
Some(*field)
} else {
None
}
})
.collect()
}
/// Iterator over all computed field indices
pub fn computed_fields(&self) -> impl Iterator<Item = usize> + '_ {
self.computed_fields.iter().copied()
}
}
impl Default for ComputedState {
fn default() -> Self {
Self::new()
}
}

View File

@@ -18,8 +18,8 @@ pub trait DataProvider {
/// Set field value (library calls this when text changes)
fn set_field_value(&mut self, index: usize, value: String);
/// Check if field supports autocomplete (optional)
fn supports_autocomplete(&self, _field_index: usize) -> bool {
/// Check if field supports suggestions (optional)
fn supports_suggestions(&self, _field_index: usize) -> bool {
false
}
@@ -34,12 +34,26 @@ pub trait DataProvider {
fn validation_config(&self, _field_index: usize) -> Option<crate::validation::ValidationConfig> {
None
}
/// Check if field is computed (display-only, skip in navigation)
/// Default: not computed
#[cfg(feature = "computed")]
fn is_computed_field(&self, _field_index: usize) -> bool {
false
}
/// Get computed field value if this is a computed field.
/// Returns None for regular fields. Default: not computed.
#[cfg(feature = "computed")]
fn computed_field_value(&self, _field_index: usize) -> Option<String> {
None
}
}
/// Optional: User implements this for autocomplete data
/// Optional: User implements this for suggestions data
#[async_trait]
pub trait AutocompleteProvider {
/// Fetch autocomplete suggestions (user's business logic)
pub trait SuggestionsProvider {
/// Fetch suggestions (user's business logic)
async fn fetch_suggestions(&mut self, field_index: usize, query: &str)
-> Result<Vec<SuggestionItem>>;
}

View File

@@ -3,12 +3,11 @@
#[cfg(feature = "cursor-style")]
use crate::canvas::CursorManager;
#[cfg(feature = "cursor-style")]
use crossterm;
use anyhow::Result;
use crate::canvas::state::EditorState;
use crate::data_provider::{DataProvider, AutocompleteProvider, SuggestionItem};
use crate::data_provider::{DataProvider, SuggestionsProvider, SuggestionItem};
use crate::canvas::modes::AppMode;
use crate::canvas::state::SelectionState;
@@ -41,6 +40,24 @@ impl<D: DataProvider> FormEditor<D> {
editor
}
/// Compute inline completion for current selection and current text.
fn compute_current_completion(&self) -> Option<String> {
let typed = self.current_text();
let idx = self.ui_state.suggestions.selected_index?;
let sugg = self.suggestions.get(idx)?;
if let Some(rest) = sugg.value_to_store.strip_prefix(typed) {
if !rest.is_empty() {
return Some(rest.to_string());
}
}
None
}
/// Update UI state's completion text from current selection
pub fn update_inline_completion(&mut self) {
self.ui_state.suggestions.completion_text = self.compute_current_completion();
}
/// Initialize validation configurations from data provider
#[cfg(feature = "validation")]
fn initialize_validation(&mut self) {
@@ -71,9 +88,9 @@ impl<D: DataProvider> FormEditor<D> {
self.ui_state.mode()
}
/// Check if autocomplete is active (for user's logic)
pub fn is_autocomplete_active(&self) -> bool {
self.ui_state.is_autocomplete_active()
/// Check if suggestions dropdown is active (for user's logic)
pub fn is_suggestions_active(&self) -> bool {
self.ui_state.is_suggestions_active()
}
/// Get current field text (convenience method)
@@ -86,11 +103,136 @@ impl<D: DataProvider> FormEditor<D> {
}
}
/// 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")]
pub fn current_display_text(&self) -> String {
let field_index = self.ui_state.current_field;
let raw = if field_index < self.data_provider.field_count() {
self.data_provider.field_value(field_index)
} else {
""
};
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
// 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 {
return mask.apply_to_display(raw);
}
}
raw.to_string()
}
/// Get reference to UI state for rendering
pub fn ui_state(&self) -> &EditorState {
&self.ui_state
}
/// Mutable access to UI state for internal crate use only.
pub(crate) fn ui_state_mut(&mut self) -> &mut EditorState {
&mut self.ui_state
}
/// Open the suggestions UI for `field_index` (UI-only; does not fetch).
pub fn open_suggestions(&mut self, field_index: usize) {
self.ui_state.open_suggestions(field_index);
}
/// Close suggestions UI and clear the current suggestion results.
pub fn close_suggestions(&mut self) {
self.ui_state.close_suggestions();
self.suggestions.clear();
}
/// 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
pub fn data_provider(&self) -> &D {
&self.data_provider
@@ -124,31 +266,69 @@ impl<D: DataProvider> FormEditor<D> {
// SYNC OPERATIONS: No async needed for basic editing
// ===================================================================
/// Handle character insertion
/// Handle character insertion with proper mask/limit coordination
pub fn insert_char(&mut self, ch: char) -> Result<()> {
if self.ui_state.current_mode != AppMode::Edit {
return Ok(()); // Ignore in non-edit modes
}
let field_index = self.ui_state.current_field;
let cursor_pos = self.ui_state.cursor_pos;
let raw_cursor_pos = self.ui_state.cursor_pos;
let current_raw_text = self.data_provider.field_value(field_index);
// Get current text from user
let current_text = self.data_provider.field_value(field_index);
// 🔥 CRITICAL FIX 1: Check mask constraints FIRST
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
if let Some(mask) = &cfg.display_mask {
// Get display cursor position
let display_cursor_pos = mask.raw_pos_to_display_pos(raw_cursor_pos);
// Validate character insertion if validation is enabled
// ❌ PREVENT BUG: Reject input if cursor is beyond mask pattern
if display_cursor_pos >= mask.pattern().len() {
tracing::debug!(
"Character insertion rejected: cursor beyond mask pattern length"
);
return Ok(()); // Silently reject - user can't type beyond mask
}
// ❌ PREVENT BUG: Reject input if cursor is on a separator position
if !mask.is_input_position(display_cursor_pos) {
tracing::debug!(
"Character insertion rejected: cursor on separator position {}",
display_cursor_pos
);
return Ok(()); // Silently reject - can't type on separators
}
// ❌ PREVENT BUG: Check if we're at max input positions for this mask
let input_char_count = (0..mask.pattern().len())
.filter(|&pos| mask.is_input_position(pos))
.count();
if current_raw_text.len() >= input_char_count {
tracing::debug!(
"Character insertion rejected: mask pattern full ({} input positions)",
input_char_count
);
return Ok(()); // Silently reject - mask is full
}
}
}
}
// 🔥 CRITICAL FIX 2: Validate character insertion with mask awareness
#[cfg(feature = "validation")]
{
let validation_result = self.ui_state.validation.validate_char_insertion(
field_index,
current_text,
cursor_pos,
current_raw_text,
raw_cursor_pos,
ch,
);
// Reject input if validation failed with error
if !validation_result.is_acceptable() {
// Log validation failure for debugging
tracing::debug!(
"Character insertion rejected for field {}: {:?}",
field_index,
@@ -158,38 +338,130 @@ impl<D: DataProvider> FormEditor<D> {
}
}
// Insert character
let mut new_text = current_text.to_string();
new_text.insert(cursor_pos, ch);
// 🔥 CRITICAL FIX 3: Validate the insertion won't break display/limit coordination
let new_raw_text = {
let mut temp = current_raw_text.to_string();
temp.insert(raw_cursor_pos, ch);
temp
};
// Update user's data
self.data_provider.set_field_value(field_index, new_text);
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
// Check character limits on the new raw text
if let Some(limits) = &cfg.character_limits {
if let Some(result) = limits.validate_content(&new_raw_text) {
if !result.is_acceptable() {
tracing::debug!(
"Character insertion rejected: would exceed character limits"
);
return Ok(()); // Silently reject - would exceed limits
}
}
}
// Update library's UI state
self.ui_state.cursor_pos += 1;
// Check that mask can handle the new raw text length
if let Some(mask) = &cfg.display_mask {
let input_positions = (0..mask.pattern().len())
.filter(|&pos| mask.is_input_position(pos))
.count();
if new_raw_text.len() > input_positions {
tracing::debug!(
"Character insertion rejected: raw text length {} exceeds mask input positions {}",
new_raw_text.len(),
input_positions
);
return Ok(()); // Silently reject - mask can't handle this length
}
}
}
}
// ✅ ALL CHECKS PASSED: Safe to insert character
self.data_provider.set_field_value(field_index, new_raw_text);
// 🔥 CRITICAL FIX 4: Update cursor position correctly for mask context
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
if let Some(mask) = &cfg.display_mask {
// Move to next input position, skipping separators
let new_raw_pos = raw_cursor_pos + 1;
let display_pos = mask.raw_pos_to_display_pos(new_raw_pos);
let next_input_pos = mask.next_input_position(display_pos);
let next_raw_pos = mask.display_pos_to_raw_pos(next_input_pos);
self.ui_state.cursor_pos = next_raw_pos;
self.ui_state.ideal_cursor_column = next_raw_pos;
return Ok(());
}
}
}
// No mask: simple increment
self.ui_state.cursor_pos = raw_cursor_pos + 1;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
Ok(())
}
/// Handle cursor movement
/// Handle cursor movement left - skips mask separator positions
pub fn move_left(&mut self) {
if self.ui_state.cursor_pos > 0 {
self.ui_state.cursor_pos -= 1;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
if self.ui_state.cursor_pos == 0 {
return;
}
let field_index = self.ui_state.current_field;
let mut new_pos = self.ui_state.cursor_pos - 1;
// Skip mask separator positions if configured
#[cfg(feature = "validation")]
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
if let Some(mask) = &cfg.display_mask {
// Convert to display position, find previous input position, convert back
let display_pos = mask.raw_pos_to_display_pos(new_pos);
if let Some(prev_input_display_pos) = mask.prev_input_position(display_pos) {
new_pos = mask.display_pos_to_raw_pos(prev_input_display_pos);
}
}
}
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
}
/// Handle cursor movement right - skips mask separator positions
pub fn move_right(&mut self) {
let current_text = self.current_text();
let max_pos = if self.ui_state.current_mode == AppMode::Edit {
current_text.len() // Edit mode: can go past end
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let max_pos = if is_edit_mode {
current_text.len()
} else {
current_text.len().saturating_sub(1) // ReadOnly: stay in bounds
current_text.len().saturating_sub(1)
};
if self.ui_state.cursor_pos < max_pos {
self.ui_state.cursor_pos += 1;
if self.ui_state.cursor_pos >= max_pos {
return;
}
let field_index = self.ui_state.current_field;
let mut new_pos = self.ui_state.cursor_pos + 1;
// Skip mask separator positions if configured
#[cfg(feature = "validation")]
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
if let Some(mask) = &cfg.display_mask {
// Convert to display position, find next input position, convert back
let display_pos = mask.raw_pos_to_display_pos(new_pos);
let next_input_display_pos = mask.next_input_position(display_pos);
new_pos = mask.display_pos_to_raw_pos(next_input_display_pos);
new_pos = new_pos.min(max_pos);
}
}
if new_pos <= max_pos {
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
}
}
@@ -342,50 +614,56 @@ impl<D: DataProvider> FormEditor<D> {
}
// ===================================================================
// ASYNC OPERATIONS: Only autocomplete needs async
// ASYNC OPERATIONS: Only suggestions need async
// ===================================================================
/// Trigger autocomplete (async because it fetches data)
pub async fn trigger_autocomplete<A>(&mut self, provider: &mut A) -> Result<()>
/// Trigger suggestions (async because it fetches data)
pub async fn trigger_suggestions<A>(&mut self, provider: &mut A) -> Result<()>
where
A: AutocompleteProvider,
A: SuggestionsProvider,
{
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(());
}
// Activate autocomplete UI
self.ui_state.activate_autocomplete(field_index);
// Activate suggestions UI
self.ui_state.activate_suggestions(field_index);
// Fetch suggestions from user (no conversion needed!)
let query = self.current_text();
self.suggestions = provider.fetch_suggestions(field_index, query).await?;
// Update UI state
self.ui_state.autocomplete.is_loading = false;
self.ui_state.suggestions.is_loading = false;
if !self.suggestions.is_empty() {
self.ui_state.autocomplete.selected_index = Some(0);
self.ui_state.suggestions.selected_index = Some(0);
// Compute initial inline completion from first suggestion
self.update_inline_completion();
}
Ok(())
}
/// Navigate autocomplete suggestions
pub fn autocomplete_next(&mut self) {
if !self.ui_state.autocomplete.is_active || self.suggestions.is_empty() {
/// Navigate suggestions
pub fn suggestions_next(&mut self) {
if !self.ui_state.suggestions.is_active || self.suggestions.is_empty() {
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();
self.ui_state.autocomplete.selected_index = Some(next);
self.ui_state.suggestions.selected_index = Some(next);
// Update inline completion to reflect new highlighted item
self.update_inline_completion();
}
/// Apply selected autocomplete suggestion
pub fn apply_autocomplete(&mut self) -> Option<String> {
if let Some(selected_index) = self.ui_state.autocomplete.selected_index {
/// Apply selected suggestion
/// Apply selected suggestion
pub fn apply_suggestion(&mut self) -> Option<String> {
if let Some(selected_index) = self.ui_state.suggestions.selected_index {
if let Some(suggestion) = self.suggestions.get(selected_index).cloned() {
let field_index = self.ui_state.current_field;
@@ -399,8 +677,8 @@ impl<D: DataProvider> FormEditor<D> {
self.ui_state.cursor_pos = suggestion.value_to_store.len();
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
// Close autocomplete
self.ui_state.deactivate_autocomplete();
// Close suggestions
self.ui_state.deactivate_suggestions();
self.suggestions.clear();
// Validate the new content if validation is enabled
@@ -429,6 +707,45 @@ impl<D: DataProvider> FormEditor<D> {
return Ok(());
}
// Skip computed fields during navigation when feature enabled
#[cfg(feature = "computed")]
{
if let Some(computed_state) = &self.ui_state.computed {
// Find previous non-computed field
let mut candidate = self.ui_state.current_field;
for _ in 0..field_count {
candidate = candidate.saturating_sub(1);
if !computed_state.is_computed_field(candidate) {
// Validate and move as usual
#[cfg(feature = "validation")]
{
let current_text = self.current_text();
if !self.ui_state.validation.allows_field_switch(self.ui_state.current_field, current_text) {
if let Some(reason) = self.ui_state.validation.field_switch_block_reason(self.ui_state.current_field, current_text) {
tracing::debug!("Field switch blocked: {}", reason);
return Err(anyhow::anyhow!("Cannot switch fields: {}", reason));
}
}
}
#[cfg(feature = "validation")]
{
let current_text = self.current_text().to_string();
let _validation_result = self.ui_state.validation.validate_field_content(
self.ui_state.current_field,
&current_text,
);
}
self.ui_state.move_to_field(candidate, field_count);
self.clamp_cursor_to_current_field();
return Ok(());
}
if candidate == 0 {
break;
}
}
}
}
// Check if field switching is allowed (minimum character enforcement)
#[cfg(feature = "validation")]
{
@@ -466,6 +783,45 @@ impl<D: DataProvider> FormEditor<D> {
return Ok(());
}
// Skip computed fields during navigation when feature enabled
#[cfg(feature = "computed")]
{
if let Some(computed_state) = &self.ui_state.computed {
// Find next non-computed field
let mut candidate = self.ui_state.current_field;
for _ in 0..field_count {
candidate = (candidate + 1).min(field_count - 1);
if !computed_state.is_computed_field(candidate) {
// Validate and move as usual
#[cfg(feature = "validation")]
{
let current_text = self.current_text();
if !self.ui_state.validation.allows_field_switch(self.ui_state.current_field, current_text) {
if let Some(reason) = self.ui_state.validation.field_switch_block_reason(self.ui_state.current_field, current_text) {
tracing::debug!("Field switch blocked: {}", reason);
return Err(anyhow::anyhow!("Cannot switch fields: {}", reason));
}
}
}
#[cfg(feature = "validation")]
{
let current_text = self.current_text().to_string();
let _validation_result = self.ui_state.validation.validate_field_content(
self.ui_state.current_field,
&current_text,
);
}
self.ui_state.move_to_field(candidate, field_count);
self.clamp_cursor_to_current_field();
return Ok(());
}
if candidate == field_count - 1 {
break;
}
}
}
}
// Check if field switching is allowed (minimum character enforcement)
#[cfg(feature = "validation")]
{
@@ -524,6 +880,112 @@ impl<D: DataProvider> FormEditor<D> {
self.move_up()
}
// ================================================================================================
// COMPUTED FIELDS (behind 'computed' feature)
// ================================================================================================
/// Initialize computed fields from provider and computed provider
#[cfg(feature = "computed")]
pub fn set_computed_provider<C>(&mut self, mut provider: C)
where
C: crate::computed::ComputedProvider,
{
// Initialize computed state
self.ui_state.computed = Some(crate::computed::ComputedState::new());
// Register computed fields and their dependencies
let field_count = self.data_provider.field_count();
for field_index in 0..field_count {
if provider.handles_field(field_index) {
let deps = provider.field_dependencies(field_index);
if let Some(computed_state) = &mut self.ui_state.computed {
computed_state.register_computed_field(field_index, deps);
}
}
}
// Initial computation of all computed fields
self.recompute_all_fields(&mut provider);
}
/// Recompute specific computed fields
#[cfg(feature = "computed")]
pub fn recompute_fields<C>(&mut self, provider: &mut C, field_indices: &[usize])
where
C: crate::computed::ComputedProvider,
{
if let Some(computed_state) = &mut self.ui_state.computed {
// Collect all field values for context
let field_values: Vec<String> = (0..self.data_provider.field_count())
.map(|i| {
if computed_state.is_computed_field(i) {
// Use cached computed value
computed_state
.get_computed_value(i)
.cloned()
.unwrap_or_default()
} else {
// Use regular field value
self.data_provider.field_value(i).to_string()
}
})
.collect();
let field_refs: Vec<&str> = field_values.iter().map(|s| s.as_str()).collect();
// Recompute specified fields
for &field_index in field_indices {
if provider.handles_field(field_index) {
let context = crate::computed::ComputedContext {
field_values: &field_refs,
target_field: field_index,
current_field: Some(self.ui_state.current_field),
};
let computed_value = provider.compute_field(context);
computed_state.set_computed_value(field_index, computed_value);
}
}
}
}
/// Recompute all computed fields
#[cfg(feature = "computed")]
pub fn recompute_all_fields<C>(&mut self, provider: &mut C)
where
C: crate::computed::ComputedProvider,
{
if let Some(computed_state) = &self.ui_state.computed {
let computed_fields: Vec<usize> = computed_state.computed_fields().collect();
self.recompute_fields(provider, &computed_fields);
}
}
/// Trigger recomputation when field changes (call this after set_field_value)
#[cfg(feature = "computed")]
pub fn on_field_changed<C>(&mut self, provider: &mut C, changed_field: usize)
where
C: crate::computed::ComputedProvider,
{
if let Some(computed_state) = &self.ui_state.computed {
let fields_to_update = computed_state.fields_to_recompute(changed_field);
if !fields_to_update.is_empty() {
self.recompute_fields(provider, &fields_to_update);
}
}
}
/// Enhanced getter that returns computed values for computed fields when available
#[cfg(feature = "computed")]
pub fn effective_field_value(&self, field_index: usize) -> String {
if let Some(computed_state) = &self.ui_state.computed {
if let Some(computed_value) = computed_state.get_computed_value(field_index) {
return computed_value.clone();
}
}
self.data_provider.field_value(field_index).to_string()
}
/// Move to next field (alternative to move_down)
pub fn next_field(&mut self) -> Result<()> {
self.move_down()
@@ -713,14 +1175,23 @@ impl<D: DataProvider> FormEditor<D> {
}
self.set_mode(AppMode::ReadOnly);
// Deactivate autocomplete when exiting edit mode
self.ui_state.deactivate_autocomplete();
// Deactivate suggestions when exiting edit mode
self.ui_state.deactivate_suggestions();
Ok(())
}
/// Enter edit mode from read-only mode (vim i/a/o)
pub fn enter_edit_mode(&mut self) {
#[cfg(feature = "computed")]
{
if let Some(computed_state) = &self.ui_state.computed {
if computed_state.is_computed_field(self.ui_state.current_field) {
// Can't edit computed fields - silently ignore
return;
}
}
}
self.set_mode(AppMode::Edit);
}
@@ -812,24 +1283,38 @@ impl<D: DataProvider> FormEditor<D> {
self.ui_state.ideal_cursor_column = clamped_pos;
}
/// Get cursor position for display (respects mode-specific positioning rules)
/// Get cursor position for display (maps raw cursor to display position with formatter/mask)
pub fn display_cursor_position(&self) -> usize {
let current_text = self.current_text();
match self.ui_state.current_mode {
AppMode::Edit => {
// Edit mode: cursor can be past end of text
self.ui_state.cursor_pos.min(current_text.len())
}
let raw_pos = match self.ui_state.current_mode {
AppMode::Edit => self.ui_state.cursor_pos.min(current_text.len()),
_ => {
// Normal/other modes: cursor must be on a character
if current_text.is_empty() {
0
} else {
self.ui_state.cursor_pos.min(current_text.len().saturating_sub(1))
}
}
};
#[cfg(feature = "validation")]
{
let field_index = self.ui_state.current_field;
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 {
return mask.raw_pos_to_display_pos(self.ui_state.cursor_pos);
}
}
}
self.ui_state.cursor_pos
}
/// Cleanup cursor style (call this when shutting down)
@@ -914,12 +1399,12 @@ impl<D: DataProvider> FormEditor<D> {
}
pub fn move_up_with_selection(&mut self) {
self.move_up();
let _ = self.move_up();
// Selection anchor stays in place, cursor position updates automatically
}
pub fn move_down_with_selection(&mut self) {
self.move_down();
let _ = self.move_down();
// Selection anchor stays in place, cursor position updates automatically
}

View File

@@ -4,14 +4,18 @@ pub mod canvas;
pub mod editor;
pub mod data_provider;
// Only include autocomplete module if feature is enabled
#[cfg(feature = "autocomplete")]
pub mod autocomplete;
// Only include suggestions module if feature is enabled
#[cfg(feature = "suggestions")]
pub mod suggestions;
// Only include validation module if feature is enabled
#[cfg(feature = "validation")]
pub mod validation;
// Only include computed module if feature is enabled
#[cfg(feature = "computed")]
pub mod computed;
#[cfg(feature = "cursor-style")]
pub use canvas::CursorManager;
@@ -21,7 +25,7 @@ pub use canvas::CursorManager;
// Main API exports
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)
pub use canvas::state::EditorState;
@@ -35,9 +39,16 @@ pub use canvas::actions::{CanvasAction, ActionResult};
pub use validation::{
ValidationConfig, ValidationResult, ValidationError,
CharacterLimits, ValidationConfigBuilder, ValidationState,
ValidationSummary,
ValidationSummary, PatternFilters, PositionFilter, PositionRange, CharacterFilter,
DisplayMask, // Simple display mask instead of complex ReservedCharacters
// Feature 4: custom formatting exports
CustomFormatter, FormattingResult, PositionMapper, DefaultPositionMapper,
};
// Computed exports (only when computed feature is enabled)
#[cfg(feature = "computed")]
pub use computed::{ComputedProvider, ComputedContext, ComputedState};
// Theming and GUI
#[cfg(feature = "gui")]
pub use canvas::theme::{CanvasTheme, DefaultCanvasTheme};
@@ -48,5 +59,5 @@ pub use canvas::gui::render_canvas;
#[cfg(feature = "gui")]
pub use canvas::gui::render_canvas_default;
#[cfg(all(feature = "gui", feature = "autocomplete"))]
pub use autocomplete::gui::render_autocomplete_dropdown;
#[cfg(all(feature = "gui", feature = "suggestions"))]
pub use suggestions::gui::render_suggestions_dropdown;

View File

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

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

@@ -1,32 +1,220 @@
// src/validation/config.rs
//! Validation configuration types and builders
use crate::validation::CharacterLimits;
use serde::{Deserialize, Serialize};
use crate::validation::{CharacterLimits, PatternFilters, DisplayMask};
#[cfg(feature = "validation")]
use crate::validation::{CustomFormatter, FormattingResult, PositionMapper};
use std::sync::Arc;
/// Main validation configuration for a field
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[derive(Clone, Default)]
pub struct ValidationConfig {
/// Character limit configuration
pub character_limits: Option<CharacterLimits>,
/// Future: Predefined patterns
#[serde(skip)]
pub patterns: Option<()>, // Placeholder for future implementation
/// Pattern filtering configuration
pub pattern_filters: Option<PatternFilters>,
/// Future: Reserved characters
#[serde(skip)]
pub reserved_chars: Option<()>, // Placeholder for future implementation
/// User-defined display mask for visual formatting
pub display_mask: Option<DisplayMask>,
/// Future: Custom formatting
#[serde(skip)]
pub custom_formatting: Option<()>, // Placeholder for future implementation
/// Optional: user-provided custom formatter (feature 4)
#[cfg(feature = "validation")]
pub custom_formatter: Option<Arc<dyn CustomFormatter + Send + Sync>>,
/// Enable external validation indicator UI (feature 5)
pub external_validation_enabled: bool,
/// Future: External validation
#[serde(skip)]
pub external_validation: Option<()>, // Placeholder for future implementation
}
/// Manual Debug to avoid requiring Debug on dyn CustomFormatter
impl std::fmt::Debug for ValidationConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut ds = f.debug_struct("ValidationConfig");
ds.field("character_limits", &self.character_limits)
.field("pattern_filters", &self.pattern_filters)
.field("display_mask", &self.display_mask)
// Do not print the formatter itself to avoid requiring Debug
.field(
"custom_formatter",
&{
#[cfg(feature = "validation")]
{
if self.custom_formatter.is_some() { &"Some(<CustomFormatter>)" } else { &"None" }
}
#[cfg(not(feature = "validation"))]
{
&"N/A"
}
},
)
.field("external_validation_enabled", &self.external_validation_enabled)
.field("external_validation", &self.external_validation)
.finish()
}
}
// ✅ FIXED: Move function from struct definition to impl block
impl ValidationConfig {
/// If a custom formatter is configured, run it and return the formatted text,
/// the position mapper and an optional warning message.
///
/// Returns None when no custom formatter is configured.
#[cfg(feature = "validation")]
pub fn run_custom_formatter(
&self,
raw: &str,
) -> Option<(String, Arc<dyn PositionMapper>, Option<String>)> {
let formatter = self.custom_formatter.as_ref()?;
match formatter.format(raw) {
FormattingResult::Success { formatted, mapper } => {
Some((formatted, mapper, None))
}
FormattingResult::Warning { formatted, message, mapper } => {
Some((formatted, mapper, Some(message)))
}
FormattingResult::Error { .. } => None, // Fall back to raw display
}
}
/// Create a new empty validation configuration
pub fn new() -> Self {
Self::default()
}
/// Create a configuration with just character limits
pub fn with_max_length(max_length: usize) -> Self {
ValidationConfigBuilder::new()
.with_max_length(max_length)
.build()
}
/// Create a configuration with pattern filters
pub fn with_patterns(patterns: PatternFilters) -> Self {
ValidationConfigBuilder::new()
.with_pattern_filters(patterns)
.build()
}
/// Create a configuration with user-defined display mask
///
/// # Examples
/// ```
/// use canvas::{ValidationConfig, DisplayMask};
///
/// let phone_mask = DisplayMask::new("(###) ###-####", '#');
/// let config = ValidationConfig::with_mask(phone_mask);
/// ```
pub fn with_mask(mask: DisplayMask) -> Self {
ValidationConfigBuilder::new()
.with_display_mask(mask)
.build()
}
/// Validate a character insertion at a specific position (raw text space).
///
/// Note: Display masks are visual-only and do not participate in validation.
/// Editor logic is responsible for skipping mask separator positions; here we
/// only validate the raw insertion against limits and patterns.
pub fn validate_char_insertion(
&self,
current_text: &str,
position: usize,
character: char,
) -> ValidationResult {
// Character limits validation
if let Some(ref limits) = self.character_limits {
// ✅ FIXED: Explicit return type annotation
if let Some(result) = limits.validate_insertion(current_text, position, character) {
if !result.is_acceptable() {
return result;
}
}
}
// Pattern filters validation
if let Some(ref patterns) = self.pattern_filters {
// ✅ FIXED: Explicit error handling
if let Err(message) = patterns.validate_char_at_position(position, character) {
return ValidationResult::error(message);
}
}
// Future: Add other validation types here
ValidationResult::Valid
}
/// Validate the current text content (raw text space)
pub fn validate_content(&self, text: &str) -> ValidationResult {
// Character limits validation
if let Some(ref limits) = self.character_limits {
// ✅ FIXED: Explicit return type annotation
if let Some(result) = limits.validate_content(text) {
if !result.is_acceptable() {
return result;
}
}
}
// Pattern filters validation
if let Some(ref patterns) = self.pattern_filters {
// ✅ FIXED: Explicit error handling
if let Err(message) = patterns.validate_text(text) {
return ValidationResult::error(message);
}
}
// Future: Add other validation types here
ValidationResult::Valid
}
/// Check if any validation rules are configured
pub fn has_validation(&self) -> bool {
self.character_limits.is_some()
|| self.pattern_filters.is_some()
|| self.display_mask.is_some()
|| {
#[cfg(feature = "validation")]
{ self.custom_formatter.is_some() }
#[cfg(not(feature = "validation"))]
{ false }
}
}
pub fn allows_field_switch(&self, text: &str) -> bool {
// Character limits validation
if let Some(ref limits) = self.character_limits {
// ✅ FIXED: Direct boolean return
if !limits.allows_field_switch(text) {
return false;
}
}
// Future: Add other validation types here
true
}
/// Get reason why field switching is blocked (if any)
pub fn field_switch_block_reason(&self, text: &str) -> Option<String> {
// Character limits validation
if let Some(ref limits) = self.character_limits {
// ✅ FIXED: Direct option return
if let Some(reason) = limits.field_switch_block_reason(text) {
return Some(reason);
}
}
// Future: Add other validation types here
None
}
}
/// Builder for creating validation configurations
#[derive(Debug, Default)]
pub struct ValidationConfigBuilder {
@@ -45,12 +233,68 @@ impl ValidationConfigBuilder {
self
}
/// Set pattern filters for the field
pub fn with_pattern_filters(mut self, filters: PatternFilters) -> Self {
self.config.pattern_filters = Some(filters);
self
}
/// Set user-defined display mask for visual formatting
///
/// # Examples
/// ```
/// use canvas::{ValidationConfigBuilder, DisplayMask};
///
/// // Phone number with dynamic formatting
/// let phone_mask = DisplayMask::new("(###) ###-####", '#');
/// let config = ValidationConfigBuilder::new()
/// .with_display_mask(phone_mask)
/// .build();
///
/// // Date with template formatting
/// let date_mask = DisplayMask::new("##/##/####", '#')
/// .with_template('_');
/// let config = ValidationConfigBuilder::new()
/// .with_display_mask(date_mask)
/// .build();
///
/// // Custom business format
/// let employee_id = DisplayMask::new("EMP-####-##", '#')
/// .with_template('•');
/// let config = ValidationConfigBuilder::new()
/// .with_display_mask(employee_id)
/// .with_max_length(6) // Only store the 6 digits
/// .build();
/// ```
pub fn with_display_mask(mut self, mask: DisplayMask) -> Self {
self.config.display_mask = Some(mask);
self
}
/// Set optional custom formatter (feature 4)
#[cfg(feature = "validation")]
pub fn with_custom_formatter<F>(mut self, formatter: Arc<F>) -> Self
where
F: CustomFormatter + Send + Sync + 'static,
{
self.config.custom_formatter = Some(formatter);
// When custom formatter is present, it takes precedence over display mask.
self.config.display_mask = None;
self
}
/// Set maximum number of characters (convenience method)
pub fn with_max_length(mut self, max_length: usize) -> Self {
self.config.character_limits = Some(CharacterLimits::new(max_length));
self
}
/// Enable or disable external validation indicator UI (feature 5)
pub fn with_external_validation_enabled(mut self, enabled: bool) -> Self {
self.config.external_validation_enabled = enabled;
self
}
/// Build the final validation configuration
pub fn build(self) -> ValidationConfig {
self.config
@@ -101,96 +345,28 @@ 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()
}
/// Validate a character insertion at a specific position
pub fn validate_char_insertion(
&self,
current_text: &str,
position: usize,
character: char,
) -> ValidationResult {
// Character limits validation
if let Some(ref limits) = self.character_limits {
if let Some(result) = limits.validate_insertion(current_text, position, character) {
if !result.is_acceptable() {
return result;
}
}
}
// Future: Add other validation types here
ValidationResult::Valid
}
/// Validate the current text content
pub fn validate_content(&self, text: &str) -> ValidationResult {
// Character limits validation
if let Some(ref limits) = self.character_limits {
if let Some(result) = limits.validate_content(text) {
if !result.is_acceptable() {
return result;
}
}
}
// Future: Add other validation types here
ValidationResult::Valid
}
/// Check if any validation rules are configured
pub fn has_validation(&self) -> bool {
self.character_limits.is_some()
// || self.patterns.is_some()
// || self.reserved_chars.is_some()
// || self.custom_formatting.is_some()
// || self.external_validation.is_some()
}
pub fn allows_field_switch(&self, text: &str) -> bool {
// Character limits validation
if let Some(ref limits) = self.character_limits {
if !limits.allows_field_switch(text) {
return false;
}
}
// Future: Add other validation types here
true
}
/// Get reason why field switching is blocked (if any)
pub fn field_switch_block_reason(&self, text: &str) -> Option<String> {
// Character limits validation
if let Some(ref limits) = self.character_limits {
if let Some(reason) = limits.field_switch_block_reason(text) {
return Some(reason);
}
}
// Future: Add other validation types here
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_with_user_defined_mask() {
// User creates their own phone mask
let phone_mask = DisplayMask::new("(###) ###-####", '#');
let config = ValidationConfig::with_mask(phone_mask);
// has_validation should be true because mask is configured
assert!(config.has_validation());
// Display mask is visual only; validation still focuses on raw content
let result = config.validate_char_insertion("123", 3, '4');
assert!(result.is_acceptable());
// Content validation unaffected by mask
let result = config.validate_content("1234567890");
assert!(result.is_acceptable());
}
#[test]
fn test_validation_config_builder() {
let config = ValidationConfigBuilder::new()
@@ -201,6 +377,20 @@ mod tests {
assert_eq!(config.character_limits.unwrap().max_length(), Some(10));
}
#[test]
fn test_config_builder_with_user_mask() {
// User defines custom format
let custom_mask = DisplayMask::new("##-##-##", '#').with_template('_');
let config = ValidationConfigBuilder::new()
.with_display_mask(custom_mask)
.with_max_length(6)
.build();
assert!(config.has_validation());
assert!(config.character_limits.is_some());
assert!(config.display_mask.is_some());
}
#[test]
fn test_validation_result() {
let valid = ValidationResult::Valid;
@@ -232,4 +422,26 @@ mod tests {
let result = config.validate_char_insertion("tests", 5, 'x');
assert!(!result.is_acceptable());
}
#[test]
fn test_config_with_patterns() {
use crate::validation::{PatternFilters, PositionFilter, PositionRange, CharacterFilter};
let patterns = PatternFilters::new()
.add_filter(PositionFilter::new(
PositionRange::Range(0, 1),
CharacterFilter::Alphabetic,
));
let config = ValidationConfig::with_patterns(patterns);
assert!(config.has_validation());
// Test valid pattern insertion
let result = config.validate_char_insertion("", 0, 'A');
assert!(result.is_acceptable());
// Test invalid pattern insertion
let result = config.validate_char_insertion("", 0, '1');
assert!(!result.is_acceptable());
}
}

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

@@ -0,0 +1,333 @@
// src/validation/mask.rs
//! Pure display mask system - user-defined patterns only
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MaskDisplayMode {
/// Only show separators as user types
/// Example: "" → "", "123" → "123", "12345" → "(123) 45"
Dynamic,
/// Show full template with placeholders from start
/// Example: "" → "(___) ___-____", "123" → "(123) ___-____"
Template {
/// Character to use as placeholder for empty input positions
placeholder: char
},
}
impl Default for MaskDisplayMode {
fn default() -> Self {
MaskDisplayMode::Dynamic
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DisplayMask {
/// Mask pattern like "##-##-####" where # = input position, others are visual separators
pattern: String,
/// Character used to represent input positions (usually '#')
input_char: char,
/// How to display the mask (dynamic vs template)
display_mode: MaskDisplayMode,
}
impl DisplayMask {
/// Create a new display mask with dynamic mode (current behavior)
///
/// # Arguments
/// * `pattern` - The mask pattern (e.g., "##-##-####", "(###) ###-####")
/// * `input_char` - Character representing input positions (usually '#')
///
/// # Examples
/// ```
/// // Phone number format
/// let phone_mask = DisplayMask::new("(###) ###-####", '#');
///
/// // Date format
/// let date_mask = DisplayMask::new("##/##/####", '#');
///
/// // Custom business format
/// let employee_id = DisplayMask::new("EMP-####-##", '#');
/// ```
pub fn new(pattern: impl Into<String>, input_char: char) -> Self {
Self {
pattern: pattern.into(),
input_char,
display_mode: MaskDisplayMode::Dynamic,
}
}
/// Set the display mode for this mask
///
/// # Examples
/// ```
/// let dynamic_mask = DisplayMask::new("##-##", '#')
/// .with_mode(MaskDisplayMode::Dynamic);
///
/// let template_mask = DisplayMask::new("##-##", '#')
/// .with_mode(MaskDisplayMode::Template { placeholder: '_' });
/// ```
pub fn with_mode(mut self, mode: MaskDisplayMode) -> Self {
self.display_mode = mode;
self
}
/// Set template mode with custom placeholder
///
/// # Examples
/// ```
/// let phone_template = DisplayMask::new("(###) ###-####", '#')
/// .with_template('_'); // Shows "(___) ___-____" when empty
///
/// let date_dots = DisplayMask::new("##/##/####", '#')
/// .with_template('•'); // Shows "••/••/••••" when empty
/// ```
pub fn with_template(self, placeholder: char) -> Self {
self.with_mode(MaskDisplayMode::Template { placeholder })
}
/// Apply mask to raw input, showing visual separators and handling display mode
pub fn apply_to_display(&self, raw_input: &str) -> String {
match &self.display_mode {
MaskDisplayMode::Dynamic => self.apply_dynamic(raw_input),
MaskDisplayMode::Template { placeholder } => self.apply_template(raw_input, *placeholder),
}
}
/// Dynamic mode - only show separators as user types
fn apply_dynamic(&self, raw_input: &str) -> String {
let mut result = String::new();
let mut raw_chars = raw_input.chars();
for pattern_char in self.pattern.chars() {
if pattern_char == self.input_char {
// Input position - take from raw input
if let Some(input_char) = raw_chars.next() {
result.push(input_char);
} else {
// No more input - stop here in dynamic mode
break;
}
} else {
// Visual separator - always show
result.push(pattern_char);
}
}
// Append any remaining raw characters that don't fit the pattern
for remaining_char in raw_chars {
result.push(remaining_char);
}
result
}
/// Template mode - show full pattern with placeholders
fn apply_template(&self, raw_input: &str, placeholder: char) -> String {
let mut result = String::new();
let mut raw_chars = raw_input.chars().peekable();
for pattern_char in self.pattern.chars() {
if pattern_char == self.input_char {
// Input position - take from raw input or use placeholder
if let Some(input_char) = raw_chars.next() {
result.push(input_char);
} else {
// No more input - use placeholder to show template
result.push(placeholder);
}
} else {
// Visual separator - always show in template mode
result.push(pattern_char);
}
}
// In template mode, we don't append extra characters beyond the pattern
// This keeps the template consistent
result
}
/// Check if a display position should accept cursor/input
pub fn is_input_position(&self, display_position: usize) -> bool {
self.pattern.chars()
.nth(display_position)
.map(|c| c == self.input_char)
.unwrap_or(true) // Beyond pattern = accept input
}
/// Map display position to raw position
pub fn display_pos_to_raw_pos(&self, display_pos: usize) -> usize {
let mut raw_pos = 0;
for (i, pattern_char) in self.pattern.chars().enumerate() {
if i >= display_pos {
break;
}
if pattern_char == self.input_char {
raw_pos += 1;
}
}
raw_pos
}
/// Map raw position to display position
pub fn raw_pos_to_display_pos(&self, raw_pos: usize) -> usize {
let mut input_positions_seen = 0;
for (display_pos, pattern_char) in self.pattern.chars().enumerate() {
if pattern_char == self.input_char {
if input_positions_seen == raw_pos {
return display_pos;
}
input_positions_seen += 1;
}
}
// Beyond pattern, return position after pattern
self.pattern.len() + (raw_pos - input_positions_seen)
}
/// Find next input position at or after the given display position
pub fn next_input_position(&self, display_pos: usize) -> usize {
for (i, pattern_char) in self.pattern.chars().enumerate().skip(display_pos) {
if pattern_char == self.input_char {
return i;
}
}
// Beyond pattern = all positions are input positions
display_pos.max(self.pattern.len())
}
/// Find previous input position at or before the given display position
pub fn prev_input_position(&self, display_pos: usize) -> Option<usize> {
// Collect pattern chars with indices first, then search backwards
let pattern_chars: Vec<(usize, char)> = self.pattern.chars().enumerate().collect();
// Search backwards from display_pos
for &(i, pattern_char) in pattern_chars.iter().rev() {
if i <= display_pos && pattern_char == self.input_char {
return Some(i);
}
}
None
}
/// Get the display mode
pub fn display_mode(&self) -> &MaskDisplayMode {
&self.display_mode
}
/// Check if this mask uses template mode
pub fn is_template_mode(&self) -> bool {
matches!(self.display_mode, MaskDisplayMode::Template { .. })
}
/// Get the pattern string
pub fn pattern(&self) -> &str {
&self.pattern
}
/// Get the position of the first input character in the pattern
pub fn first_input_position(&self) -> usize {
for (pos, ch) in self.pattern.chars().enumerate() {
if ch == self.input_char {
return pos;
}
}
0
}
}
impl Default for DisplayMask {
fn default() -> Self {
Self::new("", '#')
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_user_defined_phone_mask() {
// User creates their own phone mask
let dynamic = DisplayMask::new("(###) ###-####", '#');
let template = DisplayMask::new("(###) ###-####", '#').with_template('_');
// Dynamic mode
assert_eq!(dynamic.apply_to_display(""), "");
assert_eq!(dynamic.apply_to_display("1234567890"), "(123) 456-7890");
// Template mode
assert_eq!(template.apply_to_display(""), "(___) ___-____");
assert_eq!(template.apply_to_display("123"), "(123) ___-____");
}
#[test]
fn test_user_defined_date_mask() {
// User creates their own date formats
let us_date = DisplayMask::new("##/##/####", '#');
let eu_date = DisplayMask::new("##.##.####", '#');
let iso_date = DisplayMask::new("####-##-##", '#');
assert_eq!(us_date.apply_to_display("12252024"), "12/25/2024");
assert_eq!(eu_date.apply_to_display("25122024"), "25.12.2024");
assert_eq!(iso_date.apply_to_display("20241225"), "2024-12-25");
}
#[test]
fn test_user_defined_business_formats() {
// User creates custom business formats
let employee_id = DisplayMask::new("EMP-####-##", '#');
let product_code = DisplayMask::new("###-###-###", '#');
let invoice = DisplayMask::new("INV####/##", '#');
assert_eq!(employee_id.apply_to_display("123456"), "EMP-1234-56");
assert_eq!(product_code.apply_to_display("123456789"), "123-456-789");
assert_eq!(invoice.apply_to_display("123456"), "INV1234/56");
}
#[test]
fn test_custom_input_characters() {
// User can define their own input character
let mask_with_x = DisplayMask::new("XXX-XX-XXXX", 'X');
let mask_with_hash = DisplayMask::new("###-##-####", '#');
let mask_with_n = DisplayMask::new("NNN-NN-NNNN", 'N');
assert_eq!(mask_with_x.apply_to_display("123456789"), "123-45-6789");
assert_eq!(mask_with_hash.apply_to_display("123456789"), "123-45-6789");
assert_eq!(mask_with_n.apply_to_display("123456789"), "123-45-6789");
}
#[test]
fn test_custom_placeholders() {
// User can define custom placeholder characters
let underscores = DisplayMask::new("##-##", '#').with_template('_');
let dots = DisplayMask::new("##-##", '#').with_template('•');
let dashes = DisplayMask::new("##-##", '#').with_template('-');
assert_eq!(underscores.apply_to_display(""), "__-__");
assert_eq!(dots.apply_to_display(""), "••-••");
assert_eq!(dashes.apply_to_display(""), "---"); // Note: dashes blend with separator
}
#[test]
fn test_position_mapping_user_patterns() {
let custom = DisplayMask::new("ABC-###-XYZ", '#');
// Position mapping should work correctly with any pattern
assert_eq!(custom.raw_pos_to_display_pos(0), 4); // First # at position 4
assert_eq!(custom.raw_pos_to_display_pos(1), 5); // Second # at position 5
assert_eq!(custom.raw_pos_to_display_pos(2), 6); // Third # at position 6
assert_eq!(custom.display_pos_to_raw_pos(4), 0); // Position 4 -> first input
assert_eq!(custom.display_pos_to_raw_pos(5), 1); // Position 5 -> second input
assert_eq!(custom.display_pos_to_raw_pos(6), 2); // Position 6 -> third input
assert!(!custom.is_input_position(0)); // A
assert!(!custom.is_input_position(3)); // -
assert!(custom.is_input_position(4)); // #
assert!(!custom.is_input_position(8)); // Y
}
}

View File

@@ -1,26 +1,40 @@
//! Validation module for canvas form fields
// src/validation/mod.rs
// Core validation modules
pub mod config;
pub mod limits;
pub mod state;
pub mod patterns;
pub mod mask; // Simple display mask instead of complex reserved chars
pub mod formatting; // Custom formatter and position mapping (feature 4)
// Re-export main types
pub use config::{ValidationConfig, ValidationResult, ValidationConfigBuilder};
pub use limits::{CharacterLimits, LimitCheckResult};
pub use state::{ValidationState, ValidationSummary};
pub use patterns::{PatternFilters, PositionFilter, PositionRange, CharacterFilter};
pub use mask::DisplayMask; // Simple mask instead of ReservedCharacters
pub use formatting::{CustomFormatter, FormattingResult, PositionMapper, DefaultPositionMapper};
/// External validation UI state (Feature 5)
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExternalValidationState {
NotValidated,
Validating,
Valid(Option<String>),
Invalid { message: String, suggestion: Option<String> },
Warning { message: String },
}
/// Validation error types
#[derive(Debug, Clone, thiserror::Error)]
pub enum ValidationError {
#[error("Character limit exceeded: {current}/{max}")]
CharacterLimitExceeded { current: usize, max: usize },
#[error("Character limit exceeded: {message}")]
LimitExceeded { message: String },
#[error("Invalid character '{char}' at position {position}")]
InvalidCharacter { char: char, position: usize },
#[error("Pattern validation failed: {message}")]
PatternFailed { message: String },
#[error("Validation configuration error: {message}")]
ConfigurationError { message: String },
#[error("Custom validation failed: {message}")]
CustomFailed { message: String },
}
/// Result type for validation operations
pub type Result<T> = std::result::Result<T, ValidationError>;

View File

@@ -0,0 +1,326 @@
// src/validation/patterns.rs
//! Position-based pattern filtering for validation
use serde::{Deserialize, Serialize};
use std::sync::Arc;
/// A filter that applies to specific character positions in a field
#[derive(Debug, Clone)]
pub struct PositionFilter {
/// Which positions this filter applies to
pub positions: PositionRange,
/// What type of character filter to apply
pub filter: CharacterFilter,
}
/// Defines which character positions a filter applies to
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PositionRange {
/// Single position (e.g., position 3 only)
Single(usize),
/// Range of positions (e.g., positions 0-2, inclusive)
Range(usize, usize),
/// From position onwards (e.g., position 4 and beyond)
From(usize),
/// Multiple specific positions (e.g., positions 0, 2, 5)
Multiple(Vec<usize>),
}
/// Types of character filters that can be applied
pub enum CharacterFilter {
/// Allow only alphabetic characters (a-z, A-Z)
Alphabetic,
/// Allow only numeric characters (0-9)
Numeric,
/// Allow alphanumeric characters (a-z, A-Z, 0-9)
Alphanumeric,
/// Allow only exact character match
Exact(char),
/// Allow any character from the provided set
OneOf(Vec<char>),
/// Custom user-defined filter function
Custom(Arc<dyn Fn(char) -> bool + Send + Sync>),
}
// Manual implementations for Debug and Clone
impl std::fmt::Debug for CharacterFilter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CharacterFilter::Alphabetic => write!(f, "Alphabetic"),
CharacterFilter::Numeric => write!(f, "Numeric"),
CharacterFilter::Alphanumeric => write!(f, "Alphanumeric"),
CharacterFilter::Exact(ch) => write!(f, "Exact('{}')", ch),
CharacterFilter::OneOf(chars) => write!(f, "OneOf({:?})", chars),
CharacterFilter::Custom(_) => write!(f, "Custom(<function>)"),
}
}
}
impl Clone for CharacterFilter {
fn clone(&self) -> Self {
match self {
CharacterFilter::Alphabetic => CharacterFilter::Alphabetic,
CharacterFilter::Numeric => CharacterFilter::Numeric,
CharacterFilter::Alphanumeric => CharacterFilter::Alphanumeric,
CharacterFilter::Exact(ch) => CharacterFilter::Exact(*ch),
CharacterFilter::OneOf(chars) => CharacterFilter::OneOf(chars.clone()),
CharacterFilter::Custom(func) => CharacterFilter::Custom(Arc::clone(func)),
}
}
}
impl PositionRange {
/// Check if a position is included in this range
pub fn contains(&self, position: usize) -> bool {
match self {
PositionRange::Single(pos) => position == *pos,
PositionRange::Range(start, end) => position >= *start && position <= *end,
PositionRange::From(start) => position >= *start,
PositionRange::Multiple(positions) => positions.contains(&position),
}
}
/// Get all positions up to a given length that this range covers
pub fn positions_up_to(&self, max_length: usize) -> Vec<usize> {
match self {
PositionRange::Single(pos) => {
if *pos < max_length { vec![*pos] } else { vec![] }
},
PositionRange::Range(start, end) => {
let actual_end = (*end).min(max_length.saturating_sub(1));
if *start <= actual_end {
(*start..=actual_end).collect()
} else {
vec![]
}
},
PositionRange::From(start) => {
if *start < max_length {
(*start..max_length).collect()
} else {
vec![]
}
},
PositionRange::Multiple(positions) => {
positions.iter()
.filter(|&&pos| pos < max_length)
.copied()
.collect()
},
}
}
}
impl CharacterFilter {
/// Test if a character passes this filter
pub fn accepts(&self, ch: char) -> bool {
match self {
CharacterFilter::Alphabetic => ch.is_alphabetic(),
CharacterFilter::Numeric => ch.is_numeric(),
CharacterFilter::Alphanumeric => ch.is_alphanumeric(),
CharacterFilter::Exact(expected) => ch == *expected,
CharacterFilter::OneOf(chars) => chars.contains(&ch),
CharacterFilter::Custom(func) => func(ch),
}
}
/// Get a human-readable description of this filter
pub fn description(&self) -> String {
match self {
CharacterFilter::Alphabetic => "alphabetic characters (a-z, A-Z)".to_string(),
CharacterFilter::Numeric => "numeric characters (0-9)".to_string(),
CharacterFilter::Alphanumeric => "alphanumeric characters (a-z, A-Z, 0-9)".to_string(),
CharacterFilter::Exact(ch) => format!("exactly '{}'", ch),
CharacterFilter::OneOf(chars) => {
let char_list: String = chars.iter().collect();
format!("one of: {}", char_list)
},
CharacterFilter::Custom(_) => "custom filter".to_string(),
}
}
}
impl PositionFilter {
/// Create a new position filter
pub fn new(positions: PositionRange, filter: CharacterFilter) -> Self {
Self { positions, filter }
}
/// Validate a character at a specific position
pub fn validate_position(&self, position: usize, character: char) -> bool {
if self.positions.contains(position) {
self.filter.accepts(character)
} else {
true // Position not covered by this filter, allow any character
}
}
/// Get error message for invalid character at position
pub fn error_message(&self, position: usize, character: char) -> Option<String> {
if self.positions.contains(position) && !self.filter.accepts(character) {
Some(format!(
"Position {} requires {} but got '{}'",
position,
self.filter.description(),
character
))
} else {
None
}
}
}
/// A collection of position filters for a field
#[derive(Debug, Clone, Default)]
pub struct PatternFilters {
filters: Vec<PositionFilter>,
}
impl PatternFilters {
/// Create empty pattern filters
pub fn new() -> Self {
Self::default()
}
/// Add a position filter
pub fn add_filter(mut self, filter: PositionFilter) -> Self {
self.filters.push(filter);
self
}
/// Add multiple filters
pub fn add_filters(mut self, filters: Vec<PositionFilter>) -> Self {
self.filters.extend(filters);
self
}
/// Validate a character at a specific position against all applicable filters
pub fn validate_char_at_position(&self, position: usize, character: char) -> Result<(), String> {
for filter in &self.filters {
if let Some(error) = filter.error_message(position, character) {
return Err(error);
}
}
Ok(())
}
/// Validate entire text against all filters
pub fn validate_text(&self, text: &str) -> Result<(), String> {
for (position, character) in text.char_indices() {
if let Err(error) = self.validate_char_at_position(position, character) {
return Err(error);
}
}
Ok(())
}
/// Check if any filters are configured
pub fn has_filters(&self) -> bool {
!self.filters.is_empty()
}
/// Get all configured filters
pub fn filters(&self) -> &[PositionFilter] {
&self.filters
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_position_range_contains() {
assert!(PositionRange::Single(3).contains(3));
assert!(!PositionRange::Single(3).contains(2));
assert!(PositionRange::Range(1, 4).contains(3));
assert!(!PositionRange::Range(1, 4).contains(5));
assert!(PositionRange::From(2).contains(5));
assert!(!PositionRange::From(2).contains(1));
assert!(PositionRange::Multiple(vec![0, 2, 5]).contains(2));
assert!(!PositionRange::Multiple(vec![0, 2, 5]).contains(3));
}
#[test]
fn test_position_range_positions_up_to() {
assert_eq!(PositionRange::Single(3).positions_up_to(5), vec![3]);
assert_eq!(PositionRange::Single(5).positions_up_to(3), vec![]);
assert_eq!(PositionRange::Range(1, 3).positions_up_to(5), vec![1, 2, 3]);
assert_eq!(PositionRange::Range(1, 5).positions_up_to(3), vec![1, 2]);
assert_eq!(PositionRange::From(2).positions_up_to(5), vec![2, 3, 4]);
assert_eq!(PositionRange::Multiple(vec![0, 2, 5]).positions_up_to(4), vec![0, 2]);
}
#[test]
fn test_character_filter_accepts() {
assert!(CharacterFilter::Alphabetic.accepts('a'));
assert!(CharacterFilter::Alphabetic.accepts('Z'));
assert!(!CharacterFilter::Alphabetic.accepts('1'));
assert!(CharacterFilter::Numeric.accepts('5'));
assert!(!CharacterFilter::Numeric.accepts('a'));
assert!(CharacterFilter::Alphanumeric.accepts('a'));
assert!(CharacterFilter::Alphanumeric.accepts('5'));
assert!(!CharacterFilter::Alphanumeric.accepts('-'));
assert!(CharacterFilter::Exact('x').accepts('x'));
assert!(!CharacterFilter::Exact('x').accepts('y'));
assert!(CharacterFilter::OneOf(vec!['a', 'b', 'c']).accepts('b'));
assert!(!CharacterFilter::OneOf(vec!['a', 'b', 'c']).accepts('d'));
}
#[test]
fn test_position_filter_validation() {
let filter = PositionFilter::new(
PositionRange::Range(0, 1),
CharacterFilter::Alphabetic,
);
assert!(filter.validate_position(0, 'A'));
assert!(filter.validate_position(1, 'b'));
assert!(!filter.validate_position(0, '1'));
assert!(filter.validate_position(2, '1')); // Position 2 not covered, allow anything
}
#[test]
fn test_pattern_filters_validation() {
let patterns = PatternFilters::new()
.add_filter(PositionFilter::new(
PositionRange::Range(0, 1),
CharacterFilter::Alphabetic,
))
.add_filter(PositionFilter::new(
PositionRange::Range(2, 4),
CharacterFilter::Numeric,
));
// Valid pattern: AB123
assert!(patterns.validate_text("AB123").is_ok());
// Invalid: number in alphabetic position
assert!(patterns.validate_text("A1123").is_err());
// Invalid: letter in numeric position
assert!(patterns.validate_text("AB1A3").is_err());
}
#[test]
fn test_custom_filter() {
let pattern = PatternFilters::new()
.add_filter(PositionFilter::new(
PositionRange::From(0),
CharacterFilter::Custom(Arc::new(|c| c.is_lowercase())),
));
assert!(pattern.validate_text("hello").is_ok());
assert!(pattern.validate_text("Hello").is_err()); // Uppercase not allowed
}
}

View File

@@ -1,7 +1,7 @@
// src/validation/state.rs
//! Validation state management
use crate::validation::{ValidationConfig, ValidationResult};
use crate::validation::{ValidationConfig, ValidationResult, ExternalValidationState};
use std::collections::HashMap;
/// Validation state for all fields in a form
@@ -18,6 +18,9 @@ pub struct ValidationState {
/// Global validation enabled/disabled
enabled: bool,
/// External validation results per field (Feature 5)
external_results: HashMap<usize, ExternalValidationState>,
}
impl ValidationState {
@@ -28,6 +31,7 @@ impl ValidationState {
field_results: HashMap::new(),
validated_fields: std::collections::HashSet::new(),
enabled: true,
external_results: HashMap::new(),
}
}
@@ -38,6 +42,7 @@ impl ValidationState {
// Clear all validation results when disabled
self.field_results.clear();
self.validated_fields.clear();
self.external_results.clear(); // Also clear external results
}
}
@@ -48,12 +53,13 @@ impl ValidationState {
/// Set validation configuration for a field
pub fn set_field_config(&mut self, field_index: usize, config: ValidationConfig) {
if config.has_validation() {
if config.has_validation() || config.external_validation_enabled {
self.field_configs.insert(field_index, config);
} else {
self.field_configs.remove(&field_index);
self.field_results.remove(&field_index);
self.validated_fields.remove(&field_index);
self.external_results.remove(&field_index);
}
}
@@ -67,6 +73,30 @@ impl ValidationState {
self.field_configs.remove(&field_index);
self.field_results.remove(&field_index);
self.validated_fields.remove(&field_index);
self.external_results.remove(&field_index);
}
/// Set external validation state for a field (Feature 5)
pub fn set_external_validation(&mut self, field_index: usize, state: ExternalValidationState) {
self.external_results.insert(field_index, state);
}
/// Get current external validation state for a field
pub fn get_external_validation(&self, field_index: usize) -> ExternalValidationState {
self.external_results
.get(&field_index)
.cloned()
.unwrap_or(ExternalValidationState::NotValidated)
}
/// Clear external validation state for a field
pub fn clear_external_validation(&mut self, field_index: usize) {
self.external_results.remove(&field_index);
}
/// Clear all external validation states
pub fn clear_all_external_validation(&mut self) {
self.external_results.clear();
}
/// Validate character insertion for a field
@@ -122,6 +152,18 @@ impl ValidationState {
self.field_results.get(&field_index)
}
/// Get formatted display for a field if a custom formatter is configured.
/// Returns (formatted_text, position_mapper, optional_warning_message).
#[cfg(feature = "validation")]
pub fn formatted_for(
&self,
field_index: usize,
raw: &str,
) -> Option<(String, std::sync::Arc<dyn crate::validation::PositionMapper>, Option<String>)> {
let config = self.field_configs.get(&field_index)?;
config.run_custom_formatter(raw)
}
/// Check if a field has been validated
pub fn is_field_validated(&self, field_index: usize) -> bool {
self.validated_fields.contains(&field_index)

View File

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