mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
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
This commit is contained in:
@@ -17,6 +17,7 @@ mod layout;
|
||||
mod mutations;
|
||||
mod prompt_header;
|
||||
mod search;
|
||||
mod select_mode;
|
||||
mod selection;
|
||||
mod snapshot;
|
||||
mod text;
|
||||
@@ -38,6 +39,7 @@ use ratatui::widgets::Widget;
|
||||
|
||||
use layout::LayoutCache;
|
||||
use search::Search;
|
||||
use select_mode::SelectMode;
|
||||
use selection::Selection;
|
||||
use snapshot::ViewSnapshot;
|
||||
use text::TextLayout;
|
||||
@@ -126,6 +128,7 @@ pub(crate) struct TranscriptView {
|
||||
suppressed_prompt_header: Option<prompt_header::SuppressedHeader>,
|
||||
visible: Vec<VisibleRow>,
|
||||
selection: Option<Selection>,
|
||||
select_mode: Option<SelectMode>,
|
||||
copy_mode: Option<copy_mode::CopyMode>,
|
||||
held_reading: Option<ViewSnapshot>,
|
||||
search: Search,
|
||||
@@ -164,6 +167,7 @@ impl Default for TranscriptView {
|
||||
suppressed_prompt_header: None,
|
||||
visible: Vec::new(),
|
||||
selection: None,
|
||||
select_mode: None,
|
||||
copy_mode: None,
|
||||
held_reading: None,
|
||||
search: Search::default(),
|
||||
@@ -313,6 +317,7 @@ impl TranscriptView {
|
||||
self.unseen_activity = false;
|
||||
}
|
||||
self.render_selection(buf);
|
||||
self.render_select_cursor(buf);
|
||||
if self.history == TranscriptHistoryState::LoadingBeginning && self.is_following() {
|
||||
self.scroll(current_cells, /*rows*/ 0);
|
||||
}
|
||||
@@ -380,6 +385,7 @@ impl TranscriptView {
|
||||
return;
|
||||
}
|
||||
self.selection = None;
|
||||
self.select_mode = None;
|
||||
self.copy_mode = None;
|
||||
self.release_live_reading();
|
||||
self.cache.clear();
|
||||
@@ -397,7 +403,10 @@ impl TranscriptView {
|
||||
}
|
||||
|
||||
pub(crate) fn has_active_interaction(&self) -> bool {
|
||||
self.selection.is_some() || self.search.is_active() || self.is_activity_focused()
|
||||
self.select_mode.is_some()
|
||||
|| self.selection.is_some()
|
||||
|| self.search.is_active()
|
||||
|| self.is_activity_focused()
|
||||
}
|
||||
|
||||
pub(crate) fn is_detailed(&self) -> bool {
|
||||
@@ -405,7 +414,7 @@ impl TranscriptView {
|
||||
}
|
||||
|
||||
pub(crate) fn is_following(&self) -> bool {
|
||||
self.selection.is_none() && self.position == Position::Latest
|
||||
self.select_mode.is_none() && self.selection.is_none() && self.position == Position::Latest
|
||||
}
|
||||
|
||||
/// Hold the current reading position until every older page has arrived.
|
||||
@@ -426,6 +435,7 @@ impl TranscriptView {
|
||||
self.cancel_beginning();
|
||||
self.position = Position::Latest;
|
||||
self.selection = None;
|
||||
self.select_mode = None;
|
||||
self.copy_mode = None;
|
||||
self.release_live_reading();
|
||||
self.unseen_activity = false;
|
||||
|
||||
@@ -20,6 +20,7 @@ use super::*;
|
||||
|
||||
pub(crate) enum ViewAction {
|
||||
Changed,
|
||||
ExitSelectMode,
|
||||
Copy(String),
|
||||
CopyOnSelect(String),
|
||||
PrimarySelection(String),
|
||||
@@ -96,6 +97,7 @@ impl TranscriptView {
|
||||
clear_selection: self.copy_on_select_clear_selection,
|
||||
},
|
||||
ViewAction::Changed
|
||||
| ViewAction::ExitSelectMode
|
||||
| ViewAction::PrimarySelection(_)
|
||||
| ViewAction::Copy(_)
|
||||
| ViewAction::CopyAndFollow(_)
|
||||
@@ -159,6 +161,9 @@ impl TranscriptView {
|
||||
if self.copy_mode.is_some() {
|
||||
return true;
|
||||
}
|
||||
if self.select_mode.is_some() {
|
||||
return true;
|
||||
}
|
||||
if self.selection.is_some() {
|
||||
return crate::text_selection::is_copy_key(key)
|
||||
|| code == KeyCode::Esc
|
||||
@@ -192,6 +197,11 @@ impl TranscriptView {
|
||||
if let Some(action) = self.handle_copy_mode_key(key, cells) {
|
||||
return Some(action);
|
||||
}
|
||||
if self.select_mode.is_some() {
|
||||
return self
|
||||
.handle_select_mode_key(key, cells)
|
||||
.or(Some(ViewAction::Changed));
|
||||
}
|
||||
if let Some(action) = self.handle_disclosure_key(key, cells) {
|
||||
return Some(action);
|
||||
}
|
||||
@@ -221,6 +231,9 @@ impl TranscriptView {
|
||||
event: MouseEvent,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
) -> Option<ViewAction> {
|
||||
if self.select_mode.is_some() {
|
||||
return self.handle_selection_mouse(event, cells);
|
||||
}
|
||||
self.handle_mouse_with_mode(event, cells, MouseMode::Interactive)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
//! 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;
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Basic cursor motions and marked-range derivation for keyboard transcript selection.
|
||||
|
||||
use super::*;
|
||||
use crossterm::event::KeyCode;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
impl TranscriptView {
|
||||
pub(super) fn move_select_cursor(
|
||||
&mut self,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
motion: CursorMotion,
|
||||
) {
|
||||
let Some(mut mode) = self.select_mode else {
|
||||
return;
|
||||
};
|
||||
self.cancel_beginning();
|
||||
let Some(selection) = self.selection.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let cursor = mode.cursor(selection);
|
||||
let snapshot = self.snapshot_cells();
|
||||
let cells = snapshot.as_deref().unwrap_or(cells);
|
||||
let next = match motion {
|
||||
CursorMotion::Character(code) => {
|
||||
mode.preferred_column = None;
|
||||
self.move_select_character(cells, cursor, code)
|
||||
}
|
||||
CursorMotion::Rows(rows) => {
|
||||
let index = self.resolve(cells, cursor);
|
||||
let layout = self.layout(cells, index);
|
||||
let column = mode.preferred_column.or_else(|| {
|
||||
layout.as_ref().map(|layout| {
|
||||
let row = layout
|
||||
.row_for_offset(cursor.offset)
|
||||
.saturating_add_signed(cursor.row_bias.saturating_neg());
|
||||
layout.column_for_offset_on_row(row, cursor.offset)
|
||||
})
|
||||
});
|
||||
mode.preferred_column = column;
|
||||
self.move_select_rows(cells, cursor, rows, column)
|
||||
}
|
||||
};
|
||||
let Some(next) = next else {
|
||||
self.select_mode = Some(mode);
|
||||
return;
|
||||
};
|
||||
if let Some(mark) = &mut mode.mark {
|
||||
mark.active = next;
|
||||
} else if let Some(selection) = &mut self.selection {
|
||||
selection.set_keyboard_range(next, next);
|
||||
}
|
||||
self.select_mode = Some(mode);
|
||||
self.sync_select_range(cells);
|
||||
self.reveal_select_cursor(cells);
|
||||
}
|
||||
|
||||
fn move_select_character(
|
||||
&mut self,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
cursor: Anchor,
|
||||
code: KeyCode,
|
||||
) -> Option<Anchor> {
|
||||
let mut index = self.resolve(cells, cursor);
|
||||
let layout = self.layout(cells, index)?;
|
||||
let offset = layout
|
||||
.text()
|
||||
.floor_char_boundary(cursor.offset.min(layout.text().len()));
|
||||
let local = match code {
|
||||
KeyCode::Left if offset > 0 => selectable_graphemes(&layout.text()[..offset])
|
||||
.rfind(|(offset, _)| layout.is_cursor_position(*offset))
|
||||
.map(|(offset, _)| offset),
|
||||
KeyCode::Right if offset < layout.text().len() => {
|
||||
selectable_graphemes(&layout.text()[offset..])
|
||||
.filter(|(next, _)| layout.is_cursor_position(offset + *next))
|
||||
.find(|(next, _)| cursor.row_bias != 0 || *next > 0)
|
||||
.map(|(next, _)| offset + next)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(offset) = local {
|
||||
return Some(Anchor {
|
||||
key: self.entry_key(cells, index),
|
||||
index,
|
||||
offset,
|
||||
row_bias: 0,
|
||||
});
|
||||
}
|
||||
loop {
|
||||
index = match code {
|
||||
KeyCode::Left => index.checked_sub(1)?,
|
||||
KeyCode::Right => index + 1,
|
||||
_ => return None,
|
||||
};
|
||||
let next = self.layout(cells, index)?;
|
||||
let offset = match code {
|
||||
KeyCode::Left => selectable_graphemes(next.text())
|
||||
.rfind(|(offset, _)| next.is_cursor_position(*offset))
|
||||
.map(|(offset, _)| offset),
|
||||
KeyCode::Right => selectable_graphemes(next.text())
|
||||
.find(|(offset, _)| next.is_cursor_position(*offset))
|
||||
.map(|(offset, _)| offset),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(offset) = offset {
|
||||
return Some(Anchor {
|
||||
key: self.entry_key(cells, index),
|
||||
index,
|
||||
offset,
|
||||
row_bias: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn move_select_rows(
|
||||
&mut self,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
mut cursor: Anchor,
|
||||
rows: isize,
|
||||
column: Option<u16>,
|
||||
) -> Option<Anchor> {
|
||||
let code = if rows < 0 { KeyCode::Up } else { KeyCode::Down };
|
||||
for _ in 0..rows.unsigned_abs() {
|
||||
let Some(mut next) = self.move_selection_endpoint(cells, cursor, code, column) else {
|
||||
break;
|
||||
};
|
||||
let index = self.resolve(cells, next);
|
||||
if let Some(layout) = self.layout(cells, index) {
|
||||
let row = layout
|
||||
.row_for_offset(next.offset)
|
||||
.saturating_add_signed(next.row_bias.saturating_neg());
|
||||
next.offset = layout.cursor_position_at(row, column.unwrap_or_default());
|
||||
next.row_bias = layout.row_for_offset(next.offset) as isize - row as isize;
|
||||
}
|
||||
if next == cursor {
|
||||
break;
|
||||
}
|
||||
cursor = next;
|
||||
}
|
||||
Some(cursor)
|
||||
}
|
||||
|
||||
pub(super) fn select_boundary(
|
||||
&mut self,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
end: bool,
|
||||
) -> Option<Anchor> {
|
||||
let count = cells.len() + usize::from(self.layout(cells, cells.len()).is_some());
|
||||
let mut index = if end { count.checked_sub(1)? } else { 0 };
|
||||
loop {
|
||||
let layout = self.layout(cells, index)?;
|
||||
let offset = if end {
|
||||
selectable_graphemes(layout.text())
|
||||
.rfind(|(offset, _)| layout.is_cursor_position(*offset))
|
||||
.map(|(offset, _)| offset)
|
||||
} else {
|
||||
selectable_graphemes(layout.text())
|
||||
.find(|(offset, _)| layout.is_cursor_position(*offset))
|
||||
.map(|(offset, _)| offset)
|
||||
};
|
||||
if let Some(offset) = offset {
|
||||
return Some(Anchor {
|
||||
key: self.entry_key(cells, index),
|
||||
index,
|
||||
offset,
|
||||
row_bias: 0,
|
||||
});
|
||||
}
|
||||
if end {
|
||||
index = index.checked_sub(1)?;
|
||||
} else {
|
||||
index += 1;
|
||||
if index >= count {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn sync_select_range(&mut self, cells: &[Arc<dyn HistoryCell>]) {
|
||||
let Some(mark) = self.select_mode.and_then(|mode| mode.mark) else {
|
||||
return;
|
||||
};
|
||||
let snapshot = self.snapshot_cells();
|
||||
let cells = snapshot.as_deref().unwrap_or(cells);
|
||||
let after_anchor = self.after_grapheme(cells, mark.anchor);
|
||||
let after_active = self.after_grapheme(cells, mark.active);
|
||||
let Some(mut selection) = self.selection.take() else {
|
||||
return;
|
||||
};
|
||||
let (start, end) =
|
||||
if self.anchor_order(cells, mark.anchor) <= self.anchor_order(cells, mark.active) {
|
||||
(mark.anchor, after_active)
|
||||
} else {
|
||||
(after_anchor, mark.active)
|
||||
};
|
||||
selection.set_keyboard_marked_range(
|
||||
start,
|
||||
end,
|
||||
(mark.anchor, after_anchor),
|
||||
SelectionUnit::Character,
|
||||
);
|
||||
self.pin_selection_range(cells, &mut selection);
|
||||
self.selection = Some(selection);
|
||||
}
|
||||
|
||||
fn after_grapheme(&mut self, cells: &[Arc<dyn HistoryCell>], anchor: Anchor) -> Anchor {
|
||||
let index = self.resolve(cells, anchor);
|
||||
let Some(layout) = self.layout(cells, index) else {
|
||||
return anchor;
|
||||
};
|
||||
let offset = layout
|
||||
.text()
|
||||
.floor_char_boundary(anchor.offset.min(layout.text().len()));
|
||||
let row = layout
|
||||
.row_for_offset(offset)
|
||||
.saturating_add_signed(anchor.row_bias.saturating_neg());
|
||||
let offset = layout.after_cursor_grapheme(row, offset);
|
||||
Anchor {
|
||||
key: self.entry_key(cells, index),
|
||||
index,
|
||||
offset,
|
||||
row_bias: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn anchor_order(&self, cells: &[Arc<dyn HistoryCell>], anchor: Anchor) -> (usize, usize) {
|
||||
(self.resolve(cells, anchor), anchor.offset)
|
||||
}
|
||||
}
|
||||
|
||||
fn selectable_graphemes(text: &str) -> impl DoubleEndedIterator<Item = (usize, &str)> {
|
||||
text.grapheme_indices(/*is_extended*/ true)
|
||||
.filter(|(_, grapheme)| !grapheme.contains('\n'))
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
use super::*;
|
||||
use crate::history_cell::PlainHistoryCell;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::text::Line;
|
||||
|
||||
fn cell(lines: &[&str]) -> Arc<dyn HistoryCell> {
|
||||
Arc::new(PlainHistoryCell::new(
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| Line::from((*line).to_owned()))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
fn render(view: &mut TranscriptView, cells: &[Arc<dyn HistoryCell>]) -> Buffer {
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 32, /*height*/ 6,
|
||||
);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
view.render(area, &mut buffer, cells);
|
||||
buffer
|
||||
}
|
||||
|
||||
fn press(view: &mut TranscriptView, cells: &[Arc<dyn HistoryCell>], code: KeyCode) {
|
||||
assert!(matches!(
|
||||
view.handle_key(KeyEvent::new(code, KeyModifiers::NONE), cells),
|
||||
Some(ViewAction::Changed)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_starts_on_the_last_selectable_visible_line() {
|
||||
for (cells, expected) in [
|
||||
(vec![cell(&["top"]), cell(&["bottom"])], "b"),
|
||||
(vec![cell(&["text"]), cell(&[""])], "t"),
|
||||
(vec![cell(&["abc", "", "", "", "", "", ""])], "c"),
|
||||
] {
|
||||
let mut view = TranscriptView::default();
|
||||
render(&mut view, &cells);
|
||||
assert!(view.begin_select_mode(&cells));
|
||||
press(&mut view, &cells, KeyCode::Char('v'));
|
||||
assert_eq!(view.selected_text(&cells).as_deref(), Some(expected));
|
||||
}
|
||||
|
||||
let cells = vec![cell(&["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"])];
|
||||
let mut view = TranscriptView::default();
|
||||
render(&mut view, &cells);
|
||||
view.scroll(&cells, /*rows*/ -2);
|
||||
render(&mut view, &cells);
|
||||
assert!(view.begin_select_mode(&cells));
|
||||
press(&mut view, &cells, KeyCode::Char('v'));
|
||||
assert_eq!(view.selected_text(&cells).as_deref(), Some("h"));
|
||||
|
||||
let cells = vec![cell(&["a", "", "", "", "", "", "", "", "", ""])];
|
||||
let mut view = TranscriptView::default();
|
||||
render(&mut view, &cells);
|
||||
assert!(view.begin_select_mode(&cells));
|
||||
assert!(matches!(view.position, Position::Reading(_)));
|
||||
|
||||
let cells = vec![cell(&[""])];
|
||||
let mut view = TranscriptView::default();
|
||||
render(&mut view, &cells);
|
||||
assert!(!view.begin_select_mode(&cells));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_motion_crosses_line_and_entry_boundaries_without_invisible_stops() {
|
||||
let cells = vec![cell(&["a", ""]), cell(&[""]), cell(&["", "b"])];
|
||||
let mut view = TranscriptView::default();
|
||||
render(&mut view, &cells);
|
||||
assert!(view.begin_select_mode(&cells));
|
||||
press(&mut view, &cells, KeyCode::Left);
|
||||
press(&mut view, &cells, KeyCode::Char('v'));
|
||||
assert_eq!(view.selected_text(&cells).as_deref(), Some("a"));
|
||||
|
||||
press(&mut view, &cells, KeyCode::Esc);
|
||||
press(&mut view, &cells, KeyCode::Right);
|
||||
press(&mut view, &cells, KeyCode::Char('v'));
|
||||
assert_eq!(view.selected_text(&cells).as_deref(), Some("b"));
|
||||
|
||||
let cells = vec![cell(&["ab"])];
|
||||
let mut view = TranscriptView::default();
|
||||
render(&mut view, &cells);
|
||||
assert!(view.begin_select_mode(&cells));
|
||||
let separator = Anchor {
|
||||
key: EntryKey::cell(&cells[0]),
|
||||
index: 0,
|
||||
offset: 0,
|
||||
row_bias: 1,
|
||||
};
|
||||
view.selection
|
||||
.as_mut()
|
||||
.expect("selection")
|
||||
.set_keyboard_range(separator, separator);
|
||||
press(&mut view, &cells, KeyCode::Right);
|
||||
press(&mut view, &cells, KeyCode::Char('v'));
|
||||
assert_eq!(view.selected_text(&cells).as_deref(), Some("a"));
|
||||
|
||||
let wrapped = format!("{} one", "1234567890".repeat(3));
|
||||
let cells = vec![cell(&[&wrapped])];
|
||||
let mut view = TranscriptView::default();
|
||||
render(&mut view, &cells);
|
||||
assert!(view.begin_select_mode(&cells));
|
||||
press(&mut view, &cells, KeyCode::Left);
|
||||
press(&mut view, &cells, KeyCode::Char('v'));
|
||||
assert_eq!(view.selected_text(&cells).as_deref(), Some("0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_motion_keeps_the_preferred_display_column() {
|
||||
let cells = vec![cell(&["abcdef", "x", "abcdef"])];
|
||||
let mut view = TranscriptView::default();
|
||||
render(&mut view, &cells);
|
||||
assert!(view.begin_select_mode(&cells));
|
||||
for _ in 0..3 {
|
||||
press(&mut view, &cells, KeyCode::Right);
|
||||
}
|
||||
press(&mut view, &cells, KeyCode::Up);
|
||||
press(&mut view, &cells, KeyCode::Up);
|
||||
press(&mut view, &cells, KeyCode::Char('v'));
|
||||
assert_eq!(view.selected_text(&cells).as_deref(), Some("d"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_without_a_mark_exits_but_enter_with_a_mark_copies_and_follows() {
|
||||
let cells = vec![cell(&["copy me"])];
|
||||
let mut view = TranscriptView::default();
|
||||
render(&mut view, &cells);
|
||||
assert!(view.begin_select_mode(&cells));
|
||||
assert!(matches!(
|
||||
view.handle_key(KeyCode::Enter.into(), &cells),
|
||||
Some(ViewAction::ExitSelectMode)
|
||||
));
|
||||
|
||||
assert!(view.begin_select_mode(&cells));
|
||||
press(&mut view, &cells, KeyCode::Char('v'));
|
||||
assert!(matches!(
|
||||
view.handle_key(
|
||||
KeyEvent::new_with_kind(KeyCode::Char('v'), KeyModifiers::NONE, KeyEventKind::Repeat,),
|
||||
&cells,
|
||||
),
|
||||
Some(ViewAction::Changed)
|
||||
));
|
||||
assert!(matches!(
|
||||
view.handle_key(KeyCode::Enter.into(), &cells),
|
||||
Some(ViewAction::CopyAndFollow(text)) if text == "c"
|
||||
));
|
||||
}
|
||||
@@ -41,6 +41,51 @@ pub(super) struct Selection {
|
||||
preferred_column: Option<u16>,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
pub(super) fn keyboard(snapshot: ViewSnapshot, cursor: Anchor) -> Self {
|
||||
Self {
|
||||
pending_copy: None,
|
||||
primary_owner: None,
|
||||
snapshot,
|
||||
start: cursor,
|
||||
end: cursor,
|
||||
dragging: false,
|
||||
moved: true,
|
||||
moved_vertically: false,
|
||||
pointer_origin_row: 0,
|
||||
resume_on_empty: false,
|
||||
pointer: None,
|
||||
pressed_link: None,
|
||||
origin: (cursor, cursor),
|
||||
unit: SelectionUnit::Character,
|
||||
preferred_column: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_keyboard_range(&mut self, start: Anchor, end: Anchor) {
|
||||
if (self.start, self.end) != (start, end) {
|
||||
self.primary_owner = None;
|
||||
self.pending_copy = None;
|
||||
}
|
||||
self.start = start;
|
||||
self.end = end;
|
||||
self.origin = (start, end);
|
||||
self.preferred_column = None;
|
||||
}
|
||||
|
||||
pub(super) fn set_keyboard_marked_range(
|
||||
&mut self,
|
||||
start: Anchor,
|
||||
end: Anchor,
|
||||
origin: (Anchor, Anchor),
|
||||
unit: SelectionUnit,
|
||||
) {
|
||||
self.set_keyboard_range(start, end);
|
||||
self.origin = origin;
|
||||
self.unit = unit;
|
||||
}
|
||||
}
|
||||
|
||||
impl TranscriptView {
|
||||
pub(super) fn select_copy_range(
|
||||
&mut self,
|
||||
@@ -139,6 +184,14 @@ impl TranscriptView {
|
||||
let Some(selection) = self.selection.take() else {
|
||||
return;
|
||||
};
|
||||
if let Some(mode) = &mut self.select_mode {
|
||||
let cursor = mode.cursor(&selection);
|
||||
mode.mark = None;
|
||||
let mut selection = selection;
|
||||
selection.set_keyboard_range(cursor, cursor);
|
||||
self.selection = Some(selection);
|
||||
return;
|
||||
}
|
||||
let empty = (selection.start.key, selection.start.offset)
|
||||
== (selection.end.key, selection.end.offset);
|
||||
if empty && selection.resume_on_empty {
|
||||
@@ -394,7 +447,9 @@ impl TranscriptView {
|
||||
self.publish_primary(tui, text);
|
||||
return None;
|
||||
}
|
||||
ViewAction::Changed | ViewAction::OpenLink(_) => return None,
|
||||
ViewAction::Changed | ViewAction::ExitSelectMode | ViewAction::OpenLink(_) => {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let selection_copy = self.selection_copy(action);
|
||||
let clear_selection = selection_copy.clear_selection();
|
||||
@@ -695,7 +750,11 @@ impl TranscriptView {
|
||||
}
|
||||
}
|
||||
|
||||
fn pin_selection_range(&mut self, cells: &[Arc<dyn HistoryCell>], selection: &mut Selection) {
|
||||
pub(super) fn pin_selection_range(
|
||||
&mut self,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
selection: &mut Selection,
|
||||
) {
|
||||
selection.start.index = self.resolve(cells, selection.start);
|
||||
selection.end.index = self.resolve(cells, selection.end);
|
||||
for index in selection.start.index.min(selection.end.index)
|
||||
@@ -710,7 +769,7 @@ impl TranscriptView {
|
||||
}
|
||||
}
|
||||
|
||||
fn move_selection_endpoint(
|
||||
pub(super) fn move_selection_endpoint(
|
||||
&mut self,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
end: Anchor,
|
||||
|
||||
@@ -314,6 +314,53 @@ impl TextLayout {
|
||||
row.source.end
|
||||
}
|
||||
|
||||
/// Resolve a keyboard caret to a grapheme start while leaving truly empty rows blank.
|
||||
pub(super) fn cursor_position_at(&self, row: usize, column: u16) -> usize {
|
||||
let offset = self.position_at(row, column);
|
||||
let Some(row) = self.rows.get(row) else {
|
||||
return offset;
|
||||
};
|
||||
if row.source.start == row.source.end || offset < row.source.end {
|
||||
return offset;
|
||||
}
|
||||
self.text[row.source.clone()]
|
||||
.grapheme_indices(/*is_extended*/ true)
|
||||
.next_back()
|
||||
.map_or(row.source.start, |(offset, _)| row.source.start + offset)
|
||||
}
|
||||
|
||||
/// Return the first selectable source grapheme on a display row, excluding synthetic rows.
|
||||
pub(super) fn first_cursor_position(&self, row: usize) -> Option<usize> {
|
||||
let row = self.rows.get(row)?;
|
||||
self.text[row.source.clone()]
|
||||
.grapheme_indices(/*is_extended*/ true)
|
||||
.next()
|
||||
.map(|(offset, _)| row.source.start + offset)
|
||||
}
|
||||
|
||||
/// Whether a source grapheme is actually painted on a display row.
|
||||
pub(super) fn is_cursor_position(&self, offset: usize) -> bool {
|
||||
self.rows.iter().any(|row| row.source.contains(&offset))
|
||||
}
|
||||
|
||||
/// Advance one grapheme inside the caret's display row without crossing an empty row.
|
||||
pub(super) fn after_cursor_grapheme(&self, row: usize, offset: usize) -> usize {
|
||||
let Some(row) = self.rows.get(row) else {
|
||||
return offset;
|
||||
};
|
||||
if row.source.start == row.source.end {
|
||||
return offset;
|
||||
}
|
||||
let offset = self
|
||||
.text
|
||||
.floor_char_boundary(offset.clamp(row.source.start, row.source.end));
|
||||
offset
|
||||
+ self.text[offset..row.source.end]
|
||||
.graphemes(/*is_extended*/ true)
|
||||
.next()
|
||||
.map_or(/*default*/ 0, str::len)
|
||||
}
|
||||
|
||||
/// Find the display row containing a source position, including omitted wrapping whitespace.
|
||||
pub(super) fn row_for_offset(&self, offset: usize) -> usize {
|
||||
let before_control = if let Some(control_row) = self.disclosure_row() {
|
||||
@@ -332,7 +379,12 @@ impl TextLayout {
|
||||
}
|
||||
|
||||
pub(super) fn column_for_offset(&self, offset: usize) -> u16 {
|
||||
let Some(row) = self.rows.get(self.row_for_offset(offset)) else {
|
||||
self.column_for_offset_on_row(self.row_for_offset(offset), offset)
|
||||
}
|
||||
|
||||
/// Resolve a source position within a specific display row when offsets are shared.
|
||||
pub(super) fn column_for_offset_on_row(&self, row: usize, offset: usize) -> u16 {
|
||||
let Some(row) = self.rows.get(row) else {
|
||||
return 0;
|
||||
};
|
||||
let offset = self
|
||||
|
||||
Reference in New Issue
Block a user