Compare commits

..

19 Commits

Author SHA1 Message Date
Priec
215be3cf09 renamed capital lettered functions and fixed examples 2025-08-16 23:10:50 +02:00
Priec
b2aa966588 suggestions behind features flag only 2025-08-15 00:06:54 +02:00
Priec
67512ac151 src/editor.rs doesnt exist anymore 2025-08-15 00:06:19 +02:00
Priec
3f5dedbd6e a bit of a cleanup, updated functionality of ge now working porperly well 2025-08-14 14:23:08 +02:00
Priec
ce07105eea more vim functionality added 2025-08-14 00:08:18 +02:00
Priec
587470c48b vim like behaviour is being built 2025-08-13 22:16:28 +02:00
Priec
3227d341ed cleared codebase 2025-08-13 01:22:50 +02:00
Priec
2b16a80ef8 removed silenced variables 2025-08-12 09:53:24 +02:00
Priec
8b742bbe09 comments for reimplementation of autotrigger 2025-08-11 23:08:57 +02:00
Priec
189d3d2fc5 suggestions2 only on tab trigger and not automatic 2025-08-11 23:05:56 +02:00
Priec
082093ea17 compiled examples 2025-08-11 22:50:28 +02:00
Priec
280f314100 fixing examples 2025-08-11 12:41:42 +02:00
Priec
163a6262c8 proper example is being set 2025-08-11 12:03:56 +02:00
Priec
e8a564aed3 sugggestions are agnostic 2025-08-11 00:01:53 +02:00
filipriec
53464dfcbf switch handled by the library from now on 2025-08-10 22:07:25 +02:00
filipriec
b364a6606d fixing more code refactorization 2025-08-10 16:10:45 +02:00
Priec
f09e476bb6 working, restored 2025-08-10 12:20:43 +02:00
Priec
e2c9cc4347 WIP: staged changes before destructive reset 2025-08-10 11:03:31 +02:00
Priec
06106dc31b improvements done by gpt5 2025-08-08 23:10:23 +02:00
33 changed files with 4271 additions and 2352 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ server/tantivy_indexes
steel_decimal/tests/property_tests.proptest-regressions
.direnv/
canvas/*.toml
.aider*

View File

@@ -42,29 +42,34 @@ required-features = ["suggestions", "gui", "cursor-style"]
path = "examples/suggestions.rs"
[[example]]
name = "canvas_gui_demo"
name = "suggestions2"
required-features = ["suggestions", "gui", "cursor-style"]
path = "examples/suggestions2.rs"
[[example]]
name = "canvas_cursor_auto"
required-features = ["gui", "cursor-style"]
path = "examples/canvas_gui_demo.rs"
path = "examples/canvas_cursor_auto.rs"
[[example]]
name = "validation_1"
required-features = ["gui", "validation"]
required-features = ["gui", "validation", "cursor-style"]
[[example]]
name = "validation_2"
required-features = ["gui", "validation"]
required-features = ["gui", "validation", "cursor-style"]
[[example]]
name = "validation_3"
required-features = ["gui", "validation"]
required-features = ["gui", "validation", "cursor-style"]
[[example]]
name = "validation_4"
required-features = ["gui", "validation"]
required-features = ["gui", "validation", "cursor-style"]
[[example]]
name = "validation_5"
required-features = ["gui", "validation"]
required-features = ["gui", "validation", "cursor-style"]
[[example]]
name = "computed_fields"

View File

@@ -275,7 +275,8 @@ impl<D: DataProvider> AutoCursorFormEditor<D> {
}
fn current_text(&self) -> &str {
self.editor.current_text()
let field_index = self.editor.current_field();
self.editor.data_provider().field_value(field_index)
}
fn data_provider(&self) -> &D {
@@ -306,6 +307,42 @@ impl<D: DataProvider> AutoCursorFormEditor<D> {
fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
fn open_line_below(&mut self) -> anyhow::Result<()> {
let result = self.editor.open_line_below();
if result.is_ok() {
self.debug_message = "✏️ INSERT (open line below) - Cursor: Steady Bar |".to_string();
}
result
}
fn open_line_above(&mut self) -> anyhow::Result<()> {
let result = self.editor.open_line_above();
if result.is_ok() {
self.debug_message = "✏️ INSERT (open line above) - Cursor: Steady Bar |".to_string();
}
result
}
fn move_big_word_next(&mut self) {
self.editor.move_big_word_next();
self.update_visual_selection();
}
fn move_big_word_prev(&mut self) {
self.editor.move_big_word_prev();
self.update_visual_selection();
}
fn move_big_word_end(&mut self) {
self.editor.move_big_word_end();
self.update_visual_selection();
}
fn move_big_word_end_prev(&mut self) {
self.editor.move_big_word_end_prev();
self.update_visual_selection();
}
}
// Demo form data with interesting text for cursor demonstration
@@ -389,10 +426,17 @@ fn handle_key_press(
editor.set_debug_message("✏️ INSERT (end of line) - Cursor: Steady Bar |".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('o'), _) => {
editor.move_line_end();
editor.enter_edit_mode(); // 🎯 Automatic: cursor becomes bar |
editor.set_debug_message("✏️ INSERT (open line) - Cursor: Steady Bar |".to_string());
if let Err(e) = editor.open_line_below() {
editor.set_debug_message(format!("Error opening line below: {}", e));
}
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('O'), _) => {
if let Err(e) = editor.open_line_above() {
editor.set_debug_message(format!("Error opening line above: {}", e));
}
editor.clear_command_buffer();
}
@@ -507,10 +551,40 @@ fn handle_key_press(
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('e'), _) => {
editor.move_word_end();
editor.set_debug_message("e: word end".to_string());
// Check if this is 'ge' command
if editor.get_command_buffer() == "g" {
editor.move_word_end_prev();
editor.set_debug_message("ge: previous word end".to_string());
editor.clear_command_buffer();
} else {
editor.move_word_end();
editor.set_debug_message("e: word end".to_string());
editor.clear_command_buffer();
}
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('W'), _) => {
editor.move_big_word_next();
editor.set_debug_message("W: next WORD start".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('B'), _) => {
editor.move_big_word_prev();
editor.set_debug_message("B: previous WORD start".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('E'), _) => {
// Check if this is 'gE' command
if editor.get_command_buffer() == "g" {
editor.move_big_word_end_prev();
editor.set_debug_message("gE: previous WORD end".to_string());
editor.clear_command_buffer();
} else {
editor.move_big_word_end();
editor.set_debug_message("E: WORD end".to_string());
editor.clear_command_buffer();
}
}
// Line movement
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('0'), _)
@@ -721,9 +795,9 @@ fn render_status_and_help(
}
} else {
"🎯 CURSOR-STYLE DEMO: Normal █ | Insert | | Visual blinking█\n\
Normal: hjkl/arrows=move, w/b/e=words, 0/$=line, gg/G=first/last\n\
i/a/A=insert, v/b=visual, x/X=delete, ?=info\n\
F1=demo manual cursor, F2=restore automatic"
Normal: hjkl/arrows=move, w/b/e=words, W/B/E=WORDS, 0/$=line, gg/G=first/last\n\
i/a/A/o/O=insert, v/V=visual, x/X=delete, ?=info\n\
F1=demo manual cursor, F2=restore automatic"
}
}
AppMode::Edit => {

View File

@@ -383,7 +383,10 @@ impl<D: DataProvider> ComputedFieldsEditor<D> {
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 current_text(&self) -> &str {
let field_index = self.editor.current_field();
self.editor.data_provider().field_value(field_index)
}
fn data_provider(&self) -> &D { self.editor.data_provider() }
fn ui_state(&self) -> &canvas::EditorState { self.editor.ui_state() }
fn move_left(&mut self) { self.editor.move_left(); }

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,11 @@
// examples/suggestions2.rs
//! Demonstrates automatic cursor management + MULTIPLE SUGGESTION FIELDS
//! Production-ready Tab-triggered suggestions demonstration
//!
//! This example REQUIRES the `cursor-style` feature to compile.
//! This example demonstrates:
//! - Tab-triggered suggestions dropdown
//! - Non-blocking architecture for real network/database calls
//! - Multiple suggestion field types
//! - Professional-grade user experience
//!
//! Run with:
//! cargo run --example suggestions2 --features "gui,cursor-style,suggestions"
@@ -35,7 +39,7 @@ use ratatui::{
use canvas::{
canvas::{
gui::render_canvas_default,
modes::{AppMode, ModeManager, HighlightState},
modes::AppMode,
CursorManager, // This import only exists when cursor-style feature is enabled
},
suggestions::gui::render_suggestions_dropdown,
@@ -45,7 +49,7 @@ use canvas::{
use async_trait::async_trait;
use anyhow::Result;
// Enhanced FormEditor that demonstrates automatic cursor management + SUGGESTIONS
// Enhanced FormEditor that demonstrates professional suggestions architecture
struct AutoCursorFormEditor<D: DataProvider> {
editor: FormEditor<D>,
has_unsaved_changes: bool,
@@ -58,11 +62,15 @@ impl<D: DataProvider> AutoCursorFormEditor<D> {
Self {
editor: FormEditor::new(data_provider),
has_unsaved_changes: false,
debug_message: "🎯 Multi-Field Suggestions Demo - 5 fields with different suggestions!".to_string(),
debug_message: "🚀 Production-Ready Tab-Triggered Suggestions Demo - Copy this architecture for your app!".to_string(),
command_buffer: String::new(),
}
}
fn close_suggestions(&mut self) {
self.editor.close_suggestions();
}
// === COMMAND BUFFER HANDLING ===
fn clear_command_buffer(&mut self) {
@@ -84,19 +92,16 @@ impl<D: DataProvider> AutoCursorFormEditor<D> {
// === VISUAL/HIGHLIGHT MODE SUPPORT ===
fn enter_visual_mode(&mut self) {
// Use the library method instead of manual state setting
self.editor.enter_highlight_mode();
self.debug_message = "🔥 VISUAL MODE - Cursor: Blinking Block █".to_string();
}
fn enter_visual_line_mode(&mut self) {
// Use the library method instead of manual state setting
self.editor.enter_highlight_line_mode();
self.debug_message = "🔥 VISUAL LINE MODE - Cursor: Blinking Block █".to_string();
}
fn exit_visual_mode(&mut self) {
// Use the library method
self.editor.exit_highlight_mode();
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █".to_string();
}
@@ -128,12 +133,12 @@ impl<D: DataProvider> AutoCursorFormEditor<D> {
// === ENHANCED MOVEMENT WITH VISUAL UPDATES ===
fn move_left(&mut self) {
self.editor.move_left();
let _ = self.editor.move_left();
self.update_visual_selection();
}
fn move_right(&mut self) {
self.editor.move_right();
let _ = self.editor.move_right();
self.update_visual_selection();
}
@@ -178,12 +183,12 @@ impl<D: DataProvider> AutoCursorFormEditor<D> {
}
fn move_first_line(&mut self) {
self.editor.move_first_line();
let _ = self.editor.move_first_line();
self.update_visual_selection();
}
fn move_last_line(&mut self) {
self.editor.move_last_line();
let _ = self.editor.move_last_line();
self.update_visual_selection();
}
@@ -223,20 +228,16 @@ impl<D: DataProvider> AutoCursorFormEditor<D> {
self.editor.open_suggestions(field_index);
}
fn close_suggestions(&mut self) {
self.editor.close_suggestions();
}
// === MODE TRANSITIONS WITH AUTOMATIC CURSOR MANAGEMENT ===
fn enter_edit_mode(&mut self) {
self.editor.enter_edit_mode(); // 🎯 Library automatically sets cursor to bar |
self.debug_message = "✏️ INSERT MODE - Cursor: Steady Bar |".to_string();
self.debug_message = "✏️ INSERT MODE - Cursor: Steady Bar | - Press Tab for suggestions".to_string();
}
fn enter_append_mode(&mut self) {
self.editor.enter_append_mode(); // 🎯 Library automatically positions cursor and sets mode
self.debug_message = "✏️ INSERT (append) - Cursor: Steady Bar |".to_string();
self.debug_message = "✏️ INSERT (append) - Cursor: Steady Bar | - Press Tab for suggestions".to_string();
}
fn exit_edit_mode(&mut self) {
@@ -253,13 +254,39 @@ impl<D: DataProvider> AutoCursorFormEditor<D> {
Ok(result?)
}
// === SUGGESTIONS SUPPORT ===
// === PRODUCTION-READY NON-BLOCKING SUGGESTIONS ===
async fn trigger_suggestions<A>(&mut self, provider: &mut A) -> anyhow::Result<()>
where
A: SuggestionsProvider,
{
self.editor.trigger_suggestions(provider).await
/// Trigger suggestions with non-blocking approach (production pattern)
///
/// This method demonstrates the proper way to integrate suggestions with
/// real APIs, databases, or any async data source without blocking the UI.
async fn trigger_suggestions_async(
&mut self,
provider: &mut ProductionSuggestionsProvider,
field_index: usize,
) {
// Step 1: Start loading immediately (UI updates instantly)
if let Some(query) = self.editor.start_suggestions(field_index) {
// Step 2: Fetch from your data source (API, database, etc.)
match provider.fetch_suggestions(field_index, &query).await {
Ok(results) => {
// Step 3: Apply results with built-in stale protection
let applied = self.editor.apply_suggestions_result(field_index, &query, results);
if applied {
self.editor.update_inline_completion();
if self.editor.suggestions().is_empty() {
self.set_debug_message(format!("🔍 No matches for '{}'", query));
} else {
self.set_debug_message(format!("{} matches for '{}'", self.editor.suggestions().len(), query));
}
}
// If not applied, results were stale (user kept typing)
}
Err(e) => {
self.set_debug_message(format!("❌ Suggestion error: {}", e));
}
}
}
}
fn suggestions_next(&mut self) {
@@ -284,16 +311,13 @@ impl<D: DataProvider> AutoCursorFormEditor<D> {
// === MANUAL CURSOR OVERRIDE DEMONSTRATION ===
/// Demonstrate manual cursor control (for advanced users)
fn demo_manual_cursor_control(&mut self) -> std::io::Result<()> {
// Users can still manually control cursor if needed
CursorManager::update_for_mode(AppMode::Command)?;
self.debug_message = "🔧 Manual override: Command cursor _".to_string();
Ok(())
}
fn restore_automatic_cursor(&mut self) -> std::io::Result<()> {
// Restore automatic cursor based on current mode
CursorManager::update_for_mode(self.editor.mode())?;
self.debug_message = "🎯 Restored automatic cursor management".to_string();
Ok(())
@@ -314,7 +338,8 @@ impl<D: DataProvider> AutoCursorFormEditor<D> {
}
fn current_text(&self) -> &str {
self.editor.current_text()
let field_index = self.editor.current_field();
self.editor.data_provider().field_value(field_index)
}
fn data_provider(&self) -> &D {
@@ -348,28 +373,28 @@ impl<D: DataProvider> AutoCursorFormEditor<D> {
}
// ===================================================================
// MULTI-FIELD DEMO DATA - 5 different types of suggestion fields
// PRODUCTION DATA MODEL - Copy this pattern for your application
// ===================================================================
struct MultiFieldDemoData {
struct ApplicationData {
fields: Vec<(String, String)>,
}
impl MultiFieldDemoData {
impl ApplicationData {
fn new() -> Self {
Self {
fields: vec![
("🍎 Favorite Fruit".to_string(), "".to_string()), // Field 0: Fruits
("💼 Job Role".to_string(), "".to_string()), // Field 1: Jobs
("💻 Programming Language".to_string(), "".to_string()), // Field 2: Languages
("🌍 Country".to_string(), "".to_string()), // Field 3: Countries
("🎨 Favorite Color".to_string(), "".to_string()), // Field 4: Colors
("🍎 Favorite Fruit".to_string(), "".to_string()),
("💼 Job Role".to_string(), "".to_string()),
("💻 Programming Language".to_string(), "".to_string()),
("🌍 Country".to_string(), "".to_string()),
("🎨 Favorite Color".to_string(), "".to_string()),
],
}
}
}
impl DataProvider for MultiFieldDemoData {
impl DataProvider for ApplicationData {
fn field_count(&self) -> usize {
self.fields.len()
}
@@ -387,7 +412,7 @@ impl DataProvider for MultiFieldDemoData {
}
fn supports_suggestions(&self, field_index: usize) -> bool {
// All 5 fields support suggestions - perfect for testing!
// Configure which fields support suggestions
field_index < 5
}
@@ -397,36 +422,74 @@ impl DataProvider for MultiFieldDemoData {
}
// ===================================================================
// COMPREHENSIVE SUGGESTIONS PROVIDER - 5 different suggestion types!
// PRODUCTION SUGGESTIONS PROVIDER - Copy this pattern for your APIs
// ===================================================================
struct ComprehensiveSuggestionsProvider;
/// Production-ready suggestions provider
///
/// Replace the data sources below with your actual:
/// - REST API calls (reqwest, hyper)
/// - Database queries (sqlx, diesel)
/// - Search engines (elasticsearch, algolia)
/// - Cache lookups (redis, memcached)
/// - GraphQL queries
/// - gRPC services
///
/// The non-blocking architecture works with any async data source.
struct ProductionSuggestionsProvider {
// Add your API clients, database connections, cache clients here
// Example:
// api_client: reqwest::Client,
// db_pool: sqlx::PgPool,
// cache: redis::Client,
}
impl ComprehensiveSuggestionsProvider {
impl ProductionSuggestionsProvider {
fn new() -> Self {
Self
Self {
// Initialize your clients here
// api_client: reqwest::Client::new(),
// db_pool: create_db_pool().await,
// cache: redis::Client::open("redis://localhost").unwrap(),
}
}
/// Get fruit suggestions (field 0)
fn get_fruit_suggestions(&self, query: &str) -> Vec<SuggestionItem> {
/// Get fruit suggestions (replace with your API call)
async fn get_fruit_suggestions(&self, query: &str) -> Result<Vec<SuggestionItem>> {
// Example: Replace with actual API call
// let response = self.api_client
// .get(&format!("https://api.example.com/fruits?q={}", query))
// .send()
// .await?;
// let fruits: Vec<Fruit> = response.json().await?;
let fruits = vec![
("Apple", "🍎 Crisp and sweet"),
("Banana", "🍌 Rich in potassium"),
("Cherry", "🍒 Small and tart"),
("Date", "📅 Sweet and chewy"),
("Ananas", "🍎 Crisp and sweet"),
("Elderberry", "🫐 Dark purple berry"),
("Fig", "🍇 Sweet Mediterranean fruit"),
("Grape", "🍇 Perfect for wine"),
("Honeydew", "🍈 Sweet melon"),
("Ananas", "🍎 Crisp and sweet"),
("Avocado", "🍈 Sweet melon"),
("avocado", "🍎 Crisp and sweet"),
];
self.filter_suggestions(fruits, query, "fruit")
Ok(self.filter_suggestions(fruits, query))
}
/// Get job role suggestions (field 1)
fn get_job_suggestions(&self, query: &str) -> Vec<SuggestionItem> {
/// Get job suggestions (replace with your database query)
async fn get_job_suggestions(&self, query: &str) -> Result<Vec<SuggestionItem>> {
// Example: Replace with actual database query
// let jobs = sqlx::query_as!(
// JobRow,
// "SELECT title, description FROM jobs WHERE title ILIKE $1 LIMIT 10",
// format!("%{}%", query)
// )
// .fetch_all(&self.db_pool)
// .await?;
let jobs = vec![
("Software Engineer", "👨‍💻 Build applications"),
("Product Manager", "📋 Manage product roadmap"),
@@ -438,11 +501,17 @@ impl ComprehensiveSuggestionsProvider {
("Accountant", "💼 Manage finances"),
];
self.filter_suggestions(jobs, query, "role")
Ok(self.filter_suggestions(jobs, query))
}
/// Get programming language suggestions (field 2)
fn get_language_suggestions(&self, query: &str) -> Vec<SuggestionItem> {
/// Get language suggestions (replace with your cache lookup)
async fn get_language_suggestions(&self, query: &str) -> Result<Vec<SuggestionItem>> {
// Example: Replace with cache lookup + fallback to API
// let cached = self.cache.get(&format!("langs:{}", query)).await?;
// if let Some(cached_result) = cached {
// return Ok(serde_json::from_str(&cached_result)?);
// }
let languages = vec![
("Rust", "🦀 Systems programming"),
("Python", "🐍 Versatile and popular"),
@@ -454,11 +523,18 @@ impl ComprehensiveSuggestionsProvider {
("Swift", "🍎 iOS development"),
];
self.filter_suggestions(languages, query, "language")
Ok(self.filter_suggestions(languages, query))
}
/// Get country suggestions (field 3)
fn get_country_suggestions(&self, query: &str) -> Vec<SuggestionItem> {
/// Get country suggestions (replace with your geographic API)
async fn get_country_suggestions(&self, query: &str) -> Result<Vec<SuggestionItem>> {
// Example: Replace with geographic API call
// let response = self.api_client
// .get(&format!("https://restcountries.com/v3.1/name/{}", query))
// .send()
// .await?;
// let countries: Vec<Country> = response.json().await?;
let countries = vec![
("United States", "🇺🇸 North America"),
("Canada", "🇨🇦 Great neighbors"),
@@ -470,11 +546,11 @@ impl ComprehensiveSuggestionsProvider {
("Brazil", "🇧🇷 Carnival country"),
];
self.filter_suggestions(countries, query, "country")
Ok(self.filter_suggestions(countries, query))
}
/// Get color suggestions (field 4)
fn get_color_suggestions(&self, query: &str) -> Vec<SuggestionItem> {
/// Get color suggestions (local data)
async fn get_color_suggestions(&self, query: &str) -> Result<Vec<SuggestionItem>> {
let colors = vec![
("Red", "🔴 Bold and energetic"),
("Blue", "🔵 Calm and trustworthy"),
@@ -486,11 +562,11 @@ impl ComprehensiveSuggestionsProvider {
("Black", "⚫ Classic and elegant"),
];
self.filter_suggestions(colors, query, "color")
Ok(self.filter_suggestions(colors, query))
}
/// Generic filtering helper
fn filter_suggestions(&self, items: Vec<(&str, &str)>, query: &str, _category: &str) -> Vec<SuggestionItem> {
/// Generic filtering helper (reusable for any data source)
fn filter_suggestions(&self, items: Vec<(&str, &str)>, query: &str) -> Vec<SuggestionItem> {
let query_lower = query.to_lowercase();
items.iter()
@@ -506,38 +582,26 @@ impl ComprehensiveSuggestionsProvider {
}
#[async_trait]
impl SuggestionsProvider for ComprehensiveSuggestionsProvider {
impl SuggestionsProvider for ProductionSuggestionsProvider {
/// Main suggestions entry point - route to appropriate data source
async fn fetch_suggestions(&mut self, field_index: usize, query: &str) -> Result<Vec<SuggestionItem>> {
// Simulate different network delays for different fields (realistic!)
let delay_ms = match field_index {
0 => 100, // Fruits: local data
1 => 200, // Jobs: medium API call
2 => 150, // Languages: cached data
3 => 300, // Countries: slow geographic API
4 => 80, // Colors: instant local
_ => 100,
};
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
let suggestions = match field_index {
0 => self.get_fruit_suggestions(query),
1 => self.get_job_suggestions(query),
2 => self.get_language_suggestions(query),
3 => self.get_country_suggestions(query),
4 => self.get_color_suggestions(query),
_ => Vec::new(),
};
Ok(suggestions)
match field_index {
0 => self.get_fruit_suggestions(query).await, // API call
1 => self.get_job_suggestions(query).await, // Database query
2 => self.get_language_suggestions(query).await, // Cache + API
3 => self.get_country_suggestions(query).await, // Geographic API
4 => self.get_color_suggestions(query).await, // Local data
_ => Ok(Vec::new()),
}
}
}
/// Multi-field suggestions demonstration + automatic cursor management
/// Production-ready key handling with Tab-triggered suggestions
async fn handle_key_press(
key: KeyCode,
modifiers: KeyModifiers,
editor: &mut AutoCursorFormEditor<MultiFieldDemoData>,
suggestions_provider: &mut ComprehensiveSuggestionsProvider,
editor: &mut AutoCursorFormEditor<ApplicationData>,
suggestions_provider: &mut ProductionSuggestionsProvider,
) -> anyhow::Result<bool> {
let mode = editor.mode();
@@ -550,27 +614,16 @@ async fn handle_key_press(
}
match (mode, key, modifiers) {
// === SUGGESTIONS HANDLING ===
// === TAB-TRIGGERED SUGGESTIONS HANDLING ===
(_, KeyCode::Tab, _) => {
if editor.is_suggestions_active() {
// Cycle through suggestions
editor.suggestions_next();
editor.set_debug_message("📍 Next suggestion".to_string());
} else if editor.data_provider().supports_suggestions(editor.current_field()) {
// Open suggestions explicitly
editor.open_suggestions(editor.current_field());
match editor.trigger_suggestions(suggestions_provider).await {
Ok(_) => {
editor.update_inline_completion();
editor.set_debug_message(format!(
"{} suggestions loaded",
editor.suggestions().len()
));
}
Err(e) => {
editor.set_debug_message(format!("❌ Suggestion error: {}", e));
}
}
// Trigger non-blocking suggestions
let field_index = editor.current_field();
editor.trigger_suggestions_async(suggestions_provider, field_index).await;
} else {
editor.next_field();
editor.set_debug_message("Tab: next field".to_string());
@@ -613,26 +666,21 @@ async fn handle_key_press(
}
}
// === MODE TRANSITIONS WITH AUTOMATIC CURSOR MANAGEMENT ===
// === MODE TRANSITIONS (NO AUTO-SUGGESTIONS) ===
(AppMode::ReadOnly, KeyCode::Char('i'), _) => {
editor.enter_edit_mode(); // 🎯 Automatic: cursor becomes bar |
editor.enter_edit_mode();
editor.clear_command_buffer();
// Auto-show suggestions on entering insert mode
if editor.data_provider().supports_suggestions(editor.current_field()) {
let _ = editor.trigger_suggestions(suggestions_provider).await;
editor.update_inline_completion();
}
// For auto-suggestions on insert: add `editor.auto_trigger_suggestions(suggestions_provider).await;`
}
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
editor.enter_append_mode();
editor.set_debug_message("✏️ INSERT (append) - Cursor: Steady Bar |".to_string());
editor.set_debug_message("✏️ INSERT (append) - Cursor: Steady Bar | - Press Tab for suggestions".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('A'), _) => {
editor.move_line_end();
editor.enter_edit_mode(); // 🎯 Automatic: cursor becomes bar |
editor.set_debug_message("✏️ INSERT (end of line) - Cursor: Steady Bar |".to_string());
editor.enter_edit_mode();
editor.set_debug_message("✏️ INSERT (end of line) - Cursor: Steady Bar | - Press Tab for suggestions".to_string());
editor.clear_command_buffer();
}
@@ -671,7 +719,6 @@ async fn handle_key_press(
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('j'), _)
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Down, _) => {
editor.close_suggestions(); // ⬅ close dropdown
editor.move_down();
let field_names = ["Fruit", "Job", "Language", "Country", "Color"];
let field_name = field_names.get(editor.current_field()).unwrap_or(&"Field");
@@ -680,7 +727,6 @@ async fn handle_key_press(
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('k'), _)
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Up, _) => {
editor.close_suggestions(); // ⬅ close dropdown
editor.move_up();
let field_names = ["Fruit", "Job", "Language", "Country", "Color"];
let field_name = field_names.get(editor.current_field()).unwrap_or(&"Field");
@@ -751,11 +797,9 @@ async fn handle_key_press(
editor.move_right();
}
(AppMode::Edit, KeyCode::Up, _) => {
editor.close_suggestions();
editor.move_up();
}
(AppMode::Edit, KeyCode::Down, _) => {
editor.close_suggestions();
editor.move_down();
}
(AppMode::Edit, KeyCode::Home, _) => {
@@ -765,59 +809,23 @@ async fn handle_key_press(
editor.move_line_end();
}
// === DELETE OPERATIONS ===
// === DELETE OPERATIONS (AUTO-FETCH WHEN SUGGESTIONS ACTIVE) ===
(AppMode::Edit, KeyCode::Backspace, _) => {
editor.delete_backward()?;
// Update suggestions after deletion
if editor.data_provider().supports_suggestions(editor.current_field()) {
let current_text = editor.current_text().to_string();
if current_text.is_empty() {
let _ = editor.trigger_suggestions(suggestions_provider).await;
editor.set_debug_message(format!("{} total suggestions", editor.suggestions().len()));
editor.update_inline_completion();
} else {
match editor.trigger_suggestions(suggestions_provider).await {
Ok(_) => {
if editor.suggestions().is_empty() {
editor.set_debug_message(format!("🔍 No matches for '{}'", current_text));
} else {
editor.set_debug_message(format!("{} matches for '{}'", editor.suggestions().len(), current_text));
}
}
Err(e) => {
editor.set_debug_message(format!("❌ Suggestion error: {}", e));
}
}
editor.update_inline_completion();
}
// Auto-fetch only if suggestions are already active (triggered by Tab)
// For full auto-triggering: remove the `if` check below
if editor.is_suggestions_active() {
let field_index = editor.current_field();
editor.trigger_suggestions_async(suggestions_provider, field_index).await;
}
}
(AppMode::Edit, KeyCode::Delete, _) => {
editor.delete_forward()?;
// Update suggestions after deletion
if editor.data_provider().supports_suggestions(editor.current_field()) {
let current_text = editor.current_text().to_string();
if current_text.is_empty() {
let _ = editor.trigger_suggestions(suggestions_provider).await;
editor.set_debug_message(format!("{} total suggestions", editor.suggestions().len()));
editor.update_inline_completion();
} else {
match editor.trigger_suggestions(suggestions_provider).await {
Ok(_) => {
if editor.suggestions().is_empty() {
editor.set_debug_message(format!("🔍 No matches for '{}'", current_text));
} else {
editor.set_debug_message(format!("{} matches for '{}'", editor.suggestions().len(), current_text));
}
}
Err(e) => {
editor.set_debug_message(format!("❌ Suggestion error: {}", e));
}
}
editor.update_inline_completion();
}
// Auto-fetch only if suggestions are already active (triggered by Tab)
// For full auto-triggering: remove the `if` check below
if editor.is_suggestions_active() {
let field_index = editor.current_field();
editor.trigger_suggestions_async(suggestions_provider, field_index).await;
}
}
@@ -831,26 +839,14 @@ async fn handle_key_press(
editor.set_debug_message("X: deleted character backward".to_string());
}
// === CHARACTER INPUT WITH REAL-TIME SUGGESTIONS ===
// === CHARACTER INPUT (AUTO-FETCH WHEN SUGGESTIONS ACTIVE) ===
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
editor.insert_char(c)?;
// Auto-trigger suggestions after typing
if editor.data_provider().supports_suggestions(editor.current_field()) {
match editor.trigger_suggestions(suggestions_provider).await {
Ok(_) => {
let current_text = editor.current_text().to_string();
if editor.suggestions().is_empty() {
editor.set_debug_message(format!("🔍 No matches for '{}'", current_text));
} else {
editor.set_debug_message(format!("{} matches for '{}'", editor.suggestions().len(), current_text));
}
editor.update_inline_completion();
}
Err(e) => {
editor.set_debug_message(format!("❌ Suggestion error: {}", e));
}
}
// Auto-fetch only if suggestions are already active (triggered by Tab)
// For full auto-triggering: remove the `if` check below
if editor.is_suggestions_active() {
let field_index = editor.current_field();
editor.trigger_suggestions_async(suggestions_provider, field_index).await;
}
}
@@ -888,9 +884,9 @@ async fn handle_key_press(
async fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut editor: AutoCursorFormEditor<MultiFieldDemoData>,
mut editor: AutoCursorFormEditor<ApplicationData>,
) -> io::Result<()> {
let mut suggestions_provider = ComprehensiveSuggestionsProvider::new();
let mut suggestions_provider = ProductionSuggestionsProvider::new();
loop {
terminal.draw(|f| ui(f, &editor))?;
@@ -912,7 +908,7 @@ async fn run_app<B: Backend>(
Ok(())
}
fn ui(f: &mut Frame, editor: &AutoCursorFormEditor<MultiFieldDemoData>) {
fn ui(f: &mut Frame, editor: &AutoCursorFormEditor<ApplicationData>) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(12)])
@@ -937,7 +933,7 @@ fn ui(f: &mut Frame, editor: &AutoCursorFormEditor<MultiFieldDemoData>) {
fn render_enhanced_canvas(
f: &mut Frame,
area: ratatui::layout::Rect,
editor: &AutoCursorFormEditor<MultiFieldDemoData>,
editor: &AutoCursorFormEditor<ApplicationData>,
) -> Option<ratatui::layout::Rect> {
render_canvas_default(f, area, &editor.editor)
}
@@ -945,7 +941,7 @@ fn render_enhanced_canvas(
fn render_status_and_help(
f: &mut Frame,
area: ratatui::layout::Rect,
editor: &AutoCursorFormEditor<MultiFieldDemoData>,
editor: &AutoCursorFormEditor<ApplicationData>,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
@@ -984,38 +980,37 @@ fn render_status_and_help(
);
let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("🎯 Multi-Field Suggestions Demo"));
.block(Block::default().borders(Borders::ALL).title("🚀 Production-Ready Smart Suggestions (Tab to activate → type to filter)"));
f.render_widget(status, chunks[0]);
// Comprehensive help text
// Production help text
let help_text = match editor.mode() {
AppMode::ReadOnly => {
"🎯 MULTI-FIELD SUGGESTIONS DEMO: Normal █ | Insert | | Visual █\n\
"🚀 PRODUCTION-READY SUGGESTIONS: Copy this architecture for your app!\n\
Movement: j/k or ↑↓=fields, h/l or ←→=chars, gg/G=first/last, w/b/e=words\n\
Actions: i/a/A=insert, v/V=visual, x/X=delete, ?=info, Enter=next field\n\
🍎 Fruits: Apple, Banana, Cherry... | 💼 Jobs: Engineer, Manager, Designer...\n\
💻 Languages: Rust, Python, JS... | 🌍 Countries: USA, Canada, UK...\n\
🎨 Colors: Red, Blue, Green... | Tab=suggestions, Enter=select\n\
Edge cases to test: empty→suggestions, partial matches, field navigation!"
Integration: Replace data sources with your APIs, databases, caches\n\
Architecture: Non-blocking • Instant UI • Stale protection • Professional UX\n\
🔑 Tab=activate suggestions → type to filter • Enter=select • Ready for: REST, GraphQL, SQL, Redis"
}
AppMode::Edit => {
"🎯 INSERT MODE - Cursor: | (bar)\n\
Type to filter suggestions! Tab=show/cycle, Enter=select, Esc=normal\n\
Test cases: 'r'→Red/Rust, 's'→Software Engineer/Swift, 'c'→Canada/Cherry...\n\
"🚀 INSERT MODE - Press Tab to activate suggestions, then type to filter!\n\
Tab=activate suggestions • Type/Backspace=filter while active • Enter=select\n\
Perfect for: Autocomplete, search dropdowns, data entry assistance\n\
Navigation: arrows=move, Ctrl+arrows=words, Home/End=line edges\n\
Try different fields for different suggestion behaviors and timing!"
Copy this pattern for production: API calls, database queries, cache lookups"
}
AppMode::Highlight => {
"🎯 VISUAL MODE - Cursor: █ (blinking block)\n\
"🚀 VISUAL MODE - Selection with suggestions support\n\
Selection: hjkl/arrows=extend, w/b/e=word selection, Esc=normal\n\
Test multi-character selections across different suggestion field types!"
Professional editor experience with Tab-triggered autocomplete!"
}
_ => "🎯 Multi-field suggestions! 5 fields × 8 suggestions each = lots of testing!"
_ => "🚀 Copy this suggestions architecture for your production app!"
};
let help = Paragraph::new(help_text)
.block(Block::default().borders(Borders::ALL).title("🚀 Comprehensive Testing Guide"))
.block(Block::default().borders(Borders::ALL).title("📋 Production Integration Guide"))
.style(Style::default().fg(Color::Gray));
f.render_widget(help, chunks[1]);
@@ -1023,27 +1018,27 @@ fn render_status_and_help(
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Print comprehensive demo information
println!("🎯 Multi-Field Suggestions Demo - Perfect for Testing Edge Cases!");
println!("cursor-style feature: ENABLED");
println!("suggestions feature: ENABLED");
println!("🚀 Automatic cursor management: ACTIVE");
println!("✨ 5 different suggestion types: ACTIVE");
// Print production-ready information
println!("🚀 Production-Ready Tab-Triggered Suggestions Demo");
println!("Press Tab to activate suggestions, then type to filter in real-time");
println!("Professional autocomplete architecture");
println!("✅ Copy this pattern for your production application!");
println!();
println!("📋 Test These 5 Fields:");
println!(" 🍎 Fruits: Apple, Banana, Cherry, Date, Elderberry, Fig, Grape, Honeydew");
println!(" 💼 Jobs: Software Engineer, Product Manager, Data Scientist, UX Designer...");
println!(" 💻 Languages: Rust, Python, JavaScript, TypeScript, Go, Java, C++, Swift");
println!(" 🌍 Countries: USA, Canada, UK, Germany, France, Japan, Australia, Brazil");
println!(" 🎨 Colors: Red, Blue, Green, Yellow, Purple, Orange, Pink, Black");
println!("🏗️ Integration Ready For:");
println!(" 📡 REST APIs (reqwest, hyper)");
println!(" 🗄️ Databases (sqlx, diesel, mongodb)");
println!(" 🔍 Search Engines (elasticsearch, algolia, typesense)");
println!(" 💾 Caches (redis, memcached)");
println!(" 🌐 GraphQL APIs");
println!(" 🔗 gRPC Services");
println!();
println!("🧪 Edge Cases to Test:");
println!("Navigation between suggestion/non-suggestion fields");
println!("Empty field → Tab → see all suggestions");
println!("Partial typing → Tab → filtered suggestions");
println!("Different loading times per field (100-300ms)");
println!("Field switching while suggestions active");
println!("Visual mode selections across suggestion fields");
println!("⚡ Key Features:");
println!("Press Tab to activate suggestions dropdown");
println!("Real-time filtering while suggestions are active");
println!("Built-in stale result protection");
println!("Tab cycles through suggestions");
println!("Professional-grade user experience");
println!("Easy to integrate with any async data source");
println!();
enable_raw_mode()?;
@@ -1052,7 +1047,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let data = MultiFieldDemoData::new();
let data = ApplicationData::new();
let mut editor = AutoCursorFormEditor::new(data);
// Initialize with normal mode - library automatically sets block cursor
@@ -1079,6 +1074,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("{:?}", err);
}
println!("🎯 Multi-field testing complete! Great for finding edge cases!");
println!("🚀 Ready to integrate this architecture into your production app!");
Ok(())
}

View File

@@ -34,7 +34,6 @@ use ratatui::{
widgets::{Block, Borders, Paragraph, Wrap},
Frame, Terminal,
};
use canvas::{
canvas::{
gui::render_canvas_default,
@@ -62,10 +61,8 @@ struct ValidationFormEditor<D: DataProvider> {
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,
@@ -98,7 +95,6 @@ impl<D: DataProvider> ValidationFormEditor<D> {
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 {
@@ -110,14 +106,12 @@ impl<D: DataProvider> ValidationFormEditor<D> {
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)
}
@@ -125,11 +119,9 @@ impl<D: DataProvider> ValidationFormEditor<D> {
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)
@@ -162,7 +154,6 @@ impl<D: DataProvider> ValidationFormEditor<D> {
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",
@@ -250,21 +241,17 @@ impl<D: DataProvider> ValidationFormEditor<D> {
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);
}
let result = self.editor.validate_current_field();
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);
}
}
@@ -293,22 +280,24 @@ impl<D: DataProvider> ValidationFormEditor<D> {
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);
}
let validation_result = self.editor.validate_current_field();
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() {
let field_index = self.editor.current_field();
if let Some(status) = limits.status_text(
self.editor.data_provider().field_value(field_index)
) {
self.debug_message = format!("✏️ {}", status);
}
}
ValidationResult::Warning { message } => {
self.debug_message = format!("⚠️ {}", message);
}
ValidationResult::Error { message } => {
self.debug_message = format!("{}", message);
}
}
ValidationResult::Warning { message } => {
self.debug_message = format!("⚠️ {}", message);
}
ValidationResult::Error { message } => {
self.debug_message = format!("{}", message);
}
}
}
@@ -354,7 +343,8 @@ impl<D: DataProvider> ValidationFormEditor<D> {
}
fn current_text(&self) -> &str {
self.editor.current_text()
let field_index = self.editor.current_field();
self.editor.data_provider().field_value(field_index)
}
fn data_provider(&self) -> &D {
@@ -536,7 +526,6 @@ fn handle_key_press(
editor.enter_edit_mode();
editor.clear_command_buffer();
}
// Escape: Exit edit mode
(_, KeyCode::Esc, _) => {
if mode == AppMode::Edit {
@@ -629,7 +618,6 @@ fn handle_key_press(
summary.validated_fields
));
}
_ => {
if editor.has_pending_command() {
editor.clear_command_buffer();
@@ -661,7 +649,6 @@ fn run_app<B: Backend>(
}
}
}
Ok(())
}
@@ -705,6 +692,7 @@ fn render_validation_status(
};
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)
@@ -718,7 +706,6 @@ fn render_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
@@ -764,7 +751,6 @@ fn render_validation_status(
.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
@@ -791,7 +777,6 @@ fn render_validation_status(
.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]);
}

View File

@@ -129,12 +129,11 @@ impl<D: DataProvider> AdvancedPatternFormEditor<D> {
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
let result = self.editor.insert_char(ch);
if result.is_ok() {
if let Some(validation_result) = self.editor.current_field_validation() {
match validation_result {
ValidationResult::Valid => { self.debug_message = "✅ Character accepted".to_string(); }
ValidationResult::Warning { message } => { self.debug_message = format!("⚠️ Warning: {}", message); }
ValidationResult::Error { message } => { self.debug_message = format!("❌ Pattern violation: {}", message); }
}
let validation_result = self.editor.validate_current_field();
match validation_result {
ValidationResult::Valid => { self.debug_message = "✅ Character accepted".to_string(); }
ValidationResult::Warning { message } => { self.debug_message = format!("⚠️ Warning: {}", message); }
ValidationResult::Error { message } => { self.debug_message = format!("❌ Pattern violation: {}", message); }
}
}
Ok(result?)
@@ -156,7 +155,10 @@ impl<D: DataProvider> AdvancedPatternFormEditor<D> {
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 current_text(&self) -> &str {
let field_index = self.editor.current_field();
self.editor.data_provider().field_value(field_index)
}
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); }
@@ -180,12 +182,11 @@ impl<D: DataProvider> AdvancedPatternFormEditor<D> {
fn update_field_validation_status(&mut self) {
if !self.validation_enabled { return; }
if let Some(result) = self.editor.current_field_validation() {
match result {
ValidationResult::Valid => { self.debug_message = format!("Field {}: ✅ Pattern valid", self.editor.current_field() + 1); }
ValidationResult::Warning { message } => { self.debug_message = format!("Field {}: ⚠️ {}", self.editor.current_field() + 1, message); }
ValidationResult::Error { message } => { self.debug_message = format!("Field {}: ❌ {}", self.editor.current_field() + 1, message); }
}
let result = self.editor.validate_current_field();
match result {
ValidationResult::Valid => { self.debug_message = format!("Field {}: ✅ Pattern valid", self.editor.current_field() + 1); }
ValidationResult::Warning { message } => { self.debug_message = format!("Field {}: ⚠️ {}", self.editor.current_field() + 1, message); }
ValidationResult::Error { message } => { self.debug_message = format!("Field {}: ❌ {}", self.editor.current_field() + 1, message); }
}
}

View File

@@ -108,7 +108,7 @@ impl<D: DataProvider> MaskDemoFormEditor<D> {
fn get_current_field_info(&self) -> (String, String, String) {
let field_index = self.editor.current_field();
let raw_data = self.editor.current_text();
let raw_data = self.editor.data_provider().field_value(field_index);
let display_data = if self.validation_enabled {
self.editor.current_display_text()
} else {
@@ -237,7 +237,10 @@ impl<D: DataProvider> MaskDemoFormEditor<D> {
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 current_text(&self) -> &str {
let field_index = self.editor.current_field();
self.editor.data_provider().field_value(field_index)
}
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) {
@@ -648,7 +651,7 @@ fn render_mask_status(
• Dynamic vs Template modes • Custom separators • Different input chars\n\
\n\
Commands: i/a=insert, m=mask details, r=toggle raw/display view\n\
Movement: hjkl/arrows=move, 0=$=line start/end, Tab=next field, F1=toggle masks\n\
Movement: hjkl/arrows=move, 0/$=line start/end, Tab=next field, F1=toggle masks\n\
?=detailed info, Ctrl+C=quit"
}
AppMode::Edit => {

View File

@@ -364,7 +364,8 @@ impl<D: DataProvider> EnhancedDemoEditor<D> {
}
fn get_current_field_analysis(&self) -> (String, String, String, Option<String>) {
let raw = self.editor.current_text();
let field_index = self.editor.current_field();
let raw = self.editor.data_provider().field_value(field_index);
let display = self.editor.current_display_text();
let status = if raw == display {
@@ -445,7 +446,8 @@ impl<D: DataProvider> EnhancedDemoEditor<D> {
let raw_pos = self.editor.cursor_position();
let display_pos = self.editor.display_cursor_position();
let raw = self.editor.current_text();
let field_index = self.editor.current_field();
let raw = self.editor.data_provider().field_value(field_index);
let display = self.editor.current_display_text();
if raw_pos != display_pos {

View File

@@ -1,9 +1,10 @@
// examples/validation_5.rs
//! Enhanced Feature 5: Comprehensive external validation (UI-only) demo with Feature 4 integration
//! Enhanced Feature 5: Comprehensive external validation (UI-only) demo with automatic validation
//!
//! Demonstrates:
//! - Multiple external validation types: PSC lookup, email domain check, username availability,
//! API key validation, credit card verification
//! - AUTOMATIC validation on field transitions (arrows, Tab, Esc)
//! - Async validation simulation with realistic delays
//! - Validation caching and debouncing
//! - Progressive validation (local → remote)
@@ -15,7 +16,8 @@
//! Controls:
//! - i/a: insert/append
//! - Esc: exit edit mode (triggers validation on configured fields)
//! - Tab/Shift+Tab: next/prev field (triggers validation)
//! - Tab/Shift+Tab: next/prev field (triggers validation automatically)
//! - Arrow keys: move between fields (triggers validation automatically)
//! - v: manually trigger validation of current field
//! - V: validate all fields
//! - c: clear external validation state for current field
@@ -37,7 +39,7 @@ compile_error!(
);
use std::io;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use std::collections::HashMap;
use std::time::{Instant, Duration};
@@ -546,16 +548,15 @@ impl DataProvider for ValidationDemoData {
}
}
/// Enhanced editor with comprehensive external validation management
/// Enhanced editor with automatic external validation management
struct ValidationDemoEditor<D: DataProvider> {
editor: FormEditor<D>,
services: ValidationServices,
services: Arc<Mutex<ValidationServices>>,
validation_history: Vec<(usize, String, ValidationResult)>,
debug_message: String,
show_history: bool,
example_mode: usize,
validation_enabled: bool,
auto_validate: bool,
validation_stats: HashMap<usize, (u32, Duration)>, // field -> (count, total_time)
}
@@ -564,15 +565,70 @@ impl<D: DataProvider> ValidationDemoEditor<D> {
let mut editor = FormEditor::new(data_provider);
editor.set_validation_enabled(true);
let services = Arc::new(Mutex::new(ValidationServices::new()));
let services_for_cb = Arc::clone(&services);
let services_for_history = Arc::clone(&services);
// Create a history tracker that we'll share between callback and editor
let validation_history: Arc<Mutex<Vec<(usize, String, ValidationResult)>>> = Arc::new(Mutex::new(Vec::new()));
let history_for_cb = Arc::clone(&validation_history);
// Library-level automatic external validation on field transitions
editor.set_external_validation_callback(move |field_idx, text| {
let mut svc = services_for_cb.lock().unwrap();
let validation_type = match field_idx {
0 => "PSC Lookup",
1 => "Email Domain Check",
2 => "Username Availability",
3 => "API Key Auth",
4 => "Credit Card Verify",
_ => "Unknown",
}.to_string();
let start_time = Instant::now();
let validation_result = match field_idx {
0 => svc.validate_psc(text),
1 => svc.validate_email(text),
2 => svc.validate_username(text),
3 => svc.validate_api_key(text),
4 => svc.validate_credit_card(text),
_ => ExternalValidationState::NotValidated,
};
// Record in shared history (if we can lock it)
if let Ok(mut history) = history_for_cb.try_lock() {
let duration = start_time.elapsed();
let result = ValidationResult {
state: validation_result.clone(),
started_at: start_time,
completed_at: Some(Instant::now()),
validation_type,
cached: false, // We could enhance this by checking if it was from cache
};
history.push((field_idx, text.to_string(), result));
// Limit history size
if history.len() > 50 {
history.remove(0);
}
}
validation_result
});
Self {
editor,
services: ValidationServices::new(),
services,
validation_history: Vec::new(),
debug_message: "🧪 Enhanced External Validation Demo - Multiple validation types with rich scenarios!".to_string(),
debug_message:
"🧪 Enhanced External Validation Demo - Automatic validation on field transitions!"
.to_string(),
show_history: false,
example_mode: 0,
validation_enabled: true,
auto_validate: true,
validation_stats: HashMap::new(),
}
}
@@ -608,7 +664,7 @@ impl<D: DataProvider> ValidationDemoEditor<D> {
self.current_field() < 5
}
/// Trigger external validation for specific field
/// Trigger external validation for specific field (manual validation)
fn validate_field(&mut self, field_index: usize) {
if !self.validation_enabled || field_index >= 5 {
return;
@@ -634,14 +690,17 @@ impl<D: DataProvider> ValidationDemoEditor<D> {
let mut result = ValidationResult::new(validation_type.clone());
// Perform validation (in real app, this would be async)
let validation_result = match field_index {
0 => self.services.validate_psc(&raw_value),
1 => self.services.validate_email(&raw_value),
2 => self.services.validate_username(&raw_value),
3 => self.services.validate_api_key(&raw_value),
4 => self.services.validate_credit_card(&raw_value),
_ => ExternalValidationState::NotValidated,
// Perform validation using the shared services
let validation_result = {
let mut svc = self.services.lock().unwrap();
match field_index {
0 => svc.validate_psc(&raw_value),
1 => svc.validate_email(&raw_value),
2 => svc.validate_username(&raw_value),
3 => svc.validate_api_key(&raw_value),
4 => svc.validate_credit_card(&raw_value),
_ => ExternalValidationState::NotValidated,
}
};
result = result.complete(validation_result.clone());
@@ -665,7 +724,7 @@ impl<D: DataProvider> ValidationDemoEditor<D> {
let duration_ms = result.duration().as_millis();
let cached_text = if result.cached { " (cached)" } else { "" };
self.debug_message = format!(
"🔍 {} validation completed in {}ms{}",
"🔍 {} validation completed in {}ms{} (manual)",
validation_type, duration_ms, cached_text
);
}
@@ -675,7 +734,7 @@ impl<D: DataProvider> ValidationDemoEditor<D> {
for i in 0..field_count {
self.validate_field(i);
}
self.debug_message = "🔍 All fields validated".to_string();
self.debug_message = "🔍 All fields validated manually".to_string();
}
fn clear_validation_state(&mut self, field_index: Option<usize>) {
@@ -690,7 +749,9 @@ impl<D: DataProvider> ValidationDemoEditor<D> {
}
self.validation_history.clear();
self.validation_stats.clear();
self.services.clear_cache();
if let Ok(mut svc) = self.services.lock() {
svc.clear_cache();
}
self.debug_message = "🧹 Cleared all validation states and cache".to_string();
}
}
@@ -720,7 +781,7 @@ impl<D: DataProvider> ValidationDemoEditor<D> {
fn cycle_examples(&mut self) {
let examples = [
// Valid examples
vec!["01001", "user@gmail.com", "alice_dev", "valid_api_key_123456789012345", "4000123456789012", "Valid data"],
vec!["01001", "user@gmail.com", "alice_dev_new", "valid_api_key_123456789012345", "4000123456789012", "Valid data"],
// Invalid examples
vec!["00000", "invalid-email", "admin", "short_key", "0000000000000000", "Invalid data"],
// Warning examples
@@ -739,7 +800,7 @@ impl<D: DataProvider> ValidationDemoEditor<D> {
}
let mode_names = ["Valid Examples", "Invalid Examples", "Warning Cases", "Mixed Scenarios"];
self.debug_message = format!("📋 Loaded: {}", mode_names[self.example_mode]);
self.debug_message = format!("📋 Loaded: {} (navigate to trigger validation)", mode_names[self.example_mode]);
}
fn get_validation_summary(&self) -> String {
@@ -758,7 +819,7 @@ impl<D: DataProvider> ValidationDemoEditor<D> {
self.editor.ui_state().validation_state().get_external_validation(field_index)
}
// Editor pass-through methods
// Editor pass-through methods - simplified since library handles automatic validation
fn enter_edit_mode(&mut self) {
self.editor.enter_edit_mode();
let rules = self.field_validation_rules();
@@ -766,34 +827,36 @@ impl<D: DataProvider> ValidationDemoEditor<D> {
}
fn exit_edit_mode(&mut self) {
let current_field = self.current_field();
self.editor.exit_edit_mode();
// Auto-validate on blur if enabled
if self.auto_validate && self.has_external_validation() {
self.validate_field(current_field);
}
self.debug_message = format!("🔒 NORMAL - Cursor: Steady Block █ - {}", self.field_type());
// Library automatically validates on exit, no manual call needed
self.debug_message = format!("🔒 NORMAL - Cursor: Steady Block █ - {} (auto-validated)", self.field_type());
}
fn next_field(&mut self) {
let current = self.current_field();
if let Ok(()) = self.editor.next_field() {
if self.auto_validate && current < 5 {
self.validate_field(current);
}
self.debug_message = "➡ Next field".to_string();
// Library triggers external validation automatically via transition_to_field()
self.debug_message = "➡ Next field (auto-validation triggered by library)".to_string();
}
}
fn prev_field(&mut self) {
let current = self.current_field();
if let Ok(()) = self.editor.prev_field() {
if self.auto_validate && current < 5 {
self.validate_field(current);
}
self.debug_message = "⬅ Previous field".to_string();
// Library triggers external validation automatically via transition_to_field()
self.debug_message = "⬅ Previous field (auto-validation triggered by library)".to_string();
}
}
fn move_up(&mut self) {
if let Ok(()) = self.editor.move_up() {
// Library triggers external validation automatically via transition_to_field()
self.debug_message = "⬆ Move up (auto-validation triggered by library)".to_string();
}
}
fn move_down(&mut self) {
if let Ok(()) = self.editor.move_down() {
// Library triggers external validation automatically via transition_to_field()
self.debug_message = "⬇ Move down (auto-validation triggered by library)".to_string();
}
}
@@ -839,16 +902,25 @@ fn run_app<B: Backend>(
},
(_, KeyCode::Esc, _) => editor.exit_edit_mode(),
// Movement - cursor within field
(_, KeyCode::Left, _) | (AppMode::ReadOnly, KeyCode::Char('h'), _) => { let _ = editor.editor.move_left(); },
(_, KeyCode::Right, _) | (AppMode::ReadOnly, KeyCode::Char('l'), _) => { let _ = editor.editor.move_right(); },
(_, KeyCode::Up, _) | (AppMode::ReadOnly, KeyCode::Char('k'), _) => { let _ = editor.editor.move_up(); },
(_, KeyCode::Down, _) | (AppMode::ReadOnly, KeyCode::Char('j'), _) => { let _ = editor.editor.move_down(); },
// Field switching
// Movement - these now trigger automatic validation via the library!
(_, KeyCode::Left, _) | (AppMode::ReadOnly, KeyCode::Char('h'), _) => {
let _ = editor.editor.move_left();
},
(_, KeyCode::Right, _) | (AppMode::ReadOnly, KeyCode::Char('l'), _) => {
let _ = editor.editor.move_right();
},
(_, KeyCode::Up, _) | (AppMode::ReadOnly, KeyCode::Char('k'), _) => {
editor.move_up(); // Use wrapper to get debug message
},
(_, KeyCode::Down, _) | (AppMode::ReadOnly, KeyCode::Char('j'), _) => {
editor.move_down(); // Use wrapper to get debug message
},
// Field switching - these trigger automatic validation via the library!
(_, KeyCode::Tab, _) => editor.next_field(),
(_, KeyCode::BackTab, _) => editor.prev_field(),
// Validation commands (ONLY in ReadOnly mode)
// Manual validation commands (ONLY in ReadOnly mode)
(AppMode::ReadOnly, KeyCode::Char('v'), _) => {
let field = editor.current_field();
editor.validate_field(field);
@@ -868,8 +940,8 @@ fn run_app<B: Backend>(
// Editing
(AppMode::Edit, KeyCode::Left, _) => { let _ = editor.editor.move_left(); },
(AppMode::Edit, KeyCode::Right, _) => { let _ = editor.editor.move_right(); },
(AppMode::Edit, KeyCode::Up, _) => { let _ = editor.editor.move_up(); },
(AppMode::Edit, KeyCode::Down, _) => { let _ = editor.editor.move_down(); },
(AppMode::Edit, KeyCode::Up, _) => { editor.move_up(); },
(AppMode::Edit, KeyCode::Down, _) => { editor.move_down(); },
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
let _ = editor.insert_char(c);
},
@@ -922,19 +994,18 @@ fn render_validation_panel(
let summary = editor.get_validation_summary();
let status_text = format!(
"-- {} -- {} | {} | Auto: {} | View: {}",
"-- {} -- {} | {} | View: {}",
mode_text,
editor.debug_message,
summary,
if editor.auto_validate { "ON" } else { "OFF" },
if editor.show_history { "HISTORY" } else { "STATUS" }
);
let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("🧪 External Validation Demo"));
.block(Block::default().borders(Borders::ALL).title("🧪 Automatic External Validation Demo"));
f.render_widget(status, chunks[0]);
// Validation states for all fields - FIXED: render each field on its own line
// Validation states for all fields - render each field on its own line
let mut field_lines: Vec<Line> = Vec::new();
for i in 0..editor.data_provider().field_count() {
let field_name = editor.data_provider().field_name(i);
@@ -969,9 +1040,8 @@ fn render_validation_panel(
field_lines.push(field_line);
}
// Use Vec<Line> to avoid a single long line overflowing
let validation_states = Paragraph::new(field_lines)
.block(Block::default().borders(Borders::ALL).title("🔍 Validation States"));
.block(Block::default().borders(Borders::ALL).title("🔍 Validation States (Library Auto-triggered)"));
f.render_widget(validation_states, chunks[1]);
// History or Help panel
@@ -1014,36 +1084,36 @@ fn render_validation_panel(
.collect();
let history = List::new(recent_history)
.block(Block::default().borders(Borders::ALL).title("📜 Validation History (recent 5)"));
.block(Block::default().borders(Borders::ALL).title("📜 Auto-Validation History (recent 5)"));
f.render_widget(history, chunks[2]);
} else {
let help_text = match editor.mode() {
AppMode::ReadOnly => {
"🎯 CURSOR-STYLE: Normal █ | Insert |\n\
🧪 EXTERNAL VALIDATION DEMO - Multiple validation types with async simulation\n\
"🎯 FULLY AUTOMATIC VALIDATION: Library handles all validation on field transitions!\n\
🧪 EXTERNAL VALIDATION DEMO - No manual triggers needed, just navigate!\n\
\n\
Commands: v=validate current, V=validate all, c=clear current, C=clear all\n\
e=cycle examples, r=toggle history, h=field help, F1=toggle validation\n\
Movement: Tab/Shift+Tab=switch fields, i/a=insert/append, Esc=exit edit\n\
🚀 AUTOMATIC: Arrow keys, Tab, and Esc trigger validation automatically\n\
Manual: v=validate current, V=validate all, c=clear current, C=clear all\n\
Controls: e=cycle examples, r=toggle history, h=field help, F1=toggle validation\n\
\n\
Try different values to see validation in action!"
Just load examples and navigate - validation happens automatically!"
}
AppMode::Edit => {
"🎯 INSERT MODE - Cursor: | (bar)\n\
✏️ Type to see validation on field blur\n\
✏️ Type to edit field content\n\
\n\
Current field validation will trigger when you:\n\
🚀 AUTOMATIC: Library validates when you leave this field via:\n\
• Press Esc (exit edit mode)\n\
• Press Tab (move to next field)\n\
• Press 'v' manually\n\
• Press Tab/Shift+Tab (move between fields)\n\
• Press arrow keys (Up/Down move between fields)\n\
\n\
Esc=exit edit, arrows=navigate, Backspace/Del=delete"
}
_ => "🧪 Enhanced External Validation Demo"
_ => "🧪 Enhanced Fully Automatic External Validation Demo"
};
let help = Paragraph::new(help_text)
.block(Block::default().borders(Borders::ALL).title("🚀 External Validation Features"))
.block(Block::default().borders(Borders::ALL).title("🚀 Fully Automatic External Validation"))
.style(Style::default().fg(Color::Gray))
.wrap(Wrap { trim: true });
f.render_widget(help, chunks[2]);
@@ -1051,16 +1121,19 @@ fn render_validation_panel(
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🧪 Enhanced External Validation Demo (Feature 5)");
println!("🧪 Enhanced Fully Automatic External Validation Demo (Feature 5)");
println!("✅ validation feature: ENABLED");
println!("✅ gui feature: ENABLED");
println!("✅ cursor-style feature: ENABLED");
println!("🚀 NEW: Library handles all automatic validation!");
println!("🧪 Enhanced features:");
println!(" • 5 different external validation types with realistic scenarios");
println!(" • LIBRARY-LEVEL automatic validation on all field transitions");
println!(" • Validation caching and performance metrics");
println!(" • Comprehensive validation history and error handling");
println!(" • Multiple example datasets for testing edge cases");
println!(" • Progressive validation patterns (local + remote simulation)");
println!(" • NO manual validation calls needed - library handles everything!");
println!();
enable_raw_mode()?;
@@ -1092,11 +1165,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("{:?}", err);
}
println!("🧪 Enhanced external validation demo completed!");
println!("🏆 You experienced comprehensive external validation with:");
println!("🧪 Enhanced fully automatic external validation demo completed!");
println!("🏆 You experienced library-level automatic external validation with:");
println!(" • Multiple validation services (PSC, Email, Username, API Key, Credit Card)");
println!(" • AUTOMATIC validation handled entirely by the library");
println!(" • Realistic async validation simulation with caching");
println!(" • Comprehensive error handling and user feedback");
println!(" • Performance metrics and validation history tracking");
println!(" • Zero manual validation calls needed!");
Ok(())
}

View File

@@ -114,7 +114,7 @@ async fn state_machine_example() {
}
}
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
match action {
CanvasAction::Custom(cmd) => match cmd.as_str() {
"submit" => {
@@ -147,7 +147,7 @@ async fn state_machine_example() {
println!(" Initial state: {:?}", form.state);
// Type some text to trigger state change
let _result = ActionDispatcher::dispatch(
let result = ActionDispatcher::dispatch(
CanvasAction::InsertChar('u'),
&mut form,
&mut ideal_cursor,
@@ -231,7 +231,7 @@ async fn event_driven_example() {
self.has_changes = changed;
}
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
match action {
CanvasAction::Custom(cmd) => match cmd.as_str() {
"validate" => {
@@ -384,7 +384,7 @@ async fn validation_pipeline_example() {
fn has_unsaved_changes(&self) -> bool { self.has_changes }
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
match action {
CanvasAction::Custom(cmd) => match cmd.as_str() {
"validate" => {

View File

@@ -5,6 +5,12 @@ pub mod line;
pub mod char;
// Re-export commonly used functions
pub use word::{find_next_word_start, find_word_end, find_prev_word_start, find_prev_word_end};
pub use word::{
find_next_word_start, find_word_end, find_prev_word_start, find_prev_word_end,
find_next_big_word_start, find_prev_big_word_start, find_big_word_end, find_prev_big_word_end,
// Add these new exports:
find_last_word_start_in_field, find_last_word_end_in_field,
find_last_big_word_start_in_field, find_last_big_word_end_in_field,
};
pub use line::{line_start_position, line_end_position, safe_cursor_position};
pub use char::{move_left, move_right, is_valid_cursor_position, clamp_cursor_position};

View File

@@ -1,6 +1,7 @@
// src/canvas/actions/movement/word.rs
// Replace the entire file with this corrected version:
#[derive(PartialEq)]
#[derive(PartialEq, Copy, Clone)]
enum CharType {
Whitespace,
Alphanumeric,
@@ -107,40 +108,296 @@ pub fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
}
}
/// Find the end of the previous word
/// Find the end of the previous word (CORRECTED VERSION for vim's ge command)
pub fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
// Find all word end positions using boundary detection
let mut word_ends = Vec::new();
let mut in_word = false;
let mut current_word_type: Option<CharType> = None;
for (i, &ch) in chars.iter().enumerate() {
let char_type = get_char_type(ch);
match char_type {
CharType::Whitespace => {
if in_word {
// End of a word
word_ends.push(i - 1);
in_word = false;
current_word_type = None;
}
}
_ => {
if !in_word || current_word_type != Some(char_type) {
// Start of a new word (or word type change)
if in_word {
// End the previous word first
word_ends.push(i - 1);
}
in_word = true;
current_word_type = Some(char_type);
}
}
}
}
// Add the final word end if text doesn't end with whitespace
if in_word && !chars.is_empty() {
word_ends.push(chars.len() - 1);
}
// Find the largest word end position that's before current_pos
for &end_pos in word_ends.iter().rev() {
if end_pos < current_pos {
return end_pos;
}
}
0
}
/// Find the start of the next big_word (whitespace-separated)
pub fn find_next_big_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos >= chars.len() {
return text.chars().count();
}
let mut pos = current_pos;
// If we're on non-whitespace, skip to end of current big_word
while pos < chars.len() && !chars[pos].is_whitespace() {
pos += 1;
}
// Skip whitespace to find start of next big_word
while pos < chars.len() && chars[pos].is_whitespace() {
pos += 1;
}
pos
}
/// Find the start of the previous big_word (whitespace-separated)
pub fn find_prev_big_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
// Skip whitespace backwards
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
while pos > 0 && chars[pos].is_whitespace() {
pos -= 1;
}
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
return 0;
}
if pos == 0 && get_char_type(chars[0]) != CharType::Whitespace {
return 0;
}
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
// Find start of current big_word by going back while non-whitespace
while pos > 0 && !chars[pos - 1].is_whitespace() {
pos -= 1;
}
// Skip whitespace before this word
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
pos -= 1;
}
if pos > 0 {
pos - 1
} else {
0
}
pos
}
/// Find the end of the current/next big_word (whitespace-separated)
pub fn find_big_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
let mut pos = current_pos;
// If we're on whitespace, skip to start of next big_word
while pos < chars.len() && chars[pos].is_whitespace() {
pos += 1;
}
// If we reached end, return it
if pos >= chars.len() {
return chars.len();
}
// Find end of current big_word (last non-whitespace char)
while pos < chars.len() && !chars[pos].is_whitespace() {
pos += 1;
}
// Return position of last character in big_word
pos.saturating_sub(1)
}
/// Find the end of the previous big_word (whitespace-separated)
pub fn find_prev_big_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
// Skip whitespace backwards
while pos > 0 && chars[pos].is_whitespace() {
pos -= 1;
}
// If we hit start of text and it's whitespace, return 0
if pos == 0 && chars[0].is_whitespace() {
return 0;
}
// Skip back to start of current big_word, then forward to end
while pos > 0 && !chars[pos - 1].is_whitespace() {
pos -= 1;
}
// Now find end of this big_word
while pos < chars.len() && !chars[pos].is_whitespace() {
pos += 1;
}
// Return position of last character in big_word
pos.saturating_sub(1)
}
// ============================================================================
// FIELD BOUNDARY HELPER FUNCTIONS (for cross-field movement)
// ============================================================================
/// Find the start of the last word in a field (for cross-field 'b' movement)
pub fn find_last_word_start_in_field(text: &str) -> usize {
if text.is_empty() {
return 0;
}
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
let mut pos = chars.len().saturating_sub(1);
// Skip trailing whitespace
while pos > 0 && chars[pos].is_whitespace() {
pos -= 1;
}
// If the whole field is whitespace, return 0
if pos == 0 && chars[0].is_whitespace() {
return 0;
}
// Now we're on a non-whitespace character
// Find the start of this word by going backwards while chars are the same type
let char_type = if chars[pos].is_alphanumeric() { "alnum" } else { "punct" };
while pos > 0 {
let prev_char = chars[pos - 1];
let prev_type = if prev_char.is_alphanumeric() {
"alnum"
} else if prev_char.is_whitespace() {
"space"
} else {
"punct"
};
// Stop if we hit whitespace or different word type
if prev_type == "space" || prev_type != char_type {
break;
}
pos -= 1;
}
pos
}
/// Find the end of the last word in a field (for cross-field 'ge' movement)
pub fn find_last_word_end_in_field(text: &str) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
// Start from the end and find the last non-whitespace character
let mut pos = chars.len() - 1;
// Skip trailing whitespace
while pos > 0 && chars[pos].is_whitespace() {
pos -= 1;
}
// If the whole field is whitespace, return 0
if chars[pos].is_whitespace() {
return 0;
}
// We're now at the end of the last word
pos
}
/// Find the start of the last big_word in a field (for cross-field 'B' movement)
pub fn find_last_big_word_start_in_field(text: &str) -> usize {
if text.is_empty() {
return 0;
}
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
let mut pos = chars.len().saturating_sub(1);
// Skip trailing whitespace
while pos > 0 && chars[pos].is_whitespace() {
pos -= 1;
}
// If the whole field is whitespace, return 0
if pos == 0 && chars[0].is_whitespace() {
return 0;
}
// Now we're on a non-whitespace character
// Find the start of this big_word by going backwards while chars are non-whitespace
while pos > 0 {
let prev_char = chars[pos - 1];
// Stop if we hit whitespace (big_word boundary)
if prev_char.is_whitespace() {
break;
}
pos -= 1;
}
pos
}
/// Find the end of the last big_word in a field (for cross-field 'gE' movement)
pub fn find_last_big_word_end_in_field(text: &str) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
let mut pos = chars.len().saturating_sub(1);
// Skip trailing whitespace
while pos > 0 && chars[pos].is_whitespace() {
pos -= 1;
}
// If the whole field is whitespace, return 0
if pos == 0 && chars[0].is_whitespace() {
return 0;
}
// We're now at the end of the last big_word
pos
}

View File

@@ -15,6 +15,7 @@ use crate::canvas::theme::{CanvasTheme, DefaultCanvasTheme};
use crate::canvas::modes::HighlightState;
use crate::data_provider::DataProvider;
use crate::editor::FormEditor;
use unicode_width::UnicodeWidthChar;
#[cfg(feature = "gui")]
use std::cmp::{max, min};
@@ -68,6 +69,7 @@ pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
let is_edit_mode = matches!(ui_state.mode(), crate::canvas::modes::AppMode::Edit);
// Precompute completion for active field
#[cfg(feature = "suggestions")]
let active_completion = if ui_state.is_suggestions_active()
&& ui_state.suggestions.active_field == Some(current_field_idx)
{
@@ -76,6 +78,9 @@ pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
None
};
#[cfg(not(feature = "suggestions"))]
let active_completion: Option<String> = None;
render_canvas_fields(
f,
area,
@@ -87,42 +92,27 @@ pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
highlight_state,
editor.display_cursor_position(), // Use display cursor position for masks
false, // TODO: track unsaved changes in editor
|i| {
// Get display value for field i using editor logic (Feature 4 + masks)
#[cfg(feature = "validation")]
{
editor.display_text_for_field(i)
}
#[cfg(not(feature = "validation"))]
{
data_provider.field_value(i).to_string()
}
// Closures for getting display values and overrides
#[cfg(feature = "validation")]
|field_idx| editor.display_text_for_field(field_idx),
#[cfg(not(feature = "validation"))]
|field_idx| data_provider.field_value(field_idx).to_string(),
// Closure for checking display overrides
#[cfg(feature = "validation")]
|field_idx| {
editor.ui_state().validation_state().get_field_config(field_idx)
.map(|cfg| {
let has_formatter = cfg.custom_formatter.is_some();
let has_mask = cfg.display_mask.is_some();
has_formatter || has_mask
})
.unwrap_or(false)
},
|i| {
// Check if field has display override (custom formatter or mask)
#[cfg(feature = "validation")]
{
editor.ui_state().validation_state().get_field_config(i)
.map(|cfg| {
// Formatter takes precedence; if present, it's a display override
#[allow(unused_mut)]
let mut has_override = false;
#[cfg(feature = "validation")]
{
has_override = cfg.custom_formatter.is_some();
}
has_override || cfg.display_mask.is_some()
})
.unwrap_or(false)
}
#[cfg(not(feature = "validation"))]
{
false
}
},
// NEW: provide completion for the active field
|i| {
if i == current_field_idx {
#[cfg(not(feature = "validation"))]
|_field_idx| false,
// Closure for providing completion
|field_idx| {
if field_idx == current_field_idx {
active_completion.clone()
} else {
None
@@ -268,7 +258,8 @@ where
{
let mut active_field_input_rect = None;
for (i, _input) in inputs.iter().enumerate() {
// FIX: Iterate over indices only since we never use the input values directly
for i in 0..inputs.len() {
let is_active = i == *current_field_idx;
let typed_text = get_display_value(i);
@@ -322,7 +313,7 @@ fn apply_highlighting<'a, T: CanvasTheme>(
current_cursor_pos: usize,
highlight_state: &HighlightState,
theme: &T,
_is_active: bool,
is_active: bool,
) -> Line<'a> {
let text_len = text.chars().count();
@@ -334,10 +325,10 @@ fn apply_highlighting<'a, T: CanvasTheme>(
))
}
HighlightState::Characterwise { anchor } => {
apply_characterwise_highlighting(text, text_len, field_index, current_field_idx, current_cursor_pos, anchor, theme, _is_active)
apply_characterwise_highlighting(text, text_len, field_index, current_field_idx, current_cursor_pos, anchor, theme, is_active)
}
HighlightState::Linewise { anchor_line } => {
apply_linewise_highlighting(text, field_index, current_field_idx, anchor_line, theme, _is_active)
apply_linewise_highlighting(text, field_index, current_field_idx, anchor_line, theme, is_active)
}
}
}
@@ -486,13 +477,21 @@ fn set_cursor_position(
field_rect: Rect,
text: &str,
current_cursor_pos: usize,
has_display_override: bool,
_has_display_override: bool,
) {
// BUG FIX: Use the correct display cursor position, not end of text
let cursor_x = field_rect.x + current_cursor_pos as u16;
// Sum display widths of the first current_cursor_pos characters
let mut cols: u16 = 0;
for (i, ch) in text.chars().enumerate() {
if i >= current_cursor_pos {
break;
}
cols = cols.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16);
}
let cursor_x = field_rect.x.saturating_add(cols);
let cursor_y = field_rect.y;
// SAFETY: Ensure cursor doesn't go beyond field bounds
// Clamp to field bounds
let max_cursor_x = field_rect.x + field_rect.width.saturating_sub(1);
let safe_cursor_x = cursor_x.min(max_cursor_x);

View File

@@ -14,7 +14,8 @@ pub struct EditorState {
// Mode state
pub(crate) current_mode: AppMode,
// Suggestions dropdown state
// Suggestions dropdown state (only available with suggestions feature)
#[cfg(feature = "suggestions")]
pub(crate) suggestions: SuggestionsUIState,
// Selection state (for vim visual mode)
@@ -29,12 +30,14 @@ pub struct EditorState {
pub(crate) computed: Option<crate::computed::ComputedState>,
}
#[cfg(feature = "suggestions")]
#[derive(Debug, Clone)]
pub struct SuggestionsUIState {
pub(crate) is_active: bool,
pub(crate) is_loading: bool,
pub(crate) selected_index: Option<usize>,
pub(crate) active_field: Option<usize>,
pub(crate) active_query: Option<String>,
pub(crate) completion_text: Option<String>,
}
@@ -52,11 +55,13 @@ impl EditorState {
cursor_pos: 0,
ideal_cursor_column: 0,
current_mode: AppMode::Edit,
#[cfg(feature = "suggestions")]
suggestions: SuggestionsUIState {
is_active: false,
is_loading: false,
selected_index: None,
active_field: None,
active_query: None,
completion_text: None,
},
selection: SelectionState::None,
@@ -101,11 +106,13 @@ impl EditorState {
}
/// Check if suggestions dropdown is active (for user's business logic)
#[cfg(feature = "suggestions")]
pub fn is_suggestions_active(&self) -> bool {
self.suggestions.is_active
}
/// Check if suggestions dropdown is loading (for user's business logic)
#[cfg(feature = "suggestions")]
pub fn is_suggestions_loading(&self) -> bool {
self.suggestions.is_loading
}
@@ -150,38 +157,24 @@ impl EditorState {
self.ideal_cursor_column = self.cursor_pos;
}
/// Legacy internal activation (still used internally if needed)
pub(crate) fn activate_suggestions(&mut self, field_index: usize) {
self.suggestions.is_active = true;
self.suggestions.is_loading = true;
self.suggestions.active_field = Some(field_index);
self.suggestions.selected_index = None;
self.suggestions.completion_text = None;
}
/// Legacy internal deactivation
pub(crate) fn deactivate_suggestions(&mut self) {
self.suggestions.is_active = false;
self.suggestions.is_loading = false;
self.suggestions.active_field = None;
self.suggestions.selected_index = None;
self.suggestions.completion_text = None;
}
/// Explicitly open suggestions — should only be called on Tab
#[cfg(feature = "suggestions")]
pub(crate) fn open_suggestions(&mut self, field_index: usize) {
self.suggestions.is_active = true;
self.suggestions.is_loading = true;
self.suggestions.active_field = Some(field_index);
self.suggestions.active_query = None;
self.suggestions.selected_index = None;
self.suggestions.completion_text = None;
}
/// Explicitly close suggestions — should be called on Esc or field change
#[cfg(feature = "suggestions")]
pub(crate) fn close_suggestions(&mut self) {
self.suggestions.is_active = false;
self.suggestions.is_loading = false;
self.suggestions.active_field = None;
self.suggestions.active_query = None;
self.suggestions.selected_index = None;
self.suggestions.completion_text = None;
}

View File

@@ -1,7 +1,9 @@
// src/data_provider.rs
//! Simplified user interface - only business data, no UI state
#[cfg(feature = "suggestions")]
use anyhow::Result;
#[cfg(feature = "suggestions")]
use async_trait::async_trait;
/// User implements this - only business data, no UI state
@@ -51,6 +53,7 @@ pub trait DataProvider {
}
/// Optional: User implements this for suggestions data
#[cfg(feature = "suggestions")]
#[async_trait]
pub trait SuggestionsProvider {
/// Fetch suggestions (user's business logic)
@@ -58,6 +61,7 @@ pub trait SuggestionsProvider {
-> Result<Vec<SuggestionItem>>;
}
#[cfg(feature = "suggestions")]
#[derive(Debug, Clone)]
pub struct SuggestionItem {
pub display_text: String,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,111 @@
// src/editor/computed_helpers.rs
use crate::computed::{ComputedContext, ComputedProvider, ComputedState};
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
#[cfg(feature = "computed")]
pub fn set_computed_provider<C>(&mut self, mut provider: C)
where
C: ComputedProvider,
{
self.ui_state.computed = Some(ComputedState::new());
let field_count = self.data_provider.field_count();
for field_index in 0..field_count {
if provider.handles_field(field_index) {
let deps = provider.field_dependencies(field_index);
if let Some(computed_state) = &mut self.ui_state.computed {
computed_state.register_computed_field(field_index, deps);
}
}
}
self.recompute_all_fields(&mut provider);
}
#[cfg(feature = "computed")]
pub fn recompute_fields<C>(
&mut self,
provider: &mut C,
field_indices: &[usize],
) where
C: ComputedProvider,
{
if let Some(computed_state) = &mut self.ui_state.computed {
let field_values: Vec<String> = (0..self.data_provider.field_count())
.map(|i| {
if computed_state.is_computed_field(i) {
computed_state
.get_computed_value(i)
.cloned()
.unwrap_or_default()
} else {
self.data_provider.field_value(i).to_string()
}
})
.collect();
let field_refs: Vec<&str> =
field_values.iter().map(|s| s.as_str()).collect();
for &field_index in field_indices {
if provider.handles_field(field_index) {
let context = ComputedContext {
field_values: &field_refs,
target_field: field_index,
current_field: Some(self.ui_state.current_field),
};
let computed_value = provider.compute_field(context);
computed_state.set_computed_value(
field_index,
computed_value,
);
}
}
}
}
#[cfg(feature = "computed")]
pub fn recompute_all_fields<C>(&mut self, provider: &mut C)
where
C: ComputedProvider,
{
if let Some(computed_state) = &self.ui_state.computed {
let computed_fields: Vec<usize> =
computed_state.computed_fields().collect();
self.recompute_fields(provider, &computed_fields);
}
}
#[cfg(feature = "computed")]
pub fn on_field_changed<C>(
&mut self,
provider: &mut C,
changed_field: usize,
) where
C: ComputedProvider,
{
if let Some(computed_state) = &self.ui_state.computed {
let fields_to_update =
computed_state.fields_to_recompute(changed_field);
if !fields_to_update.is_empty() {
self.recompute_fields(provider, &fields_to_update);
}
}
}
#[cfg(feature = "computed")]
pub fn effective_field_value(&self, field_index: usize) -> String {
if let Some(computed_state) = &self.ui_state.computed {
if let Some(computed_value) =
computed_state.get_computed_value(field_index)
{
return computed_value.clone();
}
}
self.data_provider.field_value(field_index).to_string()
}
}

122
canvas/src/editor/core.rs Normal file
View File

@@ -0,0 +1,122 @@
// src/editor/core.rs
#[cfg(feature = "cursor-style")]
use crate::canvas::CursorManager;
use crate::canvas::modes::AppMode;
use crate::canvas::state::EditorState;
use crate::DataProvider;
#[cfg(feature = "suggestions")]
use crate::SuggestionItem;
pub struct FormEditor<D: DataProvider> {
pub(crate) ui_state: EditorState,
pub(crate) data_provider: D,
#[cfg(feature = "suggestions")]
pub(crate) suggestions: Vec<SuggestionItem>,
#[cfg(feature = "validation")]
pub(crate) external_validation_callback: Option<
Box<
dyn FnMut(usize, &str) -> crate::validation::ExternalValidationState
+ Send
+ Sync,
>,
>,
}
impl<D: DataProvider> FormEditor<D> {
// Make helpers visible to sibling modules in this crate
pub(crate) fn char_to_byte_index(s: &str, char_idx: usize) -> usize {
s.char_indices()
.nth(char_idx)
.map(|(byte_idx, _)| byte_idx)
.unwrap_or_else(|| s.len())
}
#[allow(dead_code)]
pub(crate) fn byte_to_char_index(s: &str, byte_idx: usize) -> usize {
s[..byte_idx].chars().count()
}
pub fn new(data_provider: D) -> Self {
let editor = Self {
ui_state: EditorState::new(),
data_provider,
#[cfg(feature = "suggestions")]
suggestions: Vec::new(),
#[cfg(feature = "validation")]
external_validation_callback: None,
};
#[cfg(feature = "validation")]
{
let mut editor = editor;
editor.initialize_validation();
editor
}
#[cfg(not(feature = "validation"))]
{
editor
}
}
// Library-internal, used by multiple modules
pub(crate) fn current_text(&self) -> &str {
let field_index = self.ui_state.current_field;
if field_index < self.data_provider.field_count() {
self.data_provider.field_value(field_index)
} else {
""
}
}
// Read-only getters
pub fn current_field(&self) -> usize {
self.ui_state.current_field()
}
pub fn cursor_position(&self) -> usize {
self.ui_state.cursor_position()
}
pub fn mode(&self) -> AppMode {
self.ui_state.mode()
}
#[cfg(feature = "suggestions")]
pub fn is_suggestions_active(&self) -> bool {
self.ui_state.is_suggestions_active()
}
pub fn ui_state(&self) -> &EditorState {
&self.ui_state
}
pub fn data_provider(&self) -> &D {
&self.data_provider
}
pub fn data_provider_mut(&mut self) -> &mut D {
&mut self.data_provider
}
#[cfg(feature = "suggestions")]
pub fn suggestions(&self) -> &[SuggestionItem] {
&self.suggestions
}
#[cfg(feature = "validation")]
pub fn validation_state(&self) -> &crate::validation::ValidationState {
self.ui_state.validation_state()
}
// Cursor cleanup
#[cfg(feature = "cursor-style")]
pub fn cleanup_cursor(&self) -> std::io::Result<()> {
CursorManager::reset()
}
#[cfg(not(feature = "cursor-style"))]
pub fn cleanup_cursor(&self) -> std::io::Result<()> {
Ok(())
}
}
impl<D: DataProvider> Drop for FormEditor<D> {
fn drop(&mut self) {
let _ = self.cleanup_cursor();
}
}

View File

@@ -0,0 +1,123 @@
// src/editor/display.rs
use crate::canvas::modes::AppMode;
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
/// Get current field text for display.
/// Policies documented in original file.
#[cfg(feature = "validation")]
pub fn current_display_text(&self) -> String {
let field_index = self.ui_state.current_field;
let raw = if field_index < self.data_provider.field_count() {
self.data_provider.field_value(field_index)
} else {
""
};
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
if cfg.custom_formatter.is_none() {
if let Some(mask) = &cfg.display_mask {
return mask.apply_to_display(raw);
}
}
if cfg.custom_formatter.is_some() {
if matches!(self.ui_state.current_mode, AppMode::Edit) {
return raw.to_string();
}
if let Some((formatted, _mapper, _warning)) =
cfg.run_custom_formatter(raw)
{
return formatted;
}
}
if let Some(mask) = &cfg.display_mask {
return mask.apply_to_display(raw);
}
}
raw.to_string()
}
/// Get effective display text for any field index (Feature 4 + masks).
#[cfg(feature = "validation")]
pub fn display_text_for_field(&self, field_index: usize) -> String {
let raw = if field_index < self.data_provider.field_count() {
self.data_provider.field_value(field_index)
} else {
""
};
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
if cfg.custom_formatter.is_none() {
if let Some(mask) = &cfg.display_mask {
return mask.apply_to_display(raw);
}
}
if cfg.custom_formatter.is_some() {
if field_index == self.ui_state.current_field
&& matches!(self.ui_state.current_mode, AppMode::Edit)
{
return raw.to_string();
}
if let Some((formatted, _mapper, _warning)) =
cfg.run_custom_formatter(raw)
{
return formatted;
}
}
if let Some(mask) = &cfg.display_mask {
return mask.apply_to_display(raw);
}
}
raw.to_string()
}
/// Map raw cursor to display position (formatter/mask aware).
pub fn display_cursor_position(&self) -> usize {
let current_text = self.current_text();
let char_count = current_text.chars().count();
let raw_pos = match self.ui_state.current_mode {
AppMode::Edit => self.ui_state.cursor_pos.min(char_count),
_ => {
if char_count == 0 {
0
} else {
self.ui_state
.cursor_pos
.min(char_count.saturating_sub(1))
}
}
};
#[cfg(feature = "validation")]
{
let field_index = self.ui_state.current_field;
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
if !matches!(self.ui_state.current_mode, AppMode::Edit) {
if let Some((formatted, mapper, _)) =
cfg.run_custom_formatter(current_text)
{
return mapper.raw_to_formatted(
current_text,
&formatted,
raw_pos,
);
}
}
if let Some(mask) = &cfg.display_mask {
return mask.raw_pos_to_display_pos(raw_pos);
}
}
}
raw_pos
}
}

View File

@@ -0,0 +1,348 @@
// src/editor/editing.rs
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
/// Open new line below (vim o)
pub fn open_line_below(&mut self) -> anyhow::Result<()> {
// paste the method body unchanged from editor.rs
// (exact code from your VIM COMMANDS: o and O section)
let field_count = self.data_provider.field_count();
if field_count == 0 {
return Ok(());
}
let next_field = (self.ui_state.current_field + 1)
.min(field_count.saturating_sub(1));
self.transition_to_field(next_field)?;
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
self.enter_edit_mode();
Ok(())
}
/// Open new line above (vim O)
pub fn open_line_above(&mut self) -> anyhow::Result<()> {
let prev_field = self.ui_state.current_field.saturating_sub(1);
self.transition_to_field(prev_field)?;
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
self.enter_edit_mode();
Ok(())
}
/// Handle character insertion (mask/limit-aware)
pub fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
// paste entire insert_char body unchanged
if self.ui_state.current_mode != crate::canvas::modes::AppMode::Edit
{
return Ok(());
}
#[cfg(feature = "validation")]
let field_index = self.ui_state.current_field;
#[cfg(feature = "validation")]
let raw_cursor_pos = self.ui_state.cursor_pos;
#[cfg(feature = "validation")]
let current_raw_text = self.data_provider.field_value(field_index);
#[cfg(not(feature = "validation"))]
let field_index = self.ui_state.current_field;
#[cfg(not(feature = "validation"))]
let raw_cursor_pos = self.ui_state.cursor_pos;
#[cfg(not(feature = "validation"))]
let current_raw_text = self.data_provider.field_value(field_index);
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(
field_index,
) {
if let Some(mask) = &cfg.display_mask {
let display_cursor_pos =
mask.raw_pos_to_display_pos(raw_cursor_pos);
let pattern_char_len = mask.pattern().chars().count();
if display_cursor_pos >= pattern_char_len {
return Ok(());
}
if !mask.is_input_position(display_cursor_pos) {
return Ok(());
}
let input_slots = (0..pattern_char_len)
.filter(|&pos| mask.is_input_position(pos))
.count();
if current_raw_text.chars().count() >= input_slots {
return Ok(());
}
}
}
}
#[cfg(feature = "validation")]
{
let vr = self.ui_state.validation.validate_char_insertion(
field_index,
current_raw_text,
raw_cursor_pos,
ch,
);
if !vr.is_acceptable() {
return Ok(());
}
}
let new_raw_text = {
let mut temp = current_raw_text.to_string();
let byte_pos = Self::char_to_byte_index(
current_raw_text,
raw_cursor_pos,
);
temp.insert(byte_pos, ch);
temp
};
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(
field_index,
) {
if let Some(limits) = &cfg.character_limits {
if let Some(result) = limits.validate_content(&new_raw_text)
{
if !result.is_acceptable() {
return Ok(());
}
}
}
if let Some(mask) = &cfg.display_mask {
let pattern_char_len = mask.pattern().chars().count();
let input_slots = (0..pattern_char_len)
.filter(|&pos| mask.is_input_position(pos))
.count();
if new_raw_text.chars().count() > input_slots {
return Ok(());
}
}
}
}
self.data_provider
.set_field_value(field_index, new_raw_text.clone());
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(
field_index,
) {
if let Some(mask) = &cfg.display_mask {
let new_raw_pos = raw_cursor_pos + 1;
let display_pos = mask.raw_pos_to_display_pos(new_raw_pos);
let next_input_display =
mask.next_input_position(display_pos);
let next_raw_pos =
mask.display_pos_to_raw_pos(next_input_display);
let max_raw = new_raw_text.chars().count();
self.ui_state.cursor_pos = next_raw_pos.min(max_raw);
self.ui_state.ideal_cursor_column =
self.ui_state.cursor_pos;
return Ok(());
}
}
}
self.ui_state.cursor_pos = raw_cursor_pos + 1;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
Ok(())
}
/// Delete backward (backspace)
pub fn delete_backward(&mut self) -> anyhow::Result<()> {
// paste entire delete_backward body unchanged
if self.ui_state.current_mode != crate::canvas::modes::AppMode::Edit
{
return Ok(());
}
if self.ui_state.cursor_pos == 0 {
return Ok(());
}
let field_index = self.ui_state.current_field;
let mut current_text =
self.data_provider.field_value(field_index).to_string();
let new_cursor = self.ui_state.cursor_pos.saturating_sub(1);
let start = Self::char_to_byte_index(
&current_text,
self.ui_state.cursor_pos - 1,
);
let end =
Self::char_to_byte_index(&current_text, self.ui_state.cursor_pos);
current_text.replace_range(start..end, "");
self.data_provider
.set_field_value(field_index, current_text.clone());
#[cfg(feature = "validation")]
let mut target_cursor = new_cursor;
#[cfg(not(feature = "validation"))]
let target_cursor = new_cursor;
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(
field_index,
) {
if let Some(mask) = &cfg.display_mask {
let display_pos =
mask.raw_pos_to_display_pos(new_cursor);
if let Some(prev_input) =
mask.prev_input_position(display_pos)
{
target_cursor =
mask.display_pos_to_raw_pos(prev_input);
}
}
}
}
self.ui_state.cursor_pos = target_cursor;
self.ui_state.ideal_cursor_column = target_cursor;
#[cfg(feature = "validation")]
{
let _ = self.ui_state.validation.validate_field_content(
field_index,
&current_text,
);
}
Ok(())
}
/// Delete forward (Delete key)
pub fn delete_forward(&mut self) -> anyhow::Result<()> {
// paste entire delete_forward body unchanged
if self.ui_state.current_mode != crate::canvas::modes::AppMode::Edit
{
return Ok(());
}
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.chars().count() {
let start = Self::char_to_byte_index(
&current_text,
self.ui_state.cursor_pos,
);
let end = Self::char_to_byte_index(
&current_text,
self.ui_state.cursor_pos + 1,
);
current_text.replace_range(start..end, "");
self.data_provider
.set_field_value(field_index, current_text.clone());
#[cfg(feature = "validation")]
let mut target_cursor = self.ui_state.cursor_pos;
#[cfg(not(feature = "validation"))]
let target_cursor = self.ui_state.cursor_pos;
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(
field_index,
) {
if let Some(mask) = &cfg.display_mask {
let display_pos =
mask.raw_pos_to_display_pos(
self.ui_state.cursor_pos,
);
let next_input =
mask.next_input_position(display_pos);
target_cursor = mask
.display_pos_to_raw_pos(next_input)
.min(current_text.chars().count());
}
}
}
self.ui_state.cursor_pos = target_cursor;
self.ui_state.ideal_cursor_column = target_cursor;
#[cfg(feature = "validation")]
{
let _ = self.ui_state.validation.validate_field_content(
field_index,
&current_text,
);
}
}
Ok(())
}
/// Enter edit mode with cursor positioned for append (vim 'a')
pub fn enter_append_mode(&mut self) {
// paste body unchanged
let current_text = self.current_text();
let char_len = current_text.chars().count();
let append_pos = if current_text.is_empty() {
0
} else {
(self.ui_state.cursor_pos + 1).min(char_len)
};
self.ui_state.cursor_pos = append_pos;
self.ui_state.ideal_cursor_column = append_pos;
self.set_mode(crate::canvas::modes::AppMode::Edit);
}
/// Set current field value (validates under feature flag)
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.clone());
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
#[cfg(feature = "validation")]
{
let _ = self
.ui_state
.validation
.validate_field_content(field_index, &value);
}
}
/// Set specific field value by index (validates under feature flag)
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.clone());
if field_index == self.ui_state.current_field {
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
}
#[cfg(feature = "validation")]
{
let _ = self
.ui_state
.validation
.validate_field_content(field_index, &value);
}
}
}
/// Clear the current field
pub fn clear_current_field(&mut self) {
self.set_current_field_value(String::new());
}
}

21
canvas/src/editor/mod.rs Normal file
View File

@@ -0,0 +1,21 @@
// src/editor/mod.rs
// Only module declarations and re-exports.
pub mod core;
pub mod display;
pub mod editing;
pub mod movement;
pub mod navigation;
pub mod mode;
#[cfg(feature = "suggestions")]
pub mod suggestions;
#[cfg(feature = "validation")]
pub mod validation_helpers;
#[cfg(feature = "computed")]
pub mod computed_helpers;
// Re-export the main type
pub use core::FormEditor;

222
canvas/src/editor/mode.rs Normal file
View File

@@ -0,0 +1,222 @@
// src/editor/mode.rs
#[cfg(feature = "cursor-style")]
use crate::canvas::CursorManager;
use crate::canvas::modes::AppMode;
use crate::canvas::state::SelectionState;
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
/// Change mode (for vim compatibility)
pub fn set_mode(&mut self, mode: AppMode) {
match (self.ui_state.current_mode, mode) {
(AppMode::ReadOnly, AppMode::Highlight) => {
self.enter_highlight_mode();
}
(AppMode::Highlight, AppMode::ReadOnly) => {
self.exit_highlight_mode();
}
(_, new_mode) => {
self.ui_state.current_mode = new_mode;
if new_mode != AppMode::Highlight {
self.ui_state.selection = SelectionState::None;
}
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(new_mode);
}
}
}
}
/// Exit edit mode to read-only mode (vim Escape)
pub fn exit_edit_mode(&mut self) -> anyhow::Result<()> {
#[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,
)
{
self.ui_state
.validation
.set_last_switch_block(reason.clone());
return Err(anyhow::anyhow!(
"Cannot exit edit mode: {}",
reason
));
}
}
}
let current_text = self.current_text();
if !current_text.is_empty() {
let max_normal_pos =
current_text.chars().count().saturating_sub(1);
if self.ui_state.cursor_pos > max_normal_pos {
self.ui_state.cursor_pos = max_normal_pos;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
}
}
#[cfg(feature = "validation")]
{
let field_index = self.ui_state.current_field;
if let Some(cfg) =
self.ui_state.validation.get_field_config(field_index)
{
if cfg.external_validation_enabled {
let text = self.current_text().to_string();
if !text.is_empty() {
self.set_external_validation(
field_index,
crate::validation::ExternalValidationState::Validating,
);
if let Some(cb) =
self.external_validation_callback.as_mut()
{
let final_state = cb(field_index, &text);
self.set_external_validation(field_index, final_state);
}
}
}
}
}
self.set_mode(AppMode::ReadOnly);
#[cfg(feature = "suggestions")]
{
self.close_suggestions();
}
Ok(())
}
/// Enter edit mode from read-only mode (vim i/a/o)
pub fn enter_edit_mode(&mut self) {
#[cfg(feature = "computed")]
{
if let Some(computed_state) = &self.ui_state.computed {
if computed_state.is_computed_field(self.ui_state.current_field)
{
return;
}
}
}
self.set_mode(AppMode::Edit);
}
// -------------------- Highlight/Visual mode -------------------------
pub fn enter_highlight_mode(&mut self) {
if self.ui_state.current_mode == AppMode::ReadOnly {
self.ui_state.current_mode = AppMode::Highlight;
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);
}
}
}
pub fn enter_highlight_line_mode(&mut self) {
if self.ui_state.current_mode == AppMode::ReadOnly {
self.ui_state.current_mode = AppMode::Highlight;
self.ui_state.selection =
SelectionState::Linewise { anchor_field: self.ui_state.current_field };
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(AppMode::Highlight);
}
}
}
pub fn exit_highlight_mode(&mut self) {
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);
}
}
}
pub fn is_highlight_mode(&self) -> bool {
self.ui_state.current_mode == AppMode::Highlight
}
pub fn selection_state(&self) -> &SelectionState {
&self.ui_state.selection
}
// Visual-mode movements reuse existing movement methods
pub fn move_left_with_selection(&mut self) {
let _ = self.move_left();
}
pub fn move_right_with_selection(&mut self) {
let _ = self.move_right();
}
pub fn move_up_with_selection(&mut self) {
let _ = self.move_up();
}
pub fn move_down_with_selection(&mut self) {
let _ = self.move_down();
}
pub fn move_word_next_with_selection(&mut self) {
self.move_word_next();
}
pub fn move_word_end_with_selection(&mut self) {
self.move_word_end();
}
pub fn move_word_prev_with_selection(&mut self) {
self.move_word_prev();
}
pub fn move_word_end_prev_with_selection(&mut self) {
self.move_word_end_prev();
}
pub fn move_big_word_next_with_selection(&mut self) {
self.move_big_word_next();
}
pub fn move_big_word_end_with_selection(&mut self) {
self.move_big_word_end();
}
pub fn move_big_word_prev_with_selection(&mut self) {
self.move_big_word_prev();
}
pub fn move_big_word_end_prev_with_selection(&mut self) {
self.move_big_word_end_prev();
}
pub fn move_line_start_with_selection(&mut self) {
self.move_line_start();
}
pub fn move_line_end_with_selection(&mut self) {
self.move_line_end();
}
}

View File

@@ -0,0 +1,690 @@
// src/editor/movement.rs
use crate::canvas::actions::movement::line::{
line_end_position, line_start_position,
};
use crate::canvas::modes::AppMode;
use crate::editor::FormEditor;
use crate::DataProvider;
use crate::canvas::actions::movement::word::{
find_last_big_word_start_in_field, find_last_word_start_in_field,
};
impl<D: DataProvider> FormEditor<D> {
/// Move cursor left within current field (mask-aware)
pub fn move_left(&mut self) -> anyhow::Result<()> {
#[cfg(feature = "validation")]
let mut moved = false;
#[cfg(not(feature = "validation"))]
let moved = false;
#[cfg(feature = "validation")]
{
let field_index = self.ui_state.current_field;
if let Some(cfg) =
self.ui_state.validation.get_field_config(field_index)
{
if let Some(mask) = &cfg.display_mask {
let display_pos =
mask.raw_pos_to_display_pos(self.ui_state.cursor_pos);
if let Some(prev_input) =
mask.prev_input_position(display_pos)
{
let raw_pos =
mask.display_pos_to_raw_pos(prev_input);
let max_pos = self.current_text().chars().count();
self.ui_state.cursor_pos = raw_pos.min(max_pos);
self.ui_state.ideal_cursor_column =
self.ui_state.cursor_pos;
moved = true;
} else {
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
moved = true;
}
}
}
}
if !moved {
if self.ui_state.cursor_pos > 0 {
self.ui_state.cursor_pos -= 1;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
}
}
Ok(())
}
/// Move cursor right within current field (mask-aware)
pub fn move_right(&mut self) -> anyhow::Result<()> {
#[cfg(feature = "validation")]
let mut moved = false;
#[cfg(not(feature = "validation"))]
let moved = false;
#[cfg(feature = "validation")]
{
let field_index = self.ui_state.current_field;
if let Some(cfg) =
self.ui_state.validation.get_field_config(field_index)
{
if let Some(mask) = &cfg.display_mask {
let display_pos =
mask.raw_pos_to_display_pos(self.ui_state.cursor_pos);
let next_display_pos = mask.next_input_position(display_pos);
let next_pos =
mask.display_pos_to_raw_pos(next_display_pos);
let max_pos = self.current_text().chars().count();
self.ui_state.cursor_pos = next_pos.min(max_pos);
self.ui_state.ideal_cursor_column =
self.ui_state.cursor_pos;
moved = true;
}
}
}
if !moved {
let max_pos = self.current_text().chars().count();
if self.ui_state.cursor_pos < max_pos {
self.ui_state.cursor_pos += 1;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
}
}
Ok(())
}
/// Move to start of current field (vim 0)
pub fn move_line_start(&mut self) {
let new_pos = line_start_position();
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
}
/// Move to end of current field (vim $)
pub fn move_line_end(&mut self) {
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;
}
/// Set cursor to exact position (for f/F/t/T etc.)
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;
let char_len = current_text.chars().count();
let max_pos = if is_edit_mode {
char_len
} else {
char_len.saturating_sub(1)
};
let clamped_pos = position.min(max_pos);
self.ui_state.cursor_pos = clamped_pos;
self.ui_state.ideal_cursor_column = clamped_pos;
}
}
impl<D: DataProvider> FormEditor<D> {
/// Move to start of next word (vim w) - can cross field boundaries
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() {
// Empty field - try to move to next field
if self.move_down().is_ok() {
// Successfully moved to next field, try to find first word
let new_text = self.current_text();
if !new_text.is_empty() {
let first_word_pos = if new_text.chars().next().map_or(false, |c| !c.is_whitespace()) {
// Field starts with non-whitespace, go to position 0
0
} else {
// Field starts with whitespace, find first word
find_next_word_start(new_text, 0)
};
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = new_text.chars().count();
let final_pos = if is_edit_mode {
first_word_pos.min(char_len)
} else {
first_word_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
let new_pos = find_next_word_start(current_text, current_pos);
// Check if we've hit the end of the current field
if new_pos >= current_text.chars().count() {
// At end of field - jump to next field and start from beginning
if self.move_down().is_ok() {
// Successfully moved to next field
let new_text = self.current_text();
if new_text.is_empty() {
// New field is empty, cursor stays at 0
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
} else {
// Find first word in new field
let first_word_pos = if new_text.chars().next().map_or(false, |c| !c.is_whitespace()) {
// Field starts with non-whitespace, go to position 0
0
} else {
// Field starts with whitespace, find first word
find_next_word_start(new_text, 0)
};
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = new_text.chars().count();
let final_pos = if is_edit_mode {
first_word_pos.min(char_len)
} else {
first_word_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
// If move_down() failed, we stay where we are (at end of last field)
} else {
// Normal word movement within current field
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = current_text.chars().count();
let final_pos = if is_edit_mode {
new_pos.min(char_len)
} else {
new_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
/// Move to start of previous word (vim b) - can cross field boundaries
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() {
// Empty field - try to move to previous field and find last word
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_word_start = find_last_word_start_in_field(new_text);
self.ui_state.cursor_pos = last_word_start;
self.ui_state.ideal_cursor_column = last_word_start;
}
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
// Special case: if we're at position 0, jump to previous field
if current_pos == 0 {
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_word_start = find_last_word_start_in_field(new_text);
self.ui_state.cursor_pos = last_word_start;
self.ui_state.ideal_cursor_column = last_word_start;
}
}
}
return;
}
// Try to find previous word in current field
let new_pos = find_prev_word_start(current_text, current_pos);
// Check if we actually moved
if new_pos < current_pos {
// Normal word movement within current field - we found a previous word
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
} else {
// We didn't move (probably at start of first word), try previous field
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_word_start = find_last_word_start_in_field(new_text);
self.ui_state.cursor_pos = last_word_start;
self.ui_state.ideal_cursor_column = last_word_start;
}
}
}
}
}
/// Move to end of current/next word (vim e) - can cross field boundaries
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() {
// Empty field - try to move to next field
if self.move_down().is_ok() {
// Recursively call move_word_end in the new field
self.move_word_end();
}
return;
}
let current_pos = self.ui_state.cursor_pos;
let char_len = current_text.chars().count();
let new_pos = find_word_end(current_text, current_pos);
// Check if we didn't move or hit the end of the field
if new_pos == current_pos && current_pos + 1 < char_len {
// Try next character and find word end from there
let next_pos = find_word_end(current_text, current_pos + 1);
if next_pos < char_len {
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let final_pos = if is_edit_mode {
next_pos.min(char_len)
} else {
next_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
return;
}
}
// If we're at or near the end of the field, try next field
if new_pos >= char_len.saturating_sub(1) {
if self.move_down().is_ok() {
// Position at start and find first word end
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
self.move_word_end();
}
} else {
// Normal word end movement within current field
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let final_pos = if is_edit_mode {
new_pos.min(char_len)
} else {
new_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
/// Move to end of previous word (vim ge) - can cross field boundaries
pub fn move_word_end_prev(&mut self) {
use crate::canvas::actions::movement::word::{find_prev_word_end, find_last_word_end_in_field};
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to previous field (but don't recurse)
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
// Find end of last word in the field
let last_word_end = find_last_word_end_in_field(new_text);
self.ui_state.cursor_pos = last_word_end;
self.ui_state.ideal_cursor_column = last_word_end;
}
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
// Special case: if we're at position 0, jump to previous field (but don't recurse)
if current_pos == 0 {
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_word_end = find_last_word_end_in_field(new_text);
self.ui_state.cursor_pos = last_word_end;
self.ui_state.ideal_cursor_column = last_word_end;
}
}
}
return;
}
let new_pos = find_prev_word_end(current_text, current_pos);
// Only try to cross fields if we didn't move at all (stayed at same position)
if new_pos == current_pos {
// We didn't move within the current field, try previous field
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_word_end = find_last_word_end_in_field(new_text);
self.ui_state.cursor_pos = last_word_end;
self.ui_state.ideal_cursor_column = last_word_end;
}
}
}
} else {
// Normal word movement within current field
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = current_text.chars().count();
let final_pos = if is_edit_mode {
new_pos.min(char_len)
} else {
new_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
/// Move to start of next big_word (vim W) - can cross field boundaries
pub fn move_big_word_next(&mut self) {
use crate::canvas::actions::movement::word::find_next_big_word_start;
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to next field
if self.move_down().is_ok() {
// Successfully moved to next field, try to find first big_word
let new_text = self.current_text();
if !new_text.is_empty() {
let first_big_word_pos = if new_text.chars().next().map_or(false, |c| !c.is_whitespace()) {
// Field starts with non-whitespace, go to position 0
0
} else {
// Field starts with whitespace, find first big_word
find_next_big_word_start(new_text, 0)
};
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = new_text.chars().count();
let final_pos = if is_edit_mode {
first_big_word_pos.min(char_len)
} else {
first_big_word_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
let new_pos = find_next_big_word_start(current_text, current_pos);
// Check if we've hit the end of the current field
if new_pos >= current_text.chars().count() {
// At end of field - jump to next field and start from beginning
if self.move_down().is_ok() {
// Successfully moved to next field
let new_text = self.current_text();
if new_text.is_empty() {
// New field is empty, cursor stays at 0
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
} else {
// Find first big_word in new field
let first_big_word_pos = if new_text.chars().next().map_or(false, |c| !c.is_whitespace()) {
// Field starts with non-whitespace, go to position 0
0
} else {
// Field starts with whitespace, find first big_word
find_next_big_word_start(new_text, 0)
};
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = new_text.chars().count();
let final_pos = if is_edit_mode {
first_big_word_pos.min(char_len)
} else {
first_big_word_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
// If move_down() failed, we stay where we are (at end of last field)
} else {
// Normal big_word movement within current field
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = current_text.chars().count();
let final_pos = if is_edit_mode {
new_pos.min(char_len)
} else {
new_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
/// Move to start of previous big_word (vim B) - can cross field boundaries
pub fn move_big_word_prev(&mut self) {
use crate::canvas::actions::movement::word::find_prev_big_word_start;
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to previous field and find last big_word
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_big_word_start = find_last_big_word_start_in_field(new_text);
self.ui_state.cursor_pos = last_big_word_start;
self.ui_state.ideal_cursor_column = last_big_word_start;
}
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
// Special case: if we're at position 0, jump to previous field
if current_pos == 0 {
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_big_word_start = find_last_big_word_start_in_field(new_text);
self.ui_state.cursor_pos = last_big_word_start;
self.ui_state.ideal_cursor_column = last_big_word_start;
}
}
}
return;
}
// Try to find previous big_word in current field
let new_pos = find_prev_big_word_start(current_text, current_pos);
// Check if we actually moved
if new_pos < current_pos {
// Normal big_word movement within current field - we found a previous big_word
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
} else {
// We didn't move (probably at start of first big_word), try previous field
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_big_word_start = find_last_big_word_start_in_field(new_text);
self.ui_state.cursor_pos = last_big_word_start;
self.ui_state.ideal_cursor_column = last_big_word_start;
}
}
}
}
}
/// Move to end of current/next big_word (vim E) - can cross field boundaries
pub fn move_big_word_end(&mut self) {
use crate::canvas::actions::movement::word::find_big_word_end;
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to next field (but don't recurse)
if self.move_down().is_ok() {
let new_text = self.current_text();
if !new_text.is_empty() {
// Find first big_word end in new field
let first_big_word_end = find_big_word_end(new_text, 0);
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = new_text.chars().count();
let final_pos = if is_edit_mode {
first_big_word_end.min(char_len)
} else {
first_big_word_end.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
let char_len = current_text.chars().count();
let new_pos = find_big_word_end(current_text, current_pos);
// Check if we didn't move or hit the end of the field
if new_pos == current_pos && current_pos + 1 < char_len {
// Try next character and find big_word end from there
let next_pos = find_big_word_end(current_text, current_pos + 1);
if next_pos < char_len {
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let final_pos = if is_edit_mode {
next_pos.min(char_len)
} else {
next_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
return;
}
}
// If we're at or near the end of the field, try next field (but don't recurse)
if new_pos >= char_len.saturating_sub(1) {
if self.move_down().is_ok() {
// Find first big_word end in new field
let new_text = self.current_text();
if !new_text.is_empty() {
let first_big_word_end = find_big_word_end(new_text, 0);
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let new_char_len = new_text.chars().count();
let final_pos = if is_edit_mode {
first_big_word_end.min(new_char_len)
} else {
first_big_word_end.min(new_char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
} else {
// Normal big_word end movement within current field
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let final_pos = if is_edit_mode {
new_pos.min(char_len)
} else {
new_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
/// Move to end of previous big_word (vim gE) - can cross field boundaries
pub fn move_big_word_end_prev(&mut self) {
use crate::canvas::actions::movement::word::{
find_prev_big_word_end, find_big_word_end,
};
let current_text = self.current_text();
if current_text.is_empty() {
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
// Find first big_word end in new field
let last_big_word_end = find_big_word_end(new_text, 0);
self.ui_state.cursor_pos = last_big_word_end;
self.ui_state.ideal_cursor_column = last_big_word_end;
}
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
let new_pos = find_prev_big_word_end(current_text, current_pos);
// Only try to cross fields if we didn't move at all (stayed at same position)
if new_pos == current_pos {
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_big_word_end = find_big_word_end(new_text, 0);
self.ui_state.cursor_pos = last_big_word_end;
self.ui_state.ideal_cursor_column = last_big_word_end;
}
}
}
} else {
// Normal big_word movement within current field
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = current_text.chars().count();
let final_pos = if is_edit_mode {
new_pos.min(char_len)
} else {
new_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
}

View File

@@ -0,0 +1,177 @@
// src/editor/navigation.rs
use crate::canvas::modes::AppMode;
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
/// Centralized field transition logic (unchanged).
pub fn transition_to_field(&mut self, new_field: usize) -> anyhow::Result<()> {
let field_count = self.data_provider.field_count();
if field_count == 0 {
return Ok(());
}
let prev_field = self.ui_state.current_field;
#[cfg(feature = "computed")]
let mut target_field = new_field.min(field_count - 1);
#[cfg(not(feature = "computed"))]
let target_field = new_field.min(field_count - 1);
#[cfg(feature = "computed")]
{
if let Some(computed_state) = &self.ui_state.computed {
if computed_state.is_computed_field(target_field) {
if target_field >= prev_field {
for i in (target_field + 1)..field_count {
if !computed_state.is_computed_field(i) {
target_field = i;
break;
}
}
} else {
let mut i = target_field;
loop {
if !computed_state.is_computed_field(i) {
target_field = i;
break;
}
if i == 0 {
break;
}
i -= 1;
}
}
}
}
}
if target_field == prev_field {
return Ok(());
}
#[cfg(feature = "validation")]
self.ui_state.validation.clear_last_switch_block();
#[cfg(feature = "validation")]
{
let current_text = self.current_text();
if !self
.ui_state
.validation
.allows_field_switch(prev_field, current_text)
{
if let Some(reason) = self
.ui_state
.validation
.field_switch_block_reason(prev_field, current_text)
{
self.ui_state
.validation
.set_last_switch_block(reason.clone());
tracing::debug!("Field switch blocked: {}", reason);
return Err(anyhow::anyhow!(
"Cannot switch fields: {}",
reason
));
}
}
}
#[cfg(feature = "validation")]
{
let text =
self.data_provider.field_value(prev_field).to_string();
let _ = self
.ui_state
.validation
.validate_field_content(prev_field, &text);
if let Some(cfg) =
self.ui_state.validation.get_field_config(prev_field)
{
if cfg.external_validation_enabled && !text.is_empty() {
self.set_external_validation(
prev_field,
crate::validation::ExternalValidationState::Validating,
);
if let Some(cb) =
self.external_validation_callback.as_mut()
{
let final_state = cb(prev_field, &text);
self.set_external_validation(prev_field, final_state);
}
}
}
}
#[cfg(feature = "computed")]
{
// Placeholder for recompute hook if needed later
}
self.ui_state.move_to_field(target_field, field_count);
let current_text = self.current_text();
let max_pos = current_text.chars().count();
self.ui_state.set_cursor(
self.ui_state.ideal_cursor_column,
max_pos,
self.ui_state.current_mode == AppMode::Edit,
);
// Automatically close suggestions on field switch
#[cfg(feature = "suggestions")]
{
self.close_suggestions();
}
Ok(())
}
/// Move to first line (vim gg)
pub fn move_first_line(&mut self) -> anyhow::Result<()> {
self.transition_to_field(0)
}
/// Move to last line (vim G)
pub fn move_last_line(&mut self) -> anyhow::Result<()> {
let last_field =
self.data_provider.field_count().saturating_sub(1);
self.transition_to_field(last_field)
}
/// Move to previous field (vim k / up)
pub fn move_up(&mut self) -> anyhow::Result<()> {
let new_field = self.ui_state.current_field.saturating_sub(1);
self.transition_to_field(new_field)
}
/// Move to next field (vim j / down)
pub fn move_down(&mut self) -> anyhow::Result<()> {
let new_field = (self.ui_state.current_field + 1)
.min(self.data_provider.field_count().saturating_sub(1));
self.transition_to_field(new_field)
}
/// Move to next field cyclic
pub fn move_to_next_field(&mut self) -> anyhow::Result<()> {
let field_count = self.data_provider.field_count();
if field_count == 0 {
return Ok(());
}
let new_field = (self.ui_state.current_field + 1) % field_count;
self.transition_to_field(new_field)
}
/// Aliases
pub fn prev_field(&mut self) -> anyhow::Result<()> {
self.move_up()
}
pub fn next_field(&mut self) -> anyhow::Result<()> {
self.move_down()
}
}

View File

@@ -0,0 +1,166 @@
// src/editor/suggestions.rs
use crate::editor::FormEditor;
use crate::{DataProvider, SuggestionItem};
impl<D: DataProvider> FormEditor<D> {
/// Compute inline completion for current selection and text
fn compute_current_completion(&self) -> Option<String> {
let typed = self.current_text();
let idx = self.ui_state.suggestions.selected_index?;
let sugg = self.suggestions.get(idx)?;
if let Some(rest) = sugg.value_to_store.strip_prefix(typed) {
if !rest.is_empty() {
return Some(rest.to_string());
}
}
None
}
/// Update UI state's completion text from current selection
pub fn update_inline_completion(&mut self) {
self.ui_state.suggestions.completion_text =
self.compute_current_completion();
}
/// Open the suggestions UI for `field_index`
pub fn open_suggestions(&mut self, field_index: usize) {
self.ui_state.open_suggestions(field_index);
}
/// Close suggestions UI and clear current suggestion results
pub fn close_suggestions(&mut self) {
self.ui_state.close_suggestions();
self.suggestions.clear();
}
/// Handle Escape key in ReadOnly mode (closes suggestions if active)
pub fn handle_escape_readonly(&mut self) {
if self.ui_state.suggestions.is_active {
self.close_suggestions();
}
}
// ----------------- Non-blocking suggestions API --------------------
#[cfg(feature = "suggestions")]
pub fn start_suggestions(&mut self, field_index: usize) -> Option<String> {
if !self.data_provider.supports_suggestions(field_index) {
return None;
}
let query = self.current_text().to_string();
self.ui_state.open_suggestions(field_index);
self.ui_state.suggestions.is_loading = true;
self.ui_state.suggestions.active_query = Some(query.clone());
self.suggestions.clear();
Some(query)
}
#[cfg(not(feature = "suggestions"))]
pub fn start_suggestions(&mut self, _field_index: usize) -> Option<String> {
None
}
#[cfg(feature = "suggestions")]
pub fn apply_suggestions_result(
&mut self,
field_index: usize,
query: &str,
results: Vec<SuggestionItem>,
) -> bool {
if self.ui_state.suggestions.active_field != Some(field_index) {
return false;
}
if self.ui_state.suggestions.active_query.as_deref() != Some(query) {
return false;
}
self.ui_state.suggestions.is_loading = false;
self.suggestions = results;
if !self.suggestions.is_empty() {
self.ui_state.suggestions.selected_index = Some(0);
self.update_inline_completion();
} else {
self.ui_state.suggestions.selected_index = None;
self.ui_state.suggestions.completion_text = None;
}
true
}
#[cfg(not(feature = "suggestions"))]
pub fn apply_suggestions_result(
&mut self,
_field_index: usize,
_query: &str,
_results: Vec<SuggestionItem>,
) -> bool {
false
}
#[cfg(feature = "suggestions")]
pub fn pending_suggestions_query(&self) -> Option<(usize, String)> {
if self.ui_state.suggestions.is_loading {
if let (Some(field), Some(query)) = (
self.ui_state.suggestions.active_field,
&self.ui_state.suggestions.active_query,
) {
return Some((field, query.clone()));
}
}
None
}
#[cfg(not(feature = "suggestions"))]
pub fn pending_suggestions_query(&self) -> Option<(usize, String)> {
None
}
pub fn cancel_suggestions(&mut self) {
self.close_suggestions();
}
pub fn suggestions_next(&mut self) {
if !self.ui_state.suggestions.is_active || self.suggestions.is_empty()
{
return;
}
let current = self.ui_state.suggestions.selected_index.unwrap_or(0);
let next = (current + 1) % self.suggestions.len();
self.ui_state.suggestions.selected_index = Some(next);
self.update_inline_completion();
}
pub fn apply_suggestion(&mut self) -> Option<String> {
if let Some(selected_index) = self.ui_state.suggestions.selected_index {
if let Some(suggestion) = self.suggestions.get(selected_index).cloned()
{
let field_index = self.ui_state.current_field;
self.data_provider.set_field_value(
field_index,
suggestion.value_to_store.clone(),
);
self.ui_state.cursor_pos = suggestion.value_to_store.len();
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
self.close_suggestions();
self.suggestions.clear();
#[cfg(feature = "validation")]
{
let _ = self.ui_state.validation.validate_field_content(
field_index,
&suggestion.value_to_store,
);
}
return Some(suggestion.display_text);
}
}
None
}
}

View File

@@ -0,0 +1,23 @@
// src/editor/suggestions_stub.rs
// Crate-private no-op methods so internal calls compile when feature is off.
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
pub(crate) fn open_suggestions(&mut self, _field_index: usize) {
// no-op
}
pub(crate) fn close_suggestions(&mut self) {
// no-op
}
pub(crate) fn handle_escape_readonly(&mut self) {
// no-op
}
pub(crate) fn cancel_suggestions(&mut self) {
// no-op
}
}

View File

@@ -0,0 +1,178 @@
// src/editor/validation_helpers.rs
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
#[cfg(feature = "validation")]
pub fn set_validation_enabled(&mut self, enabled: bool) {
self.ui_state.validation.set_enabled(enabled);
}
#[cfg(feature = "validation")]
pub fn is_validation_enabled(&self) -> bool {
self.ui_state.validation.is_enabled()
}
#[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);
}
#[cfg(feature = "validation")]
pub fn remove_field_validation(&mut self, field_index: usize) {
self.ui_state.validation.remove_field_config(field_index);
}
#[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)
}
#[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
}
}
#[cfg(feature = "validation")]
pub fn clear_validation_results(&mut self) {
self.ui_state.validation.clear_all_results();
}
#[cfg(feature = "validation")]
pub fn validation_summary(
&self,
) -> crate::validation::ValidationSummary {
self.ui_state.validation.summary()
}
#[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,
)
}
#[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,
)
}
#[cfg(feature = "validation")]
pub fn last_switch_block(&self) -> Option<&str> {
self.ui_state.validation.last_switch_block()
}
#[cfg(feature = "validation")]
pub fn current_limits_status_text(&self) -> Option<String> {
let idx = self.ui_state.current_field;
if let Some(cfg) = self.ui_state.validation.get_field_config(idx) {
if let Some(limits) = &cfg.character_limits {
return limits.status_text(self.current_text());
}
}
None
}
#[cfg(feature = "validation")]
pub fn current_formatter_warning(&self) -> Option<String> {
let idx = self.ui_state.current_field;
if let Some(cfg) = self.ui_state.validation.get_field_config(idx) {
if let Some((_fmt, _mapper, warn)) =
cfg.run_custom_formatter(self.current_text())
{
return warn;
}
}
None
}
#[cfg(feature = "validation")]
pub fn external_validation_of(
&self,
field_index: usize,
) -> crate::validation::ExternalValidationState {
self.ui_state
.validation
.get_external_validation(field_index)
}
#[cfg(feature = "validation")]
pub fn clear_all_external_validation(&mut self) {
self.ui_state.validation.clear_all_external_validation();
}
#[cfg(feature = "validation")]
pub fn clear_external_validation(&mut self, field_index: usize) {
self.ui_state
.validation
.clear_external_validation(field_index);
}
#[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);
}
#[cfg(feature = "validation")]
pub fn set_external_validation_callback<F>(&mut self, callback: F)
where
F: FnMut(usize, &str) -> crate::validation::ExternalValidationState
+ Send
+ Sync
+ 'static,
{
self.external_validation_callback = Some(Box::new(callback));
}
#[cfg(feature = "validation")]
pub(crate) 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);
}
}
}
}

View File

@@ -25,7 +25,9 @@ pub use canvas::CursorManager;
// Main API exports
pub use editor::FormEditor;
pub use data_provider::{DataProvider, SuggestionsProvider, SuggestionItem};
pub use data_provider::DataProvider;
#[cfg(feature = "suggestions")]
pub use data_provider::{SuggestionsProvider, SuggestionItem};
// UI state (read-only access for users)
pub use canvas::state::EditorState;

View File

@@ -122,19 +122,36 @@ impl CharacterLimits {
pub fn validate_insertion(
&self,
current_text: &str,
_position: usize,
position: usize,
character: char,
) -> Option<ValidationResult> {
// FIX: Actually simulate the insertion at the specified position
// This makes the `position` parameter essential to the logic
// 1. Create the new string by inserting the character at the correct position
let mut new_text = String::with_capacity(current_text.len() + character.len_utf8());
let mut chars = current_text.chars();
// Append characters from the original string that come before the insertion point
// We clamp the position to be safe
let clamped_pos = position.min(current_text.chars().count());
for _ in 0..clamped_pos {
if let Some(ch) = chars.next() {
new_text.push(ch);
}
}
// Insert the new character
new_text.push(character);
// Append the rest of the original string
for ch in chars {
new_text.push(ch);
}
// 2. Now perform all validation on the *actual* resulting text
let new_count = self.count(&new_text);
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 {

View File

@@ -21,6 +21,8 @@ pub struct ValidationState {
/// External validation results per field (Feature 5)
external_results: HashMap<usize, ExternalValidationState>,
last_switch_block: Option<String>,
}
impl ValidationState {
@@ -32,6 +34,7 @@ impl ValidationState {
validated_fields: std::collections::HashSet::new(),
enabled: true,
external_results: HashMap::new(),
last_switch_block: None,
}
}
@@ -256,6 +259,22 @@ impl ValidationState {
error_fields: errors,
}
}
/// Set the last switch block reason (for UI convenience)
pub fn set_last_switch_block<S: Into<String>>(&mut self, reason: S) {
self.last_switch_block = Some(reason.into());
}
/// Clear the last switch block reason
pub fn clear_last_switch_block(&mut self) {
self.last_switch_block = None;
}
/// Get the last switch block reason (if any)
pub fn last_switch_block(&self) -> Option<&str> {
self.last_switch_block.as_deref()
}
}
/// Summary of validation state across all fields