1436 lines
54 KiB
Rust
1436 lines
54 KiB
Rust
// 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::data_provider::{DataProvider, SuggestionsProvider, SuggestionItem};
|
|
use crate::canvas::modes::AppMode;
|
|
use crate::canvas::state::SelectionState;
|
|
|
|
/// Main editor that manages UI state internally and delegates data to user
|
|
pub struct FormEditor<D: DataProvider> {
|
|
// 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<SuggestionItem>,
|
|
}
|
|
|
|
impl<D: DataProvider> FormEditor<D> {
|
|
pub fn new(data_provider: D) -> Self {
|
|
let mut editor = Self {
|
|
ui_state: EditorState::new(),
|
|
data_provider,
|
|
suggestions: Vec::new(),
|
|
};
|
|
|
|
// Initialize validation configurations if validation feature is enabled
|
|
#[cfg(feature = "validation")]
|
|
{
|
|
editor.initialize_validation();
|
|
}
|
|
|
|
editor
|
|
}
|
|
|
|
/// Compute inline completion for current selection and current text.
|
|
fn compute_current_completion(&self) -> Option<String> {
|
|
let typed = self.current_text();
|
|
let idx = self.ui_state.suggestions.selected_index?;
|
|
let sugg = self.suggestions.get(idx)?;
|
|
if let Some(rest) = sugg.value_to_store.strip_prefix(typed) {
|
|
if !rest.is_empty() {
|
|
return Some(rest.to_string());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Update UI state's completion text from current selection
|
|
pub fn update_inline_completion(&mut self) {
|
|
self.ui_state.suggestions.completion_text = self.compute_current_completion();
|
|
}
|
|
|
|
/// 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 (convenience method)
|
|
pub 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 {
|
|
""
|
|
}
|
|
}
|
|
|
|
/// Get current field text for display.
|
|
///
|
|
/// Policies:
|
|
/// - Feature 4 (custom formatter):
|
|
/// - While editing the focused field: ALWAYS show raw (no custom formatting).
|
|
/// - When not editing the field: show formatted (fallback to raw on error).
|
|
/// - 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
|
|
}
|
|
|
|
/// Mutable access to UI state for internal crate use only.
|
|
pub(crate) fn ui_state_mut(&mut self) -> &mut EditorState {
|
|
&mut 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 field (Feature 5)
|
|
#[cfg(feature = "validation")]
|
|
pub fn clear_external_validation(&mut self, field_index: usize) {
|
|
self.ui_state.validation.clear_external_validation(field_index);
|
|
}
|
|
|
|
/// Get effective display text for any field index.
|
|
///
|
|
/// Policies:
|
|
/// - Feature 4 fields (with custom formatter):
|
|
/// - If the field is currently focused AND in Edit mode: return raw (no formatting).
|
|
/// - Otherwise: return formatted (fallback to raw on error).
|
|
/// - 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)
|
|
}
|
|
|
|
// ===================================================================
|
|
// SYNC OPERATIONS: No async needed for basic editing
|
|
// ===================================================================
|
|
|
|
/// 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
|
|
}
|
|
|
|
let field_index = self.ui_state.current_field;
|
|
let raw_cursor_pos = self.ui_state.cursor_pos;
|
|
let current_raw_text = self.data_provider.field_value(field_index);
|
|
|
|
// 🔥 CRITICAL FIX 1: Check mask constraints FIRST
|
|
#[cfg(feature = "validation")]
|
|
{
|
|
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
|
|
if let Some(mask) = &cfg.display_mask {
|
|
// Get display cursor position
|
|
let display_cursor_pos = mask.raw_pos_to_display_pos(raw_cursor_pos);
|
|
|
|
// ❌ PREVENT BUG: Reject input if cursor is beyond mask pattern
|
|
if display_cursor_pos >= mask.pattern().len() {
|
|
tracing::debug!(
|
|
"Character insertion rejected: cursor beyond mask pattern length"
|
|
);
|
|
return Ok(()); // Silently reject - user can't type beyond mask
|
|
}
|
|
|
|
// ❌ PREVENT BUG: Reject input if cursor is on a separator position
|
|
if !mask.is_input_position(display_cursor_pos) {
|
|
tracing::debug!(
|
|
"Character insertion rejected: cursor on separator position {}",
|
|
display_cursor_pos
|
|
);
|
|
return Ok(()); // Silently reject - can't type on separators
|
|
}
|
|
|
|
// ❌ PREVENT BUG: Check if we're at max input positions for this mask
|
|
let input_char_count = (0..mask.pattern().len())
|
|
.filter(|&pos| mask.is_input_position(pos))
|
|
.count();
|
|
|
|
if current_raw_text.len() >= input_char_count {
|
|
tracing::debug!(
|
|
"Character insertion rejected: mask pattern full ({} input positions)",
|
|
input_char_count
|
|
);
|
|
return Ok(()); // Silently reject - mask is full
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 🔥 CRITICAL FIX 2: Validate character insertion with mask awareness
|
|
#[cfg(feature = "validation")]
|
|
{
|
|
let validation_result = self.ui_state.validation.validate_char_insertion(
|
|
field_index,
|
|
current_raw_text,
|
|
raw_cursor_pos,
|
|
ch,
|
|
);
|
|
|
|
// Reject input if validation failed with error
|
|
if !validation_result.is_acceptable() {
|
|
tracing::debug!(
|
|
"Character insertion rejected for field {}: {:?}",
|
|
field_index,
|
|
validation_result
|
|
);
|
|
return Ok(()); // Silently reject invalid input
|
|
}
|
|
}
|
|
|
|
// 🔥 CRITICAL FIX 3: Validate the insertion won't break display/limit coordination
|
|
let new_raw_text = {
|
|
let mut temp = current_raw_text.to_string();
|
|
temp.insert(raw_cursor_pos, ch);
|
|
temp
|
|
};
|
|
|
|
#[cfg(feature = "validation")]
|
|
{
|
|
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
|
|
// Check character limits on the new raw text
|
|
if let Some(limits) = &cfg.character_limits {
|
|
if let Some(result) = limits.validate_content(&new_raw_text) {
|
|
if !result.is_acceptable() {
|
|
tracing::debug!(
|
|
"Character insertion rejected: would exceed character limits"
|
|
);
|
|
return Ok(()); // Silently reject - would exceed limits
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check that mask can handle the new raw text length
|
|
if let Some(mask) = &cfg.display_mask {
|
|
let input_positions = (0..mask.pattern().len())
|
|
.filter(|&pos| mask.is_input_position(pos))
|
|
.count();
|
|
|
|
if new_raw_text.len() > input_positions {
|
|
tracing::debug!(
|
|
"Character insertion rejected: raw text length {} exceeds mask input positions {}",
|
|
new_raw_text.len(),
|
|
input_positions
|
|
);
|
|
return Ok(()); // Silently reject - mask can't handle this length
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ✅ ALL CHECKS PASSED: Safe to insert character
|
|
self.data_provider.set_field_value(field_index, new_raw_text);
|
|
|
|
// 🔥 CRITICAL FIX 4: Update cursor position correctly for mask context
|
|
#[cfg(feature = "validation")]
|
|
{
|
|
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
|
|
if let Some(mask) = &cfg.display_mask {
|
|
// Move to next input position, skipping separators
|
|
let new_raw_pos = raw_cursor_pos + 1;
|
|
let display_pos = mask.raw_pos_to_display_pos(new_raw_pos);
|
|
let next_input_pos = mask.next_input_position(display_pos);
|
|
let next_raw_pos = mask.display_pos_to_raw_pos(next_input_pos);
|
|
|
|
self.ui_state.cursor_pos = next_raw_pos;
|
|
self.ui_state.ideal_cursor_column = next_raw_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(())
|
|
}
|
|
|
|
/// Handle cursor movement left - skips mask separator positions
|
|
pub fn move_left(&mut self) {
|
|
if self.ui_state.cursor_pos == 0 {
|
|
return;
|
|
}
|
|
|
|
let field_index = self.ui_state.current_field;
|
|
let mut new_pos = self.ui_state.cursor_pos - 1;
|
|
|
|
// Skip mask separator positions if configured
|
|
#[cfg(feature = "validation")]
|
|
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
|
|
if let Some(mask) = &cfg.display_mask {
|
|
// Convert to display position, find previous input position, convert back
|
|
let display_pos = mask.raw_pos_to_display_pos(new_pos);
|
|
if let Some(prev_input_display_pos) = mask.prev_input_position(display_pos) {
|
|
new_pos = mask.display_pos_to_raw_pos(prev_input_display_pos);
|
|
}
|
|
}
|
|
}
|
|
|
|
self.ui_state.cursor_pos = new_pos;
|
|
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
|
}
|
|
|
|
/// Handle cursor movement right - skips mask separator positions
|
|
pub fn move_right(&mut self) {
|
|
let current_text = self.current_text();
|
|
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
|
let max_pos = if is_edit_mode {
|
|
current_text.len()
|
|
} else {
|
|
current_text.len().saturating_sub(1)
|
|
};
|
|
|
|
if self.ui_state.cursor_pos >= max_pos {
|
|
return;
|
|
}
|
|
|
|
let field_index = self.ui_state.current_field;
|
|
let mut new_pos = self.ui_state.cursor_pos + 1;
|
|
|
|
// Skip mask separator positions if configured
|
|
#[cfg(feature = "validation")]
|
|
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
|
|
if let Some(mask) = &cfg.display_mask {
|
|
// Convert to display position, find next input position, convert back
|
|
let display_pos = mask.raw_pos_to_display_pos(new_pos);
|
|
let next_input_display_pos = mask.next_input_position(display_pos);
|
|
new_pos = mask.display_pos_to_raw_pos(next_input_display_pos);
|
|
new_pos = new_pos.min(max_pos);
|
|
}
|
|
}
|
|
|
|
if new_pos <= max_pos {
|
|
self.ui_state.cursor_pos = new_pos;
|
|
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
|
}
|
|
}
|
|
|
|
/// Handle field navigation
|
|
pub fn move_to_next_field(&mut self) {
|
|
let field_count = self.data_provider.field_count();
|
|
let next_field = (self.ui_state.current_field + 1) % field_count;
|
|
|
|
// Validate current field content before moving if validation is enabled
|
|
#[cfg(feature = "validation")]
|
|
{
|
|
let current_text = self.current_text().to_string(); // Convert to String to avoid borrow conflicts
|
|
let _validation_result = self.ui_state.validation.validate_field_content(
|
|
self.ui_state.current_field,
|
|
¤t_text,
|
|
);
|
|
// Note: We don't prevent field switching on validation failure,
|
|
// just record the validation state
|
|
}
|
|
|
|
self.ui_state.move_to_field(next_field, field_count);
|
|
|
|
// Clamp cursor to new field
|
|
let current_text = self.current_text();
|
|
let max_pos = current_text.len();
|
|
self.ui_state.set_cursor(
|
|
self.ui_state.ideal_cursor_column,
|
|
max_pos,
|
|
self.ui_state.current_mode == AppMode::Edit
|
|
);
|
|
}
|
|
|
|
/// 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 append_pos = if current_text.is_empty() {
|
|
0
|
|
} else {
|
|
(self.ui_state.cursor_pos + 1).min(current_text.len())
|
|
};
|
|
|
|
// Set cursor position for append
|
|
self.ui_state.cursor_pos = append_pos;
|
|
self.ui_state.ideal_cursor_column = append_pos;
|
|
|
|
// Enter edit mode (which will update cursor style)
|
|
self.set_mode(AppMode::Edit);
|
|
}
|
|
|
|
// ===================================================================
|
|
// VALIDATION METHODS (only available with validation feature)
|
|
// ===================================================================
|
|
|
|
/// Enable or disable validation
|
|
#[cfg(feature = "validation")]
|
|
pub fn set_validation_enabled(&mut self, enabled: bool) {
|
|
self.ui_state.validation.set_enabled(enabled);
|
|
}
|
|
|
|
/// Check if validation is enabled
|
|
#[cfg(feature = "validation")]
|
|
pub fn is_validation_enabled(&self) -> bool {
|
|
self.ui_state.validation.is_enabled()
|
|
}
|
|
|
|
/// Set validation configuration for a specific field
|
|
#[cfg(feature = "validation")]
|
|
pub fn set_field_validation(&mut self, field_index: usize, config: crate::validation::ValidationConfig) {
|
|
self.ui_state.validation.set_field_config(field_index, config);
|
|
}
|
|
|
|
/// Remove validation configuration for a specific field
|
|
#[cfg(feature = "validation")]
|
|
pub fn remove_field_validation(&mut self, field_index: usize) {
|
|
self.ui_state.validation.remove_field_config(field_index);
|
|
}
|
|
|
|
/// Manually validate current field content
|
|
#[cfg(feature = "validation")]
|
|
pub fn validate_current_field(&mut self) -> crate::validation::ValidationResult {
|
|
let field_index = self.ui_state.current_field;
|
|
let current_text = self.current_text().to_string();
|
|
self.ui_state.validation.validate_field_content(field_index, ¤t_text)
|
|
}
|
|
|
|
/// Manually validate specific field content
|
|
#[cfg(feature = "validation")]
|
|
pub fn validate_field(&mut self, field_index: usize) -> Option<crate::validation::ValidationResult> {
|
|
if field_index < self.data_provider.field_count() {
|
|
let text = self.data_provider.field_value(field_index).to_string();
|
|
Some(self.ui_state.validation.validate_field_content(field_index, &text))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Clear validation results for all fields
|
|
#[cfg(feature = "validation")]
|
|
pub fn clear_validation_results(&mut self) {
|
|
self.ui_state.validation.clear_all_results();
|
|
}
|
|
|
|
/// Get validation summary for all fields
|
|
#[cfg(feature = "validation")]
|
|
pub fn validation_summary(&self) -> crate::validation::ValidationSummary {
|
|
self.ui_state.validation.summary()
|
|
}
|
|
|
|
/// Check if field switching is allowed from current field
|
|
#[cfg(feature = "validation")]
|
|
pub fn can_switch_fields(&self) -> bool {
|
|
let current_text = self.current_text();
|
|
self.ui_state.validation.allows_field_switch(self.ui_state.current_field, current_text)
|
|
}
|
|
|
|
/// Get reason why field switching is blocked (if any)
|
|
#[cfg(feature = "validation")]
|
|
pub fn field_switch_block_reason(&self) -> Option<String> {
|
|
let current_text = self.current_text();
|
|
self.ui_state.validation.field_switch_block_reason(self.ui_state.current_field, current_text)
|
|
}
|
|
|
|
// ===================================================================
|
|
// ASYNC OPERATIONS: Only suggestions need async
|
|
// ===================================================================
|
|
|
|
/// Trigger suggestions (async because it fetches data)
|
|
pub async fn trigger_suggestions<A>(&mut self, provider: &mut A) -> Result<()>
|
|
where
|
|
A: SuggestionsProvider,
|
|
{
|
|
let field_index = self.ui_state.current_field;
|
|
|
|
if !self.data_provider.supports_suggestions(field_index) {
|
|
return Ok(());
|
|
}
|
|
|
|
// Activate suggestions UI
|
|
self.ui_state.activate_suggestions(field_index);
|
|
|
|
// Fetch suggestions from user (no conversion needed!)
|
|
let query = self.current_text();
|
|
self.suggestions = provider.fetch_suggestions(field_index, query).await?;
|
|
|
|
// Update UI state
|
|
self.ui_state.suggestions.is_loading = false;
|
|
if !self.suggestions.is_empty() {
|
|
self.ui_state.suggestions.selected_index = Some(0);
|
|
// Compute initial inline completion from first suggestion
|
|
self.update_inline_completion();
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// 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<String> {
|
|
if let Some(selected_index) = self.ui_state.suggestions.selected_index {
|
|
if let Some(suggestion) = self.suggestions.get(selected_index).cloned() {
|
|
let field_index = self.ui_state.current_field;
|
|
|
|
// 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.ui_state.deactivate_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 previous field (vim k / up arrow)
|
|
pub fn move_up(&mut self) -> Result<()> {
|
|
let field_count = self.data_provider.field_count();
|
|
if field_count == 0 {
|
|
return Ok(());
|
|
}
|
|
|
|
// Skip computed fields during navigation when feature enabled
|
|
#[cfg(feature = "computed")]
|
|
{
|
|
if let Some(computed_state) = &self.ui_state.computed {
|
|
// Find previous non-computed field
|
|
let mut candidate = self.ui_state.current_field;
|
|
for _ in 0..field_count {
|
|
candidate = candidate.saturating_sub(1);
|
|
if !computed_state.is_computed_field(candidate) {
|
|
// Validate and move as usual
|
|
#[cfg(feature = "validation")]
|
|
{
|
|
let current_text = self.current_text();
|
|
if !self.ui_state.validation.allows_field_switch(self.ui_state.current_field, current_text) {
|
|
if let Some(reason) = self.ui_state.validation.field_switch_block_reason(self.ui_state.current_field, current_text) {
|
|
tracing::debug!("Field switch blocked: {}", reason);
|
|
return Err(anyhow::anyhow!("Cannot switch fields: {}", reason));
|
|
}
|
|
}
|
|
}
|
|
#[cfg(feature = "validation")]
|
|
{
|
|
let current_text = self.current_text().to_string();
|
|
let _validation_result = self.ui_state.validation.validate_field_content(
|
|
self.ui_state.current_field,
|
|
¤t_text,
|
|
);
|
|
}
|
|
self.ui_state.move_to_field(candidate, field_count);
|
|
self.clamp_cursor_to_current_field();
|
|
return Ok(());
|
|
}
|
|
if candidate == 0 {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if field switching is allowed (minimum character enforcement)
|
|
#[cfg(feature = "validation")]
|
|
{
|
|
let current_text = self.current_text();
|
|
if !self.ui_state.validation.allows_field_switch(self.ui_state.current_field, current_text) {
|
|
if let Some(reason) = self.ui_state.validation.field_switch_block_reason(self.ui_state.current_field, current_text) {
|
|
tracing::debug!("Field switch blocked: {}", reason);
|
|
return Err(anyhow::anyhow!("Cannot switch fields: {}", reason));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate current field before moving
|
|
#[cfg(feature = "validation")]
|
|
{
|
|
let current_text = self.current_text().to_string(); // Convert to String to avoid borrow conflicts
|
|
let _validation_result = self.ui_state.validation.validate_field_content(
|
|
self.ui_state.current_field,
|
|
¤t_text,
|
|
);
|
|
}
|
|
|
|
let current_field = self.ui_state.current_field;
|
|
let new_field = current_field.saturating_sub(1);
|
|
|
|
self.ui_state.move_to_field(new_field, field_count);
|
|
self.clamp_cursor_to_current_field();
|
|
Ok(())
|
|
}
|
|
|
|
/// Move to next field (vim j / down arrow)
|
|
pub fn move_down(&mut self) -> Result<()> {
|
|
let field_count = self.data_provider.field_count();
|
|
if field_count == 0 {
|
|
return Ok(());
|
|
}
|
|
|
|
// Skip computed fields during navigation when feature enabled
|
|
#[cfg(feature = "computed")]
|
|
{
|
|
if let Some(computed_state) = &self.ui_state.computed {
|
|
// Find next non-computed field
|
|
let mut candidate = self.ui_state.current_field;
|
|
for _ in 0..field_count {
|
|
candidate = (candidate + 1).min(field_count - 1);
|
|
if !computed_state.is_computed_field(candidate) {
|
|
// Validate and move as usual
|
|
#[cfg(feature = "validation")]
|
|
{
|
|
let current_text = self.current_text();
|
|
if !self.ui_state.validation.allows_field_switch(self.ui_state.current_field, current_text) {
|
|
if let Some(reason) = self.ui_state.validation.field_switch_block_reason(self.ui_state.current_field, current_text) {
|
|
tracing::debug!("Field switch blocked: {}", reason);
|
|
return Err(anyhow::anyhow!("Cannot switch fields: {}", reason));
|
|
}
|
|
}
|
|
}
|
|
#[cfg(feature = "validation")]
|
|
{
|
|
let current_text = self.current_text().to_string();
|
|
let _validation_result = self.ui_state.validation.validate_field_content(
|
|
self.ui_state.current_field,
|
|
¤t_text,
|
|
);
|
|
}
|
|
self.ui_state.move_to_field(candidate, field_count);
|
|
self.clamp_cursor_to_current_field();
|
|
return Ok(());
|
|
}
|
|
if candidate == field_count - 1 {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if field switching is allowed (minimum character enforcement)
|
|
#[cfg(feature = "validation")]
|
|
{
|
|
let current_text = self.current_text();
|
|
if !self.ui_state.validation.allows_field_switch(self.ui_state.current_field, current_text) {
|
|
if let Some(reason) = self.ui_state.validation.field_switch_block_reason(self.ui_state.current_field, current_text) {
|
|
tracing::debug!("Field switch blocked: {}", reason);
|
|
return Err(anyhow::anyhow!("Cannot switch fields: {}", reason));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate current field before moving
|
|
#[cfg(feature = "validation")]
|
|
{
|
|
let current_text = self.current_text().to_string(); // Convert to String to avoid borrow conflicts
|
|
let _validation_result = self.ui_state.validation.validate_field_content(
|
|
self.ui_state.current_field,
|
|
¤t_text,
|
|
);
|
|
}
|
|
|
|
let current_field = self.ui_state.current_field;
|
|
let new_field = (current_field + 1).min(field_count - 1);
|
|
|
|
self.ui_state.move_to_field(new_field, field_count);
|
|
self.clamp_cursor_to_current_field();
|
|
Ok(())
|
|
}
|
|
|
|
/// Move to first field (vim gg)
|
|
pub fn move_first_line(&mut self) {
|
|
let field_count = self.data_provider.field_count();
|
|
if field_count == 0 {
|
|
return;
|
|
}
|
|
|
|
self.ui_state.move_to_field(0, field_count);
|
|
self.clamp_cursor_to_current_field();
|
|
}
|
|
|
|
/// Move to last field (vim G)
|
|
pub fn move_last_line(&mut self) {
|
|
let field_count = self.data_provider.field_count();
|
|
if field_count == 0 {
|
|
return;
|
|
}
|
|
|
|
let last_field = field_count - 1;
|
|
self.ui_state.move_to_field(last_field, field_count);
|
|
self.clamp_cursor_to_current_field();
|
|
}
|
|
|
|
/// 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<C>(&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<C>(&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<String> = (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<C>(&mut self, provider: &mut C)
|
|
where
|
|
C: crate::computed::ComputedProvider,
|
|
{
|
|
if let Some(computed_state) = &self.ui_state.computed {
|
|
let computed_fields: Vec<usize> = computed_state.computed_fields().collect();
|
|
self.recompute_fields(provider, &computed_fields);
|
|
}
|
|
}
|
|
|
|
/// Trigger recomputation when field changes (call this after set_field_value)
|
|
#[cfg(feature = "computed")]
|
|
pub fn on_field_changed<C>(&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)
|
|
pub fn move_word_next(&mut self) {
|
|
use crate::canvas::actions::movement::word::find_next_word_start;
|
|
let current_text = self.current_text();
|
|
|
|
if current_text.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let new_pos = find_next_word_start(current_text, self.ui_state.cursor_pos);
|
|
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
|
|
|
// Clamp to valid bounds for current mode
|
|
let final_pos = if is_edit_mode {
|
|
new_pos.min(current_text.len())
|
|
} else {
|
|
new_pos.min(current_text.len().saturating_sub(1))
|
|
};
|
|
|
|
self.ui_state.cursor_pos = final_pos;
|
|
self.ui_state.ideal_cursor_column = final_pos;
|
|
}
|
|
|
|
/// Move to start of previous word (vim b)
|
|
pub fn move_word_prev(&mut self) {
|
|
use crate::canvas::actions::movement::word::find_prev_word_start;
|
|
let current_text = self.current_text();
|
|
|
|
if current_text.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let new_pos = find_prev_word_start(current_text, self.ui_state.cursor_pos);
|
|
self.ui_state.cursor_pos = new_pos;
|
|
self.ui_state.ideal_cursor_column = new_pos;
|
|
}
|
|
|
|
/// Move to end of current/next word (vim e)
|
|
pub fn move_word_end(&mut self) {
|
|
use crate::canvas::actions::movement::word::find_word_end;
|
|
let current_text = self.current_text();
|
|
|
|
if current_text.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let current_pos = self.ui_state.cursor_pos;
|
|
let new_pos = find_word_end(current_text, current_pos);
|
|
|
|
// If we didn't move, try next word
|
|
let final_pos = if new_pos == current_pos && current_pos + 1 < current_text.len() {
|
|
find_word_end(current_text, current_pos + 1)
|
|
} else {
|
|
new_pos
|
|
};
|
|
|
|
// Clamp for read-only mode
|
|
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
|
let clamped_pos = if is_edit_mode {
|
|
final_pos.min(current_text.len())
|
|
} else {
|
|
final_pos.min(current_text.len().saturating_sub(1))
|
|
};
|
|
|
|
self.ui_state.cursor_pos = clamped_pos;
|
|
self.ui_state.ideal_cursor_column = clamped_pos;
|
|
}
|
|
|
|
/// Move to end of previous word (vim ge)
|
|
pub fn move_word_end_prev(&mut self) {
|
|
use crate::canvas::actions::movement::word::find_prev_word_end;
|
|
let current_text = self.current_text();
|
|
|
|
if current_text.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let new_pos = find_prev_word_end(current_text, self.ui_state.cursor_pos);
|
|
self.ui_state.cursor_pos = new_pos;
|
|
self.ui_state.ideal_cursor_column = new_pos;
|
|
}
|
|
|
|
/// 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(()); // Silently ignore in non-edit modes
|
|
}
|
|
|
|
if self.ui_state.cursor_pos == 0 {
|
|
return Ok(()); // Nothing to delete
|
|
}
|
|
|
|
let field_index = self.ui_state.current_field;
|
|
let mut current_text = self.data_provider.field_value(field_index).to_string();
|
|
|
|
if self.ui_state.cursor_pos <= current_text.len() {
|
|
current_text.remove(self.ui_state.cursor_pos - 1);
|
|
self.data_provider.set_field_value(field_index, current_text.clone());
|
|
self.ui_state.cursor_pos -= 1;
|
|
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
|
|
|
// Validate the new content if validation is enabled
|
|
#[cfg(feature = "validation")]
|
|
{
|
|
let _validation_result = self.ui_state.validation.validate_field_content(
|
|
field_index,
|
|
¤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(()); // Silently ignore in non-edit modes
|
|
}
|
|
|
|
let field_index = self.ui_state.current_field;
|
|
let mut current_text = self.data_provider.field_value(field_index).to_string();
|
|
|
|
if self.ui_state.cursor_pos < current_text.len() {
|
|
current_text.remove(self.ui_state.cursor_pos);
|
|
self.data_provider.set_field_value(field_index, current_text.clone());
|
|
|
|
// Validate the new content if validation is enabled
|
|
#[cfg(feature = "validation")]
|
|
{
|
|
let _validation_result = self.ui_state.validation.validate_field_content(
|
|
field_index,
|
|
¤t_text,
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// 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) {
|
|
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.len().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;
|
|
}
|
|
}
|
|
|
|
self.set_mode(AppMode::ReadOnly);
|
|
// Deactivate suggestions when exiting edit mode
|
|
self.ui_state.deactivate_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
|
|
// ===================================================================
|
|
|
|
/// Clamp cursor position to valid bounds for current field and mode
|
|
fn clamp_cursor_to_current_field(&mut self) {
|
|
let current_text = self.current_text();
|
|
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
|
|
|
use crate::canvas::actions::movement::line::safe_cursor_position;
|
|
let safe_pos = safe_cursor_position(
|
|
current_text,
|
|
self.ui_state.ideal_cursor_column,
|
|
is_edit_mode
|
|
);
|
|
|
|
self.ui_state.cursor_pos = safe_pos;
|
|
}
|
|
|
|
|
|
/// Set the value of the current field
|
|
pub fn set_current_field_value(&mut self, value: String) {
|
|
let field_index = self.ui_state.current_field;
|
|
self.data_provider.set_field_value(field_index, value.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 max_pos = if is_edit_mode {
|
|
current_text.len() // Edit mode: can go past end
|
|
} else {
|
|
current_text.len().saturating_sub(1).max(0) // Read-only: stay within text
|
|
};
|
|
|
|
let clamped_pos = position.min(max_pos);
|
|
|
|
// Update cursor position directly
|
|
self.ui_state.cursor_pos = clamped_pos;
|
|
self.ui_state.ideal_cursor_column = clamped_pos;
|
|
}
|
|
|
|
/// 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();
|
|
let raw_pos = match self.ui_state.current_mode {
|
|
AppMode::Edit => self.ui_state.cursor_pos.min(current_text.len()),
|
|
_ => {
|
|
if current_text.is_empty() {
|
|
0
|
|
} else {
|
|
self.ui_state.cursor_pos.min(current_text.len().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) {
|
|
// Only apply custom formatter cursor mapping when NOT editing
|
|
if !matches!(self.ui_state.current_mode, AppMode::Edit) {
|
|
if let Some((formatted, mapper, _warning)) = cfg.run_custom_formatter(current_text) {
|
|
return mapper.raw_to_formatted(current_text, &formatted, raw_pos);
|
|
}
|
|
}
|
|
// Fallback to display mask
|
|
if let Some(mask) = &cfg.display_mask {
|
|
return mask.raw_pos_to_display_pos(self.ui_state.cursor_pos);
|
|
}
|
|
}
|
|
}
|
|
|
|
self.ui_state.cursor_pos
|
|
}
|
|
|
|
/// Cleanup cursor style (call this when shutting down)
|
|
pub fn cleanup_cursor(&self) -> std::io::Result<()> {
|
|
#[cfg(feature = "cursor-style")]
|
|
{
|
|
crate::canvas::CursorManager::reset()
|
|
}
|
|
#[cfg(not(feature = "cursor-style"))]
|
|
{
|
|
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) {
|
|
self.move_left();
|
|
// Selection anchor stays in place, cursor position updates automatically
|
|
}
|
|
|
|
pub fn move_right_with_selection(&mut self) {
|
|
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_prev_with_selection(&mut self) {
|
|
self.move_word_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<D: DataProvider> Drop for FormEditor<D> {
|
|
fn drop(&mut self) {
|
|
// Reset cursor to default when FormEditor is dropped
|
|
let _ = self.cleanup_cursor();
|
|
}
|
|
}
|