extern crate alloc;
use alloc::string::{ String, ToString };
use super::{ Segment, parse_segments };
use super::strip::strip;
#[ derive( Debug, Clone, PartialEq, Eq ) ]
pub struct TruncateOptions
{
pub max_width : usize,
pub suffix : Option< String >,
pub append_reset : bool,
}
impl TruncateOptions
{
pub fn new( max_width : usize ) -> Self
{
assert!( max_width != 0, "TruncateOptions: max_width must be greater than 0" );
Self
{
max_width,
suffix : None,
append_reset : false,
}
}
pub fn with_suffix( mut self, suffix : impl Into< String > ) -> Self
{
self.suffix = Some( suffix.into() );
self
}
pub fn with_reset( mut self, reset : bool ) -> Self
{
self.append_reset = reset;
self
}
}
impl Default for TruncateOptions
{
fn default() -> Self
{
Self
{
max_width : 80,
suffix : None,
append_reset : false,
}
}
}
pub fn truncate( text : &str, options : &TruncateOptions ) -> String
{
truncate_internal( text, options, &CharCounter )
}
#[ cfg( feature = "ansi_unicode" ) ]
pub fn truncate_unicode( text : &str, options : &TruncateOptions ) -> String
{
truncate_internal( text, options, &GraphemeCounter )
}
pub fn truncate_if_needed( text : &str, max_width : usize, options : &TruncateOptions ) -> String
{
truncate_if_needed_internal( text, max_width, options, &CharCounter )
}
#[ cfg( feature = "ansi_unicode" ) ]
pub fn truncate_if_needed_unicode( text : &str, max_width : usize, options : &TruncateOptions ) -> String
{
truncate_if_needed_internal( text, max_width, options, &GraphemeCounter )
}
fn truncate_if_needed_internal< C : VisibleCounter >(
text : &str,
max_width : usize,
options : &TruncateOptions,
counter : &C
) -> String
{
let visible_width = counter.count( &strip( text ) );
if visible_width > max_width
{
truncate_internal( text, options, counter )
}
else
{
text.to_string()
}
}
pub fn truncate_lines( text : &str, max_width : usize, options : &TruncateOptions ) -> ( String, bool )
{
truncate_lines_internal( text, max_width, options, &CharCounter )
}
#[ cfg( feature = "ansi_unicode" ) ]
pub fn truncate_lines_unicode( text : &str, max_width : usize, options : &TruncateOptions ) -> ( String, bool )
{
truncate_lines_internal( text, max_width, options, &GraphemeCounter )
}
fn truncate_lines_internal< C : VisibleCounter >(
text : &str,
max_width : usize,
options : &TruncateOptions,
counter : &C
) -> ( String, bool )
{
let mut any_truncated = false;
let lines : alloc::vec::Vec< String > = text
.lines()
.map( | line |
{
let visible_width = counter.count( &strip( line ) );
if visible_width > max_width
{
any_truncated = true;
truncate_internal( line, options, counter )
}
else
{
line.to_string()
}
} )
.collect();
( lines.join( "\n" ), any_truncated )
}
trait VisibleCounter
{
fn count( &self, text : &str ) -> usize;
fn take_first< 'a >( &self, text : &'a str, n : usize ) -> &'a str;
}
struct CharCounter;
impl VisibleCounter for CharCounter
{
fn count( &self, text : &str ) -> usize
{
text.chars().count()
}
fn take_first< 'a >( &self, text : &'a str, n : usize ) -> &'a str
{
let end = text
.char_indices()
.nth( n )
.map_or( text.len(), | ( idx, _ ) | idx );
&text[ ..end ]
}
}
#[ cfg( feature = "ansi_unicode" ) ]
struct GraphemeCounter;
#[ cfg( feature = "ansi_unicode" ) ]
impl VisibleCounter for GraphemeCounter
{
fn count( &self, text : &str ) -> usize
{
use unicode_segmentation::UnicodeSegmentation;
text.graphemes( true ).count()
}
fn take_first< 'a >( &self, text : &'a str, n : usize ) -> &'a str
{
use unicode_segmentation::UnicodeSegmentation;
let mut end = 0;
for ( idx, grapheme ) in text.grapheme_indices( true ).take( n )
{
end = idx + grapheme.len();
}
&text[ ..end ]
}
}
fn truncate_internal< C : VisibleCounter >(
text : &str,
options : &TruncateOptions,
counter : &C,
) -> String
{
let segments = parse_segments( text );
let suffix_len = options.suffix.as_ref().map_or( 0, | s | counter.count( s ) );
let ( effective_max, use_suffix ) = if suffix_len >= options.max_width
{
( options.max_width, false )
}
else
{
( options.max_width - suffix_len, true )
};
let mut result = String::new();
let mut visible_count = 0;
let mut truncated = false;
for segment in segments
{
match segment
{
Segment::Ansi( code ) =>
{
result.push_str( code );
}
Segment::Text( text_content ) =>
{
let text_len = counter.count( text_content );
if visible_count + text_len <= effective_max
{
result.push_str( text_content );
visible_count += text_len;
}
else if visible_count < effective_max
{
let remaining = effective_max - visible_count;
let truncated_text = counter.take_first( text_content, remaining );
result.push_str( truncated_text );
truncated = true;
break;
}
else
{
truncated = true;
break;
}
}
}
}
if truncated && use_suffix
{
if let Some( ref suffix ) = options.suffix
{
result.push_str( suffix );
}
}
if options.append_reset
{
result.push_str( "\x1b[0m" );
}
result
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn options_new()
{
let opts = TruncateOptions::new( 10 );
assert_eq!( opts.max_width, 10 );
assert!( opts.suffix.is_none() );
assert!( !opts.append_reset );
}
#[ test ]
#[ should_panic( expected = "max_width must be greater than 0" ) ]
fn options_panic_on_zero()
{
TruncateOptions::new( 0 );
}
#[ test ]
fn options_builder()
{
let opts = TruncateOptions::new( 10 )
.with_suffix( "..." )
.with_reset( true );
assert_eq!( opts.max_width, 10 );
assert_eq!( opts.suffix, Some( "...".to_string() ) );
assert!( opts.append_reset );
}
#[ test ]
fn options_default()
{
let opts = TruncateOptions::default();
assert_eq!( opts.max_width, 80 );
assert!( opts.suffix.is_none() );
assert!( !opts.append_reset );
}
#[ test ]
fn truncate_no_change_when_fits()
{
let opts = TruncateOptions::new( 10 );
assert_eq!( truncate( "hello", &opts ), "hello" );
}
#[ test ]
fn truncate_plain_text()
{
let opts = TruncateOptions::new( 5 );
assert_eq!( truncate( "hello world", &opts ), "hello" );
}
#[ test ]
fn truncate_with_suffix()
{
let opts = TruncateOptions::new( 8 ).with_suffix( "..." );
assert_eq!( truncate( "hello world", &opts ), "hello..." );
}
#[ test ]
fn truncate_with_ellipsis()
{
let opts = TruncateOptions::new( 6 ).with_suffix( "…" );
assert_eq!( truncate( "hello world", &opts ), "hello…" );
}
#[ test ]
fn truncate_preserves_ansi()
{
let opts = TruncateOptions::new( 3 );
assert_eq!( truncate( "\x1b[31mhello\x1b[0m", &opts ), "\x1b[31mhel" );
}
#[ test ]
fn truncate_with_reset()
{
let opts = TruncateOptions::new( 3 ).with_reset( true );
assert_eq!( truncate( "\x1b[31mhello\x1b[0m", &opts ), "\x1b[31mhel\x1b[0m" );
}
#[ test ]
fn truncate_ansi_only_fits()
{
let opts = TruncateOptions::new( 10 );
assert_eq!( truncate( "\x1b[31m\x1b[0m", &opts ), "\x1b[31m\x1b[0m" );
}
#[ test ]
fn truncate_multiple_ansi_segments()
{
let opts = TruncateOptions::new( 5 );
let input = "\x1b[31mre\x1b[32md green\x1b[0m";
assert_eq!( truncate( input, &opts ), "\x1b[31mre\x1b[32md g" );
}
#[ test ]
fn truncate_empty()
{
let opts = TruncateOptions::new( 5 );
assert_eq!( truncate( "", &opts ), "" );
}
#[ test ]
fn truncate_suffix_too_long()
{
let opts = TruncateOptions::new( 2 ).with_suffix( "..." );
assert_eq!( truncate( "hello", &opts ), "he" );
}
#[ test ]
fn truncate_unicode_char_based()
{
let opts = TruncateOptions::new( 2 );
assert_eq!( truncate( "日本語", &opts ), "日本" );
}
#[ cfg( feature = "ansi_unicode" ) ]
mod unicode_tests
{
use crate::ansi::truncate::{ truncate_unicode, TruncateOptions };
#[ test ]
fn truncate_grapheme_emoji()
{
let opts = TruncateOptions::new( 2 );
assert_eq!( truncate_unicode( "👋🏽ab", &opts ), "👋🏽a" );
}
#[ test ]
fn truncate_grapheme_with_ansi()
{
let opts = TruncateOptions::new( 2 ).with_reset( true );
assert_eq!(
truncate_unicode( "\x1b[33m日本語\x1b[0m", &opts ),
"\x1b[33m日本\x1b[0m"
);
}
#[ test ]
fn truncate_grapheme_combining()
{
let opts = TruncateOptions::new( 2 );
assert_eq!( truncate_unicode( "e\u{0301}ab", &opts ), "e\u{0301}a" );
}
}
}