use ratatui::style::Style;
use ratatui::text::{Line, Span};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthChar;
const EMOJI_VS: char = '\u{FE0F}';
pub fn char_width(c: char) -> usize {
UnicodeWidthChar::width(c).unwrap_or(0)
}
fn is_emoji_modifier(c: char) -> bool {
('\u{1F3FB}'..='\u{1F3FF}').contains(&c)
}
fn is_regional_indicator(c: char) -> bool {
('\u{1F1E6}'..='\u{1F1FF}').contains(&c)
}
pub fn width_at(chars: &[char], i: usize) -> usize {
let c = chars[i];
if is_emoji_modifier(c) {
return 0;
}
if c == EMOJI_VS {
let prev = if i > 0 { char_width(chars[i - 1]) } else { 0 };
return 2usize.saturating_sub(prev);
}
char_width(c)
}
pub fn display_width(chars: &[char]) -> usize {
(0..chars.len()).map(|i| width_at(chars, i)).sum()
}
pub fn str_width(s: &str) -> usize {
display_width(&s.chars().collect::<Vec<_>>())
}
pub fn truncate_to_width(s: &str, max: usize) -> (String, usize) {
let chars: Vec<char> = s.chars().collect();
let full = display_width(&chars);
if full <= max {
return (s.to_string(), full);
}
if max == 0 {
return (String::new(), 0);
}
let budget = max.saturating_sub(1);
let mut out = String::new();
let mut w = 0;
for i in 0..chars.len() {
let cw = width_at(&chars, i);
if w + cw > budget {
break;
}
w += cw;
out.push(chars[i]);
}
out.push('…');
(out, w + 1)
}
pub fn elide_middle(s: &str, max: usize) -> String {
if str_width(s) <= max {
return s.to_string();
}
if max == 0 {
return String::new();
}
let body = max - 1;
let tail_budget = body / 2;
let head_budget = body - tail_budget;
let clusters: Vec<&str> = s.graphemes(true).collect();
let mut head = String::new();
let mut w = 0;
for g in &clusters {
let gw = str_width(g);
if w + gw > head_budget {
break;
}
head.push_str(g);
w += gw;
}
let mut tail_parts: Vec<&str> = Vec::new();
let mut w = 0;
for g in clusters.iter().rev() {
let gw = str_width(g);
if w + gw > tail_budget {
break;
}
tail_parts.push(g);
w += gw;
}
let tail: String = tail_parts.iter().rev().copied().collect();
format!("{head}…{tail}")
}
pub fn prev_boundary(chars: &[char], col: usize) -> usize {
let s: String = chars.iter().collect();
let mut prev = 0;
let mut acc = 0;
for g in s.graphemes(true) {
acc += g.chars().count();
if acc >= col {
break;
}
prev = acc;
}
prev
}
pub fn next_boundary(chars: &[char], col: usize) -> usize {
let s: String = chars.iter().collect();
let mut acc = 0;
for g in s.graphemes(true) {
acc += g.chars().count();
if acc > col {
return acc;
}
}
chars.len()
}
pub fn snap_boundary(chars: &[char], col: usize) -> usize {
let s: String = chars.iter().collect();
let mut acc = 0;
for g in s.graphemes(true) {
let next = acc + g.chars().count();
if next > col {
break;
}
acc = next;
}
acc
}
fn hard_break(chars: &[char], start: usize, i: usize) -> usize {
let c = chars[i];
if c != EMOJI_VS && !is_regional_indicator(c) {
return i;
}
let b = snap_boundary(chars, i);
if b > start {
b
} else {
next_boundary(chars, start)
}
}
pub fn wrap_ranges(chars: &[char], width: usize) -> Vec<(usize, usize)> {
if chars.is_empty() {
return vec![(0, 0)];
}
if width == 0 {
return vec![(0, chars.len())];
}
let n = chars.len();
let mut rows = Vec::new();
let mut start = 0;
while start < n {
let mut w = 0;
let mut i = start;
let mut last_ws: Option<usize> = None;
let end = loop {
if i >= n {
break n;
}
let cw = width_at(chars, i);
if w + cw > width && i > start {
if chars[i].is_whitespace() {
while i < n && chars[i].is_whitespace() {
i += 1;
}
break i;
}
break match last_ws {
Some(ws) => ws + 1,
None => hard_break(chars, start, i),
};
}
if chars[i].is_whitespace() {
last_ws = Some(i);
}
w += cw;
i += 1;
};
rows.push((start, end));
start = end;
}
rows
}
pub fn wrap_line(line: &Line<'_>, width: usize) -> Vec<Line<'static>> {
let mut chars: Vec<char> = Vec::new();
let mut styles: Vec<Style> = Vec::new();
for span in &line.spans {
for c in span.content.chars() {
chars.push(c);
styles.push(span.style);
}
}
wrap_ranges(&chars, width)
.into_iter()
.map(|(s, e)| {
let mut spans: Vec<Span<'static>> = Vec::new();
let mut k = s;
while k < e {
let st = styles[k];
let mut buf = String::new();
while k < e && styles[k] == st {
buf.push(chars[k]);
k += 1;
}
spans.push(Span::styled(buf, st));
}
let mut out = Line::from(spans);
out.style = line.style;
out.alignment = line.alignment;
out
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn chars(s: &str) -> Vec<char> {
s.chars().collect()
}
fn wrap_text(s: &str, width: usize) -> Vec<String> {
let cs = chars(s);
wrap_ranges(&cs, width)
.into_iter()
.map(|(a, b)| cs[a..b].iter().collect())
.collect()
}
#[test]
fn empty_line_is_one_empty_row() {
assert_eq!(wrap_ranges(&[], 10), vec![(0, 0)]);
}
#[test]
fn short_line_fits_in_one_row() {
assert_eq!(wrap_text("привет", 10), vec!["привет"]);
}
#[test]
fn wraps_on_word_boundary() {
assert_eq!(wrap_text("один два три", 8), vec!["один два ", "три"]);
}
#[test]
fn long_word_is_hard_broken() {
assert_eq!(wrap_text("abcdefghij", 4), vec!["abcd", "efgh", "ij"]);
}
#[test]
fn emoji_presentation_selector_makes_width_two() {
let cs = chars("❤\u{FE0F}");
assert_eq!(display_width(&cs), 2);
assert_eq!(display_width(&chars("❤")), 1);
assert_eq!(width_at(&cs, 0), 1);
assert_eq!(width_at(&cs, 1), 1);
}
#[test]
fn skin_tone_modifier_adds_no_columns() {
assert_eq!(display_width(&chars("👍🏽")), 2);
assert_eq!(display_width(&chars("😊")), 2);
}
#[test]
fn selector_after_wide_emoji_adds_nothing() {
let cs = chars("😊\u{FE0F}");
assert_eq!(display_width(&cs), 2);
}
#[test]
fn grapheme_boundaries_group_emoji_clusters() {
let cs = chars("a❤\u{FE0F}b👍🏽");
assert_eq!(cs.len(), 6);
assert_eq!(prev_boundary(&cs, 6), 4);
assert_eq!(prev_boundary(&cs, 3), 1);
assert_eq!(next_boundary(&cs, 1), 3);
let e = chars("😊x");
assert_eq!(next_boundary(&e, 0), 1);
assert_eq!(prev_boundary(&e, 1), 0);
}
#[test]
fn snap_boundary_lands_on_cluster_start() {
let cs = chars("❤\u{FE0F}x");
assert_eq!(cs.len(), 3);
assert_eq!(snap_boundary(&cs, 0), 0);
assert_eq!(snap_boundary(&cs, 1), 0);
assert_eq!(snap_boundary(&cs, 2), 2);
assert_eq!(snap_boundary(&cs, 3), 3);
assert_eq!(snap_boundary(&cs, 99), 3);
let ascii = chars("abcd");
for i in 0..=4 {
assert_eq!(snap_boundary(&ascii, i), i);
}
}
#[test]
fn hard_break_keeps_vs16_cluster_together() {
let cs = chars("aa❤\u{FE0F}bb");
assert_eq!(cs.len(), 6);
let rows = wrap_ranges(&cs, 3);
for &(s, _) in &rows {
assert_ne!(cs[s], '\u{FE0F}', "row started with an orphaned selector");
}
assert!(
rows.iter().any(|&(s, e)| s <= 2 && e >= 4),
"❤️ split across rows: {rows:?}"
);
}
#[test]
fn hard_break_keeps_flag_together() {
let cs = chars("aa🇬🇧aa");
let rows = wrap_ranges(&cs, 2);
assert!(
rows.iter().any(|&(s, e)| s == 2 && e == 4),
"flag split: {rows:?}"
);
}
#[test]
fn wide_chars_count_two_columns() {
let cs = chars("🔧🔧");
assert_eq!(wrap_ranges(&cs, 3), vec![(0, 1), (1, 2)]);
}
#[test]
fn single_wide_char_never_loops() {
let cs = chars("🔧");
assert_eq!(wrap_ranges(&cs, 1), vec![(0, 1)]);
}
#[test]
fn zero_width_returns_whole_line() {
let cs = chars("abc");
assert_eq!(wrap_ranges(&cs, 0), vec![(0, 3)]);
}
#[test]
fn wrap_line_preserves_styles() {
use ratatui::style::Stylize;
let line = Line::from(vec![Span::raw("раз ").red(), Span::raw("два три")]);
let rows = wrap_line(&line, 6);
let joined: String = rows
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert_eq!(joined, "раз два три");
assert!(rows[0].spans.iter().any(|s| s.style.fg.is_some()));
}
#[test]
fn wrap_line_keeps_empty_row_for_blank_line() {
let line = Line::from("");
let rows = wrap_line(&line, 10);
assert_eq!(rows.len(), 1);
}
#[test]
fn truncate_to_width_cuts_the_tail_with_a_marker() {
assert_eq!(truncate_to_width("abc", 3), ("abc".to_string(), 3));
assert_eq!(truncate_to_width("abcdef", 4), ("abc…".to_string(), 4));
assert_eq!(truncate_to_width("a🔧b", 3), ("a…".to_string(), 2));
assert_eq!(truncate_to_width("abc", 0), (String::new(), 0));
}
#[test]
fn elide_middle_keeps_both_ends() {
assert_eq!(elide_middle("report.pdf", 10), "report.pdf");
let out = elide_middle("quarterly-report-2026-final.pdf", 16);
assert_eq!(out, "quarterl…nal.pdf");
assert_eq!(str_width(&out), 16);
assert_eq!(elide_middle("abcdefghij", 4), "ab…j");
assert_eq!(elide_middle("abcdefghij", 5), "ab…ij");
assert_eq!(elide_middle("abcdef", 1), "…");
assert_eq!(elide_middle("abcdef", 0), "");
}
#[test]
fn elide_middle_does_not_split_emoji_clusters() {
let out = elide_middle("aaaa❤\u{FE0F}bbbb", 6);
assert!(
!out.contains('\u{FE0F}') || out.contains("❤\u{FE0F}"),
"the cluster survives whole or not at all: {out:?}"
);
assert!(str_width(&out) <= 6, "{out:?}");
assert_eq!(elide_middle("🔧🔧🔧🔧", 5), "🔧…🔧");
}
}