Compare commits

...

8 Commits

18 changed files with 4692 additions and 205 deletions

1
Cargo.lock generated
View File

@@ -479,6 +479,7 @@ dependencies = [
"common",
"crossterm",
"ratatui",
"regex",
"serde",
"thiserror",
"tokio",

1
canvas/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
docs_prompts/

View File

@@ -16,13 +16,14 @@ crossterm = { workspace = true, optional = true }
anyhow.workspace = true
tokio = { workspace = true, optional = true }
toml = { workspace = true }
serde = { workspace = true }
serde.workspace = true
unicode-width.workspace = true
thiserror = { workspace = true }
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
async-trait.workspace = true
regex = { workspace = true, optional = true }
[dev-dependencies]
tokio-test = "0.4.4"
@@ -32,6 +33,7 @@ default = []
gui = ["ratatui"]
autocomplete = ["tokio"]
cursor-style = ["crossterm"]
validation = ["regex"]
[[example]]
name = "autocomplete"
@@ -42,3 +44,7 @@ path = "examples/autocomplete.rs"
name = "canvas_gui_demo"
required-features = ["gui"]
path = "examples/canvas_gui_demo.rs"
[[example]]
name = "validation_1"
required-features = ["gui", "validation"]

View File

@@ -1,77 +0,0 @@
git status
On branch main
Your branch is ahead of 'origin/main' by 1 commit.
(use "git push" to publish your local commits)
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: src/canvas/actions/handlers/edit.rs
modified: src/canvas/actions/types.rs
no changes added to commit (use "git add" and/or "git commit -a")
git --no-pager diff
diff --git a/canvas/src/canvas/actions/handlers/edit.rs b/canvas/src/canvas/actions/handlers/edit.rs
index a26fe6f..fa1becb 100644
--- a/canvas/src/canvas/actions/handlers/edit.rs
+++ b/canvas/src/canvas/actions/handlers/edit.rs
@@ -29,6 +29,21 @@ pub async fn handle_edit_action<S: CanvasState>(
Ok(ActionResult::success())
}
+ CanvasAction::SelectAll => {
+ // Select all text in current field
+ let current_input = state.get_current_input();
+ let text_length = current_input.len();
+
+ // Set cursor to start and select all
+ state.set_current_cursor_pos(0);
+ // TODO: You'd need to add selection state to CanvasState trait
+ // For now, just move cursor to end to "select" all
+ state.set_current_cursor_pos(text_length);
+ *ideal_cursor_column = text_length;
+
+ Ok(ActionResult::success_with_message(&format!("Selected all {} characters", text_length)))
+ }
+
CanvasAction::DeleteBackward => {
let cursor_pos = state.current_cursor_pos();
if cursor_pos > 0 {
@@ -323,6 +338,13 @@ impl ActionHandlerIntrospection for EditHandler {
is_required: false,
});
+ actions.push(ActionSpec {
+ name: "select_all".to_string(),
+ description: "Select all text in current field".to_string(),
+ examples: vec!["Ctrl+a".to_string()],
+ is_required: false, // Optional action
+ });
+
HandlerCapabilities {
mode_name: "edit".to_string(),
actions,
diff --git a/canvas/src/canvas/actions/types.rs b/canvas/src/canvas/actions/types.rs
index 433a4d5..3794596 100644
--- a/canvas/src/canvas/actions/types.rs
+++ b/canvas/src/canvas/actions/types.rs
@@ -31,6 +31,8 @@ pub enum CanvasAction {
NextField,
PrevField,
+ SelectAll,
+
// Autocomplete actions
TriggerAutocomplete,
SuggestionUp,
@@ -62,6 +64,7 @@ impl CanvasAction {
"move_word_end_prev" => Self::MoveWordEndPrev,
"next_field" => Self::NextField,
"prev_field" => Self::PrevField,
+ "select_all" => Self::SelectAll,
"trigger_autocomplete" => Self::TriggerAutocomplete,
"suggestion_up" => Self::SuggestionUp,
"suggestion_down" => Self::SuggestionDown,
╭─    ~/Doc/p/komp_ac/canvas  on   main ⇡1 !2 
╰─

View File

@@ -0,0 +1,831 @@
// examples/validation_1.rs
//! Demonstrates field validation with the canvas library
//!
//! This example REQUIRES the `validation` feature to compile.
//!
//! Run with:
//! cargo run --example validation_1 --features "gui,validation"
//!
//! This will fail without validation:
//! cargo run --example validation_1 --features "gui"
// REQUIRE validation feature - example won't compile without it
#[cfg(not(feature = "validation"))]
compile_error!(
"This example requires the 'validation' feature. \
Run with: cargo run --example validation_1 --features \"gui,validation\""
);
use std::io;
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers,
},
execute,
terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
Frame, Terminal,
};
use canvas::{
canvas::{
gui::render_canvas_default,
modes::AppMode,
},
DataProvider, FormEditor,
ValidationConfig, ValidationConfigBuilder, CharacterLimits, ValidationResult,
};
// Import CountMode from the validation module directly
use canvas::validation::limits::CountMode;
// Enhanced FormEditor that demonstrates validation functionality
struct ValidationFormEditor<D: DataProvider> {
editor: FormEditor<D>,
has_unsaved_changes: bool,
debug_message: String,
command_buffer: String,
validation_enabled: bool,
field_switch_blocked: bool,
block_reason: Option<String>,
}
impl<D: DataProvider> ValidationFormEditor<D> {
fn new(data_provider: D) -> Self {
let mut editor = FormEditor::new(data_provider);
// Enable validation by default
editor.set_validation_enabled(true);
Self {
editor,
has_unsaved_changes: false,
debug_message: "🔍 Validation Demo - Try typing in different fields!".to_string(),
command_buffer: String::new(),
validation_enabled: true,
field_switch_blocked: false,
block_reason: None,
}
}
// === 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()
}
// === VALIDATION 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 = "✅ Validation ENABLED - Try exceeding limits!".to_string();
} else {
self.debug_message = "❌ Validation DISABLED - No limits enforced".to_string();
}
}
fn check_field_switch_allowed(&self) -> (bool, Option<String>) {
if !self.validation_enabled {
return (true, None);
}
let can_switch = self.editor.can_switch_fields();
let reason = if !can_switch {
self.editor.field_switch_block_reason()
} else {
None
};
(can_switch, reason)
}
fn get_validation_status(&self) -> String {
if !self.validation_enabled {
return "❌ DISABLED".to_string();
}
if self.field_switch_blocked {
return "🚫 SWITCH BLOCKED".to_string();
}
let summary = self.editor.validation_summary();
if summary.has_errors() {
format!("{} ERRORS", summary.error_fields)
} else if summary.has_warnings() {
format!("⚠️ {} WARNINGS", summary.warning_fields)
} else if summary.validated_fields > 0 {
format!("{} VALID", summary.valid_fields)
} else {
"🔍 READY".to_string()
}
}
fn validate_current_field(&mut self) {
let result = self.editor.validate_current_field();
match result {
ValidationResult::Valid => {
self.debug_message = "✅ Current field is valid!".to_string();
}
ValidationResult::Warning { message } => {
self.debug_message = format!("⚠️ Warning: {}", message);
}
ValidationResult::Error { message } => {
self.debug_message = format!("❌ Error: {}", message);
}
}
}
fn validate_all_fields(&mut self) {
let field_count = self.editor.data_provider().field_count();
for i in 0..field_count {
self.editor.validate_field(i);
}
let summary = self.editor.validation_summary();
self.debug_message = format!(
"🔍 Validated all fields: {} valid, {} warnings, {} errors",
summary.valid_fields, summary.warning_fields, summary.error_fields
);
}
fn clear_validation_results(&mut self) {
self.editor.clear_validation_results();
self.debug_message = "🧹 Cleared all validation results".to_string();
}
// === ENHANCED MOVEMENT WITH VALIDATION ===
fn move_left(&mut self) {
self.editor.move_left();
self.field_switch_blocked = false;
self.block_reason = None;
}
fn move_right(&mut self) {
self.editor.move_right();
self.field_switch_blocked = false;
self.block_reason = None;
}
fn move_up(&mut self) {
match self.editor.move_up() {
Ok(()) => {
self.update_field_validation_status();
self.field_switch_blocked = false;
self.block_reason = None;
}
Err(e) => {
self.field_switch_blocked = true;
self.block_reason = Some(e.to_string());
self.debug_message = format!("🚫 Field switch blocked: {}", e);
}
}
}
fn move_down(&mut self) {
match self.editor.move_down() {
Ok(()) => {
self.update_field_validation_status();
self.field_switch_blocked = false;
self.block_reason = None;
}
Err(e) => {
self.field_switch_blocked = true;
self.block_reason = Some(e.to_string());
self.debug_message = format!("🚫 Field switch blocked: {}", e);
}
}
}
fn move_line_start(&mut self) {
self.editor.move_line_start();
}
fn move_line_end(&mut self) {
self.editor.move_line_end();
}
fn move_word_next(&mut self) {
self.editor.move_word_next();
}
fn move_word_prev(&mut self) {
self.editor.move_word_prev();
}
fn move_word_end(&mut self) {
self.editor.move_word_end();
}
fn move_first_line(&mut self) {
self.editor.move_first_line();
}
fn move_last_line(&mut self) {
self.editor.move_last_line();
}
fn update_field_validation_status(&mut self) {
if !self.validation_enabled {
return;
}
if let Some(result) = self.editor.current_field_validation() {
match result {
ValidationResult::Valid => {
self.debug_message = format!("Field {}: ✅ Valid", self.editor.current_field() + 1);
}
ValidationResult::Warning { message } => {
self.debug_message = format!("Field {}: ⚠️ {}", self.editor.current_field() + 1, message);
}
ValidationResult::Error { message } => {
self.debug_message = format!("Field {}: ❌ {}", self.editor.current_field() + 1, message);
}
}
} else {
self.debug_message = format!("Field {}: 🔍 Not validated yet", self.editor.current_field() + 1);
}
}
// === MODE TRANSITIONS ===
fn enter_edit_mode(&mut self) {
self.editor.enter_edit_mode();
self.debug_message = "✏️ INSERT MODE - Type to test validation".to_string();
}
fn enter_append_mode(&mut self) {
self.editor.enter_append_mode();
self.debug_message = "✏️ INSERT (append) - Validation active".to_string();
}
fn exit_edit_mode(&mut self) {
self.editor.exit_edit_mode();
self.debug_message = "🔒 NORMAL MODE - Press 'v' to validate current field".to_string();
self.update_field_validation_status();
}
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
let result = self.editor.insert_char(ch);
if result.is_ok() {
self.has_unsaved_changes = true;
// Show real-time validation feedback
if let Some(validation_result) = self.editor.current_field_validation() {
match validation_result {
ValidationResult::Valid => {
// Don't spam with valid messages, just show character count if applicable
if let Some(limits) = self.get_current_field_limits() {
if let Some(status) = limits.status_text(self.editor.current_text()) {
self.debug_message = format!("✏️ {}", status);
}
}
}
ValidationResult::Warning { message } => {
self.debug_message = format!("⚠️ {}", message);
}
ValidationResult::Error { message } => {
self.debug_message = format!("{}", message);
}
}
}
}
Ok(result?)
}
fn get_current_field_limits(&self) -> Option<&CharacterLimits> {
let validation_state = self.editor.validation_state();
let config = validation_state.get_field_config(self.editor.current_field())?;
config.character_limits.as_ref()
}
// === DELETE OPERATIONS ===
fn delete_backward(&mut self) -> anyhow::Result<()> {
let result = self.editor.delete_backward();
if result.is_ok() {
self.has_unsaved_changes = true;
self.debug_message = "⌫ Deleted character".to_string();
}
Ok(result?)
}
fn delete_forward(&mut self) -> anyhow::Result<()> {
let result = self.editor.delete_forward();
if result.is_ok() {
self.has_unsaved_changes = true;
self.debug_message = "⌦ Deleted character".to_string();
}
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_validation_status();
self.field_switch_blocked = false;
self.block_reason = None;
}
Err(e) => {
self.field_switch_blocked = true;
self.block_reason = Some(e.to_string());
self.debug_message = format!("🚫 Cannot move to next field: {}", e);
}
}
}
fn prev_field(&mut self) {
match self.editor.prev_field() {
Ok(()) => {
self.update_field_validation_status();
self.field_switch_blocked = false;
self.block_reason = None;
}
Err(e) => {
self.field_switch_blocked = true;
self.block_reason = Some(e.to_string());
self.debug_message = format!("🚫 Cannot move to previous field: {}", e);
}
}
}
// === STATUS AND DEBUG ===
fn set_debug_message(&mut self, msg: String) {
self.debug_message = msg;
}
fn debug_message(&self) -> &str {
&self.debug_message
}
fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
}
// Demo form data with different validation rules
struct ValidationDemoData {
fields: Vec<(String, String)>,
}
impl ValidationDemoData {
fn new() -> Self {
Self {
fields: vec![
("👤 Name (max 20)".to_string(), "".to_string()),
("📧 Email (max 50, warn@40)".to_string(), "".to_string()),
("🔑 Password (5-20 chars)".to_string(), "".to_string()),
("🔢 ID (min 3, max 10)".to_string(), "".to_string()),
("📝 Comment (min 10, max 100)".to_string(), "".to_string()),
("🏷️ Tag (max 30, bytes)".to_string(), "".to_string()),
("🌍 Unicode (width, min 2)".to_string(), "".to_string()),
],
}
}
}
impl DataProvider for ValidationDemoData {
fn field_count(&self) -> usize {
self.fields.len()
}
fn field_name(&self, index: usize) -> &str {
&self.fields[index].0
}
fn field_value(&self, index: usize) -> &str {
&self.fields[index].1
}
fn set_field_value(&mut self, index: usize, value: String) {
self.fields[index].1 = value;
}
fn supports_autocomplete(&self, _field_index: usize) -> bool {
false
}
fn display_value(&self, _index: usize) -> Option<&str> {
None
}
// 🎯 NEW: Validation configuration per field
fn validation_config(&self, field_index: usize) -> Option<ValidationConfig> {
match field_index {
0 => Some(ValidationConfig::with_max_length(20)), // Name: simple 20 char limit
1 => Some(
ValidationConfigBuilder::new()
.with_character_limits(
CharacterLimits::new(50).with_warning_threshold(40)
)
.build()
), // Email: 50 chars with warning at 40
2 => Some(
ValidationConfigBuilder::new()
.with_character_limits(CharacterLimits::new_range(5, 20))
.build()
), // Password: must be 5-20 characters (blocks field switching if 1-4 chars)
3 => Some(
ValidationConfigBuilder::new()
.with_character_limits(CharacterLimits::new_range(3, 10))
.build()
), // ID: must be 3-10 characters (blocks field switching if 1-2 chars)
4 => Some(
ValidationConfigBuilder::new()
.with_character_limits(CharacterLimits::new_range(10, 100))
.build()
), // Comment: must be 10-100 characters (blocks field switching if 1-9 chars)
5 => Some(
ValidationConfigBuilder::new()
.with_character_limits(
CharacterLimits::new(30).with_count_mode(CountMode::Bytes)
)
.build()
), // Tag: 30 bytes (useful for UTF-8)
6 => Some(
ValidationConfigBuilder::new()
.with_character_limits(
CharacterLimits::new_range(2, 20).with_count_mode(CountMode::DisplayWidth)
)
.build()
), // Unicode: 2-20 display width (useful for CJK characters, blocks if 1 char)
_ => None,
}
}
}
/// Handle key presses with validation-focused commands
fn handle_key_press(
key: KeyCode,
modifiers: KeyModifiers,
editor: &mut ValidationFormEditor<ValidationDemoData>,
) -> 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();
}
}
// === VALIDATION COMMANDS ===
(AppMode::ReadOnly, KeyCode::Char('v'), _) => {
editor.validate_current_field();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('V'), _) => {
editor.validate_all_fields();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('c'), _) => {
editor.clear_validation_results();
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();
}
// === 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 summary = editor.editor.validation_summary();
editor.set_debug_message(format!(
"Field {}/{}, Pos {}, Mode: {:?}, Validation: {} fields configured, {} validated",
editor.current_field() + 1,
editor.data_provider().field_count(),
editor.cursor_position(),
editor.mode(),
summary.total_fields,
summary.validated_fields
));
}
_ => {
if editor.has_pending_command() {
editor.clear_command_buffer();
editor.set_debug_message("Invalid command sequence".to_string());
}
}
}
Ok(true)
}
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut editor: ValidationFormEditor<ValidationDemoData>,
) -> 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: &ValidationFormEditor<ValidationDemoData>) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(12)])
.split(f.area());
render_enhanced_canvas(f, chunks[0], editor);
render_validation_status(f, chunks[1], editor);
}
fn render_enhanced_canvas(
f: &mut Frame,
area: Rect,
editor: &ValidationFormEditor<ValidationDemoData>,
) {
render_canvas_default(f, area, &editor.editor);
}
fn render_validation_status(
f: &mut Frame,
area: Rect,
editor: &ValidationFormEditor<ValidationDemoData>,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Status bar
Constraint::Length(4), // Validation summary
Constraint::Length(5), // Help
])
.split(area);
// Status bar with validation information
let mode_text = match editor.mode() {
AppMode::Edit => "INSERT",
AppMode::ReadOnly => "NORMAL",
_ => "OTHER",
};
let validation_status = editor.get_validation_status();
let status_text = if editor.has_pending_command() {
format!("-- {} -- {} [{}] | Validation: {}",
mode_text, editor.debug_message(), editor.get_command_buffer(), validation_status)
} else if editor.has_unsaved_changes() {
format!("-- {} -- [Modified] {} | Validation: {}",
mode_text, editor.debug_message(), validation_status)
} else {
format!("-- {} -- {} | Validation: {}",
mode_text, editor.debug_message(), validation_status)
};
let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("🔍 Validation Status"));
f.render_widget(status, chunks[0]);
// Validation summary with field switching info
let summary = editor.editor.validation_summary();
let summary_text = if editor.validation_enabled {
let switch_info = if editor.field_switch_blocked {
format!("\n🚫 Field switching blocked: {}",
editor.block_reason.as_deref().unwrap_or("Unknown reason"))
} else {
let (can_switch, reason) = editor.check_field_switch_allowed();
if !can_switch {
format!("\n⚠️ Field switching will be blocked: {}",
reason.as_deref().unwrap_or("Unknown reason"))
} else {
"\n✅ Field switching allowed".to_string()
}
};
format!(
"📊 Validation Summary: {} fields configured, {} validated{}\n\
✅ Valid: {} ⚠️ Warnings: {} ❌ Errors: {} 📈 Progress: {:.0}%",
summary.total_fields,
summary.validated_fields,
switch_info,
summary.valid_fields,
summary.warning_fields,
summary.error_fields,
summary.completion_percentage() * 100.0
)
} else {
"❌ Validation is currently DISABLED\nPress F1 to enable validation".to_string()
};
let summary_style = if summary.has_errors() {
Style::default().fg(Color::Red)
} else if summary.has_warnings() {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Green)
};
let validation_summary = Paragraph::new(summary_text)
.block(Block::default().borders(Borders::ALL).title("📈 Validation Overview"))
.style(summary_style)
.wrap(Wrap { trim: true });
f.render_widget(validation_summary, chunks[1]);
// Enhanced help text
let help_text = match editor.mode() {
AppMode::ReadOnly => {
"🔍 VALIDATION DEMO: Different fields have different limits!\n\
Fields with MINIMUM requirements will block field switching if too short!\n\
Movement: hjkl/arrows=move, Tab/Shift+Tab=fields\n\
Edit: i/a/A=insert modes, Esc=normal\n\
Validation: v=validate current, V=validate all, c=clear results, F1=toggle\n\
?=info, Ctrl+C/Ctrl+Q=quit"
}
AppMode::Edit => {
"✏️ INSERT MODE - Type to test validation limits!\n\
Some fields have MINIMUM character requirements!\n\
Try typing 1-2 chars in Password/ID/Comment fields, then try to switch!\n\
arrows=move, Backspace/Del=delete, Esc=normal, Tab=next field\n\
Field switching may be BLOCKED if minimum requirements not met!"
}
_ => "🔍 Validation Demo Active!"
};
let help = Paragraph::new(help_text)
.block(Block::default().borders(Borders::ALL).title("🚀 Validation 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 Validation Demo");
println!("✅ validation feature: ENABLED");
println!("🚀 Field validation: ACTIVE");
println!("🚫 Field switching validation: ACTIVE");
println!("📊 Try typing in fields with minimum requirements!");
println!(" - Password (min 5): Type 1-4 chars, then try to switch fields");
println!(" - ID (min 3): Type 1-2 chars, then try to switch fields");
println!(" - Comment (min 10): Type 1-9 chars, then try to switch fields");
println!(" - Unicode (min 2): Type 1 char, then try to switch fields");
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 = ValidationDemoData::new();
let editor = ValidationFormEditor::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!("🔍 Validation demo completed!");
Ok(())
}

