mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Add mouse selection and editing to the fullscreen composer (#46858)
## What changed - Support click-to-position, drag selection, double-click word selection, and triple-click logical line selection in the composer, respecting wrapping, scrolling, and atomic grapheme and placeholder boundaries. - Copy selected draft text through the existing clipboard status flow without changing the draft or selection, before interrupt and exit shortcuts consume the key. - Replace or delete selected text with editor input, flush buffered typing before mouse edits, and reconcile removed paste and image placeholders. - Coordinate composer gestures with transcript selections, completion popups, and modals. Use generic retry messages for copy failures. ## Testing Add coverage for Unicode and wrapped-text hit testing, selection highlighting and editing, multiclick dragging, placeholder cleanup, history navigation, and fullscreen copy and input ownership. GitOrigin-RevId: 939ef461ec67855b3e87c3c7ddd8a60a3250c7cd
This commit is contained in:
@@ -857,6 +857,9 @@ impl App {
|
||||
app_server: &mut AppServerSession,
|
||||
event: TuiEvent,
|
||||
) -> Result<AppRunControl> {
|
||||
if 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.
|
||||
if matches!(&event, TuiEvent::Resume) || !tui.is_owned_screen() || self.overlay.is_some() {
|
||||
self.chat_widget
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Compose the owned transcript above the existing composer and route transcript-only gestures.
|
||||
//! Compose the owned transcript above the composer and route their selection gestures.
|
||||
//! Reserve a cleared row between transcript content and the composer. Slash suggestions overlay
|
||||
//! already-painted rows so opening or closing them leaves transcript geometry unchanged.
|
||||
//! Fresh-thread decoration is painted separately in unused cells and never enters history.
|
||||
@@ -15,6 +15,30 @@ use crate::transcript_view::ViewAction;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
impl App {
|
||||
/// Copy draft selections before interrupt/exit shortcuts and configurable chords can consume C.
|
||||
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()
|
||||
&& self.overlay.is_none()
|
||||
&& !self.transcript_view.has_active_interaction()
|
||||
&& let Some(text) = self.chat_widget.composer_selection_for_copy(*key)
|
||||
{
|
||||
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());
|
||||
tui.frame_requester().schedule_frame();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn sync_owned_transcript(&mut self, width: u16) -> bool {
|
||||
let chat_widget = &self.chat_widget;
|
||||
let transcript_width = chat_widget.history_wrap_width(width);
|
||||
@@ -209,6 +233,12 @@ impl App {
|
||||
{
|
||||
self.transcript_view.end_drag();
|
||||
}
|
||||
if !tui.is_owned_screen()
|
||||
|| matches!(event, TuiEvent::FocusLost | TuiEvent::Resume)
|
||||
|| self.overlay.is_some()
|
||||
{
|
||||
self.chat_widget.end_composer_drag();
|
||||
}
|
||||
if !tui.is_owned_screen() || self.overlay.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -230,15 +260,33 @@ impl App {
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
if let TuiEvent::Mouse(mouse) = event {
|
||||
let composer_ready = self.chat_widget.prepare_composer_mouse(*mouse);
|
||||
if mouse.kind != crossterm::event::MouseEventKind::Moved {
|
||||
let size = tui.prepare_draw_size()?;
|
||||
self.render_owned_transcript(tui, size)?;
|
||||
}
|
||||
if composer_ready && self.chat_widget.handle_composer_mouse(*mouse) {
|
||||
self.transcript_view.end_selection(&self.transcript_cells);
|
||||
self.transcript_view.cancel_search();
|
||||
self.transcript_view.clear_activity_focus();
|
||||
tui.frame_requester().schedule_frame();
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
if !self.chat_widget.no_modal_or_popup_active() {
|
||||
self.chat_widget.end_composer_drag();
|
||||
return Ok(false);
|
||||
}
|
||||
let input = matches!(event, TuiEvent::Key(key) if key.kind != KeyEventKind::Release)
|
||||
|| matches!(event, TuiEvent::Mouse(mouse) if mouse.kind != crossterm::event::MouseEventKind::Moved);
|
||||
if input {
|
||||
if matches!(event, TuiEvent::Key(key) if key.kind != KeyEventKind::Release) {
|
||||
let size = tui.prepare_draw_size()?;
|
||||
self.render_owned_transcript(tui, size)?;
|
||||
}
|
||||
if matches!(event, TuiEvent::Key(_))
|
||||
|| matches!(event, TuiEvent::Mouse(mouse) if matches!(mouse.kind, crossterm::event::MouseEventKind::Down(_)))
|
||||
{
|
||||
self.chat_widget.end_composer_drag();
|
||||
}
|
||||
// Visible shortcut help owns Escape before returning a paused viewport to latest.
|
||||
// A transcript search or selection still owns Escape while it replaces the help footer.
|
||||
if let TuiEvent::Key(key) = event
|
||||
|
||||
@@ -11,6 +11,12 @@ use crate::test_support::test_path_buf;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
use codex_config::types::ApprovalsReviewer;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use crossterm::event::MouseButton::Left;
|
||||
use crossterm::event::MouseEvent;
|
||||
use crossterm::event::MouseEventKind;
|
||||
use crossterm::event::MouseEventKind::Down;
|
||||
use crossterm::event::MouseEventKind::Drag;
|
||||
use crossterm::event::MouseEventKind::Up;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
|
||||
@@ -1127,3 +1133,88 @@ async fn find_refreshes_live_details_before_searching_the_first_query() -> Resul
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mouse(kind: MouseEventKind, column: u16, row: u16) -> TuiEvent {
|
||||
TuiEvent::Mouse(MouseEvent {
|
||||
kind,
|
||||
column,
|
||||
row,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
})
|
||||
}
|
||||
|
||||
fn row_containing(tui: &tui::Tui, text: &str) -> u16 {
|
||||
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
|
||||
buffer_text(buffer)
|
||||
.lines()
|
||||
.position(|row| row.contains(text))
|
||||
.unwrap() as u16
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fullscreen_composer_mouse_copy_and_input_ownership() -> Result<()> {
|
||||
let mut app = crate::app::test_support::make_test_app().await;
|
||||
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
tui.set_owned_screen(/*owned*/ true)?;
|
||||
let size = tui.terminal.size()?;
|
||||
app.transcript_cells = vec![user_cell("transcript text")];
|
||||
app.chat_widget.apply_external_edit("hello world".into());
|
||||
app.render_owned_transcript(&mut tui, size)?;
|
||||
let end = tui.terminal.last_known_cursor_pos;
|
||||
let x = end.x - 11;
|
||||
let y = end.y;
|
||||
for event in [
|
||||
mouse(Down(Left), x, y),
|
||||
mouse(Drag(Left), x + 5, y),
|
||||
mouse(Up(Left), x + 5, y),
|
||||
] {
|
||||
assert!(app.handle_owned_transcript_event(&mut tui, &mut server, &event)?);
|
||||
}
|
||||
assert!(!app.transcript_view.has_active_interaction());
|
||||
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)
|
||||
})
|
||||
);
|
||||
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);
|
||||
|
||||
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");
|
||||
|
||||
// A transcript drag that crosses into the composer remains a transcript selection.
|
||||
let transcript_y = row_containing(&tui, "transcript text");
|
||||
for event in [
|
||||
mouse(Down(Left), /*column*/ 1, transcript_y),
|
||||
mouse(Drag(Left), x + 3, y),
|
||||
mouse(Up(Left), x + 3, y),
|
||||
] {
|
||||
app.handle_owned_transcript_event(&mut tui, &mut server, &event)?;
|
||||
}
|
||||
assert!(app.transcript_view.has_active_interaction());
|
||||
|
||||
// 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))?;
|
||||
assert!(!app.transcript_view.has_active_interaction());
|
||||
app.chat_widget.apply_external_edit("/".into());
|
||||
assert!(!app.chat_widget.no_modal_or_popup_active());
|
||||
assert!(app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Down(Left), x, y))?);
|
||||
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"
|
||||
),)
|
||||
);
|
||||
assert!(!app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Down(Left), x, y))?);
|
||||
server.shutdown().await?;
|
||||
tui.set_owned_screen(/*owned*/ false)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -237,6 +237,7 @@
|
||||
//! The burst detector can also be disabled (`disable_paste_burst`), which bypasses the state
|
||||
//! machine and treats the key stream as normal typing. When toggling from enabled → disabled, the
|
||||
//! composer flushes/clears any in-flight burst state so it cannot leak into subsequent input.
|
||||
//! Mouse edits flush pending typing; selection and copy behavior lives in [`mouse`].
|
||||
//!
|
||||
//! For the detailed burst state machine, see `codex-rs/tui/src/bottom_pane/paste_burst.rs`.
|
||||
//!
|
||||
@@ -365,6 +366,7 @@ mod draft_state;
|
||||
mod footer_state;
|
||||
mod history_search;
|
||||
mod inline_input;
|
||||
mod mouse;
|
||||
mod paste_input;
|
||||
mod popup_state;
|
||||
mod reconnect;
|
||||
@@ -1802,8 +1804,10 @@ impl ChatComposer {
|
||||
pub fn attach_image(&mut self, path: PathBuf) {
|
||||
self.dismiss_sparkle();
|
||||
let started_vim_edit = self.begin_direct_vim_edit();
|
||||
let elements_before = self.draft.textarea.element_payloads();
|
||||
self.attachments
|
||||
.attach_image(&mut self.draft.textarea, path);
|
||||
self.reconcile_deleted_elements(elements_before);
|
||||
if started_vim_edit {
|
||||
self.finish_vim_edit();
|
||||
}
|
||||
@@ -1937,19 +1941,6 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn insert_str(&mut self, text: &str) {
|
||||
if !text.is_empty() && self.sparkle.draft.get() == sparkle::SparkleDraft::Untouched {
|
||||
self.dismiss_sparkle();
|
||||
}
|
||||
let started_vim_edit = self.begin_direct_vim_edit();
|
||||
self.draft.textarea.insert_str(text);
|
||||
self.sync_bash_mode_from_text();
|
||||
self.sync_popups();
|
||||
if started_vim_edit {
|
||||
self.finish_vim_edit();
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a key event coming from the main UI.
|
||||
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||
if !self.draft.input_enabled {
|
||||
@@ -2103,12 +2094,9 @@ impl ChatComposer {
|
||||
if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {
|
||||
self.apply_paste(pasted);
|
||||
}
|
||||
let elements_before = self.draft.textarea.element_payloads();
|
||||
self.draft.textarea.input(input);
|
||||
|
||||
let text_after = self.draft.textarea.text();
|
||||
self.draft
|
||||
.pending_pastes
|
||||
.retain(|(placeholder, _)| text_after.contains(placeholder));
|
||||
self.reconcile_deleted_elements(elements_before);
|
||||
(InputResult::None, true)
|
||||
}
|
||||
|
||||
@@ -3449,6 +3437,9 @@ impl ChatComposer {
|
||||
&mut self,
|
||||
key_event: &KeyEvent,
|
||||
) -> Option<(InputResult, bool)> {
|
||||
if self.draft.textarea.mouse_selection_range().is_some() {
|
||||
return None;
|
||||
}
|
||||
let removes_remote_image = matches!(key_event.code, KeyCode::Delete | KeyCode::Backspace)
|
||||
&& self.attachments.selected_remote_image_index.is_some();
|
||||
let started_vim_edit = removes_remote_image && self.begin_direct_vim_edit();
|
||||
@@ -3555,9 +3546,11 @@ impl ChatComposer {
|
||||
)
|
||||
};
|
||||
if history_up_pressed || history_down_pressed {
|
||||
if self
|
||||
.history
|
||||
.should_handle_navigation(&self.current_text(), self.history_navigation_cursor())
|
||||
if self.draft.textarea.mouse_selection_range().is_none()
|
||||
&& self.history.should_handle_navigation(
|
||||
&self.current_text(),
|
||||
self.history_navigation_cursor(),
|
||||
)
|
||||
{
|
||||
let replace_entry = if history_up_pressed {
|
||||
self.history.navigate_up(&self.app_event_tx)
|
||||
@@ -3935,7 +3928,7 @@ impl ChatComposer {
|
||||
self.popups.dismissed_mention_token = None;
|
||||
return;
|
||||
}
|
||||
if !self.popups_enabled() {
|
||||
if !self.popups_enabled() || self.draft.textarea.mouse_selection_range().is_some() {
|
||||
self.popups.active = ActivePopup::None;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//! 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.
|
||||
|
||||
use super::*;
|
||||
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)
|
||||
|| !self.draft.input_enabled
|
||||
|| self.history_search.is_some()
|
||||
|| self.draft.textarea.vim_query().is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {
|
||||
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())
|
||||
}
|
||||
|
||||
pub(crate) fn end_mouse_drag(&mut self) {
|
||||
self.draft.textarea.end_mouse_drag();
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_mouse(&mut self, event: MouseEvent) -> bool {
|
||||
if !self.draft.input_enabled
|
||||
|| self.blocks_direct_input
|
||||
|| self.history_search.is_some()
|
||||
|| self.draft.textarea.vim_query().is_some()
|
||||
{
|
||||
self.end_mouse_drag();
|
||||
return false;
|
||||
}
|
||||
if matches!(event.kind, MouseEventKind::Down(_)) {
|
||||
if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {
|
||||
self.apply_paste(pasted);
|
||||
}
|
||||
self.draft.paste_burst.clear_window_after_non_char();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Dispatch after preparation and rendering have refreshed the viewport.
|
||||
pub(crate) fn handle_mouse(&mut self, event: MouseEvent) -> bool {
|
||||
let handled = self
|
||||
.draft
|
||||
.textarea
|
||||
.handle_mouse(event, *self.draft.textarea_state.borrow());
|
||||
if handled {
|
||||
self.attachments.clear_remote_image_selection();
|
||||
self.sync_popups();
|
||||
}
|
||||
handled
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mouse_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Mouse edits preserve buffered typing and keep placeholder payloads in sync.
|
||||
|
||||
use super::super::tests::new_test_composer;
|
||||
use super::*;
|
||||
use crossterm::event::MouseButton::Left;
|
||||
use crossterm::event::MouseEventKind::Down;
|
||||
use crossterm::event::MouseEventKind::Up;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn mouse(composer: &mut ChatComposer, area: Rect, kind: MouseEventKind, column: u16, row: u16) {
|
||||
let event = MouseEvent {
|
||||
kind,
|
||||
column,
|
||||
row,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
};
|
||||
assert!(composer.prepare_mouse(event));
|
||||
composer.render(area, &mut Buffer::empty(area));
|
||||
assert!(composer.handle_mouse(event));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_mouse_edits_flush_typing_and_reconcile_placeholders() {
|
||||
let (mut composer, _rx) = new_test_composer();
|
||||
composer.insert_str("hello");
|
||||
composer
|
||||
.draft
|
||||
.paste_burst
|
||||
.begin_with_retro_grabbed("\nworld".into(), Instant::now());
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 4,
|
||||
);
|
||||
composer.render(area, &mut Buffer::empty(area));
|
||||
let (x, y) = composer.cursor_pos(area).unwrap();
|
||||
mouse(&mut composer, area, Down(Left), x - 5, y);
|
||||
composer.insert_str("X");
|
||||
assert_eq!(composer.current_text(), "hello\nXworld");
|
||||
composer.handle_paste("z".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1));
|
||||
for path in [Some("first.png"), Some("second.png"), None] {
|
||||
composer.render(area, &mut Buffer::empty(area));
|
||||
let (x, y) = composer.cursor_pos(area).unwrap();
|
||||
// Double-clicking inside the label selects the entire atomic element.
|
||||
for kind in [Down(Left), Up(Left), Down(Left), Up(Left)] {
|
||||
mouse(&mut composer, area, kind, x - 1, y);
|
||||
}
|
||||
if let Some(path) = path {
|
||||
let path = PathBuf::from(path);
|
||||
composer.attach_image(path.clone());
|
||||
assert_eq!(composer.attachments.local_image_paths(), vec![path]);
|
||||
} else {
|
||||
composer.handle_key_event(KeyCode::Char('é').into());
|
||||
assert!(composer.attachments.is_empty());
|
||||
}
|
||||
assert!(composer.draft.pending_pastes.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_text_navigation_does_not_recall_history_or_select_images() {
|
||||
for (key, remote) in [
|
||||
(KeyCode::Up, false),
|
||||
(KeyCode::Down, false),
|
||||
(KeyCode::Up, true),
|
||||
] {
|
||||
let (mut composer, _rx) = new_test_composer();
|
||||
for text in ["first", "second"] {
|
||||
composer
|
||||
.history
|
||||
.record_local_submission(HistoryEntry::new(text.into()));
|
||||
}
|
||||
composer.handle_key_event(KeyCode::Up.into());
|
||||
if remote {
|
||||
composer.set_remote_image_urls(vec!["https://example.com/image.png".into()]);
|
||||
}
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 10,
|
||||
);
|
||||
composer.render(area, &mut Buffer::empty(area));
|
||||
let (x, y) = composer.cursor_pos(area).unwrap();
|
||||
if remote {
|
||||
composer.draft.textarea.set_cursor(/*pos*/ 0);
|
||||
composer.handle_key_event(KeyCode::Up.into());
|
||||
}
|
||||
let (start, end) = if key == KeyCode::Up {
|
||||
(x, x - 6)
|
||||
} else {
|
||||
(x - 6, x)
|
||||
};
|
||||
for (kind, column) in [
|
||||
(Down(Left), start),
|
||||
(MouseEventKind::Drag(Left), end),
|
||||
(Up(Left), end),
|
||||
] {
|
||||
mouse(&mut composer, area, kind, column, y);
|
||||
}
|
||||
assert_eq!(composer.attachments.selected_remote_image_index, None);
|
||||
composer.handle_key_event(key.into());
|
||||
assert_eq!(
|
||||
(
|
||||
composer.current_text(),
|
||||
composer.draft.textarea.mouse_selection_range(),
|
||||
composer.attachments.selected_remote_image_index
|
||||
),
|
||||
("second".to_string(), None, None)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,27 @@ use super::*;
|
||||
use crate::bottom_pane::paste_burst::FlushResult;
|
||||
|
||||
impl ChatComposer {
|
||||
pub(crate) fn insert_str(&mut self, text: &str) {
|
||||
if !text.is_empty() && self.sparkle.draft.get() == sparkle::SparkleDraft::Untouched {
|
||||
self.dismiss_sparkle();
|
||||
}
|
||||
let started_vim_edit = self.begin_direct_vim_edit();
|
||||
let elements_before = self
|
||||
.draft
|
||||
.textarea
|
||||
.mouse_selection_range()
|
||||
.map(|_| self.draft.textarea.element_payloads());
|
||||
self.draft.textarea.insert_str(text);
|
||||
if let Some(elements_before) = elements_before {
|
||||
self.reconcile_deleted_elements(elements_before);
|
||||
}
|
||||
self.sync_bash_mode_from_text();
|
||||
self.sync_popups();
|
||||
if started_vim_edit {
|
||||
self.finish_vim_edit();
|
||||
}
|
||||
}
|
||||
|
||||
/// Include accepted but unflushed keys without changing live paste detection.
|
||||
pub(crate) fn recovery_snapshot(&self) -> ComposerDraftSnapshot {
|
||||
let mut snapshot = self.draft_snapshot();
|
||||
@@ -108,6 +129,7 @@ impl ChatComposer {
|
||||
return true;
|
||||
}
|
||||
let started_vim_edit = self.begin_direct_vim_edit();
|
||||
let elements_before = self.draft.textarea.element_payloads();
|
||||
let char_count = pasted.chars().count();
|
||||
if char_count > LARGE_PASTE_CHAR_THRESHOLD {
|
||||
let placeholder = self.next_large_paste_placeholder(char_count);
|
||||
@@ -125,6 +147,7 @@ impl ChatComposer {
|
||||
self.insert_str(&pasted);
|
||||
}
|
||||
self.draft.paste_burst.clear_after_explicit_paste();
|
||||
self.reconcile_deleted_elements(elements_before);
|
||||
self.sync_popups();
|
||||
if started_vim_edit {
|
||||
self.finish_vim_edit();
|
||||
|
||||
@@ -1743,6 +1743,29 @@ impl BottomPane {
|
||||
self.can_launch_external_editor()
|
||||
}
|
||||
|
||||
pub(crate) fn end_composer_drag(&mut self) {
|
||||
self.composer.end_mouse_drag();
|
||||
}
|
||||
|
||||
pub(crate) fn composer_selection_for_copy(&mut self, key: KeyEvent) -> Option<String> {
|
||||
if self.has_active_view() || self.questions.as_ref().is_some_and(|q| q.expanded) {
|
||||
return None;
|
||||
}
|
||||
self.composer.selection_for_copy(key)
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_composer_mouse(&mut self, event: crossterm::event::MouseEvent) -> bool {
|
||||
if self.has_active_view() || self.questions.as_ref().is_some_and(|q| q.expanded) {
|
||||
self.composer.end_mouse_drag();
|
||||
return false;
|
||||
}
|
||||
self.composer.prepare_mouse(event)
|
||||
}
|
||||
|
||||
pub(crate) fn handle_composer_mouse(&mut self, event: crossterm::event::MouseEvent) -> bool {
|
||||
self.composer.handle_mouse(event)
|
||||
}
|
||||
|
||||
pub(crate) fn show_view(&mut self, view: Box<dyn BottomPaneView>) {
|
||||
self.push_view(view);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
//! breaks, interior separators hang off the preceding row without changing the editable text.
|
||||
//! Visible web URLs carry their complete terminal hyperlink destination across wrapped rows;
|
||||
//! masked rendering never exposes hyperlink destinations.
|
||||
//! Mouse selection uses those same visual rows and keeps graphemes and elements atomic.
|
||||
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::key_hint::is_altgr;
|
||||
@@ -41,6 +42,7 @@ use ratatui::text::Span;
|
||||
use ratatui::widgets::StatefulWidgetRef;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use std::borrow::Cow;
|
||||
use std::cell::Cell;
|
||||
use std::cell::OnceCell;
|
||||
use std::cell::Ref;
|
||||
use std::cell::RefCell;
|
||||
@@ -49,6 +51,7 @@ use std::sync::Arc;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
mod hyperlinks;
|
||||
mod mouse;
|
||||
mod vim;
|
||||
mod vim_commands;
|
||||
mod vim_search;
|
||||
@@ -134,6 +137,9 @@ pub(crate) struct TextElementSnapshot {
|
||||
pub(crate) struct TextArea {
|
||||
text: String,
|
||||
cursor_pos: usize,
|
||||
mouse_selection: Option<mouse::MouseSelection>,
|
||||
last_click: Option<(std::time::Instant, u16, u16, u8)>,
|
||||
rendered_area: Cell<Rect>,
|
||||
wrap_cache: RefCell<Option<WrapCache>>,
|
||||
preferred_col: Option<usize>,
|
||||
elements: Vec<TextElement>,
|
||||
@@ -186,6 +192,9 @@ impl TextArea {
|
||||
Self {
|
||||
text: String::new(),
|
||||
cursor_pos: 0,
|
||||
mouse_selection: None,
|
||||
last_click: None,
|
||||
rendered_area: Cell::new(Rect::default()),
|
||||
wrap_cache: RefCell::new(None),
|
||||
preferred_col: None,
|
||||
elements: Vec::new(),
|
||||
@@ -240,6 +249,7 @@ impl TextArea {
|
||||
}
|
||||
|
||||
fn set_text_inner(&mut self, text: &str, elements: Option<&[UserTextElement]>) {
|
||||
self.mouse_selection = None;
|
||||
// Stage 1: replace the raw text and keep the cursor in a safe byte range.
|
||||
self.text = text.to_string();
|
||||
self.cursor_pos = self.cursor_pos.clamp(0, self.text.len());
|
||||
@@ -432,8 +442,9 @@ impl TextArea {
|
||||
}
|
||||
|
||||
pub fn insert_str(&mut self, text: &str) {
|
||||
let replaced_selection = !text.is_empty() && self.delete_mouse_selection();
|
||||
self.record_vim_inserted_text(text);
|
||||
if self.is_vim_replace_mode() {
|
||||
if self.is_vim_replace_mode() && !replaced_selection {
|
||||
self.replace_vim_text(text);
|
||||
} else {
|
||||
self.insert_str_at(self.cursor_pos, text);
|
||||
@@ -441,6 +452,7 @@ impl TextArea {
|
||||
}
|
||||
|
||||
pub fn insert_str_at(&mut self, pos: usize, text: &str) {
|
||||
self.mouse_selection = None;
|
||||
self.clear_vim_replace_recovery();
|
||||
let pos = self.clamp_pos_for_insertion(pos);
|
||||
self.text.insert_str(pos, text);
|
||||
@@ -464,6 +476,7 @@ impl TextArea {
|
||||
}
|
||||
|
||||
fn replace_range_raw(&mut self, range: std::ops::Range<usize>, text: &str) {
|
||||
self.mouse_selection = None;
|
||||
assert!(range.start <= range.end);
|
||||
let start = range.start.clamp(0, self.text.len());
|
||||
let end = range.end.clamp(0, self.text.len());
|
||||
@@ -501,6 +514,7 @@ impl TextArea {
|
||||
}
|
||||
|
||||
pub fn set_cursor(&mut self, pos: usize) {
|
||||
self.mouse_selection = None;
|
||||
self.cursor_pos = pos.clamp(0, self.text.len());
|
||||
self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);
|
||||
self.preferred_col = None;
|
||||
@@ -548,18 +562,24 @@ impl TextArea {
|
||||
line_end: usize,
|
||||
target_col: usize,
|
||||
) {
|
||||
let pos = self.position_at_display_col_on_line(line_start, line_end, target_col);
|
||||
self.cursor_pos = self.clamp_pos_to_nearest_boundary(pos);
|
||||
}
|
||||
|
||||
fn position_at_display_col_on_line(
|
||||
&self,
|
||||
line_start: usize,
|
||||
line_end: usize,
|
||||
target_col: usize,
|
||||
) -> usize {
|
||||
let mut width_so_far = 0usize;
|
||||
for (i, g) in self.text[line_start..line_end].grapheme_indices(true) {
|
||||
width_so_far += display_width(g);
|
||||
if width_so_far > target_col {
|
||||
self.cursor_pos = line_start + i;
|
||||
// Avoid landing inside an element; round to nearest boundary
|
||||
self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);
|
||||
return;
|
||||
return line_start + i;
|
||||
}
|
||||
}
|
||||
self.cursor_pos = line_end;
|
||||
self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);
|
||||
line_end
|
||||
}
|
||||
|
||||
fn beginning_of_line(&self, pos: usize) -> usize {
|
||||
@@ -594,7 +614,11 @@ impl TextArea {
|
||||
if !matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
|
||||
return;
|
||||
}
|
||||
self.last_click = None;
|
||||
if self.vim_enabled {
|
||||
if self.is_vim_normal_mode() {
|
||||
self.mouse_selection = None;
|
||||
}
|
||||
self.handle_vim_input(event);
|
||||
} else {
|
||||
let keymap = self.editor_keymap.clone();
|
||||
@@ -603,6 +627,8 @@ impl TextArea {
|
||||
}
|
||||
|
||||
pub fn input_with_keymap(&mut self, event: KeyEvent, keymap: &EditorKeymap) {
|
||||
self.last_click = None;
|
||||
self.end_mouse_drag();
|
||||
if keymap.insert_newline.is_pressed(event) {
|
||||
self.insert_str("\n");
|
||||
return;
|
||||
@@ -718,6 +744,7 @@ impl TextArea {
|
||||
}
|
||||
|
||||
tracing::debug!("Unhandled key event in TextArea: {:?}", event);
|
||||
self.mouse_selection = None;
|
||||
}
|
||||
|
||||
fn handle_vim_input(&mut self, event: KeyEvent) {
|
||||
@@ -742,6 +769,7 @@ impl TextArea {
|
||||
return;
|
||||
}
|
||||
if self.is_vim_replace_mode()
|
||||
&& self.mouse_selection_range().is_none()
|
||||
&& self.editor_keymap.delete_backward.is_pressed(event)
|
||||
&& self.apply_vim_insert_action(VimAction::RestoreReplacedCharacter)
|
||||
{
|
||||
@@ -752,6 +780,7 @@ impl TextArea {
|
||||
}
|
||||
|
||||
fn leave_vim_insert_mode(&mut self) {
|
||||
self.mouse_selection = None;
|
||||
let bol = self.beginning_of_current_line();
|
||||
if self.cursor_pos > bol {
|
||||
self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos).max(bol);
|
||||
@@ -1648,6 +1677,7 @@ impl TextArea {
|
||||
let inserted_len = new.len();
|
||||
let diff = inserted_len as isize - removed_len as isize;
|
||||
|
||||
self.mouse_selection = None;
|
||||
self.text.replace_range(range, new);
|
||||
self.wrap_cache.replace(None);
|
||||
self.preferred_col = None;
|
||||
@@ -1694,6 +1724,7 @@ impl TextArea {
|
||||
}
|
||||
|
||||
pub fn insert_element(&mut self, text: &str) -> u64 {
|
||||
self.delete_mouse_selection();
|
||||
let start = self.clamp_pos_for_insertion(self.cursor_pos);
|
||||
self.insert_str_at(start, text);
|
||||
let end = start + text.len();
|
||||
@@ -2123,6 +2154,7 @@ impl StatefulWidgetRef for &TextArea {
|
||||
type State = TextAreaState;
|
||||
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||
self.rendered_area.set(area);
|
||||
let lines = self.wrapped_lines(area.width);
|
||||
let scroll = self.effective_scroll(area, &lines, state.scroll);
|
||||
state.scroll = scroll;
|
||||
@@ -2141,6 +2173,7 @@ impl TextArea {
|
||||
state: &mut TextAreaState,
|
||||
mask_char: char,
|
||||
) {
|
||||
self.rendered_area.set(area);
|
||||
let lines = self.wrapped_lines(area.width);
|
||||
let scroll = self.effective_scroll(area, &lines, state.scroll);
|
||||
state.scroll = scroll;
|
||||
@@ -2162,6 +2195,7 @@ impl TextArea {
|
||||
base_style: Style,
|
||||
highlights: &[(Range<usize>, Style)],
|
||||
) {
|
||||
self.rendered_area.set(area);
|
||||
let lines = self.wrapped_lines(area.width);
|
||||
let scroll = self.effective_scroll(area, &lines, state.scroll);
|
||||
state.scroll = scroll;
|
||||
@@ -2197,12 +2231,18 @@ impl TextArea {
|
||||
base_style,
|
||||
);
|
||||
|
||||
// Apply search highlights last so they remain visible over styled elements.
|
||||
// Selection takes precedence over elements and search highlights.
|
||||
let selection = self.mouse_selection_range();
|
||||
let overlays = self
|
||||
.elements
|
||||
.iter()
|
||||
.map(|element| (&element.range, element_style))
|
||||
.chain(highlights.iter().map(|(range, style)| (range, *style)));
|
||||
.chain(highlights.iter().map(|(range, style)| (range, *style)))
|
||||
.chain(
|
||||
selection
|
||||
.as_ref()
|
||||
.map(|range| (range, base_style.reversed())),
|
||||
);
|
||||
for (overlay_range, style) in overlays {
|
||||
let overlap_start = overlay_range.start.max(line_range.start);
|
||||
let overlap_end = overlay_range.end.min(line_range.end);
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Hit testing and editable mouse selections share the textarea's rendered wrap and scroll state.
|
||||
|
||||
use super::*;
|
||||
use crate::text_selection::SelectionUnit;
|
||||
use crossterm::event::MouseButton;
|
||||
use crossterm::event::MouseEvent;
|
||||
use crossterm::event::MouseEventKind;
|
||||
use ratatui::layout::Position;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct MouseSelection {
|
||||
origin: Range<usize>,
|
||||
unit: SelectionUnit,
|
||||
dragging: bool,
|
||||
moved: bool,
|
||||
}
|
||||
|
||||
impl TextArea {
|
||||
pub(crate) fn end_mouse_drag(&mut self) {
|
||||
if let Some(selection) = &mut self.mouse_selection {
|
||||
selection.dragging = false;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mouse_selection_range(&self) -> Option<Range<usize>> {
|
||||
let origin = &self.mouse_selection.as_ref()?.origin;
|
||||
let range = origin.start.min(self.cursor_pos)..origin.end.max(self.cursor_pos);
|
||||
(!range.is_empty()).then_some(range)
|
||||
}
|
||||
|
||||
pub(super) fn delete_mouse_selection(&mut self) -> bool {
|
||||
let Some(range) = self.mouse_selection_range() else {
|
||||
return false;
|
||||
};
|
||||
// A pointer-selected edit cannot be replayed as a keyboard-relative Vim command.
|
||||
self.vim_commands = VimCommandState::default();
|
||||
self.replace_range(range, "");
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn handle_mouse(&mut self, event: MouseEvent, state: TextAreaState) -> bool {
|
||||
let area = self.rendered_area.get();
|
||||
let dragging = self.mouse_selection.as_ref().is_some_and(|s| s.dragging);
|
||||
if area.is_empty() {
|
||||
self.end_mouse_drag();
|
||||
return false;
|
||||
}
|
||||
match event.kind {
|
||||
MouseEventKind::Down(MouseButton::Left)
|
||||
if event.modifiers.is_empty()
|
||||
&& area.contains(Position::new(event.column, event.row)) => {}
|
||||
MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left)
|
||||
if dragging => {}
|
||||
_ => return false,
|
||||
}
|
||||
|
||||
if matches!(event.kind, MouseEventKind::Up(MouseButton::Left))
|
||||
&& self.mouse_selection.as_ref().is_some_and(|s| !s.moved)
|
||||
{
|
||||
self.end_mouse_drag();
|
||||
return true;
|
||||
}
|
||||
let line = {
|
||||
let lines = self.wrapped_lines(area.width);
|
||||
let row = usize::from(state.scroll)
|
||||
+ usize::from(event.row.saturating_sub(area.y).min(area.height - 1));
|
||||
// Dragging beyond the viewport advances one wrapped row per event.
|
||||
let row = if event.row < area.y {
|
||||
row.saturating_sub(1)
|
||||
} else if event.row >= area.bottom() {
|
||||
row + 1
|
||||
} else {
|
||||
row
|
||||
};
|
||||
lines[row.min(lines.len() - 1)].clone()
|
||||
};
|
||||
let col = usize::from(event.column.saturating_sub(area.x).min(area.width));
|
||||
let pos = self.position_at_display_col_on_line(line.start, line.end - 1, col);
|
||||
let down = matches!(event.kind, MouseEventKind::Down(MouseButton::Left));
|
||||
let mut selection = if down {
|
||||
if self.mouse_selection.is_none() {
|
||||
self.last_click = None;
|
||||
}
|
||||
let clicks =
|
||||
crate::text_selection::click_count(&mut self.last_click, event.column, event.row);
|
||||
MouseSelection {
|
||||
origin: pos..pos,
|
||||
// Padding remains an insertion target on repeated clicks.
|
||||
unit: SelectionUnit::from_clicks(if pos == line.end - 1 { 1 } else { clicks }),
|
||||
dragging: true,
|
||||
moved: false,
|
||||
}
|
||||
} else if let Some(selection) = self.mouse_selection.take() {
|
||||
selection
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
let unit = selection.unit;
|
||||
let range = if unit == SelectionUnit::Character {
|
||||
let pos = self.clamp_pos_to_nearest_boundary(pos);
|
||||
pos..pos
|
||||
} else if unit == SelectionUnit::Word
|
||||
&& let Some(element) = self.elements.iter().find(|e| e.range.contains(&pos))
|
||||
{
|
||||
element.range.clone()
|
||||
} else {
|
||||
self.expand_range_to_element_boundaries(unit.range(&self.text, pos))
|
||||
};
|
||||
self.preferred_col = None;
|
||||
self.clear_vim_replace_recovery();
|
||||
self.vim_pending = VimPending::None;
|
||||
if down {
|
||||
selection.origin = range.clone();
|
||||
}
|
||||
self.cursor_pos = if range.start < selection.origin.start {
|
||||
range.start
|
||||
} else {
|
||||
range.end
|
||||
};
|
||||
selection.moved |= !down;
|
||||
selection.dragging = !matches!(event.kind, MouseEventKind::Up(MouseButton::Left));
|
||||
self.mouse_selection = Some(selection);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mouse_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Pointer editing follows rendered rows and preserves atomic text boundaries.
|
||||
|
||||
use super::*;
|
||||
use crossterm::event::MouseButton::Left;
|
||||
use crossterm::event::MouseEventKind::Down;
|
||||
use crossterm::event::MouseEventKind::Drag;
|
||||
use crossterm::event::MouseEventKind::Up;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::style::Modifier;
|
||||
|
||||
fn render(t: &TextArea, area: Rect, state: &mut TextAreaState) -> Buffer {
|
||||
let mut buffer = Buffer::empty(area);
|
||||
StatefulWidgetRef::render_ref(&t, area, &mut buffer, state);
|
||||
buffer
|
||||
}
|
||||
|
||||
fn mouse(t: &mut TextArea, state: TextAreaState, kind: MouseEventKind, x: u16, y: u16) {
|
||||
assert!(t.handle_mouse(
|
||||
MouseEvent {
|
||||
kind,
|
||||
column: x,
|
||||
row: y,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
},
|
||||
state
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_click_maps_wrapping_unicode_tabs_and_empty_lines() {
|
||||
let area = Rect::new(
|
||||
/*x*/ 3, /*y*/ 2, /*width*/ 8, /*height*/ 8,
|
||||
);
|
||||
let mut t = TextArea::new();
|
||||
let text = "ab 界e\u{301}👩💻\n\nx\ty";
|
||||
t.insert_str(text);
|
||||
let mut state = TextAreaState::default();
|
||||
render(&t, area, &mut state);
|
||||
for (pos, _) in text
|
||||
.grapheme_indices(/*is_extended*/ true)
|
||||
.chain(std::iter::once((text.len(), "")))
|
||||
{
|
||||
t.set_cursor(pos);
|
||||
let (x, y) = t.cursor_pos_with_state(area, state).unwrap();
|
||||
mouse(&mut t, state, Down(Left), x, y);
|
||||
assert_eq!(t.cursor(), pos);
|
||||
}
|
||||
// Both cells of a wide grapheme resolve to its leading insertion boundary.
|
||||
mouse(&mut t, state, Down(Left), /*x*/ 7, /*y*/ 2);
|
||||
t.insert_str("!");
|
||||
assert_eq!(t.text(), "ab !界e\u{301}👩💻\n\nx\ty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_drag_highlights_and_edits_wrapped_selection() {
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 1, /*width*/ 8, /*height*/ 1,
|
||||
);
|
||||
let backward_word = KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL);
|
||||
let forward_word = KeyEvent::new(KeyCode::Delete, KeyModifiers::CONTROL);
|
||||
let kill_line = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL);
|
||||
for (start, end, key, expected) in [
|
||||
((1, 1), (3, 2), KeyCode::Char('X').into(), ("hXld!", 2)),
|
||||
((3, 1), (1, 0), KeyCode::Backspace.into(), ("hld!", 1)),
|
||||
((1, 1), (3, 2), KeyCode::Delete.into(), ("hld!", 1)),
|
||||
((3, 1), (1, 0), backward_word, ("hld!", 1)),
|
||||
((1, 1), (3, 2), forward_word, ("hld!", 1)),
|
||||
((1, 1), (3, 2), kill_line, ("hld!", 1)),
|
||||
] {
|
||||
let mut t = TextArea::new();
|
||||
t.insert_str("hello world");
|
||||
if key.modifiers.is_empty() {
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
t.input(KeyCode::Char('A').into());
|
||||
t.input(KeyCode::Char('!').into());
|
||||
t.input(KeyCode::Esc.into());
|
||||
t.input(KeyCode::Char('R').into());
|
||||
} else {
|
||||
t.insert_str("!");
|
||||
}
|
||||
if end.1 > start.1 {
|
||||
t.set_cursor(/*pos*/ 0);
|
||||
}
|
||||
let mut state = TextAreaState::default();
|
||||
render(&t, area, &mut state);
|
||||
mouse(&mut t, state, Down(Left), start.0, start.1);
|
||||
mouse(&mut t, state, Drag(Left), end.0, end.1);
|
||||
mouse(&mut t, state, Up(Left), end.0, end.1);
|
||||
render(&t, area, &mut state);
|
||||
assert_eq!(state.scroll, u16::from(end.1 > start.1));
|
||||
let buffer = render(&t, Rect { height: 2, ..area }, &mut state);
|
||||
let rows = buffer
|
||||
.content
|
||||
.chunks(usize::from(area.width))
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.map(|cell| {
|
||||
if cell.modifier.contains(Modifier::REVERSED) {
|
||||
cell.symbol()
|
||||
} else {
|
||||
"·"
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
insta::assert_snapshot!("mouse_wrapped_selection", rows);
|
||||
t.input(key);
|
||||
assert_eq!((t.text(), t.cursor()), expected);
|
||||
if key.modifiers == KeyModifiers::CONTROL {
|
||||
t.input(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL));
|
||||
assert_eq!(t.text(), "hello world!");
|
||||
} else {
|
||||
t.input(KeyCode::Esc.into());
|
||||
assert!(t.vim_repeat_actions().is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_multiclick_preserves_the_initial_word_or_wrapped_line_when_reversing() {
|
||||
for (clicks, selected, backward) in
|
||||
[(2, "two", "one two"), (3, "one two six\n", "one two six\n")]
|
||||
{
|
||||
let mut t = TextArea::new();
|
||||
t.insert_str("one two six\nnext");
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 8, /*height*/ 4,
|
||||
);
|
||||
let mut state = TextAreaState::default();
|
||||
render(&t, area, &mut state);
|
||||
// Click timing is shared and tested by the transcript; exercise the editor's unit mapping.
|
||||
mouse(&mut t, state, Down(Left), /*x*/ 5, /*y*/ 0);
|
||||
t.last_click = Some((std::time::Instant::now(), 5, 0, clicks - 1));
|
||||
mouse(&mut t, state, Down(Left), /*x*/ 5, /*y*/ 0);
|
||||
assert_eq!(&t.text()[t.mouse_selection_range().unwrap()], selected);
|
||||
mouse(&mut t, state, Drag(Left), /*x*/ 1, /*y*/ 0);
|
||||
mouse(&mut t, state, Up(Left), /*x*/ 1, /*y*/ 0);
|
||||
assert_eq!(&t.text()[t.mouse_selection_range().unwrap()], backward);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/textarea/mouse_tests.rs
|
||||
assertion_line: 91
|
||||
expression: rows
|
||||
---
|
||||
·ello ··
|
||||
wor·····
|
||||
@@ -224,16 +224,6 @@ impl TextArea {
|
||||
}
|
||||
|
||||
pub(super) fn apply_vim_insert_action(&mut self, action: VimAction) -> bool {
|
||||
let recording = self.vim_enabled
|
||||
&& matches!(self.vim_mode, VimMode::Insert | VimMode::Replace)
|
||||
&& !self.vim_commands.replaying
|
||||
&& !self.vim_commands.pending_change.is_empty();
|
||||
let prior_len = self.text.len();
|
||||
if !self.apply_vim_editor_action(action.clone()) {
|
||||
return false;
|
||||
}
|
||||
let changed =
|
||||
self.text.len() != prior_len || matches!(action, VimAction::RestoreReplacedCharacter);
|
||||
let deletion = matches!(
|
||||
action,
|
||||
VimAction::DeleteBackward
|
||||
@@ -244,6 +234,26 @@ impl TextArea {
|
||||
| VimAction::KillLine
|
||||
| VimAction::KillLineEnd
|
||||
);
|
||||
if deletion && let Some(range) = self.mouse_selection_range() {
|
||||
self.vim_commands = VimCommandState::default();
|
||||
if matches!(action, VimAction::DeleteBackward | VimAction::DeleteForward) {
|
||||
self.replace_range(range, "");
|
||||
} else {
|
||||
self.kill_range(range);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
self.mouse_selection = None;
|
||||
let recording = self.vim_enabled
|
||||
&& matches!(self.vim_mode, VimMode::Insert | VimMode::Replace)
|
||||
&& !self.vim_commands.replaying
|
||||
&& !self.vim_commands.pending_change.is_empty();
|
||||
let prior_len = self.text.len();
|
||||
if !self.apply_vim_editor_action(action.clone()) {
|
||||
return false;
|
||||
}
|
||||
let changed =
|
||||
self.text.len() != prior_len || matches!(action, VimAction::RestoreReplacedCharacter);
|
||||
if recording && (changed || !deletion) {
|
||||
self.vim_commands
|
||||
.pending_change
|
||||
|
||||
@@ -5,6 +5,22 @@ use crate::bottom_pane::BottomPaneView;
|
||||
use crate::clipboard_copy::CopyFormat;
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn end_composer_drag(&mut self) {
|
||||
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 handle_composer_mouse(&mut self, event: crossterm::event::MouseEvent) -> bool {
|
||||
self.bottom_pane.handle_composer_mouse(event)
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_composer_mouse(&mut self, event: crossterm::event::MouseEvent) -> bool {
|
||||
self.bottom_pane.prepare_composer_mouse(event)
|
||||
}
|
||||
|
||||
pub(crate) fn set_agents_navigation_enabled(&mut self, enabled: bool) {
|
||||
self.bottom_pane.set_agents_navigation_enabled(enabled);
|
||||
}
|
||||
|
||||
@@ -89,8 +89,8 @@ impl CopyFeedback {
|
||||
"Copy unconfirmed".into(),
|
||||
],
|
||||
Err(()) => [
|
||||
"Copy failed · /export saves chat".into(),
|
||||
"Copy failed · /export".into(),
|
||||
"Copy failed · try again".into(),
|
||||
"Copy failed".into(),
|
||||
"Copy failed".into(),
|
||||
],
|
||||
};
|
||||
|
||||
+2
-2
@@ -22,10 +22,10 @@ Ok(Unconfirmed), 18 columns
|
||||
Copy unconfirmed
|
||||
|
||||
Err("unavailable"), 80 columns
|
||||
Copy failed · /export saves chat
|
||||
Copy failed · try again
|
||||
|
||||
Err("unavailable"), 32 columns
|
||||
Copy failed · /export
|
||||
Copy failed · try again
|
||||
|
||||
Err("unavailable"), 18 columns
|
||||
Copy failed
|
||||
|
||||
Reference in New Issue
Block a user