Add keyboard table selection to the transcript (#61422)

## What changed

- Add configurable `pager.mark_markdown`, bound to the leader followed by `v`, to mark the table cell under the selection caret.
- Use arrows or `hjkl` to extend a rectangular cell selection, `o` to swap corners, and `y` to copy. Press `v` to select text within the active cell without including neighboring cells, including across wrapped lines.
- Show table-specific footer hints and route the marking chord in both the main transcript and transcript overlays.

## Testing

Add coverage for cell rectangles, header-only selections, empty cells, wrapped text, Unicode and escaped pipes, Markdown copying, search cancellation, mouse takeover, and chord routing with remapped find and leader keys.

GitOrigin-RevId: 81ed9e52092bba3c7ef064e95043d2c070df0f4c
This commit is contained in:
Felipe Coury
2026-11-14 00:32:35 +00:00
committed by copyberry
parent 4598f33790
commit 68c63d72d3
31 changed files with 1264 additions and 100 deletions
+2
View File
@@ -482,6 +482,8 @@ pub struct TuiPagerKeymap {
pub close_transcript: Option<KeybindingsSpec>,
/// Find text in a transcript pager.
pub find: Option<KeybindingsSpec>,
/// Mark Markdown structure under the caret in transcript selection mode.
pub mark_markdown: Option<KeybindingsSpec>,
}
/// List selection context keybindings for popup-style selectable lists.
+10
View File
@@ -5514,6 +5514,7 @@
"half_page_up": null,
"jump_bottom": null,
"jump_top": null,
"mark_markdown": null,
"page_down": null,
"page_up": null,
"scroll_down": null,
@@ -6730,6 +6731,7 @@
"half_page_up": null,
"jump_bottom": null,
"jump_top": null,
"mark_markdown": null,
"page_down": null,
"page_up": null,
"scroll_down": null,
@@ -6991,6 +6993,14 @@
],
"description": "Jump to the beginning."
},
"mark_markdown": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Mark Markdown structure under the caret in transcript selection mode."
},
"page_down": {
"allOf": [
{
+13 -17
View File
@@ -274,30 +274,26 @@ impl App {
use crate::keymap::KeymapContextSet;
if let Some(overlay) = &self.overlay {
let context = if matches!(overlay, Overlay::Transcript(view) if view.is_search_editing())
{
KeymapContext::Editor
} else {
KeymapContext::Pager
};
let contexts =
if matches!(overlay, Overlay::Transcript(_)) && context == KeymapContext::Pager {
KeymapContextSet::transcript()
} else {
KeymapContextSet::new(context)
let contexts = match overlay {
Overlay::Transcript(view) if view.is_search_editing() => {
KeymapContextSet::new(KeymapContext::Editor)
}
.with_voice_toggle(&self.keymap);
return if self.backtrack.overlay_preview_active && context == KeymapContext::Pager {
KeymapContextSet::browsing().with_voice_toggle(&self.keymap)
} else {
contexts
Overlay::Transcript(view) if view.is_select_mode() => {
KeymapContextSet::transcript().with_transcript_select()
}
_ if self.backtrack.overlay_preview_active => KeymapContextSet::browsing(),
Overlay::Transcript(_) => KeymapContextSet::transcript(),
_ => KeymapContextSet::new(KeymapContext::Pager),
};
return contexts.with_voice_toggle(&self.keymap);
}
if self.transcript_view.is_search_editing() && self.chat_widget.no_modal_or_popup_active() {
return KeymapContextSet::new(KeymapContext::Editor).with_voice_toggle(&self.keymap);
}
if self.transcript_view.is_select_mode() && self.chat_widget.no_modal_or_popup_active() {
return KeymapContextSet::transcript().with_voice_toggle(&self.keymap);
return KeymapContextSet::transcript()
.with_transcript_select()
.with_voice_toggle(&self.keymap);
}
if self.transcript_view.is_activity_focused() && self.chat_widget.no_modal_or_popup_active()
{
+11 -4
View File
@@ -101,6 +101,7 @@ impl App {
| "half_page_down"
| "jump_top"
| "jump_bottom"
| "mark_markdown"
)
) || (self.overlay.is_some()
&& action.context == crate::keymap::KeymapContext::Pager
@@ -113,15 +114,19 @@ impl App {
&& crate::keymap::dispatch_event(binding.action) == Some(dispatch)
})
};
if fixed_search_control || fixed_select_control || pager_motion {
false
} else if self.key_chord_matcher.is_pending() {
let fixed_control = fixed_search_control || fixed_select_control || pager_motion;
if self.key_chord_matcher.is_pending() {
match self
.key_chord_matcher
.clone()
.advance(key_event, &self.keymap.chords, contexts)
{
crate::keymap::KeyChordMatch::Completed(dispatch) => completed_action(dispatch),
crate::keymap::KeyChordMatch::Completed(dispatch) => {
// The Markdown chord's completion may be the standalone text mark.
completed_action(dispatch)
&& (!fixed_control || self.keymap.pager.mark_markdown.is_pressed(dispatch))
}
_ if fixed_control => false,
crate::keymap::KeyChordMatch::Pending(prefix) => {
let (code, modifiers) = prefix.parts();
self.keymap.chords.bindings.iter().any(|binding| {
@@ -136,6 +141,8 @@ impl App {
crate::keymap::KeyChordMatch::PassThrough
| crate::keymap::KeyChordMatch::Cancelled => false,
}
} else if fixed_control {
false
} else {
self.keymap.chords.bindings.iter().any(|binding| {
active_action(binding.action)
@@ -136,6 +136,76 @@ async fn configured_ctrl_space_submit_wins_over_transcript_selection() -> Result
Ok(())
}
#[tokio::test]
async fn markdown_chord_and_remapped_find_work_in_transcript_selection() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ true)?;
let size = Size::new(/*width*/ 80, /*height*/ 16);
tui.terminal.resize(size)?;
for (config, prefix) in [
(
serde_json::json!({"pager": {"find": "c"}}),
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
),
(
serde_json::json!({"global": {"leader": "f6"}, "pager": {"find": "c"}}),
KeyCode::F(6).into(),
),
] {
let config = serde_json::from_value(config)?;
app.keymap = RuntimeKeymap::from_config(&config).expect("valid Markdown shortcuts");
app.local_settings.tui.keymap = config.clone();
app.chat_widget.apply_keymap_update(config, &app.keymap);
app.transcript_view = Default::default();
app.transcript_cells = vec![Arc::new(crate::history_cell::AgentMarkdownCell::new(
"| Left | Right |\n|---|---|\n| alpha | Beta |".into(),
std::path::Path::new("/"),
))];
app.render_owned_transcript(&mut tui, size)?;
assert!(app.transcript_view.begin_select_mode(&app.transcript_cells));
app.handle_tui_event(
&mut tui,
&mut server,
TuiEvent::Key(KeyCode::Char('c').into()),
)
.await?;
assert!(app.transcript_view.is_search_editing());
for event in [
TuiEvent::Paste("Beta".into()),
TuiEvent::Draw,
TuiEvent::Key(KeyCode::Enter.into()),
TuiEvent::Key(prefix),
TuiEvent::Key(KeyCode::Char('v').into()),
] {
app.handle_tui_event(&mut tui, &mut server, event).await?;
}
assert_eq!(
app.transcript_view
.selected_text(&app.transcript_cells)
.as_deref(),
Some("Beta")
);
app.handle_tui_event(
&mut tui,
&mut server,
TuiEvent::Key(KeyCode::Char('v').into()),
)
.await?;
assert_eq!(
app.transcript_view
.selected_text(&app.transcript_cells)
.as_deref(),
Some("B")
);
}
server.shutdown().await?;
tui.set_owned_screen(/*owned*/ false)?;
Ok(())
}
#[tokio::test]
async fn default_page_up_still_scrolls_the_transcript() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
@@ -180,3 +180,59 @@ async fn offline_selection_reserves_keys_and_preserves_draft_and_backtrack_state
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn markdown_chord_marks_whole_cells_in_the_transcript_overlay() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
let mut app_server = start_config_write_test_app_server(&app).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
app.keymap = RuntimeKeymap::from_config(&toml::from_str("[pager]\nfind = 'c'\n")?)
.expect("valid pager bindings");
app.transcript_cells = vec![Arc::new(crate::history_cell::AgentMarkdownCell::new(
"| Left | Right |\n|---|---|\n| alpha | Beta |".into(),
std::path::Path::new("/"),
))];
app.open_transcript_overlay(&mut tui);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 16,
);
let Some(Overlay::Transcript(overlay)) = &mut app.overlay else {
panic!("expected transcript overlay");
};
overlay.begin_select_mode();
overlay.render(area, &mut Buffer::empty(area));
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyCode::Char('c').into()),
)
.await?;
assert!(
matches!(&app.overlay, Some(Overlay::Transcript(overlay)) if overlay.is_search_editing())
);
for event in [
TuiEvent::Paste("Be".into()),
TuiEvent::Draw,
TuiEvent::Key(KeyCode::Enter.into()),
TuiEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
TuiEvent::Key(KeyCode::Char('v').into()),
] {
app.handle_tui_event(&mut tui, &mut app_server, event)
.await?;
}
let Some(Overlay::Transcript(overlay)) = &mut app.overlay else {
panic!("expected transcript overlay");
};
let mut buffer = Buffer::empty(area);
overlay.render(area, &mut buffer);
let selected = buffer
.content()
.iter()
.filter(|cell| cell.modifier.contains(ratatui::style::Modifier::REVERSED))
.map(ratatui::buffer::Cell::symbol)
.collect::<String>();
assert_eq!(selected, "Beta");
app_server.shutdown().await?;
Ok(())
}
+1
View File
@@ -66,6 +66,7 @@ impl App {
"open_presets" => (2, "Presets ›"),
"find_transcript" => (2, "Find text"),
"select_transcript" => (2, "Select transcript"),
"mark_markdown" => (2, "Mark table cell"),
"focus_conversation_composer" => (2, "Composer"),
other => (2, other),
};
+4
View File
@@ -340,6 +340,7 @@ pub(crate) struct PagerKeymap {
pub(crate) close: Vec<KeyBinding>,
pub(crate) close_transcript: Vec<KeyBinding>,
pub(crate) find: Vec<KeyBinding>,
pub(crate) mark_markdown: Vec<KeyBinding>,
chord_hints: Arc<RuntimeChordKeymap>,
}
@@ -1523,6 +1524,7 @@ impl RuntimeKeymap {
close: resolve_local!(keymap, defaults, pager, close),
close_transcript: resolve_local!(keymap, defaults, pager, close_transcript),
find: resolve_local!(keymap, defaults, pager, find),
mark_markdown: resolve_local!(keymap, defaults, pager, mark_markdown),
chord_hints: Arc::clone(&chords),
};
@@ -2098,6 +2100,7 @@ impl RuntimeKeymap {
close: default_bindings![plain(KeyCode::Char('q')), ctrl(KeyCode::Char('c'))],
close_transcript: default_bindings![ctrl(KeyCode::Char('t'))],
find: default_bindings![plain(KeyCode::F(3)), plain(KeyCode::Char('/'))],
mark_markdown: Vec::new(),
chord_hints: Arc::default(),
},
list: ListKeymap {
@@ -2640,6 +2643,7 @@ impl RuntimeKeymap {
self.pager.close_transcript.as_slice(),
),
("pager.find", self.pager.find.as_slice()),
("pager.mark_markdown", self.pager.mark_markdown.as_slice()),
],
)?;
+1
View File
@@ -466,6 +466,7 @@ define_runtime_action_bindings! {
close,
close_transcript,
find,
mark_markdown,
],
"list" => List, list, list [
move_up,
+19 -7
View File
@@ -63,6 +63,7 @@ const FOCUSED_SIDE_PANE: u32 = 1 << 26;
const SIDEBAR_CHOOSER: u32 = 1 << 27;
const SIDEBAR_RESIZE: u32 = 1 << 28;
const FOCUSED_SIDEBAR: u32 = 1 << 29;
const TRANSCRIPT_SELECT: u32 = 1 << 30;
const TRANSCRIPT_BROWSING: u32 = 1 << 14;
@@ -106,6 +107,11 @@ impl KeymapContextSet {
)
}
/// Enable Markdown marking while the transcript owns the selection caret.
pub(crate) const fn with_transcript_select(self) -> Self {
Self(self.0 | TRANSCRIPT_SELECT)
}
/// Warning pages admit navigation plus their own toggle and copy.
pub(crate) const fn warnings() -> Self {
Self(context_bit(KeymapContext::List) | context_bit(KeymapContext::Global) | WARNINGS_FOCUS)
@@ -205,6 +211,9 @@ impl KeymapContextSet {
/// Whether this input path can dispatch the action, including focus-specific exclusions.
pub(crate) fn contains_action(self, action: KeymapActionId) -> bool {
if action.context == KeymapContext::Pager && action.action == "mark_markdown" {
return self.0 & TRANSCRIPT_SELECT != 0;
}
if action.context == KeymapContext::Global && action.action == "change_host" {
return self.0 & BACKEND_CYCLING != 0;
}
@@ -304,15 +313,18 @@ impl RuntimeChordBinding {
/// Transcript selection remains active beside pager actions on native transcript surfaces.
fn overlaps(&self, action: KeymapActionId) -> bool {
self.action.overlaps(action)
|| (matches!(
(self.action.context, self.action.action),
(KeymapContext::Global, "select_transcript")
) || matches!(
let transcript = KeymapContextSet::transcript().with_transcript_select();
let selection_action = |action: KeymapActionId| {
matches!(
(action.context, action.action),
(KeymapContext::Global, "select_transcript")
)) && KeymapContextSet::transcript().contains_action(self.action)
&& KeymapContextSet::transcript().contains_action(action)
| (KeymapContext::Pager, "mark_markdown")
)
};
self.action.overlaps(action)
|| (selection_action(self.action) || selection_action(action))
&& transcript.contains_action(self.action)
&& transcript.contains_action(action)
|| self.is_leader()
&& [
KeymapContextSet::browsing(),
@@ -29,6 +29,7 @@ pub(in crate::keymap) fn register(
use KeyCode::Char;
use KeymapContext::Chat;
use KeymapContext::Global;
use KeymapContext::Pager;
for (context, name, completion, spec) in [
(Global, "change_host", Char('b'), "b"),
(Global, "select_transcript", Char('['), "["),
@@ -55,6 +56,7 @@ pub(in crate::keymap) fn register(
(Global, "sidebar_up", Char('k'), "k"),
(Global, "sidebar_down", KeyCode::Down, "down"),
(Global, "sidebar_down", Char('j'), "j"),
(Pager, "mark_markdown", Char('v'), "v"),
] {
if remaining == 0 {
break;
+1
View File
@@ -277,6 +277,7 @@ define_keymap_actions! {
close => "Close the pager overlay.",
close_transcript => "Close the transcript overlay.",
find => "Find text in a transcript pager.",
mark_markdown => "Mark table cells in transcript selection mode.",
}
list "List" {
move_up => "Move list selection up.",
+17
View File
@@ -69,6 +69,23 @@ impl TableLine {
Some(first.output.start..last.output.end)
}
pub(crate) fn source_fragments(
&self,
) -> impl Iterator<Item = (&Range<usize>, &LogicalLineSource)> {
self.fragments
.iter()
.map(|fragment| (&fragment.output, &fragment.source))
}
/// Find the leading display column even if a trailing empty cell has no fragment.
pub(crate) fn grid_cell_column(&self, row: usize, column: usize) -> Option<usize> {
let ends = self.grid_columns.as_ref()?;
if self.fragments.first()?.row != row || column >= ends.len() {
return None;
}
Some(if column == 0 { 0 } else { ends[column - 1] })
}
pub(crate) fn empty(table: &Arc<[&'static str]>) -> Self {
Self {
table: Arc::clone(table),
@@ -250,6 +250,7 @@ impl TranscriptOverlay {
}
pub(crate) fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
self.keymap.clone_from(&keymap.pager);
self.find_transcript.clone_from(&keymap.app.find_transcript);
self.select_transcript
.clone_from(&keymap.app.select_transcript);
+51 -23
View File
@@ -1392,38 +1392,66 @@ impl PickerState {
}
fn route_key_chord(&mut self, key: KeyEvent) -> Option<KeyEvent> {
if matches!(&self.overlay, Some(Overlay::Transcript(overlay)) if overlay.owns_interaction_key(key))
{
if self.chord_matcher.cancel() {
self.requester.schedule_frame();
}
return Some(key);
}
let context = if matches!(&self.overlay, Some(Overlay::Transcript(overlay)) if overlay.is_search_editing())
{
crate::keymap::KeymapContext::Editor
} else if self.overlay.is_some() {
crate::keymap::KeymapContext::Pager
use crate::keymap::KeyChordMatch;
use crate::keymap::KeymapContext;
use crate::keymap::KeymapContextSet;
let mut contexts = KeymapContextSet::new(if self.overlay.is_some() {
KeymapContext::Pager
} else {
crate::keymap::KeymapContext::List
};
KeymapContext::List
});
if let Some(Overlay::Transcript(overlay)) = &self.overlay {
if overlay.is_search_editing() {
contexts = KeymapContextSet::new(KeymapContext::Editor);
}
let selecting = overlay.is_select_mode() && !overlay.is_search_editing();
if selecting {
contexts = contexts.with_transcript_select();
}
if overlay.owns_interaction_key(key) {
let markdown_chord = selecting
&& match self
.chord_matcher
.clone()
.advance(key, &self.keymap.chords, contexts)
{
KeyChordMatch::Completed(key) => {
self.keymap.pager.mark_markdown.is_pressed(key)
}
KeyChordMatch::Pending(prefix) => {
self.keymap.chords.bindings.iter().any(|binding| {
binding.action.action == "mark_markdown"
&& binding.chord.prefix == prefix
})
}
KeyChordMatch::PassThrough
| KeyChordMatch::Cancelled
| KeyChordMatch::Ignored => false,
};
if !markdown_chord {
if self.chord_matcher.cancel() {
self.requester.schedule_frame();
}
return Some(key);
}
}
}
let was_pending = self.chord_matcher.is_pending();
let result = self.chord_matcher.advance(
key,
&self.keymap.chords,
crate::keymap::KeymapContextSet::new(context),
);
let result = self
.chord_matcher
.advance(key, &self.keymap.chords, contexts);
if was_pending && !self.chord_matcher.is_pending() {
self.requester.schedule_frame();
}
match result {
crate::keymap::KeyChordMatch::PassThrough => Some(key),
crate::keymap::KeyChordMatch::Completed(dispatch_event) => Some(dispatch_event),
crate::keymap::KeyChordMatch::Pending(_) | crate::keymap::KeyChordMatch::Cancelled => {
KeyChordMatch::PassThrough => Some(key),
KeyChordMatch::Completed(dispatch_event) => Some(dispatch_event),
KeyChordMatch::Pending(_) | KeyChordMatch::Cancelled => {
self.requester.schedule_frame();
None
}
crate::keymap::KeyChordMatch::Ignored => None,
KeyChordMatch::Ignored => None,
}
}
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
153 actions, 1 customized, 5 unbound.
154 actions, 1 customized, 5 unbound.
All Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval ›
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
154 actions, 0 customized, 6 unbound.
155 actions, 0 customized, 6 unbound.
All Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval ›
@@ -2,7 +2,7 @@
source: tui/src/keymap_setup.rs
expression: snapshot
---
tab: All (153 selectable)
tab: All (154 selectable)
tab: Common (20 selectable)
tab: Customized (0) (0 selectable)
tab: Unbound (5) (5 selectable)
@@ -10,7 +10,7 @@ tab: App (22 selectable)
tab: Composer (5 selectable)
tab: Editor (17 selectable)
tab: Vim (69 selectable)
tab: Navigation (21 selectable)
tab: Navigation (22 selectable)
tab: Agents (11 selectable)
tab: Approval (8 selectable)
tab: Debug (1 selectable)
@@ -5,9 +5,9 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
153 actions, 1 customized, 7 unbound.
154 actions, 1 customized, 8 unbound.
All Common Customized (1) Unbound (7) App Composer Editor Vim Navigation Agents Approval ›
All Common Customized (1) Unbound (8) App Composer Editor Vim Navigation Agents Approval ›
Type to search shortcuts
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
Keymap
All configurable shortcuts.
153 actions, 0 customized, 5 unbound.
154 actions, 0 customized, 5 unbound.
All Common Customized (0) Unbound (5) App Composer Editor ›
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
153 actions, 0 customized, 5 unbound.
154 actions, 0 customized, 5 unbound.
All Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval ›
+38 -1
View File
@@ -53,7 +53,39 @@ impl TranscriptView {
});
}
if self.select_mode.is_some() {
let hints = if has_selected_text {
let hints = if let Some(mark) = self.table_mark() {
let (full, short, minimal) =
if mark.kind == super::select_mode::table::TableMarkKind::Text {
(
"arrows/hjkl move in cell · o swap · y copy · v/esc clear",
"arrows move · y copy · esc clear",
"y copy · esc clear",
)
} else {
(
"arrows/hjkl extend cells · v mark text · o swap · y copy · esc clear",
"arrows move · v mark text · y copy",
"v mark text · y copy",
)
};
[full, short, minimal, "esc clear"].map(str::to_owned)
} else if !has_selected_text
&& self.select_cursor_is_on_table()
&& let Some(mark) = self
.disclosure
.keymap
.primary_hint(crate::keymap::KeymapContext::Pager, "mark_markdown")
{
let mark = mark.display_label();
[
format!(
"arrows/hjkl move · v mark text · V mark lines · {mark} mark cell · / find · q close"
),
format!("arrows move · {mark} mark cell · v mark text"),
format!("{mark} mark cell · q close"),
format!("{mark} mark cell"),
]
} else if has_selected_text {
let copy = crate::key_hint::ctrl(KeyCode::Char('c')).display_label();
[
format!(
@@ -274,6 +306,11 @@ pub(super) fn navigation_line(navigation: &str) -> Line<'static> {
" copy & latest",
" clear selection",
" copy+↓",
" extend cells",
" mark cell",
" mark text",
" mark lines",
" move in cell",
" move",
" ends",
" page",
+17 -9
View File
@@ -213,7 +213,7 @@ impl TranscriptView {
if accepting && self.search.is_reading() {
self.accept_search_into_select_mode(cells);
} else if cancelling {
self.restore_select_search_origin();
self.restore_select_search_origin(cells);
}
return Some(ViewAction::Changed);
}
@@ -270,16 +270,24 @@ impl TranscriptView {
| MouseEventKind::Drag(MouseButton::Left)
| MouseEventKind::Up(MouseButton::Left)
);
let before = self
.selection
.as_ref()
.map(|selection| (selection.start, selection.end, selection.unit()));
let before = self.selection.as_ref().map(|selection| {
(
selection.start,
selection.end,
selection.unit(),
selection.has_table_scope(),
)
});
let action =
self.handle_mouse_with_mode(event, cells, MouseMode::Selection(patch_approval));
let after = self
.selection
.as_ref()
.map(|selection| (selection.start, selection.end, selection.unit()));
let after = self.selection.as_ref().map(|selection| {
(
selection.start,
selection.end,
selection.unit(),
selection.has_table_scope(),
)
});
if endpoint_changed && before != after {
if self.has_selection_range() {
self.disclosure.resume_after_approval = None;
+6 -1
View File
@@ -122,6 +122,7 @@ impl TranscriptView {
mode.search_origin = Some(SelectSearchOrigin {
mark: mode.mark,
preferred_column: mode.preferred_column,
table_caret: mode.table_caret.take(),
start: selection.start,
end: selection.end,
});
@@ -321,7 +322,7 @@ impl TranscriptView {
self.move_select_cursor_to_search_match(anchor, &layout, cells);
}
pub(super) fn restore_select_search_origin(&mut self) {
pub(super) fn restore_select_search_origin(&mut self, cells: &[Arc<dyn HistoryCell>]) {
let (Some(mode), Some(selection)) = (&mut self.select_mode, &mut self.selection) else {
return;
};
@@ -329,8 +330,12 @@ impl TranscriptView {
return;
};
mode.mark = origin.mark;
mode.table_caret = origin.table_caret;
mode.preferred_column = origin.preferred_column;
selection.set_keyboard_range(origin.start, origin.end);
if self.table_mark().is_some() {
self.sync_select_range(cells);
}
}
pub(super) fn cancel_pending_select_search(&mut self) {
@@ -16,11 +16,13 @@ use ratatui::layout::Rect;
use unicode_segmentation::UnicodeSegmentation;
mod motion;
pub(super) mod table;
#[derive(Clone, Copy, Default)]
pub(super) struct SelectMode {
pub(super) mark: Option<Mark>,
pub(super) preferred_column: Option<u16>,
pub(super) table_caret: Option<table::CellPosition>,
pub(super) search_origin: Option<SelectSearchOrigin>,
}
@@ -31,12 +33,14 @@ pub(super) struct Mark {
anchor_edges: Option<(Anchor, Anchor)>,
active_edges: Option<(Anchor, Anchor)>,
unit: SelectionUnit,
table: Option<table::TableMark>,
}
#[derive(Clone, Copy)]
pub(super) struct SelectSearchOrigin {
pub(super) mark: Option<Mark>,
pub(super) preferred_column: Option<u16>,
pub(super) table_caret: Option<table::CellPosition>,
pub(super) start: Anchor,
pub(super) end: Anchor,
}
@@ -85,9 +89,27 @@ fn pager_motion(
}
impl SelectMode {
pub(super) fn clear_mark(&mut self) {
self.table_caret = self
.mark
.take()
.and_then(|mark| mark.table)
.map(|table| table.active)
.or(self.table_caret);
self.preferred_column = None;
}
pub(super) fn cursor(self, selection: &Selection) -> Anchor {
self.mark.map_or(selection.end, |mark| mark.active)
}
fn padding_column(self, width: u16) -> Option<u16> {
self.mark
.and_then(|mark| mark.table.map(|table| table.active))
.or(self.table_caret)
.and_then(|cell| cell.padding_column)
.map(|column| column.min(width.saturating_sub(1)))
}
}
impl TranscriptView {
@@ -206,13 +228,23 @@ impl TranscriptView {
&& matches!(code, KeyCode::Char('v') | KeyCode::Char(' '))
&& modifiers == KeyModifiers::NONE
{
self.start_select_mark(cells, SelectionUnit::Character);
if self.table_mark().is_some() && code == KeyCode::Char('v') {
self.start_select_cell_text(cells);
} else {
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
&& self.disclosure.keymap.pager.mark_markdown.is_pressed(key)
&& self.start_select_table(cells)
{
return Some(ViewAction::Changed);
}
if pressed && code == KeyCode::Char('o') && modifiers == KeyModifiers::NONE {
self.swap_select_ends(cells);
return Some(ViewAction::Changed);
@@ -301,25 +333,27 @@ impl TranscriptView {
};
let cursor = mode.cursor(selection);
mode.mark = match mode.mark {
Some(mark) if mark.unit == unit => {
Some(mark) if mark.unit == unit && mark.table.is_none() => {
self.clear_select_mark();
return;
}
Some(mark) => Some(Mark {
Some(mark) if mark.table.is_none() => Some(Mark {
anchor_edges: None,
active_edges: None,
unit,
..mark
}),
None => Some(Mark {
_ => Some(Mark {
anchor: cursor,
active: cursor,
anchor_edges: None,
active_edges: None,
unit,
table: None,
}),
};
mode.preferred_column = None;
mode.table_caret = None;
self.select_mode = Some(mode);
self.sync_select_range(cells);
}
@@ -333,8 +367,7 @@ impl TranscriptView {
return;
};
let cursor = mode.cursor(selection);
mode.mark = None;
mode.preferred_column = None;
mode.clear_mark();
selection.set_keyboard_range(cursor, cursor);
}
@@ -348,6 +381,9 @@ impl TranscriptView {
};
std::mem::swap(&mut mark.anchor, &mut mark.active);
std::mem::swap(&mut mark.anchor_edges, &mut mark.active_edges);
if let Some(table) = &mut mark.table {
std::mem::swap(&mut table.anchor, &mut table.active);
}
mode.preferred_column = None;
self.sync_select_range(cells);
self.reveal_select_cursor(cells);
@@ -391,9 +427,11 @@ impl TranscriptView {
}) else {
return;
};
let column = visible
.layout
.column_for_offset_on_row(visible.row, cursor.offset);
let column = mode.padding_column(self.area.width).unwrap_or_else(|| {
visible
.layout
.column_for_offset_on_row(visible.row, cursor.offset)
});
if column < self.area.width {
let area = Rect::new(
self.area.x + column,
@@ -429,6 +467,7 @@ impl TranscriptView {
anchor_edges: Some((start, anchor_end)),
active_edges: Some((active_start, end)),
unit,
table: None,
}
} else {
let anchor = self.before_grapheme(cells, start);
@@ -444,12 +483,14 @@ impl TranscriptView {
anchor_edges: Some((anchor_start, start)),
active_edges: Some((end, active_end)),
unit,
table: None,
}
}
});
if let Some(mode) = &mut self.select_mode {
mode.preferred_column = None;
mode.mark = mark;
mode.table_caret = None;
}
}
@@ -489,6 +530,10 @@ impl TranscriptView {
selection.set_keyboard_range(origin.start, origin.end);
}
mode.preferred_column = None;
mode.table_caret = None;
if mode.mark.is_some_and(|mark| mark.table.is_some()) {
mode.mark = None;
}
if let Some(mark) = &mut mode.mark {
mark.active = anchor;
mark.active_edges = None;
@@ -1,5 +1,6 @@
//! Cursor motions and marked-range derivation for keyboard transcript selection.
use super::table::TableMarkKind;
use super::*;
use crossterm::event::KeyCode;
use unicode_segmentation::UnicodeSegmentation;
@@ -24,6 +25,19 @@ impl TranscriptView {
let snapshot = self.snapshot_cells();
let cells = snapshot.as_deref().unwrap_or(cells);
let next = match motion {
_ if let Some((mark, table)) = mode.mark.and_then(|mark| Some((mark, mark.table?))) => {
if table.kind == TableMarkKind::Cells {
self.move_select_table(cells, mode, mark, motion);
return;
}
self.move_cell_text_cursor(
cells,
mark,
table.active,
motion,
&mut mode.preferred_column,
)
}
CursorMotion::Character(code) => {
mode.preferred_column = None;
self.move_select_character(cells, cursor, code)
@@ -31,7 +45,8 @@ impl TranscriptView {
CursorMotion::Rows(rows) => {
let index = self.resolve(cells, cursor);
let layout = self.layout(cells, index);
let column = mode.preferred_column.or_else(|| {
let padding_column = mode.padding_column(self.area.width);
let column = mode.preferred_column.or(padding_column).or_else(|| {
layout.as_ref().map(|layout| {
let row = layout
.row_for_offset(cursor.offset)
@@ -76,7 +91,10 @@ impl TranscriptView {
if let Some(mark) = &mut mode.mark {
mark.active = next;
mark.active_edges = None;
} else if let Some(selection) = &mut self.selection {
} else if let Some(selection) = &mut self.selection
&& (next != cursor || mode.table_caret.is_none())
{
mode.table_caret = None;
selection.set_keyboard_range(next, next);
}
self.select_mode = Some(mode);
@@ -189,9 +207,7 @@ impl TranscriptView {
} else {
0
};
let mut words = text
.split_word_bound_indices()
.filter(|(_, word)| word.chars().any(char::is_alphanumeric));
let mut words = selectable_words(text);
let found = if backward {
words
.rfind(|(start, _)| *start < offset)
@@ -297,12 +313,16 @@ impl TranscriptView {
}
}
pub(super) fn sync_select_range(&mut self, cells: &[Arc<dyn HistoryCell>]) {
pub(in crate::transcript_view) 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);
if let Some(table) = mark.table {
self.sync_select_table(cells, mark, table);
return;
}
let after_anchor = self.after_grapheme(cells, mark.anchor);
let after_active = self.after_grapheme(cells, mark.active);
let origin = match mark.unit {
@@ -438,3 +458,8 @@ fn selectable_graphemes(text: &str) -> impl DoubleEndedIterator<Item = (usize, &
text.grapheme_indices(/*is_extended*/ true)
.filter(|(_, grapheme)| !grapheme.contains('\n'))
}
pub(super) fn selectable_words(text: &str) -> impl DoubleEndedIterator<Item = (usize, &str)> {
text.split_word_bound_indices()
.filter(|(_, word)| word.chars().any(char::is_alphanumeric))
}
@@ -0,0 +1,350 @@
//! Keyboard table marks move between logical cells and use the shared table selection ranges.
use super::*;
use crate::transcript_view::text::TableCellHit;
use itertools::Itertools;
use std::ops::Range;
#[derive(Clone, Copy)]
pub(in crate::transcript_view) struct TableMark {
pub(super) anchor: CellPosition,
pub(super) active: CellPosition,
pub(in crate::transcript_view) kind: TableMarkKind,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(in crate::transcript_view) enum TableMarkKind {
Cells,
Text,
}
#[derive(Clone, Copy)]
pub(in crate::transcript_view) struct CellPosition {
cell: (usize, usize),
// Trailing empty grid cells share the row's end offset.
pub(super) padding_column: Option<u16>,
}
fn table_hit(layout: &TextLayout, cursor: Anchor) -> Option<TableCellHit> {
let row = layout
.row_for_offset(cursor.offset)
.saturating_add_signed(cursor.row_bias.saturating_neg());
let column = layout.column_for_offset_on_row(row, cursor.offset);
layout.table_cell_at(row, column, cursor.offset)
}
impl TranscriptView {
pub(in crate::transcript_view) fn table_mark(&self) -> Option<TableMark> {
self.select_mode?.mark?.table
}
pub(in crate::transcript_view) fn select_cursor_is_on_table(&self) -> bool {
let (Some(mode), Some(selection)) = (self.select_mode, self.selection.as_ref()) else {
return false;
};
let cursor = mode.cursor(selection);
mode.table_caret.is_some()
|| self
.visible
.iter()
.find(|visible| visible.key == cursor.key)
.is_some_and(|visible| table_hit(&visible.layout, cursor).is_some())
}
pub(super) fn start_select_table(&mut self, cells: &[Arc<dyn HistoryCell>]) -> bool {
if self
.table_mark()
.is_some_and(|table| table.kind == TableMarkKind::Cells)
{
self.clear_select_mark();
return true;
}
let (Some(mut mode), Some(selection)) = (self.select_mode, self.selection.as_ref()) else {
return false;
};
let cursor = mode.cursor(selection);
let snapshot = self.snapshot_cells();
let cells = snapshot.as_deref().unwrap_or(cells);
let index = self.resolve(cells, cursor);
let Some(layout) = self.layout(cells, index) else {
return false;
};
let cell = mode
.mark
.and_then(|mark| mark.table.map(|table| table.active))
.or(mode.table_caret)
.or_else(|| {
table_hit(&layout, cursor).map(|hit| CellPosition {
cell: hit.endpoint.cell,
padding_column: None,
})
});
let Some(cell) = cell else { return false };
mode.table_caret = None;
self.cancel_pending_select_search();
self.cancel_beginning();
mode.mark = Some(Mark {
anchor: cursor,
active: cursor,
anchor_edges: None,
active_edges: None,
unit: SelectionUnit::Character,
table: Some(TableMark {
anchor: cell,
active: cell,
kind: TableMarkKind::Cells,
}),
});
mode.preferred_column = None;
self.select_mode = Some(mode);
self.sync_select_range(cells);
true
}
pub(super) fn start_select_cell_text(&mut self, cells: &[Arc<dyn HistoryCell>]) {
let Some(mut mode) = self.select_mode else {
return;
};
let Some((mut mark, table)) = mode.mark.and_then(|mark| Some((mark, mark.table?))) else {
return;
};
if table.kind == TableMarkKind::Text {
self.clear_select_mark();
return;
}
let snapshot = self.snapshot_cells();
let cells = snapshot.as_deref().unwrap_or(cells);
let Some((_, ranges)) = self.cell_text_ranges(cells, mark.active, table.active) else {
return;
};
let Some(offset) = ranges
.iter()
.find(|range| range.contains(&mark.active.offset))
.map(|_| mark.active.offset)
.or_else(|| {
ranges
.iter()
.find(|range| !range.is_empty())
.map(|range| range.start)
})
else {
return;
};
let cursor = Anchor {
offset,
row_bias: 0,
..mark.active
};
let cell = CellPosition {
padding_column: None,
..table.active
};
mark.anchor = cursor;
mark.active = cursor;
mark.table = Some(TableMark {
anchor: cell,
active: cell,
kind: TableMarkKind::Text,
});
mode.mark = Some(mark);
mode.preferred_column = None;
self.cancel_pending_select_search();
self.select_mode = Some(mode);
self.sync_select_range(cells);
self.reveal_select_cursor(cells);
}
pub(super) fn move_cell_text_cursor(
&mut self,
cells: &[Arc<dyn HistoryCell>],
mark: Mark,
cell: CellPosition,
motion: CursorMotion,
preferred_column: &mut Option<u16>,
) -> Option<Anchor> {
let (layout, ranges) = self.cell_text_ranges(cells, mark.anchor, cell)?;
let cursor = mark.active;
let offset = if let CursorMotion::Rows(rows) = motion {
let row = layout.row_for_offset(cursor.offset);
let column = *preferred_column
.get_or_insert_with(|| layout.column_for_offset_on_row(row, cursor.offset));
let first = layout.row_for_offset(ranges.first()?.start);
let last = layout.row_for_offset(ranges.last()?.end.saturating_sub(1));
let mut current = row;
let mut remaining = rows.unsigned_abs();
let mut offset = cursor.offset;
while let Some(row) = current.checked_add_signed(rows.signum())
&& (first..=last).contains(&row)
&& remaining > 0
{
current = row;
if let Some(fragment) = layout.table_cell_on_row(&ranges, row)
&& !fragment.is_empty()
{
let last = layout.text()[fragment.clone()]
.grapheme_indices(/*is_extended*/ true)
.next_back()
.map_or(fragment.start, |(offset, _)| fragment.start + offset);
offset = layout.position_at(row, column).clamp(fragment.start, last);
remaining -= 1;
}
}
offset
} else {
*preferred_column = None;
let fragments = ranges
.iter()
.map(|range| (range.start, &layout.text()[range.clone()]));
let mut offsets = fragments.clone().flat_map(|(start, text)| {
text.grapheme_indices(/*is_extended*/ true)
.map(move |(offset, _)| start + offset)
.filter(|&offset| layout.is_cursor_position(offset))
});
match motion {
CursorMotion::Character(KeyCode::Left) => {
offsets.rfind(|&offset| offset < cursor.offset)
}
CursorMotion::Character(KeyCode::Right) => {
offsets.find(|&offset| offset > cursor.offset)
}
CursorMotion::LineStart => offsets.next(),
CursorMotion::LineEnd => offsets.next_back(),
CursorMotion::WordForward | CursorMotion::WordBackward | CursorMotion::WordEnd => {
// Scan each original line once. Escaped pipes can leave gaps
// between its displayed fragments.
let lines = layout
.table_word_fragments(&ranges)
.chunk_by(|(text, _, _)| std::ptr::from_ref(*text));
let mut offsets = (&lines).into_iter().flat_map(|(_, fragments)| {
let mut fragments = fragments.peekable();
let text = fragments.peek().map_or("", |&(text, _, _)| text);
super::motion::selectable_words(text).filter_map(move |(offset, word)| {
let boundary = if matches!(motion, CursorMotion::WordEnd) {
offset + word.grapheme_indices(/*is_extended*/ true).next_back()?.0
} else {
offset
};
while fragments
.peek()
.is_some_and(|(_, range, _)| range.end <= boundary)
{
fragments.next();
}
let (_, range, start) = fragments.peek()?;
range
.contains(&boundary)
.then(|| start + (boundary - range.start))
})
});
if matches!(motion, CursorMotion::WordBackward) {
offsets.take_while(|&offset| offset < cursor.offset).last()
} else {
offsets.find(|&offset| offset > cursor.offset)
}
}
CursorMotion::Rows(_)
| CursorMotion::Character(_)
| CursorMotion::Beginning
| CursorMotion::End => None,
}?
};
Some(Anchor {
offset,
row_bias: 0,
..cursor
})
}
fn cell_text_ranges(
&mut self,
cells: &[Arc<dyn HistoryCell>],
anchor: Anchor,
cell: CellPosition,
) -> Option<(Arc<TextLayout>, Vec<Range<usize>>)> {
let index = self.resolve(cells, anchor);
let layout = self.layout(cells, index)?;
let hit = layout.table_cell_at_offset(anchor.offset, cell.cell)?;
let ranges = layout.table_selection_ranges(&hit, &hit);
Some((layout, ranges))
}
pub(super) fn move_select_table(
&mut self,
cells: &[Arc<dyn HistoryCell>],
mut mode: SelectMode,
mut mark: Mark,
motion: CursorMotion,
) {
let Some(table) = &mut mark.table else { return };
let (row, column) = table.active.cell;
let cell = match motion {
CursorMotion::Character(KeyCode::Left) => column.checked_sub(1).map(|col| (row, col)),
CursorMotion::Character(KeyCode::Right) => Some((row, column + 1)),
CursorMotion::Rows(-1) => row.checked_sub(1).map(|row| (row, column)),
CursorMotion::Rows(1) => Some((row + 1, column)),
_ => None,
};
let Some(cell) = cell else { return };
let index = self.resolve(cells, mark.anchor);
let Some(layout) = self.layout(cells, index) else {
return;
};
let Some(anchor) = layout.table_cell_at_offset(mark.active.offset, table.active.cell)
else {
return;
};
if cell == table.anchor.cell {
mark.active = mark.anchor;
table.active = table.anchor;
} else {
let Some((offset, padding_column)) = layout.table_cell_position(&anchor, cell) else {
return;
};
mark.active = Anchor {
offset,
row_bias: 0,
..mark.anchor
};
table.active = CellPosition {
cell,
padding_column,
};
}
mode.mark = Some(mark);
self.select_mode = Some(mode);
self.sync_select_range(cells);
self.reveal_select_cursor(cells);
}
pub(super) fn sync_select_table(
&mut self,
cells: &[Arc<dyn HistoryCell>],
mark: Mark,
table: TableMark,
) {
let index = self.resolve(cells, mark.anchor);
let Some(layout) = self.layout(cells, index) else {
return;
};
let (Some(anchor), Some(active)) = (
layout.table_cell_at_offset(mark.anchor.offset, table.anchor.cell),
layout.table_cell_at_offset(mark.active.offset, table.active.cell),
) else {
return;
};
if let Some(selection) = &mut self.selection {
selection.set_keyboard_table_range(
mark.anchor,
&layout,
anchor,
active,
(table.kind == TableMarkKind::Text).then_some(mark.active),
);
selection
.snapshot
.pinned
.entry(mark.anchor.key)
.or_insert(layout);
}
}
}
@@ -1,5 +1,6 @@
use super::*;
use crate::clipboard_copy::CopyStatus;
use crate::history_cell::AgentMarkdownCell;
use crate::history_cell::PlainHistoryCell;
use crossterm::event::MouseButton;
use crossterm::event::MouseEvent;
@@ -272,7 +273,7 @@ fn configured_pager_page_motion_moves_the_select_cursor() {
(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"}
"pager": {"page_down": "c"}
}))
.expect("keymap config");
let keymap = crate::keymap::RuntimeKeymap::from_config(&config).expect("configured page down");
@@ -284,7 +285,7 @@ fn configured_pager_page_motion_moves_the_select_cursor() {
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'));
press(&mut view, &cells, KeyCode::Char('c'));
let cursor = view
.select_mode
@@ -804,3 +805,314 @@ fn select_mode_renders_cursor_and_scannable_footer_hints() {
find_footer.cursor_column,
));
}
fn select_table_at_match(
cells: &[Arc<dyn HistoryCell>],
width: u16,
query: &str,
) -> TranscriptView {
let mut view = TranscriptView::default();
crate::transcript_view::tests::render(&mut view, cells, width, /*height*/ 32);
assert!(view.begin_select_mode(cells));
press(&mut view, cells, KeyCode::Char('/'));
assert!(view.paste_search(query));
while view.advance_search(cells) {}
press(&mut view, cells, KeyCode::Enter);
view
}
// These view tests receive the semantic event produced by the app's chord router.
fn mark_markdown(view: &mut TranscriptView, cells: &[Arc<dyn HistoryCell>]) {
let key = crate::keymap::dispatch_event(crate::keymap::KeymapActionId {
context: crate::keymap::KeymapContext::Pager,
action: "mark_markdown",
})
.unwrap();
press(view, cells, key.code);
}
#[test]
fn keyboard_table_mark_selects_cells_and_swaps_rectangle_corners() {
let cells: [Arc<dyn HistoryCell>; 1] = [Arc::new(AgentMarkdownCell::new(
"| Left | Right |\n|---|---|\n| alpha | Beta |\n| Gamma | delta |".into(),
std::path::Path::new("/"),
))];
let mut view = select_table_at_match(&cells, /*width*/ 32, "Beta");
let footer = |view: &TranscriptView| {
view.footer_with_navigation(/*width*/ 32, crate::motion::MotionMode::Reduced, "")
.unwrap()
.text
};
let before = footer(&view);
mark_markdown(&mut view, &cells);
press(&mut view, &cells, KeyCode::Down);
press(&mut view, &cells, KeyCode::Char('h'));
let rectangle = "| | |\n|---|---|\n| alpha | Beta |\n| Gamma | delta |";
assert!(matches!(
view.handle_key(KeyCode::Char('y').into(), &cells),
Some(ViewAction::Copy(text)) if text == rectangle
));
let marked = crate::transcript_view::tests::render(
&mut view, &cells, /*width*/ 32, /*height*/ 5,
);
let highlight: String = marked
.content()
.chunks(32)
.map(|row| {
row.iter()
.map(|cell| {
use ratatui::style::Modifier;
if cell.modifier.contains(Modifier::UNDERLINED) {
"@"
} else if cell.modifier.contains(Modifier::REVERSED) {
cell.symbol()
} else {
" "
}
})
.collect::<String>()
.trim_end()
.to_owned()
})
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(format!("{before}\n{highlight}\n{}", footer(&view)));
press(&mut view, &cells, KeyCode::Char('/'));
press(&mut view, &cells, KeyCode::Esc);
assert_eq!(view.selected_text(&cells).as_deref(), Some(rectangle));
press(&mut view, &cells, KeyCode::Char('o'));
press(&mut view, &cells, KeyCode::Down);
press(&mut view, &cells, KeyCode::Left);
assert_eq!(view.selected_text(&cells).as_deref(), Some("Gamma"));
press(&mut view, &cells, KeyCode::Esc);
assert_eq!(view.selected_text(&cells), None);
}
#[test]
fn keyboard_table_header_rectangle_excludes_record_values() {
let cells = [Arc::new(AgentMarkdownCell::new(
"| Left | Right |\n|---|---|\n| alpha | Beta |".into(),
std::path::Path::new("/"),
)) as Arc<dyn HistoryCell>];
for width in [10, 32] {
let mut view = select_table_at_match(&cells, width, "Left");
mark_markdown(&mut view, &cells);
press(&mut view, &cells, KeyCode::Right);
assert_eq!(
view.selected_text(&cells).as_deref(),
Some("| Left | Right |\n|---|---|"),
"width {width}",
);
}
}
#[test]
fn shift_click_extends_from_the_fixed_in_cell_text_anchor() {
let cells = [Arc::new(AgentMarkdownCell::new(
"| Text | Other |\n|---|---|\n| abcde | 1 |".into(),
std::path::Path::new("/"),
)) as Arc<dyn HistoryCell>];
for (motion, target, expected) in [(KeyCode::Left, "e", "bcd"), (KeyCode::Right, "a", "ab")] {
let mut view = select_table_at_match(&cells, /*width*/ 32, "bcd");
mark_markdown(&mut view, &cells);
press(&mut view, &cells, KeyCode::Char('v'));
press(&mut view, &cells, motion);
let buffer = crate::transcript_view::tests::render(
&mut view, &cells, /*width*/ 32, /*height*/ 12,
);
let index = buffer
.content()
.iter()
.rposition(|cell| cell.symbol() == target)
.unwrap();
let (column, row) = buffer.pos_of(index);
view.handle_selection_mouse(
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column,
row,
modifiers: KeyModifiers::SHIFT,
},
&cells,
/*patch_approval*/ None,
);
assert_eq!(view.selected_text(&cells).as_deref(), Some(expected));
}
}
#[test]
fn in_cell_word_motion_projects_boundaries_past_escaped_pipes() {
for (header, query, motion, expected) in [
(r"A\|界語", "語", 'b', "界語"),
(r"A\|alpha", "alpha", 'e', "alpha"),
] {
let cells = [Arc::new(AgentMarkdownCell::new(
format!("| {header} | B |\n|---|---|"),
std::path::Path::new("/"),
)) as Arc<dyn HistoryCell>];
let mut view = select_table_at_match(&cells, /*width*/ 8, query);
mark_markdown(&mut view, &cells);
press(&mut view, &cells, KeyCode::Char('v'));
press(&mut view, &cells, KeyCode::Char(motion));
assert!(matches!(
view.handle_key(KeyCode::Char('y').into(), &cells),
Some(ViewAction::Copy(text)) if text == expected
));
}
}
#[test]
fn shift_click_replaces_the_cell_mark_at_the_same_endpoint() {
let cells = [Arc::new(AgentMarkdownCell::new(
"| Left | Right |\n|---|---|\n| alpha | Beta |\n| Gamma | delta |".into(),
std::path::Path::new("/"),
)) as Arc<dyn HistoryCell>];
let mut view = select_table_at_match(&cells, /*width*/ 32, "Beta");
mark_markdown(&mut view, &cells);
press(&mut view, &cells, KeyCode::Down);
crate::transcript_view::tests::render(&mut view, &cells, /*width*/ 32, /*height*/ 12);
let selection = view.selection.as_ref().unwrap();
let bounds = (selection.start, selection.end);
let (row, line) = view
.visible
.iter()
.enumerate()
.find(|(_, visible)| {
visible.key == bounds.1.key
&& visible.layout.row_for_offset(bounds.1.offset) == visible.row
})
.unwrap();
let column = line
.layout
.column_for_offset_on_row(line.row, bounds.1.offset);
view.handle_selection_mouse(
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column,
row: row as u16,
modifiers: KeyModifiers::SHIFT,
},
&cells,
/*patch_approval*/ None,
);
let selection = view.selection.as_ref().unwrap();
assert_eq!((selection.start, selection.end), bounds);
assert!(view.table_mark().is_none());
}
#[test]
fn keyboard_cell_word_motion_crosses_a_wrapped_identifier() {
let cells = [Arc::new(AgentMarkdownCell::new(
"| Name | Value |\n|---|---|\n| abcdefghijklmnop next | 1 |".into(),
std::path::Path::new("/"),
)) as Arc<dyn HistoryCell>];
let mut view = select_table_at_match(&cells, /*width*/ 12, "abcd");
mark_markdown(&mut view, &cells);
press(&mut view, &cells, KeyCode::Char('v'));
press(&mut view, &cells, KeyCode::Char('e'));
assert_eq!(
view.selected_text(&cells).as_deref(),
Some("abcdefghijklmnop")
);
press(&mut view, &cells, KeyCode::Char('b'));
press(&mut view, &cells, KeyCode::Char('w'));
assert_eq!(
view.selected_text(&cells).as_deref(),
Some("abcdefghijklmnop n")
);
}
#[test]
fn keyboard_table_motion_skips_record_labels_and_reaches_empty_cells() {
let cells: [Arc<dyn HistoryCell>; 1] = [Arc::new(AgentMarkdownCell::new(
"| Left | Right |\n|---|---|\n| alpha | Beta |\n| Gamma | |\n| omega | delta |".into(),
std::path::Path::new("/"),
))];
for width in [32, 10] {
let mut view = select_table_at_match(&cells, width, "Beta");
mark_markdown(&mut view, &cells);
press(&mut view, &cells, KeyCode::Char('j'));
press(&mut view, &cells, KeyCode::Char('j'));
assert_eq!(
view.selected_text(&cells).as_deref(),
Some("| | |\n|---|---|\n| | Beta |\n| | |\n| | delta |"),
"width {width}"
);
press(&mut view, &cells, KeyCode::Char('k'));
press(&mut view, &cells, KeyCode::Esc);
mark_markdown(&mut view, &cells);
assert_eq!(view.selected_text(&cells), None);
press(&mut view, &cells, KeyCode::Char('k'));
assert!(
view.selected_text(&cells)
.is_some_and(|text| text.contains("Beta"))
);
if width == 32 {
press(&mut view, &cells, KeyCode::Down);
press(&mut view, &cells, KeyCode::Esc);
press(&mut view, &cells, KeyCode::Up); // Cross the separator.
press(&mut view, &cells, KeyCode::Up);
mark_markdown(&mut view, &cells);
assert_eq!(view.selected_text(&cells).as_deref(), Some("Beta"));
}
}
}
#[test]
fn keyboard_cell_text_crosses_wraps_without_selecting_neighbors() {
let cells: [Arc<dyn HistoryCell>; 1] = [Arc::new(AgentMarkdownCell::new(
"| Description | Size |\n|---|---|\n| alpha **界 beta gamma delta** omega | 123 |\n| following | 456 |".into(),
std::path::Path::new("/"),
))];
for width in [34, 12] {
let mut view = select_table_at_match(&cells, width, "界");
mark_markdown(&mut view, &cells);
press(&mut view, &cells, KeyCode::Char('v'));
assert_eq!(view.selected_text(&cells).as_deref(), Some("界"));
press(&mut view, &cells, KeyCode::Down);
assert!(
view.selected_text(&cells).unwrap().starts_with("界 beta"),
"width {width}"
);
press(&mut view, &cells, KeyCode::Up);
press(&mut view, &cells, KeyCode::Char('w'));
assert_eq!(view.selected_text(&cells).as_deref(), Some("界 b"));
press(&mut view, &cells, KeyCode::End);
press(&mut view, &cells, KeyCode::Right);
let text = "界 beta gamma delta omega";
assert!(matches!(
view.handle_key(KeyCode::Char('y').into(), &cells),
Some(ViewAction::Copy(copy)) if copy == text
));
view.copy_selected_text_with(
&cells,
text,
/*clear_selection*/ false,
|text, format| {
assert_eq!(
(text, format),
(
"界 beta gamma delta omega",
crate::clipboard_copy::CopyFormat::MarkdownSelection(
"**界 beta gamma delta** omega".into()
),
)
);
Ok(CopyStatus::Confirmed)
},
)
.unwrap();
press(&mut view, &cells, KeyCode::Char('o'));
press(&mut view, &cells, KeyCode::Home);
press(&mut view, &cells, KeyCode::Left);
assert_eq!(
view.selected_text(&cells).as_deref(),
Some("alpha 界 beta gamma delta omega"),
"width {width}"
);
}
}
+55 -4
View File
@@ -30,6 +30,8 @@ struct TableSelection {
anchor: TableCellHit,
// A head marks a whole-cell drag. Returning to its anchor selects the whole cell again.
head: Option<TableEndpoint>,
// Keyboard text marks keep the full grapheme at their fixed anchor on mouse takeover.
keyboard_text: bool,
ranges: Vec<Range<usize>>,
}
@@ -37,8 +39,14 @@ impl TableSelection {
fn rectangle_rows(&self) -> Option<RangeInclusive<usize>> {
let anchor = self.anchor.endpoint;
let head = self.head?;
if head == anchor {
return None;
}
if anchor.is_header_row_with(head) {
return Some(0..=0);
}
let (first, last) = (anchor.display_row(), head.display_row());
(head != anchor).then(|| first.min(last)..=first.max(last))
Some(first.min(last)..=first.max(last))
}
fn bounds(&self) -> Range<usize> {
@@ -139,8 +147,8 @@ impl Selection {
}
fn clear_table_cell_scope(&mut self) {
if self.table_cell.take().is_some() {
if self.unit == SelectionUnit::Character {
if let Some(table) = self.table_cell.take() {
if self.unit == SelectionUnit::Character && !table.keyboard_text {
self.origin.1 = self.origin.0;
}
self.pending_copy = None;
@@ -160,6 +168,44 @@ impl Selection {
self.preferred_column = None;
}
pub(super) fn set_keyboard_table_range(
&mut self,
entry: Anchor,
layout: &TextLayout,
anchor: TableCellHit,
active: TableCellHit,
text_active: Option<Anchor>,
) {
let ranges = layout.table_selection_ranges(&anchor, &active);
let table = TableSelection {
anchor,
head: text_active.is_none().then_some(active.endpoint),
keyboard_text: text_active.is_some(),
ranges,
};
let at = |offset| Anchor {
offset,
row_bias: 0,
..entry
};
if let Some(active) = text_active {
let after =
|offset| at(layout.after_cursor_grapheme(layout.row_for_offset(offset), offset));
let origin = (entry, after(entry.offset));
let (start, end) = if entry.offset <= active.offset {
(entry, after(active.offset))
} else {
(active, origin.1)
};
self.set_keyboard_marked_range(start, end, origin, SelectionUnit::Character);
} else {
let bounds = table.bounds();
self.set_keyboard_range(at(bounds.start), at(bounds.end));
self.unit = SelectionUnit::Character;
}
self.table_cell = Some(table);
}
pub(super) fn set_keyboard_marked_range(
&mut self,
start: Anchor,
@@ -175,6 +221,10 @@ impl Selection {
pub(super) fn unit(&self) -> SelectionUnit {
self.unit
}
pub(super) fn has_table_scope(&self) -> bool {
self.table_cell.is_some()
}
}
impl TranscriptView {
@@ -245,6 +295,7 @@ impl TranscriptView {
let ranges = layout.table_selection_ranges(&cell, &cell);
Some(TableSelection {
head: (clicks == 3).then_some(cell.endpoint),
keyboard_text: false,
anchor: cell,
ranges,
})
@@ -301,7 +352,7 @@ impl TranscriptView {
};
if let Some(mode) = &mut self.select_mode {
let cursor = mode.cursor(&selection);
mode.mark = None;
mode.clear_mark();
let mut selection = selection;
selection.set_keyboard_range(cursor, cursor);
self.selection = Some(selection);
@@ -0,0 +1,12 @@
---
source: tui/src/transcript_view/select_mode_tests.rs
assertion_line: 881
expression: "format!(\"{before}\\n{highlight}\\n{}\", footer(&view))"
---
⌃x v mark cell · q close
alpha Beta
@amma delta
v mark text · y copy
+120 -9
View File
@@ -27,6 +27,7 @@ use unicode_segmentation::UnicodeSegmentation;
use crate::line_truncation::line_width;
use crate::line_truncation::truncate_line_to_width;
use crate::markdown_copy::table::TableLine;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::HyperlinkParagraph;
use crate::terminal_hyperlinks::LineWrapPolicy;
@@ -93,9 +94,24 @@ impl TableEndpoint {
pub(super) fn display_row(self) -> usize {
self.record_row.unwrap_or(self.cell.0)
}
pub(super) fn is_header_row_with(self, other: Self) -> bool {
self.cell.0 == 0 && other.cell.0 == 0 && self.record_row == other.record_row
}
}
impl TableCellHit {
fn new(table: &TableLine, cell: (usize, usize), line: Range<usize>) -> Self {
Self {
table: Arc::clone(&table.table),
endpoint: TableEndpoint {
cell,
record_row: table.record_row,
},
line,
}
}
pub(super) fn same_table(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.table, &other.table)
}
@@ -385,14 +401,80 @@ impl TextLayout {
let byte = origin.range.start + offset - position;
let column = display_width(&origin.text[..byte]) + usize::from(trailing);
let cell = table.cell_at(byte + usize::from(trailing), column)?;
Some(TableCellHit {
table: Arc::clone(&table.table),
endpoint: TableEndpoint {
cell,
record_row: table.record_row,
},
line: position..position + origin.range.len(),
})
Some(TableCellHit::new(
table,
cell,
position..position + origin.range.len(),
))
}
pub(super) fn table_cell_at_offset(
&self,
offset: usize,
cell: (usize, usize),
) -> Option<TableCellHit> {
let (start, source) = self
.logical_sources()
.find(|(start, source)| (*start..=*start + source.range.len()).contains(&offset))?;
let table = source.copy.as_ref()?.table.as_ref()?;
Some(TableCellHit::new(
table,
cell,
start..start + source.range.len(),
))
}
/// Locate a logical cell in the current presentation. A missing trailing grid cell
/// uses its display column on the first line of the row.
pub(super) fn table_cell_position(
&self,
current: &TableCellHit,
cell: (usize, usize),
) -> Option<(usize, Option<u16>)> {
let (row, column) = cell;
let mut padding = None;
for (start, source) in self.logical_sources() {
let Some(table) = source.copy.as_ref().and_then(|copy| copy.table.as_ref()) else {
continue;
};
if !Arc::ptr_eq(&table.table, &current.table) {
continue;
}
if row == 0
&& current.endpoint.cell.0 == 0
&& table.record_row != current.endpoint.record_row
{
continue;
}
let range = table.cell_range(&(row..=row), &(column..=column));
let position = range.and_then(|range| {
if range.is_empty() && source.range.is_empty() {
// Stacked records discard the indentation of blank values.
let padding = display_width(&source.text[..range.start]);
Some((start, u16::try_from(padding).ok()))
} else if range.start <= source.range.end && range.end >= source.range.start {
Some((start + range.start.saturating_sub(source.range.start), None))
} else {
None
}
});
if let Some(position) = position {
return Some(position);
}
if padding.is_none()
&& let Some(column) = table.grid_cell_column(row, column)
&& let Some(display_row) = self.rows.get(self.row_for_offset(start))
{
let first_column = display_row.first_column;
let source_prefix = display_width(&source.text[..source.range.start]);
let column = first_column + column.saturating_sub(source_prefix);
padding = Some((
start + source.range.len(),
Some(u16::try_from(column).unwrap_or(u16::MAX)),
));
}
}
padding
}
pub(super) fn is_table_row(&self, row: usize, offset: usize, hit: &TableCellHit) -> bool {
@@ -426,7 +508,7 @@ impl TextLayout {
.display_row()
.max(head.endpoint.display_row());
let records = first..=last;
let rows = if anchor.endpoint == head.endpoint && row == 0 {
let rows = if anchor.endpoint.is_header_row_with(head.endpoint) {
0..=0
} else if row == 0 || head_row == 0 {
0..=last
@@ -469,6 +551,35 @@ impl TextLayout {
table_cell_intersection(ranges, &self.rows.get(row)?.source)
}
pub(super) fn table_word_fragments<'a>(
&'a self,
ranges: &'a [Range<usize>],
) -> impl Iterator<Item = (&'a str, Range<usize>, usize)> {
self.logical_sources()
.filter_map(move |(start, source)| {
let table = source.copy.as_ref()?.table.as_ref()?;
let selection =
table_cell_intersection(ranges, &(start..start + source.range.len()))?;
let display = source.range.start + selection.start - start
..source.range.start + selection.end - start;
Some((table, display, start, source.range.start))
})
.flat_map(|(table, display, start, source_start)| {
table.source_fragments().filter_map(move |(output, cell)| {
let first = display.start.max(output.start);
let last = display.end.min(output.end);
(first < last).then(|| {
(
cell.text.as_ref(),
cell.range.start + first - output.start
..cell.range.start + last - output.start,
start + first - source_start,
)
})
})
})
}
/// A drag includes the glyph under the pointer. Padding snaps to the nearest text edge.
pub(super) fn table_cell_pointer_range(
&self,