mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
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:
@@ -858,7 +858,9 @@ impl App {
|
||||
app_server: &mut AppServerSession,
|
||||
event: TuiEvent,
|
||||
) -> Result<AppRunControl> {
|
||||
if self.handle_composer_copy_event(tui, &event, tui::Tui::copy_transcript_selection) {
|
||||
if matches!(&event, TuiEvent::Key(_))
|
||||
&& self.handle_composer_copy_event(tui, &event, tui::Tui::copy_transcript_selection)
|
||||
{
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
// Resume arrives after suspension; retain the last painted phase across hidden owners.
|
||||
|
||||
@@ -14,24 +14,22 @@ use crate::transcript_view::ViewAction;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
impl App {
|
||||
/// Copy draft selections before interrupt/exit shortcuts and configurable chords can consume C.
|
||||
/// Copy draft selections before key shortcuts, or after mouse layout has been refreshed.
|
||||
pub(super) fn handle_composer_copy_event(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
event: &TuiEvent,
|
||||
copy: impl FnOnce(&mut tui::Tui, &str) -> Result<crate::clipboard_copy::CopyStatus, String>,
|
||||
) -> bool {
|
||||
if let TuiEvent::Key(key) = event
|
||||
&& tui.is_owned_screen()
|
||||
if tui.is_owned_screen()
|
||||
&& self.overlay.is_none()
|
||||
&& !self.transcript_view.has_active_interaction()
|
||||
&& let Some(text) = self.chat_widget.composer_selection_for_copy(*key)
|
||||
&& let Some((char_count, result)) = self
|
||||
.chat_widget
|
||||
.copy_composer_selection(event, |text| copy(tui, text))
|
||||
{
|
||||
self.cancel_pending_key_chord();
|
||||
self.chat_widget.end_composer_drag();
|
||||
let result = copy(tui, &text);
|
||||
self.transcript_view
|
||||
.show_copy_feedback(&result, text.chars().count());
|
||||
self.transcript_view.show_copy_feedback(&result, char_count);
|
||||
tui.frame_requester().schedule_frame();
|
||||
return true;
|
||||
}
|
||||
@@ -255,6 +253,11 @@ impl App {
|
||||
let size = tui.prepare_draw_size()?;
|
||||
self.render_owned_transcript(tui, size)?;
|
||||
}
|
||||
if composer_ready
|
||||
&& self.handle_composer_copy_event(tui, event, tui::Tui::copy_transcript_selection)
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
if composer_ready && self.chat_widget.handle_composer_mouse(*mouse) {
|
||||
self.transcript_view.end_selection(&self.transcript_cells);
|
||||
self.transcript_view.cancel_search();
|
||||
|
||||
@@ -12,6 +12,7 @@ use codex_app_server_protocol::AskForApproval;
|
||||
use codex_config::types::ApprovalsReviewer;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use crossterm::event::MouseButton::Left;
|
||||
use crossterm::event::MouseButton::Right;
|
||||
use crossterm::event::MouseEvent;
|
||||
use crossterm::event::MouseEventKind;
|
||||
use crossterm::event::MouseEventKind::Down;
|
||||
@@ -1172,21 +1173,97 @@ async fn fullscreen_composer_mouse_copy_and_input_ownership() -> Result<()> {
|
||||
assert!(app.handle_owned_transcript_event(&mut tui, &mut server, &event)?);
|
||||
}
|
||||
assert!(!app.transcript_view.has_active_interaction());
|
||||
app.render_owned_transcript(&mut tui, size)?;
|
||||
let cursor = tui.terminal.last_known_cursor_pos;
|
||||
let draft = app.chat_widget.capture_thread_input_state();
|
||||
let copy_event = TuiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SUPER));
|
||||
assert!(
|
||||
app.handle_composer_copy_event(&mut tui, ©_event, |_, text| {
|
||||
assert_eq!(text, "hello");
|
||||
Ok(crate::clipboard_copy::CopyStatus::Confirmed)
|
||||
})
|
||||
);
|
||||
for event in [
|
||||
mouse(Up(Right), x + 2, y),
|
||||
mouse(Down(Right), /*column*/ 0, y),
|
||||
mouse(Down(Right), size.width, y),
|
||||
mouse(Down(Right), x, y - 1),
|
||||
mouse(Down(Right), x, y + 1),
|
||||
] {
|
||||
assert!(!app.handle_composer_copy_event(&mut tui, &event, |_, _| unreachable!()));
|
||||
}
|
||||
let copy_events = [
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SUPER)),
|
||||
mouse(Down(Right), x + 2, y),
|
||||
];
|
||||
let mut selection_frames = Vec::new();
|
||||
for event in ©_events {
|
||||
for result in [
|
||||
Err("clipboard unavailable".to_string()),
|
||||
Ok(crate::clipboard_copy::CopyStatus::Unconfirmed),
|
||||
Ok(crate::clipboard_copy::CopyStatus::Confirmed),
|
||||
] {
|
||||
assert!(app.handle_composer_copy_event(&mut tui, event, |_, text| {
|
||||
assert_eq!(text, "hello");
|
||||
result.clone()
|
||||
}));
|
||||
app.render_owned_transcript(&mut tui, size)?;
|
||||
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
|
||||
let rendered_draft = (x..x + 11)
|
||||
.map(|column| buffer[(column, y)].symbol())
|
||||
.collect::<String>();
|
||||
let selection = (x..x + 11)
|
||||
.map(|column| {
|
||||
if buffer[(column, y)]
|
||||
.modifier
|
||||
.contains(ratatui::style::Modifier::REVERSED)
|
||||
{
|
||||
'^'
|
||||
} else {
|
||||
'·'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
let cleared = matches!(event, TuiEvent::Mouse(_))
|
||||
&& result == Ok(crate::clipboard_copy::CopyStatus::Confirmed);
|
||||
assert_eq!(
|
||||
(
|
||||
app.chat_widget.capture_thread_input_state(),
|
||||
tui.terminal.last_known_cursor_pos,
|
||||
selection.as_str(),
|
||||
),
|
||||
(
|
||||
draft.clone(),
|
||||
cursor,
|
||||
if cleared {
|
||||
"···········"
|
||||
} else {
|
||||
"^^^^^······"
|
||||
},
|
||||
)
|
||||
);
|
||||
let gesture = if matches!(event, TuiEvent::Mouse(_)) {
|
||||
"right-click"
|
||||
} else {
|
||||
"keyboard"
|
||||
};
|
||||
selection_frames.push(format!(
|
||||
"{gesture} {result:?}\n{rendered_draft}\n{selection}"
|
||||
));
|
||||
}
|
||||
}
|
||||
assert_eq!(app.chat_widget.capture_thread_input_state(), draft);
|
||||
app.render_owned_transcript(&mut tui, size)?;
|
||||
assert!(row_containing(&tui, "Copied 5 chars to host clipboard") < y);
|
||||
insta::assert_snapshot!(
|
||||
"fullscreen_composer_right_click_copy",
|
||||
format!(
|
||||
"{}\n\n{}",
|
||||
selection_frames.join("\n\n"),
|
||||
normalize_snapshot_paths(buffer_text(
|
||||
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal)
|
||||
))
|
||||
)
|
||||
);
|
||||
|
||||
app.chat_widget.handle_key_event(KeyCode::Char('x').into());
|
||||
assert!(!app.handle_composer_copy_event(&mut tui, ©_event, |_, _| unreachable!()));
|
||||
assert_eq!(app.chat_widget.composer_text_with_pending(), "x world");
|
||||
for event in ©_events {
|
||||
assert!(!app.handle_composer_copy_event(&mut tui, event, |_, _| unreachable!()));
|
||||
}
|
||||
assert_eq!(app.chat_widget.composer_text_with_pending(), "hellox world");
|
||||
|
||||
// A transcript drag that crosses into the composer remains a transcript selection.
|
||||
let transcript_y = row_containing(&tui, "transcript text");
|
||||
@@ -1198,6 +1275,9 @@ async fn fullscreen_composer_mouse_copy_and_input_ownership() -> Result<()> {
|
||||
app.handle_owned_transcript_event(&mut tui, &mut server, &event)?;
|
||||
}
|
||||
assert!(app.transcript_view.has_active_interaction());
|
||||
for event in ©_events {
|
||||
assert!(!app.handle_composer_copy_event(&mut tui, event, |_, _| unreachable!()));
|
||||
}
|
||||
|
||||
// A fresh composer click takes ownership away from the transcript selection.
|
||||
app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Down(Left), x, y))?;
|
||||
@@ -1208,11 +1288,13 @@ async fn fullscreen_composer_mouse_copy_and_input_ownership() -> Result<()> {
|
||||
app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Drag(Left), x + 1, y))?;
|
||||
assert!(app.chat_widget.no_modal_or_popup_active());
|
||||
app.chat_widget.open_feature_enable_prompt(Feature::Collab);
|
||||
assert!(
|
||||
!app.handle_composer_copy_event(&mut tui, ©_event, |_, _| panic!(
|
||||
"modals own copy input"
|
||||
),)
|
||||
);
|
||||
for event in ©_events {
|
||||
assert!(
|
||||
!app.handle_composer_copy_event(&mut tui, event, |_, _| panic!(
|
||||
"modals own copy input"
|
||||
))
|
||||
);
|
||||
}
|
||||
assert!(!app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Down(Left), x, y))?);
|
||||
server.shutdown().await?;
|
||||
tui.set_owned_screen(/*owned*/ false)?;
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
---
|
||||
source: tui/src/app/owned_transcript_tests.rs
|
||||
assertion_line: 1245
|
||||
expression: "format!(\"{}\\n\\n{}\", selection_frames.join(\"\\n\\n\"),\nnormalize_snapshot_paths(buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal))))"
|
||||
---
|
||||
keyboard Err("clipboard unavailable")
|
||||
hello world
|
||||
^^^^^······
|
||||
|
||||
keyboard Ok(Unconfirmed)
|
||||
hello world
|
||||
^^^^^······
|
||||
|
||||
keyboard Ok(Confirmed)
|
||||
hello world
|
||||
^^^^^······
|
||||
|
||||
right-click Err("clipboard unavailable")
|
||||
hello world
|
||||
^^^^^······
|
||||
|
||||
right-click Ok(Unconfirmed)
|
||||
hello world
|
||||
^^^^^······
|
||||
|
||||
right-click Ok(Confirmed)
|
||||
hello world
|
||||
···········
|
||||
|
||||
|
||||
› transcript text
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Copied 5 chars to host clipboard
|
||||
|
||||
› hello world
|
||||
|
||||
GPT-5.6-Sol default · /tmp/project
|
||||
@@ -1,9 +1,8 @@
|
||||
//! The chat composer is the bottom-pane text input state machine.
|
||||
//!
|
||||
//! It edits the [`TextArea`] buffer and attachment elements, routes popup keys, promotes
|
||||
//! completed slash commands to atomic elements, and handles Enter submission/newlines.
|
||||
//! It also shows Luna Reserve's yellow prompt arrow and detects unbracketed paste bursts
|
||||
//! from raw key streams, particularly on Windows.
|
||||
//! It edits [`TextArea`] and attachments, routes popup keys, makes completed slash commands atomic,
|
||||
//! and handles Enter/newlines. It shows Luna Reserve's yellow arrow and detects unbracketed paste
|
||||
//! bursts, especially on Windows. Copy shortcuts and right clicks preserve selected draft text.
|
||||
//! The live voice strip renders after effort ignition, followed by the Astra sparkle when eligible.
|
||||
//! Owned transcripts keep persistent status below the composer and hints on a separate final row.
|
||||
//! Shortcut help expands above the composer, with its close hint replacing the final shortcuts row
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
//! Mouse gestures flush pending typing before layout and hit testing. Left dragging selects
|
||||
//! editable text using the textarea's last rendered viewport and hides completion suggestions.
|
||||
//! Double/triple clicks select words/logical lines using the transcript's shared gesture rules.
|
||||
//! Copy shortcuts preserve the draft and selection and use fullscreen copy feedback above the input.
|
||||
//! Copy preserves the draft; confirmed right-click copies clear selection, keyboard copies retain it.
|
||||
|
||||
use super::*;
|
||||
use crate::clipboard_copy::CopyStatus;
|
||||
use crate::tui::TuiEvent;
|
||||
use crossterm::event::MouseButton;
|
||||
use crossterm::event::MouseEvent;
|
||||
use crossterm::event::MouseEventKind;
|
||||
|
||||
impl ChatComposer {
|
||||
pub(crate) fn selection_for_copy(&mut self, key: KeyEvent) -> Option<String> {
|
||||
if !crate::text_selection::is_copy_key(key)
|
||||
pub(crate) fn copy_selection(
|
||||
&mut self,
|
||||
event: &TuiEvent,
|
||||
copy: impl FnOnce(&str) -> Result<CopyStatus, String>,
|
||||
) -> Option<(usize, Result<CopyStatus, String>)> {
|
||||
let copy_requested = matches!(event, TuiEvent::Key(key) if crate::text_selection::is_copy_key(*key))
|
||||
|| matches!(event, TuiEvent::Mouse(mouse)
|
||||
if mouse.kind == MouseEventKind::Down(MouseButton::Right)
|
||||
&& self.draft.textarea.contains_mouse(*mouse));
|
||||
if !copy_requested
|
||||
|| !self.draft.input_enabled
|
||||
|| self.history_search.is_some()
|
||||
|| self.draft.textarea.vim_query().is_some()
|
||||
@@ -20,10 +31,15 @@ impl ChatComposer {
|
||||
self.apply_paste(pasted);
|
||||
}
|
||||
self.draft.paste_burst.clear_window_after_non_char();
|
||||
self.draft
|
||||
.textarea
|
||||
.mouse_selection_range()
|
||||
.map(|range| self.draft.textarea.text()[range].to_owned())
|
||||
let range = self.draft.textarea.mouse_selection_range()?;
|
||||
self.end_mouse_drag();
|
||||
let text = &self.draft.textarea.text()[range];
|
||||
let char_count = text.chars().count();
|
||||
let result = copy(text);
|
||||
if matches!(event, TuiEvent::Mouse(_)) && result == Ok(CopyStatus::Confirmed) {
|
||||
self.draft.textarea.set_cursor(self.draft.textarea.cursor());
|
||||
}
|
||||
Some((char_count, result))
|
||||
}
|
||||
|
||||
pub(crate) fn end_mouse_drag(&mut self) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use super::super::tests::new_test_composer;
|
||||
use super::*;
|
||||
use crossterm::event::MouseButton::Left;
|
||||
use crossterm::event::MouseButton::Right;
|
||||
use crossterm::event::MouseEventKind::Down;
|
||||
use crossterm::event::MouseEventKind::Up;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -105,3 +106,53 @@ fn selected_text_navigation_does_not_recall_history_or_select_images() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_click_copy_uses_the_refreshed_editor_bounds() {
|
||||
let (mut composer, _rx) = new_test_composer();
|
||||
composer.insert_str("hello world");
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 4,
|
||||
);
|
||||
composer.render(area, &mut Buffer::empty(area));
|
||||
let (x, y) = composer.cursor_pos(area).unwrap();
|
||||
for (kind, column) in [
|
||||
(Down(Left), x - 11),
|
||||
(MouseEventKind::Drag(Left), x - 6),
|
||||
(Up(Left), x - 6),
|
||||
] {
|
||||
mouse(&mut composer, area, kind, column, y);
|
||||
}
|
||||
let click = MouseEvent {
|
||||
kind: Down(Right),
|
||||
column: x - 9,
|
||||
row: y,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
};
|
||||
assert_eq!(
|
||||
composer.copy_selection(&TuiEvent::Mouse(click), |text| {
|
||||
assert_eq!(text, "hello");
|
||||
Ok(CopyStatus::Unconfirmed)
|
||||
}),
|
||||
Some((5, Ok(CopyStatus::Unconfirmed)))
|
||||
);
|
||||
|
||||
let shifted_area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 10, /*width*/ 20, /*height*/ 4,
|
||||
);
|
||||
assert!(composer.prepare_mouse(click));
|
||||
composer.render(shifted_area, &mut Buffer::empty(shifted_area));
|
||||
assert_eq!(
|
||||
composer.copy_selection(&TuiEvent::Mouse(click), |_| unreachable!()),
|
||||
None
|
||||
);
|
||||
let (_, row) = composer.cursor_pos(shifted_area).unwrap();
|
||||
assert_eq!(
|
||||
composer.copy_selection(&TuiEvent::Mouse(MouseEvent { row, ..click }), |text| {
|
||||
assert_eq!(text, "hello");
|
||||
Ok(CopyStatus::Unconfirmed)
|
||||
}),
|
||||
Some((5, Ok(CopyStatus::Unconfirmed)))
|
||||
);
|
||||
assert_eq!(composer.current_text(), "hello world");
|
||||
}
|
||||
|
||||
@@ -1747,11 +1747,15 @@ impl BottomPane {
|
||||
self.composer.end_mouse_drag();
|
||||
}
|
||||
|
||||
pub(crate) fn composer_selection_for_copy(&mut self, key: KeyEvent) -> Option<String> {
|
||||
pub(crate) fn copy_composer_selection(
|
||||
&mut self,
|
||||
event: &crate::tui::TuiEvent,
|
||||
copy: impl FnOnce(&str) -> Result<crate::clipboard_copy::CopyStatus, String>,
|
||||
) -> Option<(usize, Result<crate::clipboard_copy::CopyStatus, String>)> {
|
||||
if self.has_active_view() || self.questions.as_ref().is_some_and(|q| q.expanded) {
|
||||
return None;
|
||||
}
|
||||
self.composer.selection_for_copy(key)
|
||||
self.composer.copy_selection(event, copy)
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_composer_mouse(&mut self, event: crossterm::event::MouseEvent) -> bool {
|
||||
|
||||
@@ -16,6 +16,12 @@ pub(super) struct MouseSelection {
|
||||
}
|
||||
|
||||
impl TextArea {
|
||||
pub(in crate::bottom_pane) fn contains_mouse(&self, event: MouseEvent) -> bool {
|
||||
self.rendered_area
|
||||
.get()
|
||||
.contains(Position::new(event.column, event.row))
|
||||
}
|
||||
|
||||
pub(crate) fn end_mouse_drag(&mut self) {
|
||||
if let Some(selection) = &mut self.mouse_selection {
|
||||
selection.dragging = false;
|
||||
@@ -47,8 +53,7 @@ impl TextArea {
|
||||
}
|
||||
match event.kind {
|
||||
MouseEventKind::Down(MouseButton::Left)
|
||||
if event.modifiers.is_empty()
|
||||
&& area.contains(Position::new(event.column, event.row)) => {}
|
||||
if event.modifiers.is_empty() && self.contains_mouse(event) => {}
|
||||
MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left)
|
||||
if dragging => {}
|
||||
_ => return false,
|
||||
|
||||
@@ -9,8 +9,12 @@ impl ChatWidget {
|
||||
self.bottom_pane.end_composer_drag();
|
||||
}
|
||||
|
||||
pub(crate) fn composer_selection_for_copy(&mut self, key: KeyEvent) -> Option<String> {
|
||||
self.bottom_pane.composer_selection_for_copy(key)
|
||||
pub(crate) fn copy_composer_selection(
|
||||
&mut self,
|
||||
event: &crate::tui::TuiEvent,
|
||||
copy: impl FnOnce(&str) -> Result<crate::clipboard_copy::CopyStatus, String>,
|
||||
) -> Option<(usize, Result<crate::clipboard_copy::CopyStatus, String>)> {
|
||||
self.bottom_pane.copy_composer_selection(event, copy)
|
||||
}
|
||||
|
||||
pub(crate) fn handle_composer_mouse(&mut self, event: crossterm::event::MouseEvent) -> bool {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
+21
@@ -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
|
||||
Reference in New Issue
Block a user