Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96cde3ca0d | ||
|
|
6ba0124779 | ||
|
|
34c68858a3 |
738
canvas/examples/validation_4.rs
Normal file
738
canvas/examples/validation_4.rs
Normal file
@@ -0,0 +1,738 @@
|
||||
/* examples/validation_4.rs
|
||||
Enhanced Feature 4 Demo: Multiple custom formatters with comprehensive edge cases
|
||||
|
||||
Demonstrates:
|
||||
- Multiple formatter types: PSC, Phone, Credit Card, Date
|
||||
- Edge case handling: incomplete input, invalid chars, overflow
|
||||
- Real-time validation feedback and format preview
|
||||
- Advanced cursor position mapping
|
||||
- Raw vs formatted data separation
|
||||
- Error handling and fallback behavior
|
||||
|
||||
*/
|
||||
|
||||
#![allow(clippy::needless_return)]
|
||||
|
||||
#[cfg(not(all(feature = "validation", feature = "gui")))]
|
||||
compile_error!(
|
||||
"This example requires the 'validation' and 'gui' features. \
|
||||
Run with: cargo run --example validation_4 --features \"gui,validation\""
|
||||
);
|
||||
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crossterm::{
|
||||
event::{
|
||||
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers,
|
||||
},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{
|
||||
backend::{Backend, CrosstermBackend},
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
use canvas::{
|
||||
canvas::{gui::render_canvas_default, modes::AppMode},
|
||||
DataProvider, FormEditor,
|
||||
ValidationConfig, ValidationConfigBuilder,
|
||||
CustomFormatter, FormattingResult,
|
||||
};
|
||||
|
||||
/// PSC (Postal Code) Formatter: "01001" -> "010 01"
|
||||
struct PSCFormatter;
|
||||
|
||||
impl CustomFormatter for PSCFormatter {
|
||||
fn format(&self, raw: &str) -> FormattingResult {
|
||||
if raw.is_empty() {
|
||||
return FormattingResult::success("");
|
||||
}
|
||||
|
||||
// Validate: only digits allowed
|
||||
if !raw.chars().all(|c| c.is_ascii_digit()) {
|
||||
return FormattingResult::error("PSC must contain only digits");
|
||||
}
|
||||
|
||||
let len = raw.chars().count();
|
||||
match len {
|
||||
0 => FormattingResult::success(""),
|
||||
1..=3 => FormattingResult::success(raw),
|
||||
4 => FormattingResult::warning(
|
||||
format!("{} ", &raw[..3]),
|
||||
"PSC incomplete (4/5 digits)"
|
||||
),
|
||||
5 => {
|
||||
let formatted = format!("{} {}", &raw[..3], &raw[3..]);
|
||||
if raw == "00000" {
|
||||
FormattingResult::warning(formatted, "Invalid PSC: 00000")
|
||||
} else {
|
||||
FormattingResult::success(formatted)
|
||||
}
|
||||
},
|
||||
_ => FormattingResult::error("PSC too long (max 5 digits)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Phone Number Formatter: "1234567890" -> "(123) 456-7890"
|
||||
struct PhoneFormatter;
|
||||
|
||||
impl CustomFormatter for PhoneFormatter {
|
||||
fn format(&self, raw: &str) -> FormattingResult {
|
||||
if raw.is_empty() {
|
||||
return FormattingResult::success("");
|
||||
}
|
||||
|
||||
// Only digits allowed
|
||||
if !raw.chars().all(|c| c.is_ascii_digit()) {
|
||||
return FormattingResult::error("Phone must contain only digits");
|
||||
}
|
||||
|
||||
let len = raw.chars().count();
|
||||
match len {
|
||||
0 => FormattingResult::success(""),
|
||||
1..=3 => FormattingResult::success(format!("({})", raw)),
|
||||
4..=6 => FormattingResult::success(format!("({}) {}", &raw[..3], &raw[3..])),
|
||||
7..=10 => FormattingResult::success(format!("({}) {}-{}", &raw[..3], &raw[3..6], &raw[6..])),
|
||||
10 => {
|
||||
let formatted = format!("({}) {}-{}", &raw[..3], &raw[3..6], &raw[6..]);
|
||||
FormattingResult::success(formatted)
|
||||
},
|
||||
_ => FormattingResult::warning(
|
||||
format!("({}) {}-{}", &raw[..3], &raw[3..6], &raw[6..10]),
|
||||
"Phone too long (extra digits ignored)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Credit Card Formatter: "1234567890123456" -> "1234 5678 9012 3456"
|
||||
struct CreditCardFormatter;
|
||||
|
||||
impl CustomFormatter for CreditCardFormatter {
|
||||
fn format(&self, raw: &str) -> FormattingResult {
|
||||
if raw.is_empty() {
|
||||
return FormattingResult::success("");
|
||||
}
|
||||
|
||||
if !raw.chars().all(|c| c.is_ascii_digit()) {
|
||||
return FormattingResult::error("Card number must contain only digits");
|
||||
}
|
||||
|
||||
let mut formatted = String::new();
|
||||
for (i, ch) in raw.chars().enumerate() {
|
||||
if i > 0 && i % 4 == 0 {
|
||||
formatted.push(' ');
|
||||
}
|
||||
formatted.push(ch);
|
||||
}
|
||||
|
||||
let len = raw.chars().count();
|
||||
match len {
|
||||
0..=15 => FormattingResult::warning(formatted, format!("Card incomplete ({}/16 digits)", len)),
|
||||
16 => FormattingResult::success(formatted),
|
||||
_ => FormattingResult::warning(formatted, "Card too long (extra digits shown)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/// Date Formatter: "12012024" -> "12/01/2024"
|
||||
struct DateFormatter;
|
||||
|
||||
impl CustomFormatter for DateFormatter {
|
||||
fn format(&self, raw: &str) -> FormattingResult {
|
||||
if raw.is_empty() {
|
||||
return FormattingResult::success("");
|
||||
}
|
||||
|
||||
if !raw.chars().all(|c| c.is_ascii_digit()) {
|
||||
return FormattingResult::error("Date must contain only digits");
|
||||
}
|
||||
|
||||
let len = raw.len();
|
||||
match len {
|
||||
0 => FormattingResult::success(""),
|
||||
1..=2 => FormattingResult::success(raw.to_string()),
|
||||
3..=4 => FormattingResult::success(format!("{}/{}", &raw[..2], &raw[2..])),
|
||||
5..=8 => FormattingResult::success(format!("{}/{}/{}", &raw[..2], &raw[2..4], &raw[4..])),
|
||||
8 => {
|
||||
let month = &raw[..2];
|
||||
let day = &raw[2..4];
|
||||
let year = &raw[4..];
|
||||
|
||||
// Basic validation
|
||||
let m: u32 = month.parse().unwrap_or(0);
|
||||
let d: u32 = day.parse().unwrap_or(0);
|
||||
|
||||
if m == 0 || m > 12 {
|
||||
FormattingResult::warning(
|
||||
format!("{}/{}/{}", month, day, year),
|
||||
"Invalid month (01-12)"
|
||||
)
|
||||
} else if d == 0 || d > 31 {
|
||||
FormattingResult::warning(
|
||||
format!("{}/{}/{}", month, day, year),
|
||||
"Invalid day (01-31)"
|
||||
)
|
||||
} else {
|
||||
FormattingResult::success(format!("{}/{}/{}", month, day, year))
|
||||
}
|
||||
},
|
||||
_ => FormattingResult::error("Date too long (MMDDYYYY format)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced demo data with multiple formatter types
|
||||
struct MultiFormatterDemoData {
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl MultiFormatterDemoData {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
fields: vec![
|
||||
("🏁 PSC (01001)".to_string(), "".to_string()),
|
||||
("📞 Phone (1234567890)".to_string(), "".to_string()),
|
||||
("💳 Credit Card (16 digits)".to_string(), "".to_string()),
|
||||
("📅 Date (12012024)".to_string(), "".to_string()),
|
||||
("📝 Plain Text".to_string(), "".to_string()),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DataProvider for MultiFormatterDemoData {
|
||||
fn field_count(&self) -> usize {
|
||||
self.fields.len()
|
||||
}
|
||||
|
||||
fn field_name(&self, index: usize) -> &str {
|
||||
&self.fields[index].0
|
||||
}
|
||||
|
||||
fn field_value(&self, index: usize) -> &str {
|
||||
&self.fields[index].1
|
||||
}
|
||||
|
||||
fn set_field_value(&mut self, index: usize, value: String) {
|
||||
self.fields[index].1 = value;
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
fn validation_config(&self, field_index: usize) -> Option<ValidationConfig> {
|
||||
match field_index {
|
||||
0 => Some(ValidationConfigBuilder::new()
|
||||
.with_custom_formatter(Arc::new(PSCFormatter))
|
||||
.with_max_length(5)
|
||||
.build()),
|
||||
1 => Some(ValidationConfigBuilder::new()
|
||||
.with_custom_formatter(Arc::new(PhoneFormatter))
|
||||
.with_max_length(12)
|
||||
.build()),
|
||||
2 => Some(ValidationConfigBuilder::new()
|
||||
.with_custom_formatter(Arc::new(CreditCardFormatter))
|
||||
.with_max_length(20)
|
||||
.build()),
|
||||
3 => Some(ValidationConfigBuilder::new()
|
||||
.with_custom_formatter(Arc::new(DateFormatter))
|
||||
.with_max_length(8)
|
||||
.build()),
|
||||
4 => Some(ValidationConfigBuilder::new()
|
||||
.with_custom_formatter(Arc::new(DateFormatter))
|
||||
.with_max_length(8)
|
||||
.build()),
|
||||
_ => None, // Plain text field - no formatter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced demo editor with comprehensive status tracking
|
||||
struct EnhancedDemoEditor<D: DataProvider> {
|
||||
editor: FormEditor<D>,
|
||||
debug_message: String,
|
||||
validation_enabled: bool,
|
||||
show_raw_data: bool,
|
||||
show_cursor_details: bool,
|
||||
example_mode: usize,
|
||||
}
|
||||
|
||||
impl<D: DataProvider> EnhancedDemoEditor<D> {
|
||||
fn new(data_provider: D) -> Self {
|
||||
let mut editor = FormEditor::new(data_provider);
|
||||
editor.set_validation_enabled(true);
|
||||
|
||||
Self {
|
||||
editor,
|
||||
debug_message: "🧩 Enhanced Custom Formatter Demo - Multiple formatters with rich edge cases!".to_string(),
|
||||
validation_enabled: true,
|
||||
show_raw_data: false,
|
||||
show_cursor_details: false,
|
||||
example_mode: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Field type detection
|
||||
fn current_field_type(&self) -> &'static str {
|
||||
match self.editor.current_field() {
|
||||
0 => "PSC",
|
||||
1 => "Phone",
|
||||
2 => "Credit Card",
|
||||
3 => "Date",
|
||||
_ => "Plain Text",
|
||||
}
|
||||
}
|
||||
|
||||
fn has_formatter(&self) -> bool {
|
||||
self.editor.current_field() < 5 // First 5 fields have formatters
|
||||
}
|
||||
|
||||
fn get_input_rules(&self) -> &'static str {
|
||||
match self.editor.current_field() {
|
||||
0 => "5 digits only (PSC format)",
|
||||
1 => "10+ digits (US phone format)",
|
||||
2 => "16+ digits (credit card)",
|
||||
3 => "Digits as cents (12345 = $123.45)",
|
||||
4 => "8 digits MMDDYYYY (date format)",
|
||||
_ => "Any text (no formatting)",
|
||||
}
|
||||
}
|
||||
|
||||
fn cycle_example_data(&mut self) {
|
||||
let examples = [
|
||||
// PSC examples
|
||||
vec!["01001", "1234567890", "1234567890123456", "12345", "12012024", "Plain text here"],
|
||||
// Incomplete examples
|
||||
vec!["010", "123", "1234", "123", "1201", "More text"],
|
||||
// Invalid examples (will show error handling)
|
||||
vec!["0abc1", "12a45", "123abc", "abc", "ab01cd", "Special chars!"],
|
||||
// Edge cases
|
||||
vec!["00000", "0000000000", "0000000000000000", "99", "13012024", ""],
|
||||
];
|
||||
|
||||
self.example_mode = (self.example_mode + 1) % examples.len();
|
||||
let current_examples = &examples[self.example_mode];
|
||||
|
||||
for (i, example) in current_examples.iter().enumerate() {
|
||||
if i < self.editor.data_provider().field_count() {
|
||||
self.editor.data_provider_mut().set_field_value(i, example.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mode_names = ["Valid Examples", "Incomplete Input", "Invalid Characters", "Edge Cases"];
|
||||
self.debug_message = format!("📋 Loaded: {}", mode_names[self.example_mode]);
|
||||
}
|
||||
|
||||
// Enhanced status methods
|
||||
fn toggle_validation(&mut self) {
|
||||
self.validation_enabled = !self.validation_enabled;
|
||||
self.editor.set_validation_enabled(self.validation_enabled);
|
||||
self.debug_message = if self.validation_enabled {
|
||||
"✅ Custom Formatters ENABLED".to_string()
|
||||
} else {
|
||||
"❌ Custom Formatters DISABLED".to_string()
|
||||
};
|
||||
}
|
||||
|
||||
fn toggle_raw_data_view(&mut self) {
|
||||
self.show_raw_data = !self.show_raw_data;
|
||||
self.debug_message = if self.show_raw_data {
|
||||
"👁️ Showing RAW data focus".to_string()
|
||||
} else {
|
||||
"✨ Showing FORMATTED display focus".to_string()
|
||||
};
|
||||
}
|
||||
|
||||
fn toggle_cursor_details(&mut self) {
|
||||
self.show_cursor_details = !self.show_cursor_details;
|
||||
self.debug_message = if self.show_cursor_details {
|
||||
"📍 Detailed cursor mapping info ON".to_string()
|
||||
} else {
|
||||
"📍 Detailed cursor mapping info OFF".to_string()
|
||||
};
|
||||
}
|
||||
|
||||
fn get_current_field_analysis(&self) -> (String, String, String, Option<String>) {
|
||||
let raw = self.editor.current_text();
|
||||
let display = self.editor.current_display_text();
|
||||
|
||||
let status = if raw == display {
|
||||
if self.has_formatter() {
|
||||
if self.mode() == AppMode::Edit {
|
||||
"Raw (editing)".to_string()
|
||||
} else {
|
||||
"No formatting needed".to_string()
|
||||
}
|
||||
} else {
|
||||
"No formatter".to_string()
|
||||
}
|
||||
} else {
|
||||
"Custom formatted".to_string()
|
||||
};
|
||||
|
||||
let warning = if self.validation_enabled && self.has_formatter() {
|
||||
// Check if there are any formatting warnings
|
||||
if raw.len() > 0 {
|
||||
match self.editor.current_field() {
|
||||
0 if raw.len() < 5 => Some(format!("PSC incomplete: {}/5", raw.len())),
|
||||
1 if raw.len() < 10 => Some(format!("Phone incomplete: {}/10", raw.len())),
|
||||
2 if raw.len() < 16 => Some(format!("Card incomplete: {}/16", raw.len())),
|
||||
4 if raw.len() < 8 => Some(format!("Date incomplete: {}/8", raw.len())),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
(raw.to_string(), display, status, warning)
|
||||
}
|
||||
|
||||
// Delegate methods with enhanced feedback
|
||||
fn enter_edit_mode(&mut self) {
|
||||
self.editor.enter_edit_mode();
|
||||
let field_type = self.current_field_type();
|
||||
let rules = self.get_input_rules();
|
||||
self.debug_message = format!("✏️ EDITING {} - {}", field_type, rules);
|
||||
}
|
||||
|
||||
fn exit_edit_mode(&mut self) {
|
||||
self.editor.exit_edit_mode();
|
||||
let (raw, display, _, warning) = self.get_current_field_analysis();
|
||||
if let Some(warn) = warning {
|
||||
self.debug_message = format!("🔒 NORMAL - {} | ⚠️ {}", self.current_field_type(), warn);
|
||||
} else if raw != display {
|
||||
self.debug_message = format!("🔒 NORMAL - {} formatted successfully", self.current_field_type());
|
||||
} else {
|
||||
self.debug_message = "🔒 NORMAL MODE".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
|
||||
let result = self.editor.insert_char(ch);
|
||||
if result.is_ok() {
|
||||
let (raw, display, _, _) = self.get_current_field_analysis();
|
||||
if raw != display && self.validation_enabled {
|
||||
self.debug_message = format!("✏️ '{}' added - Real-time formatting active", ch);
|
||||
} else {
|
||||
self.debug_message = format!("✏️ '{}' added", ch);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// Position mapping demo
|
||||
fn show_position_mapping(&mut self) {
|
||||
if !self.has_formatter() {
|
||||
self.debug_message = "📍 No position mapping (plain text field)".to_string();
|
||||
return;
|
||||
}
|
||||
|
||||
let raw_pos = self.editor.cursor_position();
|
||||
let display_pos = self.editor.display_cursor_position();
|
||||
let raw = self.editor.current_text();
|
||||
let display = self.editor.current_display_text();
|
||||
|
||||
if raw_pos != display_pos {
|
||||
self.debug_message = format!(
|
||||
"🗺️ Position mapping: Raw[{}]='{}' ↔ Display[{}]='{}'",
|
||||
raw_pos,
|
||||
raw.chars().nth(raw_pos).unwrap_or('∅'),
|
||||
display_pos,
|
||||
display.chars().nth(display_pos).unwrap_or('∅')
|
||||
);
|
||||
} else {
|
||||
self.debug_message = format!("📍 Cursor at position {} (no mapping needed)", raw_pos);
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate remaining methods
|
||||
fn mode(&self) -> AppMode { self.editor.mode() }
|
||||
fn current_field(&self) -> usize { self.editor.current_field() }
|
||||
fn cursor_position(&self) -> usize { self.editor.cursor_position() }
|
||||
fn data_provider(&self) -> &D { self.editor.data_provider() }
|
||||
fn data_provider_mut(&mut self) -> &mut D { self.editor.data_provider_mut() }
|
||||
fn ui_state(&self) -> &canvas::EditorState { self.editor.ui_state() }
|
||||
|
||||
fn move_up(&mut self) { let _ = self.editor.move_up(); }
|
||||
fn move_down(&mut self) { let _ = self.editor.move_down(); }
|
||||
fn move_left(&mut self) { let _ = self.editor.move_left(); }
|
||||
fn move_right(&mut self) { let _ = self.editor.move_right(); }
|
||||
fn delete_backward(&mut self) -> anyhow::Result<()> { self.editor.delete_backward() }
|
||||
fn delete_forward(&mut self) -> anyhow::Result<()> { self.editor.delete_forward() }
|
||||
fn next_field(&mut self) { let _ = self.editor.next_field(); }
|
||||
fn prev_field(&mut self) { let _ = self.editor.prev_field(); }
|
||||
}
|
||||
|
||||
// Enhanced key handling
|
||||
fn handle_key_press(
|
||||
key: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
editor: &mut EnhancedDemoEditor<MultiFormatterDemoData>,
|
||||
) -> anyhow::Result<bool> {
|
||||
let mode = editor.mode();
|
||||
|
||||
// Quit
|
||||
if matches!(key, KeyCode::F(10)) ||
|
||||
(key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL)) ||
|
||||
(key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
match (mode, key, modifiers) {
|
||||
// Mode transitions
|
||||
(AppMode::ReadOnly, KeyCode::Char('i'), _) => editor.enter_edit_mode(),
|
||||
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
|
||||
editor.editor.enter_append_mode();
|
||||
editor.debug_message = format!("✏️ APPEND {} - {}", editor.current_field_type(), editor.get_input_rules());
|
||||
},
|
||||
(_, KeyCode::Esc, _) => editor.exit_edit_mode(),
|
||||
|
||||
// Enhanced demo features
|
||||
(AppMode::ReadOnly, KeyCode::Char('e'), _) => editor.cycle_example_data(),
|
||||
(AppMode::ReadOnly, KeyCode::Char('r'), _) => editor.toggle_raw_data_view(),
|
||||
(AppMode::ReadOnly, KeyCode::Char('c'), _) => editor.toggle_cursor_details(),
|
||||
(AppMode::ReadOnly, KeyCode::Char('m'), _) => editor.show_position_mapping(),
|
||||
(AppMode::ReadOnly, KeyCode::F(1), _) => editor.toggle_validation(),
|
||||
|
||||
// Movement
|
||||
(_, KeyCode::Up, _) | (AppMode::ReadOnly, KeyCode::Char('k'), _) => editor.move_up(),
|
||||
(_, KeyCode::Down, _) | (AppMode::ReadOnly, KeyCode::Char('j'), _) => editor.move_down(),
|
||||
(_, KeyCode::Left, _) | (AppMode::ReadOnly, KeyCode::Char('h'), _) => editor.move_left(),
|
||||
(_, KeyCode::Right, _) | (AppMode::ReadOnly, KeyCode::Char('l'), _) => editor.move_right(),
|
||||
(_, KeyCode::Tab, _) => editor.next_field(),
|
||||
(_, KeyCode::BackTab, _) => editor.prev_field(),
|
||||
|
||||
// Editing
|
||||
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
|
||||
editor.insert_char(c)?;
|
||||
},
|
||||
(AppMode::Edit, KeyCode::Backspace, _) => { editor.delete_backward()?; },
|
||||
(AppMode::Edit, KeyCode::Delete, _) => { editor.delete_forward()?; },
|
||||
|
||||
// Field analysis
|
||||
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
|
||||
let (raw, display, status, warning) = editor.get_current_field_analysis();
|
||||
let warning_text = warning.map(|w| format!(" ⚠️ {}", w)).unwrap_or_default();
|
||||
editor.debug_message = format!(
|
||||
"🔍 Field {}: {} | Raw: '{}' | Display: '{}'{}",
|
||||
editor.current_field() + 1, status, raw, display, warning_text
|
||||
);
|
||||
},
|
||||
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn run_app<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
mut editor: EnhancedDemoEditor<MultiFormatterDemoData>,
|
||||
) -> io::Result<()> {
|
||||
loop {
|
||||
terminal.draw(|f| ui(f, &editor))?;
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
match handle_key_press(key.code, key.modifiers, &mut editor) {
|
||||
Ok(should_continue) => {
|
||||
if !should_continue {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
editor.debug_message = format!("❌ Error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ui(f: &mut Frame, editor: &EnhancedDemoEditor<MultiFormatterDemoData>) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(8), Constraint::Length(18)])
|
||||
.split(f.area());
|
||||
|
||||
render_canvas_default(f, chunks[0], &editor.editor);
|
||||
render_enhanced_status(f, chunks[1], editor);
|
||||
}
|
||||
|
||||
fn render_enhanced_status(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
editor: &EnhancedDemoEditor<MultiFormatterDemoData>,
|
||||
) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Status bar
|
||||
Constraint::Length(6), // Current field analysis
|
||||
Constraint::Length(9), // Help
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Status bar
|
||||
let mode_text = match editor.mode() {
|
||||
AppMode::Edit => "INSERT",
|
||||
AppMode::ReadOnly => "NORMAL",
|
||||
_ => "OTHER",
|
||||
};
|
||||
|
||||
let formatter_count = (0..editor.data_provider().field_count())
|
||||
.filter(|&i| editor.data_provider().validation_config(i).is_some())
|
||||
.count();
|
||||
|
||||
let status_text = format!(
|
||||
"-- {} -- {} | Formatters: {}/{} active | View: {}{}",
|
||||
mode_text,
|
||||
editor.debug_message,
|
||||
formatter_count,
|
||||
editor.data_provider().field_count(),
|
||||
if editor.show_raw_data { "RAW" } else { "DISPLAY" },
|
||||
if editor.show_cursor_details { " | CURSOR+" } else { "" }
|
||||
);
|
||||
|
||||
let status = Paragraph::new(Line::from(Span::raw(status_text)))
|
||||
.block(Block::default().borders(Borders::ALL).title("🧩 Enhanced Custom Formatter Demo"));
|
||||
|
||||
f.render_widget(status, chunks[0]);
|
||||
|
||||
// Current field analysis
|
||||
let (raw, display, status, warning) = editor.get_current_field_analysis();
|
||||
let field_name = editor.data_provider().field_name(editor.current_field());
|
||||
let field_type = editor.current_field_type();
|
||||
|
||||
let mut analysis_lines = vec![
|
||||
format!("📝 Current: {} ({})", field_name, field_type),
|
||||
format!("🔧 Status: {}", status),
|
||||
];
|
||||
|
||||
if editor.show_raw_data || editor.mode() == AppMode::Edit {
|
||||
analysis_lines.push(format!("💾 Raw Data: '{}'", raw));
|
||||
analysis_lines.push(format!("✨ Display: '{}'", display));
|
||||
} else {
|
||||
analysis_lines.push(format!("✨ User Sees: '{}'", display));
|
||||
analysis_lines.push(format!("💾 Stored As: '{}'", raw));
|
||||
}
|
||||
|
||||
if editor.show_cursor_details {
|
||||
analysis_lines.push(format!(
|
||||
"📍 Cursor: Raw[{}] → Display[{}]",
|
||||
editor.cursor_position(),
|
||||
editor.editor.display_cursor_position()
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(ref warn) = warning {
|
||||
analysis_lines.push(format!("⚠️ Warning: {}", warn));
|
||||
}
|
||||
|
||||
let analysis_color = if warning.is_some() {
|
||||
Color::Yellow
|
||||
} else if raw != display && editor.validation_enabled {
|
||||
Color::Green
|
||||
} else {
|
||||
Color::Gray
|
||||
};
|
||||
|
||||
let analysis = Paragraph::new(analysis_lines.join("\n"))
|
||||
.block(Block::default().borders(Borders::ALL).title("🔍 Field Analysis"))
|
||||
.style(Style::default().fg(analysis_color))
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
f.render_widget(analysis, chunks[1]);
|
||||
|
||||
// Enhanced help
|
||||
let help_text = match editor.mode() {
|
||||
AppMode::ReadOnly => {
|
||||
"🧩 ENHANCED CUSTOM FORMATTER DEMO\n\
|
||||
\n\
|
||||
Try these formatters:
|
||||
• PSC: 01001 → 010 01 | Phone: 1234567890 → (123) 456-7890 | Card: 1234567890123456 → 1234 5678 9012 3456
|
||||
• Date: 12012024 → 12/01/2024 | Plain: no formatting
|
||||
\n\
|
||||
Commands: i=insert, e=cycle examples, r=toggle raw/display, c=cursor details, m=position mapping\n\
|
||||
Movement: hjkl/arrows, Tab=next field, ?=analyze current field, F1=toggle formatters\n\
|
||||
Ctrl+C/F10=quit"
|
||||
}
|
||||
AppMode::Edit => {
|
||||
"✏️ INSERT MODE - Real-time formatting as you type!\n\
|
||||
\n\
|
||||
Current field rules: {}\n\
|
||||
• Raw input is authoritative (what gets stored)\n\
|
||||
• Display formatting updates in real-time (what users see)\n\
|
||||
• Cursor position is mapped between raw and display\n\
|
||||
\n\
|
||||
Esc=normal mode, arrows=navigate, Backspace/Del=delete"
|
||||
}
|
||||
_ => "🧩 Enhanced Custom Formatter Demo"
|
||||
};
|
||||
|
||||
let formatted_help = if editor.mode() == AppMode::Edit {
|
||||
help_text.replace("{}", editor.get_input_rules())
|
||||
} else {
|
||||
help_text.to_string()
|
||||
};
|
||||
|
||||
let help = Paragraph::new(formatted_help)
|
||||
.block(Block::default().borders(Borders::ALL).title("🚀 Enhanced Features & Commands"))
|
||||
.style(Style::default().fg(Color::Gray))
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
f.render_widget(help, chunks[2]);
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("🧩 Enhanced Canvas Custom Formatter Demo (Feature 4)");
|
||||
println!("✅ validation feature: ENABLED");
|
||||
println!("✅ gui feature: ENABLED");
|
||||
println!("🧩 Enhanced features:");
|
||||
println!(" • 5 different custom formatters with edge cases");
|
||||
println!(" • Real-time format preview and validation");
|
||||
println!(" • Advanced cursor position mapping");
|
||||
println!(" • Comprehensive error handling and warnings");
|
||||
println!(" • Raw vs formatted data separation demos");
|
||||
println!();
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let data = MultiFormatterDemoData::new();
|
||||
let editor = EnhancedDemoEditor::new(data);
|
||||
|
||||
let res = run_app(&mut terminal, editor);
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(err) = res {
|
||||
println!("{:?}", err);
|
||||
}
|
||||
|
||||
println!("🧩 Enhanced custom formatter demo completed!");
|
||||
println!("🏆 You experienced comprehensive custom formatting with:");
|
||||
println!(" • Multiple formatter types (PSC, Phone, Credit Card, Date)");
|
||||
println!(" • Edge case handling (incomplete, invalid, overflow)");
|
||||
println!(" • Real-time format preview and cursor mapping");
|
||||
println!(" • Clear separation between raw business data and display formatting");
|
||||
Ok(())
|
||||
}
|
||||
1089
canvas/examples/validation_5.rs
Normal file
1089
canvas/examples/validation_5.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -53,24 +53,10 @@ pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
|
||||
for i in 0..field_count {
|
||||
fields.push(data_provider.field_name(i));
|
||||
|
||||
// Use display text that applies masks if configured
|
||||
// Use editor-provided effective display text per field (Feature 4/mask aware)
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
if i == editor.current_field() {
|
||||
inputs.push(editor.current_display_text());
|
||||
} else {
|
||||
// For non-current fields, we need to apply mask manually
|
||||
let raw = data_provider.field_value(i);
|
||||
if let Some(cfg) = editor.ui_state().validation_state().get_field_config(i) {
|
||||
if let Some(mask) = &cfg.display_mask {
|
||||
inputs.push(mask.apply_to_display(raw));
|
||||
} else {
|
||||
inputs.push(raw.to_string());
|
||||
}
|
||||
} else {
|
||||
inputs.push(raw.to_string());
|
||||
}
|
||||
}
|
||||
inputs.push(editor.display_text_for_field(i));
|
||||
}
|
||||
#[cfg(not(feature = "validation"))]
|
||||
{
|
||||
@@ -93,23 +79,10 @@ pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
|
||||
editor.display_cursor_position(), // Use display cursor position for masks
|
||||
false, // TODO: track unsaved changes in editor
|
||||
|i| {
|
||||
// Get display value for field i
|
||||
// Get display value for field i using editor logic (Feature 4 + masks)
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
if i == editor.current_field() {
|
||||
editor.current_display_text()
|
||||
} else {
|
||||
let raw = data_provider.field_value(i);
|
||||
if let Some(cfg) = editor.ui_state().validation_state().get_field_config(i) {
|
||||
if let Some(mask) = &cfg.display_mask {
|
||||
mask.apply_to_display(raw)
|
||||
} else {
|
||||
raw.to_string()
|
||||
}
|
||||
} else {
|
||||
raw.to_string()
|
||||
}
|
||||
}
|
||||
editor.display_text_for_field(i)
|
||||
}
|
||||
#[cfg(not(feature = "validation"))]
|
||||
{
|
||||
@@ -117,12 +90,21 @@ pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
|
||||
}
|
||||
},
|
||||
|i| {
|
||||
// Check if field has display override (mask)
|
||||
// Check if field has display override (custom formatter or mask)
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
editor.ui_state().validation_state().get_field_config(i)
|
||||
.and_then(|cfg| cfg.display_mask.as_ref())
|
||||
.is_some()
|
||||
.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"))]
|
||||
{
|
||||
|
||||
@@ -85,7 +85,14 @@ impl<D: DataProvider> FormEditor<D> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current field text for display, applying mask if configured
|
||||
/// Get current field text for display.
|
||||
///
|
||||
/// Policies:
|
||||
/// - Feature 4 (custom formatter):
|
||||
/// - While editing the focused field: ALWAYS show raw (no custom formatting).
|
||||
/// - When not editing the field: show formatted (fallback to raw on error).
|
||||
/// - Mask-only fields: mask applies even in Edit mode (preserve legacy behavior).
|
||||
/// - Otherwise: raw.
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn current_display_text(&self) -> String {
|
||||
let field_index = self.ui_state.current_field;
|
||||
@@ -96,10 +103,29 @@ impl<D: DataProvider> FormEditor<D> {
|
||||
};
|
||||
|
||||
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
|
||||
// 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()
|
||||
}
|
||||
|
||||
@@ -108,6 +134,71 @@ impl<D: DataProvider> FormEditor<D> {
|
||||
&self.ui_state
|
||||
}
|
||||
|
||||
/// Set external validation state for a field (Feature 5)
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn set_external_validation(
|
||||
&mut self,
|
||||
field_index: usize,
|
||||
state: crate::validation::ExternalValidationState,
|
||||
) {
|
||||
self.ui_state
|
||||
.validation
|
||||
.set_external_validation(field_index, state);
|
||||
}
|
||||
|
||||
/// Clear external validation state for a field (Feature 5)
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn clear_external_validation(&mut self, field_index: usize) {
|
||||
self.ui_state.validation.clear_external_validation(field_index);
|
||||
}
|
||||
|
||||
/// Get effective display text for any field index.
|
||||
///
|
||||
/// Policies:
|
||||
/// - Feature 4 fields (with custom formatter):
|
||||
/// - If the field is currently focused AND in Edit mode: return raw (no formatting).
|
||||
/// - Otherwise: return formatted (fallback to raw on error).
|
||||
/// - Mask-only fields: mask applies regardless of mode (legacy behavior).
|
||||
/// - Otherwise: raw.
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn display_text_for_field(&self, field_index: usize) -> String {
|
||||
let raw = if field_index < self.data_provider.field_count() {
|
||||
self.data_provider.field_value(field_index)
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
|
||||
// Mask-only fields: mask applies even in Edit mode
|
||||
if cfg.custom_formatter.is_none() {
|
||||
if let Some(mask) = &cfg.display_mask {
|
||||
return mask.apply_to_display(raw);
|
||||
}
|
||||
}
|
||||
|
||||
// Feature 4 fields:
|
||||
if cfg.custom_formatter.is_some() {
|
||||
// Focused + Edit -> raw
|
||||
if field_index == self.ui_state.current_field
|
||||
&& matches!(self.ui_state.current_mode, AppMode::Edit)
|
||||
{
|
||||
return raw.to_string();
|
||||
}
|
||||
// Not editing -> formatted
|
||||
if let Some((formatted, _mapper, _warning)) = cfg.run_custom_formatter(raw) {
|
||||
return formatted;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to mask if present (in case formatter didn't return output)
|
||||
if let Some(mask) = &cfg.display_mask {
|
||||
return mask.apply_to_display(raw);
|
||||
}
|
||||
}
|
||||
|
||||
raw.to_string()
|
||||
}
|
||||
|
||||
/// Get reference to data provider for rendering
|
||||
pub fn data_provider(&self) -> &D {
|
||||
&self.data_provider
|
||||
@@ -959,7 +1050,7 @@ impl<D: DataProvider> FormEditor<D> {
|
||||
self.ui_state.ideal_cursor_column = clamped_pos;
|
||||
}
|
||||
|
||||
/// Get cursor position for display (maps raw cursor to display position with mask)
|
||||
/// Get cursor position for display (maps raw cursor to display position with formatter/mask)
|
||||
pub fn display_cursor_position(&self) -> usize {
|
||||
let current_text = self.current_text();
|
||||
let raw_pos = match self.ui_state.current_mode {
|
||||
@@ -977,6 +1068,13 @@ impl<D: DataProvider> FormEditor<D> {
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ pub use validation::{
|
||||
CharacterLimits, ValidationConfigBuilder, ValidationState,
|
||||
ValidationSummary, PatternFilters, PositionFilter, PositionRange, CharacterFilter,
|
||||
DisplayMask, // Simple display mask instead of complex ReservedCharacters
|
||||
// Feature 4: custom formatting exports
|
||||
CustomFormatter, FormattingResult, PositionMapper, DefaultPositionMapper,
|
||||
};
|
||||
|
||||
// Theming and GUI
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
//! Validation configuration types and builders
|
||||
|
||||
use crate::validation::{CharacterLimits, PatternFilters, DisplayMask};
|
||||
#[cfg(feature = "validation")]
|
||||
use crate::validation::{CustomFormatter, FormattingResult, PositionMapper};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Main validation configuration for a field
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ValidationConfig {
|
||||
/// Character limit configuration
|
||||
pub character_limits: Option<CharacterLimits>,
|
||||
@@ -15,13 +18,203 @@ pub struct ValidationConfig {
|
||||
/// User-defined display mask for visual formatting
|
||||
pub display_mask: Option<DisplayMask>,
|
||||
|
||||
/// Future: Custom formatting
|
||||
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
|
||||
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 {
|
||||
@@ -78,12 +271,30 @@ impl ValidationConfigBuilder {
|
||||
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
|
||||
@@ -134,131 +345,6 @@ impl ValidationResult {
|
||||
}
|
||||
}
|
||||
|
||||
impl ValidationConfig {
|
||||
/// Create a new empty validation configuration
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Create a configuration with just character limits
|
||||
pub fn with_max_length(max_length: usize) -> Self {
|
||||
ValidationConfigBuilder::new()
|
||||
.with_max_length(max_length)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Create a configuration with pattern filters
|
||||
pub fn with_patterns(patterns: PatternFilters) -> Self {
|
||||
ValidationConfigBuilder::new()
|
||||
.with_pattern_filters(patterns)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Create a configuration with user-defined display mask
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use canvas::{ValidationConfig, DisplayMask};
|
||||
///
|
||||
/// let phone_mask = DisplayMask::new("(###) ###-####", '#');
|
||||
/// let config = ValidationConfig::with_mask(phone_mask);
|
||||
/// ```
|
||||
pub fn with_mask(mask: DisplayMask) -> Self {
|
||||
ValidationConfigBuilder::new()
|
||||
.with_display_mask(mask)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Validate a character insertion at a specific position (raw text space).
|
||||
///
|
||||
/// Note: Display masks are visual-only and do not participate in validation.
|
||||
/// Editor logic is responsible for skipping mask separator positions; here we
|
||||
/// only validate the raw insertion against limits and patterns.
|
||||
pub fn validate_char_insertion(
|
||||
&self,
|
||||
current_text: &str,
|
||||
position: usize,
|
||||
character: char,
|
||||
) -> ValidationResult {
|
||||
// Character limits validation
|
||||
if let Some(ref limits) = self.character_limits {
|
||||
if let Some(result) = limits.validate_insertion(current_text, position, character) {
|
||||
if !result.is_acceptable() {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern filters validation
|
||||
if let Some(ref patterns) = self.pattern_filters {
|
||||
if let Err(message) = patterns.validate_char_at_position(position, character) {
|
||||
return ValidationResult::error(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Future: Add other validation types here
|
||||
|
||||
ValidationResult::Valid
|
||||
}
|
||||
|
||||
/// Validate the current text content (raw text space)
|
||||
pub fn validate_content(&self, text: &str) -> ValidationResult {
|
||||
// Character limits validation
|
||||
if let Some(ref limits) = self.character_limits {
|
||||
if let Some(result) = limits.validate_content(text) {
|
||||
if !result.is_acceptable() {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern filters validation
|
||||
if let Some(ref patterns) = self.pattern_filters {
|
||||
if let Err(message) = patterns.validate_text(text) {
|
||||
return ValidationResult::error(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Future: Add other validation types here
|
||||
|
||||
ValidationResult::Valid
|
||||
}
|
||||
|
||||
/// Check if any validation rules are configured
|
||||
pub fn has_validation(&self) -> bool {
|
||||
self.character_limits.is_some()
|
||||
|| self.pattern_filters.is_some()
|
||||
|| self.display_mask.is_some()
|
||||
}
|
||||
|
||||
pub fn allows_field_switch(&self, text: &str) -> bool {
|
||||
// Character limits validation
|
||||
if let Some(ref limits) = self.character_limits {
|
||||
if !limits.allows_field_switch(text) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Future: Add other validation types here
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Get reason why field switching is blocked (if any)
|
||||
pub fn field_switch_block_reason(&self, text: &str) -> Option<String> {
|
||||
// Character limits validation
|
||||
if let Some(ref limits) = self.character_limits {
|
||||
if let Some(reason) = limits.field_switch_block_reason(text) {
|
||||
return Some(reason);
|
||||
}
|
||||
}
|
||||
|
||||
// Future: Add other validation types here
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
217
canvas/src/validation/formatting.rs
Normal file
217
canvas/src/validation/formatting.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
/* canvas/src/validation/formatting.rs
|
||||
Add new formatting module with CustomFormatter, PositionMapper, DefaultPositionMapper, and FormattingResult
|
||||
*/
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Bidirectional mapping between raw input positions and formatted display positions.
|
||||
///
|
||||
/// The library uses this to keep cursor/selection behavior intuitive when the UI
|
||||
/// shows a formatted transformation (e.g., "01001" -> "010 01") while the editor
|
||||
/// still stores raw text.
|
||||
pub trait PositionMapper: Send + Sync {
|
||||
/// Map a raw cursor position to a formatted cursor position.
|
||||
///
|
||||
/// raw_pos is an index into the raw text (0..=raw.len() in char positions).
|
||||
/// Implementations should return a position within 0..=formatted.len() (in char positions).
|
||||
fn raw_to_formatted(&self, raw: &str, formatted: &str, raw_pos: usize) -> usize;
|
||||
|
||||
/// Map a formatted cursor position to a raw cursor position.
|
||||
///
|
||||
/// formatted_pos is an index into the formatted text (0..=formatted.len()).
|
||||
/// Implementations should return a position within 0..=raw.len() (in char positions).
|
||||
fn formatted_to_raw(&self, raw: &str, formatted: &str, formatted_pos: usize) -> usize;
|
||||
}
|
||||
|
||||
/// A reasonable default mapper that works for "insert separators" style formatting,
|
||||
/// such as grouping digits or adding dashes/spaces.
|
||||
///
|
||||
/// Heuristic:
|
||||
/// - Treat letters and digits (is_alphanumeric) in the formatted string as user-entered characters
|
||||
/// corresponding to raw characters, in order.
|
||||
/// - Treat any non-alphanumeric characters as purely visual separators.
|
||||
/// - Raw positions are mapped by counting alphanumeric characters in the formatted string.
|
||||
/// - If the formatted contains fewer alphanumeric characters than the raw (shouldn't happen
|
||||
/// for plain grouping), we cap at the end of the formatted string.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct DefaultPositionMapper;
|
||||
|
||||
impl PositionMapper for DefaultPositionMapper {
|
||||
fn raw_to_formatted(&self, raw: &str, formatted: &str, raw_pos: usize) -> usize {
|
||||
// Convert to char indices for correctness in presence of UTF-8
|
||||
let raw_len = raw.chars().count();
|
||||
let clamped_raw_pos = raw_pos.min(raw_len);
|
||||
|
||||
// Count alphanumerics in formatted, find the index where we've seen `clamped_raw_pos` of them.
|
||||
let mut seen_user_chars = 0usize;
|
||||
for (idx, ch) in formatted.char_indices() {
|
||||
if ch.is_alphanumeric() {
|
||||
if seen_user_chars == clamped_raw_pos {
|
||||
// Cursor is positioned before this user character in the formatted view
|
||||
return idx;
|
||||
}
|
||||
seen_user_chars += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// If we consumed all alphanumeric chars and still haven't reached clamped_raw_pos,
|
||||
// place cursor at the end of the formatted string.
|
||||
formatted.len()
|
||||
}
|
||||
|
||||
fn formatted_to_raw(&self, raw: &str, formatted: &str, formatted_pos: usize) -> usize {
|
||||
let clamped_fmt_pos = formatted_pos.min(formatted.len());
|
||||
|
||||
// Count alphanumerics in formatted up to formatted_pos.
|
||||
let mut seen_user_chars = 0usize;
|
||||
for (idx, ch) in formatted.char_indices() {
|
||||
if idx >= clamped_fmt_pos {
|
||||
break;
|
||||
}
|
||||
if ch.is_alphanumeric() {
|
||||
seen_user_chars += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Map to raw position by clamping to raw char count
|
||||
let raw_len = raw.chars().count();
|
||||
seen_user_chars.min(raw_len)
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of invoking a custom formatter on the raw input.
|
||||
///
|
||||
/// Success variants carry the formatted string and a position mapper to translate
|
||||
/// between raw and formatted cursor positions. If you don't provide a custom mapper,
|
||||
/// the library will fall back to DefaultPositionMapper.
|
||||
pub enum FormattingResult {
|
||||
/// Successfully produced a formatted display value and a position mapper.
|
||||
Success {
|
||||
formatted: String,
|
||||
/// Mapper to convert cursor positions between raw and formatted representations.
|
||||
mapper: Arc<dyn PositionMapper>,
|
||||
},
|
||||
/// Successfully produced a formatted value, but with a non-fatal warning message
|
||||
/// that can be shown in the UI (e.g., "incomplete value").
|
||||
Warning {
|
||||
formatted: String,
|
||||
message: String,
|
||||
mapper: Arc<dyn PositionMapper>,
|
||||
},
|
||||
/// Failed to produce a formatted display. The library will typically fall back to raw.
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl FormattingResult {
|
||||
/// Convenience to create a success result using the default mapper.
|
||||
pub fn success(formatted: impl Into<String>) -> Self {
|
||||
FormattingResult::Success {
|
||||
formatted: formatted.into(),
|
||||
mapper: Arc::new(DefaultPositionMapper::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience to create a warning result using the default mapper.
|
||||
pub fn warning(formatted: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
FormattingResult::Warning {
|
||||
formatted: formatted.into(),
|
||||
message: message.into(),
|
||||
mapper: Arc::new(DefaultPositionMapper::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience to create a success result with a custom mapper.
|
||||
pub fn success_with_mapper(
|
||||
formatted: impl Into<String>,
|
||||
mapper: Arc<dyn PositionMapper>,
|
||||
) -> Self {
|
||||
FormattingResult::Success {
|
||||
formatted: formatted.into(),
|
||||
mapper,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience to create a warning result with a custom mapper.
|
||||
pub fn warning_with_mapper(
|
||||
formatted: impl Into<String>,
|
||||
message: impl Into<String>,
|
||||
mapper: Arc<dyn PositionMapper>,
|
||||
) -> Self {
|
||||
FormattingResult::Warning {
|
||||
formatted: formatted.into(),
|
||||
message: message.into(),
|
||||
mapper,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience to create an error result.
|
||||
pub fn error(message: impl Into<String>) -> Self {
|
||||
FormattingResult::Error {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A user-implemented formatter that turns raw input into a formatted display string,
|
||||
/// optionally providing a custom cursor position mapper.
|
||||
///
|
||||
/// Notes:
|
||||
/// - The library will keep raw input authoritative for editing and validation.
|
||||
/// - The formatted value is only used for display.
|
||||
/// - If formatting fails, return Error; the library will show the raw value.
|
||||
/// - For common grouping (spaces/dashes), you can return Success/Warning and rely
|
||||
/// on DefaultPositionMapper, or provide your own mapper for advanced cases
|
||||
/// (reordering, compression, locale-specific rules, etc.).
|
||||
pub trait CustomFormatter: Send + Sync {
|
||||
fn format(&self, raw: &str) -> FormattingResult;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct GroupEvery3;
|
||||
impl CustomFormatter for GroupEvery3 {
|
||||
fn format(&self, raw: &str) -> FormattingResult {
|
||||
let mut out = String::new();
|
||||
for (i, ch) in raw.chars().enumerate() {
|
||||
if i > 0 && i % 3 == 0 {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
FormattingResult::success(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_mapper_roundtrip_basic() {
|
||||
let mapper = DefaultPositionMapper::default();
|
||||
let raw = "01001";
|
||||
let formatted = "010 01";
|
||||
|
||||
// raw_to_formatted monotonicity and bounds
|
||||
for rp in 0..=raw.chars().count() {
|
||||
let fp = mapper.raw_to_formatted(raw, formatted, rp);
|
||||
assert!(fp <= formatted.len());
|
||||
}
|
||||
|
||||
// formatted_to_raw bounds
|
||||
for fp in 0..=formatted.len() {
|
||||
let rp = mapper.formatted_to_raw(raw, formatted, fp);
|
||||
assert!(rp <= raw.chars().count());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatter_groups_every_3() {
|
||||
let f = GroupEvery3;
|
||||
match f.format("1234567") {
|
||||
FormattingResult::Success { formatted, .. } => {
|
||||
assert_eq!(formatted, "123 456 7");
|
||||
}
|
||||
_ => panic!("expected success"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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};
|
||||
@@ -13,6 +14,17 @@ 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)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user