Skip to content

Commit 1599bf9

Browse files
committed
Add new function ansi::slice_ansi_str
1 parent d0907e9 commit 1599bf9

File tree

3 files changed

+153
-61
lines changed

3 files changed

+153
-61
lines changed

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ ansi-parsing = []
1919

2020
[dependencies]
2121
libc = "0.2.99"
22-
once_cell = "1.8"
22+
once_cell = "1.8,<1.21" # version 1.21 requires Rust >= 1.70
2323
unicode-width = { version = "0.2", optional = true }
2424

2525
[target.'cfg(windows)'.dependencies]

src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,8 @@ pub use crate::term::{
8484
};
8585
pub use crate::utils::{
8686
colors_enabled, colors_enabled_stderr, measure_text_width, pad_str, pad_str_with,
87-
set_colors_enabled, set_colors_enabled_stderr, style, truncate_str, Alignment, Attribute,
88-
Color, Emoji, Style, StyledObject,
87+
set_colors_enabled, set_colors_enabled_stderr, slice_str, style, truncate_str, Alignment,
88+
Attribute, Color, Emoji, Style, StyledObject,
8989
};
9090

9191
#[cfg(feature = "ansi-parsing")]

src/utils.rs

Lines changed: 150 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use std::borrow::Cow;
22
use std::env;
33
use std::fmt;
44
use std::fmt::{Debug, Formatter};
5+
use std::ops::Range;
56
use std::sync::atomic::{AtomicBool, Ordering};
67

78
use once_cell::sync::Lazy;
@@ -800,80 +801,119 @@ pub(crate) fn char_width(_c: char) -> usize {
800801
1
801802
}
802803

803-
/// Truncates a string to a certain number of characters.
804+
/// Slice a `&str` in terms of text width. This means that only the text
805+
/// columns strictly between `start` and `stop` will be kept.
804806
///
805-
/// This ensures that escape codes are not screwed up in the process.
806-
/// If the maximum length is hit the string will be truncated but
807-
/// escapes code will still be honored. If truncation takes place
808-
/// the tail string will be appended.
809-
pub fn truncate_str<'a>(s: &'a str, width: usize, tail: &str) -> Cow<'a, str> {
807+
/// If a multi-columns character overlaps with the end of the interval it will
808+
/// not be included. In such a case, the result will be less than `end - start`
809+
/// columns wide.
810+
///
811+
/// This ensures that escape codes are not screwed up in the process. And if
812+
/// non-empty head and tail are specified, they are inserted between the ANSI
813+
/// codes from truncated bounds and the slice.
814+
pub fn slice_str<'a>(s: &'a str, head: &str, bounds: Range<usize>, tail: &str) -> Cow<'a, str> {
810815
#[cfg(feature = "ansi-parsing")]
811816
{
812-
use std::cmp::Ordering;
813-
let mut iter = AnsiCodeIterator::new(s);
814-
let mut length = 0;
815-
let mut rv = None;
816-
817-
while let Some(item) = iter.next() {
818-
match item {
819-
(s, false) => {
820-
if rv.is_none() {
821-
if str_width(s) + length > width - str_width(tail) {
822-
let ts = iter.current_slice();
823-
824-
let mut s_byte = 0;
825-
let mut s_width = 0;
826-
let rest_width = width - str_width(tail) - length;
827-
for c in s.chars() {
828-
s_byte += c.len_utf8();
829-
s_width += char_width(c);
830-
match s_width.cmp(&rest_width) {
831-
Ordering::Equal => break,
832-
Ordering::Greater => {
833-
s_byte -= c.len_utf8();
834-
break;
835-
}
836-
Ordering::Less => continue,
837-
}
838-
}
839-
840-
let idx = ts.len() - s.len() + s_byte;
841-
let mut buf = ts[..idx].to_string();
842-
buf.push_str(tail);
843-
rv = Some(buf);
844-
}
845-
length += str_width(s);
846-
}
847-
}
848-
(s, true) => {
849-
if let Some(ref mut rv) = rv {
850-
rv.push_str(s);
851-
}
817+
let mut pos = 0;
818+
let mut code_iter = AnsiCodeIterator::new(s).peekable();
819+
820+
// Search for the begining of the slice while collecting heading ANSI
821+
// codes
822+
let mut slice_start = 0;
823+
let mut front_ansi = String::new();
824+
825+
while pos < bounds.start {
826+
let (sub, is_ansi) = match code_iter.peek_mut() {
827+
None => break,
828+
Some(x) => x,
829+
};
830+
831+
if *is_ansi {
832+
front_ansi.push_str(sub);
833+
slice_start += sub.len();
834+
} else if let Some(c) = sub.chars().next() {
835+
// Pop the head char of `sub` while keeping `sub` on top of
836+
// the iterator
837+
pos += char_width(c);
838+
slice_start += c.len_utf8();
839+
*sub = &sub[c.len_utf8()..];
840+
continue;
841+
}
842+
843+
code_iter.next();
844+
}
845+
846+
// Search for the end of the slice
847+
let mut slice_end = slice_start;
848+
849+
'search_slice_end: for (sub, is_ansi) in &mut code_iter {
850+
if is_ansi {
851+
slice_end += sub.len();
852+
continue;
853+
}
854+
855+
for c in sub.chars() {
856+
let c_width = char_width(c);
857+
858+
if pos + c_width > bounds.end {
859+
// We will only search for ANSI codes after breaking this
860+
// loop, so we can safely drop the remaining of `sub`
861+
break 'search_slice_end;
852862
}
863+
864+
pos += c_width;
865+
slice_end += c.len_utf8();
853866
}
854867
}
855868

856-
if let Some(buf) = rv {
857-
Cow::Owned(buf)
858-
} else {
859-
Cow::Borrowed(s)
869+
// Initialise the result, no allocation may have to be performed if
870+
// both head and front are empty
871+
let slice = &s[slice_start..slice_end];
872+
873+
let mut result = {
874+
if front_ansi.is_empty() && head.is_empty() && tail.is_empty() {
875+
Cow::Borrowed(slice)
876+
} else {
877+
Cow::Owned(front_ansi + head + slice + tail)
878+
}
879+
};
880+
881+
// Push back remaining ANSI codes to result
882+
for (sub, is_ansi) in code_iter {
883+
if is_ansi {
884+
*result.to_mut() += sub;
885+
}
860886
}
861-
}
862887

888+
result
889+
}
863890
#[cfg(not(feature = "ansi-parsing"))]
864891
{
865-
if s.len() <= width - tail.len() {
866-
Cow::Borrowed(s)
892+
let slice = s.get(bounds).unwrap_or("");
893+
894+
if head.is_empty() && tail.is_empty() {
895+
Cow::Borrowed(slice)
867896
} else {
868-
Cow::Owned(format!(
869-
"{}{}",
870-
s.get(..width - tail.len()).unwrap_or_default(),
871-
tail
872-
))
897+
Cow::Owned(format!("{head}{slice}{tail}"))
873898
}
874899
}
875900
}
876901

902+
/// Truncates a string to a certain number of characters.
903+
///
904+
/// This ensures that escape codes are not screwed up in the process.
905+
/// If the maximum length is hit the string will be truncated but
906+
/// escapes code will still be honored. If truncation takes place
907+
/// the tail string will be appended.
908+
pub fn truncate_str<'a>(s: &'a str, width: usize, tail: &str) -> Cow<'a, str> {
909+
if measure_text_width(s) > width {
910+
let tail_width = measure_text_width(tail);
911+
slice_str(s, "", 0..width.saturating_sub(tail_width), tail)
912+
} else {
913+
Cow::Borrowed(s)
914+
}
915+
}
916+
877917
/// Pads a string to fill a certain number of characters.
878918
///
879919
/// This will honor ansi codes correctly and allows you to align a string
@@ -979,8 +1019,60 @@ fn test_truncate_str() {
9791019
);
9801020
}
9811021

