Add right-click copying for transcript and composer selections (#46895)

## What changed

Allow right-clicking inside the transcript or composer to copy its selected text using the existing clipboard status messages. Clear the selection only when the right-click copy is confirmed; retain it after failed or unconfirmed copies. Keyboard copies continue to retain composer selections.

Preserve the draft, cursor, and transcript reading position. Refresh composer layout before checking mouse coordinates, and respect modal and transcript input ownership.

## Testing

Add regression tests and snapshots covering copy outcomes, selection retention, reading position, editor bounds after layout changes, and input ownership.

GitOrigin-RevId: 322b303394378a20b2a25b3d218777c9ea65c92b
This commit is contained in:
Felipe Coury
2026-09-20 22:11:51 +00:00
committed by copyberry
parent 9fdff739ea
commit 2db0ab7af1
13 changed files with 391 additions and 40 deletions
+10
View File
@@ -202,6 +202,12 @@ impl TranscriptView {
match event.kind {
MouseEventKind::ScrollUp => self.scroll(cells, /*rows*/ -3),
MouseEventKind::ScrollDown => self.scroll(cells, /*rows*/ 3),
MouseEventKind::Down(MouseButton::Right) if inside => {
return self
.selected_text(cells)
.filter(|text| !text.is_empty())
.map(ViewAction::Copy);
}
MouseEventKind::Down(MouseButton::Left) => return self.pointer_down(event, cells),
MouseEventKind::Drag(MouseButton::Left) if dragging => {
self.extend_selection(event.column, event.row);
@@ -360,3 +366,7 @@ impl TranscriptView {
#[cfg(test)]
#[path = "input_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "right_click_copy_tests.rs"]
mod right_click_copy_tests;
@@ -0,0 +1,102 @@
//! Right-click copying uses existing delivery semantics without moving the reading position.
use super::*;
use crate::clipboard_copy::CopyStatus;
use crate::transcript_view::tests::cell;
use crate::transcript_view::tests::render;
use crate::transcript_view::tests::text;
use pretty_assertions::assert_eq;
#[test]
fn right_click_retains_selection_until_confirmed_and_preserves_reading_position() {
let cells = vec![cell("selected text\nsecond line\nthird line\nlatest line")];
let mut view = TranscriptView::default();
render(&mut view, &cells, /*width*/ 40, /*height*/ 3);
view.scroll(&cells, /*rows*/ -1);
render(&mut view, &cells, /*width*/ 40, /*height*/ 3);
view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3);
view.end_drag();
let position = view.position;
let click = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Right),
column: 4,
row: 0,
modifiers: KeyModifiers::NONE,
};
let mut frames = Vec::new();
for result in [
Err("clipboard unavailable".to_owned()),
Ok(CopyStatus::Unconfirmed),
Ok(CopyStatus::Confirmed),
] {
let Some(ViewAction::Copy(selected)) = view.handle_mouse(click, &cells) else {
panic!("right-click must request a copy without following new output");
};
assert_eq!(selected, "selected text\n");
let copied = view.copy_selected_text_with(&cells, &selected, |copied| {
assert_eq!(copied, selected);
result.clone()
});
assert_eq!(copied, result);
assert_eq!(
(view.selected_text(&cells), view.position),
(
(result != Ok(CopyStatus::Confirmed)).then_some(selected.clone()),
position,
)
);
view.show_copy_feedback(&copied, selected.chars().count());
let mut buffer = Buffer::empty(Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 4,
));
view.render(
Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 3,
),
&mut buffer,
&cells,
);
view.render_composer_gap(
Some(Rect::new(
/*x*/ 0, /*y*/ 3, /*width*/ 40, /*height*/ 1,
)),
/*hint*/ None,
&mut buffer,
);
frames.push(format!("{result:?}\n{}", text(&buffer)));
}
insta::assert_snapshot!(frames.join("\n\n"));
assert!(view.handle_mouse(click, &cells).is_none());
}
#[test]
fn right_click_requires_a_nonempty_selection_and_a_press_inside_the_transcript() {
let cells = vec![cell("selected text")];
let mut view = TranscriptView::default();
render(&mut view, &cells, /*width*/ 40, /*height*/ 3);
let click = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Right),
column: 4,
row: 0,
modifiers: KeyModifiers::NONE,
};
assert!(view.handle_mouse(click, &cells).is_none());
view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 1);
assert!(view.handle_mouse(click, &cells).is_none());
view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3);
// Outside presses must stay ignored even while an active drag owns mouse events.
for event in [
MouseEvent {
column: 40,
..click
},
MouseEvent { row: 3, ..click },
MouseEvent {
kind: MouseEventKind::Up(MouseButton::Right),
..click
},
] {
assert!(view.handle_mouse(event, &cells).is_none());
assert_eq!(view.selected_text(&cells).as_deref(), Some("selected text"));
}
}
@@ -0,0 +1,21 @@
---
source: tui/src/transcript_view/right_click_copy_tests.rs
expression: "frames.join(\"\\n\\n\")"
---
Err("clipboard unavailable")
selected text
second line
third line
Copy failed · try again
Ok(Unconfirmed)
selected text
second line
third line
Copy sent to terminal · paste to verify
Ok(Confirmed)
selected text
second line
third line
Copied 14 chars to host clipboard