Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add utility function ansi::slice_ansi_str #206

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 0.16.0

### Enhancements

* Added `slice_str` util.

## 0.15.8

### Enhancements
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "console"
description = "A terminal and console abstraction for Rust"
version = "0.15.8"
version = "0.16.0"
keywords = ["cli", "terminal", "colors", "console", "ansi"]
authors = ["Armin Ronacher <armin.ronacher@active-4.com>"]
license = "MIT"
Expand Down
4 changes: 2 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ pub use crate::term::{
};
pub use crate::utils::{
colors_enabled, colors_enabled_stderr, measure_text_width, pad_str, pad_str_with,
set_colors_enabled, set_colors_enabled_stderr, style, truncate_str, Alignment, Attribute,
Color, Emoji, Style, StyledObject,
set_colors_enabled, set_colors_enabled_stderr, slice_str, style, truncate_str, Alignment,
Attribute, Color, Emoji, Style, StyledObject,
};

#[cfg(feature = "ansi-parsing")]
Expand Down
2 changes: 1 addition & 1 deletion src/unix_term.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ pub fn terminal_size(out: &Term) -> Option<(u16, u16)> {
#[allow(clippy::useless_conversion)]
libc::ioctl(out.as_raw_fd(), libc::TIOCGWINSZ.into(), &mut winsize);
if winsize.ws_row > 0 && winsize.ws_col > 0 {
Some((winsize.ws_row as u16, winsize.ws_col as u16))
Some((winsize.ws_row, winsize.ws_col))
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was just a random (new?) clippy lint : these are already u16 🤷

} else {
None
}
Expand Down
234 changes: 162 additions & 72 deletions src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,15 @@ use std::borrow::Cow;
use std::collections::BTreeSet;
use std::env;
use std::fmt;
use std::ops::Range;
use std::sync::atomic::{AtomicBool, Ordering};

use lazy_static::lazy_static;

use crate::term::{wants_emoji, Term};

#[cfg(feature = "ansi-parsing")]
use crate::ansi::{strip_ansi_codes, AnsiCodeIterator};

#[cfg(not(feature = "ansi-parsing"))]
fn strip_ansi_codes(s: &str) -> &str {
s
}
use crate::ansi::AnsiCodeIterator;

fn default_colors_enabled(out: &Term) -> bool {
(out.features().colors_supported()
Expand Down Expand Up @@ -71,7 +67,17 @@ pub fn set_colors_enabled_stderr(val: bool) {

/// Measure the width of a string in terminal characters.
pub fn measure_text_width(s: &str) -> usize {
str_width(&strip_ansi_codes(s))
#[cfg(feature = "ansi-parsing")]
{
AnsiCodeIterator::new(s)
.filter(|(_, is_ansi)| !is_ansi)
.map(|(sub, _)| str_width(sub))
.sum()
Comment on lines +72 to +75
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not related to the MR, but while implementing a less optimized draft of my fix I thought this could spare a few allocs. This is probably not something to care about so I wouldn't mind if you want to revert this for the sake of simplification.

}
#[cfg(not(feature = "ansi-parsing"))]
{
str_width(s)
}
}

/// A terminal color.
Expand Down Expand Up @@ -732,80 +738,119 @@ fn char_width(c: char) -> usize {
}
}

/// Truncates a string to a certain number of characters.
/// Slice a `&str` in terms of text width. This means that only the text
/// columns strictly between `start` and `stop` will be kept.
///
/// This ensures that escape codes are not screwed up in the process.
/// If the maximum length is hit the string will be truncated but
/// escapes code will still be honored. If truncation takes place
/// the tail string will be appended.
pub fn truncate_str<'a>(s: &'a str, width: usize, tail: &str) -> Cow<'a, str> {
/// If a multi-columns character overlaps with the end of the interval it will
/// not be included. In such a case, the result will be less than `end - start`
/// columns wide.
///
/// This ensures that escape codes are not screwed up in the process. And if
/// non-empty head and tail are specified, they are inserted between the ANSI
/// codes from truncated bounds and the slice.
pub fn slice_str<'a>(s: &'a str, head: &str, bounds: Range<usize>, tail: &str) -> Cow<'a, str> {
#[cfg(feature = "ansi-parsing")]
{
use std::cmp::Ordering;
let mut iter = AnsiCodeIterator::new(s);
let mut length = 0;
let mut rv = None;

while let Some(item) = iter.next() {
match item {
(s, false) => {
if rv.is_none() {
if str_width(s) + length > width - str_width(tail) {
let ts = iter.current_slice();

let mut s_byte = 0;
let mut s_width = 0;
let rest_width = width - str_width(tail) - length;
for c in s.chars() {
s_byte += c.len_utf8();
s_width += char_width(c);
match s_width.cmp(&rest_width) {
Ordering::Equal => break,
Ordering::Greater => {
s_byte -= c.len_utf8();
break;
}
Ordering::Less => continue,
}
}

let idx = ts.len() - s.len() + s_byte;
let mut buf = ts[..idx].to_string();
buf.push_str(tail);
rv = Some(buf);
}
length += str_width(s);
}
}
(s, true) => {
if let Some(ref mut rv) = rv {
rv.push_str(s);
}
let mut pos = 0;
let mut code_iter = AnsiCodeIterator::new(s).peekable();

// Search for the begining of the slice while collecting heading ANSI
// codes
let mut slice_start = 0;
let mut front_ansi = String::new();

while pos < bounds.start {
let (sub, is_ansi) = match code_iter.peek_mut() {
Some(x) => x,
None => break,
};

if *is_ansi {
front_ansi.push_str(sub);
slice_start += sub.len();
} else if let Some(c) = sub.chars().next() {
// Pop the head char of `sub` while keeping `sub` on top of
// the iterator
pos += char_width(c);
slice_start += c.len_utf8();
*sub = &sub[c.len_utf8()..];
continue;
}

code_iter.next();
}

// Search for the end of the slice
let mut slice_end = slice_start;

'search_slice_end: for (sub, is_ansi) in &mut code_iter {
if is_ansi {
slice_end += sub.len();
continue;
}

for c in sub.chars() {
let c_width = char_width(c);

if pos + c_width > bounds.end {
// We will only search for ANSI codes after breaking this
// loop, so we can safely drop the remaining of `sub`
break 'search_slice_end;
}

pos += c_width;
slice_end += c.len_utf8();
}
}

if let Some(buf) = rv {
Cow::Owned(buf)
} else {
Cow::Borrowed(s)
// Initialise the result, no allocation may have to be performed if
// both head and front are empty
let slice = &s[slice_start..slice_end];

let mut result = {
if front_ansi.is_empty() && head.is_empty() && tail.is_empty() {
Cow::Borrowed(slice)
} else {
Cow::Owned(front_ansi + head + slice + tail)
}
};

// Push back remaining ANSI codes to result
for (sub, is_ansi) in code_iter {
if is_ansi {
*result.to_mut() += sub;
}
}
}

result
}
#[cfg(not(feature = "ansi-parsing"))]
{
if s.len() <= width - tail.len() {
Cow::Borrowed(s)
let slice = s.get(bounds).unwrap_or("");

if head.is_empty() && tail.is_empty() {
Cow::Borrowed(slice)
} else {
Cow::Owned(format!(
"{}{}",
s.get(..width - tail.len()).unwrap_or_default(),
tail
))
Cow::Owned(format!("{}{}{}", head, slice, tail))
}
}
}

/// Truncates a string to a certain number of characters.
///
/// This ensures that escape codes are not screwed up in the process.
/// If the maximum length is hit the string will be truncated but
/// escapes code will still be honored. If truncation takes place
/// the tail string will be appended.
pub fn truncate_str<'a>(s: &'a str, width: usize, tail: &str) -> Cow<'a, str> {
if measure_text_width(s) > width {
let tail_width = measure_text_width(tail);
slice_str(s, "", 0..width.saturating_sub(tail_width), tail)
} else {
Cow::Borrowed(s)
}
}

/// Pads a string to fill a certain number of characters.
///
/// This will honor ansi codes correctly and allows you to align a string
Expand Down Expand Up @@ -868,15 +913,18 @@ fn test_text_width() {
.on_black()
.bold()
.force_styling(true)
.to_string();
.to_string()
+ "🐶bar";
assert_eq!(
measure_text_width(&s),
if cfg!(feature = "ansi-parsing") {
3
} else if cfg!(feature = "unicode-width") {
17
} else {
21
match (
cfg!(feature = "ansi-parsing"),
cfg!(feature = "unicode-width")
) {
(true, true) => 8,
(true, false) => 7,
(false, true) => 22,
(false, false) => 25,
}
);
}
Expand Down Expand Up @@ -911,8 +959,50 @@ fn test_truncate_str() {
);
}

#[test]
fn test_slice_ansi_str() {
// Note that 🐶 is two columns wide
let test_str = "Hello\x1b[31m🐶\x1b[1m🐶\x1b[0m world!";
assert_eq!(slice_str(test_str, "", 0..test_str.len(), ""), test_str);

if cfg!(feature = "unicode-width") && cfg!(feature = "ansi-parsing") {
assert_eq!(measure_text_width(test_str), 16);

assert_eq!(
slice_str(test_str, "", 5..5, ""),
"\u{1b}[31m\u{1b}[1m\u{1b}[0m"
);

assert_eq!(
slice_str(test_str, "", 0..5, ""),
"Hello\x1b[31m\x1b[1m\x1b[0m"
);

assert_eq!(
slice_str(test_str, "", 0..6, ""),
"Hello\x1b[31m\x1b[1m\x1b[0m"
);

assert_eq!(
slice_str(test_str, "", 0..7, ""),
"Hello\x1b[31m🐶\x1b[1m\x1b[0m"
);

assert_eq!(
slice_str(test_str, "", 4..9, ""),
"o\x1b[31m🐶\x1b[1m🐶\x1b[0m"
);

assert_eq!(
slice_str(test_str, "", 7..21, ""),
"\x1b[31m\x1b[1m🐶\x1b[0m world!"
);
}
}

#[test]
fn test_truncate_str_no_ansi() {
assert_eq!(&truncate_str("foo bar", 7, "!"), "foo bar");
assert_eq!(&truncate_str("foo bar", 5, ""), "foo b");
assert_eq!(&truncate_str("foo bar", 5, "!"), "foo !");
assert_eq!(&truncate_str("foo bar baz", 10, "..."), "foo bar...");
Expand Down
Loading