validation passed to the canvas library now compiled

This commit is contained in:
Priec
2025-08-04 23:38:44 +02:00
parent 3d4435bac5
commit e6c4cb7e75
11 changed files with 1291 additions and 152 deletions

1
canvas/.gitignore vendored Normal file
View File

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

View File

@@ -23,6 +23,7 @@ thiserror = { workspace = true }
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
async-trait.workspace = true
regex = { workspace = true, optional = true }
[dev-dependencies]
tokio-test = "0.4.4"
@@ -32,6 +33,8 @@ default = []
gui = ["ratatui"]
autocomplete = ["tokio"]
cursor-style = ["crossterm"]
regex = ["dep:regex"]
validation = ["regex"]
[[example]]
name = "autocomplete"

View File

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

View File

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

View File

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

View File

@@ -26,10 +26,29 @@ pub struct FormEditor<D: DataProvider> {
impl<D: DataProvider> FormEditor<D> {
pub fn new(data_provider: D) -> Self {
Self {
let mut editor = Self {
ui_state: EditorState::new(),
data_provider,
suggestions: Vec::new(),
};
// Initialize validation configurations if validation feature is enabled
#[cfg(feature = "validation")]
{
editor.initialize_validation();
}
editor
}
/// Initialize validation configurations from data provider
#[cfg(feature = "validation")]
fn initialize_validation(&mut self) {
let field_count = self.data_provider.field_count();
for field_index in 0..field_count {
if let Some(config) = self.data_provider.validation_config(field_index) {
self.ui_state.validation.set_field_config(field_index, config);
}
}
}
@@ -81,6 +100,25 @@ impl<D: DataProvider> FormEditor<D> {
pub fn suggestions(&self) -> &[SuggestionItem] {
&self.suggestions
}
/// Get validation state (for user's business logic)
/// Only available when the 'validation' feature is enabled
#[cfg(feature = "validation")]
pub fn validation_state(&self) -> &crate::validation::ValidationState {
self.ui_state.validation_state()
}
/// Get validation result for current field
#[cfg(feature = "validation")]
pub fn current_field_validation(&self) -> Option<&crate::validation::ValidationResult> {
self.ui_state.validation.get_field_result(self.ui_state.current_field)
}
/// Get validation result for specific field
#[cfg(feature = "validation")]
pub fn field_validation(&self, field_index: usize) -> Option<&crate::validation::ValidationResult> {
self.ui_state.validation.get_field_result(field_index)
}
// ===================================================================
// 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;
// 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
current_text.insert(cursor_pos, ch);
let mut new_text = current_text.to_string();
new_text.insert(cursor_pos, ch);
// 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
self.ui_state.cursor_pos += 1;
@@ -137,6 +198,19 @@ impl<D: DataProvider> FormEditor<D> {
pub fn move_to_next_field(&mut self) {
let field_count = self.data_provider.field_count();
let next_field = (self.ui_state.current_field + 1) % field_count;
// Validate current field content before moving if validation is enabled
#[cfg(feature = "validation")]
{
let current_text = self.current_text().to_string(); // Convert to String to avoid borrow conflicts
let _validation_result = self.ui_state.validation.validate_field_content(
self.ui_state.current_field,
&current_text,
);
// Note: We don't prevent field switching on validation failure,
// just record the validation state
}
self.ui_state.move_to_field(next_field, field_count);
// Clamp cursor to new field
@@ -166,7 +240,7 @@ impl<D: DataProvider> FormEditor<D> {
if new_mode != AppMode::Highlight {
self.ui_state.selection = SelectionState::None;
}
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(new_mode);
@@ -178,22 +252,81 @@ impl<D: DataProvider> FormEditor<D> {
/// Enter edit mode with cursor positioned for append (vim 'a' command)
pub fn enter_append_mode(&mut self) {
let current_text = self.current_text();
// Calculate append position: always move right, even at line end
let append_pos = if current_text.is_empty() {
0
} else {
(self.ui_state.cursor_pos + 1).min(current_text.len())
};
// Set cursor position for append
self.ui_state.cursor_pos = append_pos;
self.ui_state.ideal_cursor_column = append_pos;
// Enter edit mode (which will update cursor style)
self.set_mode(AppMode::Edit);
}
// ===================================================================
// VALIDATION METHODS (only available with validation feature)
// ===================================================================
/// Enable or disable validation
#[cfg(feature = "validation")]
pub fn set_validation_enabled(&mut self, enabled: bool) {
self.ui_state.validation.set_enabled(enabled);
}
/// Check if validation is enabled
#[cfg(feature = "validation")]
pub fn is_validation_enabled(&self) -> bool {
self.ui_state.validation.is_enabled()
}
/// Set validation configuration for a specific field
#[cfg(feature = "validation")]
pub fn set_field_validation(&mut self, field_index: usize, config: crate::validation::ValidationConfig) {
self.ui_state.validation.set_field_config(field_index, config);
}
/// Remove validation configuration for a specific field
#[cfg(feature = "validation")]
pub fn remove_field_validation(&mut self, field_index: usize) {
self.ui_state.validation.remove_field_config(field_index);
}
/// Manually validate current field content
#[cfg(feature = "validation")]
pub fn validate_current_field(&mut self) -> crate::validation::ValidationResult {
let field_index = self.ui_state.current_field;
let current_text = self.current_text().to_string();
self.ui_state.validation.validate_field_content(field_index, &current_text)
}
/// Manually validate specific field content
#[cfg(feature = "validation")]
pub fn validate_field(&mut self, field_index: usize) -> Option<crate::validation::ValidationResult> {
if field_index < self.data_provider.field_count() {
let text = self.data_provider.field_value(field_index).to_string();
Some(self.ui_state.validation.validate_field_content(field_index, &text))
} else {
None
}
}
/// Clear validation results for all fields
#[cfg(feature = "validation")]
pub fn clear_validation_results(&mut self) {
self.ui_state.validation.clear_all_results();
}
/// Get validation summary for all fields
#[cfg(feature = "validation")]
pub fn validation_summary(&self) -> crate::validation::ValidationSummary {
self.ui_state.validation.summary()
}
// ===================================================================
// ASYNC OPERATIONS: Only autocomplete needs async
// ===================================================================
@@ -255,6 +388,15 @@ impl<D: DataProvider> FormEditor<D> {
// Close autocomplete
self.ui_state.deactivate_autocomplete();
self.suggestions.clear();
// Validate the new content if validation is enabled
#[cfg(feature = "validation")]
{
let _validation_result = self.ui_state.validation.validate_field_content(
field_index,
&suggestion.value_to_store,
);
}
return Some(suggestion.display_text);
}
@@ -263,7 +405,7 @@ impl<D: DataProvider> FormEditor<D> {
}
// ===================================================================
// ADD THESE MISSING MOVEMENT METHODS
// MOVEMENT METHODS (keeping existing implementations)
// ===================================================================
/// Move to previous field (vim k / up arrow)
@@ -272,24 +414,44 @@ impl<D: DataProvider> FormEditor<D> {
if field_count == 0 {
return;
}
// Validate current field before moving
#[cfg(feature = "validation")]
{
let current_text = self.current_text().to_string(); // Convert to String to avoid borrow conflicts
let _validation_result = self.ui_state.validation.validate_field_content(
self.ui_state.current_field,
&current_text,
);
}
let current_field = self.ui_state.current_field;
let new_field = current_field.saturating_sub(1);
self.ui_state.move_to_field(new_field, field_count);
self.clamp_cursor_to_current_field();
}
/// Move to next field (vim j / down arrow)
/// Move to next field (vim j / down arrow)
pub fn move_down(&mut self) {
let field_count = self.data_provider.field_count();
if field_count == 0 {
return;
}
// Validate current field before moving
#[cfg(feature = "validation")]
{
let current_text = self.current_text().to_string(); // Convert to String to avoid borrow conflicts
let _validation_result = self.ui_state.validation.validate_field_content(
self.ui_state.current_field,
&current_text,
);
}
let current_field = self.ui_state.current_field;
let new_field = (current_field + 1).min(field_count - 1);
self.ui_state.move_to_field(new_field, field_count);
self.clamp_cursor_to_current_field();
}
@@ -300,7 +462,7 @@ impl<D: DataProvider> FormEditor<D> {
if field_count == 0 {
return;
}
self.ui_state.move_to_field(0, field_count);
self.clamp_cursor_to_current_field();
}
@@ -311,7 +473,7 @@ impl<D: DataProvider> FormEditor<D> {
if field_count == 0 {
return;
}
let last_field = field_count - 1;
self.ui_state.move_to_field(last_field, field_count);
self.clamp_cursor_to_current_field();
@@ -322,7 +484,7 @@ impl<D: DataProvider> FormEditor<D> {
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) {
self.move_down();
}
@@ -340,7 +502,7 @@ impl<D: DataProvider> FormEditor<D> {
use crate::canvas::actions::movement::line::line_end_position;
let current_text = self.current_text();
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let new_pos = line_end_position(current_text, is_edit_mode);
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
@@ -350,21 +512,21 @@ impl<D: DataProvider> FormEditor<D> {
pub fn move_word_next(&mut self) {
use crate::canvas::actions::movement::word::find_next_word_start;
let current_text = self.current_text();
if current_text.is_empty() {
return;
}
let new_pos = find_next_word_start(current_text, self.ui_state.cursor_pos);
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
// Clamp to valid bounds for current mode
let final_pos = if is_edit_mode {
new_pos.min(current_text.len())
} else {
new_pos.min(current_text.len().saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
@@ -373,11 +535,11 @@ impl<D: DataProvider> FormEditor<D> {
pub fn move_word_prev(&mut self) {
use crate::canvas::actions::movement::word::find_prev_word_start;
let current_text = self.current_text();
if current_text.is_empty() {
return;
}
let new_pos = find_prev_word_start(current_text, self.ui_state.cursor_pos);
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
@@ -387,21 +549,21 @@ impl<D: DataProvider> FormEditor<D> {
pub fn move_word_end(&mut self) {
use crate::canvas::actions::movement::word::find_word_end;
let current_text = self.current_text();
if current_text.is_empty() {
return;
}
let current_pos = self.ui_state.cursor_pos;
let new_pos = find_word_end(current_text, current_pos);
// If we didn't move, try next word
let final_pos = if new_pos == current_pos && current_pos + 1 < current_text.len() {
find_word_end(current_text, current_pos + 1)
} else {
new_pos
};
// Clamp for read-only mode
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let clamped_pos = if is_edit_mode {
@@ -409,7 +571,7 @@ impl<D: DataProvider> FormEditor<D> {
} else {
final_pos.min(current_text.len().saturating_sub(1))
};
self.ui_state.cursor_pos = clamped_pos;
self.ui_state.ideal_cursor_column = clamped_pos;
}
@@ -418,11 +580,11 @@ impl<D: DataProvider> FormEditor<D> {
pub fn move_word_end_prev(&mut self) {
use crate::canvas::actions::movement::word::find_prev_word_end;
let current_text = self.current_text();
if current_text.is_empty() {
return;
}
let new_pos = find_prev_word_end(current_text, self.ui_state.cursor_pos);
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
@@ -433,21 +595,30 @@ impl<D: DataProvider> FormEditor<D> {
if self.ui_state.current_mode != AppMode::Edit {
return Ok(()); // Silently ignore in non-edit modes
}
if self.ui_state.cursor_pos == 0 {
return Ok(()); // Nothing to delete
}
let field_index = self.ui_state.current_field;
let mut current_text = self.data_provider.field_value(field_index).to_string();
if self.ui_state.cursor_pos <= current_text.len() {
current_text.remove(self.ui_state.cursor_pos - 1);
self.data_provider.set_field_value(field_index, current_text);
self.data_provider.set_field_value(field_index, current_text.clone());
self.ui_state.cursor_pos -= 1;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
// Validate the new content if validation is enabled
#[cfg(feature = "validation")]
{
let _validation_result = self.ui_state.validation.validate_field_content(
field_index,
&current_text,
);
}
}
Ok(())
}
@@ -456,21 +627,39 @@ impl<D: DataProvider> FormEditor<D> {
if self.ui_state.current_mode != AppMode::Edit {
return Ok(()); // Silently ignore in non-edit modes
}
let field_index = self.ui_state.current_field;
let mut current_text = self.data_provider.field_value(field_index).to_string();
if self.ui_state.cursor_pos < current_text.len() {
current_text.remove(self.ui_state.cursor_pos);
self.data_provider.set_field_value(field_index, current_text);
self.data_provider.set_field_value(field_index, current_text.clone());
// Validate the new content if validation is enabled
#[cfg(feature = "validation")]
{
let _validation_result = self.ui_state.validation.validate_field_content(
field_index,
&current_text,
);
}
}
Ok(())
}
/// Exit edit mode to read-only mode (vim Escape)
// TODO this is still flickering, I have no clue how to fix it
pub fn exit_edit_mode(&mut self) {
// Validate current field content when exiting edit mode
#[cfg(feature = "validation")]
{
let current_text = self.current_text().to_string(); // Convert to String to avoid borrow conflicts
let _validation_result = self.ui_state.validation.validate_field_content(
self.ui_state.current_field,
&current_text,
);
}
// Adjust cursor position when transitioning from edit to normal mode
let current_text = self.current_text();
if !current_text.is_empty() {
@@ -500,44 +689,62 @@ impl<D: DataProvider> FormEditor<D> {
fn clamp_cursor_to_current_field(&mut self) {
let current_text = self.current_text();
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
use crate::canvas::actions::movement::line::safe_cursor_position;
let safe_pos = safe_cursor_position(
current_text,
self.ui_state.ideal_cursor_column,
current_text,
self.ui_state.ideal_cursor_column,
is_edit_mode
);
self.ui_state.cursor_pos = safe_pos;
}
/// Set the value of the current field
pub fn set_current_field_value(&mut self, value: String) {
let field_index = self.ui_state.current_field;
self.data_provider.set_field_value(field_index, value);
self.data_provider.set_field_value(field_index, value.clone());
// Reset cursor to start of field
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
// Validate the new content if validation is enabled
#[cfg(feature = "validation")]
{
let _validation_result = self.ui_state.validation.validate_field_content(
field_index,
&value,
);
}
}
/// Set the value of a specific field by index
pub fn set_field_value(&mut self, field_index: usize, value: String) {
if field_index < self.data_provider.field_count() {
self.data_provider.set_field_value(field_index, value);
self.data_provider.set_field_value(field_index, value.clone());
// If we're modifying the current field, reset cursor
if field_index == self.ui_state.current_field {
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
}
// Validate the new content if validation is enabled
#[cfg(feature = "validation")]
{
let _validation_result = self.ui_state.validation.validate_field_content(
field_index,
&value,
);
}
}
}
/// Clear the current field (set to empty string)
pub fn clear_current_field(&mut self) {
self.set_current_field_value(String::new());
}
/// Get mutable access to data provider (for advanced operations)
pub fn data_provider_mut(&mut self) -> &mut D {
&mut self.data_provider
@@ -547,16 +754,16 @@ impl<D: DataProvider> FormEditor<D> {
pub fn set_cursor_position(&mut self, position: usize) {
let current_text = self.current_text();
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
// Clamp to valid bounds for current mode
let max_pos = if is_edit_mode {
current_text.len() // Edit mode: can go past end
} else {
current_text.len().saturating_sub(1).max(0) // Read-only: stay within text
};
let clamped_pos = position.min(max_pos);
// Update cursor position directly
self.ui_state.cursor_pos = clamped_pos;
self.ui_state.ideal_cursor_column = clamped_pos;
@@ -565,7 +772,7 @@ impl<D: DataProvider> FormEditor<D> {
/// Get cursor position for display (respects mode-specific positioning rules)
pub fn display_cursor_position(&self) -> usize {
let current_text = self.current_text();
match self.ui_state.current_mode {
AppMode::Edit => {
// Edit mode: cursor can be past end of text
@@ -606,7 +813,7 @@ impl<D: DataProvider> FormEditor<D> {
self.ui_state.selection = SelectionState::Characterwise {
anchor: (self.ui_state.current_field, self.ui_state.cursor_pos),
};
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(AppMode::Highlight);
@@ -621,7 +828,7 @@ impl<D: DataProvider> FormEditor<D> {
self.ui_state.selection = SelectionState::Linewise {
anchor_field: self.ui_state.current_field,
};
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(AppMode::Highlight);
@@ -634,7 +841,7 @@ impl<D: DataProvider> FormEditor<D> {
if self.ui_state.current_mode == AppMode::Highlight {
self.ui_state.current_mode = AppMode::ReadOnly;
self.ui_state.selection = SelectionState::None;
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(AppMode::ReadOnly);

View File

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

View File

@@ -0,0 +1,208 @@
//! 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()
}
}
#[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());
}
}

View File

@@ -0,0 +1,365 @@
//! 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))
},
}
}
}
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()));
}
}

View 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>;

View File

@@ -0,0 +1,374 @@
//! 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()
}
/// Get validation summary
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());
}
}