Files
codex/codex-rs/tui/src/transcript_view/select_mode.rs
T
Felipe Coury 31434401fa Add keyboard selection support to the transcript view (#53693)
## What changed

Add transcript select-mode state with an underlined cursor, grapheme-aware movement via arrow keys or `h`/`j`/`k`/`l`, and vertical movement that preserves the preferred display column. Support marking with `v`, Space, or Shift-arrow keys and copying through the existing selection machinery.

Keep the displayed transcript snapshot and reading position paused during selection and after exiting the mode. Add entry and exit methods; this change does not yet bind mode entry to a user-facing key.

## Testing

Add unit tests for initial cursor placement, movement across empty lines and entry boundaries, wrapped text, preferred-column preservation, and Enter behavior with and without a marked range.

GitOrigin-RevId: e5bc380ea0eb6e2c6157ba73ac063d548a7de145
2026-10-13 15:11:10 +00:00

267 lines
9.1 KiB
Rust

//! Keyboard-owned transcript cursor and mark state over the existing selection snapshot.
//!
//! An empty `Selection` is the caret. Marking only records its fixed and active anchors here;
//! copying, retained revisions, serialization, and asynchronous completion remain owned by
//! `Selection`.
use super::*;
use crate::text_selection::SelectionUnit;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
mod motion;
#[derive(Clone, Copy, Default)]
pub(super) struct SelectMode {
pub(super) mark: Option<Mark>,
pub(super) preferred_column: Option<u16>,
}
#[derive(Clone, Copy)]
pub(super) struct Mark {
anchor: Anchor,
active: Anchor,
}
#[derive(Clone, Copy)]
enum CursorMotion {
Character(KeyCode),
Rows(isize),
}
impl SelectMode {
pub(super) fn cursor(self, selection: &Selection) -> Anchor {
self.mark.map_or(selection.end, |mark| mark.active)
}
}
impl TranscriptView {
/// Enter at the bottommost visible selectable position.
#[cfg_attr(not(test), expect(dead_code, reason = "used by a later stack layer"))]
pub(crate) fn begin_select_mode(&mut self, cells: &[Arc<dyn HistoryCell>]) -> bool {
if self.select_mode.is_some() {
return true;
}
self.copy_mode = None;
self.cancel_beginning();
if self.selection.is_none() {
let cursor = self
.visible
.iter()
.rev()
.find_map(|visible| {
let offset = visible.layout.first_cursor_position(visible.row)?;
Some(Anchor {
key: visible.key,
index: visible.index,
offset,
row_bias: visible.layout.row_for_offset(offset) as isize
- visible.row as isize,
})
})
.or_else(|| self.select_boundary(cells, /*end*/ true));
let Some(cursor) = cursor else {
return false;
};
let snapshot = self.capture_snapshot(cells);
self.selection = Some(Selection::keyboard(snapshot, cursor));
}
if self.position == Position::Latest {
self.hold_position();
}
self.select_mode = Some(SelectMode::default());
self.reveal_select_cursor(cells);
true
}
/// Leave the mode while retaining its displayed revision at the paused reading position.
pub(crate) fn end_select_mode(&mut self) {
if self.select_mode.take().is_none() {
return;
}
self.cancel_beginning();
if self.position == Position::Latest {
self.hold_position();
}
if let Some(selection) = self.selection.take() {
self.held_reading = Some(selection.snapshot);
}
}
pub(crate) fn handle_select_mode_key(
&mut self,
key: KeyEvent,
cells: &[Arc<dyn HistoryCell>],
) -> Option<ViewAction> {
if self.select_mode.is_none() || key.kind == KeyEventKind::Release {
return None;
}
let (code, modifiers) = crate::key_hint::normalize_key_parts(key.code, key.modifiers);
let pressed = key.kind == KeyEventKind::Press;
if crate::text_selection::is_copy_key(key)
|| (code == KeyCode::Char('y') && modifiers == KeyModifiers::NONE)
|| (code == KeyCode::Enter
&& modifiers == KeyModifiers::NONE
&& key.kind == KeyEventKind::Press)
{
let Some(text) = self.selected_text(cells).filter(|text| !text.is_empty()) else {
if code == KeyCode::Enter {
return Some(self.exit_select_mode());
}
return Some(ViewAction::Changed);
};
return Some(if code == KeyCode::Enter {
ViewAction::CopyAndFollow(text)
} else {
ViewAction::Copy(text)
});
}
if pressed && code == KeyCode::Esc && modifiers == KeyModifiers::NONE {
if self.select_mode.is_some_and(|mode| mode.mark.is_some()) {
self.clear_select_mark();
return Some(ViewAction::Changed);
}
return Some(self.exit_select_mode());
}
if pressed && code == KeyCode::Char('q') && modifiers == KeyModifiers::NONE {
return Some(self.exit_select_mode());
}
if pressed
&& matches!(code, KeyCode::Char('v') | KeyCode::Char(' '))
&& modifiers == KeyModifiers::NONE
{
self.start_select_mark(cells);
return Some(ViewAction::Changed);
}
let shift_arrow = modifiers == KeyModifiers::SHIFT
&& matches!(
code,
KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down
);
if shift_arrow && self.select_mode.is_some_and(|mode| mode.mark.is_none()) {
self.start_select_mark(cells);
}
let motion = match (code, modifiers) {
(
KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down,
KeyModifiers::NONE | KeyModifiers::SHIFT,
) => {
if matches!(code, KeyCode::Up | KeyCode::Down) {
CursorMotion::Rows(if code == KeyCode::Up { -1 } else { 1 })
} else {
CursorMotion::Character(code)
}
}
(KeyCode::Char('h'), KeyModifiers::NONE) => CursorMotion::Character(KeyCode::Left),
(KeyCode::Char('l'), KeyModifiers::NONE) => CursorMotion::Character(KeyCode::Right),
(KeyCode::Char('k'), KeyModifiers::NONE) => CursorMotion::Rows(-1),
(KeyCode::Char('j'), KeyModifiers::NONE) => CursorMotion::Rows(1),
_ => return None,
};
self.move_select_cursor(cells, motion);
Some(ViewAction::Changed)
}
fn exit_select_mode(&mut self) -> ViewAction {
self.end_select_mode();
ViewAction::ExitSelectMode
}
fn start_select_mark(&mut self, cells: &[Arc<dyn HistoryCell>]) {
let Some(mut mode) = self.select_mode else {
return;
};
let Some(selection) = self.selection.as_ref() else {
return;
};
let cursor = mode.cursor(selection);
mode.mark = match mode.mark {
Some(_) => {
self.clear_select_mark();
return;
}
None => Some(Mark {
anchor: cursor,
active: cursor,
}),
};
mode.preferred_column = None;
self.select_mode = Some(mode);
self.sync_select_range(cells);
}
fn clear_select_mark(&mut self) {
let Some(mode) = &mut self.select_mode else {
return;
};
let Some(selection) = &mut self.selection else {
return;
};
let cursor = mode.cursor(selection);
mode.mark = None;
mode.preferred_column = None;
selection.set_keyboard_range(cursor, cursor);
}
fn reveal_select_cursor(&mut self, cells: &[Arc<dyn HistoryCell>]) {
let Some(mode) = self.select_mode else {
return;
};
let Some(selection) = self.selection.as_ref() else {
return;
};
let cursor = mode.cursor(selection);
let index = self.resolve(cells, cursor);
let row = self.layout(cells, index).map_or(/*default*/ 0, |layout| {
layout
.row_for_offset(cursor.offset)
.saturating_add_signed(cursor.row_bias.saturating_neg())
});
if !self
.visible
.iter()
.any(|visible| visible.index == index && visible.row == row)
{
self.position = Position::Reading(Anchor { index, ..cursor });
}
}
pub(super) fn render_select_cursor(&self, buf: &mut Buffer) {
let (Some(mode), Some(selection)) = (self.select_mode, self.selection.as_ref()) else {
return;
};
let cursor = mode.cursor(selection);
let Some((y, visible)) = self.visible.iter().enumerate().find(|(_, visible)| {
visible.key == cursor.key
&& visible.row
== visible
.layout
.row_for_offset(cursor.offset)
.saturating_add_signed(cursor.row_bias.saturating_neg())
}) else {
return;
};
let column = visible
.layout
.column_for_offset_on_row(visible.row, cursor.offset);
if column < self.area.width {
let area = Rect::new(
self.area.x + column,
self.area.y + y as u16,
/*width*/ 1,
/*height*/ 1,
);
buf.set_style(area, crate::style::selection_style().underlined());
}
}
}
#[cfg(test)]
#[path = "select_mode_tests.rs"]
mod tests;