core-agent-ide/codex-rs/tui2/src/transcript_copy.rs
Josh McKinney 0130a2fa40
feat(tui2): add multi-click transcript selection (#8471)
Support multi-click transcript selection using transcript/viewport
coordinates
(wrapped visual line index + content column), not terminal buffer
positions.

Gestures:
- double click: select word-ish token under cursor
- triple click: select entire wrapped line
- quad click: select paragraph (contiguous non-empty wrapped lines)
- quint+ click: select the entire history cell (all wrapped lines
belonging to a
  single `HistoryCell`, including blank lines inside the cell)

Selection expansion rebuilds the wrapped transcript view from
`HistoryCell::display_lines(width)` so boundaries match on-screen
wrapping during
scroll/resize/streaming reflow. Click grouping is resilient to minor
drag jitter
(some terminals emit tiny Drag events during clicks) and becomes more
tolerant as
the sequence progresses so quad/quint clicks are practical.

Tests cover expansion (word/line/paragraph/cell), sequence resets
(timing, motion,
line changes, real drags), drag jitter, and behavior on spacer lines
between
history cells (paragraph/cell selection prefers the cell above).
2025-12-23 21:05:06 +00:00

801 lines
28 KiB
Rust

//! Converting a transcript selection to clipboard text.
//!
//! Copy is driven by a content-relative selection (`TranscriptSelectionPoint`),
//! but the transcript is rendered with styling and wrapping for the TUI. This
//! module reconstructs clipboard text from the rendered transcript lines while
//! preserving user expectations:
//!
//! - Soft-wrapped prose is treated as a single logical line when copying.
//! - Code blocks preserve meaningful indentation.
//! - Markdown “source markers” are emitted when copying (backticks for inline
//! code, triple-backtick fences for code blocks) even if the on-screen
//! rendering is styled differently.
//!
//! ## Inputs and invariants
//!
//! Clipboard reconstruction is performed over the same *visual lines* that are
//! rendered in the transcript viewport:
//!
//! - `lines`: wrapped transcript `Line`s, including the gutter spans.
//! - `joiner_before`: a parallel vector describing which wrapped lines are
//! *soft wrap* continuations (and what to insert at the wrap boundary).
//! - `(line_index, column)` selection points in *content space* (columns exclude
//! the gutter).
//!
//! Callers must keep `lines` and `joiner_before` aligned. In practice, `App`
//! obtains both from `transcript_render`, which itself builds from each cell's
//! `HistoryCell::transcript_lines_with_joiners` implementation.
//!
//! ## Style-derived Markdown cues
//!
//! For fidelity, we copy Markdown source markers even though the viewport may
//! render content using styles instead of literal characters. Today, the copy
//! logic derives "inline code" and "code block" boundaries from the styling we
//! apply during rendering (currently cyan spans/lines).
//!
//! If transcript styling changes (for example, if code blocks stop using cyan),
//! update `is_code_block_line` and [`span_is_inline_code`] so clipboard output
//! continues to match user expectations.
//!
//! The caller can choose whether copy covers only the visible viewport range
//! (by passing `visible_start..visible_end`) or spans the entire transcript
//! (by passing `0..lines.len()`).
//!
//! UI affordances (keybinding detection and the on-screen "copy" pill) live in
//! `transcript_copy_ui`.
use ratatui::text::Line;
use ratatui::text::Span;
use crate::history_cell::HistoryCell;
use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS;
use crate::transcript_selection::TranscriptSelection;
use crate::transcript_selection::TranscriptSelectionPoint;
use std::sync::Arc;
/// Render the current transcript selection into clipboard text.
///
/// This is the `App`-level helper: it rebuilds wrapped transcript lines using
/// the same rules as the on-screen viewport and then applies
/// [`selection_to_copy_text`] across the full transcript range (including
/// off-screen lines).
pub(crate) fn selection_to_copy_text_for_cells(
cells: &[Arc<dyn HistoryCell>],
selection: TranscriptSelection,
width: u16,
) -> Option<String> {
let (anchor, head) = selection.anchor.zip(selection.head)?;
let transcript = crate::transcript_render::build_wrapped_transcript_lines(cells, width);
let total_lines = transcript.lines.len();
if total_lines == 0 {
return None;
}
selection_to_copy_text(
&transcript.lines,
&transcript.joiner_before,
anchor,
head,
0,
total_lines,
width,
)
}
/// Render the selected region into clipboard text.
///
/// `lines` must be the wrapped transcript lines as rendered by the TUI,
/// including the leading gutter spans. `start`/`end` columns are expressed in
/// content-space (excluding the gutter), and will be ordered internally if the
/// endpoints are reversed.
///
/// `joiner_before[i]` is the exact string to insert *before* `lines[i]` when
/// it is a continuation of a soft-wrapped prose line. This enables copy to
/// treat soft-wrapped prose as a single logical line.
///
/// Notes:
///
/// - For code/preformatted runs, copy is permitted to extend beyond the
/// viewport width when the user selects “to the right edge”, so we avoid
/// producing truncated logical lines in narrow terminals.
/// - Markdown markers are derived from render-time styles (see module docs).
/// - Column math is display-width-aware (wide glyphs count as multiple columns).
///
/// Returns `None` if the inputs imply an empty selection or if `width` is too
/// small to contain the gutter plus at least one content column.
pub(crate) fn selection_to_copy_text(
lines: &[Line<'static>],
joiner_before: &[Option<String>],
start: TranscriptSelectionPoint,
end: TranscriptSelectionPoint,
visible_start: usize,
visible_end: usize,
width: u16,
) -> Option<String> {
use ratatui::style::Color;
if width <= TRANSCRIPT_GUTTER_COLS {
return None;
}
// Selection points are expressed in content-relative coordinates and may be provided in either
// direction (dragging "backwards"). Normalize to a forward `(start, end)` pair so the rest of
// the logic can assume `start <= end`.
let (start, end) = order_points(start, end);
if start == end {
return None;
}
// Transcript `Line`s include a left gutter (bullet/prefix space). Selection columns exclude the
// gutter, so we translate selection columns to absolute columns by adding `base_x`.
let base_x = TRANSCRIPT_GUTTER_COLS;
let max_x = width.saturating_sub(1);
let mut out = String::new();
let mut prev_selected_line: Option<usize> = None;
// We emit Markdown fences around runs of code/preformatted visual lines so:
// - the clipboard captures source-style markers (` ``` `) even if the viewport is stylized
// - indentation is preserved and paste is stable in editors
let mut in_code_run = false;
// `wrote_any` lets us handle separators (newline or soft-wrap joiner) without special-casing
// "first output line" at every decision point.
let mut wrote_any = false;
for line_index in visible_start..visible_end {
// Only consider lines that intersect the selection's line range. (Selection endpoints are
// clamped elsewhere; if the indices don't exist, `lines.get(...)` returns `None`.)
if line_index < start.line_index || line_index > end.line_index {
continue;
}
let line = lines.get(line_index)?;
// Code blocks (and other preformatted content) are detected via styling and copied as
// "verbatim lines" (no inline Markdown re-encoding). This also enables special handling for
// narrow terminals: selecting "to the right edge" should copy the full logical line, not a
// viewport-truncated slice.
let is_code_block_line = line.style.fg == Some(Color::Cyan);
// Flatten the line to compute the rightmost non-space column. We use that to:
// - avoid copying trailing right-margin padding
// - clamp prose selection to the viewport width
let flat = line_to_flat(line);
let text_end = if is_code_block_line {
last_non_space_col(flat.as_str())
} else {
last_non_space_col(flat.as_str()).map(|c| c.min(max_x))
};
// Convert selection endpoints into a selection range for this specific visual line:
// - first line clamps the start column
// - last line clamps the end column
// - intermediate lines select the full line.
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).min(max_x);
// For code/preformatted lines, treat "selection ends at the viewport edge" as a special
// "copy to end of logical line" case. This prevents narrow terminals from producing
// truncated clipboard content when the user drags to the right edge.
let row_sel_end = if is_code_block_line && line_end_col >= max_x.saturating_sub(base_x) {
u16::MAX
} else {
base_x.saturating_add(line_end_col).min(max_x)
};
if row_sel_start > row_sel_end {
continue;
}
let selected_line = if let Some(text_end) = text_end {
let from_col = row_sel_start.max(base_x);
let to_col = row_sel_end.min(text_end);
if from_col > to_col {
Line::default().style(line.style)
} else {
slice_line_by_cols(line, from_col, to_col)
}
} else {
Line::default().style(line.style)
};
// Convert the selected `Line` into Markdown source:
// - For prose: wrap inline-code spans in backticks.
// - For code blocks: return the raw flat text so we preserve indentation/spacing.
let line_text = line_to_markdown(&selected_line, is_code_block_line);
// Track transitions into/out of code/preformatted runs and emit triple-backtick fences.
// We always separate a code run from prior prose with a newline.
if is_code_block_line && !in_code_run {
if wrote_any {
out.push('\n');
}
out.push_str("```");
out.push('\n');
in_code_run = true;
prev_selected_line = None;
wrote_any = true;
} else if !is_code_block_line && in_code_run {
out.push('\n');
out.push_str("```");
out.push('\n');
in_code_run = false;
prev_selected_line = None;
wrote_any = true;
}
// When copying inside a code run, every selected visual line becomes a literal line inside
// the fence (no soft-wrap joining). We preserve explicit blank lines by writing empty
// strings as a line.
if in_code_run {
if wrote_any && (!out.ends_with('\n') || prev_selected_line.is_some()) {
out.push('\n');
}
out.push_str(line_text.as_str());
prev_selected_line = Some(line_index);
wrote_any = true;
continue;
}
// Prose path:
// - If this line is a soft-wrap continuation of the previous selected line, insert the
// recorded joiner (often spaces) instead of a newline.
// - Otherwise, insert a newline to preserve hard breaks.
if wrote_any {
let joiner = joiner_before.get(line_index).cloned().unwrap_or(None);
if prev_selected_line == Some(line_index.saturating_sub(1))
&& let Some(joiner) = joiner
{
out.push_str(joiner.as_str());
} else {
out.push('\n');
}
}
out.push_str(line_text.as_str());
prev_selected_line = Some(line_index);
wrote_any = true;
}
if in_code_run {
out.push('\n');
out.push_str("```");
}
(!out.is_empty()).then_some(out)
}
/// Order two selection endpoints into `(start, end)` in transcript order.
///
/// Dragging can produce reversed endpoints; callers typically want a normalized range before
/// iterating visual lines.
fn order_points(
a: TranscriptSelectionPoint,
b: TranscriptSelectionPoint,
) -> (TranscriptSelectionPoint, TranscriptSelectionPoint) {
if (b.line_index < a.line_index) || (b.line_index == a.line_index && b.column < a.column) {
(b, a)
} else {
(a, b)
}
}
/// Flatten a styled `Line` into its plain text content.
///
/// This is used for cursor/column arithmetic and for emitting plain-text code lines.
fn line_to_flat(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
}
/// Return the last non-space *display column* in `flat` (inclusive).
///
/// This is display-width-aware, so wide glyphs (e.g. CJK) advance by more than one column.
///
/// Rationale: transcript rendering often pads out to the viewport width; copy should avoid
/// including that right-margin whitespace.
fn last_non_space_col(flat: &str) -> Option<u16> {
use unicode_width::UnicodeWidthChar;
let mut col: u16 = 0;
let mut last: Option<u16> = None;
for ch in flat.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
if ch != ' ' {
let end = col.saturating_add(w.saturating_sub(1));
last = Some(end);
}
col = col.saturating_add(w);
}
last
}
/// Map a display-column range to a UTF-8 byte range within `flat`.
///
/// The returned range is suitable for slicing `flat` and for slicing the original `Span` strings
/// (once translated into span-local offsets).
///
/// This walks Unicode scalar values and advances by display width so callers can slice based on the
/// same column semantics the selection model uses.
fn byte_range_for_cols(flat: &str, start_col: u16, end_col: u16) -> Option<std::ops::Range<usize>> {
use unicode_width::UnicodeWidthChar;
// We translate selection columns (display columns, not bytes) into a UTF-8 byte range. This is
// intentionally Unicode-width aware: wide glyphs cover multiple columns but occupy one `char`
// and several bytes.
//
// Strategy:
// - Walk `flat` by `char_indices()` while tracking the current display column.
// - The start byte is the first char whose rendered columns intersect `start_col`.
// - The end byte is the end of the last char whose rendered columns intersect `end_col`.
let mut col: u16 = 0;
let mut start_byte: Option<usize> = None;
let mut end_byte: Option<usize> = None;
for (idx, ch) in flat.char_indices() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
let end = col.saturating_add(w.saturating_sub(1));
// Start is inclusive: select the first glyph whose right edge reaches the start column.
if start_byte.is_none() && end >= start_col {
start_byte = Some(idx);
}
// End is inclusive in column space; keep extending end byte while we're still at/before
// `end_col`. This includes a wide glyph even if it starts before `end_col` but ends after.
if col <= end_col {
end_byte = Some(idx + ch.len_utf8());
}
col = col.saturating_add(w);
if col > end_col && start_byte.is_some() {
break;
}
}
match (start_byte, end_byte) {
(Some(s), Some(e)) if e >= s => Some(s..e),
_ => None,
}
}
/// Slice a styled `Line` by display columns, preserving per-span style.
///
/// This is the core "selection → styled substring" helper used before Markdown re-encoding. It
/// avoids mixing styles across spans by slicing each contributing span independently, then
/// reassembling them into a new `Line` with the original line-level style.
fn slice_line_by_cols(line: &Line<'static>, start_col: u16, end_col: u16) -> Line<'static> {
// `Line` spans store independent string slices with their own styles. To slice by columns while
// preserving styling, we:
// 1) Flatten the line and compute the desired UTF-8 byte range in the flattened string.
// 2) Compute each span's byte range within the flattened string.
// 3) Intersect the selection range with each span range and slice per-span, preserving styles.
let flat = line_to_flat(line);
let mut span_bounds: Vec<(std::ops::Range<usize>, ratatui::style::Style)> = Vec::new();
let mut acc = 0usize;
for s in &line.spans {
let start = acc;
let text = s.content.as_ref();
acc += text.len();
span_bounds.push((start..acc, s.style));
}
let Some(range) = byte_range_for_cols(flat.as_str(), start_col, end_col) else {
return Line::default().style(line.style);
};
// Translate the flattened byte range back into (span-local) slices.
let start_byte = range.start;
let end_byte = range.end;
let mut spans: Vec<ratatui::text::Span<'static>> = Vec::new();
for (i, (r, style)) in span_bounds.iter().enumerate() {
let s = r.start;
let e = r.end;
if e <= start_byte {
continue;
}
if s >= end_byte {
break;
}
let seg_start = start_byte.max(s);
let seg_end = end_byte.min(e);
if seg_end > seg_start {
let local_start = seg_start - s;
let local_end = seg_end - s;
let content = line.spans[i].content.as_ref();
spans.push(ratatui::text::Span {
style: *style,
content: content[local_start..local_end].to_string().into(),
});
}
if e >= end_byte {
break;
}
}
Line::from(spans).style(line.style)
}
/// Whether a span should be treated as "inline code" when reconstructing Markdown.
///
/// TUI2 renders inline code using a cyan foreground. Links also use cyan, but are underlined, so we
/// exclude underlined cyan spans to avoid wrapping links in backticks.
fn span_is_inline_code(span: &Span<'_>) -> bool {
use ratatui::style::Color;
span.style.fg == Some(Color::Cyan)
&& !span
.style
.add_modifier
.contains(ratatui::style::Modifier::UNDERLINED)
}
/// Convert a selected, styled `Line` back into Markdown-ish source text.
///
/// - For prose: wraps runs of inline-code spans in backticks to preserve the source marker.
/// - For code blocks: emits the raw flat text (no additional escaping), since the entire run will
/// be wrapped in triple-backtick fences by the caller.
fn line_to_markdown(line: &Line<'static>, is_code_block: bool) -> String {
if is_code_block {
return line_to_flat(line);
}
let mut out = String::new();
let mut in_code = false;
for span in &line.spans {
let is_code = span_is_inline_code(span);
if is_code && !in_code {
out.push('`');
in_code = true;
} else if !is_code && in_code {
out.push('`');
in_code = false;
}
out.push_str(span.content.as_ref());
}
if in_code {
out.push('`');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::style::Stylize;
#[test]
fn selection_to_copy_text_returns_none_for_zero_content_width() {
let lines = vec![Line::from("• Hello")];
let joiner_before = vec![None];
let start = TranscriptSelectionPoint {
line_index: 0,
column: 0,
};
let end = TranscriptSelectionPoint {
line_index: 0,
column: 1,
};
assert_eq!(
selection_to_copy_text(
&lines,
&joiner_before,
start,
end,
0,
lines.len(),
TRANSCRIPT_GUTTER_COLS,
),
None
);
}
#[test]
fn selection_to_copy_text_returns_none_for_empty_selection_point() {
let lines = vec![Line::from("• Hello")];
let joiner_before = vec![None];
let pt = TranscriptSelectionPoint {
line_index: 0,
column: 0,
};
assert_eq!(
selection_to_copy_text(&lines, &joiner_before, pt, pt, 0, lines.len(), 20),
None
);
}
#[test]
fn selection_to_copy_text_orders_reversed_endpoints() {
let lines = vec![Line::from("• Hello world")];
let joiner_before = vec![None];
let start = TranscriptSelectionPoint {
line_index: 0,
column: 10,
};
let end = TranscriptSelectionPoint {
line_index: 0,
column: 6,
};
let out = selection_to_copy_text(&lines, &joiner_before, start, end, 0, 1, 80)
.expect("expected text");
assert_eq!(out, "world");
}
#[test]
fn copy_selection_soft_wrap_joins_without_newline() {
let lines = vec![Line::from("• Hello"), Line::from(" world")];
let joiner_before = vec![None, Some(" ".to_string())];
let start = TranscriptSelectionPoint {
line_index: 0,
column: 0,
};
let end = TranscriptSelectionPoint {
line_index: 1,
column: 100,
};
let out = selection_to_copy_text(&lines, &joiner_before, start, end, 0, lines.len(), 20)
.expect("expected text");
assert_eq!(out, "Hello world");
}
#[test]
fn copy_selection_wraps_inline_code_in_backticks() {
let lines = vec![Line::from(vec![
"".into(),
"Use ".into(),
ratatui::text::Span::from("foo()").style(Style::new().fg(Color::Cyan)),
" now".into(),
])];
let joiner_before = vec![None];
let start = TranscriptSelectionPoint {
line_index: 0,
column: 0,
};
let end = TranscriptSelectionPoint {
line_index: 0,
column: 100,
};
let out = selection_to_copy_text(&lines, &joiner_before, start, end, 0, 1, 80)
.expect("expected text");
assert_eq!(out, "Use `foo()` now");
}
#[test]
fn selection_to_copy_text_for_cells_reconstructs_full_code_line_beyond_viewport() {
#[derive(Debug)]
struct FakeCell {
lines: Vec<Line<'static>>,
joiner_before: Vec<Option<String>>,
}
impl HistoryCell for FakeCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
self.lines.clone()
}
fn transcript_lines_with_joiners(
&self,
_width: u16,
) -> crate::history_cell::TranscriptLinesWithJoiners {
crate::history_cell::TranscriptLinesWithJoiners {
lines: self.lines.clone(),
joiner_before: self.joiner_before.clone(),
}
}
}
let style = Style::new().fg(Color::Cyan);
let cell = FakeCell {
lines: vec![Line::from("• 0123456789ABCDEFGHIJ").style(style)],
joiner_before: vec![None],
};
let cells: Vec<std::sync::Arc<dyn HistoryCell>> = vec![std::sync::Arc::new(cell)];
let width: u16 = 12;
let max_x = width.saturating_sub(1);
let viewport_edge_col = max_x.saturating_sub(TRANSCRIPT_GUTTER_COLS);
let selection = TranscriptSelection {
anchor: Some(TranscriptSelectionPoint::new(0, 0)),
head: Some(TranscriptSelectionPoint::new(0, viewport_edge_col)),
};
let out =
selection_to_copy_text_for_cells(&cells, selection, width).expect("expected text");
assert_eq!(out, "```\n 0123456789ABCDEFGHIJ\n```");
}
#[test]
fn order_points_orders_by_line_then_column() {
let a = TranscriptSelectionPoint::new(2, 5);
let b = TranscriptSelectionPoint::new(1, 10);
assert_eq!(order_points(a, b), (b, a));
let a = TranscriptSelectionPoint::new(1, 5);
let b = TranscriptSelectionPoint::new(1, 10);
assert_eq!(order_points(a, b), (a, b));
}
#[test]
fn line_to_flat_concatenates_spans() {
let line = Line::from(vec!["a".into(), "b".into(), "c".into()]);
assert_eq!(line_to_flat(&line), "abc");
}
#[test]
fn last_non_space_col_counts_display_width() {
// "コ" is width 2, so "コX" occupies columns 0..=2.
assert_eq!(last_non_space_col("コX"), Some(2));
assert_eq!(last_non_space_col("a "), Some(0));
assert_eq!(last_non_space_col(" "), None);
}
#[test]
fn byte_range_for_cols_maps_columns_to_utf8_bytes() {
let flat = "abcd";
let range = byte_range_for_cols(flat, 1, 2).expect("range");
assert_eq!(&flat[range], "bc");
let flat = "コX";
let range = byte_range_for_cols(flat, 0, 2).expect("range");
assert_eq!(&flat[range], "コX");
}
#[test]
fn slice_line_by_cols_preserves_span_styles() {
let line = Line::from(vec![
"".into(),
"Hello".red(),
" ".into(),
"world".green(),
]);
// Slice "llo wo" (crosses span boundaries).
let sliced = slice_line_by_cols(&line, 4, 9);
assert_eq!(line_to_flat(&sliced), "llo wo");
assert_eq!(sliced.spans.len(), 3);
assert_eq!(sliced.spans[0].content.as_ref(), "llo");
assert_eq!(sliced.spans[0].style.fg, Some(Color::Red));
assert_eq!(sliced.spans[1].content.as_ref(), " ");
assert_eq!(sliced.spans[2].content.as_ref(), "wo");
assert_eq!(sliced.spans[2].style.fg, Some(Color::Green));
}
#[test]
fn span_is_inline_code_excludes_underlined_cyan() {
let inline_code = Span::from("x").style(Style::new().fg(Color::Cyan));
assert!(span_is_inline_code(&inline_code));
let link_like = Span::from("x").style(Style::new().fg(Color::Cyan).underlined());
assert!(!span_is_inline_code(&link_like));
let other = Span::from("x").style(Style::new().fg(Color::Green));
assert!(!span_is_inline_code(&other));
}
#[test]
fn line_to_markdown_wraps_contiguous_inline_code_spans() {
let line = Line::from(vec![
"Use ".into(),
Span::from("foo").style(Style::new().fg(Color::Cyan)),
Span::from("()").style(Style::new().fg(Color::Cyan)),
" now".into(),
]);
assert_eq!(line_to_markdown(&line, false), "Use `foo()` now");
}
#[test]
fn copy_selection_preserves_wide_glyphs() {
let lines = vec![Line::from("• コX")];
let joiner_before = vec![None];
let start = TranscriptSelectionPoint {
line_index: 0,
column: 0,
};
let end = TranscriptSelectionPoint {
line_index: 0,
column: 2,
};
let out = selection_to_copy_text(&lines, &joiner_before, start, end, 0, 1, 80)
.expect("expected text");
assert_eq!(out, "コX");
}
#[test]
fn copy_selection_wraps_code_block_in_fences_and_preserves_indent() {
let style = Style::new().fg(Color::Cyan);
let lines = vec![
Line::from("• fn main() {}").style(style),
Line::from(" println!(\"hi\");").style(style),
];
let joiner_before = vec![None, None];
let start = TranscriptSelectionPoint {
line_index: 0,
column: 0,
};
let end = TranscriptSelectionPoint {
line_index: 1,
column: 100,
};
let out = selection_to_copy_text(&lines, &joiner_before, start, end, 0, lines.len(), 80)
.expect("expected text");
assert_eq!(out, "```\n fn main() {}\n println!(\"hi\");\n```");
}
#[test]
fn copy_selection_code_block_end_col_at_viewport_edge_copies_full_line() {
let style = Style::new().fg(Color::Cyan);
let lines = vec![Line::from("• 0123456789ABCDEFGHIJ").style(style)];
let joiner_before = vec![None];
let width: u16 = 12;
let max_x = width.saturating_sub(1);
let viewport_edge_col = max_x.saturating_sub(TRANSCRIPT_GUTTER_COLS);
let start = TranscriptSelectionPoint {
line_index: 0,
column: 0,
};
let end = TranscriptSelectionPoint {
line_index: 0,
column: viewport_edge_col,
};
let out = selection_to_copy_text(&lines, &joiner_before, start, end, 0, 1, width)
.expect("expected text");
assert_eq!(out, "```\n 0123456789ABCDEFGHIJ\n```");
}
#[test]
fn copy_selection_code_block_end_col_before_viewport_edge_copies_partial_line() {
let style = Style::new().fg(Color::Cyan);
let lines = vec![Line::from("• 0123456789ABCDEFGHIJ").style(style)];
let joiner_before = vec![None];
let width: u16 = 12;
let start = TranscriptSelectionPoint {
line_index: 0,
column: 0,
};
let end = TranscriptSelectionPoint {
line_index: 0,
column: 7,
};
let out = selection_to_copy_text(&lines, &joiner_before, start, end, 0, 1, width)
.expect("expected text");
assert_eq!(out, "```\n 0123\n```");
}
}