use std::cell::RefCell;
use unicode_width::UnicodeWidthChar;
thread_local! {
static WRAP_SCRATCH: RefCell<Vec<(char, u16)>> = const { RefCell::new(Vec::new()) };
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Wrap {
#[default]
None,
Char,
Word,
}
pub fn wrap_segments(line: &str, width: u16, mode: Wrap) -> Vec<(usize, usize)> {
if matches!(mode, Wrap::None) || width == 0 || line.is_empty() {
return vec![(0, line.chars().count())];
}
WRAP_SCRATCH.with(|scratch| {
let mut chars = scratch.borrow_mut();
chars.clear();
chars.extend(
line.chars()
.map(|c| (c, c.width().unwrap_or(1).max(1) as u16)),
);
let total = chars.len();
let mut segs = Vec::new();
let mut start = 0usize;
while start < total {
let mut cells: u16 = 0;
let mut i = start;
while i < total {
let w = chars[i].1;
if cells + w > width {
break;
}
cells += w;
i += 1;
}
if i == start {
i = start + 1;
}
if i == total {
segs.push((start, total));
break;
}
let break_at = if matches!(mode, Wrap::Word) {
(start..i)
.rev()
.find(|&k| chars[k].0.is_whitespace())
.map(|k| k + 1)
.filter(|&end| end > start)
.unwrap_or(i)
} else {
i
};
segs.push((start, break_at));
start = break_at;
}
if segs.is_empty() {
segs.push((0, 0));
}
segs
})
}
pub fn char_col_for_visual_offset(line: &str, seg: (usize, usize), visual_offset: usize) -> usize {
let (start, end) = seg;
let mut cells = 0usize;
for (i, ch) in line
.chars()
.enumerate()
.skip(start)
.take(end.saturating_sub(start))
{
let w = ch.width().unwrap_or(1).max(1);
if cells + w > visual_offset {
return i;
}
cells += w;
}
end
}
pub fn visual_offset_for_char_col(line: &str, seg_start: usize, char_col: usize) -> usize {
if char_col <= seg_start {
return 0;
}
line.chars()
.enumerate()
.skip(seg_start)
.take(char_col - seg_start)
.map(|(_, c)| c.width().unwrap_or(1).max(1))
.sum()
}
pub fn segment_for_col(segments: &[(usize, usize)], col: usize) -> usize {
if segments.is_empty() {
return 0;
}
if let Some(idx) = segments.iter().position(|&(s, e)| col >= s && col < e) {
return idx;
}
segments.len() - 1
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn none_returns_full_line_segment() {
let segs = wrap_segments("hello world", 4, Wrap::None);
assert_eq!(segs, vec![(0, 11)]);
}
#[test]
fn wide_char_wider_than_width_terminates() {
let segs = wrap_segments("你好", 1, Wrap::Char);
assert_eq!(segs, vec![(0, 1), (1, 2)]);
let segs = wrap_segments("你好", 1, Wrap::Word);
assert_eq!(segs, vec![(0, 1), (1, 2)]);
let segs = wrap_segments("a你b", 1, Wrap::Char);
assert_eq!(segs, vec![(0, 1), (1, 2), (2, 3)]);
}
#[test]
fn segment_for_col_finds_containing_segment() {
let segs = vec![(0, 4), (4, 8), (8, 10)];
assert_eq!(segment_for_col(&segs, 0), 0);
assert_eq!(segment_for_col(&segs, 3), 0);
assert_eq!(segment_for_col(&segs, 4), 1);
assert_eq!(segment_for_col(&segs, 7), 1);
assert_eq!(segment_for_col(&segs, 9), 2);
assert_eq!(segment_for_col(&segs, 10), 2);
assert_eq!(segment_for_col(&segs, 99), 2);
}
#[test]
fn char_col_for_visual_offset_first_segment_start() {
let line = "abcdefghij";
let segs = wrap_segments(line, 4, Wrap::Char);
assert_eq!(segs, vec![(0, 4), (4, 8), (8, 10)]);
assert_eq!(char_col_for_visual_offset(line, segs[0], 0), 0);
assert_eq!(char_col_for_visual_offset(line, segs[0], 3), 3);
}
#[test]
fn char_col_for_visual_offset_is_relative_to_segment_start_not_line_start() {
let line = "abcdefghij";
let segs = wrap_segments(line, 4, Wrap::Char);
assert_eq!(
char_col_for_visual_offset(line, segs[1], 0),
4,
"offset 0 within segment 1 must land on 'e' (char index 4), not the line start"
);
assert_eq!(
char_col_for_visual_offset(line, segs[1], 2),
6,
"offset 2 within segment 1 must land on 'g' (char index 6)"
);
}
#[test]
fn char_col_for_visual_offset_past_segment_end_clamps() {
let line = "abcdefghij";
let segs = wrap_segments(line, 4, Wrap::Char);
assert_eq!(char_col_for_visual_offset(line, segs[0], 99), segs[0].1);
assert_eq!(char_col_for_visual_offset(line, segs[2], 99), segs[2].1);
}
#[test]
fn char_col_for_visual_offset_wide_char_consumes_two_cells() {
let line = "你好";
let segs = wrap_segments(line, 1, Wrap::Char);
assert_eq!(segs, vec![(0, 1), (1, 2)]);
assert_eq!(char_col_for_visual_offset(line, segs[0], 0), 0);
assert_eq!(char_col_for_visual_offset(line, segs[1], 0), 1);
}
#[test]
fn char_col_for_visual_offset_round_trips_with_wrap_segments() {
let line = "the quick brown fox jumps over lazy dogs";
for width in [3u16, 5, 8, 12] {
let segs = wrap_segments(line, width, Wrap::Word);
for (idx, &seg) in segs.iter().enumerate() {
let seg_width: usize = line
.chars()
.skip(seg.0)
.take(seg.1 - seg.0)
.map(|c| c.width().unwrap_or(1).max(1))
.sum();
for off in 0..seg_width {
let col = char_col_for_visual_offset(line, seg, off);
assert_eq!(
segment_for_col(&segs, col),
idx,
"width={width} seg={seg:?} off={off} resolved to char {col} \
which segment_for_col assigns to a different segment"
);
}
}
}
}
#[test]
fn visual_offset_for_char_col_at_segment_start_is_zero() {
let line = "abcdefghij";
let segs = wrap_segments(line, 4, Wrap::Char);
assert_eq!(visual_offset_for_char_col(line, segs[1].0, segs[1].0), 0);
}
#[test]
fn visual_offset_for_char_col_is_relative_to_segment_start() {
let line = "abcdefghij";
let segs = wrap_segments(line, 4, Wrap::Char);
assert_eq!(visual_offset_for_char_col(line, segs[1].0, 6), 2);
}
#[test]
fn visual_offset_for_char_col_before_seg_start_clamps_to_zero() {
let line = "abcdefghij";
let segs = wrap_segments(line, 4, Wrap::Char);
assert_eq!(visual_offset_for_char_col(line, segs[1].0, 0), 0);
}
#[test]
fn visual_offset_and_char_col_for_visual_offset_round_trip() {
let line = "the quick brown fox jumps over lazy dogs";
for width in [3u16, 5, 8, 12] {
let segs = wrap_segments(line, width, Wrap::Word);
for &seg in &segs {
for col in seg.0..seg.1 {
let off = visual_offset_for_char_col(line, seg.0, col);
let back = char_col_for_visual_offset(line, seg, off);
assert_eq!(
back, col,
"seg={seg:?} col={col} → offset {off} → back {back}, \
expected round-trip to {col}"
);
}
}
}
}
}