View File

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

View File

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

View File

@@ -52,7 +52,30 @@ pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
for i in 0..field_count {
fields.push(data_provider.field_name(i));
inputs.push(data_provider.field_value(i).to_string());
// Use display text that applies masks if configured
#[cfg(feature = "validation")]
{
if i == editor.current_field() {
inputs.push(editor.current_display_text());
} else {
// For non-current fields, we need to apply mask manually
let raw = data_provider.field_value(i);
if let Some(cfg) = editor.ui_state().validation_state().get_field_config(i) {
if let Some(mask) = &cfg.display_mask {
inputs.push(mask.apply_to_display(raw));
} else {
inputs.push(raw.to_string());
}
} else {
inputs.push(raw.to_string());
}
}
}
#[cfg(not(feature = "validation"))]
{
inputs.push(data_provider.field_value(i).to_string());
}
}
let current_field_idx = ui_state.current_field();
@@ -66,13 +89,46 @@ pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
&inputs,
theme,
is_edit_mode,
highlight_state, // Now using the actual highlight state!
ui_state.cursor_position(),
highlight_state,
editor.display_cursor_position(), // Use display cursor position for masks
false, // TODO: track unsaved changes in editor
|i| {
data_provider.display_value(i).unwrap_or(data_provider.field_value(i)).to_string()
// Get display value for field i
#[cfg(feature = "validation")]
{
if i == editor.current_field() {
editor.current_display_text()
} else {
let raw = data_provider.field_value(i);
if let Some(cfg) = editor.ui_state().validation_state().get_field_config(i) {
if let Some(mask) = &cfg.display_mask {
mask.apply_to_display(raw)
} else {
raw.to_string()
}
} else {
raw.to_string()
}
}
}
#[cfg(not(feature = "validation"))]
{
data_provider.field_value(i).to_string()
}
},
|i| {
// Check if field has display override (mask)
#[cfg(feature = "validation")]
{
editor.ui_state().validation_state().get_field_config(i)
.and_then(|cfg| cfg.display_mask.as_ref())
.is_some()
}
#[cfg(not(feature = "validation"))]
{
false
}
},
|i| data_provider.display_value(i).is_some(),
)
}
@@ -245,7 +301,7 @@ fn apply_highlighting<'a, T: CanvasTheme>(
current_cursor_pos: usize,
highlight_state: &HighlightState,
theme: &T,
is_active: bool,
_is_active: bool,
) -> Line<'a> {
let text_len = text.chars().count();
@@ -257,10 +313,10 @@ fn apply_highlighting<'a, T: CanvasTheme>(
))
}
HighlightState::Characterwise { anchor } => {
apply_characterwise_highlighting(text, text_len, field_index, current_field_idx, current_cursor_pos, anchor, theme, is_active)
apply_characterwise_highlighting(text, text_len, field_index, current_field_idx, current_cursor_pos, anchor, theme, _is_active)
}
HighlightState::Linewise { anchor_line } => {
apply_linewise_highlighting(text, field_index, current_field_idx, anchor_line, theme, is_active)
apply_linewise_highlighting(text, field_index, current_field_idx, anchor_line, theme, _is_active)
}
}
}
@@ -275,7 +331,7 @@ fn apply_characterwise_highlighting<'a, T: CanvasTheme>(
current_cursor_pos: usize,
anchor: &(usize, usize),
theme: &T,
is_active: bool,
_is_active: bool,
) -> Line<'a> {
let (anchor_field, anchor_char) = *anchor;
let start_field = min(anchor_field, *current_field_idx);
@@ -378,7 +434,7 @@ fn apply_linewise_highlighting<'a, T: CanvasTheme>(
current_field_idx: &usize,
anchor_line: &usize,
theme: &T,
is_active: bool,
_is_active: bool,
) -> Line<'a> {
let start_field = min(*anchor_line, *current_field_idx);
let end_field = max(*anchor_line, *current_field_idx);
@@ -411,13 +467,15 @@ fn set_cursor_position(
current_cursor_pos: usize,
has_display_override: bool,
) {
let cursor_x = if has_display_override {
field_rect.x + text.chars().count() as u16
} else {
field_rect.x + current_cursor_pos as u16
};
// BUG FIX: Use the correct display cursor position, not end of text
let cursor_x = field_rect.x + current_cursor_pos as u16;
let cursor_y = field_rect.y;
f.set_cursor_position((cursor_x, cursor_y));
// SAFETY: Ensure cursor doesn't go beyond field bounds
let max_cursor_x = field_rect.x + field_rect.width.saturating_sub(1);
let safe_cursor_x = cursor_x.min(max_cursor_x);
f.set_cursor_position((safe_cursor_x, cursor_y));
}
/// Set default theme if custom not specified

