Skip to content
Closed
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,7 @@ docs/*_PLAN.md
# direnv
.envrc
.direnv

# Superpowers plugin artifacts
docs/superpowers/
.serena/
203 changes: 202 additions & 1 deletion crates/tui/src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down Expand Up @@ -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 {
Expand All @@ -926,6 +930,7 @@ impl Default for ComposerState {
vim_enabled: false,
vim_mode: VimMode::Normal,
vim_pending_d: false,
selection_anchor: None,
}
}
}
Expand All @@ -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 {
Expand All @@ -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,
}
}
}
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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() {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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`).
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
Loading
Loading