feat(composer): mouse + keyboard text selection with copy/cut

Add mouse drag selection and Shift+Arrow text selection in the composer
input box. Ctrl+C copies selected text; Ctrl+X cuts (or toggles mode if
no selection). Selection highlighting uses the theme's selection_bg color.
Mouse coordinate mapping accounts for wrapping, scroll offset, and padding.

Also fix Home, End, Ctrl+A, and Ctrl+E to clear the selection anchor
before jumping, matching the existing Left/Right behavior. Without these
calls a stale anchor silently reforms a selection and can cause unintended
deletions on the next keystroke.

Harvested from #2228.

Co-authored-by: imkingjh999 <imkingjh999@users.noreply.github.com>
This commit is contained in:
Hunter Bown
2026-05-26 13:14:55 -05:00
parent a06bbe57a6
commit 84463711b4
5 changed files with 575 additions and 12 deletions
+13
View File
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Composer text selection with copy/cut.** Mouse drag and Shift+Arrow
selection in the composer input box, with Ctrl+C copy and Ctrl+X cut
support. Home, End, Ctrl+A, and Ctrl+E now clear the selection to prevent
accidental deletions on the next keystroke (#2228).
### Fixed
- **Deadlock when spawning multiple concurrent sub-agents.** Replaced
`RwLock`-based serialisation with a `Semaphore(1)` in `ToolCallRuntime`,
preventing re-entrant tool calls from deadlocking on the same lock (#1856).
## [0.8.46] - 2026-05-26
### Added
+202 -1
View File
@@ -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<usize>,
}
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<SelectionAutoscroll>,
pub transcript_scrollbar_dragging: bool,
pub last_transcript_area: Option<Rect>,
pub last_composer_area: Option<Rect>,
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<Rect>,
/// Inner content rect of the composer (excluding border/padding),
/// stored at render time for mouse coordinate mapping.
pub last_composer_content: Option<Rect>,
/// 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);
}
}
+107
View File
@@ -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,91 @@ 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<usize> {
let rel_col = col.saturating_sub(inner.x) as usize;
let rel_row = row.saturating_sub(inner.y) as usize;
if app.input.is_empty() {
return Some(0);
}
let width = inner.width.max(1) as usize;
let wrapped = crate::tui::widgets::wrap_input_lines_for_mouse(&app.input, 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(app.input.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<ViewEvent> {
if app.view_stack.top_kind() == Some(ModalKind::ContextMenu) {
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) {
@@ -52,6 +139,11 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEv
return app.view_stack.handle_mouse(mouse);
}
// Composer mouse events take priority over transcript.
if handle_composer_mouse(app, mouse) {
return Vec::new();
}
match mouse.kind {
MouseEventKind::Moved => {
// Update last mouse position for tooltip rendering.
@@ -585,6 +677,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 +709,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;
}
+88 -7
View File
@@ -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,16 +3492,32 @@ async fn run_event_loop(
app.delete_char_forward();
}
KeyCode::Delete => {}
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) => {
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) => {
@@ -3507,15 +3533,19 @@ async fn run_event_loop(
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 +3648,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 +5866,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);
}
+165 -4
View File
@@ -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(
&self.app.input,
&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<Line<'static>> {
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<String>, 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<String>, 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<String> {
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<String> {
if width == 0 {
return vec![text.to_string()];
@@ -2383,6 +2442,108 @@ fn wrap_text(text: &str, width: usize) -> Vec<String> {
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<Span<'a>> {
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<usize> = 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::{