View File

@@ -10,15 +10,19 @@ pub struct EditorState {
pub(crate) current_field: usize,
pub(crate) cursor_pos: usize,
pub(crate) ideal_cursor_column: usize,
// Mode state
// Mode state
pub(crate) current_mode: AppMode,
// Autocomplete state
pub(crate) autocomplete: AutocompleteUIState,
// Selection state (for vim visual mode)
pub(crate) selection: SelectionState,
// Validation state (only available with validation feature)
#[cfg(feature = "validation")]
pub(crate) validation: crate::validation::ValidationState,
}
#[derive(Debug, Clone)]
@@ -50,52 +54,61 @@ impl EditorState {
active_field: None,
},
selection: SelectionState::None,
#[cfg(feature = "validation")]
validation: crate::validation::ValidationState::new(),
}
}
// ===================================================================
// READ-ONLY ACCESS: User can fetch UI state for compatibility
// ===================================================================
/// Get current field index (for user's business logic)
pub fn current_field(&self) -> usize {
self.current_field
}
/// Get current cursor position (for user's business logic)
/// Get current cursor position (for user's business logic)
pub fn cursor_position(&self) -> usize {
self.cursor_pos
}
/// Get ideal cursor column (for vim-like behavior)
pub fn ideal_cursor_column(&self) -> usize { // ADD THIS
pub fn ideal_cursor_column(&self) -> usize {
self.ideal_cursor_column
}
/// Get current mode (for user's business logic)
pub fn mode(&self) -> AppMode {
self.current_mode
}
/// Check if autocomplete is active (for user's business logic)
pub fn is_autocomplete_active(&self) -> bool {
self.autocomplete.is_active
}
/// Check if autocomplete is loading (for user's business logic)
pub fn is_autocomplete_loading(&self) -> bool {
self.autocomplete.is_loading
}
/// Get selection state (for user's business logic)
pub fn selection_state(&self) -> &SelectionState {
&self.selection
}
/// Get validation state (for user's business logic)
/// Only available when the 'validation' feature is enabled
#[cfg(feature = "validation")]
pub fn validation_state(&self) -> &crate::validation::ValidationState {
&self.validation
}
// ===================================================================
// INTERNAL MUTATIONS: Only library modifies these
// ===================================================================
pub(crate) fn move_to_field(&mut self, field_index: usize, field_count: usize) {
if field_index < field_count {
self.current_field = field_index;
@@ -103,7 +116,7 @@ impl EditorState {
self.cursor_pos = 0;
}
}
pub(crate) fn set_cursor(&mut self, position: usize, max_position: usize, for_edit_mode: bool) {
if for_edit_mode {
// Edit mode: can go past end for insertion
@@ -114,14 +127,14 @@ impl EditorState {
}
self.ideal_cursor_column = self.cursor_pos;
}
pub(crate) fn activate_autocomplete(&mut self, field_index: usize) {
self.autocomplete.is_active = true;
self.autocomplete.is_loading = true;
self.autocomplete.active_field = Some(field_index);
self.autocomplete.selected_index = None;
}
pub(crate) fn deactivate_autocomplete(&mut self) {
self.autocomplete.is_active = false;
self.autocomplete.is_loading = false;

View File

@@ -27,6 +27,13 @@ pub trait DataProvider {
fn display_value(&self, _index: usize) -> Option<&str> {
None // Default: use actual value
}
/// Get validation configuration for a field (optional)
/// Only available when the 'validation' feature is enabled
#[cfg(feature = "validation")]
fn validation_config(&self, _field_index: usize) -> Option<crate::validation::ValidationConfig> {
None
}
}
/// Optional: User implements this for autocomplete data

View File

@@ -3,8 +3,7 @@
#[cfg(feature = "cursor-style")]
use crate::canvas::CursorManager;
#[cfg(feature = "cursor-style")]
use crossterm;
use anyhow::Result;
use crate::canvas::state::EditorState;
@@ -26,10 +25,29 @@ pub struct FormEditor<D: DataProvider> {
impl<D: DataProvider> FormEditor<D> {
pub fn new(data_provider: D) -> Self {
Self {
let mut editor = Self {
ui_state: EditorState::new(),
data_provider,
suggestions: Vec::new(),
};
// Initialize validation configurations if validation feature is enabled
#[cfg(feature = "validation")]
{
editor.initialize_validation();
}
editor
}
/// Initialize validation configurations from data provider
#[cfg(feature = "validation")]
fn initialize_validation(&mut self) {
let field_count = self.data_provider.field_count();
for field_index in 0..field_count {
if let Some(config) = self.data_provider.validation_config(field_index) {
self.ui_state.validation.set_field_config(field_index, config);
}
}
}
@@ -67,6 +85,24 @@ impl<D: DataProvider> FormEditor<D> {
}
}
/// Get current field text for display, applying mask if configured
#[cfg(feature = "validation")]
pub fn current_display_text(&self) -> String {
let field_index = self.ui_state.current_field;
let raw = if field_index < self.data_provider.field_count() {
self.data_provider.field_value(field_index)
} else {
""
};
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
if let Some(mask) = &cfg.display_mask {
return mask.apply_to_display(raw);
}
}
raw.to_string()
}
/// Get reference to UI state for rendering
pub fn ui_state(&self) -> &EditorState {
&self.ui_state
@@ -82,53 +118,225 @@ impl<D: DataProvider> FormEditor<D> {
&self.suggestions
}
/// Get validation state (for user's business logic)
/// Only available when the 'validation' feature is enabled
#[cfg(feature = "validation")]
pub fn validation_state(&self) -> &crate::validation::ValidationState {
self.ui_state.validation_state()
}
/// Get validation result for current field
#[cfg(feature = "validation")]
pub fn current_field_validation(&self) -> Option<&crate::validation::ValidationResult> {
self.ui_state.validation.get_field_result(self.ui_state.current_field)
}
/// Get validation result for specific field
#[cfg(feature = "validation")]
pub fn field_validation(&self, field_index: usize) -> Option<&crate::validation::ValidationResult> {
self.ui_state.validation.get_field_result(field_index)
}
// ===================================================================
// SYNC OPERATIONS: No async needed for basic editing
// ===================================================================
/// Handle character insertion
/// Handle character insertion with proper mask/limit coordination
pub fn insert_char(&mut self, ch: char) -> Result<()> {
if self.ui_state.current_mode != AppMode::Edit {
return Ok(()); // Ignore in non-edit modes
}
let field_index = self.ui_state.current_field;
let cursor_pos = self.ui_state.cursor_pos;
let raw_cursor_pos = self.ui_state.cursor_pos;
let current_raw_text = self.data_provider.field_value(field_index);
// Get current text from user
let mut current_text = self.data_provider.field_value(field_index).to_string();
// 🔥 CRITICAL FIX 1: Check mask constraints FIRST
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
if let Some(mask) = &cfg.display_mask {
// Get display cursor position
let display_cursor_pos = mask.raw_pos_to_display_pos(raw_cursor_pos);
// ❌ PREVENT BUG: Reject input if cursor is beyond mask pattern
if display_cursor_pos >= mask.pattern().len() {
tracing::debug!(
"Character insertion rejected: cursor beyond mask pattern length"
);
return Ok(()); // Silently reject - user can't type beyond mask
}
// ❌ PREVENT BUG: Reject input if cursor is on a separator position
if !mask.is_input_position(display_cursor_pos) {
tracing::debug!(
"Character insertion rejected: cursor on separator position {}",
display_cursor_pos
);
return Ok(()); // Silently reject - can't type on separators
}
// ❌ PREVENT BUG: Check if we're at max input positions for this mask
let input_char_count = (0..mask.pattern().len())
.filter(|&pos| mask.is_input_position(pos))
.count();
if current_raw_text.len() >= input_char_count {
tracing::debug!(
"Character insertion rejected: mask pattern full ({} input positions)",
input_char_count
);
return Ok(()); // Silently reject - mask is full
}
}
}
}
// Insert character
current_text.insert(cursor_pos, ch);
// 🔥 CRITICAL FIX 2: Validate character insertion with mask awareness
#[cfg(feature = "validation")]
{
let validation_result = self.ui_state.validation.validate_char_insertion(
field_index,
current_raw_text,
raw_cursor_pos,
ch,
);
// Update user's data
self.data_provider.set_field_value(field_index, current_text);
// Reject input if validation failed with error
if !validation_result.is_acceptable() {
tracing::debug!(
"Character insertion rejected for field {}: {:?}",
field_index,
validation_result
);
return Ok(()); // Silently reject invalid input
}
}
// Update library's UI state
self.ui_state.cursor_pos += 1;
// 🔥 CRITICAL FIX 3: Validate the insertion won't break display/limit coordination
let new_raw_text = {
let mut temp = current_raw_text.to_string();
temp.insert(raw_cursor_pos, ch);
temp
};
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
// Check character limits on the new raw text
if let Some(limits) = &cfg.character_limits {
if let Some(result) = limits.validate_content(&new_raw_text) {
if !result.is_acceptable() {
tracing::debug!(
"Character insertion rejected: would exceed character limits"
);
return Ok(()); // Silently reject - would exceed limits
}
}
}
// Check that mask can handle the new raw text length
if let Some(mask) = &cfg.display_mask {
let input_positions = (0..mask.pattern().len())
.filter(|&pos| mask.is_input_position(pos))
.count();
if new_raw_text.len() > input_positions {
tracing::debug!(
"Character insertion rejected: raw text length {} exceeds mask input positions {}",
new_raw_text.len(),
input_positions
);
return Ok(()); // Silently reject - mask can't handle this length
}
}
}
}
// ✅ ALL CHECKS PASSED: Safe to insert character
self.data_provider.set_field_value(field_index, new_raw_text);
// 🔥 CRITICAL FIX 4: Update cursor position correctly for mask context
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
if let Some(mask) = &cfg.display_mask {
// Move to next input position, skipping separators
let new_raw_pos = raw_cursor_pos + 1;
let display_pos = mask.raw_pos_to_display_pos(new_raw_pos);
let next_input_pos = mask.next_input_position(display_pos);
let next_raw_pos = mask.display_pos_to_raw_pos(next_input_pos);
self.ui_state.cursor_pos = next_raw_pos;
self.ui_state.ideal_cursor_column = next_raw_pos;
return Ok(());
}
}
}
// No mask: simple increment
self.ui_state.cursor_pos = raw_cursor_pos + 1;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
Ok(())
}
/// Handle cursor movement
/// Handle cursor movement left - skips mask separator positions
pub fn move_left(&mut self) {
if self.ui_state.cursor_pos > 0 {
self.ui_state.cursor_pos -= 1;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
if self.ui_state.cursor_pos == 0 {
return;
}
let field_index = self.ui_state.current_field;
let mut new_pos = self.ui_state.cursor_pos - 1;
// Skip mask separator positions if configured
#[cfg(feature = "validation")]
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
if let Some(mask) = &cfg.display_mask {
// Convert to display position, find previous input position, convert back
let display_pos = mask.raw_pos_to_display_pos(new_pos);
if let Some(prev_input_display_pos) = mask.prev_input_position(display_pos) {
new_pos = mask.display_pos_to_raw_pos(prev_input_display_pos);
}
}
}
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
}
/// Handle cursor movement right - skips mask separator positions
pub fn move_right(&mut self) {
let current_text = self.current_text();
let max_pos = if self.ui_state.current_mode == AppMode::Edit {
current_text.len() // Edit mode: can go past end
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let max_pos = if is_edit_mode {
current_text.len()
} else {
current_text.len().saturating_sub(1) // ReadOnly: stay in bounds
current_text.len().saturating_sub(1)
};
if self.ui_state.cursor_pos < max_pos {
self.ui_state.cursor_pos += 1;
if self.ui_state.cursor_pos >= max_pos {
return;
}
let field_index = self.ui_state.current_field;
let mut new_pos = self.ui_state.cursor_pos + 1;
// Skip mask separator positions if configured
#[cfg(feature = "validation")]
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
if let Some(mask) = &cfg.display_mask {
// Convert to display position, find next input position, convert back
let display_pos = mask.raw_pos_to_display_pos(new_pos);
let next_input_display_pos = mask.next_input_position(display_pos);
new_pos = mask.display_pos_to_raw_pos(next_input_display_pos);
new_pos = new_pos.min(max_pos);
}
}
if new_pos <= max_pos {
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
}
}
@@ -137,6 +345,19 @@ impl<D: DataProvider> FormEditor<D> {
pub fn move_to_next_field(&mut self) {
let field_count = self.data_provider.field_count();
let next_field = (self.ui_state.current_field + 1) % field_count;
// Validate current field content before moving if validation is enabled
#[cfg(feature = "validation")]
{
let current_text = self.current_text().to_string(); // Convert to String to avoid borrow conflicts
let _validation_result = self.ui_state.validation.validate_field_content(
self.ui_state.current_field,
&current_text,
);
// Note: We don't prevent field switching on validation failure,
// just record the validation state
}
self.ui_state.move_to_field(next_field, field_count);
// Clamp cursor to new field
@@ -166,7 +387,7 @@ impl<D: DataProvider> FormEditor<D> {
if new_mode != AppMode::Highlight {
self.ui_state.selection = SelectionState::None;
}
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(new_mode);
@@ -178,22 +399,95 @@ impl<D: DataProvider> FormEditor<D> {
/// Enter edit mode with cursor positioned for append (vim 'a' command)
pub fn enter_append_mode(&mut self) {
let current_text = self.current_text();
// Calculate append position: always move right, even at line end
let append_pos = if current_text.is_empty() {
0
} else {
(self.ui_state.cursor_pos + 1).min(current_text.len())
};
// Set cursor position for append
self.ui_state.cursor_pos = append_pos;
self.ui_state.ideal_cursor_column = append_pos;
// Enter edit mode (which will update cursor style)
self.set_mode(AppMode::Edit);
}
// ===================================================================
// VALIDATION METHODS (only available with validation feature)
// ===================================================================
/// Enable or disable validation
#[cfg(feature = "validation")]
pub fn set_validation_enabled(&mut self, enabled: bool) {
self.ui_state.validation.set_enabled(enabled);
}
/// Check if validation is enabled
#[cfg(feature = "validation")]
pub fn is_validation_enabled(&self) -> bool {
self.ui_state.validation.is_enabled()
}
/// Set validation configuration for a specific field
#[cfg(feature = "validation")]
pub fn set_field_validation(&mut self, field_index: usize, config: crate::validation::ValidationConfig) {
self.ui_state.validation.set_field_config(field_index, config);
}
/// Remove validation configuration for a specific field
#[cfg(feature = "validation")]
pub fn remove_field_validation(&mut self, field_index: usize) {
self.ui_state.validation.remove_field_config(field_index);
}
/// Manually validate current field content
#[cfg(feature = "validation")]
pub fn validate_current_field(&mut self) -> crate::validation::ValidationResult {
let field_index = self.ui_state.current_field;
let current_text = self.current_text().to_string();
self.ui_state.validation.validate_field_content(field_index, &current_text)
}
/// Manually validate specific field content
#[cfg(feature = "validation")]
pub fn validate_field(&mut self, field_index: usize) -> Option<crate::validation::ValidationResult> {
if field_index < self.data_provider.field_count() {
let text = self.data_provider.field_value(field_index).to_string();
Some(self.ui_state.validation.validate_field_content(field_index, &text))
} else {
None
}
}
/// Clear validation results for all fields
#[cfg(feature = "validation")]
pub fn clear_validation_results(&mut self) {
self.ui_state.validation.clear_all_results();
}
/// Get validation summary for all fields
#[cfg(feature = "validation")]
pub fn validation_summary(&self) -> crate::validation::ValidationSummary {
self.ui_state.validation.summary()
}
/// Check if field switching is allowed from current field
#[cfg(feature = "validation")]
pub fn can_switch_fields(&self) -> bool {
let current_text = self.current_text();
self.ui_state.validation.allows_field_switch(self.ui_state.current_field, current_text)
}
/// Get reason why field switching is blocked (if any)
#[cfg(feature = "validation")]
pub fn field_switch_block_reason(&self) -> Option<String> {
let current_text = self.current_text();
self.ui_state.validation.field_switch_block_reason(self.ui_state.current_field, current_text)
}
// ===================================================================
// ASYNC OPERATIONS: Only autocomplete needs async
// ===================================================================
@@ -256,6 +550,15 @@ impl<D: DataProvider> FormEditor<D> {
self.ui_state.deactivate_autocomplete();
self.suggestions.clear();
// Validate the new content if validation is enabled
#[cfg(feature = "validation")]
{
let _validation_result = self.ui_state.validation.validate_field_content(
field_index,
&suggestion.value_to_store,
);
}
return Some(suggestion.display_text);
}
}
@@ -263,35 +566,81 @@ impl<D: DataProvider> FormEditor<D> {
}
// ===================================================================
// ADD THESE MISSING MOVEMENT METHODS
// MOVEMENT METHODS (keeping existing implementations)
// ===================================================================
/// Move to previous field (vim k / up arrow)
pub fn move_up(&mut self) {
pub fn move_up(&mut self) -> Result<()> {
let field_count = self.data_provider.field_count();
if field_count == 0 {
return;
return Ok(());
}
// Check if field switching is allowed (minimum character enforcement)
#[cfg(feature = "validation")]
{
let current_text = self.current_text();
if !self.ui_state.validation.allows_field_switch(self.ui_state.current_field, current_text) {
if let Some(reason) = self.ui_state.validation.field_switch_block_reason(self.ui_state.current_field, current_text) {
tracing::debug!("Field switch blocked: {}", reason);
return Err(anyhow::anyhow!("Cannot switch fields: {}", reason));
}
}
}
// Validate current field before moving
#[cfg(feature = "validation")]
{
let current_text = self.current_text().to_string(); // Convert to String to avoid borrow conflicts
let _validation_result = self.ui_state.validation.validate_field_content(
self.ui_state.current_field,
&current_text,
);
}
let current_field = self.ui_state.current_field;
let new_field = current_field.saturating_sub(1);
self.ui_state.move_to_field(new_field, field_count);
self.clamp_cursor_to_current_field();
Ok(())
}
/// Move to next field (vim j / down arrow)
pub fn move_down(&mut self) {
/// Move to next field (vim j / down arrow)
pub fn move_down(&mut self) -> Result<()> {
let field_count = self.data_provider.field_count();
if field_count == 0 {
return;
return Ok(());
}
// Check if field switching is allowed (minimum character enforcement)
#[cfg(feature = "validation")]
{
let current_text = self.current_text();
if !self.ui_state.validation.allows_field_switch(self.ui_state.current_field, current_text) {
if let Some(reason) = self.ui_state.validation.field_switch_block_reason(self.ui_state.current_field, current_text) {
tracing::debug!("Field switch blocked: {}", reason);
return Err(anyhow::anyhow!("Cannot switch fields: {}", reason));
}
}
}
// Validate current field before moving
#[cfg(feature = "validation")]
{
let current_text = self.current_text().to_string(); // Convert to String to avoid borrow conflicts
let _validation_result = self.ui_state.validation.validate_field_content(
self.ui_state.current_field,
&current_text,
);
}
let current_field = self.ui_state.current_field;
let new_field = (current_field + 1).min(field_count - 1);
self.ui_state.move_to_field(new_field, field_count);
self.clamp_cursor_to_current_field();
Ok(())
}
/// Move to first field (vim gg)
@@ -300,7 +649,7 @@ impl<D: DataProvider> FormEditor<D> {
if field_count == 0 {
return;
}
self.ui_state.move_to_field(0, field_count);
self.clamp_cursor_to_current_field();
}
@@ -311,20 +660,20 @@ impl<D: DataProvider> FormEditor<D> {
if field_count == 0 {
return;
}
let last_field = field_count - 1;
self.ui_state.move_to_field(last_field, field_count);
self.clamp_cursor_to_current_field();
}
/// Move to previous field (alternative to move_up)
pub fn prev_field(&mut self) {
self.move_up();
pub fn prev_field(&mut self) -> Result<()> {
self.move_up()
}
/// Move to next field (alternative to move_down)
pub fn next_field(&mut self) {
self.move_down();
/// Move to next field (alternative to move_down)
pub fn next_field(&mut self) -> Result<()> {
self.move_down()
}
/// Move to start of current field (vim 0)
@@ -340,7 +689,7 @@ impl<D: DataProvider> FormEditor<D> {
use crate::canvas::actions::movement::line::line_end_position;
let current_text = self.current_text();
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let new_pos = line_end_position(current_text, is_edit_mode);
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
@@ -350,21 +699,21 @@ impl<D: DataProvider> FormEditor<D> {
pub fn move_word_next(&mut self) {
use crate::canvas::actions::movement::word::find_next_word_start;
let current_text = self.current_text();
if current_text.is_empty() {
return;
}
let new_pos = find_next_word_start(current_text, self.ui_state.cursor_pos);
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
// Clamp to valid bounds for current mode
let final_pos = if is_edit_mode {
new_pos.min(current_text.len())
} else {
new_pos.min(current_text.len().saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
@@ -373,11 +722,11 @@ impl<D: DataProvider> FormEditor<D> {
pub fn move_word_prev(&mut self) {
use crate::canvas::actions::movement::word::find_prev_word_start;
let current_text = self.current_text();
if current_text.is_empty() {
return;
}
let new_pos = find_prev_word_start(current_text, self.ui_state.cursor_pos);
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
@@ -387,21 +736,21 @@ impl<D: DataProvider> FormEditor<D> {
pub fn move_word_end(&mut self) {
use crate::canvas::actions::movement::word::find_word_end;
let current_text = self.current_text();
if current_text.is_empty() {
return;
}
let current_pos = self.ui_state.cursor_pos;
let new_pos = find_word_end(current_text, current_pos);
// If we didn't move, try next word
let final_pos = if new_pos == current_pos && current_pos + 1 < current_text.len() {
find_word_end(current_text, current_pos + 1)
} else {
new_pos
};
// Clamp for read-only mode
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let clamped_pos = if is_edit_mode {
@@ -409,7 +758,7 @@ impl<D: DataProvider> FormEditor<D> {
} else {
final_pos.min(current_text.len().saturating_sub(1))
};
self.ui_state.cursor_pos = clamped_pos;
self.ui_state.ideal_cursor_column = clamped_pos;
}
@@ -418,11 +767,11 @@ impl<D: DataProvider> FormEditor<D> {
pub fn move_word_end_prev(&mut self) {
use crate::canvas::actions::movement::word::find_prev_word_end;
let current_text = self.current_text();
if current_text.is_empty() {
return;
}
let new_pos = find_prev_word_end(current_text, self.ui_state.cursor_pos);
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
@@ -433,21 +782,30 @@ impl<D: DataProvider> FormEditor<D> {
if self.ui_state.current_mode != AppMode::Edit {
return Ok(()); // Silently ignore in non-edit modes
}
if self.ui_state.cursor_pos == 0 {
return Ok(()); // Nothing to delete
}
let field_index = self.ui_state.current_field;
let mut current_text = self.data_provider.field_value(field_index).to_string();
if self.ui_state.cursor_pos <= current_text.len() {
current_text.remove(self.ui_state.cursor_pos - 1);
self.data_provider.set_field_value(field_index, current_text);
self.data_provider.set_field_value(field_index, current_text.clone());
self.ui_state.cursor_pos -= 1;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
// Validate the new content if validation is enabled
#[cfg(feature = "validation")]
{
let _validation_result = self.ui_state.validation.validate_field_content(
field_index,
&current_text,
);
}
}
Ok(())
}
@@ -456,21 +814,40 @@ impl<D: DataProvider> FormEditor<D> {
if self.ui_state.current_mode != AppMode::Edit {
return Ok(()); // Silently ignore in non-edit modes
}
let field_index = self.ui_state.current_field;
let mut current_text = self.data_provider.field_value(field_index).to_string();
if self.ui_state.cursor_pos < current_text.len() {
current_text.remove(self.ui_state.cursor_pos);
self.data_provider.set_field_value(field_index, current_text);
self.data_provider.set_field_value(field_index, current_text.clone());
// Validate the new content if validation is enabled
#[cfg(feature = "validation")]
{
let _validation_result = self.ui_state.validation.validate_field_content(
field_index,
&current_text,
);
}
}
Ok(())
}
/// Exit edit mode to read-only mode (vim Escape)
// TODO this is still flickering, I have no clue how to fix it
pub fn exit_edit_mode(&mut self) {
pub fn exit_edit_mode(&mut self) -> Result<()> {
// Validate current field content when exiting edit mode
#[cfg(feature = "validation")]
{
let current_text = self.current_text();
if !self.ui_state.validation.allows_field_switch(self.ui_state.current_field, current_text) {
if let Some(reason) = self.ui_state.validation.field_switch_block_reason(self.ui_state.current_field, current_text) {
return Err(anyhow::anyhow!("Cannot exit edit mode: {}", reason));
}
}
}
// Adjust cursor position when transitioning from edit to normal mode
let current_text = self.current_text();
if !current_text.is_empty() {
@@ -485,6 +862,8 @@ impl<D: DataProvider> FormEditor<D> {
self.set_mode(AppMode::ReadOnly);
// Deactivate autocomplete when exiting edit mode
self.ui_state.deactivate_autocomplete();
Ok(())
}
/// Enter edit mode from read-only mode (vim i/a/o)
@@ -500,44 +879,62 @@ impl<D: DataProvider> FormEditor<D> {
fn clamp_cursor_to_current_field(&mut self) {
let current_text = self.current_text();
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
use crate::canvas::actions::movement::line::safe_cursor_position;
let safe_pos = safe_cursor_position(
current_text,
self.ui_state.ideal_cursor_column,
current_text,
self.ui_state.ideal_cursor_column,
is_edit_mode
);
self.ui_state.cursor_pos = safe_pos;
}
/// Set the value of the current field
pub fn set_current_field_value(&mut self, value: String) {
let field_index = self.ui_state.current_field;
self.data_provider.set_field_value(field_index, value);
self.data_provider.set_field_value(field_index, value.clone());
// Reset cursor to start of field
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
// Validate the new content if validation is enabled
#[cfg(feature = "validation")]
{
let _validation_result = self.ui_state.validation.validate_field_content(
field_index,
&value,
);
}
}
/// Set the value of a specific field by index
pub fn set_field_value(&mut self, field_index: usize, value: String) {
if field_index < self.data_provider.field_count() {
self.data_provider.set_field_value(field_index, value);
self.data_provider.set_field_value(field_index, value.clone());
// If we're modifying the current field, reset cursor
if field_index == self.ui_state.current_field {
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
}
// Validate the new content if validation is enabled
#[cfg(feature = "validation")]
{
let _validation_result = self.ui_state.validation.validate_field_content(
field_index,
&value,
);
}
}
}
/// Clear the current field (set to empty string)
pub fn clear_current_field(&mut self) {
self.set_current_field_value(String::new());
}
/// Get mutable access to data provider (for advanced operations)
pub fn data_provider_mut(&mut self) -> &mut D {
&mut self.data_provider
@@ -547,39 +944,46 @@ impl<D: DataProvider> FormEditor<D> {
pub fn set_cursor_position(&mut self, position: usize) {
let current_text = self.current_text();
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
// Clamp to valid bounds for current mode
let max_pos = if is_edit_mode {
current_text.len() // Edit mode: can go past end
} else {
current_text.len().saturating_sub(1).max(0) // Read-only: stay within text
};
let clamped_pos = position.min(max_pos);
// Update cursor position directly
self.ui_state.cursor_pos = clamped_pos;
self.ui_state.ideal_cursor_column = clamped_pos;
}
/// Get cursor position for display (respects mode-specific positioning rules)
/// Get cursor position for display (maps raw cursor to display position with mask)
pub fn display_cursor_position(&self) -> usize {
let current_text = self.current_text();
match self.ui_state.current_mode {
AppMode::Edit => {
// Edit mode: cursor can be past end of text
self.ui_state.cursor_pos.min(current_text.len())
}
let raw_pos = match self.ui_state.current_mode {
AppMode::Edit => self.ui_state.cursor_pos.min(current_text.len()),
_ => {
// Normal/other modes: cursor must be on a character
if current_text.is_empty() {
0
} else {
self.ui_state.cursor_pos.min(current_text.len().saturating_sub(1))
}
}
};
#[cfg(feature = "validation")]
{
let field_index = self.ui_state.current_field;
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
if let Some(mask) = &cfg.display_mask {
return mask.raw_pos_to_display_pos(self.ui_state.cursor_pos);
}
}
}
self.ui_state.cursor_pos
}
/// Cleanup cursor style (call this when shutting down)
@@ -606,7 +1010,7 @@ impl<D: DataProvider> FormEditor<D> {
self.ui_state.selection = SelectionState::Characterwise {
anchor: (self.ui_state.current_field, self.ui_state.cursor_pos),
};
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(AppMode::Highlight);
@@ -621,7 +1025,7 @@ impl<D: DataProvider> FormEditor<D> {
self.ui_state.selection = SelectionState::Linewise {
anchor_field: self.ui_state.current_field,
};
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(AppMode::Highlight);
@@ -634,7 +1038,7 @@ impl<D: DataProvider> FormEditor<D> {
if self.ui_state.current_mode == AppMode::Highlight {
self.ui_state.current_mode = AppMode::ReadOnly;
self.ui_state.selection = SelectionState::None;
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(AppMode::ReadOnly);
@@ -664,12 +1068,12 @@ impl<D: DataProvider> FormEditor<D> {
}
pub fn move_up_with_selection(&mut self) {
self.move_up();
let _ = self.move_up();
// Selection anchor stays in place, cursor position updates automatically
}
pub fn move_down_with_selection(&mut self) {
self.move_down();
let _ = self.move_down();
// Selection anchor stays in place, cursor position updates automatically
}

View File

@@ -8,6 +8,10 @@ pub mod data_provider;
#[cfg(feature = "autocomplete")]
pub mod autocomplete;
// Only include validation module if feature is enabled
#[cfg(feature = "validation")]
pub mod validation;
#[cfg(feature = "cursor-style")]
pub use canvas::CursorManager;
@@ -26,6 +30,15 @@ pub use canvas::modes::AppMode;
// Actions and results (for users who want to handle actions manually)
pub use canvas::actions::{CanvasAction, ActionResult};
// Validation exports (only when validation feature is enabled)
#[cfg(feature = "validation")]
pub use validation::{
ValidationConfig, ValidationResult, ValidationError,
CharacterLimits, ValidationConfigBuilder, ValidationState,
ValidationSummary, PatternFilters, PositionFilter, PositionRange, CharacterFilter,
DisplayMask, // Simple display mask instead of complex ReservedCharacters
};
// Theming and GUI
#[cfg(feature = "gui")]
pub use canvas::theme::{CanvasTheme, DefaultCanvasTheme};

View File

@@ -0,0 +1,361 @@
// src/validation/config.rs
//! Validation configuration types and builders
use crate::validation::{CharacterLimits, PatternFilters, DisplayMask};
/// Main validation configuration for a field
#[derive(Debug, Clone, Default)]
pub struct ValidationConfig {
/// Character limit configuration
pub character_limits: Option<CharacterLimits>,
/// Pattern filtering configuration
pub pattern_filters: Option<PatternFilters>,
/// User-defined display mask for visual formatting
pub display_mask: Option<DisplayMask>,
/// Future: Custom formatting
pub custom_formatting: Option<()>, // Placeholder for future implementation
/// Future: External validation
pub external_validation: Option<()>, // Placeholder for future implementation
}
/// Builder for creating validation configurations
#[derive(Debug, Default)]
pub struct ValidationConfigBuilder {
config: ValidationConfig,
}
impl ValidationConfigBuilder {
/// Create a new validation config builder
pub fn new() -> Self {
Self::default()
}
/// Set character limits for the field
pub fn with_character_limits(mut self, limits: CharacterLimits) -> Self {
self.config.character_limits = Some(limits);
self
}
/// Set pattern filters for the field
pub fn with_pattern_filters(mut self, filters: PatternFilters) -> Self {
self.config.pattern_filters = Some(filters);
self
}
/// Set user-defined display mask for visual formatting
///
/// # Examples
/// ```
/// use canvas::{ValidationConfigBuilder, DisplayMask};
///
/// // Phone number with dynamic formatting
/// let phone_mask = DisplayMask::new("(###) ###-####", '#');
/// let config = ValidationConfigBuilder::new()
/// .with_display_mask(phone_mask)
/// .build();
///
/// // Date with template formatting
/// let date_mask = DisplayMask::new("##/##/####", '#')
/// .with_template('_');
/// let config = ValidationConfigBuilder::new()
/// .with_display_mask(date_mask)
/// .build();
///
/// // Custom business format
/// let employee_id = DisplayMask::new("EMP-####-##", '#')
/// .with_template('•');
/// let config = ValidationConfigBuilder::new()
/// .with_display_mask(employee_id)
/// .with_max_length(6) // Only store the 6 digits
/// .build();
/// ```
pub fn with_display_mask(mut self, mask: DisplayMask) -> Self {
self.config.display_mask = Some(mask);
self
}
/// Set maximum number of characters (convenience method)
pub fn with_max_length(mut self, max_length: usize) -> Self {
self.config.character_limits = Some(CharacterLimits::new(max_length));
self
}
/// Build the final validation configuration
pub fn build(self) -> ValidationConfig {
self.config
}
}
/// Result of a validation operation
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationResult {
/// Validation passed
Valid,
/// Validation failed with warning (input still accepted)
Warning { message: String },
/// Validation failed with error (input rejected)
Error { message: String },
}
impl ValidationResult {
/// Check if the validation result allows the input
pub fn is_acceptable(&self) -> bool {
matches!(self, ValidationResult::Valid | ValidationResult::Warning { .. })
}
/// Check if the validation result is an error
pub fn is_error(&self) -> bool {
matches!(self, ValidationResult::Error { .. })
}
/// Get the message if there is one
pub fn message(&self) -> Option<&str> {
match self {
ValidationResult::Valid => None,
ValidationResult::Warning { message } => Some(message),
ValidationResult::Error { message } => Some(message),
}
}
/// Create a warning result
pub fn warning(message: impl Into<String>) -> Self {
ValidationResult::Warning { message: message.into() }
}
/// Create an error result
pub fn error(message: impl Into<String>) -> Self {
ValidationResult::Error { message: message.into() }
}
}
impl ValidationConfig {
/// Create a new empty validation configuration
pub fn new() -> Self {
Self::default()
}
/// Create a configuration with just character limits
pub fn with_max_length(max_length: usize) -> Self {
ValidationConfigBuilder::new()
.with_max_length(max_length)
.build()
}
/// Create a configuration with pattern filters
pub fn with_patterns(patterns: PatternFilters) -> Self {
ValidationConfigBuilder::new()
.with_pattern_filters(patterns)
.build()
}
/// Create a configuration with user-defined display mask
///
/// # Examples
/// ```
/// use canvas::{ValidationConfig, DisplayMask};
///
/// let phone_mask = DisplayMask::new("(###) ###-####", '#');
/// let config = ValidationConfig::with_mask(phone_mask);
/// ```
pub fn with_mask(mask: DisplayMask) -> Self {
ValidationConfigBuilder::new()
.with_display_mask(mask)
.build()
}
/// Validate a character insertion at a specific position (raw text space).
///
/// Note: Display masks are visual-only and do not participate in validation.
/// Editor logic is responsible for skipping mask separator positions; here we
/// only validate the raw insertion against limits and patterns.
pub fn validate_char_insertion(
&self,
current_text: &str,
position: usize,
character: char,
) -> ValidationResult {
// Character limits validation
if let Some(ref limits) = self.character_limits {
if let Some(result) = limits.validate_insertion(current_text, position, character) {
if !result.is_acceptable() {
return result;
}
}
}
// Pattern filters validation
if let Some(ref patterns) = self.pattern_filters {
if let Err(message) = patterns.validate_char_at_position(position, character) {
return ValidationResult::error(message);
}
}
// Future: Add other validation types here
ValidationResult::Valid
}
/// Validate the current text content (raw text space)
pub fn validate_content(&self, text: &str) -> ValidationResult {
// Character limits validation
if let Some(ref limits) = self.character_limits {
if let Some(result) = limits.validate_content(text) {
if !result.is_acceptable() {
return result;
}
}
}
// Pattern filters validation
if let Some(ref patterns) = self.pattern_filters {
if let Err(message) = patterns.validate_text(text) {
return ValidationResult::error(message);
}
}
// Future: Add other validation types here
ValidationResult::Valid
}
/// Check if any validation rules are configured
pub fn has_validation(&self) -> bool {
self.character_limits.is_some()
|| self.pattern_filters.is_some()
|| self.display_mask.is_some()
}
pub fn allows_field_switch(&self, text: &str) -> bool {
// Character limits validation
if let Some(ref limits) = self.character_limits {
if !limits.allows_field_switch(text) {
return false;
}
}
// Future: Add other validation types here
true
}
/// Get reason why field switching is blocked (if any)
pub fn field_switch_block_reason(&self, text: &str) -> Option<String> {
// Character limits validation
if let Some(ref limits) = self.character_limits {
if let Some(reason) = limits.field_switch_block_reason(text) {
return Some(reason);
}
}
// Future: Add other validation types here
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_with_user_defined_mask() {
// User creates their own phone mask
let phone_mask = DisplayMask::new("(###) ###-####", '#');
let config = ValidationConfig::with_mask(phone_mask);
// has_validation should be true because mask is configured
assert!(config.has_validation());
// Display mask is visual only; validation still focuses on raw content
let result = config.validate_char_insertion("123", 3, '4');
assert!(result.is_acceptable());
// Content validation unaffected by mask
let result = config.validate_content("1234567890");
assert!(result.is_acceptable());
}
#[test]
fn test_validation_config_builder() {
let config = ValidationConfigBuilder::new()
.with_max_length(10)
.build();
assert!(config.character_limits.is_some());
assert_eq!(config.character_limits.unwrap().max_length(), Some(10));
}
#[test]
fn test_config_builder_with_user_mask() {
// User defines custom format
let custom_mask = DisplayMask::new("##-##-##", '#').with_template('_');
let config = ValidationConfigBuilder::new()
.with_display_mask(custom_mask)
.with_max_length(6)
.build();
assert!(config.has_validation());
assert!(config.character_limits.is_some());
assert!(config.display_mask.is_some());
}
#[test]
fn test_validation_result() {
let valid = ValidationResult::Valid;
assert!(valid.is_acceptable());
assert!(!valid.is_error());
assert_eq!(valid.message(), None);
let warning = ValidationResult::warning("Too long");
assert!(warning.is_acceptable());
assert!(!warning.is_error());
assert_eq!(warning.message(), Some("Too long"));
let error = ValidationResult::error("Invalid");
assert!(!error.is_acceptable());
assert!(error.is_error());
assert_eq!(error.message(), Some("Invalid"));
}
#[test]
fn test_config_with_max_length() {
let config = ValidationConfig::with_max_length(5);
assert!(config.has_validation());
// Test valid insertion
let result = config.validate_char_insertion("test", 4, 'x');
assert!(result.is_acceptable());
// Test invalid insertion (would exceed limit)
let result = config.validate_char_insertion("tests", 5, 'x');
assert!(!result.is_acceptable());
}
#[test]
fn test_config_with_patterns() {
use crate::validation::{PatternFilters, PositionFilter, PositionRange, CharacterFilter};
let patterns = PatternFilters::new()
.add_filter(PositionFilter::new(
PositionRange::Range(0, 1),
CharacterFilter::Alphabetic,
));
let config = ValidationConfig::with_patterns(patterns);
assert!(config.has_validation());
// Test valid pattern insertion
let result = config.validate_char_insertion("", 0, 'A');
assert!(result.is_acceptable());
// Test invalid pattern insertion
let result = config.validate_char_insertion("", 0, '1');
assert!(!result.is_acceptable());
}
}

View File

@@ -0,0 +1,424 @@
// src/validation/limits.rs
//! Character limits validation implementation
use crate::validation::ValidationResult;
use serde::{Deserialize, Serialize};
use unicode_width::UnicodeWidthStr;
/// Character limits configuration for a field
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CharacterLimits {
/// Maximum number of characters allowed (None = unlimited)
max_length: Option<usize>,
/// Minimum number of characters required (None = no minimum)
min_length: Option<usize>,
/// Warning threshold (warn when approaching max limit)
warning_threshold: Option<usize>,
/// Count mode: characters vs display width
count_mode: CountMode,
}
/// How to count characters for limit checking
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum CountMode {
/// Count actual characters (default)
Characters,
/// Count display width (useful for CJK characters)
DisplayWidth,
/// Count bytes (rarely used, but available)
Bytes,
}
impl Default for CountMode {
fn default() -> Self {
CountMode::Characters
}
}
/// Result of a character limit check
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LimitCheckResult {
/// Within limits
Ok,
/// Approaching limit (warning)
Warning { current: usize, max: usize },
/// At or exceeding limit (error)
Exceeded { current: usize, max: usize },
/// Below minimum length
TooShort { current: usize, min: usize },
}
impl CharacterLimits {
/// Create new character limits with just max length
pub fn new(max_length: usize) -> Self {
Self {
max_length: Some(max_length),
min_length: None,
warning_threshold: None,
count_mode: CountMode::default(),
}
}
/// Create new character limits with min and max
pub fn new_range(min_length: usize, max_length: usize) -> Self {
Self {
max_length: Some(max_length),
min_length: Some(min_length),
warning_threshold: None,
count_mode: CountMode::default(),
}
}
/// Set warning threshold (when to show warning before hitting limit)
pub fn with_warning_threshold(mut self, threshold: usize) -> Self {
self.warning_threshold = Some(threshold);
self
}
/// Set count mode (characters vs display width vs bytes)
pub fn with_count_mode(mut self, mode: CountMode) -> Self {
self.count_mode = mode;
self
}
/// Get maximum length
pub fn max_length(&self) -> Option<usize> {
self.max_length
}
/// Get minimum length
pub fn min_length(&self) -> Option<usize> {
self.min_length
}
/// Get warning threshold
pub fn warning_threshold(&self) -> Option<usize> {
self.warning_threshold
}
/// Get count mode
pub fn count_mode(&self) -> CountMode {
self.count_mode
}
/// Count characters/width/bytes according to the configured mode
fn count(&self, text: &str) -> usize {
match self.count_mode {
CountMode::Characters => text.chars().count(),
CountMode::DisplayWidth => text.width(),
CountMode::Bytes => text.len(),
}
}
/// Check if inserting a character would exceed limits
pub fn validate_insertion(
&self,
current_text: &str,
_position: usize,
character: char,
) -> Option<ValidationResult> {
let current_count = self.count(current_text);
let char_count = match self.count_mode {
CountMode::Characters => 1,
CountMode::DisplayWidth => {
let char_str = character.to_string();
char_str.width()
},
CountMode::Bytes => character.len_utf8(),
};
let new_count = current_count + char_count;
// Check max length
if let Some(max) = self.max_length {
if new_count > max {
return Some(ValidationResult::error(format!(
"Character limit exceeded: {}/{}",
new_count,
max
)));
}
// Check warning threshold
if let Some(warning_threshold) = self.warning_threshold {
if new_count >= warning_threshold && current_count < warning_threshold {
return Some(ValidationResult::warning(format!(
"Approaching character limit: {}/{}",
new_count,
max
)));
}
}
}
None // No validation issues
}
/// Validate the current content
pub fn validate_content(&self, text: &str) -> Option<ValidationResult> {
let count = self.count(text);
// Check minimum length
if let Some(min) = self.min_length {
if count < min {
return Some(ValidationResult::warning(format!(
"Minimum length not met: {}/{}",
count,
min
)));
}
}
// Check maximum length
if let Some(max) = self.max_length {
if count > max {
return Some(ValidationResult::error(format!(
"Character limit exceeded: {}/{}",
count,
max
)));
}
// Check warning threshold
if let Some(warning_threshold) = self.warning_threshold {
if count >= warning_threshold {
return Some(ValidationResult::warning(format!(
"Approaching character limit: {}/{}",
count,
max
)));
}
}
}
None // No validation issues
}
/// Get the current status of the text against limits
pub fn check_limits(&self, text: &str) -> LimitCheckResult {
let count = self.count(text);
// Check max length first
if let Some(max) = self.max_length {
if count > max {
return LimitCheckResult::Exceeded { current: count, max };
}
// Check warning threshold
if let Some(warning_threshold) = self.warning_threshold {
if count >= warning_threshold {
return LimitCheckResult::Warning { current: count, max };
}
}
}
// Check min length
if let Some(min) = self.min_length {
if count < min {
return LimitCheckResult::TooShort { current: count, min };
}
}
LimitCheckResult::Ok
}
/// Get a human-readable status string
pub fn status_text(&self, text: &str) -> Option<String> {
match self.check_limits(text) {
LimitCheckResult::Ok => {
// Show current/max if we have a max limit
if let Some(max) = self.max_length {
Some(format!("{}/{}", self.count(text), max))
} else {
None
}
},
LimitCheckResult::Warning { current, max } => {
Some(format!("{}/{} (approaching limit)", current, max))
},
LimitCheckResult::Exceeded { current, max } => {
Some(format!("{}/{} (exceeded)", current, max))
},
LimitCheckResult::TooShort { current, min } => {
Some(format!("{}/{} minimum", current, min))
},
}
}
pub fn allows_field_switch(&self, text: &str) -> bool {
if let Some(min) = self.min_length {
let count = self.count(text);
// Allow switching if field is empty OR meets minimum requirement
count == 0 || count >= min
} else {
true // No minimum requirement, always allow switching
}
}
/// Get reason why field switching is not allowed (if any)
pub fn field_switch_block_reason(&self, text: &str) -> Option<String> {
if let Some(min) = self.min_length {
let count = self.count(text);
if count > 0 && count < min {
return Some(format!(
"Field must be empty or have at least {} characters (currently: {})",
min, count
));
}
}
None
}
}
impl Default for CharacterLimits {
fn default() -> Self {
Self {
max_length: Some(30), // Default 30 character limit as specified
min_length: None,
warning_threshold: None,
count_mode: CountMode::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_character_limits_creation() {
let limits = CharacterLimits::new(10);
assert_eq!(limits.max_length(), Some(10));
assert_eq!(limits.min_length(), None);
let range_limits = CharacterLimits::new_range(5, 15);
assert_eq!(range_limits.min_length(), Some(5));
assert_eq!(range_limits.max_length(), Some(15));
}
#[test]
fn test_default_limits() {
let limits = CharacterLimits::default();
assert_eq!(limits.max_length(), Some(30));
}
#[test]
fn test_character_counting() {
let limits = CharacterLimits::new(5);
// Test character mode (default)
assert_eq!(limits.count("hello"), 5);
assert_eq!(limits.count("héllo"), 5); // Accented character counts as 1
// Test display width mode
let limits = limits.with_count_mode(CountMode::DisplayWidth);
assert_eq!(limits.count("hello"), 5);
// Test bytes mode
let limits = limits.with_count_mode(CountMode::Bytes);
assert_eq!(limits.count("hello"), 5);
assert_eq!(limits.count("héllo"), 6); // é takes 2 bytes in UTF-8
}
#[test]
fn test_insertion_validation() {
let limits = CharacterLimits::new(5);
// Valid insertion
let result = limits.validate_insertion("test", 4, 'x');
assert!(result.is_none()); // No validation issues
// Invalid insertion (would exceed limit)
let result = limits.validate_insertion("tests", 5, 'x');
assert!(result.is_some());
assert!(!result.unwrap().is_acceptable());
}
#[test]
fn test_content_validation() {
let limits = CharacterLimits::new_range(3, 10);
// Too short
let result = limits.validate_content("hi");
assert!(result.is_some());
assert!(result.unwrap().is_acceptable()); // Warning, not error
// Just right
let result = limits.validate_content("hello");
assert!(result.is_none());
// Too long
let result = limits.validate_content("hello world!");
assert!(result.is_some());
assert!(!result.unwrap().is_acceptable()); // Error
}
#[test]
fn test_warning_threshold() {
let limits = CharacterLimits::new(10).with_warning_threshold(8);
// Below warning threshold
let result = limits.validate_insertion("1234567", 7, 'x');
assert!(result.is_none());
// At warning threshold
let result = limits.validate_insertion("1234567", 7, 'x');
assert!(result.is_none()); // This brings us to 8 chars
let result = limits.validate_insertion("12345678", 8, 'x');
assert!(result.is_some());
assert!(result.unwrap().is_acceptable()); // Warning, not error
}
#[test]
fn test_status_text() {
let limits = CharacterLimits::new(10);
assert_eq!(limits.status_text("hello"), Some("5/10".to_string()));
let limits = limits.with_warning_threshold(8);
assert_eq!(limits.status_text("12345678"), Some("8/10 (approaching limit)".to_string()));
assert_eq!(limits.status_text("1234567890x"), Some("11/10 (exceeded)".to_string()));
}
#[test]
fn test_field_switch_blocking() {
let limits = CharacterLimits::new_range(3, 10);
// Empty field: should allow switching
assert!(limits.allows_field_switch(""));
assert!(limits.field_switch_block_reason("").is_none());
// Field with content below minimum: should block switching
assert!(!limits.allows_field_switch("hi"));
assert!(limits.field_switch_block_reason("hi").is_some());
assert!(limits.field_switch_block_reason("hi").unwrap().contains("at least 3 characters"));
// Field meeting minimum: should allow switching
assert!(limits.allows_field_switch("hello"));
assert!(limits.field_switch_block_reason("hello").is_none());
// Field exceeding maximum: should still allow switching (validation shows error but doesn't block)
assert!(limits.allows_field_switch("this is way too long"));
assert!(limits.field_switch_block_reason("this is way too long").is_none());
}
#[test]
fn test_field_switch_no_minimum() {
let limits = CharacterLimits::new(10); // Only max, no minimum
// Should always allow switching when there's no minimum
assert!(limits.allows_field_switch(""));
assert!(limits.allows_field_switch("a"));
assert!(limits.allows_field_switch("hello"));
assert!(limits.field_switch_block_reason("").is_none());
assert!(limits.field_switch_block_reason("a").is_none());
}
}

View File

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

View File

@@ -0,0 +1,28 @@
// src/validation/mod.rs
// Core validation modules
pub mod config;
pub mod limits;
pub mod state;
pub mod patterns;
pub mod mask; // Simple display mask instead of complex reserved chars
// Re-export main types
pub use config::{ValidationConfig, ValidationResult, ValidationConfigBuilder};
pub use limits::{CharacterLimits, LimitCheckResult};
pub use state::{ValidationState, ValidationSummary};
pub use patterns::{PatternFilters, PositionFilter, PositionRange, CharacterFilter};
pub use mask::DisplayMask; // Simple mask instead of ReservedCharacters
/// Validation error types
#[derive(Debug, Clone, thiserror::Error)]
pub enum ValidationError {
#[error("Character limit exceeded: {message}")]
LimitExceeded { message: String },
#[error("Pattern validation failed: {message}")]
PatternFailed { message: String },
#[error("Custom validation failed: {message}")]
CustomFailed { message: String },
}

View File

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

View File

@@ -0,0 +1,399 @@
// src/validation/state.rs
//! Validation state management
use crate::validation::{ValidationConfig, ValidationResult};
use std::collections::HashMap;
/// Validation state for all fields in a form
#[derive(Debug, Clone, Default)]
pub struct ValidationState {
/// Validation configurations per field index
field_configs: HashMap<usize, ValidationConfig>,
/// Current validation results per field index
field_results: HashMap<usize, ValidationResult>,
/// Track which fields have been validated
validated_fields: std::collections::HashSet<usize>,
/// Global validation enabled/disabled
enabled: bool,
}
impl ValidationState {
/// Create a new validation state
pub fn new() -> Self {
Self {
field_configs: HashMap::new(),
field_results: HashMap::new(),
validated_fields: std::collections::HashSet::new(),
enabled: true,
}
}
/// Enable or disable validation globally
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
if !enabled {
// Clear all validation results when disabled
self.field_results.clear();
self.validated_fields.clear();
}
}
/// Check if validation is enabled
pub fn is_enabled(&self) -> bool {
self.enabled
}
/// Set validation configuration for a field
pub fn set_field_config(&mut self, field_index: usize, config: ValidationConfig) {
if config.has_validation() {
self.field_configs.insert(field_index, config);
} else {
self.field_configs.remove(&field_index);
self.field_results.remove(&field_index);
self.validated_fields.remove(&field_index);
}
}
/// Get validation configuration for a field
pub fn get_field_config(&self, field_index: usize) -> Option<&ValidationConfig> {
self.field_configs.get(&field_index)
}
/// Remove validation configuration for a field
pub fn remove_field_config(&mut self, field_index: usize) {
self.field_configs.remove(&field_index);
self.field_results.remove(&field_index);
self.validated_fields.remove(&field_index);
}
/// Validate character insertion for a field
pub fn validate_char_insertion(
&mut self,
field_index: usize,
current_text: &str,
position: usize,
character: char,
) -> ValidationResult {
if !self.enabled {
return ValidationResult::Valid;
}
if let Some(config) = self.field_configs.get(&field_index) {
let result = config.validate_char_insertion(current_text, position, character);
// Store the validation result
self.field_results.insert(field_index, result.clone());
self.validated_fields.insert(field_index);
result
} else {
ValidationResult::Valid
}
}
/// Validate field content
pub fn validate_field_content(
&mut self,
field_index: usize,
text: &str,
) -> ValidationResult {
if !self.enabled {
return ValidationResult::Valid;
}
if let Some(config) = self.field_configs.get(&field_index) {
let result = config.validate_content(text);
// Store the validation result
self.field_results.insert(field_index, result.clone());
self.validated_fields.insert(field_index);
result
} else {
ValidationResult::Valid
}
}
/// Get current validation result for a field
pub fn get_field_result(&self, field_index: usize) -> Option<&ValidationResult> {
self.field_results.get(&field_index)
}
/// Check if a field has been validated
pub fn is_field_validated(&self, field_index: usize) -> bool {
self.validated_fields.contains(&field_index)
}
/// Clear validation result for a field
pub fn clear_field_result(&mut self, field_index: usize) {
self.field_results.remove(&field_index);
self.validated_fields.remove(&field_index);
}
/// Clear all validation results
pub fn clear_all_results(&mut self) {
self.field_results.clear();
self.validated_fields.clear();
}
/// Get all field indices that have validation configured
pub fn validated_field_indices(&self) -> impl Iterator<Item = usize> + '_ {
self.field_configs.keys().copied()
}
/// Get all field indices with validation errors
pub fn fields_with_errors(&self) -> impl Iterator<Item = usize> + '_ {
self.field_results
.iter()
.filter(|(_, result)| result.is_error())
.map(|(index, _)| *index)
}
/// Get all field indices with validation warnings
pub fn fields_with_warnings(&self) -> impl Iterator<Item = usize> + '_ {
self.field_results
.iter()
.filter(|(_, result)| matches!(result, ValidationResult::Warning { .. }))
.map(|(index, _)| *index)
}
/// Check if any field has validation errors
pub fn has_errors(&self) -> bool {
self.field_results.values().any(|result| result.is_error())
}
/// Check if any field has validation warnings
pub fn has_warnings(&self) -> bool {
self.field_results.values().any(|result| matches!(result, ValidationResult::Warning { .. }))
}
/// Get total count of fields with validation configured
pub fn validated_field_count(&self) -> usize {
self.field_configs.len()
}
/// Check if field switching is allowed for a specific field
pub fn allows_field_switch(&self, field_index: usize, text: &str) -> bool {
if !self.enabled {
return true;
}
if let Some(config) = self.field_configs.get(&field_index) {
config.allows_field_switch(text)
} else {
true // No validation configured, allow switching
}
}
/// Get reason why field switching is blocked (if any)
pub fn field_switch_block_reason(&self, field_index: usize, text: &str) -> Option<String> {
if !self.enabled {
return None;
}
if let Some(config) = self.field_configs.get(&field_index) {
config.field_switch_block_reason(text)
} else {
None // No validation configured
}
}
pub fn summary(&self) -> ValidationSummary {
let total_validated = self.validated_fields.len();
let errors = self.fields_with_errors().count();
let warnings = self.fields_with_warnings().count();
let valid = total_validated - errors - warnings;
ValidationSummary {
total_fields: self.field_configs.len(),
validated_fields: total_validated,
valid_fields: valid,
warning_fields: warnings,
error_fields: errors,
}
}
}
/// Summary of validation state across all fields
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationSummary {
/// Total number of fields with validation configured
pub total_fields: usize,
/// Number of fields that have been validated
pub validated_fields: usize,
/// Number of fields with valid validation results
pub valid_fields: usize,
/// Number of fields with warnings
pub warning_fields: usize,
/// Number of fields with errors
pub error_fields: usize,
}
impl ValidationSummary {
/// Check if all configured fields are valid
pub fn is_all_valid(&self) -> bool {
self.error_fields == 0 && self.validated_fields == self.total_fields
}
/// Check if there are any errors
pub fn has_errors(&self) -> bool {
self.error_fields > 0
}
/// Check if there are any warnings
pub fn has_warnings(&self) -> bool {
self.warning_fields > 0
}
/// Get completion percentage (validated fields / total fields)
pub fn completion_percentage(&self) -> f32 {
if self.total_fields == 0 {
1.0
} else {
self.validated_fields as f32 / self.total_fields as f32
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::validation::{CharacterLimits, ValidationConfigBuilder};
#[test]
fn test_validation_state_creation() {
let state = ValidationState::new();
assert!(state.is_enabled());
assert_eq!(state.validated_field_count(), 0);
}
#[test]
fn test_enable_disable() {
let mut state = ValidationState::new();
// Add some validation config
let config = ValidationConfigBuilder::new()
.with_max_length(10)
.build();
state.set_field_config(0, config);
// Validate something
let result = state.validate_field_content(0, "test");
assert!(result.is_acceptable());
assert!(state.is_field_validated(0));
// Disable validation
state.set_enabled(false);
assert!(!state.is_enabled());
assert!(!state.is_field_validated(0)); // Should be cleared
// Validation should now return valid regardless
let result = state.validate_field_content(0, "this is way too long for the limit");
assert!(result.is_acceptable());
}
#[test]
fn test_field_config_management() {
let mut state = ValidationState::new();
let config = ValidationConfigBuilder::new()
.with_max_length(5)
.build();
// Set config
state.set_field_config(0, config);
assert_eq!(state.validated_field_count(), 1);
assert!(state.get_field_config(0).is_some());
// Remove config
state.remove_field_config(0);
assert_eq!(state.validated_field_count(), 0);
assert!(state.get_field_config(0).is_none());
}
#[test]
fn test_character_insertion_validation() {
let mut state = ValidationState::new();
let config = ValidationConfigBuilder::new()
.with_max_length(5)
.build();
state.set_field_config(0, config);
// Valid insertion
let result = state.validate_char_insertion(0, "test", 4, 'x');
assert!(result.is_acceptable());
// Invalid insertion
let result = state.validate_char_insertion(0, "tests", 5, 'x');
assert!(!result.is_acceptable());
// Check that result was stored
assert!(state.is_field_validated(0));
let stored_result = state.get_field_result(0);
assert!(stored_result.is_some());
assert!(!stored_result.unwrap().is_acceptable());
}
#[test]
fn test_validation_summary() {
let mut state = ValidationState::new();
// Configure two fields
let config1 = ValidationConfigBuilder::new().with_max_length(5).build();
let config2 = ValidationConfigBuilder::new().with_max_length(10).build();
state.set_field_config(0, config1);
state.set_field_config(1, config2);
// Validate field 0 (valid)
state.validate_field_content(0, "test");
// Validate field 1 (error)
state.validate_field_content(1, "this is too long");
let summary = state.summary();
assert_eq!(summary.total_fields, 2);
assert_eq!(summary.validated_fields, 2);
assert_eq!(summary.valid_fields, 1);
assert_eq!(summary.error_fields, 1);
assert_eq!(summary.warning_fields, 0);
assert!(!summary.is_all_valid());
assert!(summary.has_errors());
assert!(!summary.has_warnings());
assert_eq!(summary.completion_percentage(), 1.0);
}
#[test]
fn test_error_and_warning_tracking() {
let mut state = ValidationState::new();
let config = ValidationConfigBuilder::new()
.with_character_limits(
CharacterLimits::new_range(3, 10).with_warning_threshold(8)
)
.build();
state.set_field_config(0, config);
// Too short (warning)
state.validate_field_content(0, "hi");
assert!(state.has_warnings());
assert!(!state.has_errors());
// Just right
state.validate_field_content(0, "hello");
assert!(!state.has_warnings());
assert!(!state.has_errors());
// Too long (error)
state.validate_field_content(0, "hello world!");
assert!(!state.has_warnings());
assert!(state.has_errors());
}
}