Expand keyboard navigation and selection in the transcript (#57221)

## What changed

- Add word motions (`w`, `b`, `e`), logical line boundaries, page and half-page motions, and jumps to the transcript's beginning and end. Honor configured pager bindings for navigation.
- Add whole-line selection with `V` and active-end swapping with `o`.
- Adopt existing mouse selections when entering select mode, preserving selection units and endpoint bounds so keyboard motions can adjust the range.

## Testing

Add regression tests for word motion across entries, reverse whole-line selection, empty-line boundaries, configured pager motion, final-grapheme selection, endpoint swapping, and caret placement after copying.

GitOrigin-RevId: f0cd05900f2a7a15840777cee4d50659a2c8b134
This commit is contained in:
Felipe Coury
2026-10-26 20:03:10 +00:00
committed by copyberry
parent 20f5dde714
commit abf4bb91ec
4 changed files with 588 additions and 21 deletions
+160 -6
View File
@@ -5,6 +5,7 @@
//! `Selection`.
use super::*;
use crate::key_hint::KeyBindingListExt;
use crate::text_selection::SelectionUnit;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -25,12 +26,52 @@ pub(super) struct SelectMode {
pub(super) struct Mark {
anchor: Anchor,
active: Anchor,
anchor_edges: Option<(Anchor, Anchor)>,
active_edges: Option<(Anchor, Anchor)>,
unit: SelectionUnit,
}
#[derive(Clone, Copy)]
enum CursorMotion {
Character(KeyCode),
Rows(isize),
WordForward,
WordBackward,
WordEnd,
LineStart,
LineEnd,
Beginning,
End,
}
fn pager_motion(
keymap: &crate::keymap::RuntimeKeymap,
key: KeyEvent,
area_height: u16,
) -> Option<CursorMotion> {
let pager = &keymap.pager;
if pager.jump_top.is_pressed(key) {
return Some(CursorMotion::Beginning);
}
if pager.jump_bottom.is_pressed(key) {
return Some(CursorMotion::End);
}
let page = isize::try_from(area_height.saturating_sub(1).max(1)).unwrap_or(isize::MAX);
let half = isize::try_from(area_height.div_ceil(2)).unwrap_or(isize::MAX);
[
(&pager.scroll_up, -1),
(&pager.scroll_down, 1),
(&pager.page_up, -page),
(&pager.page_down, page),
(&pager.half_page_up, -half),
(&pager.half_page_down, half),
]
.into_iter()
.find_map(|(action_bindings, rows)| {
action_bindings
.is_pressed(key)
.then_some(CursorMotion::Rows(rows))
})
}
impl SelectMode {
@@ -48,6 +89,7 @@ impl TranscriptView {
}
self.copy_mode = None;
self.cancel_beginning();
let has_range = self.has_selection_range();
if self.selection.is_none() {
let cursor = self
.visible
@@ -74,6 +116,9 @@ impl TranscriptView {
self.hold_position();
}
self.select_mode = Some(SelectMode::default());
if has_range {
self.adopt_select_mouse_range();
}
self.reveal_select_cursor(cells);
true
}
@@ -134,17 +179,24 @@ impl TranscriptView {
&& matches!(code, KeyCode::Char('v') | KeyCode::Char(' '))
&& modifiers == KeyModifiers::NONE
{
self.start_select_mark(cells);
self.start_select_mark(cells, SelectionUnit::Character);
return Some(ViewAction::Changed);
}
if pressed && code == KeyCode::Char('v') && modifiers == KeyModifiers::SHIFT {
self.start_select_mark(cells, SelectionUnit::Line);
return Some(ViewAction::Changed);
}
if pressed && code == KeyCode::Char('o') && modifiers == KeyModifiers::NONE {
self.swap_select_ends(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);
self.start_select_mark(cells, SelectionUnit::Character);
}
let motion = match (code, modifiers) {
(
@@ -161,6 +213,38 @@ impl TranscriptView {
(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),
(KeyCode::Char('w'), KeyModifiers::NONE) => CursorMotion::WordForward,
(KeyCode::Char('b'), KeyModifiers::NONE) => CursorMotion::WordBackward,
(KeyCode::Char('e'), KeyModifiers::NONE) => CursorMotion::WordEnd,
(KeyCode::Home | KeyCode::Char('0'), KeyModifiers::NONE) => CursorMotion::LineStart,
(KeyCode::End, KeyModifiers::NONE)
| (KeyCode::Char('$'), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
CursorMotion::LineEnd
}
(KeyCode::PageUp, KeyModifiers::NONE) | (KeyCode::Char('b'), KeyModifiers::CONTROL) => {
CursorMotion::Rows(
-isize::try_from(self.area.height.saturating_sub(1).max(1))
.unwrap_or(isize::MAX),
)
}
(KeyCode::PageDown, KeyModifiers::NONE)
| (KeyCode::Char('f'), KeyModifiers::CONTROL) => CursorMotion::Rows(
isize::try_from(self.area.height.saturating_sub(1).max(1)).unwrap_or(isize::MAX),
),
(KeyCode::Char('u'), KeyModifiers::CONTROL) => CursorMotion::Rows(
-isize::try_from(self.area.height.div_ceil(2)).unwrap_or(isize::MAX),
),
(KeyCode::Char('d'), KeyModifiers::CONTROL) => CursorMotion::Rows(
isize::try_from(self.area.height.div_ceil(2)).unwrap_or(isize::MAX),
),
(KeyCode::Char('g'), KeyModifiers::NONE) | (KeyCode::Home, KeyModifiers::CONTROL) => {
CursorMotion::Beginning
}
(KeyCode::Char('g'), KeyModifiers::SHIFT) => CursorMotion::End,
(KeyCode::End, KeyModifiers::CONTROL) => CursorMotion::End,
_ if !self.is_search_editing() => {
pager_motion(&self.disclosure.keymap, key, self.area.height)?
}
_ => return None,
};
self.move_select_cursor(cells, motion);
@@ -172,7 +256,7 @@ impl TranscriptView {
ViewAction::ExitSelectMode
}
fn start_select_mark(&mut self, cells: &[Arc<dyn HistoryCell>]) {
fn start_select_mark(&mut self, cells: &[Arc<dyn HistoryCell>], unit: SelectionUnit) {
let Some(mut mode) = self.select_mode else {
return;
};
@@ -181,13 +265,22 @@ impl TranscriptView {
};
let cursor = mode.cursor(selection);
mode.mark = match mode.mark {
Some(_) => {
Some(mark) if mark.unit == unit => {
self.clear_select_mark();
return;
}
Some(mark) => Some(Mark {
anchor_edges: None,
active_edges: None,
unit,
..mark
}),
None => Some(Mark {
anchor: cursor,
active: cursor,
anchor_edges: None,
active_edges: None,
unit,
}),
};
mode.preferred_column = None;
@@ -208,7 +301,21 @@ impl TranscriptView {
selection.set_keyboard_range(cursor, cursor);
}
fn reveal_select_cursor(&mut self, cells: &[Arc<dyn HistoryCell>]) {
fn swap_select_ends(&mut self, cells: &[Arc<dyn HistoryCell>]) {
let Some(mode) = &mut self.select_mode else {
return;
};
let Some(mark) = &mut mode.mark else {
return;
};
std::mem::swap(&mut mark.anchor, &mut mark.active);
std::mem::swap(&mut mark.anchor_edges, &mut mark.active_edges);
mode.preferred_column = None;
self.sync_select_range(cells);
self.reveal_select_cursor(cells);
}
pub(super) fn reveal_select_cursor(&mut self, cells: &[Arc<dyn HistoryCell>]) {
let Some(mode) = self.select_mode else {
return;
};
@@ -259,6 +366,53 @@ impl TranscriptView {
buf.set_style(area, crate::style::selection_style().underlined());
}
}
pub(super) fn adopt_select_mouse_range(&mut self) {
let has_range = self.has_selection_range();
let Some(selection) = &self.selection else {
return;
};
let (start, end, unit) = (selection.start, selection.end, selection.unit());
let snapshot = self.snapshot_cells();
let cells = snapshot.as_deref().unwrap_or_default();
let mark = has_range.then(|| {
if self.anchor_order(cells, start) <= self.anchor_order(cells, end) {
let active = self.before_grapheme(cells, end);
let anchor_end = self
.selection_endpoint_bounds(cells, start, unit)
.map_or(start, |edges| edges.1);
let active_start = self
.selection_endpoint_bounds(cells, active, unit)
.map_or(active, |edges| edges.0);
Mark {
anchor: start,
active,
anchor_edges: Some((start, anchor_end)),
active_edges: Some((active_start, end)),
unit,
}
} else {
let anchor = self.before_grapheme(cells, start);
let anchor_start = self
.selection_endpoint_bounds(cells, anchor, unit)
.map_or(anchor, |edges| edges.0);
let active_end = self
.selection_endpoint_bounds(cells, end, unit)
.map_or(end, |edges| edges.1);
Mark {
anchor,
active: end,
anchor_edges: Some((anchor_start, start)),
active_edges: Some((end, active_end)),
unit,
}
}
});
if let Some(mode) = &mut self.select_mode {
mode.preferred_column = None;
mode.mark = mark;
}
}
}
#[cfg(test)]
@@ -1,4 +1,4 @@
//! Basic cursor motions and marked-range derivation for keyboard transcript selection.
//! Cursor motions and marked-range derivation for keyboard transcript selection.
use super::*;
use crossterm::event::KeyCode;
@@ -13,7 +13,9 @@ impl TranscriptView {
let Some(mut mode) = self.select_mode else {
return;
};
self.cancel_beginning();
if !matches!(motion, CursorMotion::Beginning) {
self.cancel_beginning();
}
let Some(selection) = self.selection.as_ref() else {
return;
};
@@ -39,6 +41,32 @@ impl TranscriptView {
mode.preferred_column = column;
self.move_select_rows(cells, cursor, rows, column)
}
CursorMotion::WordForward => {
mode.preferred_column = None;
self.move_select_word(
cells, cursor, /*backward*/ false, /*to_end*/ false,
)
}
CursorMotion::WordBackward => {
mode.preferred_column = None;
self.move_select_word(cells, cursor, /*backward*/ true, /*to_end*/ false)
}
CursorMotion::WordEnd => {
mode.preferred_column = None;
self.move_select_word(cells, cursor, /*backward*/ false, /*to_end*/ true)
}
CursorMotion::LineStart | CursorMotion::LineEnd => {
mode.preferred_column = None;
self.move_select_line(cells, cursor, matches!(motion, CursorMotion::LineEnd))
}
CursorMotion::Beginning | CursorMotion::End => {
mode.preferred_column = None;
if matches!(motion, CursorMotion::Beginning) && self.history.has_unloaded_history()
{
self.jump_to_beginning(cells);
}
self.select_boundary(cells, matches!(motion, CursorMotion::End))
}
};
let Some(next) = next else {
self.select_mode = Some(mode);
@@ -46,6 +74,7 @@ impl TranscriptView {
};
if let Some(mark) = &mut mode.mark {
mark.active = next;
mark.active_edges = None;
} else if let Some(selection) = &mut self.selection {
selection.set_keyboard_range(next, next);
}
@@ -140,6 +169,96 @@ impl TranscriptView {
Some(cursor)
}
fn move_select_word(
&mut self,
cells: &[Arc<dyn HistoryCell>],
cursor: Anchor,
backward: bool,
to_end: bool,
) -> Option<Anchor> {
let mut index = self.resolve(cells, cursor);
let origin_index = index;
loop {
let layout = self.layout(cells, index)?;
let text = layout.text();
let offset = if index == origin_index {
text.floor_char_boundary(cursor.offset.min(text.len()))
} else if backward {
text.len()
} else {
0
};
let mut words = text
.split_word_bound_indices()
.filter(|(_, word)| word.chars().any(char::is_alphanumeric));
let found = if backward {
words
.rfind(|(start, _)| *start < offset)
.map(|(start, _)| start)
} else if to_end {
words
.filter_map(|(start, word)| {
text[..start + word.len()]
.grapheme_indices(/*is_extended*/ true)
.next_back()
.map(|(offset, _)| offset)
})
.find(|end| index != origin_index || *end > offset)
} else {
words
.find(|(start, _)| {
index != origin_index
|| *start > offset
|| (cursor.row_bias > 0 && *start == offset)
})
.map(|(start, _)| start)
};
if let Some(offset) = found {
return Some(Anchor {
key: self.entry_key(cells, index),
index,
offset,
row_bias: 0,
});
}
if backward {
index = index.checked_sub(1)?;
} else {
index += 1;
self.layout(cells, index)?;
}
}
}
fn move_select_line(
&mut self,
cells: &[Arc<dyn HistoryCell>],
cursor: Anchor,
end: bool,
) -> Option<Anchor> {
let index = self.resolve(cells, cursor);
let layout = self.layout(cells, index)?;
let range = SelectionUnit::Line.range(layout.text(), cursor.offset);
let offset = if end {
let end = range.end.saturating_sub(usize::from(
range.end > range.start
&& layout.text().as_bytes().get(range.end - 1) == Some(&b'\n'),
));
layout.text()[range.start..end]
.grapheme_indices(/*is_extended*/ true)
.next_back()
.map_or(range.start, |(offset, _)| range.start + offset)
} else {
range.start
};
Some(Anchor {
key: self.entry_key(cells, index),
index,
offset,
row_bias: 0,
})
}
pub(super) fn select_boundary(
&mut self,
cells: &[Arc<dyn HistoryCell>],
@@ -185,21 +304,56 @@ impl TranscriptView {
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 origin = match mark.unit {
_ if mark.anchor_edges.is_some() => mark.anchor_edges,
SelectionUnit::Character => Some((mark.anchor, after_anchor)),
SelectionUnit::Word | SelectionUnit::Line => {
self.selection_bounds(cells, mark.anchor, mark.unit)
}
};
let Some(origin) = origin else { return };
let active_line = (mark.unit == SelectionUnit::Line)
.then(|| self.selection_bounds(cells, mark.active, SelectionUnit::Line))
.flatten();
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,
);
let anchor_order = self.anchor_order(cells, mark.anchor);
let active_order = self.anchor_order(cells, mark.active);
let forward = anchor_order <= active_order;
let (start, end) = match mark.unit {
SelectionUnit::Character | SelectionUnit::Word => {
if forward {
(
origin.0,
mark.active_edges.map_or(after_active, |edges| edges.1),
)
} else {
(
mark.active_edges.map_or(mark.active, |edges| edges.0),
origin.1,
)
}
}
SelectionUnit::Line => {
let Some(active) = active_line else {
self.selection = Some(selection);
return;
};
if forward {
(
origin.0,
mark.active_edges.map_or(active.1, |edges| edges.1),
)
} else {
(
mark.active_edges.map_or(active.0, |edges| edges.0),
origin.1,
)
}
}
};
selection.set_keyboard_marked_range(start, end, origin, mark.unit);
self.pin_selection_range(cells, &mut selection);
self.selection = Some(selection);
}
@@ -224,7 +378,57 @@ impl TranscriptView {
}
}
fn anchor_order(&self, cells: &[Arc<dyn HistoryCell>], anchor: Anchor) -> (usize, usize) {
pub(super) fn before_grapheme(
&mut self,
cells: &[Arc<dyn HistoryCell>],
anchor: Anchor,
) -> Anchor {
self.move_select_character(cells, anchor, KeyCode::Left)
.unwrap_or(anchor)
}
pub(super) fn selection_endpoint_bounds(
&mut self,
cells: &[Arc<dyn HistoryCell>],
anchor: Anchor,
unit: SelectionUnit,
) -> Option<(Anchor, Anchor)> {
if unit == SelectionUnit::Character {
return Some((anchor, self.after_grapheme(cells, anchor)));
}
self.selection_bounds(cells, anchor, unit)
}
fn selection_bounds(
&mut self,
cells: &[Arc<dyn HistoryCell>],
anchor: Anchor,
unit: SelectionUnit,
) -> Option<(Anchor, Anchor)> {
let index = self.resolve(cells, anchor);
let layout = self.layout(cells, index)?;
let range = unit.range(layout.text(), anchor.offset);
Some((
Anchor {
key: self.entry_key(cells, index),
index,
offset: range.start,
row_bias: 0,
},
Anchor {
key: self.entry_key(cells, index),
index,
offset: range.end,
row_bias: 0,
},
))
}
pub(super) fn anchor_order(
&self,
cells: &[Arc<dyn HistoryCell>],
anchor: Anchor,
) -> (usize, usize) {
(self.resolve(cells, anchor), anchor.offset)
}
}
@@ -1,4 +1,5 @@
use super::*;
use crate::clipboard_copy::CopyStatus;
use crate::history_cell::PlainHistoryCell;
use pretty_assertions::assert_eq;
use ratatui::text::Line;
@@ -104,6 +105,57 @@ fn character_motion_crosses_line_and_entry_boundaries_without_invisible_stops()
press(&mut view, &cells, KeyCode::Left);
press(&mut view, &cells, KeyCode::Char('v'));
assert_eq!(view.selected_text(&cells).as_deref(), Some("0"));
let cells = vec![cell(&["ab"]), cell(&["cd"])];
let mut view = TranscriptView::default();
render(&mut view, &cells);
let start = Anchor {
key: EntryKey::cell(&cells[0]),
index: 0,
offset: 0,
row_bias: 0,
};
let end = Anchor {
key: EntryKey::cell(&cells[1]),
index: 1,
offset: 0,
row_bias: 0,
};
let mut selection = Selection::keyboard(view.capture_snapshot(&cells), start);
selection.set_keyboard_range(start, end);
view.selection = Some(selection);
assert!(view.begin_select_mode(&cells));
assert_eq!(
view.select_mode
.and_then(|mode| mode.mark)
.expect("marked range")
.active,
Anchor { offset: 1, ..start }
);
assert_eq!(view.selected_text(&cells).as_deref(), Some("ab\n"));
press(&mut view, &cells, KeyCode::Char('o'));
assert_eq!(view.selected_text(&cells).as_deref(), Some("ab\n"));
press(&mut view, &cells, KeyCode::Char('l'));
assert_eq!(view.selected_text(&cells).as_deref(), Some("b"));
let cells = vec![cell(&["abc"])];
let mut view = TranscriptView::default();
render(&mut view, &cells);
let start = Anchor {
key: EntryKey::cell(&cells[0]),
index: 0,
offset: 1,
row_bias: 0,
};
let end = Anchor { offset: 3, ..start };
let mut selection = Selection::keyboard(view.capture_snapshot(&cells), start);
selection.set_keyboard_range(start, end);
view.selection = Some(selection);
assert!(view.begin_select_mode(&cells));
press(&mut view, &cells, KeyCode::Char('h'));
assert_eq!(view.selected_text(&cells).as_deref(), Some("b"));
press(&mut view, &cells, KeyCode::Char('h'));
assert_eq!(view.selected_text(&cells).as_deref(), Some("ab"));
}
#[test]
@@ -146,3 +198,156 @@ fn enter_without_a_mark_exits_but_enter_with_a_mark_copies_and_follows() {
Some(ViewAction::CopyAndFollow(text)) if text == "c"
));
}
#[test]
fn word_motion_crosses_entries_and_word_end_selects_the_destination_word() {
let cells = vec![cell(&["alpha"]), cell(&["beta gamma"])];
let mut view = TranscriptView::default();
render(&mut view, &cells);
assert!(view.begin_select_mode(&cells));
press(&mut view, &cells, KeyCode::Char('g'));
press(&mut view, &cells, KeyCode::Char('j'));
press(&mut view, &cells, KeyCode::Char('w'));
press(&mut view, &cells, KeyCode::Char('v'));
press(&mut view, &cells, KeyCode::Char('e'));
assert_eq!(view.selected_text(&cells).as_deref(), Some("beta"));
}
#[test]
fn reverse_line_selection_copies_complete_lines_in_document_order() {
let cells = vec![cell(&["first", "second", "third"])];
let mut view = TranscriptView::default();
render(&mut view, &cells);
assert!(view.begin_select_mode(&cells));
press(&mut view, &cells, KeyCode::Char('g'));
press(&mut view, &cells, KeyCode::Char('j'));
press(&mut view, &cells, KeyCode::Char('j'));
press(&mut view, &cells, KeyCode::Char('V'));
press(&mut view, &cells, KeyCode::Char('k'));
assert_eq!(view.selected_text(&cells).as_deref(), Some("second\nthird"));
}
#[test]
fn logical_line_end_handles_empty_lines_and_shifted_dollar() {
let cells = vec![cell(&["abc", "", "z"])];
let mut view = TranscriptView::default();
render(&mut view, &cells);
assert!(view.begin_select_mode(&cells));
press(&mut view, &cells, KeyCode::Char('g'));
assert!(matches!(
view.handle_key(
KeyEvent::new(KeyCode::Char('$'), KeyModifiers::SHIFT),
&cells,
),
Some(ViewAction::Changed)
));
press(&mut view, &cells, KeyCode::Char('v'));
assert_eq!(view.selected_text(&cells).as_deref(), Some("c"));
press(&mut view, &cells, KeyCode::Esc);
press(&mut view, &cells, KeyCode::Char('j'));
press(&mut view, &cells, KeyCode::End);
press(&mut view, &cells, KeyCode::Char('v'));
assert_eq!(view.selected_text(&cells), None);
let cells = vec![cell(&["abc", ""])];
let mut view = TranscriptView::default();
render(&mut view, &cells);
assert!(view.begin_select_mode(&cells));
press(&mut view, &cells, KeyCode::Char('g'));
press(&mut view, &cells, KeyCode::Char('j'));
press(&mut view, &cells, KeyCode::End);
}
#[test]
fn configured_pager_page_motion_moves_the_select_cursor() {
let cells = vec![Arc::new(PlainHistoryCell::new(
(0..12).map(|row| format!("line {row}").into()).collect(),
)) as Arc<dyn HistoryCell>];
let config = serde_json::from_value(serde_json::json!({
"pager": {"page_down": "x"}
}))
.expect("keymap config");
let keymap = crate::keymap::RuntimeKeymap::from_config(&config).expect("configured page down");
let mut view = TranscriptView::default();
view.set_keymap_bindings(&keymap);
render(&mut view, &cells);
assert!(view.begin_select_mode(&cells));
press(&mut view, &cells, KeyCode::Char('g'));
press(&mut view, &cells, KeyCode::Char('v'));
assert!(view.select_mode.is_some_and(|mode| mode.mark.is_some()));
press(&mut view, &cells, KeyCode::Char('x'));
let cursor = view
.select_mode
.expect("select mode")
.cursor(view.selection.as_ref().expect("selection"));
let layout = view.layout(&cells, cursor.index).expect("cursor layout");
assert_eq!(
layout
.row_for_offset(cursor.offset)
.saturating_add_signed(cursor.row_bias.saturating_neg()),
5
);
}
#[test]
fn transcript_end_character_selection_includes_the_last_grapheme() {
let cells = vec![cell(&["start"]), cell(&["tail🙂"])];
let mut view = TranscriptView::default();
render(&mut view, &cells);
assert!(view.begin_select_mode(&cells));
press(&mut view, &cells, KeyCode::Char('G'));
press(&mut view, &cells, KeyCode::Char('v'));
assert_eq!(view.selected_text(&cells).as_deref(), Some("🙂"));
}
#[test]
fn swapping_the_active_end_preserves_text_and_moves_the_shrinking_endpoint() {
let cells = vec![cell(&["abcdef"])];
let mut view = TranscriptView::default();
render(&mut view, &cells);
assert!(view.begin_select_mode(&cells));
press(&mut view, &cells, KeyCode::Char('v'));
press(&mut view, &cells, KeyCode::Char('o'));
assert_eq!(view.selected_text(&cells).as_deref(), Some("a"));
press(&mut view, &cells, KeyCode::Char('o'));
for _ in 0..3 {
press(&mut view, &cells, KeyCode::Char('l'));
}
assert_eq!(view.selected_text(&cells).as_deref(), Some("abcd"));
press(&mut view, &cells, KeyCode::Char('o'));
press(&mut view, &cells, KeyCode::Char('l'));
assert_eq!(view.selected_text(&cells).as_deref(), Some("bcd"));
}
#[test]
fn confirmed_copy_keeps_the_select_mode_caret_on_the_active_grapheme() {
let cells = vec![cell(&["abc"])];
let mut view = TranscriptView::default();
render(&mut view, &cells);
assert!(view.begin_select_mode(&cells));
press(&mut view, &cells, KeyCode::Char('v'));
press(&mut view, &cells, KeyCode::Char('l'));
let Some(ViewAction::Copy(text)) = view.handle_key(KeyCode::Char('y').into(), &cells) else {
panic!("copy key should copy the marked range");
};
assert_eq!(text, "ab");
assert_eq!(
view.copy_selected_text_with(
&cells,
&text,
/*clear_selection*/ true,
|_, _| { Ok(CopyStatus::Confirmed) }
),
Ok(CopyStatus::Confirmed)
);
assert!(view.select_mode.is_some());
press(&mut view, &cells, KeyCode::Char('v'));
assert_eq!(view.selected_text(&cells).as_deref(), Some("b"));
}
@@ -83,6 +83,10 @@ impl Selection {
self.origin = origin;
self.unit = unit;
}
pub(super) fn unit(&self) -> SelectionUnit {
self.unit
}
}
impl TranscriptView {