From 6ba0124779b8c9342d40aa0ee120b5adebb178d0 Mon Sep 17 00:00:00 2001 From: Priec Date: Thu, 7 Aug 2025 00:03:11 +0200 Subject: [PATCH] feature5 implementation is full now --- canvas/examples/validation_5.rs | 448 ++++++++++++++++++++++++++++++++ canvas/src/editor.rs | 18 ++ canvas/src/validation/config.rs | 10 + canvas/src/validation/mod.rs | 10 + canvas/src/validation/state.rs | 40 ++- 5 files changed, 520 insertions(+), 6 deletions(-) create mode 100644 canvas/examples/validation_5.rs diff --git a/canvas/examples/validation_5.rs b/canvas/examples/validation_5.rs new file mode 100644 index 0000000..2c06e33 --- /dev/null +++ b/canvas/examples/validation_5.rs @@ -0,0 +1,448 @@ +// examples/validation_5.rs +//! Feature 5: External validation (UI-only) demo with Feature 4 (custom formatter) +//! +//! Behavior: +//! - Field 1 (PSC) uses a custom formatter (Feature 4) and external validation (Feature 5). +//! β€’ While editing PSC: raw text, capped at 5 digits +//! β€’ When not editing PSC (moved focus or Esc) and raw is 5 digits: shows formatted ("XXX XX") +//! β€’ After leaving PSC (or pressing 'v'), external validation kicks in: +//! - Validating -> Valid/Invalid/Warning based on simple rules (LSP-like simulation) +//! - Field 2 (Notes) is plain text, no formatter, no external validation. +//! +//! Controls: +//! - i/a: insert/append +//! - Esc: exit edit mode (triggers PSC validation if enabled) +//! - Tab/Shift+Tab: next/prev field (triggers PSC validation if enabled) +//! - v: manually trigger validation of current field +//! - c: clear external validation state for current field +//! - r: toggle raw/format view flag in panel (visual only; canvas follows library rules) +//! - F10/Ctrl+C: quit +//! +//! Run: cargo run --example validation_5 --features "gui,validation" + +#![allow(clippy::needless_return)] + +#[cfg(not(all(feature = "validation", feature = "gui")))] +compile_error!( + "This example requires the 'validation' and 'gui' features. \ + Run with: cargo run --example validation_5 --features \"gui,validation\"" +); + +use std::io; +use std::sync::Arc; + +use crossterm::{ + event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers, + }, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; + +use ratatui::{ + backend::{Backend, CrosstermBackend}, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, Terminal, +}; + +use canvas::{ + canvas::{gui::render_canvas_default, modes::AppMode}, + DataProvider, FormEditor, + ValidationConfigBuilder, CustomFormatter, FormattingResult, + validation::ExternalValidationState, +}; + +/// PSC custom formatter for display ("XXX XX"). +struct PSCFormatter; + +impl CustomFormatter for PSCFormatter { + fn format(&self, raw: &str) -> FormattingResult { + let mut out = String::new(); + for (i, ch) in raw.chars().enumerate() { + if i == 3 { + out.push(' '); + } + out.push(ch); + } + FormattingResult::success(out) + } +} + +// Demo data provider: PSC + Notes +struct DemoData { + fields: Vec<(String, String)>, +} + +impl DemoData { + fn new() -> Self { + Self { + fields: vec![ + ("🏁 PSC (5 digits)".to_string(), "".to_string()), + ("πŸ“ Notes".to_string(), "".to_string()), + ], + } + } +} + +impl DataProvider for DemoData { + 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; } + + #[cfg(feature = "validation")] + fn validation_config(&self, field_index: usize) -> Option { + match field_index { + 0 => { + // PSC: Feature 4 + Feature 5 + max length cap + Some( + ValidationConfigBuilder::new() + .with_custom_formatter(Arc::new(PSCFormatter)) + .with_max_length(5) // Enforce raw max length during typing + .with_external_validation_enabled(true) // Show external validation indicator + .build() + ) + } + _ => None, + } + } +} + +/// Simulated LSP-like validator: +/// - Receives the raw PSC and returns a state (Validating -> Valid/Warn/Invalid). +/// - This is called by our "frontend" workflow when we leave PSC (or press 'v'). +/// In real apps, this would be an async backend call; we just simulate results. +fn simulate_external_psc_validation(raw_psc: &str) -> ExternalValidationState { + // Edge cases and outcomes: + // - Empty -> NotValidated (frontend shouldn't call on empty, but handle gracefully) + // - Non-digit -> Invalid + // - Length < 5 -> Warning (incomplete) + // - Exactly 5 digits: + // * "00000" -> Invalid (nonsensical) + // * "12345" -> Warning (pretend partial region) + // * "01001" -> Valid(Some("Known district")) + // * otherwise Valid(None) + if raw_psc.is_empty() { + return ExternalValidationState::NotValidated; + } + if !raw_psc.chars().all(|c| c.is_ascii_digit()) { + return ExternalValidationState::Invalid { message: "PSC must be digits".to_string(), suggestion: Some("Only 0-9".to_string()) }; + } + let len = raw_psc.chars().count(); + if len < 5 { + return ExternalValidationState::Warning { message: format!("PSC incomplete: {}/5", len) }; + } + // len == 5 + match raw_psc { + "00000" => ExternalValidationState::Invalid { message: "PSC cannot be 00000".to_string(), suggestion: Some("Try 01001".to_string()) }, + "12345" => ExternalValidationState::Warning { message: "Unrecognized region: verify".to_string() }, + "01001" => ExternalValidationState::Valid(Some("Known district".to_string())), + _ => ExternalValidationState::Valid(None), + } +} + +// Editor wrapper to manage external validation workflow and show UI info +struct DemoEditor { + editor: FormEditor, + debug_message: String, + show_raw_hint: bool, // panel-only toggle, does not affect canvas +} + +impl DemoEditor { + fn new(data_provider: D) -> Self { + let mut editor = FormEditor::new(data_provider); + editor.set_validation_enabled(true); + + Self { + editor, + debug_message: "πŸ§ͺ External validation demo (Feature 5) + Custom formatting (Feature 4)".to_string(), + show_raw_hint: false, + } + } + + fn current_field(&self) -> usize { self.editor.current_field() } + fn mode(&self) -> AppMode { self.editor.mode() } + fn data_provider(&self) -> &D { self.editor.data_provider() } + fn cursor_position(&self) -> usize { self.editor.cursor_position() } + fn ui_state(&self) -> &canvas::EditorState { self.editor.ui_state() } + + // Minimal pass-through editing controls + fn enter_edit_mode(&mut self) { + self.editor.enter_edit_mode(); + self.debug_message = "✏️ Edit PSC or Notes. PSC: raw while editing, formatted when not editing".to_string(); + } + fn enter_append_mode(&mut self) { + self.editor.enter_append_mode(); + self.debug_message = "✏️ Append mode active".to_string(); + } + fn exit_edit_mode(&mut self) { + self.editor.exit_edit_mode(); + self.debug_message = "πŸ”’ Normal mode".to_string(); + // Trigger external validation upon exiting the field (Feature 5 timing) + self.trigger_field_validation(self.editor.current_field()); + } + + fn next_field(&mut self) { + match self.editor.next_field() { + Ok(()) => { + self.debug_message = "➑ Switched to next field".to_string(); + // Validate field we just left (Feature 5 timing) + let prev = self.editor.current_field().saturating_sub(1); + self.trigger_field_validation(prev); + } + Err(e) => { + self.debug_message = format!("🚫 Cannot move to next field: {}", e); + } + } + } + fn prev_field(&mut self) { + match self.editor.prev_field() { + Ok(()) => { + self.debug_message = "β¬… Switched to previous field".to_string(); + // Validate field we just left + let prev = self.editor.current_field() + 1; + self.trigger_field_validation(prev); + } + Err(e) => { + self.debug_message = format!("🚫 Cannot move to previous field: {}", e); + } + } + } + + fn insert_char(&mut self, ch: char) -> anyhow::Result<()> { + self.editor.insert_char(ch) + } + fn delete_backward(&mut self) -> anyhow::Result<()> { + self.editor.delete_backward() + } + fn delete_forward(&mut self) -> anyhow::Result<()> { + self.editor.delete_forward() + } + + // Toggle panel hint: raw vs formatted clarification (canvas obeys library rules) + fn toggle_raw_hint(&mut self) { + self.show_raw_hint = !self.show_raw_hint; + self.debug_message = if self.show_raw_hint { + "πŸ‘ Showing raw vs display hints in panel".to_string() + } else { + "🎭 Hints off".to_string() + }; + } + + // Manually trigger validation for current field (press 'v') + fn trigger_current_field_validation(&mut self) { + let idx = self.editor.current_field(); + self.trigger_field_validation(idx); + } + + // Core: "frontend" triggers validation on blur + fn trigger_field_validation(&mut self, field_index: usize) { + // Only if field exists and has external_validation enabled + if let Some(cfg) = self.editor.validation_state().get_field_config(field_index) { + if cfg.external_validation_enabled { + let raw = self.editor.data_provider().field_value(field_index).to_string(); + if raw.is_empty() { + // Clear if empty + self.editor.clear_external_validation(field_index); + return; + } + // Set Validating + self.editor.set_external_validation(field_index, ExternalValidationState::Validating); + + // "Async" backend simulation: immediate result calculation. In a real app, do this later. + let result = simulate_external_psc_validation(&raw); + self.editor.set_external_validation(field_index, result); + } + } + } + + // Read external validation state for panel + fn external_state(&self, field_index: usize) -> ExternalValidationState { + self.editor.validation_state().get_external_validation(field_index) + } + + // Clear external validation state (press 'c') + fn clear_external_state(&mut self) { + let idx = self.editor.current_field(); + self.editor.clear_external_validation(idx); + self.debug_message = "🧹 Cleared external validation state".to_string(); + } +} + +// UI + +fn run_app( + terminal: &mut Terminal, + mut editor: DemoEditor, +) -> io::Result<()> { + loop { + terminal.draw(|f| ui(f, &editor))?; + + if let Event::Key(key) = event::read()? { + let mode = editor.mode(); + let kc = key.code; + let km = key.modifiers; + + // Quit + if (kc == KeyCode::Char('q') && km.contains(KeyModifiers::CONTROL)) + || (kc == KeyCode::Char('c') && km.contains(KeyModifiers::CONTROL)) + || kc == KeyCode::F(10) + { + break; + } + + match (mode, kc, km) { + // Modes + (AppMode::ReadOnly, KeyCode::Char('i'), _) => editor.enter_edit_mode(), + (AppMode::ReadOnly, KeyCode::Char('a'), _) => editor.enter_append_mode(), + (_, KeyCode::Esc, _) => editor.exit_edit_mode(), + + // Movement + (_, KeyCode::Tab, _) => editor.next_field(), + (_, KeyCode::BackTab, _) => editor.prev_field(), + + // Edit + (AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => { + let _ = editor.insert_char(c); + } + (AppMode::Edit, KeyCode::Backspace, _) => { let _ = editor.delete_backward(); } + (AppMode::Edit, KeyCode::Delete, _) => { let _ = editor.delete_forward(); } + + // External validation + (_, KeyCode::Char('v'), _) => { + editor.trigger_current_field_validation(); + } + (_, KeyCode::Char('c'), _) => { + editor.clear_external_state(); + } + + // Panel + (_, KeyCode::Char('r'), _) => editor.toggle_raw_hint(), + + _ => {} + } + } + } + + Ok(()) +} + +fn ui(f: &mut Frame, editor: &DemoEditor) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(8), Constraint::Length(14)]) + .split(f.area()); + + render_canvas_default(f, chunks[0], &editor.editor); + + render_status_panel(f, chunks[1], editor); +} + +fn render_status_panel(f: &mut Frame, area: Rect, editor: &DemoEditor) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(5), + Constraint::Length(6), + ]) + .split(area); + + // Bar + let mode_text = match editor.mode() { + AppMode::Edit => "INSERT", + AppMode::ReadOnly => "NORMAL", + _ => "OTHER", + }; + + let bar = Paragraph::new(Line::from(Span::raw(format!( + "-- {} -- {}", + mode_text, + editor.debug_message + )))) + .block(Block::default().borders(Borders::ALL).title("πŸ§ͺ External Validation Demo")); + + f.render_widget(bar, chunks[0]); + + // External validation snapshot + let mut lines = Vec::new(); + let field_count = editor.data_provider().field_count(); + for i in 0..field_count { + let name = editor.data_provider().field_name(i); + let raw = editor.editor.data_provider().field_value(i); + let display = editor.editor.display_text_for_field(i); + let state = editor.external_state(i); + let (label, color) = match state { + ExternalValidationState::NotValidated => ("Not validated", Color::Gray), + ExternalValidationState::Validating => ("Validating…", Color::Blue), + ExternalValidationState::Valid(Some(_)) => ("Valid βœ“", Color::Green), + ExternalValidationState::Valid(None) => ("Valid βœ“", Color::Green), + ExternalValidationState::Invalid { .. } => ("Invalid βœ–", Color::Red), + ExternalValidationState::Warning { .. } => ("Warning ⚠", Color::Yellow), + }; + + let mut text = format!("{}: ", name); + if editor.show_raw_hint { + text.push_str(&format!("raw='{}' display='{}' | ", raw, display)); + } + text.push_str(label); + + lines.push(Span::styled(text, Style::default().fg(color))); + lines.push(Span::raw("\n")); + } + + let snapshot = Paragraph::new(Line::from(lines)) + .block(Block::default().borders(Borders::ALL).title("πŸ“‘ External validation states")); + f.render_widget(snapshot, chunks[1]); + + // Help + let help = Paragraph::new( + "Controls:\n\ + β€’ i/a: insert/append, Esc: exit edit (validates PSC)\n\ + β€’ Tab/Shift+Tab: switch fields (validates PSC)\n\ + β€’ v: validate current field now, c: clear validation state\n\ + β€’ r: toggle raw/display hints (panel only)\n\ + β€’ Ctrl+C/F10: quit\n\ + \n\ + PSC rules:\n\ + β€’ Raw typing only while editing (Feature 4) and max 5 digits\n\ + β€’ On blur/exit: external validation occurs (Feature 5)\n\ + β€’ Known Valid: 01001; Invalid: 00000 or non-digits; Warning: incomplete or 12345", + ) + .style(Style::default().fg(Color::Gray)) + .block(Block::default().borders(Borders::ALL).title("β„Ή Help")) + .wrap(Wrap { trim: true }); + f.render_widget(help, chunks[2]); +} + +fn main() -> Result<(), Box> { + println!("πŸ§ͺ Feature 5: External validation (UI-only) + Feature 4: Custom formatter"); + println!("βœ… validation feature: ENABLED"); + println!("βœ… gui feature: ENABLED"); + + 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 = DemoData::new(); + let editor = DemoEditor::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!("πŸŽ‰ Example finished"); + Ok(()) +} + diff --git a/canvas/src/editor.rs b/canvas/src/editor.rs index f0b458a..38e8bcd 100644 --- a/canvas/src/editor.rs +++ b/canvas/src/editor.rs @@ -134,6 +134,24 @@ impl FormEditor { &self.ui_state } + /// Set external validation state for a field (Feature 5) + #[cfg(feature = "validation")] + pub fn set_external_validation( + &mut self, + field_index: usize, + state: crate::validation::ExternalValidationState, + ) { + self.ui_state + .validation + .set_external_validation(field_index, state); + } + + /// Clear external validation state for a field (Feature 5) + #[cfg(feature = "validation")] + pub fn clear_external_validation(&mut self, field_index: usize) { + self.ui_state.validation.clear_external_validation(field_index); + } + /// Get effective display text for any field index. /// /// Policies: diff --git a/canvas/src/validation/config.rs b/canvas/src/validation/config.rs index c6ed8bb..00fc255 100644 --- a/canvas/src/validation/config.rs +++ b/canvas/src/validation/config.rs @@ -22,6 +22,9 @@ pub struct ValidationConfig { #[cfg(feature = "validation")] pub custom_formatter: Option>, + /// Enable external validation indicator UI (feature 5) + pub external_validation_enabled: bool, + /// Future: External validation pub external_validation: Option<()>, // Placeholder for future implementation } @@ -47,6 +50,7 @@ impl std::fmt::Debug for ValidationConfig { } }, ) + .field("external_validation_enabled", &self.external_validation_enabled) .field("external_validation", &self.external_validation) .finish() } @@ -285,6 +289,12 @@ impl ValidationConfigBuilder { self } + /// Enable or disable external validation indicator UI (feature 5) + pub fn with_external_validation_enabled(mut self, enabled: bool) -> Self { + self.config.external_validation_enabled = enabled; + self + } + /// Build the final validation configuration pub fn build(self) -> ValidationConfig { self.config diff --git a/canvas/src/validation/mod.rs b/canvas/src/validation/mod.rs index fe9dd56..18b898e 100644 --- a/canvas/src/validation/mod.rs +++ b/canvas/src/validation/mod.rs @@ -16,6 +16,16 @@ pub use patterns::{PatternFilters, PositionFilter, PositionRange, CharacterFilte pub use mask::DisplayMask; // Simple mask instead of ReservedCharacters pub use formatting::{CustomFormatter, FormattingResult, PositionMapper, DefaultPositionMapper}; +/// External validation UI state (Feature 5) +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExternalValidationState { + NotValidated, + Validating, + Valid(Option), + Invalid { message: String, suggestion: Option }, + Warning { message: String }, +} + /// Validation error types #[derive(Debug, Clone, thiserror::Error)] pub enum ValidationError { diff --git a/canvas/src/validation/state.rs b/canvas/src/validation/state.rs index e4ba8f4..ff8aa93 100644 --- a/canvas/src/validation/state.rs +++ b/canvas/src/validation/state.rs @@ -1,9 +1,7 @@ // src/validation/state.rs //! Validation state management -use crate::validation::{ValidationConfig, ValidationResult}; -#[cfg(feature = "validation")] -use crate::validation::{PositionMapper}; +use crate::validation::{ValidationConfig, ValidationResult, ExternalValidationState}; use std::collections::HashMap; /// Validation state for all fields in a form @@ -20,6 +18,9 @@ pub struct ValidationState { /// Global validation enabled/disabled enabled: bool, + + /// External validation results per field (Feature 5) + external_results: HashMap, } impl ValidationState { @@ -30,6 +31,7 @@ impl ValidationState { field_results: HashMap::new(), validated_fields: std::collections::HashSet::new(), enabled: true, + external_results: HashMap::new(), } } @@ -40,6 +42,7 @@ impl ValidationState { // Clear all validation results when disabled self.field_results.clear(); self.validated_fields.clear(); + self.external_results.clear(); // Also clear external results } } @@ -50,12 +53,13 @@ impl ValidationState { /// Set validation configuration for a field pub fn set_field_config(&mut self, field_index: usize, config: ValidationConfig) { - if config.has_validation() { + if config.has_validation() || config.external_validation_enabled { 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); + self.external_results.remove(&field_index); } } @@ -69,6 +73,30 @@ impl ValidationState { self.field_configs.remove(&field_index); self.field_results.remove(&field_index); self.validated_fields.remove(&field_index); + self.external_results.remove(&field_index); + } + + /// Set external validation state for a field (Feature 5) + pub fn set_external_validation(&mut self, field_index: usize, state: ExternalValidationState) { + self.external_results.insert(field_index, state); + } + + /// Get current external validation state for a field + pub fn get_external_validation(&self, field_index: usize) -> ExternalValidationState { + self.external_results + .get(&field_index) + .cloned() + .unwrap_or(ExternalValidationState::NotValidated) + } + + /// Clear external validation state for a field + pub fn clear_external_validation(&mut self, field_index: usize) { + self.external_results.remove(&field_index); + } + + /// Clear all external validation states + pub fn clear_all_external_validation(&mut self) { + self.external_results.clear(); } /// Validate character insertion for a field @@ -123,7 +151,7 @@ impl ValidationState { pub fn get_field_result(&self, field_index: usize) -> Option<&ValidationResult> { self.field_results.get(&field_index) } - + /// Get formatted display for a field if a custom formatter is configured. /// Returns (formatted_text, position_mapper, optional_warning_message). #[cfg(feature = "validation")] @@ -131,7 +159,7 @@ impl ValidationState { &self, field_index: usize, raw: &str, - ) -> Option<(String, std::sync::Arc, Option)> { + ) -> Option<(String, std::sync::Arc, Option)> { let config = self.field_configs.get(&field_index)?; config.run_custom_formatter(raw) }