mirror of
https://github.com/openai/codex.git
synced 2026-11-14 21:40:39 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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()),
|
||||
],
|
||||
)?;
|
||||
|
||||
|
||||
@@ -466,6 +466,7 @@ define_runtime_action_bindings! {
|
||||
close,
|
||||
close_transcript,
|
||||
find,
|
||||
mark_markdown,
|
||||
],
|
||||
"list" => List, list, list [
|
||||
move_up,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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 ›
|
||||
|
||||
|
||||
+1
-1
@@ -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
-2
@@ -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)
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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 ›
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+12
@@ -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
|
||||
@@ -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, ¤t.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,
|
||||
|
||||
Reference in New Issue
Block a user