diff --git a/client/src/modes/canvas/read_only.rs b/client/src/modes/canvas/read_only.rs index 4ce9286..9c8d900 100644 --- a/client/src/modes/canvas/read_only.rs +++ b/client/src/modes/canvas/read_only.rs @@ -3,7 +3,7 @@ use crate::config::binds::config::Config; use crate::config::binds::key_sequences::KeySequenceTracker; use crate::services::grpc_client::GrpcClient; -use crate::state::canvas_state::CanvasState; // Import the trait +use crate::state::canvas_state::CanvasState; use crate::state::pages::auth::AuthState; use crate::state::pages::form::FormState; use crossterm::event::KeyEvent; @@ -19,26 +19,23 @@ pub async fn handle_read_only_event( app_state: &crate::state::state::AppState, key: KeyEvent, config: &Config, - form_state: &mut FormState, // Keep specific types here for routing - auth_state: &mut AuthState, // Keep specific types here for routing + form_state: &mut FormState, + auth_state: &mut AuthState, key_sequence_tracker: &mut KeySequenceTracker, - current_position: &mut u64, // Needed for form actions - total_count: u64, // Needed for form actions - grpc_client: &mut GrpcClient, // Needed for form actions + current_position: &mut u64, + total_count: u64, + grpc_client: &mut GrpcClient, command_message: &mut String, edit_mode_cooldown: &mut bool, ideal_cursor_column: &mut usize, ) -> Result<(bool, String), Box> { - // Check for entering Edit mode from Read-Only mode if config.is_enter_edit_mode_before(key.code, key.modifiers) { *edit_mode_cooldown = true; *command_message = "Entering Edit mode".to_string(); - // The actual mode switch happens in event.rs based on this return return Ok((false, command_message.clone())); } if config.is_enter_edit_mode_after(key.code, key.modifiers) { - // Use the correct state based on context let (current_input, current_pos) = if app_state.ui.show_login { ( auth_state.get_current_input(), @@ -52,7 +49,6 @@ pub async fn handle_read_only_event( }; if !current_input.is_empty() && current_pos < current_input.len() { - // Update the correct state if app_state.ui.show_login { auth_state.set_current_cursor_pos(current_pos + 1); *ideal_cursor_column = auth_state.current_cursor_pos(); @@ -63,23 +59,31 @@ pub async fn handle_read_only_event( } *edit_mode_cooldown = true; *command_message = "Entering Edit mode (after cursor)".to_string(); - // The actual mode switch happens in event.rs based on this return return Ok((false, command_message.clone())); } - // Handle Read-Only mode keybindings + const CONTEXT_ACTIONS_FORM: &[&str] = &[ + "previous_entry", + "next_entry", + "move_up", + "move_down", + "move_first_line", + "move_last_line", + ]; + const CONTEXT_ACTIONS_LOGIN: &[&str] = &[ + "move_up", + "move_down", + "move_first_line", + "move_last_line", + ]; + if key.modifiers.is_empty() { key_sequence_tracker.add_key(key.code); let sequence = key_sequence_tracker.get_sequence(); - // Try to match the current sequence against Read-Only mode bindings if let Some(action) = config.matches_key_sequence_generalized(&sequence) { - // Handle context-specific actions first - let result = if app_state.ui.show_form - && (action == "previous_entry" || action == "next_entry") - { - // Form-specific navigation + let result = if app_state.ui.show_form && CONTEXT_ACTIONS_FORM.contains(&action) { crate::tui::functions::form::handle_action( action, form_state, @@ -89,35 +93,18 @@ pub async fn handle_read_only_event( ideal_cursor_column, ) .await? - } else if app_state.ui.show_login - && (action == "move_up" || action == "move_down") - { - // Login-specific field navigation + } else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) { crate::tui::functions::login::handle_action( action, auth_state, ideal_cursor_column, ) .await? - } else if app_state.ui.show_form - && (action == "move_up" || action == "move_down") - { - // Form-specific field navigation (can reuse login handler logic if identical) - crate::tui::functions::form::handle_action( - action, - form_state, - grpc_client, // Might not be needed for simple up/down - current_position, - total_count, - ideal_cursor_column, - ) - .await? } else { - // Handle common navigation actions generically if app_state.ui.show_login { execute_action( action, - auth_state, // Pass AuthState + auth_state, ideal_cursor_column, key_sequence_tracker, command_message, @@ -126,7 +113,7 @@ pub async fn handle_read_only_event( } else { execute_action( action, - form_state, // Pass FormState + form_state, ideal_cursor_column, key_sequence_tracker, command_message, @@ -138,21 +125,15 @@ pub async fn handle_read_only_event( return Ok((false, result)); } - // Check if this might be a prefix of a longer sequence if config.is_key_sequence_prefix(&sequence) { return Ok((false, command_message.clone())); } - // Since it's not part of a multi-key sequence, check for a direct action if sequence.len() == 1 && !config.is_key_sequence_prefix(&sequence) { if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers) { - // Handle context-specific actions first - let result = if app_state.ui.show_form - && action == "previous_entry" - { - // Form-specific navigation + let result = if app_state.ui.show_form && CONTEXT_ACTIONS_FORM.contains(&action) { crate::tui::functions::form::handle_action( action, form_state, @@ -162,12 +143,18 @@ pub async fn handle_read_only_event( ideal_cursor_column, ) .await? + } else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) { + crate::tui::functions::login::handle_action( + action, + auth_state, + ideal_cursor_column, + ) + .await? } else { - // Handle common navigation actions generically if app_state.ui.show_login { execute_action( action, - auth_state, // Pass AuthState + auth_state, ideal_cursor_column, key_sequence_tracker, command_message, @@ -176,7 +163,7 @@ pub async fn handle_read_only_event( } else { execute_action( action, - form_state, // Pass FormState + form_state, ideal_cursor_column, key_sequence_tracker, command_message, @@ -188,18 +175,15 @@ pub async fn handle_read_only_event( return Ok((false, result)); } } + key_sequence_tracker.reset(); + } else { - // If modifiers are pressed, check for direct key bindings key_sequence_tracker.reset(); if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers) { - // Handle context-specific actions first - let result = if app_state.ui.show_form - && action == "previous_entry" - { - // Form-specific navigation + let result = if app_state.ui.show_form && CONTEXT_ACTIONS_FORM.contains(&action) { crate::tui::functions::form::handle_action( action, form_state, @@ -209,12 +193,18 @@ pub async fn handle_read_only_event( ideal_cursor_column, ) .await? + } else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) { + crate::tui::functions::login::handle_action( + action, + auth_state, + ideal_cursor_column, + ) + .await? } else { - // Handle common navigation actions generically if app_state.ui.show_login { execute_action( action, - auth_state, // Pass AuthState + auth_state, ideal_cursor_column, key_sequence_tracker, command_message, @@ -223,7 +213,7 @@ pub async fn handle_read_only_event( } else { execute_action( action, - form_state, // Pass FormState + form_state, ideal_cursor_column, key_sequence_tracker, command_message, @@ -235,7 +225,6 @@ pub async fn handle_read_only_event( } } - // Show a helpful message when no binding was found if !*edit_mode_cooldown { let default_key = "i".to_string(); let edit_key = config @@ -243,40 +232,33 @@ pub async fn handle_read_only_event( .read_only .get("enter_edit_mode_before") .and_then(|keys| keys.first()) - .unwrap_or(&default_key); + .map(|k| k.to_string()) + .unwrap_or(default_key); *command_message = format!("Read-only mode - press {} to edit", edit_key); } + *edit_mode_cooldown = false; + Ok((false, command_message.clone())) } -// Make this function generic over CanvasState async fn execute_action( action: &str, - state: &mut S, // Use generic state + state: &mut S, ideal_cursor_column: &mut usize, - key_sequence_tracker: &mut KeySequenceTracker, // Keep for resetting - command_message: &mut String, // Keep for clearing + key_sequence_tracker: &mut KeySequenceTracker, + command_message: &mut String, ) -> Result> { match action { - // These actions are handled outside now based on context - "previous_entry" | "next_entry" => { + "previous_entry" | "next_entry" | "move_up" | "move_down" | + "move_first_line" | "move_last_line" => { key_sequence_tracker.reset(); Ok(format!( "Action '{}' should be handled by context-specific logic", action )) } - // These actions are handled outside now based on context - "move_up" | "move_down" => { - key_sequence_tracker.reset(); - Ok(format!( - "Action '{}' should be handled by context-specific logic", - action - )) - } "exit_edit_mode" => { - // This action is primarily for Edit mode, but might be bound here too key_sequence_tracker.reset(); command_message.clear(); Ok("".to_string()) @@ -284,19 +266,18 @@ async fn execute_action( "move_left" => { let current_pos = state.current_cursor_pos(); let new_pos = current_pos.saturating_sub(1); - state.set_current_cursor_pos(new_pos); // Use trait setter + state.set_current_cursor_pos(new_pos); *ideal_cursor_column = new_pos; Ok("".to_string()) } "move_right" => { let current_input = state.get_current_input(); let current_pos = state.current_cursor_pos(); - // In read-only, cursor stops AT the last character, not after if !current_input.is_empty() && current_pos < current_input.len().saturating_sub(1) { let new_pos = current_pos + 1; - state.set_current_cursor_pos(new_pos); // Use trait setter + state.set_current_cursor_pos(new_pos); *ideal_cursor_column = new_pos; } Ok("".to_string()) @@ -306,9 +287,8 @@ async fn execute_action( if !current_input.is_empty() { let new_pos = find_next_word_start(current_input, state.current_cursor_pos()); - // Clamp to last valid character index in read-only let final_pos = new_pos.min(current_input.len().saturating_sub(1)); - state.set_current_cursor_pos(final_pos); // Use trait setter + state.set_current_cursor_pos(final_pos); *ideal_cursor_column = final_pos; } Ok("".to_string()) @@ -316,11 +296,17 @@ async fn execute_action( "move_word_end" => { let current_input = state.get_current_input(); if !current_input.is_empty() { + // 1. Find the index of the last character of the target word let new_pos = find_word_end(current_input, state.current_cursor_pos()); - // Clamp to last valid character index in read-only - let final_pos = new_pos.min(current_input.len().saturating_sub(1)); - state.set_current_cursor_pos(final_pos); // Use trait setter + + // 2. Clamp the position for Read-Only mode + // max_valid_index is the index of the VERY LAST character in the input string + let max_valid_index = current_input.len().saturating_sub(1); + let final_pos = new_pos.min(max_valid_index); + + // 3. Set the cursor + state.set_current_cursor_pos(final_pos); *ideal_cursor_column = final_pos; } Ok("".to_string()) @@ -332,7 +318,7 @@ async fn execute_action( current_input, state.current_cursor_pos(), ); - state.set_current_cursor_pos(new_pos); // Use trait setter + state.set_current_cursor_pos(new_pos); *ideal_cursor_column = new_pos; } Ok("".to_string()) @@ -344,13 +330,13 @@ async fn execute_action( current_input, state.current_cursor_pos(), ); - state.set_current_cursor_pos(new_pos); // Use trait setter + state.set_current_cursor_pos(new_pos); *ideal_cursor_column = new_pos; } Ok("Moved to previous word end".to_string()) } "move_line_start" => { - state.set_current_cursor_pos(0); // Use trait setter + state.set_current_cursor_pos(0); *ideal_cursor_column = 0; Ok("".to_string()) } @@ -358,47 +344,21 @@ async fn execute_action( let current_input = state.get_current_input(); if !current_input.is_empty() { let new_pos = current_input.len().saturating_sub(1); - state.set_current_cursor_pos(new_pos); // Use trait setter + state.set_current_cursor_pos(new_pos); *ideal_cursor_column = new_pos; } else { - state.set_current_cursor_pos(0); // Handle empty input case + state.set_current_cursor_pos(0); *ideal_cursor_column = 0; } Ok("".to_string()) } - "move_first_line" => { - // Field change is handled outside based on context - // Just set cursor position based on ideal column - let current_input = state.get_current_input(); - let max_cursor_pos = if !current_input.is_empty() { - current_input.len().saturating_sub(1) - } else { - 0 - }; - state.set_current_cursor_pos( - (*ideal_cursor_column).min(max_cursor_pos), - ); - Ok("Moved to first line".to_string()) // Message might be inaccurate now - } - "move_last_line" => { - // Field change is handled outside based on context - // Just set cursor position based on ideal column - let current_input = state.get_current_input(); - let max_cursor_pos = if !current_input.is_empty() { - current_input.len().saturating_sub(1) - } else { - 0 - }; - state.set_current_cursor_pos( - (*ideal_cursor_column).min(max_cursor_pos), - ); - Ok("Moved to last line".to_string()) // Message might be inaccurate now - } - _ => Ok(format!("Unknown read-only action: {}", action)), + _ => { + key_sequence_tracker.reset(); + Ok(format!("Unknown read-only action: {}", action)) + }, } } -// Helper functions remain unchanged as they operate on &str fn get_char_type(c: char) -> CharType { if c.is_whitespace() { CharType::Whitespace @@ -411,23 +371,22 @@ fn get_char_type(c: char) -> CharType { fn find_next_word_start(text: &str, current_pos: usize) -> usize { let chars: Vec = text.chars().collect(); - if chars.is_empty() || current_pos >= chars.len() { + if chars.is_empty() { + return 0; + } + let current_pos = current_pos.min(chars.len()); + + if current_pos == chars.len() { return current_pos; } let mut pos = current_pos; - // Handle edge case where current_pos might be out of bounds after edits - if pos >= chars.len() { - return chars.len(); - } let initial_type = get_char_type(chars[pos]); - // Move past characters of the same type while pos < chars.len() && get_char_type(chars[pos]) == initial_type { pos += 1; } - // Move past whitespace while pos < chars.len() && get_char_type(chars[pos]) == CharType::Whitespace { pos += 1; } @@ -435,29 +394,23 @@ fn find_next_word_start(text: &str, current_pos: usize) -> usize { pos } - fn find_word_end(text: &str, current_pos: usize) -> usize { let chars: Vec = text.chars().collect(); if chars.is_empty() { return 0; } - // Handle edge case where current_pos might be out of bounds let mut pos = current_pos.min(chars.len().saturating_sub(1)); - // If starting on whitespace, move to the next non-whitespace if get_char_type(chars[pos]) == CharType::Whitespace { while pos + 1 < chars.len() && get_char_type(chars[pos + 1]) == CharType::Whitespace { pos += 1; } - // If we are still on whitespace (meaning end of string or only whitespace left), return current pos if pos + 1 >= chars.len() || get_char_type(chars[pos + 1]) == CharType::Whitespace { return pos; } - // Move to the start of the next word pos += 1; } - // Now we are on a non-whitespace character let word_type = get_char_type(chars[pos]); while pos + 1 < chars.len() && get_char_type(chars[pos + 1]) == word_type { pos += 1; @@ -466,7 +419,6 @@ fn find_word_end(text: &str, current_pos: usize) -> usize { pos } - fn find_prev_word_start(text: &str, current_pos: usize) -> usize { let chars: Vec = text.chars().collect(); if chars.is_empty() || current_pos == 0 { @@ -475,64 +427,56 @@ fn find_prev_word_start(text: &str, current_pos: usize) -> usize { let mut pos = current_pos.saturating_sub(1); - // Move past whitespace while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace { pos -= 1; } - // Now on a non-whitespace or at the beginning if get_char_type(chars[pos]) != CharType::Whitespace { let word_type = get_char_type(chars[pos]); - // Move to the beginning of the word while pos > 0 && get_char_type(chars[pos - 1]) == word_type { pos -= 1; } } - // If pos is 0 and it's whitespace, keep it at 0. Otherwise, it's the start of the word. - if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace { - 0 + if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace { + 0 } else { pos } } - fn find_prev_word_end(text: &str, current_pos: usize) -> usize { let chars: Vec = text.chars().collect(); - if chars.is_empty() || current_pos <= 1 { // Need at least 2 chars to find a *previous* word end + if chars.is_empty() || current_pos == 0 { return 0; } - let mut pos = current_pos.saturating_sub(1); // Start looking one char back + let mut pos = current_pos.saturating_sub(1); - // Skip trailing whitespace from the current position while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace { pos -= 1; } - // Now we are at the end of the word the cursor was in/after, or at the start - if pos == 0 { - return 0; // Reached the beginning + if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace { + return 0; } + if pos == 0 && get_char_type(chars[0]) != CharType::Whitespace { + return 0; + } - // Skip the word itself let word_type = get_char_type(chars[pos]); while pos > 0 && get_char_type(chars[pos - 1]) == word_type { pos -= 1; } - // Skip whitespace before that word while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace { pos -= 1; } - // Now pos is at the beginning of the word *or* the end of the *previous* word. - // If we moved back, pos-1 is the index we want. if pos > 0 { pos - 1 } else { - 0 // We were at the first word + 0 } } diff --git a/client/src/tui/functions/form.rs b/client/src/tui/functions/form.rs index 1c31673..db8d8c2 100644 --- a/client/src/tui/functions/form.rs +++ b/client/src/tui/functions/form.rs @@ -1,6 +1,7 @@ // src/tui/functions/form.rs use crate::state::pages::form::FormState; use crate::services::grpc_client::GrpcClient; +use crate::state::canvas_state::CanvasState; pub async fn handle_action( action: &str, @@ -74,36 +75,90 @@ pub async fn handle_action( Ok("Already at last entry".into()) } } - "move_up" => { - // Change field first - if form_state.current_field == 0 { - form_state.current_field = form_state.fields.len() - 1; - } else { - form_state.current_field = form_state.current_field.saturating_sub(1); + "move_first_line" => { + // *** GUARD CLAUSE *** + if form_state.fields.is_empty() { + // Or log an error, or do nothing gracefully + return Ok("No fields to navigate to.".to_string()); } - // Get current input AFTER changing field + // Set the field index + form_state.set_current_field(0); + + // Get input of the *new* current field (index 0) let current_input = form_state.get_current_input(); - let max_cursor_pos = if !current_input.is_empty() { - current_input.len() - 1 - } else { + + // Calculate the maximum valid cursor position for read-only mode + // Cursor should be ON the last char, or at 0 if empty. + let max_cursor_pos = if current_input.is_empty() { 0 + } else { + current_input.len().saturating_sub(1) }; - form_state.current_cursor_pos = (*ideal_cursor_column).min(max_cursor_pos); + + // Set the cursor position, clamped by ideal column and max valid position + form_state.set_current_cursor_pos((*ideal_cursor_column).min(max_cursor_pos)); + + Ok("".to_string()) // Or a confirmation message + } + + "move_last_line" => { + // *** GUARD CLAUSE *** + if form_state.fields.is_empty() { + return Ok("No fields to navigate to.".to_string()); + } + + let last_field_index = form_state.fields.len() - 1; + form_state.set_current_field(last_field_index); + + let current_input = form_state.get_current_input(); + let max_cursor_pos = if current_input.is_empty() { + 0 + } else { + current_input.len().saturating_sub(1) + }; + form_state.set_current_cursor_pos((*ideal_cursor_column).min(max_cursor_pos)); + Ok("".to_string()) } - "move_down" => { - // Change field first - form_state.current_field = (form_state.current_field + 1) % form_state.fields.len(); - // Get current input AFTER changing field - let current_input = form_state.get_current_input(); - let max_cursor_pos = if !current_input.is_empty() { - current_input.len() - 1 + "move_up" => { + if form_state.fields.is_empty() { + return Ok("No fields to navigate.".to_string()); + } + let current_field = form_state.current_field(); + let new_field = if current_field == 0 { + form_state.fields.len() - 1 } else { - 0 + current_field - 1 }; - form_state.current_cursor_pos = (*ideal_cursor_column).min(max_cursor_pos); + form_state.set_current_field(new_field); + + let current_input = form_state.get_current_input(); + let max_cursor_pos = if current_input.is_empty() { + 0 + } else { + current_input.len().saturating_sub(1) // Adjust for read-only if needed + }; + form_state.set_current_cursor_pos((*ideal_cursor_column).min(max_cursor_pos)); + Ok("".to_string()) + } + + "move_down" => { + if form_state.fields.is_empty() { + return Ok("No fields to navigate.".to_string()); + } + let current_field = form_state.current_field(); + let new_field = (current_field + 1) % form_state.fields.len(); + form_state.set_current_field(new_field); + + let current_input = form_state.get_current_input(); + let max_cursor_pos = if current_input.is_empty() { + 0 + } else { + current_input.len().saturating_sub(1) // Adjust for read-only if needed + }; + form_state.set_current_cursor_pos((*ideal_cursor_column).min(max_cursor_pos)); Ok("".to_string()) } _ => Err("Unknown form action".into())