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); + } +}