From 68c63d72d38fd4274cdff32c1e65198c3150c666 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 13 Nov 2026 23:41:32 +0000 Subject: [PATCH] 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 --- codex-rs/config/src/tui_keymap.rs | 2 + codex-rs/core/config.schema.json | 10 + codex-rs/tui/src/app/input.rs | 30 +- codex-rs/tui/src/app/input/select_mode.rs | 15 +- .../src/app/owned_transcript_input_tests.rs | 70 ++++ .../tui/src/app/tests/transcript_selection.rs | 56 +++ codex-rs/tui/src/app/workspace_hud.rs | 1 + codex-rs/tui/src/keymap.rs | 4 + codex-rs/tui/src/keymap/bindings.rs | 1 + codex-rs/tui/src/keymap/chords.rs | 26 +- .../tui/src/keymap/chords/leader_defaults.rs | 2 + codex-rs/tui/src/keymap_setup/actions.rs | 1 + codex-rs/tui/src/markdown_copy/table.rs | 17 + codex-rs/tui/src/pager_overlay/transcript.rs | 1 + codex-rs/tui/src/resume_picker.rs | 74 ++-- ...ap_setup__tests__keymap_picker_custom.snap | 2 +- ...ests__keymap_picker_fast_mode_enabled.snap | 2 +- ...p__tests__keymap_picker_first_actions.snap | 4 +- ...up__tests__keymap_picker_global_chord.snap | 4 +- ...ap_setup__tests__keymap_picker_narrow.snap | 2 +- ...ymap_setup__tests__keymap_picker_wide.snap | 2 +- codex-rs/tui/src/transcript_view/footer.rs | 39 +- codex-rs/tui/src/transcript_view/input.rs | 26 +- codex-rs/tui/src/transcript_view/search.rs | 7 +- .../tui/src/transcript_view/select_mode.rs | 63 +++- .../src/transcript_view/select_mode/motion.rs | 37 +- .../src/transcript_view/select_mode/table.rs | 350 ++++++++++++++++++ .../src/transcript_view/select_mode_tests.rs | 316 +++++++++++++++- codex-rs/tui/src/transcript_view/selection.rs | 59 ++- ...cts_cells_and_swaps_rectangle_corners.snap | 12 + codex-rs/tui/src/transcript_view/text.rs | 129 ++++++- 31 files changed, 1264 insertions(+), 100 deletions(-) create mode 100644 codex-rs/tui/src/transcript_view/select_mode/table.rs create mode 100644 codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__select_mode__tests__keyboard_table_mark_selects_cells_and_swaps_rectangle_corners.snap diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 2d7ae5d6d4..c0036cda5f 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -482,6 +482,8 @@ pub struct TuiPagerKeymap { pub close_transcript: Option, /// Find text in a transcript pager. pub find: Option, + /// Mark Markdown structure under the caret in transcript selection mode. + pub mark_markdown: Option, } /// List selection context keybindings for popup-style selectable lists. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 0cf6e96e98..d63fb5569d 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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": [ { diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 3456d1f119..cf17c8275d 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -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() { diff --git a/codex-rs/tui/src/app/input/select_mode.rs b/codex-rs/tui/src/app/input/select_mode.rs index 513201a724..7ace4c0b77 100644 --- a/codex-rs/tui/src/app/input/select_mode.rs +++ b/codex-rs/tui/src/app/input/select_mode.rs @@ -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) diff --git a/codex-rs/tui/src/app/owned_transcript_input_tests.rs b/codex-rs/tui/src/app/owned_transcript_input_tests.rs index e622c90c9b..27358c4816 100644 --- a/codex-rs/tui/src/app/owned_transcript_input_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_input_tests.rs @@ -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; diff --git a/codex-rs/tui/src/app/tests/transcript_selection.rs b/codex-rs/tui/src/app/tests/transcript_selection.rs index 75462d38ed..4e09c1e679 100644 --- a/codex-rs/tui/src/app/tests/transcript_selection.rs +++ b/codex-rs/tui/src/app/tests/transcript_selection.rs @@ -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::(); + assert_eq!(selected, "Beta"); + app_server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/workspace_hud.rs b/codex-rs/tui/src/app/workspace_hud.rs index f61351e3d2..6f1304553d 100644 --- a/codex-rs/tui/src/app/workspace_hud.rs +++ b/codex-rs/tui/src/app/workspace_hud.rs @@ -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), }; diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 499a5cabe0..c34921c562 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -340,6 +340,7 @@ pub(crate) struct PagerKeymap { pub(crate) close: Vec, pub(crate) close_transcript: Vec, pub(crate) find: Vec, + pub(crate) mark_markdown: Vec, chord_hints: Arc, } @@ -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()), ], )?; diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index 7bb98cd5d8..bc39522e0d 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -466,6 +466,7 @@ define_runtime_action_bindings! { close, close_transcript, find, + mark_markdown, ], "list" => List, list, list [ move_up, diff --git a/codex-rs/tui/src/keymap/chords.rs b/codex-rs/tui/src/keymap/chords.rs index fdff3eb4df..10b135ff00 100644 --- a/codex-rs/tui/src/keymap/chords.rs +++ b/codex-rs/tui/src/keymap/chords.rs @@ -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(), diff --git a/codex-rs/tui/src/keymap/chords/leader_defaults.rs b/codex-rs/tui/src/keymap/chords/leader_defaults.rs index fa6d555f57..f3d10e72d1 100644 --- a/codex-rs/tui/src/keymap/chords/leader_defaults.rs +++ b/codex-rs/tui/src/keymap/chords/leader_defaults.rs @@ -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; diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 76349028ac..6e4f6fb7aa 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -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.", diff --git a/codex-rs/tui/src/markdown_copy/table.rs b/codex-rs/tui/src/markdown_copy/table.rs index 8e00fb5337..8b65610c94 100644 --- a/codex-rs/tui/src/markdown_copy/table.rs +++ b/codex-rs/tui/src/markdown_copy/table.rs @@ -69,6 +69,23 @@ impl TableLine { Some(first.output.start..last.output.end) } + pub(crate) fn source_fragments( + &self, + ) -> impl Iterator, &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 { + 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), diff --git a/codex-rs/tui/src/pager_overlay/transcript.rs b/codex-rs/tui/src/pager_overlay/transcript.rs index ebd4d84d84..1e9a8ed242 100644 --- a/codex-rs/tui/src/pager_overlay/transcript.rs +++ b/codex-rs/tui/src/pager_overlay/transcript.rs @@ -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); diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 42bcbfbead..ab05955668 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -1392,38 +1392,66 @@ impl PickerState { } fn route_key_chord(&mut self, key: KeyEvent) -> Option { - 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, } } diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap index bec1df2c81..f009ea3067 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap @@ -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 › diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap index ae034dbccf..6f788eb4c5 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap @@ -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 › diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index f68a02d102..5b196093f2 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap @@ -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) diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap index 401b5edfa5..a001b6083e 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap @@ -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 diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap index 959a54cb0a..2ce4f5e4c8 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -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 › diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index edf78a1c92..a5017d3f4e 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -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 › diff --git a/codex-rs/tui/src/transcript_view/footer.rs b/codex-rs/tui/src/transcript_view/footer.rs index addb462eb1..bbf20e44b7 100644 --- a/codex-rs/tui/src/transcript_view/footer.rs +++ b/codex-rs/tui/src/transcript_view/footer.rs @@ -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", diff --git a/codex-rs/tui/src/transcript_view/input.rs b/codex-rs/tui/src/transcript_view/input.rs index 5ebc730ce4..015f0342cf 100644 --- a/codex-rs/tui/src/transcript_view/input.rs +++ b/codex-rs/tui/src/transcript_view/input.rs @@ -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; diff --git a/codex-rs/tui/src/transcript_view/search.rs b/codex-rs/tui/src/transcript_view/search.rs index 8b082642f8..c320557e1b 100644 --- a/codex-rs/tui/src/transcript_view/search.rs +++ b/codex-rs/tui/src/transcript_view/search.rs @@ -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]) { 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) { diff --git a/codex-rs/tui/src/transcript_view/select_mode.rs b/codex-rs/tui/src/transcript_view/select_mode.rs index 1cdc1df878..8b637bf9b2 100644 --- a/codex-rs/tui/src/transcript_view/select_mode.rs +++ b/codex-rs/tui/src/transcript_view/select_mode.rs @@ -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, pub(super) preferred_column: Option, + pub(super) table_caret: Option, pub(super) search_origin: Option, } @@ -31,12 +33,14 @@ pub(super) struct Mark { anchor_edges: Option<(Anchor, Anchor)>, active_edges: Option<(Anchor, Anchor)>, unit: SelectionUnit, + table: Option, } #[derive(Clone, Copy)] pub(super) struct SelectSearchOrigin { pub(super) mark: Option, pub(super) preferred_column: Option, + pub(super) table_caret: Option, 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 { + 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; diff --git a/codex-rs/tui/src/transcript_view/select_mode/motion.rs b/codex-rs/tui/src/transcript_view/select_mode/motion.rs index ff36090b84..a082cafc34 100644 --- a/codex-rs/tui/src/transcript_view/select_mode/motion.rs +++ b/codex-rs/tui/src/transcript_view/select_mode/motion.rs @@ -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]) { + pub(in crate::transcript_view) fn sync_select_range(&mut self, cells: &[Arc]) { 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 impl DoubleEndedIterator { + text.split_word_bound_indices() + .filter(|(_, word)| word.chars().any(char::is_alphanumeric)) +} diff --git a/codex-rs/tui/src/transcript_view/select_mode/table.rs b/codex-rs/tui/src/transcript_view/select_mode/table.rs new file mode 100644 index 0000000000..6c1299fae4 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/select_mode/table.rs @@ -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, +} + +fn table_hit(layout: &TextLayout, cursor: Anchor) -> Option { + 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 { + 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]) -> 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]) { + 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], + mark: Mark, + cell: CellPosition, + motion: CursorMotion, + preferred_column: &mut Option, + ) -> Option { + 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], + anchor: Anchor, + cell: CellPosition, + ) -> Option<(Arc, Vec>)> { + 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], + 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], + 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); + } + } +} diff --git a/codex-rs/tui/src/transcript_view/select_mode_tests.rs b/codex-rs/tui/src/transcript_view/select_mode_tests.rs index 457a2a2a7d..5a1f1da434 100644 --- a/codex-rs/tui/src/transcript_view/select_mode_tests.rs +++ b/codex-rs/tui/src/transcript_view/select_mode_tests.rs @@ -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]; 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], + 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]) { + 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; 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::() + .trim_end() + .to_owned() + }) + .collect::>() + .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]; + 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]; + 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]; + 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]; + 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]; + 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; 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; 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}" + ); + } +} diff --git a/codex-rs/tui/src/transcript_view/selection.rs b/codex-rs/tui/src/transcript_view/selection.rs index a83b67f6e1..ccfe3475d6 100644 --- a/codex-rs/tui/src/transcript_view/selection.rs +++ b/codex-rs/tui/src/transcript_view/selection.rs @@ -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, + // Keyboard text marks keep the full grapheme at their fixed anchor on mouse takeover. + keyboard_text: bool, ranges: Vec>, } @@ -37,8 +39,14 @@ impl TableSelection { fn rectangle_rows(&self) -> Option> { 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 { @@ -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, + ) { + 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); diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__select_mode__tests__keyboard_table_mark_selects_cells_and_swaps_rectangle_corners.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__select_mode__tests__keyboard_table_mark_selects_cells_and_swaps_rectangle_corners.snap new file mode 100644 index 0000000000..3940a9ae82 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__select_mode__tests__keyboard_table_mark_selects_cells_and_swaps_rectangle_corners.snap @@ -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 diff --git a/codex-rs/tui/src/transcript_view/text.rs b/codex-rs/tui/src/transcript_view/text.rs index a557efad45..01d38d3377 100644 --- a/codex-rs/tui/src/transcript_view/text.rs +++ b/codex-rs/tui/src/transcript_view/text.rs @@ -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) -> 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 { + 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)> { + 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], + ) -> impl Iterator, 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,