use ratatui::style::Style;
use textwrap::core::Fragment;
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone)]
pub struct MeasuredWord {
pub text: String,
pub width: usize,
pub whitespace_width: usize,
pub penalty: String,
pub style_runs: Vec<(usize, Style)>,
}
impl MeasuredWord {
pub fn new(word: &str, trailing_whitespace: &str, style: Style) -> Self {
Self {
width: UnicodeWidthStr::width(word),
whitespace_width: UnicodeWidthStr::width(trailing_whitespace),
text: word.to_string(),
penalty: String::new(),
style_runs: vec![(0, style)],
}
}
pub fn primary_style(&self) -> Style {
self.style_runs.first().map(|r| r.1).unwrap_or_default()
}
pub fn segments(&self) -> impl Iterator<Item = (&str, Style)> {
let text = self.text.as_str();
let runs = &self.style_runs;
(0..runs.len()).filter_map(move |i| {
let start = runs[i].0;
let end = runs.get(i + 1).map_or(text.len(), |r| r.0);
if start >= end {
None
} else {
Some((&text[start..end], runs[i].1))
}
})
}
pub(crate) fn append_fragment(&mut self, other: &MeasuredWord) {
let base = self.text.len();
let last_style = self.style_runs.last().map(|r| r.1).unwrap_or_default();
for (off, style) in &other.style_runs {
let merged_off = base + off;
if *off == 0 && *style == last_style {
continue;
}
self.style_runs.push((merged_off, *style));
}
self.text.push_str(&other.text);
self.width += other.width;
self.whitespace_width = other.whitespace_width;
debug_assert!(
self.style_runs.windows(2).all(|w| w[0].0 < w[1].0),
"style_runs offsets must be strictly increasing"
);
}
}
impl Fragment for MeasuredWord {
fn width(&self) -> f64 {
self.width as f64
}
fn whitespace_width(&self) -> f64 {
self.whitespace_width as f64
}
fn penalty_width(&self) -> f64 {
UnicodeWidthStr::width(self.penalty.as_str()) as f64
}
}
pub fn measure_words(text: &str, style: Style) -> Vec<MeasuredWord> {
let mut words: Vec<MeasuredWord> = Vec::new();
let mut chars = text.char_indices().peekable();
while chars.peek().is_some() {
let Some(&(word_start, _)) = chars.peek() else {
break;
};
let mut word_end = word_start;
while let Some(&(i, ch)) = chars.peek() {
if ch.is_whitespace() {
break;
}
chars.next();
word_end = i + ch.len_utf8();
}
if word_end == word_start {
let ws_start = word_start;
let mut ws_end = ws_start;
while let Some(&(i, ch)) = chars.peek() {
if !ch.is_whitespace() {
break;
}
chars.next();
ws_end = i + ch.len_utf8();
}
let ws = &text[ws_start..ws_end];
let ws_width = UnicodeWidthStr::width(ws);
if let Some(last) = words.last_mut() {
last.whitespace_width += ws_width;
} else {
words.push(MeasuredWord {
text: String::new(),
width: 0,
whitespace_width: ws_width,
penalty: String::new(),
style_runs: vec![(0, style)],
});
}
continue;
}
let word = &text[word_start..word_end];
let ws_start = word_end;
let mut ws_end = ws_start;
while let Some(&(i, ch)) = chars.peek() {
if !ch.is_whitespace() {
break;
}
chars.next();
ws_end = i + ch.len_utf8();
}
let trailing_ws = &text[ws_start..ws_end];
words.push(MeasuredWord::new(word, trailing_ws, style));
}
words
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_measure_simple_words() {
let words = measure_words("hello world", Style::default());
assert_eq!(words.len(), 2);
assert_eq!(words[0].text, "hello");
assert_eq!(words[0].width, 5);
assert_eq!(words[0].whitespace_width, 1);
assert_eq!(words[1].text, "world");
assert_eq!(words[1].width, 5);
assert_eq!(words[1].whitespace_width, 0);
}
#[test]
fn test_measure_cjk() {
let words = measure_words("ä½ å¥½ world", Style::default());
assert_eq!(words.len(), 2);
assert_eq!(words[0].text, "ä½ å¥½");
assert_eq!(words[0].width, 4);
assert_eq!(words[1].text, "world");
assert_eq!(words[1].width, 5);
}
#[test]
fn test_measure_empty() {
let words = measure_words("", Style::default());
assert_eq!(words.len(), 0);
}
#[test]
fn test_measure_multiple_spaces() {
let words = measure_words("hello world", Style::default());
assert_eq!(words.len(), 2);
assert_eq!(words[0].text, "hello");
assert_eq!(words[0].whitespace_width, 3);
}
#[test]
fn test_measure_preserves_style() {
use ratatui::style::Color;
let style = Style::default().fg(Color::Red);
let words = measure_words("hello world", style);
assert_eq!(words[0].primary_style(), style);
assert_eq!(words[1].primary_style(), style);
assert_eq!(words[0].style_runs.len(), 1);
}
#[test]
fn test_append_fragment_preserves_both_styles() {
use ratatui::style::Color;
let red = Style::default().fg(Color::Red);
let blue = Style::default().fg(Color::Blue);
let mut word = MeasuredWord::new("hel", "", red);
let tail = MeasuredWord::new("lo", "", blue);
word.append_fragment(&tail);
assert_eq!(word.text, "hello");
assert_eq!(word.width, 5);
assert_eq!(word.style_runs.len(), 2);
assert_eq!(word.style_runs[0], (0, red));
assert_eq!(word.style_runs[1], (3, blue));
let segments: Vec<_> = word.segments().collect();
assert_eq!(segments, vec![("hel", red), ("lo", blue)]);
}
#[test]
fn test_measure_leading_whitespace_emits_sentinel() {
let words = measure_words(" hello", Style::default());
assert_eq!(words.len(), 2);
assert_eq!(words[0].text, "");
assert_eq!(words[0].width, 0);
assert_eq!(words[0].whitespace_width, 2);
assert_eq!(words[1].text, "hello");
assert_eq!(words[1].whitespace_width, 0);
}
#[test]
fn test_measure_only_whitespace_emits_sentinel() {
let words = measure_words(" ", Style::default());
assert_eq!(words.len(), 1);
assert_eq!(words[0].text, "");
assert_eq!(words[0].whitespace_width, 4);
}
#[test]
fn test_append_fragment_same_style_stays_single_run() {
use ratatui::style::Color;
let red = Style::default().fg(Color::Red);
let mut word = MeasuredWord::new("hel", "", red);
let tail = MeasuredWord::new("lo", "", red);
word.append_fragment(&tail);
assert_eq!(word.text, "hello");
assert_eq!(word.style_runs.len(), 1);
}
}