use crate::color::Style;
use alloc::string::String;
use alloc::vec::Vec;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
#[must_use]
pub fn width(s: &str) -> u16 {
#[allow(clippy::cast_possible_truncation)] let w = width_usize(s).min(usize::from(u16::MAX)) as u16;
w
}
#[must_use]
pub fn width_usize(s: &str) -> usize {
s.width()
}
#[must_use]
pub fn char_width(c: char) -> u16 {
#[allow(clippy::cast_possible_truncation)] let w = c.width().unwrap_or(1) as u16;
w
}
const CLUSTER_LOOKBACK: usize = 32;
#[must_use]
pub fn split_at_width(s: &str, max_cols: u16) -> (&str, &str) {
let (end, _cols) = split_at_width_indexed(s, max_cols);
s.split_at(end)
}
fn split_at_width_indexed(s: &str, max_cols: u16) -> (usize, usize) {
let max_cols = usize::from(max_cols);
let mut end = 0usize;
let mut cols = 0usize;
for (i, ch) in s.char_indices() {
let candidate_end = i + ch.len_utf8();
let window_start = s[..end]
.char_indices()
.rev()
.nth(CLUSTER_LOOKBACK - 1)
.map_or(0, |(idx, _)| idx);
let committed = width_usize(&s[window_start..end]);
let extended = width_usize(&s[window_start..candidate_end]);
let delta = extended.saturating_sub(committed);
if cols + delta > max_cols {
break;
}
cols += delta;
end = candidate_end;
}
(end, cols)
}
#[must_use]
pub fn truncate_measured(s: &str, max_cols: u16) -> (&str, u16) {
let (end, cols) = split_at_width_indexed(s, max_cols);
#[allow(clippy::cast_possible_truncation)] let cols = cols as u16;
(&s[..end], cols)
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Span {
pub content: String,
pub style: Style,
}
impl Span {
#[must_use]
pub fn raw(content: impl Into<String>) -> Self {
Self {
content: content.into(),
style: Style::default(),
}
}
#[must_use]
pub fn styled(content: impl Into<String>, style: Style) -> Self {
Self {
content: content.into(),
style,
}
}
#[must_use]
pub fn width(&self) -> usize {
width_usize(&self.content)
}
}
impl<S: Into<String>> From<S> for Span {
fn from(s: S) -> Self {
Self::raw(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Line {
pub spans: Vec<Span>,
}
impl Line {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn raw(content: impl Into<String>) -> Self {
Self {
spans: alloc::vec![Span::raw(content)],
}
}
#[must_use]
pub fn width(&self) -> usize {
self.spans.iter().map(Span::width).sum()
}
}
impl From<&str> for Line {
fn from(s: &str) -> Self {
Self::raw(s)
}
}
impl From<String> for Line {
fn from(s: String) -> Self {
Self::raw(s)
}
}
impl From<Span> for Line {
fn from(span: Span) -> Self {
Self {
spans: alloc::vec![span],
}
}
}
impl From<Vec<Span>> for Line {
fn from(spans: Vec<Span>) -> Self {
Self { spans }
}
}
#[macro_export]
macro_rules! spans {
($(($style:expr, $text:expr)),* $(,)?) => {
$crate::text::Line::from(alloc::vec![
$($crate::text::Span::styled($text, $style)),*
])
};
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use super::*;
use crate::color::Color;
#[test]
fn width_matches_span_width() {
assert_eq!(width("hello"), 5);
assert_eq!(width(""), 0);
}
#[test]
fn width_counts_wide_characters_as_two_columns() {
assert_eq!(width("中文"), 4);
}
#[test]
fn width_saturates_at_u16_max() {
let s = "a".repeat(usize::from(u16::MAX) + 100);
assert_eq!(width(&s), u16::MAX);
assert_eq!(width_usize(&s), s.len());
}
#[test]
fn char_width_matches_unicode_width() {
assert_eq!(char_width('a'), 1);
assert_eq!(char_width('中'), 2);
assert_eq!(char_width('\u{0301}'), 0); }
#[test]
fn char_width_treats_control_characters_as_one_column() {
assert_eq!(char_width('\u{7}'), 1); assert_eq!(char_width('\n'), 1);
assert_eq!(char_width('\t'), 1);
}
#[test]
fn split_at_width_stops_at_the_column_budget() {
assert_eq!(split_at_width("hello world", 5), ("hello", " world"));
assert_eq!(split_at_width("hi", 10), ("hi", ""));
assert_eq!(split_at_width("hi", 0), ("", "hi"));
}
#[test]
fn split_at_width_counts_wide_characters_as_two_columns() {
assert_eq!(split_at_width("aあb", 2), ("a", "あb"));
assert_eq!(split_at_width("aあb", 3), ("aあ", "b"));
assert_eq!(split_at_width("ああ", 3), ("あ", "あ"));
}
#[test]
fn split_at_width_prefix_can_exceed_max_cols() {
let (prefix, _rest) = split_at_width("\u{2764}\u{FE0F}", 1);
assert!(width(prefix) <= 1);
}
#[test]
fn split_at_width_returns_the_longest_prefix_that_fits() {
let thumbs = "\u{1F44D}\u{1F3FD}";
assert_eq!(split_at_width(thumbs, width(thumbs)), (thumbs, ""));
}
#[test]
fn truncate_measured_matches_split_at_width_plus_a_separate_measurement() {
assert_eq!(truncate_measured("hello world", 5), ("hello", 5));
assert_eq!(truncate_measured("hi", 10), ("hi", 2));
assert_eq!(truncate_measured("aあb", 2), ("a", 1));
assert_eq!(truncate_measured("aあb", 3), ("aあ", 3));
}
proptest! {
#[test]
fn split_at_width_prefix_never_exceeds_max_cols(s in ".*", max_cols in 0u16..64) {
let (prefix, _rest) = split_at_width(&s, max_cols);
prop_assert!(width(prefix) <= max_cols);
}
#[test]
fn truncate_measured_width_matches_a_direct_measurement(s in ".*", max_cols in 0u16..64) {
let (prefix, reported) = truncate_measured(&s, max_cols);
prop_assert_eq!(reported, width(prefix));
}
}
#[test]
fn test_span_raw() {
let s = Span::raw("hello");
assert_eq!(s.content, "hello");
assert_eq!(s.style, Style::default());
assert_eq!(s.width(), 5);
}
#[test]
fn test_span_styled() {
let style = Style::new().fg(Color::RED);
let s = Span::styled("hi", style);
assert_eq!(s.content, "hi");
assert_eq!(s.style, style);
}
#[test]
fn test_span_width_wide_chars() {
let s = Span::raw("中文"); assert_eq!(s.width(), 4);
}
#[test]
fn test_line_from_str() {
let line = Line::from("hello");
assert_eq!(line.spans.len(), 1);
assert_eq!(line.width(), 5);
}
#[test]
fn test_line_from_spans() {
use alloc::vec;
let line = Line::from(vec![
Span::raw("HP: "),
Span::styled("100", Style::new().fg(Color::GREEN)),
]);
assert_eq!(line.width(), 7);
}
#[test]
fn test_line_width_wide_chars() {
use alloc::vec;
let line = Line::from(vec![Span::raw("中"), Span::raw("x")]);
assert_eq!(line.width(), 3); }
#[test]
fn test_line_empty() {
let line = Line::new();
assert_eq!(line.width(), 0);
}
}