Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c36e76eaa | ||
|
|
abd8cba7a5 | ||
|
|
e6c4cb7e75 |
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -479,6 +479,7 @@ dependencies = [
|
|||||||
"common",
|
"common",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
1
canvas/.gitignore
vendored
Normal file
1
canvas/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
docs_prompts/
|
||||||
@@ -23,6 +23,7 @@ thiserror = { workspace = true }
|
|||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
tracing-subscriber = "0.3.19"
|
tracing-subscriber = "0.3.19"
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
|
regex = { workspace = true, optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio-test = "0.4.4"
|
tokio-test = "0.4.4"
|
||||||
@@ -32,6 +33,7 @@ default = []
|
|||||||
gui = ["ratatui"]
|
gui = ["ratatui"]
|
||||||
autocomplete = ["tokio"]
|
autocomplete = ["tokio"]
|
||||||
cursor-style = ["crossterm"]
|
cursor-style = ["crossterm"]
|
||||||
|
validation = ["regex"]
|
||||||
|
|
||||||
[[example]]
|
[[example]]
|
||||||
name = "autocomplete"
|
name = "autocomplete"
|
||||||
@@ -42,3 +44,7 @@ path = "examples/autocomplete.rs"
|
|||||||
name = "canvas_gui_demo"
|
name = "canvas_gui_demo"
|
||||||
required-features = ["gui"]
|
required-features = ["gui"]
|
||||||
path = "examples/canvas_gui_demo.rs"
|
path = "examples/canvas_gui_demo.rs"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "validation_1"
|
||||||
|
required-features = ["gui", "validation"]
|
||||||
|
|||||||
@@ -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
|
|
||||||
╰─
|
|
||||||
|
|
||||||
831
canvas/examples/validation_1.rs
Normal file
831
canvas/examples/validation_1.rs
Normal 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(())
|
||||||
|
}
|
||||||
@@ -19,6 +19,10 @@ pub struct EditorState {
|
|||||||
|
|
||||||
// Selection state (for vim visual mode)
|
// Selection state (for vim visual mode)
|
||||||
pub(crate) selection: SelectionState,
|
pub(crate) selection: SelectionState,
|
||||||
|
|
||||||
|
// Validation state (only available with validation feature)
|
||||||
|
#[cfg(feature = "validation")]
|
||||||
|
pub(crate) validation: crate::validation::ValidationState,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -50,6 +54,8 @@ impl EditorState {
|
|||||||
active_field: None,
|
active_field: None,
|
||||||
},
|
},
|
||||||
selection: SelectionState::None,
|
selection: SelectionState::None,
|
||||||
|
#[cfg(feature = "validation")]
|
||||||
|
validation: crate::validation::ValidationState::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +74,7 @@ impl EditorState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get ideal cursor column (for vim-like behavior)
|
/// 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
|
self.ideal_cursor_column
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +98,13 @@ impl EditorState {
|
|||||||
&self.selection
|
&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
|
// INTERNAL MUTATIONS: Only library modifies these
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ pub trait DataProvider {
|
|||||||
fn display_value(&self, _index: usize) -> Option<&str> {
|
fn display_value(&self, _index: usize) -> Option<&str> {
|
||||||
None // Default: use actual value
|
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
|
/// Optional: User implements this for autocomplete data
|
||||||
|
|||||||
@@ -26,10 +26,29 @@ pub struct FormEditor<D: DataProvider> {
|
|||||||
|
|
||||||
impl<D: DataProvider> FormEditor<D> {
|
impl<D: DataProvider> FormEditor<D> {
|
||||||
pub fn new(data_provider: D) -> Self {
|
pub fn new(data_provider: D) -> Self {
|
||||||
Self {
|
let mut editor = Self {
|
||||||
ui_state: EditorState::new(),
|
ui_state: EditorState::new(),
|
||||||
data_provider,
|
data_provider,
|
||||||
suggestions: Vec::new(),
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +101,25 @@ impl<D: DataProvider> FormEditor<D> {
|
|||||||
&self.suggestions
|
&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
|
// SYNC OPERATIONS: No async needed for basic editing
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
@@ -96,13 +134,36 @@ impl<D: DataProvider> FormEditor<D> {
|
|||||||
let cursor_pos = self.ui_state.cursor_pos;
|
let cursor_pos = self.ui_state.cursor_pos;
|
||||||
|
|
||||||
// Get current text from user
|
// Get current text from user
|
||||||
let mut current_text = self.data_provider.field_value(field_index).to_string();
|
let current_text = self.data_provider.field_value(field_index);
|
||||||
|
|
||||||
|
// Validate character insertion if validation is enabled
|
||||||
|
#[cfg(feature = "validation")]
|
||||||
|
{
|
||||||
|
let validation_result = self.ui_state.validation.validate_char_insertion(
|
||||||
|
field_index,
|
||||||
|
current_text,
|
||||||
|
cursor_pos,
|
||||||
|
ch,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reject input if validation failed with error
|
||||||
|
if !validation_result.is_acceptable() {
|
||||||
|
// Log validation failure for debugging
|
||||||
|
tracing::debug!(
|
||||||
|
"Character insertion rejected for field {}: {:?}",
|
||||||
|
field_index,
|
||||||
|
validation_result
|
||||||
|
);
|
||||||
|
return Ok(()); // Silently reject invalid input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Insert character
|
// Insert character
|
||||||
current_text.insert(cursor_pos, ch);
|
let mut new_text = current_text.to_string();
|
||||||
|
new_text.insert(cursor_pos, ch);
|
||||||
|
|
||||||
// Update user's data
|
// Update user's data
|
||||||
self.data_provider.set_field_value(field_index, current_text);
|
self.data_provider.set_field_value(field_index, new_text);
|
||||||
|
|
||||||
// Update library's UI state
|
// Update library's UI state
|
||||||
self.ui_state.cursor_pos += 1;
|
self.ui_state.cursor_pos += 1;
|
||||||
@@ -137,6 +198,19 @@ impl<D: DataProvider> FormEditor<D> {
|
|||||||
pub fn move_to_next_field(&mut self) {
|
pub fn move_to_next_field(&mut self) {
|
||||||
let field_count = self.data_provider.field_count();
|
let field_count = self.data_provider.field_count();
|
||||||
let next_field = (self.ui_state.current_field + 1) % 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,
|
||||||
|
¤t_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);
|
self.ui_state.move_to_field(next_field, field_count);
|
||||||
|
|
||||||
// Clamp cursor to new field
|
// Clamp cursor to new field
|
||||||
@@ -194,6 +268,79 @@ impl<D: DataProvider> FormEditor<D> {
|
|||||||
self.set_mode(AppMode::Edit);
|
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, ¤t_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
|
// ASYNC OPERATIONS: Only autocomplete needs async
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
@@ -256,6 +403,15 @@ impl<D: DataProvider> FormEditor<D> {
|
|||||||
self.ui_state.deactivate_autocomplete();
|
self.ui_state.deactivate_autocomplete();
|
||||||
self.suggestions.clear();
|
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);
|
return Some(suggestion.display_text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,14 +419,36 @@ impl<D: DataProvider> FormEditor<D> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// ADD THESE MISSING MOVEMENT METHODS
|
// MOVEMENT METHODS (keeping existing implementations)
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|
||||||
/// Move to previous field (vim k / up arrow)
|
/// 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();
|
let field_count = self.data_provider.field_count();
|
||||||
if field_count == 0 {
|
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,
|
||||||
|
¤t_text,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let current_field = self.ui_state.current_field;
|
let current_field = self.ui_state.current_field;
|
||||||
@@ -278,13 +456,36 @@ impl<D: DataProvider> FormEditor<D> {
|
|||||||
|
|
||||||
self.ui_state.move_to_field(new_field, field_count);
|
self.ui_state.move_to_field(new_field, field_count);
|
||||||
self.clamp_cursor_to_current_field();
|
self.clamp_cursor_to_current_field();
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move to next field (vim j / down arrow)
|
/// Move to next field (vim j / down arrow)
|
||||||
pub fn move_down(&mut self) {
|
pub fn move_down(&mut self) -> Result<()> {
|
||||||
let field_count = self.data_provider.field_count();
|
let field_count = self.data_provider.field_count();
|
||||||
if field_count == 0 {
|
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,
|
||||||
|
¤t_text,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let current_field = self.ui_state.current_field;
|
let current_field = self.ui_state.current_field;
|
||||||
@@ -292,6 +493,7 @@ impl<D: DataProvider> FormEditor<D> {
|
|||||||
|
|
||||||
self.ui_state.move_to_field(new_field, field_count);
|
self.ui_state.move_to_field(new_field, field_count);
|
||||||
self.clamp_cursor_to_current_field();
|
self.clamp_cursor_to_current_field();
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move to first field (vim gg)
|
/// Move to first field (vim gg)
|
||||||
@@ -318,13 +520,13 @@ impl<D: DataProvider> FormEditor<D> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Move to previous field (alternative to move_up)
|
/// Move to previous field (alternative to move_up)
|
||||||
pub fn prev_field(&mut self) {
|
pub fn prev_field(&mut self) -> Result<()> {
|
||||||
self.move_up();
|
self.move_up()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move to next field (alternative to move_down)
|
/// Move to next field (alternative to move_down)
|
||||||
pub fn next_field(&mut self) {
|
pub fn next_field(&mut self) -> Result<()> {
|
||||||
self.move_down();
|
self.move_down()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move to start of current field (vim 0)
|
/// Move to start of current field (vim 0)
|
||||||
@@ -443,9 +645,18 @@ impl<D: DataProvider> FormEditor<D> {
|
|||||||
|
|
||||||
if self.ui_state.cursor_pos <= current_text.len() {
|
if self.ui_state.cursor_pos <= current_text.len() {
|
||||||
current_text.remove(self.ui_state.cursor_pos - 1);
|
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.cursor_pos -= 1;
|
||||||
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
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,
|
||||||
|
¤t_text,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -462,15 +673,34 @@ impl<D: DataProvider> FormEditor<D> {
|
|||||||
|
|
||||||
if self.ui_state.cursor_pos < current_text.len() {
|
if self.ui_state.cursor_pos < current_text.len() {
|
||||||
current_text.remove(self.ui_state.cursor_pos);
|
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,
|
||||||
|
¤t_text,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exit edit mode to read-only mode (vim Escape)
|
/// 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) -> Result<()> {
|
||||||
pub fn exit_edit_mode(&mut self) {
|
// 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
|
// Adjust cursor position when transitioning from edit to normal mode
|
||||||
let current_text = self.current_text();
|
let current_text = self.current_text();
|
||||||
if !current_text.is_empty() {
|
if !current_text.is_empty() {
|
||||||
@@ -485,6 +715,8 @@ impl<D: DataProvider> FormEditor<D> {
|
|||||||
self.set_mode(AppMode::ReadOnly);
|
self.set_mode(AppMode::ReadOnly);
|
||||||
// Deactivate autocomplete when exiting edit mode
|
// Deactivate autocomplete when exiting edit mode
|
||||||
self.ui_state.deactivate_autocomplete();
|
self.ui_state.deactivate_autocomplete();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enter edit mode from read-only mode (vim i/a/o)
|
/// Enter edit mode from read-only mode (vim i/a/o)
|
||||||
@@ -515,21 +747,39 @@ impl<D: DataProvider> FormEditor<D> {
|
|||||||
/// Set the value of the current field
|
/// Set the value of the current field
|
||||||
pub fn set_current_field_value(&mut self, value: String) {
|
pub fn set_current_field_value(&mut self, value: String) {
|
||||||
let field_index = self.ui_state.current_field;
|
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
|
// Reset cursor to start of field
|
||||||
self.ui_state.cursor_pos = 0;
|
self.ui_state.cursor_pos = 0;
|
||||||
self.ui_state.ideal_cursor_column = 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
|
/// Set the value of a specific field by index
|
||||||
pub fn set_field_value(&mut self, field_index: usize, value: String) {
|
pub fn set_field_value(&mut self, field_index: usize, value: String) {
|
||||||
if field_index < self.data_provider.field_count() {
|
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 we're modifying the current field, reset cursor
|
||||||
if field_index == self.ui_state.current_field {
|
if field_index == self.ui_state.current_field {
|
||||||
self.ui_state.cursor_pos = 0;
|
self.ui_state.cursor_pos = 0;
|
||||||
self.ui_state.ideal_cursor_column = 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ pub mod data_provider;
|
|||||||
#[cfg(feature = "autocomplete")]
|
#[cfg(feature = "autocomplete")]
|
||||||
pub mod autocomplete;
|
pub mod autocomplete;
|
||||||
|
|
||||||
|
// Only include validation module if feature is enabled
|
||||||
|
#[cfg(feature = "validation")]
|
||||||
|
pub mod validation;
|
||||||
|
|
||||||
#[cfg(feature = "cursor-style")]
|
#[cfg(feature = "cursor-style")]
|
||||||
pub use canvas::CursorManager;
|
pub use canvas::CursorManager;
|
||||||
|
|
||||||
@@ -26,6 +30,14 @@ pub use canvas::modes::AppMode;
|
|||||||
// Actions and results (for users who want to handle actions manually)
|
// Actions and results (for users who want to handle actions manually)
|
||||||
pub use canvas::actions::{CanvasAction, ActionResult};
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
// Theming and GUI
|
// Theming and GUI
|
||||||
#[cfg(feature = "gui")]
|
#[cfg(feature = "gui")]
|
||||||
pub use canvas::theme::{CanvasTheme, DefaultCanvasTheme};
|
pub use canvas::theme::{CanvasTheme, DefaultCanvasTheme};
|
||||||
|
|||||||
235
canvas/src/validation/config.rs
Normal file
235
canvas/src/validation/config.rs
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
// src/validation/config.rs
|
||||||
|
//! Validation configuration types and builders
|
||||||
|
|
||||||
|
use crate::validation::CharacterLimits;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Main validation configuration for a field
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct ValidationConfig {
|
||||||
|
/// Character limit configuration
|
||||||
|
pub character_limits: Option<CharacterLimits>,
|
||||||
|
|
||||||
|
/// Future: Predefined patterns
|
||||||
|
#[serde(skip)]
|
||||||
|
pub patterns: Option<()>, // Placeholder for future implementation
|
||||||
|
|
||||||
|
/// Future: Reserved characters
|
||||||
|
#[serde(skip)]
|
||||||
|
pub reserved_chars: Option<()>, // Placeholder for future implementation
|
||||||
|
|
||||||
|
/// Future: Custom formatting
|
||||||
|
#[serde(skip)]
|
||||||
|
pub custom_formatting: Option<()>, // Placeholder for future implementation
|
||||||
|
|
||||||
|
/// Future: External validation
|
||||||
|
#[serde(skip)]
|
||||||
|
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 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a character insertion at a specific position
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Future: Add other validation types here
|
||||||
|
|
||||||
|
ValidationResult::Valid
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the current text content
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.patterns.is_some()
|
||||||
|
// || self.reserved_chars.is_some()
|
||||||
|
// || self.custom_formatting.is_some()
|
||||||
|
// || self.external_validation.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_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_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());
|
||||||
|
}
|
||||||
|
}
|
||||||
424
canvas/src/validation/limits.rs
Normal file
424
canvas/src/validation/limits.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
26
canvas/src/validation/mod.rs
Normal file
26
canvas/src/validation/mod.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
//! Validation module for canvas form fields
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
|
pub mod limits;
|
||||||
|
pub mod state;
|
||||||
|
|
||||||
|
// Re-export main types
|
||||||
|
pub use config::{ValidationConfig, ValidationResult, ValidationConfigBuilder};
|
||||||
|
pub use limits::{CharacterLimits, LimitCheckResult};
|
||||||
|
pub use state::{ValidationState, ValidationSummary};
|
||||||
|
|
||||||
|
/// Validation error types
|
||||||
|
#[derive(Debug, Clone, thiserror::Error)]
|
||||||
|
pub enum ValidationError {
|
||||||
|
#[error("Character limit exceeded: {current}/{max}")]
|
||||||
|
CharacterLimitExceeded { current: usize, max: usize },
|
||||||
|
|
||||||
|
#[error("Invalid character '{char}' at position {position}")]
|
||||||
|
InvalidCharacter { char: char, position: usize },
|
||||||
|
|
||||||
|
#[error("Validation configuration error: {message}")]
|
||||||
|
ConfigurationError { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result type for validation operations
|
||||||
|
pub type Result<T> = std::result::Result<T, ValidationError>;
|
||||||
399
canvas/src/validation/state.rs
Normal file
399
canvas/src/validation/state.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user