diff --git a/Cargo.toml b/Cargo.toml index 65fd38eab..945b5f5af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,3 +42,4 @@ lazy_static = "1.4" is-terminal = "0.4" encode_unicode = "1.0" csv = { version = "1.1", optional = true } +regex = "1" diff --git a/README.md b/README.md index 0db9e649e..4a254c06c 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,14 @@ Uppercase letters stand for **bright** counterparts of the above colors: * **B** : Bright Blue * ... and so on ... +## ANSI hyperlinks + +In most modern terminal emulators, it is possible to embed hyperlinks using ANSI escape codes. The following string field would display as a clickable link: + +```rust +"\u{1b}]8;;http://example.com\u{1b}\\example.com\u{1b}]8;;\u{1b}\\" +``` + ## Slicing Tables can be sliced into immutable borrowed subtables. diff --git a/src/utils.rs b/src/utils.rs index 1256ced7b..d6918bbed 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -3,6 +3,7 @@ use std::fmt; use std::io::{Error, ErrorKind, Write}; use std::str; +use regex::Regex; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use super::format::Alignment; @@ -82,45 +83,20 @@ pub fn print_align( } /// Return the display width of a unicode string. -/// This functions takes ANSI-escaped color codes into account. +/// This functions takes ANSI-escaped color codes and hyperlinks into account. pub fn display_width(text: &str) -> usize { - #[derive(PartialEq, Eq, Clone, Copy)] - enum State { - /// We are not inside any terminal escape. - Normal, - /// We have just seen a \u{1b} - EscapeChar, - /// We have just seen a [ - OpenBracket, - /// We just ended the escape by seeing an m - AfterEscape, - } - let width = UnicodeWidthStr::width(text); - let mut state = State::Normal; let mut hidden = 0; - for c in text.chars() { - state = match (state, c) { - (State::Normal, '\u{1b}') => State::EscapeChar, - (State::EscapeChar, '[') => State::OpenBracket, - (State::EscapeChar, _) => State::Normal, - (State::OpenBracket, 'm') => State::AfterEscape, - _ => state, - }; - - // We don't count escape characters as hidden as - // UnicodeWidthStr::width already considers them. - if matches!(state, State::OpenBracket | State::AfterEscape) { - // but if we see an escape char *inside* the ANSI escape, we should ignore it. - if UnicodeWidthChar::width(c).unwrap_or(0) > 0 { - hidden += 1; - } - } - - if state == State::AfterEscape { - state = State::Normal; - } + lazy_static! { + static ref COLOR_RE: Regex = Regex::new(r"\u{1b}(?P\[[^m]+?)m").unwrap(); + static ref HYPERLINK_RE: Regex = Regex::new(r"\u{1b}]8;;(?P[^\u{1b}]+?)\u{1b}\\(?P[^\u{1b}]+?)\u{1b}]8;;\u{1b}\\").unwrap(); + } + for caps in COLOR_RE.captures_iter(text) { + hidden += UnicodeWidthStr::width(&caps["colors"]) + } + for caps in HYPERLINK_RE.captures_iter(text) { + hidden += 10 + UnicodeWidthStr::width(&caps["url"]) } assert!( @@ -225,6 +201,17 @@ mod tests { assert_eq!(out.as_string(), "foo"); } + #[test] + fn ansi_escapes() { + let mut out = StringWriter::new(); + print_align(&mut out, Alignment::LEFT, "\u{1b}[31;40mred\u{1b}[0m", ' ', 10, false).unwrap(); + assert_eq!(display_width(out.as_string()), 10); + + let mut out = StringWriter::new(); + print_align(&mut out, Alignment::LEFT, "\u{1b}]8;;http://example.com\u{1b}\\example\u{1b}]8;;\u{1b}\\", ' ', 10, false).unwrap(); + assert_eq!(display_width(out.as_string()), 10); + } + #[test] fn utf8_error() { let mut out = StringWriter::new();