diff --git a/canvas/examples/validation_3.rs b/canvas/examples/validation_3.rs new file mode 100644 index 0000000..cdaa6a5 --- /dev/null +++ b/canvas/examples/validation_3.rs @@ -0,0 +1,712 @@ +// examples/validation_3.rs +//! Comprehensive Display Mask Features Demo +//! +//! This example showcases the full power of the display mask system (Feature 3) +//! demonstrating visual formatting that keeps business logic clean. +//! +//! Key Features Demonstrated: +//! - Dynamic vs Template display modes +//! - Custom patterns for different data types +//! - Custom input characters and separators +//! - Custom placeholder characters +//! - Real-time visual formatting with clean raw data +//! - Cursor movement through formatted displays +//! - 🔥 CRITICAL: Perfect mask/character-limit coordination to prevent invisible character bugs +//! +//! ⚠️ IMPORTANT BUG PREVENTION: +//! This example demonstrates the CORRECT way to configure masks with character limits. +//! Each mask's input position count EXACTLY matches its character limit to prevent +//! the critical bug where users can type more characters than they can see. +//! +//! Run with: cargo run --example validation_3 --features "gui,validation" + +// REQUIRE validation and gui features for mask functionality +#[cfg(not(all(feature = "validation", feature = "gui")))] +compile_error!( + "This example requires the 'validation' and 'gui' features. \ + Run with: cargo run --example validation_3 --features \"gui,validation\"" +); + +use std::io; +use crossterm::{ + event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers, + }, + execute, + terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, + }, +}; +use ratatui::{ + backend::{Backend, CrosstermBackend}, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, Terminal, +}; + +use canvas::{ + canvas::{ + gui::render_canvas_default, + modes::AppMode, + }, + DataProvider, FormEditor, + ValidationConfig, ValidationConfigBuilder, DisplayMask, + validation::mask::MaskDisplayMode, +}; + +// Enhanced FormEditor wrapper for mask demonstration +struct MaskDemoFormEditor { + editor: FormEditor, + debug_message: String, + command_buffer: String, + validation_enabled: bool, + show_raw_data: bool, +} + +impl MaskDemoFormEditor { + fn new(data_provider: D) -> Self { + let mut editor = FormEditor::new(data_provider); + editor.set_validation_enabled(true); + + Self { + editor, + debug_message: "🎭 Display Mask Demo - Visual formatting with clean business logic!".to_string(), + command_buffer: String::new(), + validation_enabled: true, + show_raw_data: false, + } + } + + // === COMMAND BUFFER HANDLING === + fn clear_command_buffer(&mut self) { self.command_buffer.clear(); } + fn add_to_command_buffer(&mut self, ch: char) { self.command_buffer.push(ch); } + fn get_command_buffer(&self) -> &str { &self.command_buffer } + fn has_pending_command(&self) -> bool { !self.command_buffer.is_empty() } + + // === MASK CONTROL === + fn toggle_validation(&mut self) { + self.validation_enabled = !self.validation_enabled; + self.editor.set_validation_enabled(self.validation_enabled); + if self.validation_enabled { + self.debug_message = "✅ Display Masks ENABLED - See visual formatting in action!".to_string(); + } else { + self.debug_message = "❌ Display Masks DISABLED - Raw text only".to_string(); + } + } + + fn toggle_raw_data_view(&mut self) { + self.show_raw_data = !self.show_raw_data; + if self.show_raw_data { + self.debug_message = "👁️ Showing RAW business data (what's actually stored)".to_string(); + } else { + self.debug_message = "🎭 Showing FORMATTED display (what users see)".to_string(); + } + } + + fn get_current_field_info(&self) -> (String, String, String) { + let field_index = self.editor.current_field(); + let raw_data = self.editor.current_text(); + let display_data = if self.validation_enabled { + self.editor.current_display_text() + } else { + raw_data.to_string() + }; + + let mask_info = if let Some(config) = self.editor.validation_state().get_field_config(field_index) { + if let Some(mask) = &config.display_mask { + format!("Pattern: '{}', Mode: {:?}", + mask.pattern(), + mask.display_mode()) + } else { + "No mask configured".to_string() + } + } else { + "No validation config".to_string() + }; + + (raw_data.to_string(), display_data, mask_info) + } + + // === ENHANCED MOVEMENT WITH MASK AWARENESS === + fn move_left(&mut self) { + self.editor.move_left(); + self.update_cursor_info(); + } + + fn move_right(&mut self) { + self.editor.move_right(); + self.update_cursor_info(); + } + + fn move_up(&mut self) { + match self.editor.move_up() { + Ok(()) => { self.update_field_info(); } + Err(e) => { self.debug_message = format!("🚫 Field switch blocked: {}", e); } + } + } + + fn move_down(&mut self) { + match self.editor.move_down() { + Ok(()) => { self.update_field_info(); } + Err(e) => { self.debug_message = format!("🚫 Field switch blocked: {}", e); } + } + } + + fn move_line_start(&mut self) { + self.editor.move_line_start(); + self.update_cursor_info(); + } + + fn move_line_end(&mut self) { + self.editor.move_line_end(); + self.update_cursor_info(); + } + + fn update_cursor_info(&mut self) { + if self.validation_enabled { + let raw_pos = self.editor.cursor_position(); + let display_pos = self.editor.display_cursor_position(); + if raw_pos != display_pos { + self.debug_message = format!("📍 Cursor: Raw pos {} → Display pos {} (mask active)", raw_pos, display_pos); + } else { + self.debug_message = format!("📍 Cursor at position {} (no mask offset)", raw_pos); + } + } + } + + fn update_field_info(&mut self) { + let field_name = self.editor.data_provider().field_name(self.editor.current_field()); + self.debug_message = format!("📝 Switched to: {}", field_name); + } + + // === MODE TRANSITIONS === + fn enter_edit_mode(&mut self) { + self.editor.enter_edit_mode(); + self.debug_message = "✏️ INSERT MODE - Type to see mask formatting in real-time".to_string(); + } + + fn enter_append_mode(&mut self) { + self.editor.enter_append_mode(); + self.debug_message = "✏️ INSERT (append) - Mask formatting active".to_string(); + } + + fn exit_edit_mode(&mut self) { + self.editor.exit_edit_mode(); + self.debug_message = "🔒 NORMAL MODE - Press 'r' to see raw data, 'm' for mask info".to_string(); + } + + fn insert_char(&mut self, ch: char) -> anyhow::Result<()> { + let result = self.editor.insert_char(ch); + if result.is_ok() { + let (raw, display, _) = self.get_current_field_info(); + if raw != display { + self.debug_message = format!("✏️ Added '{}': Raw='{}' Display='{}'", ch, raw, display); + } else { + self.debug_message = format!("✏️ Added '{}': '{}'", ch, raw); + } + } + Ok(result?) + } + + // === DELETE OPERATIONS === + fn delete_backward(&mut self) -> anyhow::Result<()> { + let result = self.editor.delete_backward(); + if result.is_ok() { + self.debug_message = "⌫ Character deleted".to_string(); + self.update_cursor_info(); + } + Ok(result?) + } + + fn delete_forward(&mut self) -> anyhow::Result<()> { + let result = self.editor.delete_forward(); + if result.is_ok() { + self.debug_message = "⌦ Character deleted".to_string(); + self.update_cursor_info(); + } + Ok(result?) + } + + // === DELEGATE TO ORIGINAL EDITOR === + fn current_field(&self) -> usize { self.editor.current_field() } + fn cursor_position(&self) -> usize { self.editor.cursor_position() } + fn mode(&self) -> AppMode { self.editor.mode() } + fn current_text(&self) -> &str { self.editor.current_text() } + fn data_provider(&self) -> &D { self.editor.data_provider() } + fn ui_state(&self) -> &canvas::EditorState { self.editor.ui_state() } + fn set_mode(&mut self, mode: AppMode) { self.editor.set_mode(mode); } + + fn next_field(&mut self) { + match self.editor.next_field() { + Ok(()) => { self.update_field_info(); } + Err(e) => { self.debug_message = format!("🚫 Cannot move to next field: {}", e); } + } + } + + fn prev_field(&mut self) { + match self.editor.prev_field() { + Ok(()) => { self.update_field_info(); } + Err(e) => { self.debug_message = format!("🚫 Cannot move to previous field: {}", e); } + } + } + + // === STATUS AND DEBUG === + fn set_debug_message(&mut self, msg: String) { self.debug_message = msg; } + fn debug_message(&self) -> &str { &self.debug_message } + + fn show_mask_details(&mut self) { + let (raw, display, mask_info) = self.get_current_field_info(); + self.debug_message = format!("🔍 Field {}: {} | Raw: '{}' Display: '{}'", + self.current_field() + 1, mask_info, raw, display); + } + + fn get_mask_status(&self) -> String { + if !self.validation_enabled { + return "❌ DISABLED".to_string(); + } + + let field_count = self.editor.data_provider().field_count(); + let mut mask_count = 0; + for i in 0..field_count { + if let Some(config) = self.editor.validation_state().get_field_config(i) { + if config.display_mask.is_some() { + mask_count += 1; + } + } + } + + format!("🎭 {} MASKS", mask_count) + } +} + +// Demo data with comprehensive mask examples +struct MaskDemoData { + fields: Vec<(String, String)>, +} + +impl MaskDemoData { + fn new() -> Self { + Self { + fields: vec![ + ("📞 Phone (Dynamic)".to_string(), "".to_string()), + ("📞 Phone (Template)".to_string(), "".to_string()), + ("📅 Date US (MM/DD/YYYY)".to_string(), "".to_string()), + ("📅 Date EU (DD.MM.YYYY)".to_string(), "".to_string()), + ("📅 Date ISO (YYYY-MM-DD)".to_string(), "".to_string()), + ("🏛️ SSN (XXX-XX-XXXX)".to_string(), "".to_string()), + ("💳 Credit Card".to_string(), "".to_string()), + ("🏢 Employee ID (EMP-####)".to_string(), "".to_string()), + ("📦 Product Code (ABC###XYZ)".to_string(), "".to_string()), + ("🌈 Custom Separators".to_string(), "".to_string()), + ("⭐ Custom Placeholders".to_string(), "".to_string()), + ("🎯 Mixed Input Chars".to_string(), "".to_string()), + ], + } + } +} + +impl DataProvider for MaskDemoData { + fn field_count(&self) -> usize { self.fields.len() } + fn field_name(&self, index: usize) -> &str { &self.fields[index].0 } + fn field_value(&self, index: usize) -> &str { &self.fields[index].1 } + fn set_field_value(&mut self, index: usize, value: String) { self.fields[index].1 = value; } + + fn validation_config(&self, field_index: usize) -> Option { + 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, +) -> anyhow::Result { + 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( + terminal: &mut Terminal, + mut editor: MaskDemoFormEditor, +) -> 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) { + 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, +) { + render_canvas_default(f, area, &editor.editor); +} + +fn render_mask_status( + f: &mut Frame, + area: Rect, + editor: &MaskDemoFormEditor, +) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Status bar + Constraint::Length(6), // Data comparison + Constraint::Length(7), // Help + ]) + .split(area); + + // Status bar with mask information + let mode_text = match editor.mode() { + AppMode::Edit => "INSERT", + AppMode::ReadOnly => "NORMAL", + _ => "OTHER", + }; + + let mask_status = editor.get_mask_status(); + let status_text = format!("-- {} -- {} | Masks: {} | View: {}", + mode_text, + editor.debug_message(), + mask_status, + if editor.show_raw_data { "RAW" } else { "FORMATTED" }); + + let status = Paragraph::new(Line::from(Span::raw(status_text))) + .block(Block::default().borders(Borders::ALL).title("🎭 Display Mask Demo")); + + f.render_widget(status, chunks[0]); + + // Data comparison showing raw vs display + let (raw_data, display_data, mask_info) = editor.get_current_field_info(); + let field_name = editor.data_provider().field_name(editor.current_field()); + + let comparison_text = format!( + "📝 Current Field: {}\n\ + 🔧 Mask Config: {}\n\ + \n\ + 💾 Raw Business Data: '{}' ← What's actually stored in your database\n\ + 🎭 Formatted Display: '{}' ← What users see in the interface\n\ + 📍 Cursor: Raw pos {} → Display pos {}", + field_name, + mask_info, + raw_data, + display_data, + editor.cursor_position(), + editor.editor.display_cursor_position() + ); + + let comparison_style = if raw_data != display_data { + Style::default().fg(Color::Green) // Green when mask is active + } else { + Style::default().fg(Color::Gray) // Gray when no formatting + }; + + let data_comparison = Paragraph::new(comparison_text) + .block(Block::default().borders(Borders::ALL).title("📊 Raw Data vs Display Formatting")) + .style(comparison_style) + .wrap(Wrap { trim: true }); + + f.render_widget(data_comparison, chunks[1]); + + // Enhanced help text + let help_text = match editor.mode() { + AppMode::ReadOnly => { + "🎭 MASK DEMO: See how visual formatting keeps business logic clean!\n\ + \n\ + 📱 Try different fields to see various mask patterns:\n\ + • Dynamic vs Template modes • Custom separators • Different input chars\n\ + \n\ + Commands: i/a=insert, m=mask details, r=toggle raw/display view\n\ + Movement: hjkl/arrows=move, 0=$=line start/end, Tab=next field, F1=toggle masks\n\ + ?=detailed info, Ctrl+C=quit" + } + AppMode::Edit => { + "✏️ INSERT MODE - Type to see real-time mask formatting!\n\ + \n\ + 🔥 Key Features in Action:\n\ + • Separators auto-appear as you type • Cursor skips over separators\n\ + • Template fields show placeholders • Raw data stays clean for business logic\n\ + \n\ + arrows=move through mask, Backspace/Del=delete, Esc=normal, Tab=next field\n\ + Notice how cursor position maps between raw data and display!" + } + _ => "🎭 Display Mask Demo Active!" + }; + + let help = Paragraph::new(help_text) + .block(Block::default().borders(Borders::ALL).title("🚀 Mask Features & Commands")) + .style(Style::default().fg(Color::Gray)) + .wrap(Wrap { trim: true }); + + f.render_widget(help, chunks[2]); +} + +fn main() -> Result<(), Box> { + // Print feature status + println!("🎭 Canvas Display Mask Demo (Feature 3)"); + println!("✅ validation feature: ENABLED"); + println!("✅ gui feature: ENABLED"); + println!("🎭 Display masks: ACTIVE"); + println!("🔥 Key Benefits Demonstrated:"); + println!(" • Clean separation: Visual formatting ≠ Business logic"); + println!(" • User-friendly: Pretty displays with automatic cursor handling"); + println!(" • Flexible: Custom patterns, separators, and placeholders"); + println!(" • Transparent: Library handles all complexity, API stays simple"); + println!(); + println!("💡 Try typing in different fields to see mask magic!"); + println!(" 📞 Phone fields show dynamic vs template modes"); + println!(" 📅 Date fields show different regional formats"); + println!(" 💳 Credit card shows spaced formatting"); + println!(" ⭐ Custom fields show advanced separator/placeholder options"); + println!(); + + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let data = MaskDemoData::new(); + let editor = MaskDemoFormEditor::new(data); + + let res = run_app(&mut terminal, editor); + + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + if let Err(err) = res { + println!("{:?}", err); + } + + println!("🎭 Display mask demo completed!"); + println!("🏆 You've seen how masks provide beautiful UX while keeping business logic clean!"); + Ok(()) +} diff --git a/canvas/src/canvas/gui.rs b/canvas/src/canvas/gui.rs index e9f5ce5..b23accb 100644 --- a/canvas/src/canvas/gui.rs +++ b/canvas/src/canvas/gui.rs @@ -52,7 +52,30 @@ pub fn render_canvas_with_highlight( for i in 0..field_count { fields.push(data_provider.field_name(i)); - inputs.push(data_provider.field_value(i).to_string()); + + // Use display text that applies masks if configured + #[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()); + } + } + } + #[cfg(not(feature = "validation"))] + { + inputs.push(data_provider.field_value(i).to_string()); + } } let current_field_idx = ui_state.current_field(); @@ -66,13 +89,46 @@ pub fn render_canvas_with_highlight( &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 + #[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() + } + } + } + #[cfg(not(feature = "validation"))] + { + data_provider.field_value(i).to_string() + } + }, + |i| { + // Check if field has display override (mask) + #[cfg(feature = "validation")] + { + editor.ui_state().validation_state().get_field_config(i) + .and_then(|cfg| cfg.display_mask.as_ref()) + .is_some() + } + #[cfg(not(feature = "validation"))] + { + false + } }, - |i| data_provider.display_value(i).is_some(), ) } @@ -245,7 +301,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 +313,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 +331,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 +434,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); diff --git a/canvas/src/editor.rs b/canvas/src/editor.rs index f100372..39d1cd3 100644 --- a/canvas/src/editor.rs +++ b/canvas/src/editor.rs @@ -3,8 +3,7 @@ #[cfg(feature = "cursor-style")] use crate::canvas::CursorManager; -#[cfg(feature = "cursor-style")] -use crossterm; + use anyhow::Result; use crate::canvas::state::EditorState; @@ -31,16 +30,16 @@ impl FormEditor { data_provider, suggestions: Vec::new(), }; - + // Initialize validation configurations if validation feature is enabled #[cfg(feature = "validation")] { editor.initialize_validation(); } - + editor } - + /// Initialize validation configurations from data provider #[cfg(feature = "validation")] fn initialize_validation(&mut self) { @@ -86,6 +85,24 @@ impl FormEditor { } } + /// Get current field text for display, applying mask if configured + #[cfg(feature = "validation")] + pub fn current_display_text(&self) -> String { + let field_index = self.ui_state.current_field; + let raw = if field_index < self.data_provider.field_count() { + self.data_provider.field_value(field_index) + } else { + "" + }; + + if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) { + if 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 @@ -100,20 +117,20 @@ impl FormEditor { pub fn suggestions(&self) -> &[SuggestionItem] { &self.suggestions } - + /// Get validation state (for user's business logic) /// Only available when the 'validation' feature is enabled #[cfg(feature = "validation")] pub fn validation_state(&self) -> &crate::validation::ValidationState { self.ui_state.validation_state() } - + /// Get validation result for current field #[cfg(feature = "validation")] pub fn current_field_validation(&self) -> Option<&crate::validation::ValidationResult> { self.ui_state.validation.get_field_result(self.ui_state.current_field) } - + /// Get validation result for specific field #[cfg(feature = "validation")] pub fn field_validation(&self, field_index: usize) -> Option<&crate::validation::ValidationResult> { @@ -124,31 +141,69 @@ impl FormEditor { // 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); + + // ❌ 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 + } + } + } + } - // Validate character insertion if validation is enabled + // 🔥 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 +213,130 @@ impl FormEditor { } } - // 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 + } + } + } + + // 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 + } + } + } + } - // Update library's UI state - self.ui_state.cursor_pos += 1; + // ✅ 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; } } @@ -198,7 +345,7 @@ impl FormEditor { pub fn move_to_next_field(&mut self) { let field_count = self.data_provider.field_count(); let next_field = (self.ui_state.current_field + 1) % field_count; - + // Validate current field content before moving if validation is enabled #[cfg(feature = "validation")] { @@ -210,7 +357,7 @@ impl FormEditor { // Note: We don't prevent field switching on validation failure, // just record the validation state } - + self.ui_state.move_to_field(next_field, field_count); // Clamp cursor to new field @@ -271,31 +418,31 @@ impl FormEditor { // =================================================================== // VALIDATION METHODS (only available with validation feature) // =================================================================== - + /// Enable or disable validation #[cfg(feature = "validation")] pub fn set_validation_enabled(&mut self, enabled: bool) { self.ui_state.validation.set_enabled(enabled); } - + /// Check if validation is enabled #[cfg(feature = "validation")] pub fn is_validation_enabled(&self) -> bool { self.ui_state.validation.is_enabled() } - + /// Set validation configuration for a specific field #[cfg(feature = "validation")] pub fn set_field_validation(&mut self, field_index: usize, config: crate::validation::ValidationConfig) { self.ui_state.validation.set_field_config(field_index, config); } - + /// Remove validation configuration for a specific field #[cfg(feature = "validation")] pub fn remove_field_validation(&mut self, field_index: usize) { self.ui_state.validation.remove_field_config(field_index); } - + /// Manually validate current field content #[cfg(feature = "validation")] pub fn validate_current_field(&mut self) -> crate::validation::ValidationResult { @@ -303,7 +450,7 @@ impl FormEditor { let current_text = self.current_text().to_string(); self.ui_state.validation.validate_field_content(field_index, ¤t_text) } - + /// Manually validate specific field content #[cfg(feature = "validation")] pub fn validate_field(&mut self, field_index: usize) -> Option { @@ -314,26 +461,26 @@ impl FormEditor { None } } - + /// Clear validation results for all fields #[cfg(feature = "validation")] pub fn clear_validation_results(&mut self) { self.ui_state.validation.clear_all_results(); } - + /// Get validation summary for all fields #[cfg(feature = "validation")] pub fn validation_summary(&self) -> crate::validation::ValidationSummary { self.ui_state.validation.summary() } - + /// Check if field switching is allowed from current field #[cfg(feature = "validation")] pub fn can_switch_fields(&self) -> bool { let current_text = self.current_text(); self.ui_state.validation.allows_field_switch(self.ui_state.current_field, current_text) } - + /// Get reason why field switching is blocked (if any) #[cfg(feature = "validation")] pub fn field_switch_block_reason(&self) -> Option { @@ -402,7 +549,7 @@ impl FormEditor { // Close autocomplete self.ui_state.deactivate_autocomplete(); self.suggestions.clear(); - + // Validate the new content if validation is enabled #[cfg(feature = "validation")] { @@ -648,7 +795,7 @@ impl FormEditor { self.data_provider.set_field_value(field_index, current_text.clone()); self.ui_state.cursor_pos -= 1; self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos; - + // Validate the new content if validation is enabled #[cfg(feature = "validation")] { @@ -674,7 +821,7 @@ impl FormEditor { if self.ui_state.cursor_pos < current_text.len() { current_text.remove(self.ui_state.cursor_pos); self.data_provider.set_field_value(field_index, current_text.clone()); - + // Validate the new content if validation is enabled #[cfg(feature = "validation")] { @@ -700,7 +847,7 @@ impl FormEditor { } } } - + // Adjust cursor position when transitioning from edit to normal mode let current_text = self.current_text(); if !current_text.is_empty() { @@ -751,7 +898,7 @@ impl FormEditor { // Reset cursor to start of field self.ui_state.cursor_pos = 0; self.ui_state.ideal_cursor_column = 0; - + // Validate the new content if validation is enabled #[cfg(feature = "validation")] { @@ -771,7 +918,7 @@ impl FormEditor { self.ui_state.cursor_pos = 0; self.ui_state.ideal_cursor_column = 0; } - + // Validate the new content if validation is enabled #[cfg(feature = "validation")] { @@ -812,24 +959,31 @@ impl FormEditor { 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 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) { + 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 +1068,12 @@ impl FormEditor { } 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 } diff --git a/canvas/src/lib.rs b/canvas/src/lib.rs index 71b380a..7f63625 100644 --- a/canvas/src/lib.rs +++ b/canvas/src/lib.rs @@ -35,7 +35,8 @@ pub use canvas::actions::{CanvasAction, ActionResult}; pub use validation::{ ValidationConfig, ValidationResult, ValidationError, CharacterLimits, ValidationConfigBuilder, ValidationState, - ValidationSummary, PatternFilters, PositionFilter, PositionRange, CharacterFilter, + ValidationSummary, PatternFilters, PositionFilter, PositionRange, CharacterFilter, + DisplayMask, // Simple display mask instead of complex ReservedCharacters }; // Theming and GUI diff --git a/canvas/src/validation/config.rs b/canvas/src/validation/config.rs index 45387f5..06ebb74 100644 --- a/canvas/src/validation/config.rs +++ b/canvas/src/validation/config.rs @@ -1,23 +1,23 @@ // src/validation/config.rs //! Validation configuration types and builders -use crate::validation::{CharacterLimits, PatternFilters}; +use crate::validation::{CharacterLimits, PatternFilters, DisplayMask}; /// Main validation configuration for a field #[derive(Debug, Clone, Default)] pub struct ValidationConfig { /// Character limit configuration pub character_limits: Option, - + /// Pattern filtering configuration pub pattern_filters: Option, - - /// Future: Reserved characters - pub reserved_chars: Option<()>, // Placeholder for future implementation - + + /// User-defined display mask for visual formatting + pub display_mask: Option, + /// Future: Custom formatting pub custom_formatting: Option<()>, // Placeholder for future implementation - + /// Future: External validation pub external_validation: Option<()>, // Placeholder for future implementation } @@ -33,25 +33,57 @@ impl ValidationConfigBuilder { pub fn new() -> Self { Self::default() } - + /// Set character limits for the field pub fn with_character_limits(mut self, limits: CharacterLimits) -> Self { self.config.character_limits = Some(limits); self } - + /// Set pattern filters for the field pub fn with_pattern_filters(mut self, filters: PatternFilters) -> Self { self.config.pattern_filters = Some(filters); self } - + + /// Set user-defined display mask for visual formatting + /// + /// # Examples + /// ``` + /// use canvas::{ValidationConfigBuilder, DisplayMask}; + /// + /// // Phone number with dynamic formatting + /// let phone_mask = DisplayMask::new("(###) ###-####", '#'); + /// let config = ValidationConfigBuilder::new() + /// .with_display_mask(phone_mask) + /// .build(); + /// + /// // Date with template formatting + /// let date_mask = DisplayMask::new("##/##/####", '#') + /// .with_template('_'); + /// let config = ValidationConfigBuilder::new() + /// .with_display_mask(date_mask) + /// .build(); + /// + /// // Custom business format + /// let employee_id = DisplayMask::new("EMP-####-##", '#') + /// .with_template('•'); + /// let config = ValidationConfigBuilder::new() + /// .with_display_mask(employee_id) + /// .with_max_length(6) // Only store the 6 digits + /// .build(); + /// ``` + pub fn with_display_mask(mut self, mask: DisplayMask) -> Self { + self.config.display_mask = Some(mask); + self + } + /// Set 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 } - + /// Build the final validation configuration pub fn build(self) -> ValidationConfig { self.config @@ -63,10 +95,10 @@ impl ValidationConfigBuilder { pub enum ValidationResult { /// Validation passed Valid, - + /// Validation failed with warning (input still accepted) Warning { message: String }, - + /// Validation failed with error (input rejected) Error { message: String }, } @@ -76,12 +108,12 @@ impl ValidationResult { pub fn is_acceptable(&self) -> bool { matches!(self, ValidationResult::Valid | ValidationResult::Warning { .. }) } - + /// Check if the validation result is an error pub fn is_error(&self) -> bool { matches!(self, ValidationResult::Error { .. }) } - + /// Get the message if there is one pub fn message(&self) -> Option<&str> { match self { @@ -90,12 +122,12 @@ impl ValidationResult { ValidationResult::Error { message } => Some(message), } } - + /// Create a warning result pub fn warning(message: impl Into) -> Self { ValidationResult::Warning { message: message.into() } } - + /// Create an error result pub fn error(message: impl Into) -> Self { ValidationResult::Error { message: message.into() } @@ -107,22 +139,41 @@ impl ValidationConfig { 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() } - - /// Validate a character insertion at a specific position + + /// 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, @@ -137,20 +188,20 @@ impl ValidationConfig { } } } - + // 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 + + /// 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 { @@ -160,26 +211,26 @@ impl ValidationConfig { } } } - + // 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.reserved_chars.is_some() - // || self.custom_formatting.is_some() - // || self.external_validation.is_some() + 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 { @@ -187,12 +238,12 @@ impl ValidationConfig { 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 { // Character limits validation @@ -201,9 +252,9 @@ impl ValidationConfig { return Some(reason); } } - + // Future: Add other validation types here - + None } } @@ -212,89 +263,99 @@ impl ValidationConfig { mod tests { use super::*; + #[test] + fn test_config_with_user_defined_mask() { + // User creates their own phone mask + let phone_mask = DisplayMask::new("(###) ###-####", '#'); + let config = ValidationConfig::with_mask(phone_mask); + + // has_validation should be true because mask is configured + assert!(config.has_validation()); + + // Display mask is visual only; validation still focuses on raw content + let result = config.validate_char_insertion("123", 3, '4'); + assert!(result.is_acceptable()); + + // Content validation unaffected by mask + let result = config.validate_content("1234567890"); + assert!(result.is_acceptable()); + } + #[test] fn test_validation_config_builder() { let config = ValidationConfigBuilder::new() .with_max_length(10) .build(); - + assert!(config.character_limits.is_some()); assert_eq!(config.character_limits.unwrap().max_length(), Some(10)); } - + + #[test] + fn test_config_builder_with_user_mask() { + // User defines custom format + let custom_mask = DisplayMask::new("##-##-##", '#').with_template('_'); + let config = ValidationConfigBuilder::new() + .with_display_mask(custom_mask) + .with_max_length(6) + .build(); + + assert!(config.has_validation()); + assert!(config.character_limits.is_some()); + assert!(config.display_mask.is_some()); + } + #[test] fn test_validation_result() { let valid = ValidationResult::Valid; assert!(valid.is_acceptable()); assert!(!valid.is_error()); assert_eq!(valid.message(), None); - + let warning = ValidationResult::warning("Too long"); assert!(warning.is_acceptable()); assert!(!warning.is_error()); assert_eq!(warning.message(), Some("Too long")); - + let error = ValidationResult::error("Invalid"); assert!(!error.is_acceptable()); assert!(error.is_error()); assert_eq!(error.message(), Some("Invalid")); } - + #[test] fn test_config_with_max_length() { let config = ValidationConfig::with_max_length(5); assert!(config.has_validation()); - + // Test valid insertion let result = config.validate_char_insertion("test", 4, 'x'); assert!(result.is_acceptable()); - + // Test invalid insertion (would exceed limit) let result = config.validate_char_insertion("tests", 5, 'x'); assert!(!result.is_acceptable()); } - + #[test] fn test_config_with_patterns() { use crate::validation::{PatternFilters, PositionFilter, PositionRange, CharacterFilter}; - + let patterns = PatternFilters::new() .add_filter(PositionFilter::new( PositionRange::Range(0, 1), CharacterFilter::Alphabetic, )); - + let config = ValidationConfig::with_patterns(patterns); assert!(config.has_validation()); - + // Test valid pattern insertion let result = config.validate_char_insertion("", 0, 'A'); assert!(result.is_acceptable()); - - // Test invalid pattern insertion + + // Test invalid pattern insertion let result = config.validate_char_insertion("", 0, '1'); assert!(!result.is_acceptable()); } - - #[test] - fn test_config_builder_with_patterns() { - use crate::validation::{PatternFilters, PositionFilter, PositionRange, CharacterFilter}; - - let patterns = PatternFilters::license_plate(); - let config = ValidationConfigBuilder::new() - .with_pattern_filters(patterns) - .with_max_length(5) - .build(); - - assert!(config.has_validation()); - assert!(config.character_limits.is_some()); - assert!(config.pattern_filters.is_some()); - - // Test pattern validation - let result = config.validate_content("AB123"); - assert!(result.is_acceptable()); - - let result = config.validate_content("A1123"); - assert!(!result.is_acceptable()); - } } diff --git a/canvas/src/validation/mask.rs b/canvas/src/validation/mask.rs new file mode 100644 index 0000000..7539740 --- /dev/null +++ b/canvas/src/validation/mask.rs @@ -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, 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 { + // 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 + } +} diff --git a/canvas/src/validation/mod.rs b/canvas/src/validation/mod.rs index 334d3c7..82a248a 100644 --- a/canvas/src/validation/mod.rs +++ b/canvas/src/validation/mod.rs @@ -1,32 +1,28 @@ // src/validation/mod.rs -//! Validation module for canvas form fields +// 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 // 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 /// Validation error types #[derive(Debug, Clone, thiserror::Error)] pub enum ValidationError { - #[error("Character limit exceeded: {current}/{max}")] - CharacterLimitExceeded { current: usize, max: usize }, - - #[error("Invalid character '{char}' at position {position}")] - InvalidCharacter { char: char, position: usize }, + #[error("Character limit exceeded: {message}")] + LimitExceeded { message: String }, #[error("Pattern validation failed: {message}")] - PatternValidationFailed { message: String }, + 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 = std::result::Result;