commit cebd413470317a1ceae104722005ac52eaab095b Author: Terts Diepraam Date: Wed Nov 8 13:29:45 2023 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fffb2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a73937d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ansi-width" +description = "Calculate the width of a string when printed to the terminal" +license = "MIT" +readme = "README.md" +authors = ["uutils developers"] +version = "0.1.0" +edition = "2021" +rust-version = "1.70" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +unicode-width = "0.1.11" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a44323d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 uutils developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e61834a --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# ANSI width + +Measure the width of a string when printed to the terminal + +For ASCII, this is identical to the length of the string in bytes. However, +there are 2 special cases: + +- Many unicode characters (CJK, emoji, etc.) span multiple columns. +- ANSI escape codes should be ignored. + +The first case is handled by the `unicode-width` crate. This function extends +that crate by ignoring ANSI escape codes. + +## Limitations + +- We cannot know the width of a `TAB` character in the terminal emulator. +- Backspace is also treated as zero width. + +## A Primer on ANSI escape codes (and how this crate works) + +ANSI codes are created using special character sequences in a string. These +sequences start with the ESC character: `'\x1b'`, followed by some other +character to determine the type of the escape code. That second character +determines how long the sequence continues: + +- `ESC [`: until a character in the range `'\x40'..='\x7E'` is found. +- `ESC ]`: until an `ST` is found. + +An `ST` is a String Terminator and is given by the sequence `ESC \` (or in Rust +syntax `'\x1b\x5c'`). + +This is the subset of sequences that this library supports, since these are used +by most applications that need this functionality. If you have a use case for +other codes, please open an issue on the +[GitHub repository](https://github.com/uutils/ansi-width). + +`ansi-width` does not parse the actual ANSI codes to improve performance, it can +only skip the ANSI codes. + +## Examples + +```rust +use ansi_width::ansi_width; + +// ASCII string +assert_eq!(ansi_width("123456"), 6); + +// Accents +assert_eq!(ansi_width("café"), 4); + +// Emoji (2 crab emoji) +assert_eq!(ansi_width("🦀🦀"), 4); + +// CJK characters (“Nǐ hǎo” or “Hello” in Chinese) +assert_eq!(ansi_width("你好"), 4); + +// ANSI colors +assert_eq!(ansi_width("\u{1b}[31mRed\u{1b}[0m"), 3); + +// ANSI hyperlink +assert_eq!( + ansi_width("\x1b]8;;http://example.com\x1b\\This is a link\x1b]8;;\x1b\\"), + 14 +); +``` + +## Alternatives + +- [`String::len`]: Returns only the length in bytes and therefore only works for + ASCII characters. +- [`unicode-width`](https://crates.io/crates/unicode-width): Does not take ANSI + characters into account by design (see + [this issue](https://github.com/unicode-rs/unicode-width/issues/24)). This + might be what you want if you don't care about ANSI codes. `unicode-width` is + used internally by this crate as well. +- [`textwrap::core::display_width`](https://docs.rs/textwrap/latest/textwrap/core/fn.display_width.html): + Similar functionality to this crate, except that it does not support ANSI + hyperlinks. Another advantage of this crate is that it does not require + pulling in the rest of `textwrap`'s functionality (even though that + functionality is excellent if you need it). +- [`console::measure_text_width`](https://docs.rs/console/latest/console/fn.measure_text_width.html): + Similar to `textwrap` and very well-tested. However, it constructs a new + string internally without ANSI codes first and then measures the width of + that. The parsing is more robust than this crate though. + +## References + +The information above is based on: + +- +- diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..f3220fc --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,96 @@ +#![doc = include_str!("../README.md")] + +/// Character that starts escape codes +const ESC: char = '\x1b'; + +/// Calculate the width of a string. +/// +/// The the [crate documentation](crate) for more information. +pub fn ansi_width(s: &str) -> usize { + let mut width = 0; + let mut chars = s.chars(); + + // This lint is a false positive, because we use the iterator later, leading to + // ownership issues if we follow the lint. + #[allow(clippy::while_let_on_iterator)] + while let Some(c) = chars.next() { + // ESC starts escape sequences, so we need to take characters until the + // end of the escape sequence. + if c == ESC { + let Some(c) = chars.next() else { + break; + }; + match c { + // String terminator character: ends other sequences + // We probably won't encounter this but it's here for completeness. + // Or for if we get passed invalid codes. + '\\' => { + // ignore + } + // Control Sequence Introducer: continue until `\x40-\x7C` + '[' => while !matches!(chars.next(), Some('\x40'..='\x7C') | None) {}, + // Operating System Command: continue until ST + ']' => { + let mut last = c; + while let Some(new) = chars.next() { + if new == '\\' && last == ESC { + break; + } + last = new; + } + } + // We don't know what character it is, best bet is to fall back to unicode width + // The ESC is assumed to have 0 width in this case. + _ => { + width += unicode_width::UnicodeWidthChar::width(c).unwrap_or(0); + } + } + } else { + // If it's a normal character outside an escape sequence, use the + // unicode width. + width += unicode_width::UnicodeWidthChar::width(c).unwrap_or(0); + } + } + width +} + +#[cfg(test)] +mod tests { + use super::ansi_width; + + #[test] + fn ascii() { + assert_eq!(ansi_width(""), 0); + assert_eq!(ansi_width("hello"), 5); + assert_eq!(ansi_width("hello world"), 11); + assert_eq!(ansi_width("WOW!"), 4); + } + + #[test] + fn c0_characters() { + // Bell + assert_eq!(ansi_width("\x07"), 0); + + // Backspace + assert_eq!(ansi_width("\x08"), 0); + + // Tab + assert_eq!(ansi_width("\t"), 0); + } + + #[test] + fn some_escape_codes() { + // Simple + assert_eq!(ansi_width("\u{1b}[34mHello\u{1b}[0m"), 5); + // Red + assert_eq!(ansi_width("\u{1b}[31mRed\u{1b}[0m"), 3); + } + + #[test] + fn hyperlink() { + assert_eq!( + ansi_width("\x1b]8;;http://example.com\x1b\\This is a link\x1b]8;;\x1b\\"), + 14 + ) + } +}