use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct GraphemeIndex(pub usize);
impl GraphemeIndex {
pub fn new(index: usize) -> Self {
Self(index)
}
pub fn as_usize(&self) -> usize {
self.0
}
}
impl From<usize> for GraphemeIndex {
fn from(index: usize) -> Self {
Self(index)
}
}
impl From<GraphemeIndex> for usize {
fn from(index: GraphemeIndex) -> usize {
index.0
}
}
pub fn display_width(s: &str) -> usize {
UnicodeWidthStr::width(s)
}
pub fn grapheme_width(grapheme: &str) -> usize {
UnicodeWidthStr::width(grapheme)
}
pub fn grapheme_count(s: &str) -> usize {
s.graphemes(true).count()
}
pub fn graphemes(s: &str) -> impl Iterator<Item = &str> {
s.graphemes(true)
}
#[derive(Debug, Clone)]
pub struct VisualLine<'a> {
pub text: &'a str,
pub grapheme_start: usize,
pub grapheme_end: usize,
pub width: usize,
}
pub fn soft_wrap_line(text: &str, width: usize) -> Vec<VisualLine<'_>> {
if width == 0 {
return vec![];
}
let text_width = display_width(text);
if text_width <= width {
return vec![VisualLine {
text,
grapheme_start: 0,
grapheme_end: grapheme_count(text),
width: text_width,
}];
}
let mut lines = Vec::new();
let mut current_start_byte = 0;
let mut current_start_grapheme = 0;
let mut current_width = 0;
let mut grapheme_idx = 0;
for (byte_idx, grapheme) in text.grapheme_indices(true) {
let g_width = grapheme_width(grapheme);
if current_width + g_width > width {
if current_start_byte < byte_idx {
let line_text = &text[current_start_byte..byte_idx];
lines.push(VisualLine {
text: line_text,
grapheme_start: current_start_grapheme,
grapheme_end: grapheme_idx,
width: current_width,
});
}
current_start_byte = byte_idx;
current_start_grapheme = grapheme_idx;
current_width = g_width;
} else {
current_width += g_width;
}
grapheme_idx += 1;
}
if current_start_byte < text.len() {
let line_text = &text[current_start_byte..];
lines.push(VisualLine {
text: line_text,
grapheme_start: current_start_grapheme,
grapheme_end: grapheme_idx,
width: display_width(line_text),
});
}
if lines.is_empty() {
lines.push(VisualLine {
text: "",
grapheme_start: 0,
grapheme_end: 0,
width: 0,
});
}
lines
}
pub fn soft_wrap(text: &str, width: usize) -> Vec<VisualLine<'_>> {
if width == 0 {
return vec![];
}
let mut result = Vec::new();
let mut line_grapheme_offset = 0;
for line in text.split('\n') {
let wrapped = soft_wrap_line(line, width);
for mut visual_line in wrapped {
visual_line.grapheme_start += line_grapheme_offset;
visual_line.grapheme_end += line_grapheme_offset;
result.push(visual_line);
}
line_grapheme_offset += grapheme_count(line) + 1;
}
if result.is_empty() {
result.push(VisualLine {
text: "",
grapheme_start: 0,
grapheme_end: 0,
width: 0,
});
}
result
}
pub fn cursor_to_screen(text: &str, grapheme_idx: usize, width: usize) -> (u16, u16) {
if width == 0 {
return (0, 0);
}
let mut row = 0u16;
let mut grapheme_offset = 0;
for line in text.split('\n') {
let line_graphemes = grapheme_count(line);
if grapheme_idx <= grapheme_offset + line_graphemes {
let cursor_in_line = grapheme_idx - grapheme_offset;
let wrapped = soft_wrap_line(line, width);
for visual_line in &wrapped {
if cursor_in_line < visual_line.grapheme_end {
let col_grapheme = cursor_in_line - visual_line.grapheme_start;
let col = graphemes(visual_line.text)
.take(col_grapheme)
.map(grapheme_width)
.sum::<usize>() as u16;
return (col, row);
}
row += 1;
}
if let Some(last) = wrapped.last() {
let col = last.width as u16;
return (col, row.saturating_sub(1));
}
}
grapheme_offset += line_graphemes + 1; row += soft_wrap_line(line, width).len() as u16;
}
(0, row.saturating_sub(1))
}
pub fn screen_to_cursor(text: &str, col: u16, row: u16, width: usize) -> usize {
if width == 0 {
return 0;
}
let mut current_row = 0u16;
let mut grapheme_offset = 0;
for line in text.split('\n') {
let wrapped = soft_wrap_line(line, width);
for visual_line in &wrapped {
if current_row == row {
let mut current_col = 0usize;
for (grapheme_in_line, grapheme) in graphemes(visual_line.text).enumerate() {
let g_width = grapheme_width(grapheme);
if current_col + g_width > col as usize {
return grapheme_offset + visual_line.grapheme_start + grapheme_in_line;
}
current_col += g_width;
}
return grapheme_offset + visual_line.grapheme_end;
}
current_row += 1;
}
grapheme_offset += grapheme_count(line) + 1; }
grapheme_count(text)
}
pub fn grapheme_to_byte_offset(text: &str, grapheme_idx: usize) -> Option<usize> {
text.grapheme_indices(true)
.nth(grapheme_idx)
.map(|(byte_idx, _)| byte_idx)
.or_else(|| {
if grapheme_idx == grapheme_count(text) {
Some(text.len())
} else {
None
}
})
}
pub fn byte_to_grapheme_offset(text: &str, byte_offset: usize) -> usize {
text.grapheme_indices(true)
.take_while(|(idx, _)| *idx < byte_offset)
.count()
}
pub fn insert_at_grapheme(text: &str, grapheme_idx: usize, insert: &str) -> String {
let byte_offset = grapheme_to_byte_offset(text, grapheme_idx).unwrap_or(text.len());
let mut result = String::with_capacity(text.len() + insert.len());
result.push_str(&text[..byte_offset]);
result.push_str(insert);
result.push_str(&text[byte_offset..]);
result
}
pub fn remove_at_grapheme(text: &str, grapheme_idx: usize) -> Option<String> {
let mut graphemes: Vec<&str> = text.graphemes(true).collect();
if grapheme_idx >= graphemes.len() {
return None;
}
graphemes.remove(grapheme_idx);
Some(graphemes.concat())
}
pub fn wrapped_height(text: &str, width: usize) -> usize {
if width == 0 {
return 0;
}
soft_wrap(text, width).len()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_display_width_ascii() {
assert_eq!(display_width("hello"), 5);
assert_eq!(display_width(""), 0);
}
#[test]
fn test_display_width_cjk() {
assert_eq!(display_width("δΈ"), 2);
assert_eq!(display_width("δΈζ"), 4);
assert_eq!(display_width("helloδΈζ"), 9); }
#[test]
fn test_grapheme_count() {
assert_eq!(grapheme_count("hello"), 5);
assert_eq!(grapheme_count("Γ©"), 1); assert_eq!(grapheme_count("e\u{0301}"), 1); }
#[test]
fn test_soft_wrap_fits() {
let lines = soft_wrap_line("hello", 10);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, "hello");
}
#[test]
fn test_soft_wrap_character_wrap() {
let lines = soft_wrap_line("hello world", 8);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].text, "hello wo"); assert_eq!(lines[1].text, "rld"); }
#[test]
fn test_soft_wrap_force_break() {
let lines = soft_wrap_line("abcdefghij", 4);
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].text, "abcd");
assert_eq!(lines[1].text, "efgh");
assert_eq!(lines[2].text, "ij");
}
#[test]
fn test_cursor_to_screen_simple() {
let text = "hello";
assert_eq!(cursor_to_screen(text, 0, 80), (0, 0));
assert_eq!(cursor_to_screen(text, 2, 80), (2, 0));
assert_eq!(cursor_to_screen(text, 5, 80), (5, 0));
}
#[test]
fn test_cursor_to_screen_multiline() {
let text = "hello\nworld";
assert_eq!(cursor_to_screen(text, 0, 80), (0, 0));
assert_eq!(cursor_to_screen(text, 5, 80), (5, 0)); assert_eq!(cursor_to_screen(text, 6, 80), (0, 1)); assert_eq!(cursor_to_screen(text, 8, 80), (2, 1)); }
#[test]
fn test_insert_at_grapheme() {
assert_eq!(insert_at_grapheme("hello", 0, "X"), "Xhello");
assert_eq!(insert_at_grapheme("hello", 2, "X"), "heXllo");
assert_eq!(insert_at_grapheme("hello", 5, "X"), "helloX");
}
#[test]
fn test_remove_at_grapheme() {
assert_eq!(remove_at_grapheme("hello", 0), Some("ello".to_string()));
assert_eq!(remove_at_grapheme("hello", 2), Some("helo".to_string()));
assert_eq!(remove_at_grapheme("hello", 4), Some("hell".to_string()));
assert_eq!(remove_at_grapheme("hello", 5), None);
}
#[test]
fn test_soft_wrap_returns_references_not_copies() {
let original = "here is my text before I resize the screen";
let wrapped = soft_wrap_line(original, 20);
for line in &wrapped {
assert!(original.contains(line.text));
}
let reconstructed: String = wrapped.iter().map(|l| l.text).collect::<Vec<_>>().concat();
assert_eq!(reconstructed, original);
}
#[test]
fn test_soft_wrap_no_newlines_inserted() {
let content = "here is my text before I resize the screen it is lovely";
let wrapped_narrow = soft_wrap_line(content, 20);
assert!(wrapped_narrow.len() > 1, "Should wrap at width 20");
let wrapped_wide = soft_wrap_line(content, 80);
assert_eq!(wrapped_wide.len(), 1, "Should not wrap at width 80");
assert!(!content.contains('\n'), "Content must not contain newlines");
for line in &wrapped_narrow {
assert!(
!line.text.contains('\n'),
"Visual line must not contain newlines"
);
}
}
#[test]
fn test_resize_reflows_correctly() {
let content = "here is my text before I resize the screen";
let at_20 = soft_wrap_line(content, 20);
let at_60 = soft_wrap_line(content, 60);
let at_80 = soft_wrap_line(content, 80);
assert!(
at_20.len() > at_60.len(),
"Narrower width should have more visual lines"
);
assert!(
at_60.len() >= at_80.len(),
"Wider width should have fewer visual lines"
);
assert!(!content.contains('\n'));
}
#[test]
fn test_content_integrity_after_simulated_typing() {
let mut content = String::new();
for i in 0..80 {
content.push(char::from_u32('a' as u32 + (i % 26)).unwrap());
}
assert_eq!(content.len(), 80);
assert_eq!(content.chars().filter(|&c| c == '\n').count(), 0);
let visual_lines = soft_wrap_line(&content, 20);
assert_eq!(
visual_lines.len(),
4,
"80 chars / 20 width = 4 visual lines"
);
assert!(
!content.contains('\n'),
"Content must never be modified by wrapping"
);
}
#[test]
fn test_cjk_content_wraps_by_display_width() {
let content = "δΈζδΈζδΈζ";
let wrapped = soft_wrap_line(content, 6);
assert_eq!(wrapped.len(), 2);
assert_eq!(display_width(wrapped[0].text), 6);
assert_eq!(display_width(wrapped[1].text), 6);
}
#[test]
fn test_grapheme_cluster_not_split() {
let content = "hello π¨βπ©βπ§βπ¦ world";
let wrapped = soft_wrap_line(content, 10);
let emoji_line = wrapped.iter().find(|l| l.text.contains('π¨')).unwrap();
assert!(emoji_line.text.contains("π¨βπ©βπ§βπ¦"));
}
#[test]
fn test_character_wrap_typing_at_edge() {
let before = "here we are on a thin scre";
let after = "here we are on a thin scree";
let wrapped_before = soft_wrap_line(before, 26);
let wrapped_after = soft_wrap_line(after, 26);
assert_eq!(wrapped_before.len(), 1);
assert_eq!(wrapped_before[0].text, before);
assert_eq!(wrapped_after.len(), 2);
assert_eq!(wrapped_after[0].text, "here we are on a thin scre");
assert_eq!(wrapped_after[1].text, "e");
}
#[test]
fn test_character_wrap_typing_continues_on_second_line() {
let content = "here we are on a thin screen";
let wrapped = soft_wrap_line(content, 26);
assert_eq!(wrapped.len(), 2);
assert_eq!(wrapped[0].text, "here we are on a thin scre");
assert_eq!(wrapped[1].text, "en");
}
#[test]
fn test_resize_wider_reflows_back() {
let content = "here is my text that wraps";
let at_20 = soft_wrap_line(content, 20);
let at_40 = soft_wrap_line(content, 40);
assert_eq!(at_20.len(), 2);
assert_eq!(at_20[0].text, "here is my text that");
assert_eq!(at_20[1].text, " wraps");
assert_eq!(at_40.len(), 1);
assert_eq!(at_40[0].text, content);
let reconstructed_20: String = at_20.iter().map(|l| l.text).collect();
let reconstructed_40: String = at_40.iter().map(|l| l.text).collect();
assert_eq!(reconstructed_20, content);
assert_eq!(reconstructed_40, content);
}
#[test]
fn test_resize_narrower_reflows_more() {
let content = "abcdefghijklmnopqrstuvwxyz";
let at_26 = soft_wrap_line(content, 26);
let at_13 = soft_wrap_line(content, 13);
let at_5 = soft_wrap_line(content, 5);
assert_eq!(at_26.len(), 1);
assert_eq!(at_13.len(), 2);
assert_eq!(at_13[0].text, "abcdefghijklm");
assert_eq!(at_13[1].text, "nopqrstuvwxyz");
assert_eq!(at_5.len(), 6);
assert_eq!(at_5[0].text, "abcde");
assert_eq!(at_5[5].text, "z");
}
#[test]
fn test_exact_width_no_wrap() {
let content = "12345";
let wrapped = soft_wrap_line(content, 5);
assert_eq!(wrapped.len(), 1);
assert_eq!(wrapped[0].text, "12345");
}
#[test]
fn test_one_over_width_wraps() {
let content = "123456";
let wrapped = soft_wrap_line(content, 5);
assert_eq!(wrapped.len(), 2);
assert_eq!(wrapped[0].text, "12345");
assert_eq!(wrapped[1].text, "6");
}
#[test]
fn test_spaces_preserved_in_wrap() {
let content = "ab cd ef";
let wrapped = soft_wrap_line(content, 4);
assert_eq!(wrapped.len(), 2);
assert_eq!(wrapped[0].text, "ab c");
assert_eq!(wrapped[1].text, "d ef");
let reconstructed: String = wrapped.iter().map(|l| l.text).collect();
assert_eq!(reconstructed, content);
}
#[test]
fn test_grapheme_indices_correct_after_wrap() {
let content = "abcdefghij";
let wrapped = soft_wrap_line(content, 4);
assert_eq!(wrapped[0].grapheme_start, 0);
assert_eq!(wrapped[0].grapheme_end, 4);
assert_eq!(wrapped[1].grapheme_start, 4);
assert_eq!(wrapped[1].grapheme_end, 8);
assert_eq!(wrapped[2].grapheme_start, 8);
assert_eq!(wrapped[2].grapheme_end, 10);
}
#[test]
fn test_cursor_position_after_wrap() {
let content = "abcdefgh";
let (col, row) = cursor_to_screen(content, 6, 5);
assert_eq!(row, 1, "Cursor should be on second visual line");
assert_eq!(col, 1, "Cursor should be at column 1 (after 'f')");
}
#[test]
fn test_cursor_at_wrap_boundary() {
let content = "abcdefgh";
let (col, row) = cursor_to_screen(content, 5, 5);
assert_eq!(row, 1, "Cursor should be on second line");
assert_eq!(col, 0, "Cursor should be at start of second line");
}
#[test]
fn test_multiple_resize_cycles() {
let content = "the quick brown fox jumps over lazy dog";
for width in [40, 20, 40, 10, 40, 15, 40] {
let wrapped = soft_wrap_line(content, width);
let reconstructed: String = wrapped.iter().map(|l| l.text).collect();
assert_eq!(
reconstructed, content,
"Content corrupted at width {}",
width
);
}
}
#[test]
fn test_emoji_at_wrap_boundary() {
let base = "a b c d e dakl asdl d fox fox badgerπππ ππ";
let base_width = display_width(base);
let wrapped_exact = soft_wrap_line(base, base_width);
assert_eq!(
wrapped_exact.len(),
1,
"Should fit on one line at exact width"
);
let with_extra = "a b c d e dakl asdl d fox fox badgerπππ πππ";
let wrapped_overflow = soft_wrap_line(with_extra, base_width);
assert_eq!(wrapped_overflow.len(), 2, "Should wrap to two lines");
assert!(
wrapped_overflow[1].text.contains('π'),
"Second line should have the overflow emoji"
);
let reconstructed: String = wrapped_overflow.iter().map(|l| l.text).collect();
assert_eq!(reconstructed, with_extra, "Content must be preserved");
}
#[test]
fn test_wide_char_at_boundary_edge_cases() {
let text = "a b c d e dakl asdl d fox fox badgerπππ ππ";
let at_47 = soft_wrap_line(text, 47);
assert_eq!(at_47.len(), 1);
let at_46 = soft_wrap_line(text, 46);
assert_eq!(at_46.len(), 2);
assert_eq!(at_46[0].width, 45); assert_eq!(at_46[1].text, "π");
let at_45 = soft_wrap_line(text, 45);
assert_eq!(at_45.len(), 2);
for width in [47, 46, 45, 44, 43, 42, 41, 40] {
let wrapped = soft_wrap_line(text, width);
let reconstructed: String = wrapped.iter().map(|l| l.text).collect();
assert_eq!(reconstructed, text, "Content corrupted at width {}", width);
}
}
}