From 7d0c5c7bd5da912b7a0bd826f4ebd41361391344 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Mon, 22 Dec 2025 15:24:52 -0800 Subject: [PATCH] fix(tui2): copy transcript selection outside viewport (#8449) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tui2/docs/tui_viewport_and_history.md | 23 +- codex-rs/tui2/src/app.rs | 213 ++------ codex-rs/tui2/src/lib.rs | 1 + codex-rs/tui2/src/transcript_selection.rs | 463 ++++++++++++++++++ 4 files changed, 525 insertions(+), 175 deletions(-) create mode 100644 codex-rs/tui2/src/transcript_selection.rs diff --git a/codex-rs/tui2/docs/tui_viewport_and_history.md b/codex-rs/tui2/docs/tui_viewport_and_history.md index cac56877d..d8cefedfe 100644 --- a/codex-rs/tui2/docs/tui_viewport_and_history.md +++ b/codex-rs/tui2/docs/tui_viewport_and_history.md @@ -183,10 +183,12 @@ Mouse interaction is a first‑class part of the new design: that we use for bullets/prefixes. - **Copy.** - - When the user triggers copy, the TUI re‑renders just the transcript region off‑screen using the - same wrapping as the visible view. - - It then walks the selected lines and columns in that off‑screen buffer to reconstruct the exact - text region the user highlighted (including internal spaces and empty lines). + - When the user triggers copy, the TUI reconstructs the same wrapped transcript lines used for + on-screen rendering. + - It then walks the content-relative selection range (even if the selection extends outside the + 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. 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>` – the logical history. - `transcript_scroll: TranscriptScroll` – whether the viewport is pinned to the bottom or anchored at a specific cell/line pair. -- `transcript_selection: TranscriptSelection` – a selection expressed in screen coordinates over - the flattened transcript region. +- `transcript_selection: TranscriptSelection` – a selection expressed in content-relative + coordinates over the flattened, wrapped transcript (line index + column). - `transcript_view_top` / `transcript_total_lines` – the current viewport’s top line index and 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 `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 -state is forwarded through `ChatWidget::set_transcript_ui_state`, +`App::copy_transcript_selection` plus `codex-rs/tui2/src/transcript_selection.rs` and +`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 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`). - While a selection is active, streaming stops “follow latest output” so the selection remains 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) @@ -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”). - **Mouse event bounds.** Ignore mouse events outside the transcript region so clicks in the 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 prose as a single logical line when copying, and copy markdown _source_ (including backticks and heading markers) even if we render it differently. diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 9175b7d24..a290433e0 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -17,6 +17,9 @@ use crate::pager_overlay::Overlay; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; 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::TuiEvent; use crate::tui::scrolling::MouseScrollState; @@ -359,28 +362,6 @@ pub(crate) struct App { 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, - head: Option, -} - -/// 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 { async fn shutdown_current_conversation(&mut self) { if let Some(conversation_id) = self.chat_widget.conversation_id() { @@ -889,7 +870,7 @@ impl App { width, 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); // Treat the transcript as the only interactive region for transcript selection. @@ -952,8 +933,7 @@ impl App { clamped_x, clamped_y, ) { - self.transcript_selection.anchor = Some(point); - self.transcript_selection.head = Some(point); + self.transcript_selection = TranscriptSelection::new(point, point); tui.frame_requester().schedule_frame(); } } @@ -1281,16 +1261,10 @@ impl App { 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 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 (start, end) = crate::transcript_selection::ordered_endpoints(anchor, head); let visible_start = self.transcript_view_top; let visible_end = self @@ -1365,15 +1339,13 @@ impl App { /// indices and columns, and this method reconstructs the same wrapped /// transcript used for on-screen rendering so the copied text closely /// 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) { - 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 width = size.width; let height = size.height; @@ -1391,132 +1363,13 @@ impl App { return; } - let transcript_area = Rect { - x: 0, - y: 0, - width, - height: transcript_height, + let (lines, _) = Self::build_transcript_lines(&self.transcript_cells, width); + let Some(text) = + crate::transcript_selection::selection_text(&lines, self.transcript_selection, width) + else { + 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 = 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) { 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] async fn model_migration_prompt_respects_hide_flag_and_self_target() { let mut seen = BTreeMap::new(); diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index e31194b5b..ce4f6d7b6 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -77,6 +77,7 @@ mod style; mod terminal_palette; mod text_formatting; mod tooltips; +mod transcript_selection; mod tui; mod ui_consts; pub mod update_action; diff --git a/codex-rs/tui2/src/transcript_selection.rs b/codex-rs/tui2/src/transcript_selection.rs new file mode 100644 index 000000000..8967180b3 --- /dev/null +++ b/codex-rs/tui2/src/transcript_selection.rs @@ -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, + /// The selection head (moving end) in transcript coordinates. + pub(crate) head: Option, +} + +impl TranscriptSelection { + /// Create an active selection with both endpoints set. + pub(crate) fn new( + anchor: impl Into, + head: impl Into, + ) -> 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 { + 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 = 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>> { + 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 { + 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 { + (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); + } +}