From 67512ac1510328d2080d40d23ba5adb07afd4fb9 Mon Sep 17 00:00:00 2001 From: Priec Date: Fri, 15 Aug 2025 00:06:19 +0200 Subject: [PATCH] src/editor.rs doesnt exist anymore --- canvas/src/editor.rs | 2192 ----------------------- canvas/src/editor/computed_helpers.rs | 111 ++ canvas/src/editor/core.rs | 122 ++ canvas/src/editor/display.rs | 123 ++ canvas/src/editor/editing.rs | 348 ++++ canvas/src/editor/mod.rs | 21 + canvas/src/editor/mode.rs | 222 +++ canvas/src/editor/movement.rs | 715 ++++++++ canvas/src/editor/navigation.rs | 177 ++ canvas/src/editor/suggestions.rs | 166 ++ canvas/src/editor/suggestions_stub.rs | 23 + canvas/src/editor/validation_helpers.rs | 178 ++ 12 files changed, 2206 insertions(+), 2192 deletions(-) delete mode 100644 canvas/src/editor.rs create mode 100644 canvas/src/editor/computed_helpers.rs create mode 100644 canvas/src/editor/core.rs create mode 100644 canvas/src/editor/display.rs create mode 100644 canvas/src/editor/editing.rs create mode 100644 canvas/src/editor/mod.rs create mode 100644 canvas/src/editor/mode.rs create mode 100644 canvas/src/editor/movement.rs create mode 100644 canvas/src/editor/navigation.rs create mode 100644 canvas/src/editor/suggestions.rs create mode 100644 canvas/src/editor/suggestions_stub.rs create mode 100644 canvas/src/editor/validation_helpers.rs diff --git a/canvas/src/editor.rs b/canvas/src/editor.rs deleted file mode 100644 index 31bb0a9..0000000 --- a/canvas/src/editor.rs +++ /dev/null @@ -1,2192 +0,0 @@ -// src/editor.rs -//! Main API for the canvas library - FormEditor with library-owned state - -#[cfg(feature = "cursor-style")] -use crate::canvas::CursorManager; - -use anyhow::Result; -use crate::canvas::state::EditorState; -use crate::{DataProvider, SuggestionItem}; -use crate::canvas::modes::AppMode; -use crate::canvas::state::SelectionState; -use crate::canvas::actions::movement::word::{ - find_last_word_start_in_field, find_last_word_end_in_field, - find_last_WORD_start_in_field, find_last_WORD_end_in_field, -}; - -/// Main editor that manages UI state internally and delegates data to user -pub struct FormEditor { - // Library owns all UI state - ui_state: EditorState, - - // User owns business data - data_provider: D, - - // Autocomplete suggestions (library manages UI, user provides data) - pub(crate) suggestions: Vec, - - #[cfg(feature = "validation")] - external_validation_callback: Option< - Box< - dyn FnMut(usize, &str) -> crate::validation::ExternalValidationState - + Send - + Sync, - >, - >, -} - -impl FormEditor { - /// Convert a char index to a byte index in a string - 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)] - /// Convert a byte index to a char index in a string - 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, - suggestions: Vec::new(), - #[cfg(feature = "validation")] - external_validation_callback: None, - }; - - // Initialize validation configurations if validation feature is enabled - #[cfg(feature = "validation")] - { - let mut editor = editor; - editor.initialize_validation(); - editor - } - #[cfg(not(feature = "validation"))] - { - editor - } - } - - /// Get current field text (convenience method) - fn current_text(&self) -> &str { - // Convenience wrapper, kept for compatibility with existing code - let field_index = self.ui_state.current_field; - if field_index < self.data_provider.field_count() { - self.data_provider.field_value(field_index) - } else { - "" - } - } - - /// Compute inline completion for current selection and current text. - fn compute_current_completion(&self) -> Option { - 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(); - } - - /// Initialize validation configurations from data provider - #[cfg(feature = "validation")] - fn initialize_validation(&mut self) { - let field_count = self.data_provider.field_count(); - for field_index in 0..field_count { - if let Some(config) = self.data_provider.validation_config(field_index) { - self.ui_state.validation.set_field_config(field_index, config); - } - } - } - - // =================================================================== - // READ-ONLY ACCESS: User can fetch UI state - // =================================================================== - - /// Get current field index (for user's compatibility) - pub fn current_field(&self) -> usize { - self.ui_state.current_field() - } - - /// Get current cursor position (for user's compatibility) - pub fn cursor_position(&self) -> usize { - self.ui_state.cursor_position() - } - - /// Get current mode (for user's mode-dependent logic) - pub fn mode(&self) -> AppMode { - self.ui_state.mode() - } - - /// Check if suggestions dropdown is active (for user's logic) - pub fn is_suggestions_active(&self) -> bool { - self.ui_state.is_suggestions_active() - } - - /// Get current field text for display. - /// - /// Policies: - /// - Feature 4 (custom formatter): - /// - While editing the focused field: ALWAYS show raw (no custom formatting). - /// - Mask-only fields: mask applies even in Edit mode (preserve legacy behavior). - /// - Otherwise: raw. - #[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) { - // 1) Mask-only fields: mask applies even in Edit (legacy behavior) - if cfg.custom_formatter.is_none() { - if let Some(mask) = &cfg.display_mask { - return mask.apply_to_display(raw); - } - } - - // 2) Feature 4 fields: raw while editing, formatted otherwise - 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; - } - } - - // 3) Fallback to mask if present (when formatter didn't produce output) - if let Some(mask) = &cfg.display_mask { - return mask.apply_to_display(raw); - } - } - - raw.to_string() - } - - /// Get reference to UI state for rendering - pub fn ui_state(&self) -> &EditorState { - &self.ui_state - } - - /// Open the suggestions UI for `field_index` (UI-only; does not fetch). - pub fn open_suggestions(&mut self, field_index: usize) { - self.ui_state.open_suggestions(field_index); - } - - /// Close suggestions UI and clear the current suggestion results. - pub fn close_suggestions(&mut self) { - self.ui_state.close_suggestions(); - self.suggestions.clear(); - } - - /// Set external validation state for a field (Feature 5) - #[cfg(feature = "validation")] - pub fn set_external_validation( - &mut self, - field_index: usize, - state: crate::validation::ExternalValidationState, - ) { - self.ui_state - .validation - .set_external_validation(field_index, state); - } - - /// Clear external validation state for a specific field - #[cfg(feature = "validation")] - pub fn clear_external_validation(&mut self, field_index: usize) { - self.ui_state.validation.clear_external_validation(field_index); - } - - /// Set external validation callback (Feature 5) - #[cfg(feature = "validation")] - pub fn set_external_validation_callback(&mut self, callback: F) - where - F: FnMut(usize, &str) -> crate::validation::ExternalValidationState - + Send - + Sync - + 'static, - { - self.external_validation_callback = Some(Box::new(callback)); - } - - /// Get effective display text for any field index. - /// - /// Policies: - /// - Feature 4 fields (with custom formatter): - /// - If the field is currently focused AND in Edit mode: return raw (no formatting). - /// - Mask-only fields: mask applies regardless of mode (legacy behavior). - /// - Otherwise: raw. - #[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) { - // Mask-only fields: mask applies even in Edit mode - if cfg.custom_formatter.is_none() { - if let Some(mask) = &cfg.display_mask { - return mask.apply_to_display(raw); - } - } - - // Feature 4 fields: - if cfg.custom_formatter.is_some() { - // Focused + Edit -> raw - if field_index == self.ui_state.current_field - && matches!(self.ui_state.current_mode, AppMode::Edit) - { - return raw.to_string(); - } - // Not editing -> formatted - if let Some((formatted, _mapper, _warning)) = cfg.run_custom_formatter(raw) { - return formatted; - } - } - - // Fallback to mask if present (in case formatter didn't return output) - if let Some(mask) = &cfg.display_mask { - return mask.apply_to_display(raw); - } - } - - raw.to_string() - } - - /// Get reference to data provider for rendering - pub fn data_provider(&self) -> &D { - &self.data_provider - } - - /// Get autocomplete suggestions for rendering (read-only) - pub fn suggestions(&self) -> &[SuggestionItem] { - &self.suggestions - } - - /// Get validation state (for user's business logic) - /// Only available when the 'validation' feature is enabled - #[cfg(feature = "validation")] - pub fn validation_state(&self) -> &crate::validation::ValidationState { - self.ui_state.validation_state() - } - - /// Get validation result for current field - #[cfg(feature = "validation")] - pub fn current_field_validation(&self) -> Option<&crate::validation::ValidationResult> { - self.ui_state.validation.get_field_result(self.ui_state.current_field) - } - - /// Get validation result for specific field - #[cfg(feature = "validation")] - pub fn field_validation(&self, field_index: usize) -> Option<&crate::validation::ValidationResult> { - self.ui_state.validation.get_field_result(field_index) - } - - // =================================================================== - // VIM COMMANDS: o and O - // =================================================================== - - /// Open new line below (vim o) - move to next field and enter insert mode - pub fn open_line_below(&mut self) -> Result<()> { - 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)); - - // Move to next field - self.transition_to_field(next_field)?; - - // Set cursor to start of field - self.ui_state.cursor_pos = 0; - self.ui_state.ideal_cursor_column = 0; - - // Enter edit mode - self.enter_edit_mode(); - - Ok(()) - } - - /// Open new line above (vim O) - move to previous field and enter insert mode - pub fn open_line_above(&mut self) -> Result<()> { - let prev_field = self.ui_state.current_field.saturating_sub(1); - - // Move to previous field - self.transition_to_field(prev_field)?; - - // Set cursor to start of field - self.ui_state.cursor_pos = 0; - self.ui_state.ideal_cursor_column = 0; - - // Enter edit mode - self.enter_edit_mode(); - - Ok(()) - } - - // =================================================================== - // SYNC OPERATIONS: No async needed for basic editing - // =================================================================== - - /// Centralized field transition logic - pub fn transition_to_field(&mut self, new_field: usize) -> Result<()> { - let field_count = self.data_provider.field_count(); - if field_count == 0 { - return Ok(()); - } - - let prev_field = self.ui_state.current_field; - - // FIX 2: Only mut when computed feature actually modifies it - #[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); - - // 2. Computed field skipping - #[cfg(feature = "computed")] - { - if let Some(computed_state) = &self.ui_state.computed { - if computed_state.is_computed_field(target_field) { - // Determine direction and search for nearest non-computed field - if target_field >= prev_field { - // Moving down: search forward - for i in (target_field + 1)..field_count { - if !computed_state.is_computed_field(i) { - target_field = i; - break; - } - } - } else { - // Moving up: search backward - let mut i = target_field; - loop { - if !computed_state.is_computed_field(i) { - target_field = i; - break; - } - if i == 0 { - break; - } - i -= 1; - } - } - } - } - } - - // No-op if the resolved target is the same as current - if target_field == prev_field { - return Ok(()); - } - - // Clear any previous switch block status on successful transition start - #[cfg(feature = "validation")] - self.ui_state.validation.clear_last_switch_block(); - - // 3. Blocking validation before leaving current field - #[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) - { - // Record the block reason for UI - self.ui_state - .validation - .set_last_switch_block(reason.clone()); - tracing::debug!("Field switch blocked: {}", reason); - return Err(anyhow::anyhow!("Cannot switch fields: {}", reason)); - } - } - } - - // 4. Exit hook for current field (content validation + external validation trigger) - #[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 external validation is enabled for this field and there is content - if cfg.external_validation_enabled && !text.is_empty() { - // Trigger external validation state - self.set_external_validation(prev_field, crate::validation::ExternalValidationState::Validating); - - // Invoke external callback if registered and set final state - 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 (requires provider) - // Could call on_field_changed with a provider when available. - } - - // 5. Move to new field - self.ui_state.move_to_field(target_field, field_count); - - // 6. Clamp cursor to new field - 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 - self.close_suggestions(); - - Ok(()) - } - - /// Handle character insertion with proper mask/limit coordination - pub fn insert_char(&mut self, ch: char) -> Result<()> { - if self.ui_state.current_mode != AppMode::Edit { - return Ok(()); // Ignore in non-edit modes - } - - // Variables are only declared when the features that use them are enabled - #[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); - - // When validation is disabled, we declare these variables differently - #[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); - - // Mask gate: reject input that doesn't fit the mask at current position - #[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); - - // Reject if at/after end of mask pattern (in char positions) - let pattern_char_len = mask.pattern().chars().count(); - if display_cursor_pos >= pattern_char_len { - return Ok(()); - } - - // Reject if on a separator, not an input position - if !mask.is_input_position(display_cursor_pos) { - return Ok(()); - } - - // Reject if mask is already full - 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(()); - } - } - } - } - - // Validation: character insertion - #[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(()); // Silently reject invalid input - } - } - - // Build new raw text with inserted character at char index raw_cursor_pos - 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 - }; - - // Post-validate full content and mask capacity - #[cfg(feature = "validation")] - { - if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) { - // Check character limits - if let Some(limits) = &cfg.character_limits { - if let Some(result) = limits.validate_content(&new_raw_text) { - if !result.is_acceptable() { - return Ok(()); // Silently reject - } - } - } - // Check mask capacity again against new length - 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(()); - } - } - } - } - - // Commit - self.data_provider - .set_field_value(field_index, new_raw_text.clone()); - - // Move 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 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(()); - } - } - } - - // No mask -> simple increment - self.ui_state.cursor_pos = raw_cursor_pos + 1; - self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos; - Ok(()) - } - - /// Move to previous field (vim k / up arrow) - pub fn move_up(&mut self) -> 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 arrow) - pub fn move_down(&mut self) -> 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 (vim j / down arrow) - pub fn move_to_next_field(&mut self) -> 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) - } - - /// Restore left and right movement within the current field - /// Move cursor left within current field - pub fn move_left(&mut self) -> Result<()> { - // FIX 3: Only mut when validation feature modifies it - #[cfg(feature = "validation")] - let mut moved = false; - #[cfg(not(feature = "validation"))] - let moved = false; - - // Try mask-aware movement if validation/mask config exists - #[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 { - // Fallback to simple left movement - 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 - pub fn move_right(&mut self) -> Result<()> { - // FIX 4: Only mut when validation feature modifies it - #[cfg(feature = "validation")] - let mut moved = false; - #[cfg(not(feature = "validation"))] - let moved = false; - - // Try mask-aware movement if mask is configured for this field - #[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); - // Next input position now returns usize - 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 { - // Fallback to simple right movement - 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(()) - } - - /// Change mode (for vim compatibility) - pub fn set_mode(&mut self, mode: AppMode) { - match (self.ui_state.current_mode, mode) { - // Entering highlight mode from read-only - (AppMode::ReadOnly, AppMode::Highlight) => { - self.enter_highlight_mode(); - } - // Exiting highlight mode - (AppMode::Highlight, AppMode::ReadOnly) => { - self.exit_highlight_mode(); - } - // Other transitions - (_, 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); - } - } - } - } - - /// Enter edit mode with cursor positioned for append (vim 'a' command) - pub fn enter_append_mode(&mut self) { - let current_text = self.current_text(); - - // Calculate append position: always move right, even at line end - let 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) - }; - - // Set cursor position for append - self.ui_state.cursor_pos = append_pos; - self.ui_state.ideal_cursor_column = append_pos; - - // Enter edit mode (which will update cursor style) - self.set_mode(AppMode::Edit); - } - - // =================================================================== - // VALIDATION METHODS (only available with validation feature) - // =================================================================== - - /// Enable or disable validation - #[cfg(feature = "validation")] - pub fn set_validation_enabled(&mut self, enabled: bool) { - self.ui_state.validation.set_enabled(enabled); - } - - /// Check if validation is enabled - #[cfg(feature = "validation")] - pub fn is_validation_enabled(&self) -> bool { - self.ui_state.validation.is_enabled() - } - - /// Set validation configuration for a specific field - #[cfg(feature = "validation")] - pub fn set_field_validation(&mut self, field_index: usize, config: crate::validation::ValidationConfig) { - self.ui_state.validation.set_field_config(field_index, config); - } - - /// Remove validation configuration for a specific field - #[cfg(feature = "validation")] - pub fn remove_field_validation(&mut self, field_index: usize) { - self.ui_state.validation.remove_field_config(field_index); - } - - /// Manually validate current field content - #[cfg(feature = "validation")] - pub fn validate_current_field(&mut self) -> crate::validation::ValidationResult { - let field_index = self.ui_state.current_field; - let current_text = self.current_text().to_string(); - self.ui_state.validation.validate_field_content(field_index, ¤t_text) - } - - /// Manually validate specific field content - #[cfg(feature = "validation")] - pub fn validate_field(&mut self, field_index: usize) -> Option { - if field_index < self.data_provider.field_count() { - let text = self.data_provider.field_value(field_index).to_string(); - Some(self.ui_state.validation.validate_field_content(field_index, &text)) - } else { - None - } - } - - /// Clear validation results for all fields - #[cfg(feature = "validation")] - pub fn clear_validation_results(&mut self) { - self.ui_state.validation.clear_all_results(); - } - - /// Get validation summary for all fields - #[cfg(feature = "validation")] - pub fn validation_summary(&self) -> crate::validation::ValidationSummary { - self.ui_state.validation.summary() - } - - /// Check if field switching is allowed from current field - #[cfg(feature = "validation")] - pub fn can_switch_fields(&self) -> bool { - let current_text = self.current_text(); - self.ui_state.validation.allows_field_switch(self.ui_state.current_field, current_text) - } - - /// Get reason why field switching is blocked (if any) - #[cfg(feature = "validation")] - pub fn field_switch_block_reason(&self) -> Option { - let current_text = self.current_text(); - self.ui_state.validation.field_switch_block_reason(self.ui_state.current_field, current_text) - } - - /// Get the last field switch block reason (UI convenience) - #[cfg(feature = "validation")] - pub fn last_switch_block(&self) -> Option<&str> { - self.ui_state.validation.last_switch_block() - } - - /// Get character limits status text for current field (UI convenience) - #[cfg(feature = "validation")] - pub fn current_limits_status_text(&self) -> Option { - 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 - } - - /// Get current custom formatter warning (UI convenience) - #[cfg(feature = "validation")] - pub fn current_formatter_warning(&self) -> Option { - 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 - } - - /// Get external validation state for specific field (UI convenience) - #[cfg(feature = "validation")] - pub fn external_validation_of( - &self, - field_index: usize, - ) -> crate::validation::ExternalValidationState { - self.ui_state - .validation - .get_external_validation(field_index) - } - - /// Clear all external validation states (UI convenience) - #[cfg(feature = "validation")] - pub fn clear_all_external_validation(&mut self) { - self.ui_state.validation.clear_all_external_validation(); - } - - // =================================================================== - // ASYNC OPERATIONS: Only suggestions need async - // =================================================================== - - // NOTE: trigger_suggestions (the async fetch helper) was removed in favor of - // the non-blocking start_suggestions / apply_suggestions_result API. - - /// Trigger suggestions (async because it fetches data) - /// (Removed - use start_suggestions + apply_suggestions_result instead) - - // =================================================================== - // NON-BLOCKING SUGGESTIONS API (ONLY API) - // =================================================================== - - /// Begin suggestions loading for a field (UI updates immediately, no fetch) - /// This opens the dropdown with "Loading..." state instantly - /// - /// The caller is responsible for fetching suggestions and calling - /// `apply_suggestions_result()` when ready. - #[cfg(feature = "suggestions")] - pub fn start_suggestions(&mut self, field_index: usize) -> Option { - if !self.data_provider.supports_suggestions(field_index) { - return None; - } - - let query = self.current_text().to_string(); - - // Open suggestions UI immediately - user sees dropdown right away - self.ui_state.open_suggestions(field_index); - - // ADD THIS LINE - mark as loading so UI shows "Loading..." - self.ui_state.suggestions.is_loading = true; - - // Store the query we're loading for (prevents stale results) - self.ui_state.suggestions.active_query = Some(query.clone()); - - // Clear any old suggestions - self.suggestions.clear(); - - // Return the query so caller knows what to fetch - Some(query) - } - - #[cfg(not(feature = "suggestions"))] - pub fn start_suggestions(&mut self, _field_index: usize) -> Option { - None - } - - /// Apply fetched suggestions results - /// - /// This will ignore stale results if the field or query has changed since - /// `start_suggestions()` was called. - /// - /// Returns `true` if results were applied, `false` if they were stale/ignored. - #[cfg(feature = "suggestions")] - pub fn apply_suggestions_result( - &mut self, - field_index: usize, - query: &str, - results: Vec, - ) -> bool { - // Ignore stale results: wrong field - if self.ui_state.suggestions.active_field != Some(field_index) { - return false; - } - - // Ignore stale results: query has changed - if self.ui_state.suggestions.active_query.as_deref() != Some(query) { - return false; - } - - // Apply results - 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, - ) -> bool { - false - } - - /// Check if there's an active suggestions query waiting for results - /// - /// Returns (field_index, query) if suggestions are loading, None otherwise. - #[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 - } - - /// Cancel any pending suggestions (useful for cleanup) - pub fn cancel_suggestions(&mut self) { - self.close_suggestions(); - } - - /// Navigate 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); - - // Update inline completion to reflect new highlighted item - self.update_inline_completion(); - } - - /// Apply selected suggestion - /// Apply selected suggestion - pub fn apply_suggestion(&mut self) -> Option { - 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; - - // Apply to user's data - self.data_provider.set_field_value( - field_index, - suggestion.value_to_store.clone() - ); - - // Update cursor position - self.ui_state.cursor_pos = suggestion.value_to_store.len(); - self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos; - - // Close suggestions - self.close_suggestions(); - self.suggestions.clear(); - - // Validate the new content if validation is enabled - #[cfg(feature = "validation")] - { - let _validation_result = self.ui_state.validation.validate_field_content( - field_index, - &suggestion.value_to_store, - ); - } - - return Some(suggestion.display_text); - } - } - None - } - - // =================================================================== - // MOVEMENT METHODS (keeping existing implementations) - // =================================================================== - - /// Move to first line (vim gg) - pub fn move_first_line(&mut self) -> Result<()> { - self.transition_to_field(0) - } - - /// Move to last line (vim G) - pub fn move_last_line(&mut self) -> Result<()> { - let last_field = self.data_provider.field_count().saturating_sub(1); - self.transition_to_field(last_field) - } - - /// Move to previous field (alternative to move_up) - pub fn prev_field(&mut self) -> Result<()> { - self.move_up() - } - - // ================================================================================================ - // COMPUTED FIELDS (behind 'computed' feature) - // ================================================================================================ - - /// Initialize computed fields from provider and computed provider - #[cfg(feature = "computed")] - pub fn set_computed_provider(&mut self, mut provider: C) - where - C: crate::computed::ComputedProvider, - { - // Initialize computed state - self.ui_state.computed = Some(crate::computed::ComputedState::new()); - - // Register computed fields and their dependencies - 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); - } - } - } - - // Initial computation of all computed fields - self.recompute_all_fields(&mut provider); - } - - /// Recompute specific computed fields - #[cfg(feature = "computed")] - pub fn recompute_fields(&mut self, provider: &mut C, field_indices: &[usize]) - where - C: crate::computed::ComputedProvider, - { - if let Some(computed_state) = &mut self.ui_state.computed { - // Collect all field values for context - let field_values: Vec = (0..self.data_provider.field_count()) - .map(|i| { - if computed_state.is_computed_field(i) { - // Use cached computed value - computed_state - .get_computed_value(i) - .cloned() - .unwrap_or_default() - } else { - // Use regular field value - self.data_provider.field_value(i).to_string() - } - }) - .collect(); - - let field_refs: Vec<&str> = field_values.iter().map(|s| s.as_str()).collect(); - - // Recompute specified fields - for &field_index in field_indices { - if provider.handles_field(field_index) { - let context = crate::computed::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); - } - } - } - } - - /// Recompute all computed fields - #[cfg(feature = "computed")] - pub fn recompute_all_fields(&mut self, provider: &mut C) - where - C: crate::computed::ComputedProvider, - { - if let Some(computed_state) = &self.ui_state.computed { - let computed_fields: Vec = computed_state.computed_fields().collect(); - self.recompute_fields(provider, &computed_fields); - } - } - - /// Trigger recomputation when field changes (call this after set_field_value) - #[cfg(feature = "computed")] - pub fn on_field_changed(&mut self, provider: &mut C, changed_field: usize) - where - C: crate::computed::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); - } - } - } - - /// Enhanced getter that returns computed values for computed fields when available - #[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() - } - - /// Move to next field (alternative to move_down) - pub fn next_field(&mut self) -> Result<()> { - self.move_down() - } - - /// Move to start of current field (vim 0) - pub fn move_line_start(&mut self) { - use crate::canvas::actions::movement::line::line_start_position; - 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) { - use crate::canvas::actions::movement::line::line_end_position; - let current_text = self.current_text(); - let is_edit_mode = self.ui_state.current_mode == AppMode::Edit; - - let new_pos = line_end_position(current_text, is_edit_mode); - self.ui_state.cursor_pos = new_pos; - self.ui_state.ideal_cursor_column = new_pos; - } - - /// 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; - } - - // CHANGE THIS LINE: replace find_prev_word_end_corrected with find_prev_word_end - 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 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 (but don't recurse) - if self.move_down().is_ok() { - let new_text = self.current_text(); - if !new_text.is_empty() { - // Find first WORD end in new field - let first_WORD_end = find_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_WORD_end.min(char_len) - } else { - first_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_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 (but don't recurse) - if new_pos >= char_len.saturating_sub(1) { - if self.move_down().is_ok() { - // Find first WORD end in new field - let new_text = self.current_text(); - if !new_text.is_empty() { - let first_WORD_end = find_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_WORD_end.min(new_char_len) - } else { - first_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 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_WORD_end}; - 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; - } - } - - /// Delete character before cursor (vim x in insert mode / backspace) - pub fn delete_backward(&mut self) -> Result<()> { - if self.ui_state.current_mode != 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(¤t_text, self.ui_state.cursor_pos - 1); - let end = Self::char_to_byte_index(¤t_text, self.ui_state.cursor_pos); - current_text.replace_range(start..end, ""); - self.data_provider.set_field_value(field_index, current_text.clone()); - - // FIX 5: Only mut when validation feature might modify it - #[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, - ¤t_text, - ); - } - - Ok(()) - } - - /// Delete character under cursor (vim x / delete key) - pub fn delete_forward(&mut self) -> Result<()> { - if self.ui_state.current_mode != 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(¤t_text, self.ui_state.cursor_pos); - let end = Self::char_to_byte_index(¤t_text, self.ui_state.cursor_pos + 1); - current_text.replace_range(start..end, ""); - self.data_provider.set_field_value(field_index, current_text.clone()); - - // FIX 6: Only mut when validation feature might modify it - #[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, - ¤t_text, - ); - } - } - - Ok(()) - } - - /// 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(); - } - } - - /// Exit edit mode to read-only mode (vim Escape) - pub fn exit_edit_mode(&mut self) -> Result<()> { - // Validate current field content when exiting edit mode - #[cfg(feature = "validation")] - { - let current_text = self.current_text(); - if !self.ui_state.validation.allows_field_switch(self.ui_state.current_field, current_text) { - if let Some(reason) = self.ui_state.validation.field_switch_block_reason(self.ui_state.current_field, current_text) { - // Record the block reason for UI - self.ui_state - .validation - .set_last_switch_block(reason.clone()); - return Err(anyhow::anyhow!("Cannot exit edit mode: {}", reason)); - } - } - } - - // Adjust cursor position when transitioning from edit to normal mode - let current_text = self.current_text(); - if !current_text.is_empty() { - // In normal mode, cursor must be ON a character, not after the last one - 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; - } - } - - // Trigger external validation on blur/exit edit mode - #[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); - // Deactivate suggestions when exiting edit mode - 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) { - // Can't edit computed fields - silently ignore - return; - } - } - } - self.set_mode(AppMode::Edit); - } - - // =================================================================== - // HELPER METHODS - // =================================================================== - - /// Set the value of the current field - pub fn set_current_field_value(&mut self, value: String) { - let field_index = self.ui_state.current_field; - self.data_provider.set_field_value(field_index, value.clone()); - // Reset cursor to start of field - self.ui_state.cursor_pos = 0; - self.ui_state.ideal_cursor_column = 0; - - // Validate the new content if validation is enabled - #[cfg(feature = "validation")] - { - let _validation_result = self.ui_state.validation.validate_field_content( - field_index, - &value, - ); - } - } - - /// Set the value of a specific field by index - pub fn set_field_value(&mut self, field_index: usize, value: String) { - if field_index < self.data_provider.field_count() { - self.data_provider.set_field_value(field_index, value.clone()); - // If we're modifying the current field, reset cursor - if field_index == self.ui_state.current_field { - self.ui_state.cursor_pos = 0; - self.ui_state.ideal_cursor_column = 0; - } - - // Validate the new content if validation is enabled - #[cfg(feature = "validation")] - { - let _validation_result = self.ui_state.validation.validate_field_content( - field_index, - &value, - ); - } - } - } - - /// Clear the current field (set to empty string) - pub fn clear_current_field(&mut self) { - self.set_current_field_value(String::new()); - } - - /// Get mutable access to data provider (for advanced operations) - pub fn data_provider_mut(&mut self) -> &mut D { - &mut self.data_provider - } - - /// Set cursor to exact position (for vim-style movements like f, F, t, T) - pub fn set_cursor_position(&mut self, position: usize) { - let current_text = self.current_text(); - let is_edit_mode = self.ui_state.current_mode == AppMode::Edit; - - // Clamp to valid bounds for current mode - let 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); - - // Update cursor position directly - self.ui_state.cursor_pos = clamped_pos; - self.ui_state.ideal_cursor_column = clamped_pos; - } - - /// Get cursor position for display (maps raw cursor to display position with formatter/mask) - pub fn display_cursor_position(&self) -> usize { - let current_text = self.current_text(); - - // Count characters, not bytes - let char_count = current_text.chars().count(); - - // Clamp raw_pos based on mode - 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) { - // Apply custom formatter mapping if not editing - 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); - } - } - // Apply mask mapping using clamped raw_pos - if let Some(mask) = &cfg.display_mask { - return mask.raw_pos_to_display_pos(raw_pos); - } - } - } - - raw_pos - } - - /// Cleanup cursor style (call this when shutting down) - #[cfg(feature = "cursor-style")] - pub fn cleanup_cursor(&self) -> std::io::Result<()> { - crate::canvas::CursorManager::reset() - } - - #[cfg(not(feature = "cursor-style"))] - pub fn cleanup_cursor(&self) -> std::io::Result<()> { - Ok(()) - } - - - // =================================================================== - // HIGHLIGHT MODE - // =================================================================== - - /// Enter highlight mode (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); - } - } - } - - /// Enter highlight line mode (visual line mode) - 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); - } - } - } - - /// Exit highlight mode back to read-only - 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); - } - } - } - - /// Check if currently in highlight mode - pub fn is_highlight_mode(&self) -> bool { - self.ui_state.current_mode == AppMode::Highlight - } - - /// Get current selection state - pub fn selection_state(&self) -> &SelectionState { - &self.ui_state.selection - } - - /// Enhanced movement methods that update selection in highlight mode - pub fn move_left_with_selection(&mut self) { - let _ = self.move_left(); - // Selection anchor stays in place, cursor position updates automatically - } - - pub fn move_right_with_selection(&mut self) { - let _ = self.move_right(); - // Selection anchor stays in place, cursor position updates automatically - } - - pub fn move_up_with_selection(&mut self) { - let _ = self.move_up(); - // Selection anchor stays in place, cursor position updates automatically - } - - pub fn move_down_with_selection(&mut self) { - let _ = self.move_down(); - // Selection anchor stays in place, cursor position updates automatically - } - - // Add similar methods for word movement, line movement, etc. - 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_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_line_start_with_selection(&mut self) { - self.move_line_start(); - } - - pub fn move_line_end_with_selection(&mut self) { - self.move_line_end(); - } -} - -// Add Drop implementation for automatic cleanup -impl Drop for FormEditor { - fn drop(&mut self) { - // Reset cursor to default when FormEditor is dropped - let _ = self.cleanup_cursor(); - } -} diff --git a/canvas/src/editor/computed_helpers.rs b/canvas/src/editor/computed_helpers.rs new file mode 100644 index 0000000..2a8d21e --- /dev/null +++ b/canvas/src/editor/computed_helpers.rs @@ -0,0 +1,111 @@ +// src/editor/computed_helpers.rs + +use crate::computed::{ComputedContext, ComputedProvider, ComputedState}; +use crate::editor::FormEditor; +use crate::DataProvider; + +impl FormEditor { + #[cfg(feature = "computed")] + pub fn set_computed_provider(&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( + &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 = (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(&mut self, provider: &mut C) + where + C: ComputedProvider, + { + if let Some(computed_state) = &self.ui_state.computed { + let computed_fields: Vec = + computed_state.computed_fields().collect(); + self.recompute_fields(provider, &computed_fields); + } + } + + #[cfg(feature = "computed")] + pub fn on_field_changed( + &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() + } +} diff --git a/canvas/src/editor/core.rs b/canvas/src/editor/core.rs new file mode 100644 index 0000000..52dbe28 --- /dev/null +++ b/canvas/src/editor/core.rs @@ -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 { + pub(crate) ui_state: EditorState, + pub(crate) data_provider: D, + #[cfg(feature = "suggestions")] + pub(crate) suggestions: Vec, + + #[cfg(feature = "validation")] + pub(crate) external_validation_callback: Option< + Box< + dyn FnMut(usize, &str) -> crate::validation::ExternalValidationState + + Send + + Sync, + >, + >, +} + +impl FormEditor { + // 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 Drop for FormEditor { + fn drop(&mut self) { + let _ = self.cleanup_cursor(); + } +} diff --git a/canvas/src/editor/display.rs b/canvas/src/editor/display.rs new file mode 100644 index 0000000..cf790af --- /dev/null +++ b/canvas/src/editor/display.rs @@ -0,0 +1,123 @@ +// src/editor/display.rs + +use crate::canvas::modes::AppMode; +use crate::editor::FormEditor; +use crate::DataProvider; + +impl FormEditor { + /// 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 + } +} diff --git a/canvas/src/editor/editing.rs b/canvas/src/editor/editing.rs new file mode 100644 index 0000000..694fa93 --- /dev/null +++ b/canvas/src/editor/editing.rs @@ -0,0 +1,348 @@ +// src/editor/editing.rs + +use crate::editor::FormEditor; +use crate::DataProvider; + +impl FormEditor { + /// 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( + ¤t_text, + self.ui_state.cursor_pos - 1, + ); + let end = + Self::char_to_byte_index(¤t_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, + ¤t_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( + ¤t_text, + self.ui_state.cursor_pos, + ); + let end = Self::char_to_byte_index( + ¤t_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, + ¤t_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()); + } +} diff --git a/canvas/src/editor/mod.rs b/canvas/src/editor/mod.rs new file mode 100644 index 0000000..6a92da2 --- /dev/null +++ b/canvas/src/editor/mod.rs @@ -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; diff --git a/canvas/src/editor/mode.rs b/canvas/src/editor/mode.rs new file mode 100644 index 0000000..fa164ac --- /dev/null +++ b/canvas/src/editor/mode.rs @@ -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 FormEditor { + /// 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_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_line_start_with_selection(&mut self) { + self.move_line_start(); + } + + pub fn move_line_end_with_selection(&mut self) { + self.move_line_end(); + } +} diff --git a/canvas/src/editor/movement.rs b/canvas/src/editor/movement.rs new file mode 100644 index 0000000..22841df --- /dev/null +++ b/canvas/src/editor/movement.rs @@ -0,0 +1,715 @@ +// 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_WORD_end_in_field, find_last_WORD_start_in_field, + find_last_word_end_in_field, find_last_word_start_in_field, + find_next_WORD_start, find_next_word_start, find_prev_WORD_end, + find_prev_WORD_start, find_prev_word_end, find_prev_word_start, + find_WORD_end, find_word_end, +}; + +impl FormEditor { + /// 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 FormEditor { + /// 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; + } + + // CHANGE THIS LINE: replace find_prev_word_end_corrected with find_prev_word_end + 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 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 (but don't recurse) + if self.move_down().is_ok() { + let new_text = self.current_text(); + if !new_text.is_empty() { + // Find first WORD end in new field + let first_WORD_end = find_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_WORD_end.min(char_len) + } else { + first_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_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 (but don't recurse) + if new_pos >= char_len.saturating_sub(1) { + if self.move_down().is_ok() { + // Find first WORD end in new field + let new_text = self.current_text(); + if !new_text.is_empty() { + let first_WORD_end = find_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_WORD_end.min(new_char_len) + } else { + first_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 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_WORD_end}; + 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; + } + } +} diff --git a/canvas/src/editor/navigation.rs b/canvas/src/editor/navigation.rs new file mode 100644 index 0000000..24b9665 --- /dev/null +++ b/canvas/src/editor/navigation.rs @@ -0,0 +1,177 @@ +// src/editor/navigation.rs + +use crate::canvas::modes::AppMode; +use crate::editor::FormEditor; +use crate::DataProvider; + +impl FormEditor { + /// 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() + } +} diff --git a/canvas/src/editor/suggestions.rs b/canvas/src/editor/suggestions.rs new file mode 100644 index 0000000..9bd9c9f --- /dev/null +++ b/canvas/src/editor/suggestions.rs @@ -0,0 +1,166 @@ +// src/editor/suggestions.rs + +use crate::editor::FormEditor; +use crate::{DataProvider, SuggestionItem}; + +impl FormEditor { + /// Compute inline completion for current selection and text + fn compute_current_completion(&self) -> Option { + 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 { + 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 { + None + } + + #[cfg(feature = "suggestions")] + pub fn apply_suggestions_result( + &mut self, + field_index: usize, + query: &str, + results: Vec, + ) -> 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, + ) -> 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 { + 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 + } +} diff --git a/canvas/src/editor/suggestions_stub.rs b/canvas/src/editor/suggestions_stub.rs new file mode 100644 index 0000000..c240eec --- /dev/null +++ b/canvas/src/editor/suggestions_stub.rs @@ -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 FormEditor { + 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 + } +} diff --git a/canvas/src/editor/validation_helpers.rs b/canvas/src/editor/validation_helpers.rs new file mode 100644 index 0000000..93a822c --- /dev/null +++ b/canvas/src/editor/validation_helpers.rs @@ -0,0 +1,178 @@ +// src/editor/validation_helpers.rs + +use crate::editor::FormEditor; +use crate::DataProvider; + +impl FormEditor { + #[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, ¤t_text) + } + + #[cfg(feature = "validation")] + pub fn validate_field( + &mut self, + field_index: usize, + ) -> Option { + 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 { + 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 { + 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 { + 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(&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); + } + } + } +}