mirror of
https://github.com/openai/codex.git
synced 2026-11-13 22:47:20 +00:00
fix(tui2): copy transcript selection outside viewport (#8449)
Copy now operates on the full logical selection range (anchor..head), not just the visible viewport, so selections that include offscreen lines copy the expected text. Selection extraction is factored into `transcript_selection` to make the logic easier to test and reason about. It reconstructs the wrapped visual transcript, renders each wrapped line into a 1-row offscreen Buffer, and reads the selected cells. This keeps clipboard text aligned with what is rendered (gutter, indentation, wrapping). Additional behavior: - Skip continuation cells for wide glyphs (e.g. CJK) so copied text does not include spurious spaces like "コ X". - Avoid copying right-margin padding spaces. Manual tested performed: - "tell me a story" a few times - scroll up, select text, scroll down, copy text - confirm copied text is what you expect
This commit is contained in:
1 parent
4673090f73
commit
7d0c5c7bd5
4 files changed
+525
-175
No files matched your search
@@ -183,10 +183,12 @@ Mouse interaction is a first‑class part of the new design:
|
|||||||
that we use for bullets/prefixes.
|
that we use for bullets/prefixes.
|
||||||
|
|
||||||
- **Copy.**
|
- **Copy.**
|
||||||
- When the user triggers copy, the TUI re‑renders just the transcript region off‑screen using the
|
- When the user triggers copy, the TUI reconstructs the same wrapped transcript lines used for
|
||||||
same wrapping as the visible view.
|
on-screen rendering.
|
||||||
- It then walks the selected lines and columns in that off‑screen buffer to reconstruct the exact
|
- It then walks the content-relative selection range (even if the selection extends outside the
|
||||||
text region the user highlighted (including internal spaces and empty lines).
|
current viewport) and re-renders each selected visual line into a 1-row offscreen buffer to
|
||||||
|
reconstruct the exact text region the user highlighted (including internal spaces and empty
|
||||||
|
lines, while skipping wide-glyph continuation cells and right-margin padding).
|
||||||
- That text is sent to the system clipboard and a status footer indicates success or failure.
|
- That text is sent to the system clipboard and a status footer indicates success or failure.
|
||||||
|
|
||||||
Because scrolling, selection, and copy all operate on the same flattened transcript representation,
|
Because scrolling, selection, and copy all operate on the same flattened transcript representation,
|
||||||
@@ -392,8 +394,8 @@ The main app struct (`codex-rs/tui2/src/app.rs`) tracks the transcript and viewp
|
|||||||
- `transcript_cells: Vec<Arc<dyn HistoryCell>>` – the logical history.
|
- `transcript_cells: Vec<Arc<dyn HistoryCell>>` – the logical history.
|
||||||
- `transcript_scroll: TranscriptScroll` – whether the viewport is pinned to the bottom or
|
- `transcript_scroll: TranscriptScroll` – whether the viewport is pinned to the bottom or
|
||||||
anchored at a specific cell/line pair.
|
anchored at a specific cell/line pair.
|
||||||
- `transcript_selection: TranscriptSelection` – a selection expressed in screen coordinates over
|
- `transcript_selection: TranscriptSelection` – a selection expressed in content-relative
|
||||||
the flattened transcript region.
|
coordinates over the flattened, wrapped transcript (line index + column).
|
||||||
- `transcript_view_top` / `transcript_total_lines` – the current viewport’s top line index and
|
- `transcript_view_top` / `transcript_total_lines` – the current viewport’s top line index and
|
||||||
total number of wrapped lines for the inline transcript area.
|
total number of wrapped lines for the inline transcript area.
|
||||||
|
|
||||||
@@ -410,8 +412,9 @@ Streaming wrapping details live in `codex-rs/tui2/docs/streaming_wrapping_design
|
|||||||
|
|
||||||
Mouse handling lives in `App::handle_mouse_event`, keyboard scrolling in
|
Mouse handling lives in `App::handle_mouse_event`, keyboard scrolling in
|
||||||
`App::handle_key_event`, selection rendering in `App::apply_transcript_selection`, and copy in
|
`App::handle_key_event`, selection rendering in `App::apply_transcript_selection`, and copy in
|
||||||
`App::copy_transcript_selection` plus `codex-rs/tui2/src/clipboard_copy.rs`. Scroll/selection UI
|
`App::copy_transcript_selection` plus `codex-rs/tui2/src/transcript_selection.rs` and
|
||||||
state is forwarded through `ChatWidget::set_transcript_ui_state`,
|
`codex-rs/tui2/src/clipboard_copy.rs`. Scroll/selection UI state is forwarded through
|
||||||
|
`ChatWidget::set_transcript_ui_state`,
|
||||||
`BottomPane::set_transcript_ui_state`, and `ChatComposer::footer_props`, with footer text
|
`BottomPane::set_transcript_ui_state`, and `ChatComposer::footer_props`, with footer text
|
||||||
assembled in `codex-rs/tui2/src/bottom_pane/footer.rs`.
|
assembled in `codex-rs/tui2/src/bottom_pane/footer.rs`.
|
||||||
|
|
||||||
@@ -435,6 +438,8 @@ feedback are already implemented:
|
|||||||
probe data (see `codex-rs/tui2/docs/scroll_input_model.md`).
|
probe data (see `codex-rs/tui2/docs/scroll_input_model.md`).
|
||||||
- While a selection is active, streaming stops “follow latest output” so the selection remains
|
- While a selection is active, streaming stops “follow latest output” so the selection remains
|
||||||
stable, and follow mode resumes after the selection is cleared.
|
stable, and follow mode resumes after the selection is cleared.
|
||||||
|
- Copy operates on the full selection range (including offscreen lines), using the same wrapping as
|
||||||
|
on-screen rendering.
|
||||||
|
|
||||||
### 10.2 Roadmap (prioritized)
|
### 10.2 Roadmap (prioritized)
|
||||||
|
|
||||||
@@ -449,8 +454,6 @@ Vim) behavior as we can while still owning the viewport.
|
|||||||
input (avoid redraw/event-loop backlog that makes scrolling feel “janky”).
|
input (avoid redraw/event-loop backlog that makes scrolling feel “janky”).
|
||||||
- **Mouse event bounds.** Ignore mouse events outside the transcript region so clicks in the
|
- **Mouse event bounds.** Ignore mouse events outside the transcript region so clicks in the
|
||||||
composer/footer don’t start or mutate transcript selection state.
|
composer/footer don’t start or mutate transcript selection state.
|
||||||
- **Copy includes offscreen lines.** Make copy operate on the full selection range even when part (or
|
|
||||||
all) of the selection is outside the current viewport.
|
|
||||||
- **Copy fidelity.** Preserve meaningful indentation (especially code blocks), treat soft-wrapped
|
- **Copy fidelity.** Preserve meaningful indentation (especially code blocks), treat soft-wrapped
|
||||||
prose as a single logical line when copying, and copy markdown _source_ (including backticks and
|
prose as a single logical line when copying, and copy markdown _source_ (including backticks and
|
||||||
heading markers) even if we render it differently.
|
heading markers) even if we render it differently.
|
||||||
|
|||||||
+48
-165
@@ -17,6 +17,9 @@ use crate::pager_overlay::Overlay;
|
|||||||
use crate::render::highlight::highlight_bash_to_lines;
|
use crate::render::highlight::highlight_bash_to_lines;
|
||||||
use crate::render::renderable::Renderable;
|
use crate::render::renderable::Renderable;
|
||||||
use crate::resume_picker::ResumeSelection;
|
use crate::resume_picker::ResumeSelection;
|
||||||
|
use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS;
|
||||||
|
use crate::transcript_selection::TranscriptSelection;
|
||||||
|
use crate::transcript_selection::TranscriptSelectionPoint;
|
||||||
use crate::tui;
|
use crate::tui;
|
||||||
use crate::tui::TuiEvent;
|
use crate::tui::TuiEvent;
|
||||||
use crate::tui::scrolling::MouseScrollState;
|
use crate::tui::scrolling::MouseScrollState;
|
||||||
@@ -359,28 +362,6 @@ pub(crate) struct App {
|
|||||||
skip_world_writable_scan_once: bool,
|
skip_world_writable_scan_once: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Content-relative selection within the inline transcript viewport.
|
|
||||||
///
|
|
||||||
/// Selection endpoints are expressed in terms of flattened, wrapped transcript
|
|
||||||
/// line indices and columns, so the highlight tracks logical conversation
|
|
||||||
/// content even when the viewport scrolls or the terminal is resized.
|
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
|
||||||
struct TranscriptSelection {
|
|
||||||
anchor: Option<TranscriptSelectionPoint>,
|
|
||||||
head: Option<TranscriptSelectionPoint>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A single endpoint of a transcript selection.
|
|
||||||
///
|
|
||||||
/// `line_index` is an index into the flattened wrapped transcript lines, and
|
|
||||||
/// `column` is a zero-based column offset within that visual line, counted from
|
|
||||||
/// the first content column to the right of the transcript gutter.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
struct TranscriptSelectionPoint {
|
|
||||||
line_index: usize,
|
|
||||||
column: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
async fn shutdown_current_conversation(&mut self) {
|
async fn shutdown_current_conversation(&mut self) {
|
||||||
if let Some(conversation_id) = self.chat_widget.conversation_id() {
|
if let Some(conversation_id) = self.chat_widget.conversation_id() {
|
||||||
@@ -889,7 +870,7 @@ impl App {
|
|||||||
width,
|
width,
|
||||||
height: transcript_height,
|
height: transcript_height,
|
||||||
};
|
};
|
||||||
let base_x = transcript_area.x.saturating_add(2);
|
let base_x = transcript_area.x.saturating_add(TRANSCRIPT_GUTTER_COLS);
|
||||||
let max_x = transcript_area.right().saturating_sub(1);
|
let max_x = transcript_area.right().saturating_sub(1);
|
||||||
|
|
||||||
// Treat the transcript as the only interactive region for transcript selection.
|
// Treat the transcript as the only interactive region for transcript selection.
|
||||||
@@ -952,8 +933,7 @@ impl App {
|
|||||||
clamped_x,
|
clamped_x,
|
||||||
clamped_y,
|
clamped_y,
|
||||||
) {
|
) {
|
||||||
self.transcript_selection.anchor = Some(point);
|
self.transcript_selection = TranscriptSelection::new(point, point);
|
||||||
self.transcript_selection.head = Some(point);
|
|
||||||
tui.frame_requester().schedule_frame();
|
tui.frame_requester().schedule_frame();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1281,16 +1261,10 @@ impl App {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let base_x = area.x.saturating_add(2);
|
let base_x = area.x.saturating_add(TRANSCRIPT_GUTTER_COLS);
|
||||||
let max_x = area.right().saturating_sub(1);
|
let max_x = area.right().saturating_sub(1);
|
||||||
|
|
||||||
let mut start = anchor;
|
let (start, end) = crate::transcript_selection::ordered_endpoints(anchor, head);
|
||||||
let mut end = head;
|
|
||||||
if (end.line_index < start.line_index)
|
|
||||||
|| (end.line_index == start.line_index && end.column < start.column)
|
|
||||||
{
|
|
||||||
std::mem::swap(&mut start, &mut end);
|
|
||||||
}
|
|
||||||
|
|
||||||
let visible_start = self.transcript_view_top;
|
let visible_start = self.transcript_view_top;
|
||||||
let visible_end = self
|
let visible_end = self
|
||||||
@@ -1365,15 +1339,13 @@ impl App {
|
|||||||
/// indices and columns, and this method reconstructs the same wrapped
|
/// indices and columns, and this method reconstructs the same wrapped
|
||||||
/// transcript used for on-screen rendering so the copied text closely
|
/// transcript used for on-screen rendering so the copied text closely
|
||||||
/// matches the highlighted region.
|
/// matches the highlighted region.
|
||||||
|
///
|
||||||
|
/// Important: copy operates on the selection's full content-relative range,
|
||||||
|
/// not just the current viewport. A selection can extend outside the visible
|
||||||
|
/// region (for example, by scrolling after selecting, or by selecting while
|
||||||
|
/// autoscrolling), and we still want the clipboard payload to reflect the
|
||||||
|
/// entire selected transcript.
|
||||||
fn copy_transcript_selection(&mut self, tui: &tui::Tui) {
|
fn copy_transcript_selection(&mut self, tui: &tui::Tui) {
|
||||||
let (anchor, head) = match (
|
|
||||||
self.transcript_selection.anchor,
|
|
||||||
self.transcript_selection.head,
|
|
||||||
) {
|
|
||||||
(Some(a), Some(h)) if a != h => (a, h),
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
let size = tui.terminal.last_known_screen_size;
|
let size = tui.terminal.last_known_screen_size;
|
||||||
let width = size.width;
|
let width = size.width;
|
||||||
let height = size.height;
|
let height = size.height;
|
||||||
@@ -1391,132 +1363,13 @@ impl App {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let transcript_area = Rect {
|
let (lines, _) = Self::build_transcript_lines(&self.transcript_cells, width);
|
||||||
x: 0,
|
let Some(text) =
|
||||||
y: 0,
|
crate::transcript_selection::selection_text(&lines, self.transcript_selection, width)
|
||||||
width,
|
else {
|
||||||
height: transcript_height,
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let cells = self.transcript_cells.clone();
|
|
||||||
let (lines, _) = Self::build_transcript_lines(&cells, transcript_area.width);
|
|
||||||
if lines.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let wrapped = crate::wrapping::word_wrap_lines_borrowed(
|
|
||||||
&lines,
|
|
||||||
transcript_area.width.max(1) as usize,
|
|
||||||
);
|
|
||||||
let total_lines = wrapped.len();
|
|
||||||
if total_lines == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let max_visible = transcript_area.height as usize;
|
|
||||||
let visible_start = self
|
|
||||||
.transcript_view_top
|
|
||||||
.min(total_lines.saturating_sub(max_visible));
|
|
||||||
let visible_end = std::cmp::min(visible_start + max_visible, total_lines);
|
|
||||||
|
|
||||||
let mut buf = Buffer::empty(transcript_area);
|
|
||||||
Clear.render_ref(transcript_area, &mut buf);
|
|
||||||
|
|
||||||
for (row_index, line_index) in (visible_start..visible_end).enumerate() {
|
|
||||||
let row_area = Rect {
|
|
||||||
x: transcript_area.x,
|
|
||||||
y: transcript_area.y + row_index as u16,
|
|
||||||
width: transcript_area.width,
|
|
||||||
height: 1,
|
|
||||||
};
|
|
||||||
wrapped[line_index].render_ref(row_area, &mut buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
let base_x = transcript_area.x.saturating_add(2);
|
|
||||||
let max_x = transcript_area.right().saturating_sub(1);
|
|
||||||
|
|
||||||
let mut start = anchor;
|
|
||||||
let mut end = head;
|
|
||||||
if (end.line_index < start.line_index)
|
|
||||||
|| (end.line_index == start.line_index && end.column < start.column)
|
|
||||||
{
|
|
||||||
std::mem::swap(&mut start, &mut end);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut lines_out: Vec<String> = Vec::new();
|
|
||||||
|
|
||||||
for (row_index, line_index) in (visible_start..visible_end).enumerate() {
|
|
||||||
if line_index < start.line_index || line_index > end.line_index {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let y = transcript_area.y + row_index as u16;
|
|
||||||
|
|
||||||
let line_start_col = if line_index == start.line_index {
|
|
||||||
start.column
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
let line_end_col = if line_index == end.line_index {
|
|
||||||
end.column
|
|
||||||
} else {
|
|
||||||
max_x.saturating_sub(base_x)
|
|
||||||
};
|
|
||||||
|
|
||||||
let row_sel_start = base_x.saturating_add(line_start_col);
|
|
||||||
let row_sel_end = base_x.saturating_add(line_end_col).min(max_x);
|
|
||||||
|
|
||||||
if row_sel_start > row_sel_end {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut first_text_x = None;
|
|
||||||
let mut last_text_x = None;
|
|
||||||
for x in base_x..=max_x {
|
|
||||||
let cell = &buf[(x, y)];
|
|
||||||
if cell.symbol() != " " {
|
|
||||||
if first_text_x.is_none() {
|
|
||||||
first_text_x = Some(x);
|
|
||||||
}
|
|
||||||
last_text_x = Some(x);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let (text_start, text_end) = match (first_text_x, last_text_x) {
|
|
||||||
// Treat indentation spaces as part of the copyable region by
|
|
||||||
// starting from the first content column to the right of the
|
|
||||||
// transcript gutter, but still clamp to the last non-space
|
|
||||||
// glyph so trailing padding is not included.
|
|
||||||
(Some(_), Some(e)) => (base_x, e),
|
|
||||||
_ => {
|
|
||||||
lines_out.push(String::new());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let from_x = row_sel_start.max(text_start);
|
|
||||||
let to_x = row_sel_end.min(text_end);
|
|
||||||
if from_x > to_x {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut line_text = String::new();
|
|
||||||
for x in from_x..=to_x {
|
|
||||||
let cell = &buf[(x, y)];
|
|
||||||
let symbol = cell.symbol();
|
|
||||||
if !symbol.is_empty() {
|
|
||||||
line_text.push_str(symbol);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines_out.push(line_text);
|
|
||||||
}
|
|
||||||
|
|
||||||
if lines_out.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let text = lines_out.join("\n");
|
|
||||||
if let Err(err) = clipboard_copy::copy_text(text) {
|
if let Err(err) = clipboard_copy::copy_text(text) {
|
||||||
tracing::error!(error = %err, "failed to copy selection to clipboard");
|
tracing::error!(error = %err, "failed to copy selection to clipboard");
|
||||||
}
|
}
|
||||||
@@ -2438,6 +2291,36 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn transcript_selection_copy_includes_offscreen_lines() {
|
||||||
|
let mut app = make_test_app().await;
|
||||||
|
app.transcript_cells = vec![Arc::new(AgentMessageCell::new(
|
||||||
|
vec![
|
||||||
|
Line::from("one"),
|
||||||
|
Line::from("two"),
|
||||||
|
Line::from("three"),
|
||||||
|
Line::from("four"),
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
))];
|
||||||
|
|
||||||
|
app.transcript_view_top = 2;
|
||||||
|
app.transcript_selection.anchor = Some(TranscriptSelectionPoint {
|
||||||
|
line_index: 0,
|
||||||
|
column: 0,
|
||||||
|
});
|
||||||
|
app.transcript_selection.head = Some(TranscriptSelectionPoint {
|
||||||
|
line_index: 3,
|
||||||
|
column: u16::MAX,
|
||||||
|
});
|
||||||
|
|
||||||
|
let (lines, _) = App::build_transcript_lines(&app.transcript_cells, 40);
|
||||||
|
let text =
|
||||||
|
crate::transcript_selection::selection_text(&lines, app.transcript_selection, 40)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(text, "one\ntwo\nthree\nfour");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn model_migration_prompt_respects_hide_flag_and_self_target() {
|
async fn model_migration_prompt_respects_hide_flag_and_self_target() {
|
||||||
let mut seen = BTreeMap::new();
|
let mut seen = BTreeMap::new();
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ mod style;
|
|||||||
mod terminal_palette;
|
mod terminal_palette;
|
||||||
mod text_formatting;
|
mod text_formatting;
|
||||||
mod tooltips;
|
mod tooltips;
|
||||||
|
mod transcript_selection;
|
||||||
mod tui;
|
mod tui;
|
||||||
mod ui_consts;
|
mod ui_consts;
|
||||||
pub mod update_action;
|
pub mod update_action;
|
||||||
|
|||||||
@@ -0,0 +1,463 @@
|
|||||||
|
//! Transcript selection helpers.
|
||||||
|
//!
|
||||||
|
//! This module defines a content-relative selection model for the inline chat
|
||||||
|
//! transcript and utilities for extracting the selected region as plain text.
|
||||||
|
//! Selection endpoints are expressed in terms of flattened, wrapped transcript
|
||||||
|
//! line indices and columns so they remain stable across scrolling and
|
||||||
|
//! reflowing when the terminal is resized.
|
||||||
|
//!
|
||||||
|
//! Copy uses offscreen rendering into a 1-row `ratatui::Buffer` per visual line
|
||||||
|
//! to match on-screen glyph layout (including indentation/prefixes) while
|
||||||
|
//! skipping the transcript gutter.
|
||||||
|
|
||||||
|
use itertools::Itertools as _;
|
||||||
|
use ratatui::prelude::*;
|
||||||
|
use ratatui::widgets::WidgetRef;
|
||||||
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
/// Number of columns reserved for the transcript gutter before the copyable
|
||||||
|
/// transcript text begins.
|
||||||
|
pub(crate) const TRANSCRIPT_GUTTER_COLS: u16 = 2;
|
||||||
|
|
||||||
|
/// Content-relative selection within the inline transcript viewport.
|
||||||
|
///
|
||||||
|
/// Selection endpoints are expressed in terms of flattened, wrapped transcript
|
||||||
|
/// line indices and columns, so the highlight tracks logical conversation
|
||||||
|
/// content even when the viewport scrolls or the terminal is resized.
|
||||||
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
|
pub(crate) struct TranscriptSelection {
|
||||||
|
/// The selection anchor (fixed start) in transcript coordinates.
|
||||||
|
pub(crate) anchor: Option<TranscriptSelectionPoint>,
|
||||||
|
/// The selection head (moving end) in transcript coordinates.
|
||||||
|
pub(crate) head: Option<TranscriptSelectionPoint>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TranscriptSelection {
|
||||||
|
/// Create an active selection with both endpoints set.
|
||||||
|
pub(crate) fn new(
|
||||||
|
anchor: impl Into<TranscriptSelectionPoint>,
|
||||||
|
head: impl Into<TranscriptSelectionPoint>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
anchor: Some(anchor.into()),
|
||||||
|
head: Some(head.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single endpoint of a transcript selection.
|
||||||
|
///
|
||||||
|
/// `line_index` is an index into the flattened wrapped transcript lines, and
|
||||||
|
/// `column` is a zero-based column offset within that visual line, counted from
|
||||||
|
/// the first content column to the right of the transcript gutter.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub(crate) struct TranscriptSelectionPoint {
|
||||||
|
/// Index into the flattened wrapped transcript lines.
|
||||||
|
pub(crate) line_index: usize,
|
||||||
|
/// Zero-based column offset within the wrapped line, relative to the first
|
||||||
|
/// content column to the right of the transcript gutter.
|
||||||
|
pub(crate) column: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TranscriptSelectionPoint {
|
||||||
|
/// Create a selection endpoint at a given wrapped line index and column.
|
||||||
|
pub(crate) const fn new(line_index: usize, column: u16) -> Self {
|
||||||
|
Self { line_index, column }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<(usize, u16)> for TranscriptSelectionPoint {
|
||||||
|
/// Convert from `(line_index, column)`.
|
||||||
|
fn from((line_index, column): (usize, u16)) -> Self {
|
||||||
|
Self::new(line_index, column)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the full transcript selection as plain text.
|
||||||
|
///
|
||||||
|
/// This intentionally does *not* use viewport state. Instead it:
|
||||||
|
///
|
||||||
|
/// - Applies the same word-wrapping used for on-screen rendering, producing
|
||||||
|
/// flattened "visual" lines.
|
||||||
|
/// - Renders each selected visual line into a 1-row offscreen `Buffer` and
|
||||||
|
/// extracts the selected character cells from that buffer.
|
||||||
|
///
|
||||||
|
/// Using the rendered buffer (instead of slicing the source strings) keeps copy
|
||||||
|
/// semantics aligned with what the user sees on screen, including:
|
||||||
|
///
|
||||||
|
/// - Prefixes / indentation introduced during rendering (e.g. list markers).
|
||||||
|
/// - The transcript gutter: selection columns are defined relative to the
|
||||||
|
/// first content column to the right of the gutter (`base_x =
|
||||||
|
/// TRANSCRIPT_GUTTER_COLS`).
|
||||||
|
/// - Multi-cell glyph rendering decisions made by the backend.
|
||||||
|
///
|
||||||
|
/// Notes:
|
||||||
|
///
|
||||||
|
/// - Trailing padding to the right margin is not included; we clamp each line
|
||||||
|
/// to the last non-space glyph to avoid copying a full-width block of spaces.
|
||||||
|
/// - `TranscriptSelectionPoint::column` can be arbitrarily large (e.g.
|
||||||
|
/// `u16::MAX` when dragging to the right edge); we clamp to the rendered line
|
||||||
|
/// width so "copy to end of line" behaves naturally.
|
||||||
|
pub(crate) fn selection_text(
|
||||||
|
lines: &[Line<'static>],
|
||||||
|
selection: TranscriptSelection,
|
||||||
|
width: u16,
|
||||||
|
) -> Option<String> {
|
||||||
|
let (anchor, head) = selection.anchor.zip(selection.head)?;
|
||||||
|
if anchor == head {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (start, end) = ordered_endpoints(anchor, head);
|
||||||
|
let wrapped = wrap_transcript_lines(lines, width)?;
|
||||||
|
let ctx = RenderContext::new(width)?;
|
||||||
|
|
||||||
|
let total_lines = wrapped.len();
|
||||||
|
if start.line_index >= total_lines {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the selection ends beyond the last wrapped line, clamp it so selection
|
||||||
|
// behaves like "copy through the end" rather than returning no text.
|
||||||
|
let (end_line_index, end_is_clamped) = clamp_end_line(end.line_index, total_lines)?;
|
||||||
|
|
||||||
|
let mut buf = Buffer::empty(ctx.area);
|
||||||
|
let mut lines_out: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for (line_index, line) in wrapped
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.take(end_line_index + 1)
|
||||||
|
.skip(start.line_index)
|
||||||
|
{
|
||||||
|
buf.reset();
|
||||||
|
line.render_ref(ctx.area, &mut buf);
|
||||||
|
|
||||||
|
let Some((row_sel_start, row_sel_end)) =
|
||||||
|
ctx.selection_bounds_for_line(line_index, start, end, end_is_clamped)
|
||||||
|
else {
|
||||||
|
// Preserve row count/newlines within the selection even if this
|
||||||
|
// particular visual line produces no selected cells.
|
||||||
|
lines_out.push(String::new());
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(content_end_x) = ctx.content_end_x(&buf) else {
|
||||||
|
// Preserve explicit blank lines (e.g., spacer rows) in the selection.
|
||||||
|
lines_out.push(String::new());
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let from_x = row_sel_start.max(ctx.base_x);
|
||||||
|
let to_x = row_sel_end.min(content_end_x);
|
||||||
|
if from_x > to_x {
|
||||||
|
// Preserve row count/newlines even when selection falls beyond the
|
||||||
|
// rendered content for this visual line.
|
||||||
|
lines_out.push(String::new());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
lines_out.push(ctx.extract_text(&buf, from_x, to_x));
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(lines_out.join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return `(start, end)` with `start <= end` in transcript order.
|
||||||
|
pub(crate) fn ordered_endpoints(
|
||||||
|
anchor: TranscriptSelectionPoint,
|
||||||
|
head: TranscriptSelectionPoint,
|
||||||
|
) -> (TranscriptSelectionPoint, TranscriptSelectionPoint) {
|
||||||
|
if anchor <= head {
|
||||||
|
(anchor, head)
|
||||||
|
} else {
|
||||||
|
(head, anchor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap transcript lines using the same algorithm as on-screen rendering.
|
||||||
|
///
|
||||||
|
/// Returns `None` for invalid widths or when wrapping produces no visual lines.
|
||||||
|
fn wrap_transcript_lines<'a>(lines: &'a [Line<'static>], width: u16) -> Option<Vec<Line<'a>>> {
|
||||||
|
if width == 0 || lines.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let wrapped = crate::wrapping::word_wrap_lines_borrowed(lines, width.max(1) as usize);
|
||||||
|
(!wrapped.is_empty()).then_some(wrapped)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Context for rendering a single wrapped transcript line into a 1-row buffer and
|
||||||
|
/// extracting selected cells.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct RenderContext {
|
||||||
|
/// One-row region used for offscreen rendering.
|
||||||
|
area: Rect,
|
||||||
|
/// X coordinate where copyable transcript content begins (gutter skipped).
|
||||||
|
base_x: u16,
|
||||||
|
/// Maximum X coordinate inside the render area (inclusive).
|
||||||
|
max_x: u16,
|
||||||
|
/// Maximum content-relative column (0-based) within the render area.
|
||||||
|
max_content_col: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderContext {
|
||||||
|
/// Create a 1-row render context for a given terminal width.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the width is too small to hold any copyable content
|
||||||
|
/// (e.g. the gutter consumes the entire row).
|
||||||
|
fn new(width: u16) -> Option<Self> {
|
||||||
|
if width == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let area = Rect::new(0, 0, width, 1);
|
||||||
|
let base_x = area.x.saturating_add(TRANSCRIPT_GUTTER_COLS);
|
||||||
|
let max_x = area.right().saturating_sub(1);
|
||||||
|
if base_x > max_x {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
area,
|
||||||
|
base_x,
|
||||||
|
max_x,
|
||||||
|
max_content_col: max_x.saturating_sub(base_x),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the inclusive selection X range for this visual line.
|
||||||
|
///
|
||||||
|
/// `start`/`end` columns are content-relative (0 starts at the first column
|
||||||
|
/// to the right of the transcript gutter). For the terminal line containing
|
||||||
|
/// the selection endpoint, this clamps the selection to that endpoint; for
|
||||||
|
/// intermediate lines it selects the whole line.
|
||||||
|
///
|
||||||
|
/// If the selection end was clamped to the last available line (meaning the
|
||||||
|
/// logical selection extended beyond the rendered transcript), the final
|
||||||
|
/// line is treated as selecting through the end of that line.
|
||||||
|
fn selection_bounds_for_line(
|
||||||
|
&self,
|
||||||
|
line_index: usize,
|
||||||
|
start: TranscriptSelectionPoint,
|
||||||
|
end: TranscriptSelectionPoint,
|
||||||
|
end_is_clamped: bool,
|
||||||
|
) -> Option<(u16, u16)> {
|
||||||
|
let line_start_col = if line_index == start.line_index {
|
||||||
|
start.column
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let line_end_col = if !end_is_clamped && line_index == end.line_index {
|
||||||
|
end.column
|
||||||
|
} else {
|
||||||
|
self.max_content_col
|
||||||
|
};
|
||||||
|
|
||||||
|
let row_sel_start = self.base_x.saturating_add(line_start_col);
|
||||||
|
let row_sel_end = self.base_x.saturating_add(line_end_col).min(self.max_x);
|
||||||
|
|
||||||
|
(row_sel_start <= row_sel_end).then_some((row_sel_start, row_sel_end))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the last non-space glyph in the rendered content area.
|
||||||
|
///
|
||||||
|
/// This is used to avoid copying right-margin padding when the rendered row
|
||||||
|
/// is shorter than the terminal width.
|
||||||
|
fn content_end_x(&self, buf: &Buffer) -> Option<u16> {
|
||||||
|
(self.base_x..=self.max_x)
|
||||||
|
.rev()
|
||||||
|
.find(|&x| buf[(x, 0)].symbol() != " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract rendered cell contents from an inclusive `[from_x, to_x]` range.
|
||||||
|
///
|
||||||
|
/// Note: terminals represent wide glyphs (e.g. CJK characters) using multiple
|
||||||
|
/// cells, but only the first cell contains the glyph's symbol. The remaining
|
||||||
|
/// cells are "continuation" cells that should not be copied as separate
|
||||||
|
/// characters. Ratatui marks those continuation cells as a single space in
|
||||||
|
/// the buffer, so we must explicitly skip `width - 1` following cells after
|
||||||
|
/// reading each rendered symbol to avoid producing output like `"コ X"`.
|
||||||
|
fn extract_text(&self, buf: &Buffer, from_x: u16, to_x: u16) -> String {
|
||||||
|
(from_x..=to_x)
|
||||||
|
.batching(|xs| {
|
||||||
|
let x = xs.next()?;
|
||||||
|
let symbol = buf[(x, 0)].symbol();
|
||||||
|
for _ in 0..symbol.width().saturating_sub(1) {
|
||||||
|
xs.next();
|
||||||
|
}
|
||||||
|
(!symbol.is_empty()).then_some(symbol)
|
||||||
|
})
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamp `end_line_index` to the last available line and report if it was clamped.
|
||||||
|
///
|
||||||
|
/// Returns `None` when there are no wrapped lines.
|
||||||
|
fn clamp_end_line(end_line_index: usize, total_lines: usize) -> Option<(usize, bool)> {
|
||||||
|
if total_lines == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let clamped = end_line_index.min(total_lines.saturating_sub(1));
|
||||||
|
Some((clamped, clamped != end_line_index))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_text_returns_none_when_missing_endpoints() {
|
||||||
|
let lines = vec![Line::from(vec!["• ".into(), "Hello".into()])];
|
||||||
|
let selection = TranscriptSelection::default();
|
||||||
|
|
||||||
|
assert_eq!(selection_text(&lines, selection, 40), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_text_returns_none_when_endpoints_equal() {
|
||||||
|
let lines = vec![Line::from(vec!["• ".into(), "Hello".into()])];
|
||||||
|
let selection = TranscriptSelection::new((0, 2), (0, 2));
|
||||||
|
|
||||||
|
assert_eq!(selection_text(&lines, selection, 40), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_text_returns_none_for_empty_lines() {
|
||||||
|
let selection = TranscriptSelection::new((0, 0), (0, 1));
|
||||||
|
|
||||||
|
assert_eq!(selection_text(&[], selection, 40), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_text_returns_none_for_zero_width() {
|
||||||
|
let lines = vec![Line::from(vec!["• ".into(), "Hello".into()])];
|
||||||
|
let selection = TranscriptSelection::new((0, 0), (0, 1));
|
||||||
|
|
||||||
|
assert_eq!(selection_text(&lines, selection, 0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_text_returns_none_when_width_smaller_than_gutter() {
|
||||||
|
let lines = vec![Line::from(vec!["• ".into(), "Hello".into()])];
|
||||||
|
let selection = TranscriptSelection::new((0, 0), (0, 1));
|
||||||
|
|
||||||
|
assert_eq!(selection_text(&lines, selection, 2), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_text_skips_gutter_prefix() {
|
||||||
|
let lines = vec![Line::from(vec!["• ".into(), "Hello".into()])];
|
||||||
|
let selection = TranscriptSelection::new((0, 0), (0, 4));
|
||||||
|
|
||||||
|
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "Hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_text_selects_substring_single_line() {
|
||||||
|
let lines = vec![Line::from(vec!["• ".into(), "Hello world".into()])];
|
||||||
|
let selection = TranscriptSelection::new((0, 6), (0, 10));
|
||||||
|
|
||||||
|
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_text_preserves_interior_spaces() {
|
||||||
|
let lines = vec![Line::from(vec!["• ".into(), "a b".into()])];
|
||||||
|
let selection = TranscriptSelection::new((0, 0), (0, 3));
|
||||||
|
|
||||||
|
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "a b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_text_skips_hidden_wide_glyph_cells() {
|
||||||
|
let lines = vec![Line::from(vec!["• ".into(), "コX".into()])];
|
||||||
|
let selection = TranscriptSelection::new((0, 0), (0, 2));
|
||||||
|
|
||||||
|
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "コX");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_text_orders_reversed_endpoints() {
|
||||||
|
let lines = vec![Line::from(vec!["• ".into(), "Hello world".into()])];
|
||||||
|
let selection = TranscriptSelection::new((0, 10), (0, 6));
|
||||||
|
|
||||||
|
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_text_selects_multiple_lines_with_partial_endpoints() {
|
||||||
|
let lines = vec![
|
||||||
|
Line::from(vec!["• ".into(), "abcde".into()]),
|
||||||
|
Line::from(vec!["• ".into(), "fghij".into()]),
|
||||||
|
Line::from(vec!["• ".into(), "klmno".into()]),
|
||||||
|
];
|
||||||
|
let selection = TranscriptSelection::new((0, 2), (2, 2));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
selection_text(&lines, selection, 40).unwrap(),
|
||||||
|
"cde\nfghij\nklm"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_text_selects_to_end_of_line_for_large_column() {
|
||||||
|
let lines = vec![Line::from(vec!["• ".into(), "one".into()])];
|
||||||
|
let selection = TranscriptSelection::new((0, 0), (0, u16::MAX));
|
||||||
|
|
||||||
|
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "one");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_text_includes_indentation_spaces() {
|
||||||
|
let lines = vec![Line::from(vec!["• ".into(), " ind".into()])];
|
||||||
|
let selection = TranscriptSelection::new((0, 0), (0, 4));
|
||||||
|
|
||||||
|
assert_eq!(selection_text(&lines, selection, 40).unwrap(), " ind");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_text_preserves_empty_lines() {
|
||||||
|
let lines = vec![
|
||||||
|
Line::from(vec!["• ".into(), "one".into()]),
|
||||||
|
Line::from("• "),
|
||||||
|
Line::from(vec!["• ".into(), "two".into()]),
|
||||||
|
];
|
||||||
|
let selection = TranscriptSelection::new((0, 0), (2, 2));
|
||||||
|
|
||||||
|
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "one\n\ntwo");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_text_clamps_end_line_index() {
|
||||||
|
let lines = vec![
|
||||||
|
Line::from(vec!["• ".into(), "one".into()]),
|
||||||
|
Line::from(vec!["• ".into(), "two".into()]),
|
||||||
|
];
|
||||||
|
let selection = TranscriptSelection::new((0, 0), (100, u16::MAX));
|
||||||
|
|
||||||
|
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "one\ntwo");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_text_clamps_end_line_index_ignoring_end_column() {
|
||||||
|
let lines = vec![
|
||||||
|
Line::from(vec!["• ".into(), "one".into()]),
|
||||||
|
Line::from(vec!["• ".into(), "two".into()]),
|
||||||
|
];
|
||||||
|
let selection = TranscriptSelection::new((0, 0), (100, 0));
|
||||||
|
|
||||||
|
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "one\ntwo");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_text_returns_none_when_start_line_out_of_range() {
|
||||||
|
let lines = vec![Line::from(vec!["• ".into(), "one".into()])];
|
||||||
|
let selection = TranscriptSelection::new((100, 0), (101, 0));
|
||||||
|
|
||||||
|
assert_eq!(selection_text(&lines, selection, 40), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user