diff --git a/canvas/src/canvas/actions/movement/mod.rs b/canvas/src/canvas/actions/movement/mod.rs index c6f5600..0c0810e 100644 --- a/canvas/src/canvas/actions/movement/mod.rs +++ b/canvas/src/canvas/actions/movement/mod.rs @@ -5,6 +5,12 @@ pub mod line; pub mod char; // Re-export commonly used functions -pub use word::{find_next_word_start, find_word_end, find_prev_word_start, find_prev_word_end}; +pub use word::{ + find_next_word_start, find_word_end, find_prev_word_start, find_prev_word_end, + find_next_WORD_start, find_prev_WORD_start, find_WORD_end, find_prev_WORD_end, + // Add these new exports: + find_last_word_start_in_field, find_last_word_end_in_field, + find_last_WORD_start_in_field, find_last_WORD_end_in_field, +}; pub use line::{line_start_position, line_end_position, safe_cursor_position}; pub use char::{move_left, move_right, is_valid_cursor_position, clamp_cursor_position}; diff --git a/canvas/src/canvas/actions/movement/word.rs b/canvas/src/canvas/actions/movement/word.rs index 6ec120c..a73c3a8 100644 --- a/canvas/src/canvas/actions/movement/word.rs +++ b/canvas/src/canvas/actions/movement/word.rs @@ -1,6 +1,7 @@ // src/canvas/actions/movement/word.rs +// Replace the entire file with this corrected version: -#[derive(PartialEq)] +#[derive(PartialEq, Copy, Clone)] enum CharType { Whitespace, Alphanumeric, @@ -55,7 +56,7 @@ pub fn find_word_end(text: &str, current_pos: usize) -> usize { let mut pos = current_pos.min(len - 1); let current_type = get_char_type(chars[pos]); - + // If we're not on whitespace, move to end of current word if current_type != CharType::Whitespace { while pos < len && get_char_type(chars[pos]) == current_type { @@ -107,46 +108,59 @@ pub fn find_prev_word_start(text: &str, current_pos: usize) -> usize { } } -/// Find the end of the previous word +/// Find the end of the previous word (CORRECTED VERSION for vim's ge command) pub fn find_prev_word_end(text: &str, current_pos: usize) -> usize { let chars: Vec = text.chars().collect(); if chars.is_empty() || current_pos == 0 { return 0; } - let mut pos = current_pos.saturating_sub(1); + // Find all word end positions using boundary detection + let mut word_ends = Vec::new(); + let mut in_word = false; + let mut current_word_type: Option = None; - // Skip whitespace backwards - while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace { - pos -= 1; + for (i, &ch) in chars.iter().enumerate() { + let char_type = get_char_type(ch); + + match char_type { + CharType::Whitespace => { + if in_word { + // End of a word + word_ends.push(i - 1); + in_word = false; + current_word_type = None; + } + } + _ => { + if !in_word || current_word_type != Some(char_type) { + // Start of a new word (or word type change) + if in_word { + // End the previous word first + word_ends.push(i - 1); + } + in_word = true; + current_word_type = Some(char_type); + } + } + } } - if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace { - return 0; - } - if pos == 0 && get_char_type(chars[0]) != CharType::Whitespace { - return 0; + // Add the final word end if text doesn't end with whitespace + if in_word && !chars.is_empty() { + word_ends.push(chars.len() - 1); } - let word_type = get_char_type(chars[pos]); - while pos > 0 && get_char_type(chars[pos - 1]) == word_type { - pos -= 1; + // Find the largest word end position that's before current_pos + for &end_pos in word_ends.iter().rev() { + if end_pos < current_pos { + return end_pos; + } } - // Skip whitespace before this word - while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace { - pos -= 1; - } - - if pos > 0 { - pos - 1 - } else { - 0 - } + 0 } -// Add these functions to your word movement module - /// Find the start of the next WORD (whitespace-separated) pub fn find_next_WORD_start(text: &str, current_pos: usize) -> usize { let chars: Vec = text.chars().collect(); @@ -251,3 +265,139 @@ pub fn find_prev_WORD_end(text: &str, current_pos: usize) -> usize { // Return position of last character in WORD pos.saturating_sub(1) } + +// ============================================================================ +// FIELD BOUNDARY HELPER FUNCTIONS (for cross-field movement) +// ============================================================================ + +/// Find the start of the last word in a field (for cross-field 'b' movement) +pub fn find_last_word_start_in_field(text: &str) -> usize { + if text.is_empty() { + return 0; + } + + let chars: Vec = text.chars().collect(); + if chars.is_empty() { + return 0; + } + + let mut pos = chars.len().saturating_sub(1); + + // Skip trailing whitespace + while pos > 0 && chars[pos].is_whitespace() { + pos -= 1; + } + + // If the whole field is whitespace, return 0 + if pos == 0 && chars[0].is_whitespace() { + return 0; + } + + // Now we're on a non-whitespace character + // Find the start of this word by going backwards while chars are the same type + let char_type = if chars[pos].is_alphanumeric() { "alnum" } else { "punct" }; + + while pos > 0 { + let prev_char = chars[pos - 1]; + let prev_type = if prev_char.is_alphanumeric() { + "alnum" + } else if prev_char.is_whitespace() { + "space" + } else { + "punct" + }; + + // Stop if we hit whitespace or different word type + if prev_type == "space" || prev_type != char_type { + break; + } + pos -= 1; + } + + pos +} + +/// Find the end of the last word in a field (for cross-field 'ge' movement) +pub fn find_last_word_end_in_field(text: &str) -> usize { + let chars: Vec = text.chars().collect(); + if chars.is_empty() { + return 0; + } + + // Start from the end and find the last non-whitespace character + let mut pos = chars.len() - 1; + + // Skip trailing whitespace + while pos > 0 && chars[pos].is_whitespace() { + pos -= 1; + } + + // If the whole field is whitespace, return 0 + if chars[pos].is_whitespace() { + return 0; + } + + // We're now at the end of the last word + pos +} + +/// Find the start of the last WORD in a field (for cross-field 'B' movement) +pub fn find_last_WORD_start_in_field(text: &str) -> usize { + if text.is_empty() { + return 0; + } + + let chars: Vec = text.chars().collect(); + if chars.is_empty() { + return 0; + } + + let mut pos = chars.len().saturating_sub(1); + + // Skip trailing whitespace + while pos > 0 && chars[pos].is_whitespace() { + pos -= 1; + } + + // If the whole field is whitespace, return 0 + if pos == 0 && chars[0].is_whitespace() { + return 0; + } + + // Now we're on a non-whitespace character + // Find the start of this WORD by going backwards while chars are non-whitespace + while pos > 0 { + let prev_char = chars[pos - 1]; + + // Stop if we hit whitespace (WORD boundary) + if prev_char.is_whitespace() { + break; + } + pos -= 1; + } + + pos +} + +/// Find the end of the last WORD in a field (for cross-field 'gE' movement) +pub fn find_last_WORD_end_in_field(text: &str) -> usize { + let chars: Vec = text.chars().collect(); + if chars.is_empty() { + return 0; + } + + let mut pos = chars.len().saturating_sub(1); + + // Skip trailing whitespace + while pos > 0 && chars[pos].is_whitespace() { + pos -= 1; + } + + // If the whole field is whitespace, return 0 + if pos == 0 && chars[0].is_whitespace() { + return 0; + } + + // We're now at the end of the last WORD + pos +} diff --git a/canvas/src/editor.rs b/canvas/src/editor.rs index d40df38..31bb0a9 100644 --- a/canvas/src/editor.rs +++ b/canvas/src/editor.rs @@ -9,6 +9,10 @@ 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 { @@ -31,116 +35,6 @@ pub struct FormEditor { >, } -/// Helper: Find start of last word in a field (for cross-field b movement) -fn find_last_word_start_in_field(text: &str) -> usize { - if text.is_empty() { - return 0; - } - - let chars: Vec = text.chars().collect(); - if chars.is_empty() { - return 0; - } - - let mut pos = chars.len().saturating_sub(1); - - // Skip trailing whitespace - while pos > 0 && chars[pos].is_whitespace() { - pos -= 1; - } - - // If the whole field is whitespace, return 0 - if pos == 0 && chars[0].is_whitespace() { - return 0; - } - - // Now we're on a non-whitespace character - // Find the start of this word by going backwards while chars are the same type - let char_type = if chars[pos].is_alphanumeric() { "alnum" } else { "punct" }; - - while pos > 0 { - let prev_char = chars[pos - 1]; - let prev_type = if prev_char.is_alphanumeric() { - "alnum" - } else if prev_char.is_whitespace() { - "space" - } else { - "punct" - }; - - // Stop if we hit whitespace or different word type - if prev_type == "space" || prev_type != char_type { - break; - } - pos -= 1; - } - - pos -} - - -// Helper function to find the end of the last WORD in a field -fn find_last_WORD_end_in_field(text: &str) -> usize { - let chars: Vec = text.chars().collect(); - if chars.is_empty() { - return 0; - } - - let mut pos = chars.len().saturating_sub(1); - - // Skip trailing whitespace - while pos > 0 && chars[pos].is_whitespace() { - pos -= 1; - } - - // If the whole field is whitespace, return 0 - if pos == 0 && chars[0].is_whitespace() { - return 0; - } - - // We're now at the end of the last WORD - pos -} - - -/// Helper: Find start of last WORD in a field (for cross-field B movement) -fn find_last_WORD_start_in_field(text: &str) -> usize { - if text.is_empty() { - return 0; - } - - let chars: Vec = text.chars().collect(); - if chars.is_empty() { - return 0; - } - - let mut pos = chars.len().saturating_sub(1); - - // Skip trailing whitespace - while pos > 0 && chars[pos].is_whitespace() { - pos -= 1; - } - - // If the whole field is whitespace, return 0 - if pos == 0 && chars[0].is_whitespace() { - return 0; - } - - // Now we're on a non-whitespace character - // Find the start of this WORD by going backwards while chars are non-whitespace - while pos > 0 { - let prev_char = chars[pos - 1]; - - // Stop if we hit whitespace (WORD boundary) - if prev_char.is_whitespace() { - break; - } - pos -= 1; - } - - pos -} - impl FormEditor { /// Convert a char index to a byte index in a string fn char_to_byte_index(s: &str, char_idx: usize) -> usize { @@ -1496,22 +1390,21 @@ impl FormEditor { /// 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; + 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 + // 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 { - // Position at end and find last word end let new_text = self.current_text(); if !new_text.is_empty() { - let char_len = new_text.chars().count(); - self.ui_state.cursor_pos = char_len; - self.ui_state.ideal_cursor_column = char_len; - self.move_word_end_prev(); + // 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; } } } @@ -1520,7 +1413,7 @@ impl FormEditor { let current_pos = self.ui_state.cursor_pos; - // Special case: if we're at position 0, jump to previous field + // 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() { @@ -1528,31 +1421,30 @@ impl FormEditor { if self.ui_state.current_field != current_field { let new_text = self.current_text(); if !new_text.is_empty() { - let char_len = new_text.chars().count(); - self.ui_state.cursor_pos = char_len; - self.ui_state.ideal_cursor_column = char_len; - self.move_word_end_prev(); + 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); - // Check if we didn't move significantly (near start of field) - if new_pos == current_pos || new_pos <= 1 { - // Try to jump to previous field + // 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 char_len = new_text.chars().count(); - self.ui_state.cursor_pos = char_len; - self.ui_state.ideal_cursor_column = char_len; - self.move_word_end_prev(); + 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; } } } @@ -1811,8 +1703,6 @@ impl FormEditor { let new_text = self.current_text(); if !new_text.is_empty() { // Find end of last WORD in the field - let char_len = new_text.chars().count(); - // Start from end and find the last WORD end 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; @@ -1843,9 +1733,9 @@ impl FormEditor { let new_pos = find_prev_WORD_end(current_text, current_pos); - // Check if we didn't move significantly (near start of field) - if new_pos == current_pos || new_pos <= 1 { - // Try to jump to previous field (but don't recurse) + // 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