diff --git a/client/src/components/handlers/canvas_multi.rs b/client/src/components/handlers/canvas_multi.rs new file mode 100644 index 00000000..839b2bc8 --- /dev/null +++ b/client/src/components/handlers/canvas_multi.rs @@ -0,0 +1,254 @@ +// src/components/handlers/canvas_multi.rs +use ratatui::{ + widgets::{Paragraph, Block, Borders}, + layout::{Layout, Constraint, Direction, Rect}, + style::{Style, Modifier}, + text::{Line, Span}, + Frame, + prelude::Alignment, +}; +use crate::config::colors::themes::Theme; +use crate::state::pages::canvas_state::CanvasState; // Ensure this trait is adapted +use crate::state::app::highlight::HighlightState; +use std::cmp::{min, max}; + +// Note to user: The CanvasState trait will need to be significantly adapted. +// It should effectively manage a multiline text buffer. +// - `inputs` (passed to this function) should be the lines from CanvasState. +// - `current_field_idx` (passed here) should be the active line index from CanvasState. +// - `form_state.current_cursor_pos()` should return cursor char position in the active line. +// - CanvasState should ensure `inputs` is ideally never empty (e.g., init with `vec!["".to_string()]`). + +pub fn render_canvas( + f: &mut Frame, + area: Rect, // Total area for this component + form_state: &impl CanvasState, + fields: &[&str], // fields[0] is an optional label for the editor + inputs: &[&String], // These are the lines of text for the editor + current_field_idx: &usize, // Active_line_idx within editor_lines + theme: &Theme, + is_edit_mode: bool, + highlight_state: &HighlightState, +) -> Option { + // Interpret parameters for the multiline editor context + let editor_label_str = fields.get(0).filter(|s| !s.is_empty()).map(|s| *s); + let editor_lines = inputs; // These are the lines of text + let active_line_idx = *current_field_idx; // Current active line + let cursor_char_pos_in_active_line = form_state.current_cursor_pos(); + + // Determine layout: optional label column, editor column + let (label_column_rect_opt, editor_area_rect) = + if editor_label_str.is_some() { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(30), // Label width + Constraint::Percentage(70), // Editor width + ]) + .split(area); + (Some(chunks[0]), chunks[1]) + } else { + (None, area) // Editor takes full area if no label + }; + + // --- Input Block Setup --- + let num_lines_in_editor = editor_lines.len().max(1); // Ensure at least 1 line height + let editor_content_height = num_lines_in_editor as u16; + let desired_input_block_height = editor_content_height + 2; // +2 for borders + let input_block_actual_height = + desired_input_block_height.min(editor_area_rect.height); + + let border_style = if form_state.has_unsaved_changes() { + Style::default().fg(theme.warning) + } else if is_edit_mode { + Style::default().fg(theme.accent) + } else { + Style::default().fg(theme.secondary) + }; + + let input_container_widget = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .style(Style::default().bg(theme.bg)); + + let input_block_rect = Rect { + x: editor_area_rect.x, + y: editor_area_rect.y, + width: editor_area_rect.width, + height: input_block_actual_height, + }; + + f.render_widget(&input_container_widget, input_block_rect); + + let text_lines_render_area = + input_container_widget.inner(input_block_rect); + + if text_lines_render_area.height == 0 || text_lines_render_area.width == 0 { + return None; // No space to render text or cursor + } + + let line_rows_layout = Layout::default() + .direction(Direction::Vertical) + .constraints( + std::iter::repeat(Constraint::Length(1)) + .take(num_lines_in_editor) + .collect::>(), + ) + .split(text_lines_render_area); + + let mut active_line_render_rect = None; + + // --- Render Label (if any) --- + if let Some(label_col_rect) = label_column_rect_opt { + if let Some(label_text) = editor_label_str { + let label_paragraph = Paragraph::new(Line::from(Span::styled( + format!("{}:", label_text), + Style::default().fg(theme.fg), + ))); + let label_y_pos = if input_block_rect.height > 0 { + input_block_rect.y + 1 // Align with first text line + } else { + input_block_rect.y + }; + let label_render_rect = Rect { + x: label_col_rect.x, + y: label_y_pos.min(label_col_rect.y + label_col_rect.height.saturating_sub(1)), + width: label_col_rect.width.saturating_sub(1), // Prevent overflow + height: 1, + }; + if label_render_rect.area() > 0 { + f.render_widget(label_paragraph, label_render_rect); + } + } + } + + // --- Render Text Lines and Cursor --- + for (line_idx, current_line_str_ref) in editor_lines.iter().enumerate() { + if line_idx >= line_rows_layout.len() { // Not enough vertical space in layout + break; + } + + let is_active_line = line_idx == active_line_idx; + let text_content = current_line_str_ref.as_str(); + let text_len = text_content.chars().count(); + let current_line_target_rect = line_rows_layout[line_idx]; + + let line_widget_content: Line; // Stores the styled Line for the paragraph + + // Define base styles for text + let style_active_text = Style::default().fg(theme.highlight); + let style_inactive_text = Style::default().fg(theme.fg); + let style_highlight_selection_bg = Style::default().fg(theme.highlight).bg(theme.highlight_bg).add_modifier(Modifier::BOLD); + + // Determine the base style for text on this line (active/inactive) + let base_text_style_for_line = if is_active_line && is_edit_mode { + style_active_text + } else { + style_inactive_text + }; + + match highlight_state { + HighlightState::Off => { + line_widget_content = Line::from(Span::styled( + text_content, + base_text_style_for_line, + )); + } + HighlightState::Characterwise { anchor } => { + let (anchor_ln_idx, anchor_char_idx) = *anchor; + let current_ln_idx = active_line_idx; + let current_char_idx_on_line = cursor_char_pos_in_active_line; + + let selection_start_ln = min(anchor_ln_idx, current_ln_idx); + let selection_end_ln = max(anchor_ln_idx, current_ln_idx); + + // Char pos on the line that starts the selection + let sel_start_char = if anchor_ln_idx == current_ln_idx { min(anchor_char_idx, current_char_idx_on_line) } + else if anchor_ln_idx < current_ln_idx { anchor_char_idx } + else { current_char_idx_on_line }; + // Char pos on the line that ends the selection + let sel_end_char = if anchor_ln_idx == current_ln_idx { max(anchor_char_idx, current_char_idx_on_line) } + else if anchor_ln_idx < current_ln_idx { current_char_idx_on_line } + else { anchor_char_idx }; + + if line_idx >= selection_start_ln && line_idx <= selection_end_ln { + // This line is part of the selection + let text_style_within_selection = if is_edit_mode { style_active_text } else { style_inactive_text }; + + if selection_start_ln == selection_end_ln { // Single-line selection + let start_h = sel_start_char.min(text_len); + let end_h = sel_end_char.min(text_len); // end_h is inclusive char index + + let before: String = text_content.chars().take(start_h).collect(); + let highlighted: String = text_content.chars().skip(start_h).take(end_h.saturating_sub(start_h) + 1).collect(); + let after: String = text_content.chars().skip(end_h + 1).collect(); + line_widget_content = Line::from(vec![ + Span::styled(before, text_style_within_selection), + Span::styled(highlighted, style_highlight_selection_bg), + Span::styled(after, text_style_within_selection), + ]); + } else if line_idx == selection_start_ln { // Start of multi-line selection + let start_h = sel_start_char.min(text_len); + let before: String = text_content.chars().take(start_h).collect(); + let highlighted: String = text_content.chars().skip(start_h).collect(); + line_widget_content = Line::from(vec![ + Span::styled(before, text_style_within_selection), + Span::styled(highlighted, style_highlight_selection_bg), + ]); + } else if line_idx == selection_end_ln { // End of multi-line selection + let end_h_inclusive = sel_end_char.min(if text_len > 0 { text_len - 1 } else { 0 }); + let highlighted: String = text_content.chars().take(end_h_inclusive + 1).collect(); + let after: String = text_content.chars().skip(end_h_inclusive + 1).collect(); + line_widget_content = Line::from(vec![ + Span::styled(highlighted, style_highlight_selection_bg), + Span::styled(after, text_style_within_selection), + ]); + } else { // Middle of multi-line selection + line_widget_content = Line::from(Span::styled(text_content, style_highlight_selection_bg)); + } + } else { // Line is outside character-wise selection + line_widget_content = Line::from(Span::styled(text_content, base_text_style_for_line)); + } + } + HighlightState::Linewise { anchor_line } => { + let selection_start_ln = min(*anchor_line, active_line_idx); + let selection_end_ln = max(*anchor_line, active_line_idx); + + if line_idx >= selection_start_ln && line_idx <= selection_end_ln { + line_widget_content = Line::from(Span::styled(text_content, style_highlight_selection_bg)); + } else { + line_widget_content = Line::from(Span::styled(text_content, base_text_style_for_line)); + } + } + } + + let line_paragraph = Paragraph::new(line_widget_content).alignment(Alignment::Left); + f.render_widget(line_paragraph, current_line_target_rect); + + if is_active_line { + active_line_render_rect = Some(current_line_target_rect); + if is_edit_mode { + let max_cursor_x_offset = current_line_target_rect.width.saturating_sub(0); // Allow cursor at end of line + let cursor_x_offset = (cursor_char_pos_in_active_line as u16).min(max_cursor_x_offset); + let cursor_x = current_line_target_rect.x + cursor_x_offset; + let cursor_y = current_line_target_rect.y; + f.set_cursor_position((cursor_x, cursor_y)); + } + } + } + + // Handle case for an empty editor (e.g., initial state with one empty line) + if editor_lines.is_empty() && num_lines_in_editor == 1 && active_line_idx == 0 && !line_rows_layout.is_empty() { + let target_rect = line_rows_layout[0]; + // Render an empty paragraph if needed, or just set cursor + // The loop above wouldn't run, so active_line_render_rect wouldn't be set. + active_line_render_rect = Some(target_rect); + if is_edit_mode { + let cursor_x = target_rect.x + cursor_char_pos_in_active_line as u16; + let cursor_y = target_rect.y; + f.set_cursor_position((cursor_x, cursor_y)); + } + } + + active_line_render_rect +} diff --git a/client/src/components/handlers/multi_canvas.rs b/client/src/components/handlers/multi_canvas.rs new file mode 100644 index 00000000..1d48adf0 --- /dev/null +++ b/client/src/components/handlers/multi_canvas.rs @@ -0,0 +1,324 @@ +// src/components/handlers/canvas_multi.rs +use ratatui::{ + widgets::{Paragraph, Block, Borders}, + layout::{Layout, Constraint, Direction, Rect}, + style::{Style, Modifier}, + text::{Line, Span}, + Frame, + prelude::Alignment, +}; +use crate::config::colors::themes::Theme; +// Import the new trait +use crate::state::pages::multiline_editor_state::MultilineEditorState; +use crate::state::app::highlight::HighlightState; // Assuming this is your global highlight state +use std::cmp::{min, max}; + +pub fn render_multiline_editor( // Renamed for clarity + f: &mut Frame, + area: Rect, // Total area for this component + editor_state: &impl MultilineEditorState, // Use the new trait + theme: &Theme, + is_edit_mode: bool, // Is the editor currently active for input? + highlight_state: &HighlightState, // Global highlight state for selections +) -> Option { // Returns the Rect of the active line for external cursor management if needed + let editor_label_str = editor_state.get_label(); + let editor_lines_vec = editor_state.lines(); // This is &Vec + + // Convert &Vec to Vec<&String> for iteration if needed by existing logic, + // or iterate directly over editor_lines_vec. + // For consistency with your previous structure, let's map: + let editor_lines_refs: Vec<&String> = editor_lines_vec.iter().collect(); + + let active_line_idx = editor_state.active_line_index(); + let cursor_char_pos_in_active_line = + editor_state.cursor_char_pos_in_active_line(); + + let (label_column_rect_opt, editor_area_rect) = + if editor_label_str.is_some() { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(30), + Constraint::Percentage(70), + ]) + .split(area); + (Some(chunks[0]), chunks[1]) + } else { + (None, area) + }; + + let num_lines_to_display = + if editor_lines_vec.is_empty() { 1 } else { editor_lines_vec.len() }; + let editor_content_height = num_lines_to_display as u16; + // Ensure editor_area_rect.height is at least 2 for borders + 1 for content + let available_height_for_block = editor_area_rect.height.max(1); // Min height of 1 for the block itself + let desired_input_block_height = (editor_content_height + 2).min(available_height_for_block); + + + let border_style = if editor_state.has_unsaved_changes() { + Style::default().fg(theme.warning) + } else if is_edit_mode { + Style::default().fg(theme.accent) + } else { + Style::default().fg(theme.secondary) + }; + + let input_container_widget = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .style(Style::default().bg(theme.bg)); + + let input_block_rect = Rect { + x: editor_area_rect.x, + y: editor_area_rect.y, + width: editor_area_rect.width, + height: desired_input_block_height, + }; + + f.render_widget(&input_container_widget, input_block_rect); + + let text_lines_render_area = + input_container_widget.inner(input_block_rect); + + if text_lines_render_area.height == 0 || + text_lines_render_area.width == 0 + { + return None; + } + + let line_rows_layout = Layout::default() + .direction(Direction::Vertical) + .constraints( + std::iter::repeat(Constraint::Length(1)) + .take(num_lines_to_display) + .collect::>(), + ) + .split(text_lines_render_area); + + let mut active_line_render_rect = None; + + if let Some(label_col_rect) = label_column_rect_opt { + if let Some(label_text) = editor_label_str { + let label_paragraph = Paragraph::new(Line::from(Span::styled( + format!("{}:", label_text), + Style::default().fg(theme.fg), + ))); + let label_y_pos = if input_block_rect.height > 0 { + input_block_rect.y + 1 + } else { + input_block_rect.y + }; + let label_render_rect = Rect { + x: label_col_rect.x, + y: label_y_pos.min( + label_col_rect.y + + label_col_rect.height.saturating_sub(1), + ), + width: label_col_rect.width.saturating_sub(1), + height: 1, + }; + if label_render_rect.area() > 0 { + f.render_widget(label_paragraph, label_render_rect); + } + } + } + + // If editor_lines_refs is empty, but we allocated space for one line (num_lines_to_display = 1) + if editor_lines_refs.is_empty() && num_lines_to_display == 1 && !line_rows_layout.is_empty() { + let target_rect = line_rows_layout[0]; + // Optionally render a placeholder or just set cursor if in edit mode + active_line_render_rect = Some(target_rect); + if is_edit_mode && active_line_idx == 0 { // Cursor for the empty line + let cursor_x = target_rect.x + cursor_char_pos_in_active_line as u16; + let cursor_y = target_rect.y; + f.set_cursor_position((cursor_x.min(target_rect.right()), cursor_y)); + } + return active_line_render_rect; + } + + + for (line_idx, current_line_str_ref) in + editor_lines_refs.iter().enumerate() + { + if line_idx >= line_rows_layout.len() { + break; + } + + let is_active_line = line_idx == active_line_idx; + let text_content = current_line_str_ref.as_str(); + let text_len = text_content.chars().count(); + let current_line_target_rect = line_rows_layout[line_idx]; + + let line_widget_content: Line; + + let style_active_text = Style::default().fg(theme.highlight); + let style_inactive_text = Style::default().fg(theme.fg); + let style_highlight_selection_bg = Style::default() + .fg(theme.highlight) + .bg(theme.highlight_bg) + .add_modifier(Modifier::BOLD); + + let base_text_style_for_line = if is_active_line && is_edit_mode { + style_active_text + } else { + style_inactive_text + }; + + match highlight_state { + HighlightState::Off => { + line_widget_content = Line::from(Span::styled( + text_content, + base_text_style_for_line, + )); + } + HighlightState::Characterwise { anchor } => { + let (anchor_ln_idx, anchor_char_idx) = *anchor; + let current_ln_idx_for_highlight = active_line_idx; // The editor's active line + let current_char_idx_for_highlight = + cursor_char_pos_in_active_line; + + let selection_start_ln = + min(anchor_ln_idx, current_ln_idx_for_highlight); + let selection_end_ln = + max(anchor_ln_idx, current_ln_idx_for_highlight); + + let sel_start_char = if anchor_ln_idx == + current_ln_idx_for_highlight + { + min(anchor_char_idx, current_char_idx_for_highlight) + } else if anchor_ln_idx < current_ln_idx_for_highlight { + anchor_char_idx + } else { + current_char_idx_for_highlight + }; + let sel_end_char = if anchor_ln_idx == + current_ln_idx_for_highlight + { + max(anchor_char_idx, current_char_idx_for_highlight) + } else if anchor_ln_idx < current_ln_idx_for_highlight { + current_char_idx_for_highlight + } else { + anchor_char_idx + }; + + if line_idx >= selection_start_ln && line_idx <= selection_end_ln + { + let text_style_within_selection = if is_edit_mode { + style_active_text + } else { + style_inactive_text + }; + + if selection_start_ln == selection_end_ln { + let start_h = sel_start_char.min(text_len); + let end_h = sel_end_char.min(text_len); + + let before: String = + text_content.chars().take(start_h).collect(); + let highlighted: String = text_content + .chars() + .skip(start_h) + .take(end_h.saturating_sub(start_h) + 1) + .collect(); + let after: String = + text_content.chars().skip(end_h + 1).collect(); + line_widget_content = Line::from(vec![ + Span::styled(before, text_style_within_selection), + Span::styled( + highlighted, + style_highlight_selection_bg, + ), + Span::styled(after, text_style_within_selection), + ]); + } else if line_idx == selection_start_ln { + let start_h = sel_start_char.min(text_len); + let before: String = + text_content.chars().take(start_h).collect(); + let highlighted: String = + text_content.chars().skip(start_h).collect(); + line_widget_content = Line::from(vec![ + Span::styled(before, text_style_within_selection), + Span::styled( + highlighted, + style_highlight_selection_bg, + ), + ]); + } else if line_idx == selection_end_ln { + let end_h_inclusive = sel_end_char + .min(if text_len > 0 { text_len - 1 } else { 0 }); + let highlighted: String = text_content + .chars() + .take(end_h_inclusive + 1) + .collect(); + let after: String = text_content + .chars() + .skip(end_h_inclusive + 1) + .collect(); + line_widget_content = Line::from(vec![ + Span::styled( + highlighted, + style_highlight_selection_bg, + ), + Span::styled(after, text_style_within_selection), + ]); + } else { + line_widget_content = Line::from(Span::styled( + text_content, + style_highlight_selection_bg, + )); + } + } else { + line_widget_content = Line::from(Span::styled( + text_content, + base_text_style_for_line, + )); + } + } + HighlightState::Linewise { anchor_line } => { + let selection_start_ln = min(*anchor_line, active_line_idx); + let selection_end_ln = max(*anchor_line, active_line_idx); + + if line_idx >= selection_start_ln && line_idx <= selection_end_ln + { + line_widget_content = Line::from(Span::styled( + text_content, + style_highlight_selection_bg, + )); + } else { + line_widget_content = Line::from(Span::styled( + text_content, + base_text_style_for_line, + )); + } + } + } + + let line_paragraph = + Paragraph::new(line_widget_content).alignment(Alignment::Left); + f.render_widget(line_paragraph, current_line_target_rect); + + if is_active_line { + active_line_render_rect = Some(current_line_target_rect); + if is_edit_mode { + let max_cursor_x_offset_for_rect = current_line_target_rect + .width + .saturating_sub(0); // Allow cursor at end of line + let cursor_x_offset_within_line = + (cursor_char_pos_in_active_line as u16); + + // The cursor_x_offset should be relative to the start of the text area, + // but not exceed the width of the current_line_target_rect. + let cursor_x_offset = cursor_x_offset_within_line.min(max_cursor_x_offset_for_rect); + + + let cursor_x = current_line_target_rect.x + cursor_x_offset; + let cursor_y = current_line_target_rect.y; + // Final check to ensure cursor is within the visible block + if cursor_x <= current_line_target_rect.right() && cursor_y < current_line_target_rect.bottom() { + f.set_cursor_position((cursor_x, cursor_y)); + } + } + } + } + active_line_render_rect +} diff --git a/client/src/state/pages.rs b/client/src/state/pages.rs index 9883701e..13a7c337 100644 --- a/client/src/state/pages.rs +++ b/client/src/state/pages.rs @@ -7,3 +7,4 @@ pub mod intro; pub mod add_table; pub mod add_logic; pub mod canvas_state; +pub mod multi_canvas_state; diff --git a/client/src/state/pages/multi_canvas_state.rs b/client/src/state/pages/multi_canvas_state.rs new file mode 100644 index 00000000..49e6a8c2 --- /dev/null +++ b/client/src/state/pages/multi_canvas_state.rs @@ -0,0 +1,321 @@ +// src/state/pages/multi_canvas_state.rs (or your chosen filename) +use std::cmp::min; + +/// Trait for managing the state of a multiline text editor. +pub trait MultilineEditorState { + fn lines(&self) -> &Vec; + fn active_line_index(&self) -> usize; + fn cursor_char_pos_in_active_line(&self) -> usize; + fn has_unsaved_changes(&self) -> bool; + fn get_label(&self) -> Option<&str>; // Optional label for the editor + + // Methods to be called by your event handling logic to modify the state + fn set_lines(&mut self, lines: Vec); + fn set_active_line_index(&mut self, index: usize); + fn set_cursor_char_pos_in_active_line(&mut self, pos: usize); + fn set_has_unsaved_changes(&mut self, changed: bool); + + // Basic editing operations + fn insert_char_at_cursor(&mut self, ch: char); + fn delete_char_before_cursor(&mut self); // Handles Backspace + fn delete_char_at_cursor(&mut self); // Handles Delete + fn insert_newline_at_cursor(&mut self); // Handles Enter + fn move_cursor_left(&mut self); + fn move_cursor_right(&mut self); + fn move_cursor_up(&mut self); + fn move_cursor_down(&mut self); +} + +#[derive(Debug, Clone)] +pub struct BasicMultilineEditor { + pub lines: Vec, + pub active_line_idx: usize, + pub cursor_char_pos: usize, // Cursor char position in the active_line_idx + pub unsaved_changes: bool, + pub label: Option, +} + +impl Default for BasicMultilineEditor { + fn default() -> Self { + Self { + lines: vec!["".to_string()], // Start with one empty line + active_line_idx: 0, + cursor_char_pos: 0, + unsaved_changes: false, + label: None, + } + } +} + +impl BasicMultilineEditor { + pub fn new(initial_text: Option, label: Option) -> Self { + let mut lines_vec = match initial_text { + Some(text) if !text.is_empty() => { + text.lines().map(String::from).collect() + } + _ => Vec::new(), // Handle empty initial text + }; + + // Ensure there's always at least one line, even if it's empty + if lines_vec.is_empty() { + lines_vec.push("".to_string()); + } + + Self { + lines: lines_vec, + active_line_idx: 0, + cursor_char_pos: 0, + unsaved_changes: false, + label, + } + } + + // Helper to get mutable access to the current line, ensuring it exists + #[allow(dead_code)] // May not be used directly if functions use self.lines.get_mut directly + fn current_line_mut(&mut self) -> Option<&mut String> { + self.lines.get_mut(self.active_line_idx) + } + + // Helper to get immutable access to the current line + #[allow(dead_code)] // May not be used directly + fn current_line(&self) -> Option<&String> { + self.lines.get(self.active_line_idx) + } + + // This method is crucial and used by others. + fn clamp_cursor_to_current_line(&mut self) { + let active_idx = self.active_line_idx; // Read before any potential borrow of self.lines + let current_cursor = self.cursor_char_pos; // Read before any potential borrow + + let new_cursor_val = if let Some(line) = self.lines.get(active_idx) { // Immutable borrow + min(current_cursor, line.chars().count()) + } else { + 0 // Should not happen if lines always has at least one element and active_idx is valid + }; + self.cursor_char_pos = new_cursor_val; // Assign after borrow + } +} + +impl MultilineEditorState for BasicMultilineEditor { + fn lines(&self) -> &Vec { + &self.lines + } + + fn active_line_index(&self) -> usize { + self.active_line_idx + } + + fn cursor_char_pos_in_active_line(&self) -> usize { + self.cursor_char_pos + } + + fn has_unsaved_changes(&self) -> bool { + self.unsaved_changes + } + + fn get_label(&self) -> Option<&str> { + self.label.as_deref() + } + + fn set_lines(&mut self, mut new_lines: Vec) { + if new_lines.is_empty() { + new_lines.push("".to_string()); + } + self.lines = new_lines; + self.active_line_idx = + min(self.active_line_idx, self.lines.len().saturating_sub(1)); + self.clamp_cursor_to_current_line(); + } + + fn set_active_line_index(&mut self, index: usize) { + self.active_line_idx = + min(index, self.lines.len().saturating_sub(1)); + self.clamp_cursor_to_current_line(); + } + + fn set_cursor_char_pos_in_active_line(&mut self, pos: usize) { + self.cursor_char_pos = pos; + self.clamp_cursor_to_current_line(); + } + + fn set_has_unsaved_changes(&mut self, changed: bool) { + self.unsaved_changes = changed; + } + + fn insert_char_at_cursor(&mut self, ch: char) { + let char_pos = self.cursor_char_pos; // Read before mutable borrow of self.lines + let active_idx = self.active_line_idx; + let mut modified = false; + + if let Some(line) = self.lines.get_mut(active_idx) { + let byte_idx = line + .char_indices() + .nth(char_pos) + .map_or(line.len(), |(idx, _)| idx); + line.insert(byte_idx, ch); + modified = true; + // `line` borrow ends here + } + + if modified { + self.cursor_char_pos += 1; // Modify after `line` borrow ended + self.unsaved_changes = true; + } + } + + fn delete_char_before_cursor(&mut self) { + // Backspace + if self.cursor_char_pos > 0 { + let char_pos_to_delete = self.cursor_char_pos - 1; + let active_idx = self.active_line_idx; + let mut modified_in_line = false; + + if let Some(line) = self.lines.get_mut(active_idx) { + if let Some((byte_idx, _)) = + line.char_indices().nth(char_pos_to_delete) + { + line.remove(byte_idx); + modified_in_line = true; + } + // `line` borrow ends here + } + + if modified_in_line { + self.cursor_char_pos -= 1; + self.unsaved_changes = true; + } + } else if self.active_line_idx > 0 { + // At the beginning of a line (not the first line), merge with previous + let idx_to_remove = self.active_line_idx; + // Ensure the line to remove exists and there's a previous line + if idx_to_remove < self.lines.len() { + let line_to_append_content = self.lines.remove(idx_to_remove); + // The line we are merging into is now at idx_to_remove - 1 + let target_line_idx = idx_to_remove - 1; + + let mut merged = false; + if let Some(target_line) = self.lines.get_mut(target_line_idx) { + let original_target_len = target_line.chars().count(); + target_line.push_str(&line_to_append_content); + // target_line borrow ends here + self.cursor_char_pos = original_target_len; // Set cursor after borrow + merged = true; + } + + if merged { + self.active_line_idx = target_line_idx; // Update active_line_idx + self.unsaved_changes = true; + } + } + } + } + + fn delete_char_at_cursor(&mut self) { + // Delete key + let current_char_pos = self.cursor_char_pos; + let current_active_line_idx = self.active_line_idx; + let mut processed = false; + + // Try to delete within the current line + if let Some(line_mut) = self.lines.get_mut(current_active_line_idx) { + if current_char_pos < line_mut.chars().count() { + if let Some((byte_idx, _)) = + line_mut.char_indices().nth(current_char_pos) + { + line_mut.remove(byte_idx); + self.unsaved_changes = true; + // Cursor position does not change + } + processed = true; + } + // line_mut borrow ends here + } + + // If not processed (i.e., cursor was at end of line), try to merge with next + if !processed { + if current_active_line_idx < self.lines.len() - 1 { + // There is a next line + let next_line_content = + self.lines.remove(current_active_line_idx + 1); + // self.lines.remove() has happened. + // Now get the current line again to append to it. + if let Some(current_line_again) = + self.lines.get_mut(current_active_line_idx) + { + current_line_again.push_str(&next_line_content); + self.unsaved_changes = true; + // Cursor position (self.cursor_char_pos) remains at current_char_pos, + // which was the end of the original current line. + } + } + } + } + + fn insert_newline_at_cursor(&mut self) { + // Enter key + let char_pos = self.cursor_char_pos; + let current_active_idx = self.active_line_idx; + let mut rest_of_line_opt: Option = None; + + if let Some(line_mut_ref) = self.lines.get_mut(current_active_idx) { + let byte_idx = line_mut_ref + .char_indices() + .nth(char_pos) + .map_or(line_mut_ref.len(), |(idx, _)| idx); + rest_of_line_opt = Some(line_mut_ref.split_off(byte_idx)); + // line_mut_ref's borrow ends here + } + + if let Some(rest_of_line) = rest_of_line_opt { + let new_active_idx = current_active_idx + 1; + self.lines.insert(new_active_idx, rest_of_line); + self.active_line_idx = new_active_idx; // Update after insert + self.cursor_char_pos = 0; + self.unsaved_changes = true; + } + } + + fn move_cursor_left(&mut self) { + if self.cursor_char_pos > 0 { + self.cursor_char_pos -= 1; + } else if self.active_line_idx > 0 { + // Move to end of previous line + self.active_line_idx -= 1; + // active_line_idx is updated, now clamp/set cursor_char_pos + let new_cursor_pos = self + .lines + .get(self.active_line_idx) + .map_or(0, |prev_line| prev_line.chars().count()); + self.cursor_char_pos = new_cursor_pos; + } + } + + fn move_cursor_right(&mut self) { + let current_line_len = self + .lines + .get(self.active_line_idx) + .map_or(0, |line| line.chars().count()); + + if self.cursor_char_pos < current_line_len { + self.cursor_char_pos += 1; + } else if self.active_line_idx < self.lines.len() - 1 { + // Move to start of next line + self.active_line_idx += 1; + self.cursor_char_pos = 0; + } + } + + fn move_cursor_up(&mut self) { + if self.active_line_idx > 0 { + self.active_line_idx -= 1; + self.clamp_cursor_to_current_line(); // Relies on updated active_line_idx + } + } + + fn move_cursor_down(&mut self) { + if self.active_line_idx < self.lines.len() - 1 { + self.active_line_idx += 1; + self.clamp_cursor_to_current_line(); // Relies on updated active_line_idx + } + } +}