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:
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
|
||||
Reference in New Issue
Block a user