diff --git a/.gitignore b/.gitignore index 0668130d3..6aaab0c8c 100644 --- a/.gitignore +++ b/.gitignore @@ -111,3 +111,7 @@ docs/*_PLAN.md # direnv .envrc .direnv + +# Superpowers plugin artifacts +docs/superpowers/ +.serena/ diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 2c3ec0c9e..201890ade 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -368,7 +368,7 @@ pub(crate) struct InputHistoryDraft { cursor: usize, } -fn char_count(text: &str) -> usize { +pub(crate) fn char_count(text: &str) -> usize { text.chars().count() } @@ -902,6 +902,10 @@ pub struct ComposerState { /// user presses `d` in Normal mode; cleared on the next key (either `d` /// to complete `dd`, or any other key to cancel). pub vim_pending_d: bool, + /// When set, the cursor is the active end of a text selection and + /// `selection_anchor` is the fixed end. Both are char-indexed. + /// `None` means no selection is active. + pub selection_anchor: Option, } impl Default for ComposerState { @@ -926,6 +930,7 @@ impl Default for ComposerState { vim_enabled: false, vim_mode: VimMode::Normal, vim_pending_d: false, + selection_anchor: None, } } } @@ -940,11 +945,21 @@ pub struct ViewportState { pub selection_autoscroll: Option, pub transcript_scrollbar_dragging: bool, pub last_transcript_area: Option, + pub last_composer_area: Option, pub last_transcript_top: usize, pub last_transcript_visible: usize, pub last_transcript_total: usize, pub last_transcript_padding_top: usize, pub jump_to_latest_button_area: Option, + /// Inner content rect of the composer (excluding border/padding), + /// stored at render time for mouse coordinate mapping. + pub last_composer_content: Option, + /// Number of rendered text lines scrolled off the top of the composer, + /// stored at render time for mouse coordinate mapping. + pub last_composer_scroll_offset: usize, + /// Vertical padding above the first text line in the composer, + /// stored at render time for mouse coordinate mapping. + pub last_composer_top_padding: usize, } impl Default for ViewportState { @@ -958,11 +973,15 @@ impl Default for ViewportState { selection_autoscroll: None, transcript_scrollbar_dragging: false, last_transcript_area: None, + last_composer_area: None, last_transcript_top: 0, last_transcript_visible: 0, last_transcript_total: 0, last_transcript_padding_top: 0, jump_to_latest_button_area: None, + last_composer_content: None, + last_composer_scroll_offset: 0, + last_composer_top_padding: 0, } } } @@ -1809,6 +1828,7 @@ impl App { vim_enabled: composer_vim_enabled, vim_mode: VimMode::Normal, vim_pending_d: false, + selection_anchor: None, }, viewport: ViewportState::default(), goal: GoalState::default(), @@ -3124,6 +3144,7 @@ impl App { if text.is_empty() { return; } + self.delete_selection(); self.selected_attachment_index = None; let cursor = self.cursor_position.min(char_count(&self.input)); let byte_index = byte_index_at_char(&self.input, cursor); @@ -3383,6 +3404,7 @@ impl App { pub fn insert_char(&mut self, c: char) { self.clear_input_history_navigation(); + self.delete_selection(); self.selected_attachment_index = None; let cursor = self.cursor_position.min(char_count(&self.input)); let byte_index = byte_index_at_char(&self.input, cursor); @@ -3409,6 +3431,9 @@ impl App { pub fn delete_char(&mut self) { self.clear_input_history_navigation(); + if self.delete_selection() { + return; + } self.selected_attachment_index = None; if self.cursor_position == 0 { return; @@ -3426,6 +3451,9 @@ impl App { pub fn delete_char_forward(&mut self) { self.clear_input_history_navigation(); + if self.delete_selection() { + return; + } self.selected_attachment_index = None; if self.input.is_empty() { return; @@ -3444,6 +3472,9 @@ impl App { /// Delete the word before the cursor. pub fn delete_word_backward(&mut self) { self.clear_input_history_navigation(); + if self.delete_selection() { + return; + } self.selected_attachment_index = None; if self.cursor_position == 0 { return; @@ -3485,6 +3516,9 @@ impl App { /// Delete from the cursor to the start of the line. pub fn delete_to_start_of_line(&mut self) { self.clear_input_history_navigation(); + if self.delete_selection() { + return; + } self.selected_attachment_index = None; if self.cursor_position == 0 { return; @@ -3510,6 +3544,9 @@ impl App { /// Delete the word after the cursor. pub fn delete_word_forward(&mut self) { self.clear_input_history_navigation(); + if self.delete_selection() { + return; + } self.selected_attachment_index = None; let cursor_byte = byte_index_at_char(&self.input, self.cursor_position); if cursor_byte >= self.input.len() { @@ -3554,6 +3591,13 @@ impl App { /// Returns `true` when bytes were moved into the kill buffer. pub fn kill_to_end_of_line(&mut self) -> bool { self.clear_input_history_navigation(); + if let Some((start, end)) = self.selection_range() { + let sb = byte_index_at_char(&self.input, start); + let eb = byte_index_at_char(&self.input, end); + self.kill_buffer = self.input[sb..eb].to_string(); + self.delete_selection(); + return true; + } let total_chars = char_count(&self.input); let cursor = self.cursor_position.min(total_chars); let start_byte = byte_index_at_char(&self.input, cursor); @@ -3599,6 +3643,7 @@ impl App { if self.kill_buffer.is_empty() { return false; } + self.delete_selection(); self.clear_input_history_navigation(); let text = self.kill_buffer.clone(); let cursor = self.cursor_position.min(char_count(&self.input)); @@ -3724,6 +3769,58 @@ impl App { self.needs_redraw = true; } + // === Selection helpers === + + /// Return the (start, end) of the active selection, or `None`. + /// `start` is inclusive, `end` is exclusive; both are char indices. + pub fn selection_range(&self) -> Option<(usize, usize)> { + let anchor = self.selection_anchor?; + let cursor = self.cursor_position; + if anchor == cursor { + return None; + } + Some(if anchor < cursor { + (anchor, cursor) + } else { + (cursor, anchor) + }) + } + + /// Return the selected text, or empty string if no selection. + pub fn selected_text(&self) -> String { + self.selection_range() + .map(|(s, e)| { + let sb = byte_index_at_char(&self.input, s); + let eb = byte_index_at_char(&self.input, e); + self.input[sb..eb].to_string() + }) + .unwrap_or_default() + } + + /// Delete the selected text, place cursor at the start of the deleted range. + /// Returns true if a selection was deleted. + pub fn delete_selection(&mut self) -> bool { + let Some((start, end)) = self.selection_range() else { + return false; + }; + let sb = byte_index_at_char(&self.input, start); + let eb = byte_index_at_char(&self.input, end); + self.input.replace_range(sb..eb, ""); + self.cursor_position = start; + self.selection_anchor = None; + self.clear_input_history_navigation(); + self.slash_menu_hidden = false; + self.mention_menu_hidden = false; + self.mention_menu_selected = 0; + self.needs_redraw = true; + true + } + + /// Clear the selection without moving the cursor. + pub fn clear_selection(&mut self) { + self.selection_anchor = None; + } + // === Vim composer mode helpers === /// Move the cursor to the start of the current logical line (vim `0`). @@ -3906,6 +4003,7 @@ impl App { self.clear_input_history_navigation(); self.input.clear(); self.cursor_position = 0; + self.selection_anchor = None; self.selected_attachment_index = None; self.slash_menu_selected = 0; self.slash_menu_hidden = false; @@ -6662,4 +6760,107 @@ mod tests { assert_eq!(app.input, "café 你好"); assert_eq!(app.cursor_position, 7); } + + #[test] + fn selection_range_returns_none_when_no_anchor() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = None; + assert!(app.selection_range().is_none()); + } + + #[test] + fn selection_range_returns_ordered_range() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + assert_eq!(app.selection_range(), Some((2, 5))); + } + + #[test] + fn selection_range_normalizes_order() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 2; + app.selection_anchor = Some(5); + assert_eq!(app.selection_range(), Some((2, 5))); + } + + #[test] + fn selection_range_returns_none_when_anchor_equals_cursor() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello".to_string(); + app.cursor_position = 3; + app.selection_anchor = Some(3); + assert!(app.selection_range().is_none()); + } + + #[test] + fn delete_selection_removes_selected_text() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + assert!(app.delete_selection()); + assert_eq!(app.input, "he world"); + assert_eq!(app.cursor_position, 2); + assert!(app.selection_anchor.is_none()); + } + + #[test] + fn insert_char_replaces_selection() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + app.insert_char('X'); + assert_eq!(app.input, "heX world"); + assert_eq!(app.cursor_position, 3); + assert!(app.selection_anchor.is_none()); + } + + #[test] + fn delete_char_removes_selection_instead_of_single_char() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + app.delete_char(); + assert_eq!(app.input, "he world"); + assert_eq!(app.cursor_position, 2); + } + + #[test] + fn selected_text_returns_correct_substring() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + assert_eq!(app.selected_text(), "llo"); + } + + #[test] + fn insert_str_replaces_selection() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + app.insert_str("yo"); + assert_eq!(app.input, "heyo world"); + assert_eq!(app.cursor_position, 4); + assert!(app.selection_anchor.is_none()); + } + + #[test] + fn delete_selection_noop_when_no_selection() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello".to_string(); + app.cursor_position = 3; + app.selection_anchor = None; + assert!(!app.delete_selection()); + assert_eq!(app.input, "hello"); + assert_eq!(app.cursor_position, 3); + } } diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index c3c985c1d..7075b58a4 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -2,6 +2,8 @@ use std::time::{Duration, Instant}; use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::Rect; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; use crate::tui::app::App; use crate::tui::command_palette::{ @@ -37,6 +39,92 @@ pub(crate) fn should_drop_loading_mouse_motion(app: &App, mouse: MouseEvent) -> } } +/// Map a mouse (column, row) within the composer area to a char index +/// in the composer input string. Uses the inner content rect (border-aware) +/// for coordinate mapping, and accounts for vertical padding and scroll offset. +fn mouse_pos_to_char_index(app: &App, col: u16, row: u16, inner: Rect) -> Option { + let rel_col = col.saturating_sub(inner.x) as usize; + let rel_row = row.saturating_sub(inner.y) as usize; + + let input_text = app.composer_display_input(); + if input_text.is_empty() { + return Some(0); + } + + let width = inner.width.max(1) as usize; + let wrapped = crate::tui::widgets::wrap_input_lines_for_mouse(input_text, width); + + // Subtract the vertical top-padding (centering of short inputs). + let text_row = rel_row.saturating_sub(app.viewport.last_composer_top_padding); + + // Add the scroll offset (lines scrolled out of view). + let absolute_row = text_row + app.viewport.last_composer_scroll_offset; + + if absolute_row >= wrapped.len() { + return Some(input_text.chars().count()); + } + + let (line_start, line_text) = &wrapped[absolute_row]; + + let mut char_offset = 0usize; + let mut col_used = 0usize; + for g in line_text.graphemes(true) { + let gw = g.width(); + if col_used + gw > rel_col { + break; + } + col_used += gw; + char_offset += g.chars().count(); + } + Some(line_start + char_offset) +} + +/// Handle mouse events within the composer area. +/// Returns true if the event was consumed. +pub(crate) fn handle_composer_mouse(app: &mut App, mouse: MouseEvent) -> bool { + // Use outer area for hit-testing (includes border). + let Some(area) = app.viewport.last_composer_area else { + return false; + }; + if mouse.column < area.x + || mouse.column >= area.x + area.width + || mouse.row < area.y + || mouse.row >= area.y + area.height + { + return false; + } + // Use inner content rect for coordinate-to-char mapping (border-aware). + let inner = app.viewport.last_composer_content.unwrap_or(area); + + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + if let Some(pos) = mouse_pos_to_char_index(app, mouse.column, mouse.row, inner) { + app.cursor_position = pos; + app.selection_anchor = None; + app.needs_redraw = true; + } + true + } + MouseEventKind::Drag(MouseButton::Left) => { + if let Some(pos) = mouse_pos_to_char_index(app, mouse.column, mouse.row, inner) { + if app.selection_anchor.is_none() { + app.selection_anchor = Some(app.cursor_position); + } + app.cursor_position = pos; + app.needs_redraw = true; + } + true + } + MouseEventKind::Up(MouseButton::Left) => { + if app.selection_anchor == Some(app.cursor_position) { + app.selection_anchor = None; + } + true + } + _ => false, + } +} + pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec { if app.view_stack.top_kind() == Some(ModalKind::ContextMenu) { if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) { @@ -52,6 +140,11 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec { // Update last mouse position for tooltip rendering. @@ -585,6 +678,10 @@ pub(crate) fn selection_point_from_position( } pub(crate) fn selection_has_content(app: &App) -> bool { + // Composer selection takes priority (same as Cmd+C handler above). + if !app.selected_text().is_empty() { + return true; + } selection_to_text(app).is_some_and(|text| !text.is_empty()) } @@ -613,6 +710,17 @@ pub(crate) fn ctrl_c_disposition(app: &App) -> CtrlCDisposition { } pub(crate) fn copy_active_selection(app: &mut App) { + // Composer selection takes priority. + let sel = app.selected_text(); + if !sel.is_empty() { + if app.clipboard.write_text(&sel).is_ok() { + app.status_message = Some("Selection copied".to_string()); + } else { + app.status_message = Some("Copy failed".to_string()); + } + app.clear_selection(); + return; + } if !app.viewport.transcript_selection.is_active() { return; } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 2ba52ef93..9abd0a065 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2969,7 +2969,17 @@ async fn run_event_loop( KeyCode::Char('c') | KeyCode::Char('C') if key_shortcuts::is_copy_shortcut(&key) => { - copy_active_selection(app); + let sel = app.selected_text(); + if !sel.is_empty() { + if app.clipboard.write_text(&sel).is_ok() { + app.push_status_toast("Copied to clipboard", StatusToastLevel::Info, None); + app.clear_selection(); + } else { + app.push_status_toast("Copy failed", StatusToastLevel::Error, None); + } + } else { + copy_active_selection(app); + } } KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { // Four behaviors layered on Ctrl+C in priority order — see @@ -3482,19 +3492,52 @@ async fn run_event_loop( app.delete_char_forward(); } KeyCode::Delete => {} + KeyCode::Left if key.modifiers.contains(KeyModifiers::SHIFT) + && is_word_cursor_modifier(key.modifiers) => + { + if app.selection_anchor.is_none() { + app.selection_anchor = Some(app.cursor_position); + } + app.move_cursor_word_backward(); + } + KeyCode::Left if key.modifiers.contains(KeyModifiers::SHIFT) => { + if app.selection_anchor.is_none() { + app.selection_anchor = Some(app.cursor_position); + } + app.move_cursor_left(); + } KeyCode::Left if is_word_cursor_modifier(key.modifiers) => { + app.clear_selection(); app.move_cursor_word_backward(); } KeyCode::Left => { + app.clear_selection(); app.move_cursor_left(); } + KeyCode::Right if key.modifiers.contains(KeyModifiers::SHIFT) + && is_word_cursor_modifier(key.modifiers) => + { + if app.selection_anchor.is_none() { + app.selection_anchor = Some(app.cursor_position); + } + app.move_cursor_word_forward(); + } + KeyCode::Right if key.modifiers.contains(KeyModifiers::SHIFT) => { + if app.selection_anchor.is_none() { + app.selection_anchor = Some(app.cursor_position); + } + app.move_cursor_right(); + } KeyCode::Right if is_word_cursor_modifier(key.modifiers) => { + app.clear_selection(); app.move_cursor_word_forward(); } KeyCode::Right => { + app.clear_selection(); app.move_cursor_right(); } KeyCode::Home if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.clear_selection(); if let Some(anchor) = TranscriptScroll::anchor_for(app.viewport.transcript_cache.line_meta(), 0) { @@ -3502,20 +3545,25 @@ async fn run_event_loop( } } KeyCode::End if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.clear_selection(); app.scroll_to_bottom(); } KeyCode::Home | KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.clear_selection(); app.move_cursor_start(); } KeyCode::Home => { + app.clear_selection(); app.move_cursor_line_start(); } KeyCode::End => { + app.clear_selection(); app.move_cursor_line_end(); } KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.clear_selection(); app.move_cursor_end(); } KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -3618,12 +3666,22 @@ async fn run_event_loop( } } KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => { - let new_mode = match app.mode { - AppMode::Plan => AppMode::Agent, - AppMode::Agent => AppMode::Yolo, - AppMode::Yolo => AppMode::Plan, - }; - app.set_mode(new_mode); + let sel = app.selected_text(); + if !sel.is_empty() { + if app.clipboard.write_text(&sel).is_ok() { + app.push_status_toast("Cut to clipboard", StatusToastLevel::Info, None); + app.delete_selection(); + } else { + app.push_status_toast("Cut failed", StatusToastLevel::Error, None); + } + } else { + let new_mode = match app.mode { + AppMode::Plan => AppMode::Agent, + AppMode::Agent => AppMode::Yolo, + AppMode::Yolo => AppMode::Plan, + }; + app.set_mode(new_mode); + } } _ if key_shortcuts::is_paste_shortcut(&key) => { app.paste_from_clipboard(); @@ -5826,6 +5884,47 @@ fn render(f: &mut Frame, app: &mut App) { composer_widget.render(chunks[3], buf); composer_widget.cursor_pos(chunks[3]) }; + app.viewport.last_composer_area = Some(chunks[3]); + { + let area = chunks[3]; + let has_panel = app.composer_border && area.height >= 3 && area.width >= 12; + let inner = if has_panel { + ratatui::widgets::Block::default() + .borders(ratatui::widgets::Borders::ALL) + .inner(area) + } else { + area + }; + app.viewport.last_composer_content = Some(inner); + + // Compute scroll offset and top padding for mouse coordinate mapping. + let input_text = app.composer_display_input(); + let input_cursor = app.composer_display_cursor(); + let content_width = usize::from(inner.width.max(1)); + let menu_lines = ComposerWidget::new( + app, + composer_max_height, + &slash_menu_entries, + &mention_menu_entries, + ) + .active_menu_reserved_rows(); + let budget = crate::tui::widgets::composer_input_rows_budget(inner.height, menu_lines); + let (_, _, _, scroll_offset) = crate::tui::widgets::layout_input_with_scroll( + input_text, + input_cursor, + content_width, + budget, + ); + let visible_lines = if input_text.is_empty() { + 1 + } else { + // Count wrapped lines (approximation matching the render path). + crate::tui::widgets::wrap_input_lines_for_mouse(input_text, content_width).len() + }; + let top_padding = budget.saturating_sub(visible_lines.clamp(1, budget)); + app.viewport.last_composer_scroll_offset = scroll_offset; + app.viewport.last_composer_top_padding = top_padding; + } if let Some(cursor_pos) = cursor_pos { f.set_cursor_position(cursor_pos); } diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index f1e395e6e..cbeaafb3a 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -474,7 +474,7 @@ impl<'a> ComposerWidget<'a> { /// backend's per-cell write cost makes the layout jitter visible /// even though the work is tiny on Unix terminals. See user /// feedback in v0.8.8 polish thread. - fn active_menu_reserved_rows(&self) -> usize { + pub fn active_menu_reserved_rows(&self) -> usize { let actual = self.active_menu_row_count(); if actual == 0 { return 0; @@ -535,8 +535,8 @@ impl Renderable for ComposerWidget<'_> { let input_rows_budget = composer_input_rows_budget(inner_area.height, menu_lines_for_budget); let content_width = usize::from(inner_area.width.max(1)); - let (visible_lines, _cursor_row, _cursor_col) = - layout_input(input_text, input_cursor, content_width, input_rows_budget); + let (visible_lines, _cursor_row, _cursor_col, scroll_offset) = + layout_input_with_scroll(input_text, input_cursor, content_width, input_rows_budget); let is_draft_mode = input_text.contains('\n') || visible_lines.len() > 1; if has_panel { let border_color = if input_text.trim().is_empty() { @@ -666,6 +666,25 @@ impl Renderable for ComposerWidget<'_> { placeholder, Style::default().fg(palette::TEXT_MUTED).italic(), ))); + } else if let Some((sel_start, sel_end)) = self.app.selection_range() { + let line_ranges = visible_line_char_ranges( + input_text, + &visible_lines, + content_width, + scroll_offset, + ); + for (line_text, (line_start, line_end)) in visible_lines.iter().zip(line_ranges.iter()) + { + let spans = line_spans_with_selection( + line_text, + *line_start, + *line_end, + sel_start, + sel_end, + self.app.ui_theme.selection_bg, + ); + input_lines.push(Line::from(spans)); + } } else { for line in &visible_lines { input_lines.push(Line::from(Span::styled( @@ -1938,7 +1957,7 @@ fn build_empty_state_lines(app: &App, area: Rect) -> Vec> { lines } -fn composer_input_rows_budget(inner_height: u16, extra_lines: usize) -> usize { +pub fn composer_input_rows_budget(inner_height: u16, extra_lines: usize) -> usize { usize::from(inner_height).saturating_sub(extra_lines).max(1) } @@ -2251,6 +2270,17 @@ fn layout_input( width: usize, max_height: usize, ) -> (Vec, usize, usize) { + let (visible, visible_cursor_row, visible_cursor_col, _) = + layout_input_with_scroll(input, cursor, width, max_height); + (visible, visible_cursor_row, visible_cursor_col) +} + +pub fn layout_input_with_scroll( + input: &str, + cursor: usize, + width: usize, + max_height: usize, +) -> (Vec, usize, usize, usize) { let mut lines = wrap_input_lines(input, width); if lines.is_empty() { lines.push(String::new()); @@ -2276,6 +2306,7 @@ fn layout_input( visible, visible_cursor_row, cursor_col.min(width.saturating_sub(1)), + start, ) } @@ -2342,6 +2373,34 @@ fn wrap_input_lines(input: &str, width: usize) -> Vec { lines } +/// For mouse coordinate mapping: returns (char_start_of_line, line_text) pairs +/// matching the wrapping produced by `wrap_input_lines`. +pub fn wrap_input_lines_for_mouse(input: &str, width: usize) -> Vec<(usize, String)> { + if input.is_empty() || width == 0 { + return vec![(0, String::new())]; + } + + let mut result = Vec::new(); + let mut char_idx = 0usize; + + for raw_line in input.split('\n') { + if raw_line.is_empty() { + result.push((char_idx, String::new())); + char_idx += 1; // the '\n' + continue; + } + let wrapped = wrap_text(raw_line, width); + for wrapped_line in &wrapped { + let line_char_len: usize = wrapped_line.chars().count(); + result.push((char_idx, wrapped_line.clone())); + char_idx += line_char_len; + } + char_idx += 1; // the '\n' + } + + result +} + fn wrap_text(text: &str, width: usize) -> Vec { if width == 0 { return vec![text.to_string()]; @@ -2383,6 +2442,108 @@ fn wrap_text(text: &str, width: usize) -> Vec { lines } +/// Compute the (char_start, char_end) range for each visible wrapped line. +/// `char_start` is inclusive, `char_end` is exclusive. +/// `scroll_offset` is the number of wrapped lines skipped from the top. +fn visible_line_char_ranges( + input: &str, + visible_lines: &[String], + width: usize, + scroll_offset: usize, +) -> Vec<(usize, usize)> { + if input.is_empty() || width == 0 { + return vec![(0, 0); visible_lines.len()]; + } + + let mut ranges = Vec::new(); + let mut char_idx = 0usize; + let mut line_start = 0usize; + let mut line_width = 0usize; + + for g in input.graphemes(true) { + if g == "\n" { + ranges.push((line_start, char_idx)); + char_idx += 1; + line_start = char_idx; + line_width = 0; + continue; + } + + let gw = g.width(); + if line_width + gw > width && line_width > 0 { + ranges.push((line_start, char_idx)); + line_start = char_idx; + line_width = 0; + } + char_idx += g.chars().count(); + line_width += gw; + if line_width >= width { + ranges.push((line_start, char_idx)); + line_start = char_idx; + line_width = 0; + } + } + ranges.push((line_start, char_idx)); + + // Use the actual scroll_offset to align with visible_lines. + let start = scroll_offset.min(ranges.len()); + ranges + .into_iter() + .skip(start) + .take(visible_lines.len()) + .collect() +} + +fn line_spans_with_selection<'a>( + line: &'a str, + line_start: usize, + line_end: usize, + sel_start: usize, + sel_end: usize, + highlight_bg: Color, +) -> Vec> { + let normal_style = Style::default().fg(palette::TEXT_PRIMARY); + let sel_style = Style::default().fg(palette::TEXT_PRIMARY).bg(highlight_bg); + + // No overlap between this line and the selection + if line_end <= sel_start || line_start >= sel_end { + return vec![Span::styled(line, normal_style)]; + } + + let local_sel_start = sel_start.saturating_sub(line_start); + let local_sel_end = sel_end.min(line_end).saturating_sub(line_start); + + // Build a Vec of byte offsets for each char boundary, plus one past the end. + let mut byte_offsets: Vec = line.char_indices().map(|(i, _)| i).collect(); + byte_offsets.push(line.len()); + + let b0 = byte_offsets + .get(local_sel_start) + .copied() + .unwrap_or(line.len()); + let b1 = byte_offsets + .get(local_sel_end) + .copied() + .unwrap_or(line.len()); + + let mut spans = Vec::with_capacity(3); + + // Text before selection + if b0 > 0 { + spans.push(Span::styled(&line[..b0], normal_style)); + } + // Selected text + if b1 > b0 { + spans.push(Span::styled(&line[b0..b1], sel_style)); + } + // Text after selection + if b1 < line.len() { + spans.push(Span::styled(&line[b1..], normal_style)); + } + + spans +} + #[cfg(test)] mod tests { use super::{