1022+
#[test]
1023+
fn test_slice_ansi_str() {
1024+
// Note that 🐶 is two columns wide
1025+
let test_str = "Hello\x1b[31m🐶\x1b[1m🐶\x1b[0m world!";
1026+
assert_eq!(slice_str(test_str, "", 0..test_str.len(), ""), test_str);
1027+
1028+
assert_eq!(
1029+
slice_str(test_str, ">>>", 0..test_str.len(), "<<<"),
1030+
format!(">>>{test_str}<<<"),
1031+
);
1032+
1033+
if cfg!(feature = "unicode-width") && cfg!(feature = "ansi-parsing") {
1034+
assert_eq!(measure_text_width(test_str), 16);
1035+
1036+
assert_eq!(
1037+
slice_str(test_str, "", 5..5, ""),
1038+
"\u{1b}[31m\u{1b}[1m\u{1b}[0m"
1039+
);
1040+
1041+
assert_eq!(
1042+
slice_str(test_str, "", 0..5, ""),
1043+
"Hello\x1b[31m\x1b[1m\x1b[0m"
1044+
);
1045+
1046+
assert_eq!(
1047+
slice_str(test_str, "", 0..6, ""),
1048+
"Hello\x1b[31m\x1b[1m\x1b[0m"
1049+
);
1050+
1051+
assert_eq!(
1052+
slice_str(test_str, "", 0..7, ""),
1053+
"Hello\x1b[31m🐶\x1b[1m\x1b[0m"
1054+
);
1055+
1056+
assert_eq!(
1057+
slice_str(test_str, "", 4..9, ""),
1058+
"o\x1b[31m🐶\x1b[1m🐶\x1b[0m"
1059+
);
1060+
1061+
assert_eq!(
1062+
slice_str(test_str, "", 7..21, ""),
1063+
"\x1b[31m\x1b[1m🐶\x1b[0m world!"
1064+
);
1065+
1066+
assert_eq!(
1067+
slice_str(test_str, ">>>", 7..21, "<<<"),
1068+
"\x1b[31m>>>\x1b[1m🐶\x1b[0m world!<<<"
1069+
);
1070+
}
1071+
}
1072+
9821073
#[test]
9831074
fn test_truncate_str_no_ansi() {
1075+
assert_eq!(&truncate_str("foo bar", 7, "!"), "foo bar");
9841076
assert_eq!(&truncate_str("foo bar", 5, ""), "foo b");
9851077
assert_eq!(&truncate_str("foo bar", 5, "!"), "foo !");
9861078
assert_eq!(&truncate_str("foo bar baz", 10, "..."), "foo bar...");

0 commit comments

Comments
 (0)