mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user