fix(composer): use wrap_input_lines_for_mouse, clamp selection to input length

- Replace visible_line_char_ranges with wrap_input_lines_for_mouse for accurate mouse selection
- Clamp selection_anchor and cursor_position to char_count
- Clear selection on history navigation to prevent stale highlights
- Add test for history-navigation-clears-stale-selection
This commit is contained in:
Hunter Bown
2026-05-26 16:39:39 -05:00
parent 74878dcd30
commit 92c8dbc7ce
2 changed files with 29 additions and 60 deletions
+22 -2
View File
@@ -3774,8 +3774,9 @@ impl App {
/// 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;
let total = char_count(&self.input);
let anchor = self.selection_anchor?.min(total);
let cursor = self.cursor_position.min(total);
if anchor == cursor {
return None;
}
@@ -4510,6 +4511,7 @@ impl App {
self.history_index = Some(new_index);
self.input = self.input_history[new_index].clone();
self.cursor_position = char_count(&self.input);
self.selection_anchor = None;
self.selected_attachment_index = None;
self.slash_menu_hidden = false;
self.paste_burst.clear_after_explicit_paste();
@@ -4526,6 +4528,7 @@ impl App {
self.history_index = Some(i + 1);
self.input = self.input_history[i + 1].clone();
self.cursor_position = char_count(&self.input);
self.selection_anchor = None;
self.selected_attachment_index = None;
self.slash_menu_hidden = false;
self.paste_burst.clear_after_explicit_paste();
@@ -4534,6 +4537,7 @@ impl App {
if let Some(draft) = self.history_navigation_draft.take() {
self.input = draft.input;
self.cursor_position = draft.cursor.min(char_count(&self.input));
self.selection_anchor = None;
self.selected_attachment_index = None;
self.slash_menu_hidden = false;
self.paste_burst.clear_after_explicit_paste();
@@ -5914,6 +5918,22 @@ mod tests {
assert!(app.history_index.is_none());
}
#[test]
fn input_history_navigation_clears_stale_selection() {
let mut app = App::new(test_options(false), &Config::default());
app.input_history.push("previous input".to_string());
app.input = "hello world".to_string();
app.cursor_position = "hello ".chars().count();
app.selection_anchor = Some(app.input.chars().count());
app.history_up();
assert_eq!(app.input, "previous input");
assert!(app.selection_anchor.is_none());
app.insert_char('x');
assert_eq!(app.input, "previous inputx");
}
#[test]
fn input_history_restores_empty_draft_at_end_of_navigation() {
let mut app = App::new(test_options(false), &Config::default());
+7 -58
View File
@@ -667,12 +667,13 @@ impl Renderable for ComposerWidget<'_> {
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,
);
let line_ranges: Vec<(usize, usize)> =
wrap_input_lines_for_mouse(&self.app.input, content_width)
.into_iter()
.skip(scroll_offset)
.take(visible_lines.len())
.map(|(start, text)| (start, start + text.chars().count()))
.collect();
for (line_text, (line_start, line_end)) in visible_lines.iter().zip(line_ranges.iter())
{
let spans = line_spans_with_selection(
@@ -2443,58 +2444,6 @@ 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,