From 34c68858a3dfc1540faa720af61bb6db149b6ed6 Mon Sep 17 00:00:00 2001 From: Priec Date: Wed, 6 Aug 2025 23:16:04 +0200 Subject: [PATCH] feature4 implemented and working properly well --- canvas/examples/validation_4.rs | 787 ++++++++++++++++++++++++++++ canvas/src/canvas/gui.rs | 50 +- canvas/src/editor.rs | 84 ++- canvas/src/lib.rs | 2 + canvas/src/validation/config.rs | 344 +++++++----- canvas/src/validation/formatting.rs | 217 ++++++++ canvas/src/validation/mod.rs | 2 + canvas/src/validation/state.rs | 14 + 8 files changed, 1330 insertions(+), 170 deletions(-) create mode 100644 canvas/examples/validation_4.rs create mode 100644 canvas/src/validation/formatting.rs diff --git a/canvas/examples/validation_4.rs b/canvas/examples/validation_4.rs new file mode 100644 index 0000000..3459577 --- /dev/null +++ b/canvas/examples/validation_4.rs @@ -0,0 +1,787 @@ +/* examples/validation_4.rs + Demonstrates Feature 4: Custom parsing/formatting provided by the app, + displayed by the library while keeping raw input authoritative. + + Use-case: PSC (postal code) typed as "01001" should display as "010 01". + - Raw input: "01001" + - Display: "010 01" + - Cursor mapping is handled by the library via PositionMapper + - Validation still applies to raw text (if configured) + - Formatting is optional and only active when feature "validation" is enabled + + Run with: + cargo run --example validation_4 --features "gui,validation" +*/ + +#![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, +}; + +// Bring library types +use canvas::{ + canvas::{gui::render_canvas_default, modes::AppMode}, + DataProvider, FormEditor, + ValidationConfig, ValidationConfigBuilder, + // Feature 4 exports + CustomFormatter, FormattingResult, +}; + +/// PSC custom formatter +/// +/// Formats a raw 5-digit PSC as "XXX XX". +/// Examples: +/// - "" -> "" +/// - "0" -> "0" +/// - "01" -> "01" +/// - "010" -> "010" +/// - "0100" -> "010 0" +/// - "01001" -> "010 01" +/// Any extra chars are appended after the space (simple behavior). +struct PSCFormatter; + +impl CustomFormatter for PSCFormatter { + fn format(&self, raw: &str) -> FormattingResult { + let mut out = String::new(); + + for (i, ch) in raw.chars().enumerate() { + // Insert space after 3rd character for PSC visual grouping + if i == 3 { + out.push(' '); + } + out.push(ch); + } + + // Use default position mapper which treats non-alphanumeric as separators + FormattingResult::success(out) + } +} + +// Demo editor wrapper for custom formatter demonstration (mirror UX from validation_3) +struct PscDemoFormEditor { + editor: FormEditor, + debug_message: String, + command_buffer: String, + validation_enabled: bool, + show_raw_data: bool, +} + +impl PscDemoFormEditor { + fn new(data_provider: D) -> Self { + let mut editor = FormEditor::new(data_provider); + editor.set_validation_enabled(true); + + Self { + editor, + debug_message: + "🧩 Custom Formatter Demo - App-defined parsing with library-managed display!".to_string(), + command_buffer: String::new(), + validation_enabled: true, + show_raw_data: false, + } + } + + // === PSC HELPERS (conditional formatting policy) === + fn is_psc_field(&self) -> bool { + self.editor.current_field() == 0 + } + fn psc_raw(&self) -> &str { + if self.is_psc_field() { self.editor.current_text() } else { "" } + } + fn psc_is_valid(&self) -> bool { + let raw = self.psc_raw(); + raw.chars().count() == 5 && raw.chars().all(|c| c.is_ascii_digit()) + } + fn psc_should_format_for_display(&self) -> bool { + // Apply formatting only when NOT editing, on PSC field, and valid 5 digits + self.mode() != AppMode::Edit && self.is_psc_field() && self.psc_is_valid() + } + fn psc_filter_input(&self, ch: char) -> bool { + if !self.is_psc_field() { + return true; + } + // Only allow digits, enforce max 5 + if !ch.is_ascii_digit() { + return false; + } + self.psc_raw().chars().count() < 5 + } + + // === 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() + } + + // === FORMATTER 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 = "✅ Custom Formatter ENABLED - Library displays app-formatted output!".to_string(); + } else { + self.debug_message = "❌ Custom Formatter 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 (provided by your app, rendered by library)".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(); + + // Conditional display policy: + // - If editing PSC: show raw (no formatting) + // - Else if PSC valid and PSC field: show formatted + // - Else: show raw + let display_data = if self.is_psc_field() { + if self.mode() == AppMode::Edit { + raw_data.to_string() + } else if self.psc_is_valid() { + self.editor.current_display_text() + } else { + raw_data.to_string() + } + } else { + // Non-PSC field: show raw in this demo + raw_data.to_string() + }; + + let fmt_info = if self.is_psc_field() { + if self.psc_is_valid() { + "CustomFormatter: PSC ‘XXX XX’ (active)".to_string() + } else { + "CustomFormatter: PSC ‘XXX XX’ (waiting for 5 digits)".to_string() + } + } else { + "No formatter".to_string() + }; + + (raw_data.to_string(), display_data, fmt_info) + } + + // === ENHANCED MOVEMENT === + 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 {} (custom formatting active)", + raw_pos, display_pos + ); + } else { + self.debug_message = format!("📍 Cursor at position {} (no display 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 custom formatting applied in real-time".to_string(); + } + + fn enter_append_mode(&mut self) { + self.editor.enter_append_mode(); + self.debug_message = "✏️ INSERT (append) - Custom 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".to_string(); + } + + fn insert_char(&mut self, ch: char) -> anyhow::Result<()> { + // Enforce PSC typing rules on PSC field: + // - Only digits + // - Max 5 characters + if self.is_psc_field() && !self.psc_filter_input(ch) { + self.debug_message = "🚦 PSC: only digits, max 5".to_string(); + return Ok(()); + } + + let result = self.editor.insert_char(ch); + if result.is_ok() { + // In edit mode we always show raw + let raw = self.editor.current_text().to_string(); + let display = if self.psc_should_format_for_display() { + self.editor.current_display_text() + } else { + raw.clone() + }; + 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() { + // In edit mode, we revert to raw view; debug info reflects that + 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() { + // In edit mode, we revert to raw view; debug info reflects that + 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_formatter_details(&mut self) { + let (raw, display, fmt_info) = self.get_current_field_info(); + self.debug_message = format!( + "🔍 Field {}: {} | Raw: '{}' Display: '{}'", + self.current_field() + 1, + fmt_info, + raw, + display + ); + } + + fn get_formatter_status(&self) -> String { + if !self.validation_enabled { + return "❌ DISABLED".to_string(); + } + + // Count fields with validation config (for demo parity) + let field_count = self.editor.data_provider().field_count(); + let mut cfg_count = 0; + for i in 0..field_count { + if self.editor.validation_state().get_field_config(i).is_some() { + cfg_count += 1; + } + } + + format!("🧩 {} FORMATTERS", cfg_count) + } +} + +// Demo data with a PSC field configured with a custom formatter +struct PscDemoData { + fields: Vec<(String, String)>, +} + +impl PscDemoData { + fn new() -> Self { + Self { + fields: vec![ + ("🏁 PSC (type 01001)".to_string(), "".to_string()), + ("📝 Notes (raw)".to_string(), "".to_string()), + ], + } + } +} + +impl DataProvider for PscDemoData { + 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; + } + + // Provide validation config with custom formatter for field 0 + #[cfg(feature = "validation")] + fn validation_config(&self, field_index: usize) -> Option { + match field_index { + 0 => { + // PSC 5 digits displayed as "XXX XX". Raw value remains unmodified. + let cfg = ValidationConfigBuilder::new() + .with_custom_formatter(Arc::new(PSCFormatter)) + // Optional: add character limits or patterns for raw value + // .with_max_length(5) + .build(); + Some(cfg) + } + _ => None, + } + } +} + +// Enhanced key handling with custom formatter specific commands +fn handle_key_press( + key: KeyCode, + modifiers: KeyModifiers, + editor: &mut PscDemoFormEditor, +) -> 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(); + } + } + + // === FORMATTER-SPECIFIC COMMANDS === + (AppMode::ReadOnly, KeyCode::Char('m'), _) => { + editor.show_formatter_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, fmt_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(), + fmt_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: PscDemoFormEditor, +) -> 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: &PscDemoFormEditor) { + 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_formatter_status(f, chunks[1], editor); +} + +fn render_enhanced_canvas(f: &mut Frame, area: Rect, editor: &PscDemoFormEditor) { + render_canvas_default(f, area, &editor.editor); +} + +fn render_formatter_status( + f: &mut Frame, + area: Rect, + editor: &PscDemoFormEditor, +) { + 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 formatter information + let mode_text = match editor.mode() { + AppMode::Edit => "INSERT", + AppMode::ReadOnly => "NORMAL", + _ => "OTHER", + }; + + let fmt_status = editor.get_formatter_status(); + let status_text = format!( + "-- {} -- {} | Formatters: {} | View: {}", + mode_text, + editor.debug_message(), + fmt_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("🧩 Custom Formatter Demo")); + + f.render_widget(status, chunks[0]); + + // Data comparison showing raw vs display + let (raw_data, display_data, fmt_info) = editor.get_current_field_info(); + let field_name = editor.data_provider().field_name(editor.current_field()); + + let comparison_text = format!( + "📝 Current Field: {}\n\ + 🔧 Formatter 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, + fmt_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 formatting 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 App-Provided Formatting"), + ) + .style(comparison_style) + .wrap(Wrap { trim: true }); + + f.render_widget(data_comparison, chunks[1]); + + // Help text + let help_text = match editor.mode() { + AppMode::ReadOnly => { + "🧩 CUSTOM FORMATTER DEMO: App provides parsing/formatting; library displays and maps cursor!\n\ + \n\ + Try the PSC field:\n\ + • Type: 01001 → Display: 010 01\n\ + • Raw data stays unmodified: '01001'\n\ + \n\ + Commands: i/a=insert, m=formatter details, r=toggle raw/display view\n\ + Movement: hjkl/arrows=move, 0/$ line start/end, Tab=next field, F1=toggle formatting\n\ + ?=detailed info, Ctrl+C=quit" + } + AppMode::Edit => { + "✏️ INSERT MODE - Type to see real-time custom formatter output!\n\ + \n\ + Key Points:\n\ + • Your app formats; library displays and maps cursor\n\ + • Raw input is authoritative for validation and storage\n\ + \n\ + arrows=move, Backspace/Del=delete, Esc=normal, Tab=next field" + } + _ => "🧩 Custom Formatter Demo Active!" + }; + + let help = Paragraph::new(help_text) + .block( + Block::default() + .borders(Borders::ALL) + .title("🚀 Formatter 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 Custom Formatter Demo (Feature 4)"); + println!("✅ validation feature: ENABLED"); + println!("✅ gui feature: ENABLED"); + println!("🧩 Custom formatting: ACTIVE"); + println!("🔥 Key Benefits Demonstrated:"); + println!(" • App decides how to display values (e.g., PSC '01001' → '010 01')"); + println!(" • Library handles display + cursor mapping automatically"); + println!(" • Raw input remains authoritative for validation/storage"); + 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 = PscDemoData::new(); + let editor = PscDemoFormEditor::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!("🧩 Custom formatter demo completed!"); + println!("🏆 You saw how app-defined formatting integrates seamlessly with the library!"); + Ok(()) +} \ No newline at end of file diff --git a/canvas/src/canvas/gui.rs b/canvas/src/canvas/gui.rs index ebf2f52..4420343 100644 --- a/canvas/src/canvas/gui.rs +++ b/canvas/src/canvas/gui.rs @@ -53,24 +53,10 @@ pub fn render_canvas_with_highlight( 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( 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( } }, |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"))] { diff --git a/canvas/src/editor.rs b/canvas/src/editor.rs index 39d1cd3..f0b458a 100644 --- a/canvas/src/editor.rs +++ b/canvas/src/editor.rs @@ -85,7 +85,14 @@ impl FormEditor { } } - /// 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 FormEditor { }; 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,53 @@ impl FormEditor { &self.ui_state } + /// 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 +1032,7 @@ impl FormEditor { 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 +1050,13 @@ impl FormEditor { { 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); } diff --git a/canvas/src/lib.rs b/canvas/src/lib.rs index 7f63625..43a9085 100644 --- a/canvas/src/lib.rs +++ b/canvas/src/lib.rs @@ -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 diff --git a/canvas/src/validation/config.rs b/canvas/src/validation/config.rs index 06ebb74..c6ed8bb 100644 --- a/canvas/src/validation/config.rs +++ b/canvas/src/validation/config.rs @@ -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, @@ -15,13 +18,199 @@ pub struct ValidationConfig { /// User-defined display mask for visual formatting pub display_mask: Option, - /// 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>, /// 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()" } else { &"None" } + } + #[cfg(not(feature = "validation"))] + { + &"N/A" + } + }, + ) + .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, Option)> { + 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 { + // 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 { @@ -47,24 +236,24 @@ impl ValidationConfigBuilder { } /// 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 + /// + /// // 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('•'); @@ -78,6 +267,18 @@ impl ValidationConfigBuilder { self } + /// Set optional custom formatter (feature 4) + #[cfg(feature = "validation")] + pub fn with_custom_formatter(mut self, formatter: Arc) -> 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)); @@ -134,131 +335,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 { - // 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::*; @@ -268,7 +344,7 @@ mod tests { // 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()); diff --git a/canvas/src/validation/formatting.rs b/canvas/src/validation/formatting.rs new file mode 100644 index 0000000..eca5eb3 --- /dev/null +++ b/canvas/src/validation/formatting.rs @@ -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, + }, + /// 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, + }, + /// 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) -> 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, message: impl Into) -> 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, + mapper: Arc, + ) -> 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, + message: impl Into, + mapper: Arc, + ) -> Self { + FormattingResult::Warning { + formatted: formatted.into(), + message: message.into(), + mapper, + } + } + + /// Convenience to create an error result. + pub fn error(message: impl Into) -> 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"), + } + } +} \ No newline at end of file diff --git a/canvas/src/validation/mod.rs b/canvas/src/validation/mod.rs index 82a248a..fe9dd56 100644 --- a/canvas/src/validation/mod.rs +++ b/canvas/src/validation/mod.rs @@ -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,7 @@ 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}; /// Validation error types #[derive(Debug, Clone, thiserror::Error)] diff --git a/canvas/src/validation/state.rs b/canvas/src/validation/state.rs index 039c4ea..e4ba8f4 100644 --- a/canvas/src/validation/state.rs +++ b/canvas/src/validation/state.rs @@ -2,6 +2,8 @@ //! Validation state management use crate::validation::{ValidationConfig, ValidationResult}; +#[cfg(feature = "validation")] +use crate::validation::{PositionMapper}; use std::collections::HashMap; /// Validation state for all fields in a form @@ -121,6 +123,18 @@ impl ValidationState { pub fn get_field_result(&self, field_index: usize) -> Option<&ValidationResult> { 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, Option)> { + 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 {