diff --git a/canvas/Cargo.toml b/canvas/Cargo.toml index e9ee400..0b78e5e 100644 --- a/canvas/Cargo.toml +++ b/canvas/Cargo.toml @@ -42,9 +42,9 @@ required-features = ["suggestions", "gui", "cursor-style"] path = "examples/suggestions.rs" [[example]] -name = "canvas_gui_demo" +name = "canvas_cursor_auto" required-features = ["gui", "cursor-style"] -path = "examples/canvas_gui_demo.rs" +path = "examples/canvas_cursor_auto.rs" [[example]] name = "validation_1" diff --git a/canvas/src/canvas/gui.rs b/canvas/src/canvas/gui.rs index 9a02563..777ff61 100644 --- a/canvas/src/canvas/gui.rs +++ b/canvas/src/canvas/gui.rs @@ -15,6 +15,7 @@ use crate::canvas::theme::{CanvasTheme, DefaultCanvasTheme}; use crate::canvas::modes::HighlightState; use crate::data_provider::DataProvider; use crate::editor::FormEditor; +use unicode_width::UnicodeWidthChar; #[cfg(feature = "gui")] use std::cmp::{max, min}; @@ -486,16 +487,24 @@ fn set_cursor_position( field_rect: Rect, text: &str, current_cursor_pos: usize, - has_display_override: bool, + _has_display_override: bool, ) { - // BUG FIX: Use the correct display cursor position, not end of text - let cursor_x = field_rect.x + current_cursor_pos as u16; + // Sum display widths of the first current_cursor_pos characters + let mut cols: u16 = 0; + for (i, ch) in text.chars().enumerate() { + if i >= current_cursor_pos { + break; + } + cols = cols.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16); + } + + let cursor_x = field_rect.x.saturating_add(cols); let cursor_y = field_rect.y; - - // SAFETY: Ensure cursor doesn't go beyond field bounds + + // Clamp to field bounds let max_cursor_x = field_rect.x + field_rect.width.saturating_sub(1); let safe_cursor_x = cursor_x.min(max_cursor_x); - + f.set_cursor_position((safe_cursor_x, cursor_y)); } diff --git a/canvas/src/editor.rs b/canvas/src/editor.rs index 396a587..43bebe6 100644 --- a/canvas/src/editor.rs +++ b/canvas/src/editor.rs @@ -24,6 +24,19 @@ pub struct FormEditor { } 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()) + } + + /// Convert a byte index to a char index in a string + #[allow(dead_code)] + fn byte_to_char_index(s: &str, byte_idx: usize) -> usize { + s[..byte_idx].chars().count() + } pub fn new(data_provider: D) -> Self { let mut editor = Self { ui_state: EditorState::new(), @@ -341,7 +354,8 @@ impl FormEditor { // 🔥 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); + let byte_pos = Self::char_to_byte_index(current_raw_text, raw_cursor_pos); + temp.insert(byte_pos, ch); temp }; @@ -467,22 +481,38 @@ impl FormEditor { } /// Handle field navigation - pub fn move_to_next_field(&mut self) { + pub fn move_to_next_field(&mut self) -> Result<()> { let field_count = self.data_provider.field_count(); - let next_field = (self.ui_state.current_field + 1) % field_count; + if field_count == 0 { + return Ok(()); + } - // Validate current field content before moving if validation is enabled + // Check if field switching is allowed (minimum character enforcement) #[cfg(feature = "validation")] { - let current_text = self.current_text().to_string(); // Convert to String to avoid borrow conflicts + 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(); 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 } + let next_field = (self.ui_state.current_field + 1) % field_count; self.ui_state.move_to_field(next_field, field_count); // Clamp cursor to new field @@ -493,6 +523,8 @@ impl FormEditor { max_pos, self.ui_state.current_mode == AppMode::Edit ); + + Ok(()) } /// Change mode (for vim compatibility) @@ -1095,52 +1127,84 @@ impl FormEditor { /// 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 + return Ok(()); } - if self.ui_state.cursor_pos == 0 { - return Ok(()); // Nothing to delete + 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.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; + let new_cursor = self.ui_state.cursor_pos.saturating_sub(1); - // 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, - ); + 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()); + + // Always run reposition logic + let mut 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(()); // Silently ignore in non-edit modes + 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.len() { - current_text.remove(self.ui_state.cursor_pos); + 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()); - // Validate the new content if validation is enabled + let mut target_cursor = self.ui_state.cursor_pos; #[cfg(feature = "validation")] { - let _validation_result = self.ui_state.validation.validate_field_content( + 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, ); @@ -1286,13 +1350,18 @@ impl FormEditor { /// 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(current_text.len()), + AppMode::Edit => self.ui_state.cursor_pos.min(char_count), _ => { - if current_text.is_empty() { + if char_count == 0 { 0 } else { - self.ui_state.cursor_pos.min(current_text.len().saturating_sub(1)) + self.ui_state.cursor_pos.min(char_count.saturating_sub(1)) } } }; @@ -1301,20 +1370,20 @@ impl FormEditor { { 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 + // Apply custom formatter mapping if not editing if !matches!(self.ui_state.current_mode, AppMode::Edit) { - if let Some((formatted, mapper, _warning)) = cfg.run_custom_formatter(current_text) { + if let Some((formatted, mapper, _)) = cfg.run_custom_formatter(current_text) { return mapper.raw_to_formatted(current_text, &formatted, raw_pos); } } - // Fallback to display mask + // Apply mask mapping using clamped raw_pos if let Some(mask) = &cfg.display_mask { - return mask.raw_pos_to_display_pos(self.ui_state.cursor_pos); + return mask.raw_pos_to_display_pos(raw_pos); } } } - self.ui_state.cursor_pos + raw_pos } /// Cleanup cursor style (call this when shutting down)