2025-09-05 07:10:32 -07:00
use ratatui ::text ::Line ;
use ratatui ::text ::Span ;
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 ::new ( ) ,
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
}
}
}
pub ( crate ) fn word_wrap_line < ' a , O > ( line : & ' a Line < ' a > , width_or_options : O ) -> Vec < Line < ' a > >
where
O : Into < RtOptions < ' a > > ,
{
// Flatten the line and record span byte ranges.
let mut flat = String ::new ( ) ;
let mut span_bounds = Vec ::new ( ) ;
let mut acc = 0 usize ;
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 ) ) ;
}
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 ( ) ;
// 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 {
return vec! [ rt_opts . initial_indent . clone ( ) ] ;
} ;
// Build first wrapped line with initial indent.
2025-09-26 16:35:56 -07:00
let mut first_line = rt_opts . initial_indent . clone ( ) . style ( line . style ) ;
2025-09-05 07:10:32 -07:00
{
2025-09-10 12:13:53 -07:00
let sliced = slice_line_spans ( line , & span_bounds , first_line_range ) ;
2025-09-05 07:10:32 -07:00
let mut spans = first_line . spans ;
2025-09-10 12:13:53 -07:00
spans . append (
& mut sliced
. spans
. into_iter ( )
. map ( | s | s . patch_style ( line . style ) )
. collect ( ) ,
) ;
2025-09-05 07:10:32 -07:00
first_line . spans = spans ;
out . push ( first_line ) ;
}
// Wrap the remainder using subsequent indent width and map back to original indices.
let base = first_line_range . end ;
let skip_leading_spaces = flat [ base .. ] . chars ( ) . take_while ( | c | * c = = ' ' ) . count ( ) ;
let base = base + skip_leading_spaces ;
let subsequent_width_available = opts
. width
. saturating_sub ( rt_opts . subsequent_indent . width ( ) )
. max ( 1 ) ;
let remaining_wrapped = wrap_ranges_trim ( & flat [ base .. ] , opts . width ( subsequent_width_available ) ) ;
for r in & remaining_wrapped {
if r . is_empty ( ) {
continue ;
}
2025-09-26 16:35:56 -07:00
let mut subsequent_line = rt_opts . subsequent_indent . clone ( ) . style ( line . style ) ;
2025-09-05 07:10:32 -07:00
let offset_range = ( r . start + base ) .. ( r . end + base ) ;
2025-09-10 12:13:53 -07:00
let sliced = slice_line_spans ( line , & span_bounds , & offset_range ) ;
2025-09-05 07:10:32 -07:00
let mut spans = subsequent_line . spans ;
2025-09-10 12:13:53 -07:00
spans . append (
& mut sliced
. spans
. into_iter ( )
. map ( | s | s . patch_style ( line . style ) )
. collect ( ) ,
) ;
2025-09-05 07:10:32 -07:00
subsequent_line . spans = spans ;
out . push ( subsequent_line ) ;
}
out
}
/// 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(dead_code) ]
pub ( crate ) fn word_wrap_lines < ' a , I , O > ( lines : I , width_or_options : O ) -> Vec < Line < 'static > >
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 < 'static > > = Vec ::new ( ) ;
for ( idx , line ) in lines . into_iter ( ) . enumerate ( ) {
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 , 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 ;
2025-09-22 20:30:16 +01:00
use std ::string ::ToString ;
2025-09-05 07:10:32 -07:00
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_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 " ) ] ;
2025-09-22 19:16:02 +02:00
let out = word_wrap_lines_borrowed ( lines . iter ( ) , opts ) ;
2025-09-05 07:10:32 -07:00
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! " ) ] ;
2025-09-22 19:16:02 +02:00
let out = word_wrap_lines_borrowed ( lines . iter ( ) , 10 ) ;
2025-09-05 07:10:32 -07:00
let rendered : Vec < String > = out . iter ( ) . map ( concat_line ) . collect ( ) ;
assert_eq! ( rendered , vec! [ " hello " , " world! " ] ) ;
}
#[ 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 ) ;
2025-09-22 20:30:16 +01:00
let joined : String = wrapped . iter ( ) . map ( ToString ::to_string ) . join ( " \n " ) ;
2025-09-05 07:10:32 -07:00
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 . " #
) ;
}
}