mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
0130a2fa40
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).
800 lines
26 KiB
Rust
800 lines
26 KiB
Rust
use ratatui::text::Line;
|
||
use ratatui::text::Span;
|
||
use std::borrow::Cow;
|
||
use std::ops::Range;
|
||
use textwrap::Options;
|
||
|
||
use crate::render::line_utils::push_owned_lines;
|
||
|
||
pub(crate) fn wrap_ranges<'a, O>(text: &str, width_or_options: O) -> Vec<Range<usize>>
|
||
where
|
||
O: Into<Options<'a>>,
|
||
{
|
||
let opts = width_or_options.into();
|
||
let mut lines: Vec<Range<usize>> = Vec::new();
|
||
for line in textwrap::wrap(text, opts).iter() {
|
||
match line {
|
||
std::borrow::Cow::Borrowed(slice) => {
|
||
let start = unsafe { slice.as_ptr().offset_from(text.as_ptr()) as usize };
|
||
let end = start + slice.len();
|
||
let trailing_spaces = text[end..].chars().take_while(|c| *c == ' ').count();
|
||
lines.push(start..end + trailing_spaces + 1);
|
||
}
|
||
std::borrow::Cow::Owned(_) => panic!("wrap_ranges: unexpected owned string"),
|
||
}
|
||
}
|
||
lines
|
||
}
|
||
|
||
/// Like `wrap_ranges` but returns ranges without trailing whitespace and
|
||
/// without the sentinel extra byte. Suitable for general wrapping where
|
||
/// trailing spaces should not be preserved.
|
||
pub(crate) fn wrap_ranges_trim<'a, O>(text: &str, width_or_options: O) -> Vec<Range<usize>>
|
||
where
|
||
O: Into<Options<'a>>,
|
||
{
|
||
let opts = width_or_options.into();
|
||
let mut lines: Vec<Range<usize>> = Vec::new();
|
||
for line in textwrap::wrap(text, opts).iter() {
|
||
match line {
|
||
std::borrow::Cow::Borrowed(slice) => {
|
||
let start = unsafe { slice.as_ptr().offset_from(text.as_ptr()) as usize };
|
||
let end = start + slice.len();
|
||
lines.push(start..end);
|
||
}
|
||
std::borrow::Cow::Owned(_) => panic!("wrap_ranges_trim: unexpected owned string"),
|
||
}
|
||
}
|
||
lines
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct RtOptions<'a> {
|
||
/// The width in columns at which the text will be wrapped.
|
||
pub width: usize,
|
||
/// Line ending used for breaking lines.
|
||
pub line_ending: textwrap::LineEnding,
|
||
/// Indentation used for the first line of output. See the
|
||
/// [`Options::initial_indent`] method.
|
||
pub initial_indent: Line<'a>,
|
||
/// Indentation used for subsequent lines of output. See the
|
||
/// [`Options::subsequent_indent`] method.
|
||
pub subsequent_indent: Line<'a>,
|
||
/// Allow long words to be broken if they cannot fit on a line.
|
||
/// When set to `false`, some lines may be longer than
|
||
/// `self.width`. See the [`Options::break_words`] method.
|
||
pub break_words: bool,
|
||
/// Wrapping algorithm to use, see the implementations of the
|
||
/// [`WrapAlgorithm`] trait for details.
|
||
pub wrap_algorithm: textwrap::WrapAlgorithm,
|
||
/// The line breaking algorithm to use, see the [`WordSeparator`]
|
||
/// trait for an overview and possible implementations.
|
||
pub word_separator: textwrap::WordSeparator,
|
||
/// The method for splitting words. This can be used to prohibit
|
||
/// splitting words on hyphens, or it can be used to implement
|
||
/// language-aware machine hyphenation.
|
||
pub word_splitter: textwrap::WordSplitter,
|
||
}
|
||
impl From<usize> for RtOptions<'_> {
|
||
fn from(width: usize) -> Self {
|
||
RtOptions::new(width)
|
||
}
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
impl<'a> RtOptions<'a> {
|
||
pub fn new(width: usize) -> Self {
|
||
RtOptions {
|
||
width,
|
||
line_ending: textwrap::LineEnding::LF,
|
||
initial_indent: Line::default(),
|
||
subsequent_indent: Line::default(),
|
||
break_words: true,
|
||
word_separator: textwrap::WordSeparator::new(),
|
||
wrap_algorithm: textwrap::WrapAlgorithm::FirstFit,
|
||
word_splitter: textwrap::WordSplitter::HyphenSplitter,
|
||
}
|
||
}
|
||
|
||
pub fn line_ending(self, line_ending: textwrap::LineEnding) -> Self {
|
||
RtOptions {
|
||
line_ending,
|
||
..self
|
||
}
|
||
}
|
||
|
||
pub fn width(self, width: usize) -> Self {
|
||
RtOptions { width, ..self }
|
||
}
|
||
|
||
pub fn initial_indent(self, initial_indent: Line<'a>) -> Self {
|
||
RtOptions {
|
||
initial_indent,
|
||
..self
|
||
}
|
||
}
|
||
|
||
pub fn subsequent_indent(self, subsequent_indent: Line<'a>) -> Self {
|
||
RtOptions {
|
||
subsequent_indent,
|
||
..self
|
||
}
|
||
}
|
||
|
||
pub fn break_words(self, break_words: bool) -> Self {
|
||
RtOptions {
|
||
break_words,
|
||
..self
|
||
}
|
||
}
|
||
|
||
pub fn word_separator(self, word_separator: textwrap::WordSeparator) -> RtOptions<'a> {
|
||
RtOptions {
|
||
word_separator,
|
||
..self
|
||
}
|
||
}
|
||
|
||
pub fn wrap_algorithm(self, wrap_algorithm: textwrap::WrapAlgorithm) -> RtOptions<'a> {
|
||
RtOptions {
|
||
wrap_algorithm,
|
||
..self
|
||
}
|
||
}
|
||
|
||
pub fn word_splitter(self, word_splitter: textwrap::WordSplitter) -> RtOptions<'a> {
|
||
RtOptions {
|
||
word_splitter,
|
||
..self
|
||
}
|
||
}
|
||
}
|
||
|
||
#[must_use]
|
||
pub(crate) fn word_wrap_line<'a, O>(line: &'a Line<'a>, width_or_options: O) -> Vec<Line<'a>>
|
||
where
|
||
O: Into<RtOptions<'a>>,
|
||
{
|
||
let (lines, _joiners) = word_wrap_line_with_joiners(line, width_or_options);
|
||
lines
|
||
}
|
||
|
||
fn flatten_line_and_bounds<'a>(
|
||
line: &'a Line<'a>,
|
||
) -> (String, Vec<(Range<usize>, ratatui::style::Style)>) {
|
||
// Flatten the line and record span byte ranges.
|
||
let mut flat = String::new();
|
||
let mut span_bounds = Vec::new();
|
||
let mut acc = 0usize;
|
||
for s in &line.spans {
|
||
let text = s.content.as_ref();
|
||
let start = acc;
|
||
flat.push_str(text);
|
||
acc += text.len();
|
||
span_bounds.push((start..acc, s.style));
|
||
}
|
||
(flat, span_bounds)
|
||
}
|
||
|
||
fn build_wrapped_line_from_range<'a>(
|
||
indent: Line<'a>,
|
||
original: &'a Line<'a>,
|
||
span_bounds: &[(Range<usize>, ratatui::style::Style)],
|
||
range: &Range<usize>,
|
||
) -> Line<'a> {
|
||
let mut out = indent.style(original.style);
|
||
let sliced = slice_line_spans(original, span_bounds, range);
|
||
let mut spans = out.spans;
|
||
spans.append(
|
||
&mut sliced
|
||
.spans
|
||
.into_iter()
|
||
.map(|s| s.patch_style(original.style))
|
||
.collect(),
|
||
);
|
||
out.spans = spans;
|
||
out
|
||
}
|
||
|
||
/// Wrap a single line and also return, for each output line, the string that should be inserted
|
||
/// when joining it to the previous output line as a *soft wrap*.
|
||
///
|
||
/// - The first output line always has `None`.
|
||
/// - Continuation lines have `Some(joiner)` where `joiner` is the exact substring (often spaces,
|
||
/// possibly empty) that was skipped at the wrap boundary.
|
||
pub(crate) fn word_wrap_line_with_joiners<'a, O>(
|
||
line: &'a Line<'a>,
|
||
width_or_options: O,
|
||
) -> (Vec<Line<'a>>, Vec<Option<String>>)
|
||
where
|
||
O: Into<RtOptions<'a>>,
|
||
{
|
||
let (flat, span_bounds) = flatten_line_and_bounds(line);
|
||
|
||
let rt_opts: RtOptions<'a> = width_or_options.into();
|
||
let opts = Options::new(rt_opts.width)
|
||
.line_ending(rt_opts.line_ending)
|
||
.break_words(rt_opts.break_words)
|
||
.wrap_algorithm(rt_opts.wrap_algorithm)
|
||
.word_separator(rt_opts.word_separator)
|
||
.word_splitter(rt_opts.word_splitter);
|
||
|
||
let mut out: Vec<Line<'a>> = Vec::new();
|
||
let mut joiners: Vec<Option<String>> = Vec::new();
|
||
|
||
// The first output line uses the initial indent and a reduced available width.
|
||
// Compute first line range with reduced width due to initial indent.
|
||
let initial_width_available = opts
|
||
.width
|
||
.saturating_sub(rt_opts.initial_indent.width())
|
||
.max(1);
|
||
let initial_wrapped = wrap_ranges_trim(&flat, opts.clone().width(initial_width_available));
|
||
let Some(first_line_range) = initial_wrapped.first() else {
|
||
out.push(rt_opts.initial_indent.clone());
|
||
joiners.push(None);
|
||
return (out, joiners);
|
||
};
|
||
|
||
let first_line = build_wrapped_line_from_range(
|
||
rt_opts.initial_indent.clone(),
|
||
line,
|
||
&span_bounds,
|
||
first_line_range,
|
||
);
|
||
out.push(first_line);
|
||
joiners.push(None);
|
||
|
||
// Wrap the remainder using subsequent indent width. We also compute the joiner strings that
|
||
// were skipped at each wrap boundary so callers can treat these as soft wraps during copy.
|
||
let mut base = first_line_range.end;
|
||
let skip_leading_spaces = flat[base..].chars().take_while(|c| *c == ' ').count();
|
||
let joiner_first = flat[base..base.saturating_add(skip_leading_spaces)].to_string();
|
||
base = base.saturating_add(skip_leading_spaces);
|
||
|
||
let subsequent_width_available = opts
|
||
.width
|
||
.saturating_sub(rt_opts.subsequent_indent.width())
|
||
.max(1);
|
||
let remaining = &flat[base..];
|
||
let remaining_wrapped = wrap_ranges_trim(remaining, opts.width(subsequent_width_available));
|
||
|
||
let mut prev_end = 0usize;
|
||
for (i, r) in remaining_wrapped.iter().enumerate() {
|
||
if r.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
// Each continuation line has `Some(joiner)`. The joiner may be empty (e.g. splitting a
|
||
// long word), but the distinction from `None` is important: `None` represents a hard break.
|
||
let joiner = if i == 0 {
|
||
joiner_first.clone()
|
||
} else {
|
||
remaining[prev_end..r.start].to_string()
|
||
};
|
||
prev_end = r.end;
|
||
|
||
let offset_range = (r.start + base)..(r.end + base);
|
||
let subsequent_line = build_wrapped_line_from_range(
|
||
rt_opts.subsequent_indent.clone(),
|
||
line,
|
||
&span_bounds,
|
||
&offset_range,
|
||
);
|
||
out.push(subsequent_line);
|
||
joiners.push(Some(joiner));
|
||
}
|
||
|
||
(out, joiners)
|
||
}
|
||
|
||
/// Like `word_wrap_lines`, but also returns a parallel vector of soft-wrap joiners.
|
||
///
|
||
/// The joiner is `None` when the line break is a hard break (between input lines), and `Some`
|
||
/// when the line break is a soft wrap continuation produced by the wrapping algorithm.
|
||
#[allow(private_bounds)] // IntoLineInput isn't public, but it doesn't really need to be.
|
||
pub(crate) fn word_wrap_lines_with_joiners<'a, I, O, L>(
|
||
lines: I,
|
||
width_or_options: O,
|
||
) -> (Vec<Line<'static>>, Vec<Option<String>>)
|
||
where
|
||
I: IntoIterator<Item = L>,
|
||
L: IntoLineInput<'a>,
|
||
O: Into<RtOptions<'a>>,
|
||
{
|
||
let base_opts: RtOptions<'a> = width_or_options.into();
|
||
let mut out: Vec<Line<'static>> = Vec::new();
|
||
let mut joiners: Vec<Option<String>> = Vec::new();
|
||
|
||
for (idx, line) in lines.into_iter().enumerate() {
|
||
let line_input = line.into_line_input();
|
||
let opts = if idx == 0 {
|
||
base_opts.clone()
|
||
} else {
|
||
let mut o = base_opts.clone();
|
||
let sub = o.subsequent_indent.clone();
|
||
o = o.initial_indent(sub);
|
||
o
|
||
};
|
||
|
||
let (wrapped, wrapped_joiners) = word_wrap_line_with_joiners(line_input.as_ref(), opts);
|
||
for (l, j) in wrapped.into_iter().zip(wrapped_joiners) {
|
||
out.push(crate::render::line_utils::line_to_static(&l));
|
||
joiners.push(j);
|
||
}
|
||
}
|
||
|
||
(out, joiners)
|
||
}
|
||
|
||
/// Utilities to allow wrapping either borrowed or owned lines.
|
||
#[derive(Debug)]
|
||
enum LineInput<'a> {
|
||
Borrowed(&'a Line<'a>),
|
||
Owned(Line<'a>),
|
||
}
|
||
|
||
impl<'a> LineInput<'a> {
|
||
fn as_ref(&self) -> &Line<'a> {
|
||
match self {
|
||
LineInput::Borrowed(line) => line,
|
||
LineInput::Owned(line) => line,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// This trait makes it easier to pass whatever we need into word_wrap_lines.
|
||
trait IntoLineInput<'a> {
|
||
fn into_line_input(self) -> LineInput<'a>;
|
||
}
|
||
|
||
impl<'a> IntoLineInput<'a> for &'a Line<'a> {
|
||
fn into_line_input(self) -> LineInput<'a> {
|
||
LineInput::Borrowed(self)
|
||
}
|
||
}
|
||
|
||
impl<'a> IntoLineInput<'a> for &'a mut Line<'a> {
|
||
fn into_line_input(self) -> LineInput<'a> {
|
||
LineInput::Borrowed(self)
|
||
}
|
||
}
|
||
|
||
impl<'a> IntoLineInput<'a> for Line<'a> {
|
||
fn into_line_input(self) -> LineInput<'a> {
|
||
LineInput::Owned(self)
|
||
}
|
||
}
|
||
|
||
impl<'a> IntoLineInput<'a> for String {
|
||
fn into_line_input(self) -> LineInput<'a> {
|
||
LineInput::Owned(Line::from(self))
|
||
}
|
||
}
|
||
|
||
impl<'a> IntoLineInput<'a> for &'a str {
|
||
fn into_line_input(self) -> LineInput<'a> {
|
||
LineInput::Owned(Line::from(self))
|
||
}
|
||
}
|
||
|
||
impl<'a> IntoLineInput<'a> for Cow<'a, str> {
|
||
fn into_line_input(self) -> LineInput<'a> {
|
||
LineInput::Owned(Line::from(self))
|
||
}
|
||
}
|
||
|
||
impl<'a> IntoLineInput<'a> for Span<'a> {
|
||
fn into_line_input(self) -> LineInput<'a> {
|
||
LineInput::Owned(Line::from(self))
|
||
}
|
||
}
|
||
|
||
impl<'a> IntoLineInput<'a> for Vec<Span<'a>> {
|
||
fn into_line_input(self) -> LineInput<'a> {
|
||
LineInput::Owned(Line::from(self))
|
||
}
|
||
}
|
||
|
||
/// Wrap a sequence of lines, applying the initial indent only to the very first
|
||
/// output line, and using the subsequent indent for all later wrapped pieces.
|
||
#[allow(private_bounds)] // IntoLineInput isn't public, but it doesn't really need to be.
|
||
pub(crate) fn word_wrap_lines<'a, I, O, L>(lines: I, width_or_options: O) -> Vec<Line<'static>>
|
||
where
|
||
I: IntoIterator<Item = L>,
|
||
L: IntoLineInput<'a>,
|
||
O: Into<RtOptions<'a>>,
|
||
{
|
||
let base_opts: RtOptions<'a> = width_or_options.into();
|
||
let mut out: Vec<Line<'static>> = Vec::new();
|
||
|
||
for (idx, line) in lines.into_iter().enumerate() {
|
||
let line_input = line.into_line_input();
|
||
let opts = if idx == 0 {
|
||
base_opts.clone()
|
||
} else {
|
||
let mut o = base_opts.clone();
|
||
let sub = o.subsequent_indent.clone();
|
||
o = o.initial_indent(sub);
|
||
o
|
||
};
|
||
let wrapped = word_wrap_line(line_input.as_ref(), opts);
|
||
push_owned_lines(&wrapped, &mut out);
|
||
}
|
||
|
||
out
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
pub(crate) fn word_wrap_lines_borrowed<'a, I, O>(lines: I, width_or_options: O) -> Vec<Line<'a>>
|
||
where
|
||
I: IntoIterator<Item = &'a Line<'a>>,
|
||
O: Into<RtOptions<'a>>,
|
||
{
|
||
let base_opts: RtOptions<'a> = width_or_options.into();
|
||
let mut out: Vec<Line<'a>> = Vec::new();
|
||
let mut first = true;
|
||
for line in lines.into_iter() {
|
||
let opts = if first {
|
||
base_opts.clone()
|
||
} else {
|
||
base_opts
|
||
.clone()
|
||
.initial_indent(base_opts.subsequent_indent.clone())
|
||
};
|
||
out.extend(word_wrap_line(line, opts));
|
||
first = false;
|
||
}
|
||
out
|
||
}
|
||
|
||
fn slice_line_spans<'a>(
|
||
original: &'a Line<'a>,
|
||
span_bounds: &[(Range<usize>, ratatui::style::Style)],
|
||
range: &Range<usize>,
|
||
) -> Line<'a> {
|
||
let start_byte = range.start;
|
||
let end_byte = range.end;
|
||
let mut acc: Vec<Span<'a>> = Vec::new();
|
||
for (i, (range, style)) in span_bounds.iter().enumerate() {
|
||
let s = range.start;
|
||
let e = range.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 = original.spans[i].content.as_ref();
|
||
let slice = &content[local_start..local_end];
|
||
acc.push(Span {
|
||
style: *style,
|
||
content: std::borrow::Cow::Borrowed(slice),
|
||
});
|
||
}
|
||
if e >= end_byte {
|
||
break;
|
||
}
|
||
}
|
||
Line {
|
||
style: original.style,
|
||
alignment: original.alignment,
|
||
spans: acc,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use itertools::Itertools as _;
|
||
use pretty_assertions::assert_eq;
|
||
use ratatui::style::Color;
|
||
use ratatui::style::Stylize;
|
||
use std::string::ToString;
|
||
|
||
fn concat_line(line: &Line) -> String {
|
||
line.spans
|
||
.iter()
|
||
.map(|s| s.content.as_ref())
|
||
.collect::<String>()
|
||
}
|
||
|
||
#[test]
|
||
fn trivial_unstyled_no_indents_wide_width() {
|
||
let line = Line::from("hello");
|
||
let out = word_wrap_line(&line, 10);
|
||
assert_eq!(out.len(), 1);
|
||
assert_eq!(concat_line(&out[0]), "hello");
|
||
}
|
||
|
||
#[test]
|
||
fn simple_unstyled_wrap_narrow_width() {
|
||
let line = Line::from("hello world");
|
||
let out = word_wrap_line(&line, 5);
|
||
assert_eq!(out.len(), 2);
|
||
assert_eq!(concat_line(&out[0]), "hello");
|
||
assert_eq!(concat_line(&out[1]), "world");
|
||
}
|
||
|
||
#[test]
|
||
fn simple_styled_wrap_preserves_styles() {
|
||
let line = Line::from(vec!["hello ".red(), "world".into()]);
|
||
let out = word_wrap_line(&line, 6);
|
||
assert_eq!(out.len(), 2);
|
||
// First line should carry the red style
|
||
assert_eq!(concat_line(&out[0]), "hello");
|
||
assert_eq!(out[0].spans.len(), 1);
|
||
assert_eq!(out[0].spans[0].style.fg, Some(Color::Red));
|
||
// Second line is unstyled
|
||
assert_eq!(concat_line(&out[1]), "world");
|
||
assert_eq!(out[1].spans.len(), 1);
|
||
assert_eq!(out[1].spans[0].style.fg, None);
|
||
}
|
||
|
||
#[test]
|
||
fn with_initial_and_subsequent_indents() {
|
||
let opts = RtOptions::new(8)
|
||
.initial_indent(Line::from("- "))
|
||
.subsequent_indent(Line::from(" "));
|
||
let line = Line::from("hello world foo");
|
||
let out = word_wrap_line(&line, opts);
|
||
// Expect three lines with proper prefixes
|
||
assert!(concat_line(&out[0]).starts_with("- "));
|
||
assert!(concat_line(&out[1]).starts_with(" "));
|
||
assert!(concat_line(&out[2]).starts_with(" "));
|
||
// And content roughly segmented
|
||
assert_eq!(concat_line(&out[0]), "- hello");
|
||
assert_eq!(concat_line(&out[1]), " world");
|
||
assert_eq!(concat_line(&out[2]), " foo");
|
||
}
|
||
|
||
#[test]
|
||
fn empty_initial_indent_subsequent_spaces() {
|
||
let opts = RtOptions::new(8)
|
||
.initial_indent(Line::from(""))
|
||
.subsequent_indent(Line::from(" "));
|
||
let line = Line::from("hello world foobar");
|
||
let out = word_wrap_line(&line, opts);
|
||
assert!(concat_line(&out[0]).starts_with("hello"));
|
||
for l in &out[1..] {
|
||
assert!(concat_line(l).starts_with(" "));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn empty_input_yields_single_empty_line() {
|
||
let line = Line::from("");
|
||
let out = word_wrap_line(&line, 10);
|
||
assert_eq!(out.len(), 1);
|
||
assert_eq!(concat_line(&out[0]), "");
|
||
}
|
||
|
||
#[test]
|
||
fn leading_spaces_preserved_on_first_line() {
|
||
let line = Line::from(" hello");
|
||
let out = word_wrap_line(&line, 8);
|
||
assert_eq!(out.len(), 1);
|
||
assert_eq!(concat_line(&out[0]), " hello");
|
||
}
|
||
|
||
#[test]
|
||
fn multiple_spaces_between_words_dont_start_next_line_with_spaces() {
|
||
let line = Line::from("hello world");
|
||
let out = word_wrap_line(&line, 8);
|
||
assert_eq!(out.len(), 2);
|
||
assert_eq!(concat_line(&out[0]), "hello");
|
||
assert_eq!(concat_line(&out[1]), "world");
|
||
}
|
||
|
||
#[test]
|
||
fn break_words_false_allows_overflow_for_long_word() {
|
||
let opts = RtOptions::new(5).break_words(false);
|
||
let line = Line::from("supercalifragilistic");
|
||
let out = word_wrap_line(&line, opts);
|
||
assert_eq!(out.len(), 1);
|
||
assert_eq!(concat_line(&out[0]), "supercalifragilistic");
|
||
}
|
||
|
||
#[test]
|
||
fn hyphen_splitter_breaks_at_hyphen() {
|
||
let line = Line::from("hello-world");
|
||
let out = word_wrap_line(&line, 7);
|
||
assert_eq!(out.len(), 2);
|
||
assert_eq!(concat_line(&out[0]), "hello-");
|
||
assert_eq!(concat_line(&out[1]), "world");
|
||
}
|
||
|
||
#[test]
|
||
fn indent_consumes_width_leaving_one_char_space() {
|
||
let opts = RtOptions::new(4)
|
||
.initial_indent(Line::from(">>>>"))
|
||
.subsequent_indent(Line::from("--"));
|
||
let line = Line::from("hello");
|
||
let out = word_wrap_line(&line, opts);
|
||
assert_eq!(out.len(), 3);
|
||
assert_eq!(concat_line(&out[0]), ">>>>h");
|
||
assert_eq!(concat_line(&out[1]), "--el");
|
||
assert_eq!(concat_line(&out[2]), "--lo");
|
||
}
|
||
|
||
#[test]
|
||
fn wide_unicode_wraps_by_display_width() {
|
||
let line = Line::from("😀😀😀");
|
||
let out = word_wrap_line(&line, 4);
|
||
assert_eq!(out.len(), 2);
|
||
assert_eq!(concat_line(&out[0]), "😀😀");
|
||
assert_eq!(concat_line(&out[1]), "😀");
|
||
}
|
||
|
||
#[test]
|
||
fn styled_split_within_span_preserves_style() {
|
||
use ratatui::style::Stylize;
|
||
let line = Line::from(vec!["abcd".red()]);
|
||
let out = word_wrap_line(&line, 2);
|
||
assert_eq!(out.len(), 2);
|
||
assert_eq!(out[0].spans.len(), 1);
|
||
assert_eq!(out[1].spans.len(), 1);
|
||
assert_eq!(out[0].spans[0].style.fg, Some(Color::Red));
|
||
assert_eq!(out[1].spans[0].style.fg, Some(Color::Red));
|
||
assert_eq!(concat_line(&out[0]), "ab");
|
||
assert_eq!(concat_line(&out[1]), "cd");
|
||
}
|
||
|
||
#[test]
|
||
fn wrap_line_with_joiners_matches_word_wrap_line_output() {
|
||
let opts = RtOptions::new(8)
|
||
.initial_indent(Line::from("- "))
|
||
.subsequent_indent(Line::from(" "));
|
||
let line = Line::from(vec!["hello ".red(), "world".into()]);
|
||
|
||
let out = word_wrap_line(&line, opts.clone());
|
||
let (with_joiners, joiners) = word_wrap_line_with_joiners(&line, opts);
|
||
|
||
assert_eq!(
|
||
with_joiners.iter().map(concat_line).collect_vec(),
|
||
out.iter().map(concat_line).collect_vec()
|
||
);
|
||
assert_eq!(joiners.len(), with_joiners.len());
|
||
assert_eq!(
|
||
joiners.first().cloned().unwrap_or(Some("x".to_string())),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn wrap_line_with_joiners_includes_skipped_spaces() {
|
||
let line = Line::from("hello world");
|
||
let (wrapped, joiners) = word_wrap_line_with_joiners(&line, 8);
|
||
|
||
assert_eq!(
|
||
wrapped.iter().map(concat_line).collect_vec(),
|
||
vec!["hello", "world"]
|
||
);
|
||
assert_eq!(joiners, vec![None, Some(" ".to_string())]);
|
||
}
|
||
|
||
#[test]
|
||
fn wrap_line_with_joiners_uses_empty_joiner_for_mid_word_split() {
|
||
let line = Line::from("abcd");
|
||
let (wrapped, joiners) = word_wrap_line_with_joiners(&line, 2);
|
||
|
||
assert_eq!(
|
||
wrapped.iter().map(concat_line).collect_vec(),
|
||
vec!["ab", "cd"]
|
||
);
|
||
assert_eq!(joiners, vec![None, Some("".to_string())]);
|
||
}
|
||
|
||
#[test]
|
||
fn wrap_lines_with_joiners_marks_hard_breaks_between_input_lines() {
|
||
let (wrapped, joiners) =
|
||
word_wrap_lines_with_joiners([Line::from("hello world"), Line::from("foo bar")], 5);
|
||
|
||
assert_eq!(
|
||
wrapped.iter().map(concat_line).collect_vec(),
|
||
vec!["hello", "world", "foo", "bar"]
|
||
);
|
||
assert_eq!(
|
||
joiners,
|
||
vec![None, Some(" ".to_string()), None, Some(" ".to_string())]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn wrap_lines_applies_initial_indent_only_once() {
|
||
let opts = RtOptions::new(8)
|
||
.initial_indent(Line::from("- "))
|
||
.subsequent_indent(Line::from(" "));
|
||
|
||
let lines = vec![Line::from("hello world"), Line::from("foo bar baz")];
|
||
let out = word_wrap_lines(lines, opts);
|
||
|
||
// Expect: first line prefixed with "- ", subsequent wrapped pieces with " "
|
||
// and for the second input line, there should be no "- " prefix on its first piece
|
||
let rendered: Vec<String> = out.iter().map(concat_line).collect();
|
||
assert!(rendered[0].starts_with("- "));
|
||
for r in rendered.iter().skip(1) {
|
||
assert!(r.starts_with(" "));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn wrap_lines_without_indents_is_concat_of_single_wraps() {
|
||
let lines = vec![Line::from("hello"), Line::from("world!")];
|
||
let out = word_wrap_lines(lines, 10);
|
||
let rendered: Vec<String> = out.iter().map(concat_line).collect();
|
||
assert_eq!(rendered, vec!["hello", "world!"]);
|
||
}
|
||
|
||
#[test]
|
||
fn wrap_lines_borrowed_applies_initial_indent_only_once() {
|
||
let opts = RtOptions::new(8)
|
||
.initial_indent(Line::from("- "))
|
||
.subsequent_indent(Line::from(" "));
|
||
|
||
let lines = [Line::from("hello world"), Line::from("foo bar baz")];
|
||
let out = word_wrap_lines_borrowed(lines.iter(), opts);
|
||
|
||
let rendered: Vec<String> = out.iter().map(concat_line).collect();
|
||
assert!(rendered.first().unwrap().starts_with("- "));
|
||
for r in rendered.iter().skip(1) {
|
||
assert!(r.starts_with(" "));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn wrap_lines_borrowed_without_indents_is_concat_of_single_wraps() {
|
||
let lines = [Line::from("hello"), Line::from("world!")];
|
||
let out = word_wrap_lines_borrowed(lines.iter(), 10);
|
||
let rendered: Vec<String> = out.iter().map(concat_line).collect();
|
||
assert_eq!(rendered, vec!["hello", "world!"]);
|
||
}
|
||
|
||
#[test]
|
||
fn wrap_lines_accepts_borrowed_iterators() {
|
||
let lines = [Line::from("hello world"), Line::from("foo bar baz")];
|
||
let out = word_wrap_lines(lines, 10);
|
||
let rendered: Vec<String> = out.iter().map(concat_line).collect();
|
||
assert_eq!(rendered, vec!["hello", "world", "foo bar", "baz"]);
|
||
}
|
||
|
||
#[test]
|
||
fn wrap_lines_accepts_str_slices() {
|
||
let lines = ["hello world", "goodnight moon"];
|
||
let out = word_wrap_lines(lines, 12);
|
||
let rendered: Vec<String> = out.iter().map(concat_line).collect();
|
||
assert_eq!(rendered, vec!["hello world", "goodnight", "moon"]);
|
||
}
|
||
|
||
#[test]
|
||
fn line_height_counts_double_width_emoji() {
|
||
let line = "😀😀😀".into(); // each emoji ~ width 2
|
||
assert_eq!(word_wrap_line(&line, 4).len(), 2);
|
||
assert_eq!(word_wrap_line(&line, 2).len(), 3);
|
||
assert_eq!(word_wrap_line(&line, 6).len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn word_wrap_does_not_split_words_simple_english() {
|
||
let sample = "Years passed, and Willowmere thrived in peace and friendship. Mira’s herb garden flourished with both ordinary and enchanted plants, and travelers spoke of the kindness of the woman who tended them.";
|
||
let line = Line::from(sample);
|
||
let lines = [line];
|
||
// Force small width to exercise wrapping at spaces.
|
||
let wrapped = word_wrap_lines_borrowed(&lines, 40);
|
||
let joined: String = wrapped.iter().map(ToString::to_string).join("\n");
|
||
assert_eq!(
|
||
joined,
|
||
r#"Years passed, and Willowmere thrived in
|
||
peace and friendship. Mira’s herb garden
|
||
flourished with both ordinary and
|
||
enchanted plants, and travelers spoke of
|
||
the kindness of the woman who tended
|
||
them."#
|
||
);
|
||
}
|
||
}
|