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:
Felipe Coury
2026-10-13 15:11:10 +00:00
committed by copyberry
parent 979771476a
commit 31434401fa
7 changed files with 789 additions and 6 deletions
+12 -2
View File
@@ -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;
+13
View File
@@ -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"
));
}
+62 -3
View File
@@ -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,
+53 -1
View File
@@ -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