feature4 implemented and working properly well
This commit is contained in:
787
canvas/examples/validation_4.rs
Normal file
787
canvas/examples/validation_4.rs
Normal file
@@ -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<D: DataProvider> {
|
||||||
|
editor: FormEditor<D>,
|
||||||
|
debug_message: String,
|
||||||
|
command_buffer: String,
|
||||||
|
validation_enabled: bool,
|
||||||
|
show_raw_data: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<D: DataProvider> PscDemoFormEditor<D> {
|
||||||
|
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<ValidationConfig> {
|
||||||
|
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<PscDemoData>,
|
||||||
|
) -> anyhow::Result<bool> {
|
||||||
|
let mode = editor.mode();
|
||||||
|
|
||||||
|
// Quit handling
|
||||||
|
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL))
|
||||||
|
|| (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL))
|
||||||
|
|| key == KeyCode::F(10)
|
||||||
|
{
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
match (mode, key, modifiers) {
|
||||||
|
// === MODE TRANSITIONS ===
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('i'), _) => {
|
||||||
|
editor.enter_edit_mode();
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
|
||||||
|
editor.enter_append_mode();
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('A'), _) => {
|
||||||
|
editor.move_line_end();
|
||||||
|
editor.enter_edit_mode();
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape: Exit edit mode
|
||||||
|
(_, KeyCode::Esc, _) => {
|
||||||
|
if mode == AppMode::Edit {
|
||||||
|
editor.exit_edit_mode();
|
||||||
|
} else {
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 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<B: Backend>(
|
||||||
|
terminal: &mut Terminal<B>,
|
||||||
|
mut editor: PscDemoFormEditor<PscDemoData>,
|
||||||
|
) -> 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<PscDemoData>) {
|
||||||
|
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<PscDemoData>) {
|
||||||
|
render_canvas_default(f, area, &editor.editor);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_formatter_status(
|
||||||
|
f: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
editor: &PscDemoFormEditor<PscDemoData>,
|
||||||
|
) {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
// 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(())
|
||||||
|
}
|
||||||
@@ -53,24 +53,10 @@ pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
|
|||||||
for i in 0..field_count {
|
for i in 0..field_count {
|
||||||
fields.push(data_provider.field_name(i));
|
fields.push(data_provider.field_name(i));
|
||||||
|
|
||||||
// Use display text that applies masks if configured
|
// Use editor-provided effective display text per field (Feature 4/mask aware)
|
||||||
#[cfg(feature = "validation")]
|
#[cfg(feature = "validation")]
|
||||||
{
|
{
|
||||||
if i == editor.current_field() {
|
inputs.push(editor.display_text_for_field(i));
|
||||||
inputs.push(editor.current_display_text());
|
|
||||||
} else {
|
|
||||||
// For non-current fields, we need to apply mask manually
|
|
||||||
let raw = data_provider.field_value(i);
|
|
||||||
if let Some(cfg) = editor.ui_state().validation_state().get_field_config(i) {
|
|
||||||
if let Some(mask) = &cfg.display_mask {
|
|
||||||
inputs.push(mask.apply_to_display(raw));
|
|
||||||
} else {
|
|
||||||
inputs.push(raw.to_string());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
inputs.push(raw.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "validation"))]
|
#[cfg(not(feature = "validation"))]
|
||||||
{
|
{
|
||||||
@@ -93,23 +79,10 @@ pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
|
|||||||
editor.display_cursor_position(), // Use display cursor position for masks
|
editor.display_cursor_position(), // Use display cursor position for masks
|
||||||
false, // TODO: track unsaved changes in editor
|
false, // TODO: track unsaved changes in editor
|
||||||
|i| {
|
|i| {
|
||||||
// Get display value for field i
|
// Get display value for field i using editor logic (Feature 4 + masks)
|
||||||
#[cfg(feature = "validation")]
|
#[cfg(feature = "validation")]
|
||||||
{
|
{
|
||||||
if i == editor.current_field() {
|
editor.display_text_for_field(i)
|
||||||
editor.current_display_text()
|
|
||||||
} else {
|
|
||||||
let raw = data_provider.field_value(i);
|
|
||||||
if let Some(cfg) = editor.ui_state().validation_state().get_field_config(i) {
|
|
||||||
if let Some(mask) = &cfg.display_mask {
|
|
||||||
mask.apply_to_display(raw)
|
|
||||||
} else {
|
|
||||||
raw.to_string()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
raw.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "validation"))]
|
#[cfg(not(feature = "validation"))]
|
||||||
{
|
{
|
||||||
@@ -117,12 +90,21 @@ pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|i| {
|
|i| {
|
||||||
// Check if field has display override (mask)
|
// Check if field has display override (custom formatter or mask)
|
||||||
#[cfg(feature = "validation")]
|
#[cfg(feature = "validation")]
|
||||||
{
|
{
|
||||||
editor.ui_state().validation_state().get_field_config(i)
|
editor.ui_state().validation_state().get_field_config(i)
|
||||||
.and_then(|cfg| cfg.display_mask.as_ref())
|
.map(|cfg| {
|
||||||
.is_some()
|
// Formatter takes precedence; if present, it's a display override
|
||||||
|
#[allow(unused_mut)]
|
||||||
|
let mut has_override = false;
|
||||||
|
#[cfg(feature = "validation")]
|
||||||
|
{
|
||||||
|
has_override = cfg.custom_formatter.is_some();
|
||||||
|
}
|
||||||
|
has_override || cfg.display_mask.is_some()
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "validation"))]
|
#[cfg(not(feature = "validation"))]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -85,7 +85,14 @@ impl<D: DataProvider> FormEditor<D> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current field text for display, applying mask if configured
|
/// Get current field text for display.
|
||||||
|
///
|
||||||
|
/// Policies:
|
||||||
|
/// - Feature 4 (custom formatter):
|
||||||
|
/// - While editing the focused field: ALWAYS show raw (no custom formatting).
|
||||||
|
/// - When not editing the field: show formatted (fallback to raw on error).
|
||||||
|
/// - Mask-only fields: mask applies even in Edit mode (preserve legacy behavior).
|
||||||
|
/// - Otherwise: raw.
|
||||||
#[cfg(feature = "validation")]
|
#[cfg(feature = "validation")]
|
||||||
pub fn current_display_text(&self) -> String {
|
pub fn current_display_text(&self) -> String {
|
||||||
let field_index = self.ui_state.current_field;
|
let field_index = self.ui_state.current_field;
|
||||||
@@ -96,10 +103,29 @@ impl<D: DataProvider> FormEditor<D> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
|
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
|
||||||
|
// 1) Mask-only fields: mask applies even in Edit (legacy behavior)
|
||||||
|
if cfg.custom_formatter.is_none() {
|
||||||
|
if let Some(mask) = &cfg.display_mask {
|
||||||
|
return mask.apply_to_display(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Feature 4 fields: raw while editing, formatted otherwise
|
||||||
|
if cfg.custom_formatter.is_some() {
|
||||||
|
if matches!(self.ui_state.current_mode, AppMode::Edit) {
|
||||||
|
return raw.to_string();
|
||||||
|
}
|
||||||
|
if let Some((formatted, _mapper, _warning)) = cfg.run_custom_formatter(raw) {
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Fallback to mask if present (when formatter didn't produce output)
|
||||||
if let Some(mask) = &cfg.display_mask {
|
if let Some(mask) = &cfg.display_mask {
|
||||||
return mask.apply_to_display(raw);
|
return mask.apply_to_display(raw);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
raw.to_string()
|
raw.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +134,53 @@ impl<D: DataProvider> FormEditor<D> {
|
|||||||
&self.ui_state
|
&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
|
/// Get reference to data provider for rendering
|
||||||
pub fn data_provider(&self) -> &D {
|
pub fn data_provider(&self) -> &D {
|
||||||
&self.data_provider
|
&self.data_provider
|
||||||
@@ -959,7 +1032,7 @@ impl<D: DataProvider> FormEditor<D> {
|
|||||||
self.ui_state.ideal_cursor_column = clamped_pos;
|
self.ui_state.ideal_cursor_column = clamped_pos;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get cursor position for display (maps raw cursor to display position with mask)
|
/// Get cursor position for display (maps raw cursor to display position with formatter/mask)
|
||||||
pub fn display_cursor_position(&self) -> usize {
|
pub fn display_cursor_position(&self) -> usize {
|
||||||
let current_text = self.current_text();
|
let current_text = self.current_text();
|
||||||
let raw_pos = match self.ui_state.current_mode {
|
let raw_pos = match self.ui_state.current_mode {
|
||||||
@@ -977,6 +1050,13 @@ impl<D: DataProvider> FormEditor<D> {
|
|||||||
{
|
{
|
||||||
let field_index = self.ui_state.current_field;
|
let field_index = self.ui_state.current_field;
|
||||||
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
|
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
|
||||||
|
// Only apply custom formatter cursor mapping when NOT editing
|
||||||
|
if !matches!(self.ui_state.current_mode, AppMode::Edit) {
|
||||||
|
if let Some((formatted, mapper, _warning)) = cfg.run_custom_formatter(current_text) {
|
||||||
|
return mapper.raw_to_formatted(current_text, &formatted, raw_pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to display mask
|
||||||
if let Some(mask) = &cfg.display_mask {
|
if let Some(mask) = &cfg.display_mask {
|
||||||
return mask.raw_pos_to_display_pos(self.ui_state.cursor_pos);
|
return mask.raw_pos_to_display_pos(self.ui_state.cursor_pos);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ pub use validation::{
|
|||||||
CharacterLimits, ValidationConfigBuilder, ValidationState,
|
CharacterLimits, ValidationConfigBuilder, ValidationState,
|
||||||
ValidationSummary, PatternFilters, PositionFilter, PositionRange, CharacterFilter,
|
ValidationSummary, PatternFilters, PositionFilter, PositionRange, CharacterFilter,
|
||||||
DisplayMask, // Simple display mask instead of complex ReservedCharacters
|
DisplayMask, // Simple display mask instead of complex ReservedCharacters
|
||||||
|
// Feature 4: custom formatting exports
|
||||||
|
CustomFormatter, FormattingResult, PositionMapper, DefaultPositionMapper,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Theming and GUI
|
// Theming and GUI
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
//! Validation configuration types and builders
|
//! Validation configuration types and builders
|
||||||
|
|
||||||
use crate::validation::{CharacterLimits, PatternFilters, DisplayMask};
|
use crate::validation::{CharacterLimits, PatternFilters, DisplayMask};
|
||||||
|
#[cfg(feature = "validation")]
|
||||||
|
use crate::validation::{CustomFormatter, FormattingResult, PositionMapper};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// Main validation configuration for a field
|
/// Main validation configuration for a field
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
pub struct ValidationConfig {
|
pub struct ValidationConfig {
|
||||||
/// Character limit configuration
|
/// Character limit configuration
|
||||||
pub character_limits: Option<CharacterLimits>,
|
pub character_limits: Option<CharacterLimits>,
|
||||||
@@ -15,13 +18,199 @@ pub struct ValidationConfig {
|
|||||||
/// User-defined display mask for visual formatting
|
/// User-defined display mask for visual formatting
|
||||||
pub display_mask: Option<DisplayMask>,
|
pub display_mask: Option<DisplayMask>,
|
||||||
|
|
||||||
/// Future: Custom formatting
|
/// Optional: user-provided custom formatter (feature 4)
|
||||||
pub custom_formatting: Option<()>, // Placeholder for future implementation
|
#[cfg(feature = "validation")]
|
||||||
|
pub custom_formatter: Option<Arc<dyn CustomFormatter + Send + Sync>>,
|
||||||
|
|
||||||
/// Future: External validation
|
/// Future: External validation
|
||||||
pub external_validation: Option<()>, // Placeholder for future implementation
|
pub external_validation: Option<()>, // Placeholder for future implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Manual Debug to avoid requiring Debug on dyn CustomFormatter
|
||||||
|
impl std::fmt::Debug for ValidationConfig {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let mut ds = f.debug_struct("ValidationConfig");
|
||||||
|
ds.field("character_limits", &self.character_limits)
|
||||||
|
.field("pattern_filters", &self.pattern_filters)
|
||||||
|
.field("display_mask", &self.display_mask)
|
||||||
|
// Do not print the formatter itself to avoid requiring Debug
|
||||||
|
.field(
|
||||||
|
"custom_formatter",
|
||||||
|
&{
|
||||||
|
#[cfg(feature = "validation")]
|
||||||
|
{
|
||||||
|
if self.custom_formatter.is_some() { &"Some(<CustomFormatter>)" } else { &"None" }
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "validation"))]
|
||||||
|
{
|
||||||
|
&"N/A"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.field("external_validation", &self.external_validation)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ FIXED: Move function from struct definition to impl block
|
||||||
|
impl ValidationConfig {
|
||||||
|
/// If a custom formatter is configured, run it and return the formatted text,
|
||||||
|
/// the position mapper and an optional warning message.
|
||||||
|
///
|
||||||
|
/// Returns None when no custom formatter is configured.
|
||||||
|
#[cfg(feature = "validation")]
|
||||||
|
pub fn run_custom_formatter(
|
||||||
|
&self,
|
||||||
|
raw: &str,
|
||||||
|
) -> Option<(String, Arc<dyn PositionMapper>, Option<String>)> {
|
||||||
|
let formatter = self.custom_formatter.as_ref()?;
|
||||||
|
match formatter.format(raw) {
|
||||||
|
FormattingResult::Success { formatted, mapper } => {
|
||||||
|
Some((formatted, mapper, None))
|
||||||
|
}
|
||||||
|
FormattingResult::Warning { formatted, message, mapper } => {
|
||||||
|
Some((formatted, mapper, Some(message)))
|
||||||
|
}
|
||||||
|
FormattingResult::Error { .. } => None, // Fall back to raw display
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new empty validation configuration
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a configuration with just character limits
|
||||||
|
pub fn with_max_length(max_length: usize) -> Self {
|
||||||
|
ValidationConfigBuilder::new()
|
||||||
|
.with_max_length(max_length)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a configuration with pattern filters
|
||||||
|
pub fn with_patterns(patterns: PatternFilters) -> Self {
|
||||||
|
ValidationConfigBuilder::new()
|
||||||
|
.with_pattern_filters(patterns)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a configuration with user-defined display mask
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
/// ```
|
||||||
|
/// use canvas::{ValidationConfig, DisplayMask};
|
||||||
|
///
|
||||||
|
/// let phone_mask = DisplayMask::new("(###) ###-####", '#');
|
||||||
|
/// let config = ValidationConfig::with_mask(phone_mask);
|
||||||
|
/// ```
|
||||||
|
pub fn with_mask(mask: DisplayMask) -> Self {
|
||||||
|
ValidationConfigBuilder::new()
|
||||||
|
.with_display_mask(mask)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a character insertion at a specific position (raw text space).
|
||||||
|
///
|
||||||
|
/// Note: Display masks are visual-only and do not participate in validation.
|
||||||
|
/// Editor logic is responsible for skipping mask separator positions; here we
|
||||||
|
/// only validate the raw insertion against limits and patterns.
|
||||||
|
pub fn validate_char_insertion(
|
||||||
|
&self,
|
||||||
|
current_text: &str,
|
||||||
|
position: usize,
|
||||||
|
character: char,
|
||||||
|
) -> ValidationResult {
|
||||||
|
// Character limits validation
|
||||||
|
if let Some(ref limits) = self.character_limits {
|
||||||
|
// ✅ FIXED: Explicit return type annotation
|
||||||
|
if let Some(result) = limits.validate_insertion(current_text, position, character) {
|
||||||
|
if !result.is_acceptable() {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern filters validation
|
||||||
|
if let Some(ref patterns) = self.pattern_filters {
|
||||||
|
// ✅ FIXED: Explicit error handling
|
||||||
|
if let Err(message) = patterns.validate_char_at_position(position, character) {
|
||||||
|
return ValidationResult::error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Future: Add other validation types here
|
||||||
|
|
||||||
|
ValidationResult::Valid
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the current text content (raw text space)
|
||||||
|
pub fn validate_content(&self, text: &str) -> ValidationResult {
|
||||||
|
// Character limits validation
|
||||||
|
if let Some(ref limits) = self.character_limits {
|
||||||
|
// ✅ FIXED: Explicit return type annotation
|
||||||
|
if let Some(result) = limits.validate_content(text) {
|
||||||
|
if !result.is_acceptable() {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern filters validation
|
||||||
|
if let Some(ref patterns) = self.pattern_filters {
|
||||||
|
// ✅ FIXED: Explicit error handling
|
||||||
|
if let Err(message) = patterns.validate_text(text) {
|
||||||
|
return ValidationResult::error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Future: Add other validation types here
|
||||||
|
|
||||||
|
ValidationResult::Valid
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if any validation rules are configured
|
||||||
|
pub fn has_validation(&self) -> bool {
|
||||||
|
self.character_limits.is_some()
|
||||||
|
|| self.pattern_filters.is_some()
|
||||||
|
|| self.display_mask.is_some()
|
||||||
|
|| {
|
||||||
|
#[cfg(feature = "validation")]
|
||||||
|
{ self.custom_formatter.is_some() }
|
||||||
|
#[cfg(not(feature = "validation"))]
|
||||||
|
{ false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn allows_field_switch(&self, text: &str) -> bool {
|
||||||
|
// Character limits validation
|
||||||
|
if let Some(ref limits) = self.character_limits {
|
||||||
|
// ✅ FIXED: Direct boolean return
|
||||||
|
if !limits.allows_field_switch(text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Future: Add other validation types here
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get reason why field switching is blocked (if any)
|
||||||
|
pub fn field_switch_block_reason(&self, text: &str) -> Option<String> {
|
||||||
|
// Character limits validation
|
||||||
|
if let Some(ref limits) = self.character_limits {
|
||||||
|
// ✅ FIXED: Direct option return
|
||||||
|
if let Some(reason) = limits.field_switch_block_reason(text) {
|
||||||
|
return Some(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Future: Add other validation types here
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Builder for creating validation configurations
|
/// Builder for creating validation configurations
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct ValidationConfigBuilder {
|
pub struct ValidationConfigBuilder {
|
||||||
@@ -47,24 +236,24 @@ impl ValidationConfigBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set user-defined display mask for visual formatting
|
/// Set user-defined display mask for visual formatting
|
||||||
///
|
///
|
||||||
/// # Examples
|
/// # Examples
|
||||||
/// ```
|
/// ```
|
||||||
/// use canvas::{ValidationConfigBuilder, DisplayMask};
|
/// use canvas::{ValidationConfigBuilder, DisplayMask};
|
||||||
///
|
///
|
||||||
/// // Phone number with dynamic formatting
|
/// // Phone number with dynamic formatting
|
||||||
/// let phone_mask = DisplayMask::new("(###) ###-####", '#');
|
/// let phone_mask = DisplayMask::new("(###) ###-####", '#');
|
||||||
/// let config = ValidationConfigBuilder::new()
|
/// let config = ValidationConfigBuilder::new()
|
||||||
/// .with_display_mask(phone_mask)
|
/// .with_display_mask(phone_mask)
|
||||||
/// .build();
|
/// .build();
|
||||||
///
|
///
|
||||||
/// // Date with template formatting
|
/// // Date with template formatting
|
||||||
/// let date_mask = DisplayMask::new("##/##/####", '#')
|
/// let date_mask = DisplayMask::new("##/##/####", '#')
|
||||||
/// .with_template('_');
|
/// .with_template('_');
|
||||||
/// let config = ValidationConfigBuilder::new()
|
/// let config = ValidationConfigBuilder::new()
|
||||||
/// .with_display_mask(date_mask)
|
/// .with_display_mask(date_mask)
|
||||||
/// .build();
|
/// .build();
|
||||||
///
|
///
|
||||||
/// // Custom business format
|
/// // Custom business format
|
||||||
/// let employee_id = DisplayMask::new("EMP-####-##", '#')
|
/// let employee_id = DisplayMask::new("EMP-####-##", '#')
|
||||||
/// .with_template('•');
|
/// .with_template('•');
|
||||||
@@ -78,6 +267,18 @@ impl ValidationConfigBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set optional custom formatter (feature 4)
|
||||||
|
#[cfg(feature = "validation")]
|
||||||
|
pub fn with_custom_formatter<F>(mut self, formatter: Arc<F>) -> Self
|
||||||
|
where
|
||||||
|
F: CustomFormatter + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
self.config.custom_formatter = Some(formatter);
|
||||||
|
// When custom formatter is present, it takes precedence over display mask.
|
||||||
|
self.config.display_mask = None;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Set maximum number of characters (convenience method)
|
/// Set maximum number of characters (convenience method)
|
||||||
pub fn with_max_length(mut self, max_length: usize) -> Self {
|
pub fn with_max_length(mut self, max_length: usize) -> Self {
|
||||||
self.config.character_limits = Some(CharacterLimits::new(max_length));
|
self.config.character_limits = Some(CharacterLimits::new(max_length));
|
||||||
@@ -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<String> {
|
|
||||||
// Character limits validation
|
|
||||||
if let Some(ref limits) = self.character_limits {
|
|
||||||
if let Some(reason) = limits.field_switch_block_reason(text) {
|
|
||||||
return Some(reason);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Future: Add other validation types here
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -268,7 +344,7 @@ mod tests {
|
|||||||
// User creates their own phone mask
|
// User creates their own phone mask
|
||||||
let phone_mask = DisplayMask::new("(###) ###-####", '#');
|
let phone_mask = DisplayMask::new("(###) ###-####", '#');
|
||||||
let config = ValidationConfig::with_mask(phone_mask);
|
let config = ValidationConfig::with_mask(phone_mask);
|
||||||
|
|
||||||
// has_validation should be true because mask is configured
|
// has_validation should be true because mask is configured
|
||||||
assert!(config.has_validation());
|
assert!(config.has_validation());
|
||||||
|
|
||||||
|
|||||||
217
canvas/src/validation/formatting.rs
Normal file
217
canvas/src/validation/formatting.rs
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
/* canvas/src/validation/formatting.rs
|
||||||
|
Add new formatting module with CustomFormatter, PositionMapper, DefaultPositionMapper, and FormattingResult
|
||||||
|
*/
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Bidirectional mapping between raw input positions and formatted display positions.
|
||||||
|
///
|
||||||
|
/// The library uses this to keep cursor/selection behavior intuitive when the UI
|
||||||
|
/// shows a formatted transformation (e.g., "01001" -> "010 01") while the editor
|
||||||
|
/// still stores raw text.
|
||||||
|
pub trait PositionMapper: Send + Sync {
|
||||||
|
/// Map a raw cursor position to a formatted cursor position.
|
||||||
|
///
|
||||||
|
/// raw_pos is an index into the raw text (0..=raw.len() in char positions).
|
||||||
|
/// Implementations should return a position within 0..=formatted.len() (in char positions).
|
||||||
|
fn raw_to_formatted(&self, raw: &str, formatted: &str, raw_pos: usize) -> usize;
|
||||||
|
|
||||||
|
/// Map a formatted cursor position to a raw cursor position.
|
||||||
|
///
|
||||||
|
/// formatted_pos is an index into the formatted text (0..=formatted.len()).
|
||||||
|
/// Implementations should return a position within 0..=raw.len() (in char positions).
|
||||||
|
fn formatted_to_raw(&self, raw: &str, formatted: &str, formatted_pos: usize) -> usize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reasonable default mapper that works for "insert separators" style formatting,
|
||||||
|
/// such as grouping digits or adding dashes/spaces.
|
||||||
|
///
|
||||||
|
/// Heuristic:
|
||||||
|
/// - Treat letters and digits (is_alphanumeric) in the formatted string as user-entered characters
|
||||||
|
/// corresponding to raw characters, in order.
|
||||||
|
/// - Treat any non-alphanumeric characters as purely visual separators.
|
||||||
|
/// - Raw positions are mapped by counting alphanumeric characters in the formatted string.
|
||||||
|
/// - If the formatted contains fewer alphanumeric characters than the raw (shouldn't happen
|
||||||
|
/// for plain grouping), we cap at the end of the formatted string.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct DefaultPositionMapper;
|
||||||
|
|
||||||
|
impl PositionMapper for DefaultPositionMapper {
|
||||||
|
fn raw_to_formatted(&self, raw: &str, formatted: &str, raw_pos: usize) -> usize {
|
||||||
|
// Convert to char indices for correctness in presence of UTF-8
|
||||||
|
let raw_len = raw.chars().count();
|
||||||
|
let clamped_raw_pos = raw_pos.min(raw_len);
|
||||||
|
|
||||||
|
// Count alphanumerics in formatted, find the index where we've seen `clamped_raw_pos` of them.
|
||||||
|
let mut seen_user_chars = 0usize;
|
||||||
|
for (idx, ch) in formatted.char_indices() {
|
||||||
|
if ch.is_alphanumeric() {
|
||||||
|
if seen_user_chars == clamped_raw_pos {
|
||||||
|
// Cursor is positioned before this user character in the formatted view
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
seen_user_chars += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we consumed all alphanumeric chars and still haven't reached clamped_raw_pos,
|
||||||
|
// place cursor at the end of the formatted string.
|
||||||
|
formatted.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn formatted_to_raw(&self, raw: &str, formatted: &str, formatted_pos: usize) -> usize {
|
||||||
|
let clamped_fmt_pos = formatted_pos.min(formatted.len());
|
||||||
|
|
||||||
|
// Count alphanumerics in formatted up to formatted_pos.
|
||||||
|
let mut seen_user_chars = 0usize;
|
||||||
|
for (idx, ch) in formatted.char_indices() {
|
||||||
|
if idx >= clamped_fmt_pos {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if ch.is_alphanumeric() {
|
||||||
|
seen_user_chars += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map to raw position by clamping to raw char count
|
||||||
|
let raw_len = raw.chars().count();
|
||||||
|
seen_user_chars.min(raw_len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of invoking a custom formatter on the raw input.
|
||||||
|
///
|
||||||
|
/// Success variants carry the formatted string and a position mapper to translate
|
||||||
|
/// between raw and formatted cursor positions. If you don't provide a custom mapper,
|
||||||
|
/// the library will fall back to DefaultPositionMapper.
|
||||||
|
pub enum FormattingResult {
|
||||||
|
/// Successfully produced a formatted display value and a position mapper.
|
||||||
|
Success {
|
||||||
|
formatted: String,
|
||||||
|
/// Mapper to convert cursor positions between raw and formatted representations.
|
||||||
|
mapper: Arc<dyn PositionMapper>,
|
||||||
|
},
|
||||||
|
/// Successfully produced a formatted value, but with a non-fatal warning message
|
||||||
|
/// that can be shown in the UI (e.g., "incomplete value").
|
||||||
|
Warning {
|
||||||
|
formatted: String,
|
||||||
|
message: String,
|
||||||
|
mapper: Arc<dyn PositionMapper>,
|
||||||
|
},
|
||||||
|
/// Failed to produce a formatted display. The library will typically fall back to raw.
|
||||||
|
Error {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FormattingResult {
|
||||||
|
/// Convenience to create a success result using the default mapper.
|
||||||
|
pub fn success(formatted: impl Into<String>) -> Self {
|
||||||
|
FormattingResult::Success {
|
||||||
|
formatted: formatted.into(),
|
||||||
|
mapper: Arc::new(DefaultPositionMapper::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience to create a warning result using the default mapper.
|
||||||
|
pub fn warning(formatted: impl Into<String>, message: impl Into<String>) -> Self {
|
||||||
|
FormattingResult::Warning {
|
||||||
|
formatted: formatted.into(),
|
||||||
|
message: message.into(),
|
||||||
|
mapper: Arc::new(DefaultPositionMapper::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience to create a success result with a custom mapper.
|
||||||
|
pub fn success_with_mapper(
|
||||||
|
formatted: impl Into<String>,
|
||||||
|
mapper: Arc<dyn PositionMapper>,
|
||||||
|
) -> Self {
|
||||||
|
FormattingResult::Success {
|
||||||
|
formatted: formatted.into(),
|
||||||
|
mapper,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience to create a warning result with a custom mapper.
|
||||||
|
pub fn warning_with_mapper(
|
||||||
|
formatted: impl Into<String>,
|
||||||
|
message: impl Into<String>,
|
||||||
|
mapper: Arc<dyn PositionMapper>,
|
||||||
|
) -> Self {
|
||||||
|
FormattingResult::Warning {
|
||||||
|
formatted: formatted.into(),
|
||||||
|
message: message.into(),
|
||||||
|
mapper,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience to create an error result.
|
||||||
|
pub fn error(message: impl Into<String>) -> Self {
|
||||||
|
FormattingResult::Error {
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A user-implemented formatter that turns raw input into a formatted display string,
|
||||||
|
/// optionally providing a custom cursor position mapper.
|
||||||
|
///
|
||||||
|
/// Notes:
|
||||||
|
/// - The library will keep raw input authoritative for editing and validation.
|
||||||
|
/// - The formatted value is only used for display.
|
||||||
|
/// - If formatting fails, return Error; the library will show the raw value.
|
||||||
|
/// - For common grouping (spaces/dashes), you can return Success/Warning and rely
|
||||||
|
/// on DefaultPositionMapper, or provide your own mapper for advanced cases
|
||||||
|
/// (reordering, compression, locale-specific rules, etc.).
|
||||||
|
pub trait CustomFormatter: Send + Sync {
|
||||||
|
fn format(&self, raw: &str) -> FormattingResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct GroupEvery3;
|
||||||
|
impl CustomFormatter for GroupEvery3 {
|
||||||
|
fn format(&self, raw: &str) -> FormattingResult {
|
||||||
|
let mut out = String::new();
|
||||||
|
for (i, ch) in raw.chars().enumerate() {
|
||||||
|
if i > 0 && i % 3 == 0 {
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
out.push(ch);
|
||||||
|
}
|
||||||
|
FormattingResult::success(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_mapper_roundtrip_basic() {
|
||||||
|
let mapper = DefaultPositionMapper::default();
|
||||||
|
let raw = "01001";
|
||||||
|
let formatted = "010 01";
|
||||||
|
|
||||||
|
// raw_to_formatted monotonicity and bounds
|
||||||
|
for rp in 0..=raw.chars().count() {
|
||||||
|
let fp = mapper.raw_to_formatted(raw, formatted, rp);
|
||||||
|
assert!(fp <= formatted.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatted_to_raw bounds
|
||||||
|
for fp in 0..=formatted.len() {
|
||||||
|
let rp = mapper.formatted_to_raw(raw, formatted, fp);
|
||||||
|
assert!(rp <= raw.chars().count());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formatter_groups_every_3() {
|
||||||
|
let f = GroupEvery3;
|
||||||
|
match f.format("1234567") {
|
||||||
|
FormattingResult::Success { formatted, .. } => {
|
||||||
|
assert_eq!(formatted, "123 456 7");
|
||||||
|
}
|
||||||
|
_ => panic!("expected success"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ pub mod limits;
|
|||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod patterns;
|
pub mod patterns;
|
||||||
pub mod mask; // Simple display mask instead of complex reserved chars
|
pub mod mask; // Simple display mask instead of complex reserved chars
|
||||||
|
pub mod formatting; // Custom formatter and position mapping (feature 4)
|
||||||
|
|
||||||
// Re-export main types
|
// Re-export main types
|
||||||
pub use config::{ValidationConfig, ValidationResult, ValidationConfigBuilder};
|
pub use config::{ValidationConfig, ValidationResult, ValidationConfigBuilder};
|
||||||
@@ -13,6 +14,7 @@ pub use limits::{CharacterLimits, LimitCheckResult};
|
|||||||
pub use state::{ValidationState, ValidationSummary};
|
pub use state::{ValidationState, ValidationSummary};
|
||||||
pub use patterns::{PatternFilters, PositionFilter, PositionRange, CharacterFilter};
|
pub use patterns::{PatternFilters, PositionFilter, PositionRange, CharacterFilter};
|
||||||
pub use mask::DisplayMask; // Simple mask instead of ReservedCharacters
|
pub use mask::DisplayMask; // Simple mask instead of ReservedCharacters
|
||||||
|
pub use formatting::{CustomFormatter, FormattingResult, PositionMapper, DefaultPositionMapper};
|
||||||
|
|
||||||
/// Validation error types
|
/// Validation error types
|
||||||
#[derive(Debug, Clone, thiserror::Error)]
|
#[derive(Debug, Clone, thiserror::Error)]
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
//! Validation state management
|
//! Validation state management
|
||||||
|
|
||||||
use crate::validation::{ValidationConfig, ValidationResult};
|
use crate::validation::{ValidationConfig, ValidationResult};
|
||||||
|
#[cfg(feature = "validation")]
|
||||||
|
use crate::validation::{PositionMapper};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// Validation state for all fields in a form
|
/// 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> {
|
pub fn get_field_result(&self, field_index: usize) -> Option<&ValidationResult> {
|
||||||
self.field_results.get(&field_index)
|
self.field_results.get(&field_index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get formatted display for a field if a custom formatter is configured.
|
||||||
|
/// Returns (formatted_text, position_mapper, optional_warning_message).
|
||||||
|
#[cfg(feature = "validation")]
|
||||||
|
pub fn formatted_for(
|
||||||
|
&self,
|
||||||
|
field_index: usize,
|
||||||
|
raw: &str,
|
||||||
|
) -> Option<(String, std::sync::Arc<dyn PositionMapper>, Option<String>)> {
|
||||||
|
let config = self.field_configs.get(&field_index)?;
|
||||||
|
config.run_custom_formatter(raw)
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if a field has been validated
|
/// Check if a field has been validated
|
||||||
pub fn is_field_validated(&self, field_index: usize) -> bool {
|
pub fn is_field_validated(&self, field_index: usize) -> bool {
|
||||||
|
|||||||
Reference in New Issue
Block a user