use crate::util::unicode;
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VisualLine {
pub logical_line: usize,
pub byte_start: usize,
pub byte_end: usize,
pub char_start: usize,
pub char_end: usize,
pub is_first: bool,
}
struct Grapheme<'a> {
s: &'a str,
byte_offset: usize,
display_width: usize,
}
fn graphemes(line: &str) -> Vec<Grapheme<'_>> {
line.grapheme_indices(true)
.map(|(i, g)| Grapheme {
s: g,
byte_offset: i,
display_width: grapheme_display_width(g),
})
.collect()
}
fn grapheme_display_width(g: &str) -> usize {
if g == "\t" {
4
} else {
unicode_width::UnicodeWidthStr::width(g)
}
}
pub fn wrap_line(line: &str, width: usize, logical_line: usize) -> Vec<VisualLine> {
if width == 0 {
return vec![VisualLine {
logical_line,
byte_start: 0,
byte_end: line.len(),
char_start: 0,
char_end: line.len(),
is_first: true,
}];
}
let dw = unicode::display_width(line);
if dw <= width {
return vec![VisualLine {
logical_line,
byte_start: 0,
byte_end: line.len(),
char_start: 0,
char_end: line.len(),
is_first: true,
}];
}
let gs = graphemes(line);
let total = gs.len();
let mut result = Vec::new();
let mut vl_start: usize = 0;
let mut col: usize = 0;
let mut i: usize = 0;
let byte_at = |idx: usize| -> usize {
if idx < gs.len() {
gs[idx].byte_offset
} else {
line.len()
}
};
while i < total {
let token_start = i;
let is_ws = gs[i].s.chars().all(|c| c.is_whitespace());
if is_ws {
while i < total && gs[i].s.chars().all(|c| c.is_whitespace()) {
i += 1;
}
} else {
while i < total && !gs[i].s.chars().all(|c| c.is_whitespace()) {
let was_hyphen = gs[i].s == "-";
i += 1;
if was_hyphen && i < total && !gs[i].s.chars().all(|c| c.is_whitespace()) {
break;
}
}
}
let token_dw: usize = gs[token_start..i].iter().map(|g| g.display_width).sum();
if col + token_dw <= width {
col += token_dw;
} else if col == 0 && !is_ws {
let mut placed_dw = 0;
let mut j = token_start;
while j < i {
let gdw = gs[j].display_width;
if placed_dw + gdw > width && placed_dw > 0 {
let be = byte_at(j);
let bs = byte_at(vl_start);
result.push(VisualLine {
logical_line,
byte_start: bs,
byte_end: be,
char_start: bs,
char_end: be,
is_first: result.is_empty(),
});
vl_start = j;
placed_dw = 0;
}
placed_dw += gdw;
j += 1;
}
col = placed_dw;
} else if is_ws {
let remaining = width.saturating_sub(col);
let mut j = token_start;
let mut placed = 0;
while j < i {
let gdw = gs[j].display_width;
if placed + gdw > remaining {
break;
}
placed += gdw;
j += 1;
}
let bs = byte_at(vl_start);
let be = byte_at(j);
result.push(VisualLine {
logical_line,
byte_start: bs,
byte_end: be,
char_start: bs,
char_end: be,
is_first: result.is_empty(),
});
vl_start = i;
col = 0;
} else {
let remaining_space = width.saturating_sub(col);
let blank_fraction = if width > 0 {
remaining_space as f64 / width as f64
} else {
0.0
};
if blank_fraction > 0.5 && remaining_space > 0 {
let mut placed = 0;
let mut j = token_start;
while j < i && placed + gs[j].display_width <= remaining_space {
placed += gs[j].display_width;
j += 1;
}
let bs = byte_at(vl_start);
let be = byte_at(j);
result.push(VisualLine {
logical_line,
byte_start: bs,
byte_end: be,
char_start: bs,
char_end: be,
is_first: result.is_empty(),
});
vl_start = j;
col = 0;
i = j;
} else {
let bs = byte_at(vl_start);
let be = byte_at(token_start);
if token_start > vl_start {
result.push(VisualLine {
logical_line,
byte_start: bs,
byte_end: be,
char_start: bs,
char_end: be,
is_first: result.is_empty(),
});
vl_start = token_start;
}
col = token_dw;
if token_dw > width {
let mut placed_dw = 0;
let mut j = token_start;
while j < i {
let gdw = gs[j].display_width;
if placed_dw + gdw > width && placed_dw > 0 {
let vbs = byte_at(vl_start);
let vbe = byte_at(j);
result.push(VisualLine {
logical_line,
byte_start: vbs,
byte_end: vbe,
char_start: vbs,
char_end: vbe,
is_first: result.is_empty(),
});
vl_start = j;
placed_dw = 0;
}
placed_dw += gdw;
j += 1;
}
col = placed_dw;
}
}
}
}
let bs = byte_at(vl_start);
result.push(VisualLine {
logical_line,
byte_start: bs,
byte_end: line.len(),
char_start: bs,
char_end: line.len(),
is_first: result.is_empty(),
});
result
}
pub fn wrap_lines(lines: &[&str], width: usize) -> Vec<VisualLine> {
let mut result = Vec::new();
for (idx, line) in lines.iter().enumerate() {
result.extend(wrap_line(line, width, idx));
}
result
}
pub fn wrap_lines_for_edit(
lines: &[&str],
width: usize,
cursor_line: usize,
cursor_col: usize,
) -> Vec<VisualLine> {
let mut vls = wrap_lines(lines, width);
if width == 0 {
return vls;
}
for i in 1..vls.len() {
if vls[i].logical_line == vls[i - 1].logical_line && vls[i].byte_start > vls[i - 1].byte_end
{
vls[i].byte_start = vls[i - 1].byte_end;
vls[i].char_start = vls[i - 1].char_end;
}
}
let cursor_vrow = logical_to_visual_row(&vls, cursor_line, cursor_col);
if let Some(vl) = vls.get(cursor_vrow)
&& cursor_col >= vl.char_end
{
let line_text = lines.get(vl.logical_line).copied().unwrap_or("");
let slice = &line_text[vl.byte_start..vl.byte_end];
let slice_width = unicode::display_width(slice);
if slice_width >= width {
let extra = VisualLine {
logical_line: vl.logical_line,
byte_start: vl.byte_end,
byte_end: vl.byte_end,
char_start: vl.byte_end,
char_end: vl.byte_end,
is_first: false,
};
vls.insert(cursor_vrow + 1, extra);
}
}
vls
}
pub fn gutter_width(line_count: usize) -> usize {
let digits = if line_count == 0 {
1
} else {
line_count.to_string().len()
};
(digits + 1).max(3)
}
pub fn logical_to_visual_row(visual_lines: &[VisualLine], line: usize, col: usize) -> usize {
for (i, vl) in visual_lines.iter().enumerate() {
if vl.logical_line == line {
if col < vl.byte_end || (col == vl.byte_end && i + 1 >= visual_lines.len()) {
return i;
}
let next_is_same = visual_lines
.get(i + 1)
.is_some_and(|next| next.logical_line == line);
if col >= vl.byte_start && !next_is_same {
return i;
}
}
}
visual_lines.len().saturating_sub(1)
}
pub fn visual_row_to_logical(
visual_lines: &[VisualLine],
row: usize,
target_visual_col: usize,
lines: &[&str],
) -> (usize, usize) {
if let Some(vl) = visual_lines.get(row) {
let logical_line = vl.logical_line;
let line_str = lines.get(logical_line).copied().unwrap_or("");
let vl_text = &line_str[vl.byte_start..vl.byte_end];
let byte_within_vl = unicode::display_col_to_byte_offset(vl_text, target_visual_col);
let col = vl.byte_start + byte_within_vl;
(logical_line, col.min(vl.byte_end))
} else {
(0, 0)
}
}
pub fn logical_to_visual_col(
visual_lines: &[VisualLine],
line: usize,
col: usize,
lines: &[&str],
) -> usize {
let row = logical_to_visual_row(visual_lines, line, col);
if let Some(vl) = visual_lines.get(row) {
let logical_line_str = lines.get(vl.logical_line).copied().unwrap_or("");
let byte_start = vl.byte_start;
let byte_cursor = col.min(vl.byte_end);
let within_vl = &logical_line_str[byte_start..byte_cursor];
unicode::display_width(within_vl)
} else {
0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_wrap_needed() {
let vls = wrap_line("hello world", 80, 0);
assert_eq!(vls.len(), 1);
assert_eq!(vls[0].byte_start, 0);
assert_eq!(vls[0].byte_end, 11);
assert!(vls[0].is_first);
}
#[test]
fn wrap_at_space() {
let vls = wrap_line("hello world", 7, 0);
assert_eq!(vls.len(), 2);
assert_eq!(vls[0].byte_start, 0);
assert!(vls[0].is_first);
assert_eq!(vls[1].logical_line, 0);
assert!(!vls[1].is_first);
let second = &"hello world"[vls[1].byte_start..vls[1].byte_end];
assert_eq!(second, "world");
}
#[test]
fn wrap_at_hyphen() {
let vls = wrap_line("long-word here", 6, 0);
assert!(vls.len() >= 2);
assert_eq!(vls[0].byte_end, 5); }
#[test]
fn char_wrap_long_word() {
let vls = wrap_line("abcdefghij", 4, 0);
assert!(vls.len() >= 2);
for vl in &vls {
let text = &"abcdefghij"[vl.byte_start..vl.byte_end];
assert!(unicode::display_width(text) <= 4);
}
}
#[test]
fn empty_line() {
let vls = wrap_line("", 80, 0);
assert_eq!(vls.len(), 1);
assert_eq!(vls[0].byte_start, 0);
assert_eq!(vls[0].byte_end, 0);
assert!(vls[0].is_first);
}
#[test]
fn zero_width() {
let vls = wrap_line("hello", 0, 0);
assert_eq!(vls.len(), 1);
}
#[test]
fn wrap_lines_multiple() {
let lines = vec!["hello world", "foo"];
let vls = wrap_lines(&lines, 6);
assert!(vls.len() >= 3);
assert_eq!(vls[0].logical_line, 0);
assert_eq!(vls.last().unwrap().logical_line, 1);
}
#[test]
fn gutter_width_small() {
assert_eq!(gutter_width(1), 3);
assert_eq!(gutter_width(9), 3);
assert_eq!(gutter_width(10), 3);
assert_eq!(gutter_width(99), 3);
assert_eq!(gutter_width(100), 4);
}
#[test]
fn logical_to_visual_roundtrip() {
let text = "hello world foo bar";
let lines = vec![text];
let vls = wrap_line(text, 6, 0);
let row = logical_to_visual_row(&vls, 0, 0);
assert_eq!(row, 0);
let (line, col) = visual_row_to_logical(&vls, row, 0, &lines);
assert_eq!(line, 0);
assert_eq!(col, 0);
}
#[test]
fn fill_heuristic_50_percent() {
let vls = wrap_line("abcd xxxxxxxxxx", 10, 0);
assert!(vls.len() >= 2);
}
#[test]
fn tab_counts_as_four() {
let vls = wrap_line("\thello", 10, 0);
assert_eq!(vls.len(), 1);
let vls = wrap_line("\thello", 8, 0);
assert!(vls.len() >= 2);
}
#[test]
fn visual_col_computation() {
let text = "hello world";
let lines = vec![text];
let vls = wrap_line(text, 6, 0);
let vcol = logical_to_visual_col(&vls, 0, 6, &lines);
assert_eq!(vcol, 0);
let vcol = logical_to_visual_col(&vls, 0, 8, &lines);
assert_eq!(vcol, 2);
}
#[test]
fn wrap_cjk() {
let vls = wrap_line("你好世界", 5, 0);
assert_eq!(vls.len(), 2);
let first = &"你好世界"[vls[0].byte_start..vls[0].byte_end];
assert_eq!(first, "你好");
}
#[test]
fn wrap_emoji() {
let s = "🎉🚀💫✨";
let vls = wrap_line(s, 5, 0);
assert_eq!(vls.len(), 2);
let first = &s[vls[0].byte_start..vls[0].byte_end];
assert_eq!(unicode::display_width(first), 4); }
#[test]
fn wrap_never_breaks_grapheme() {
let s = "cafe\u{0301} is good"; let vls = wrap_line(s, 6, 0);
for vl in &vls {
let text = &s[vl.byte_start..vl.byte_end];
if let Some(first_char) = text.chars().next() {
assert!(
unicode_width::UnicodeWidthChar::width(first_char) != Some(0)
|| first_char == '\u{0301}' && text.starts_with("e\u{0301}"),
"Line starts with zero-width character: {:?}",
text
);
}
}
}
#[test]
fn edit_cursor_at_full_width_line_end() {
let lines = vec!["abcdefghij"];
let vls = wrap_lines_for_edit(&lines, 10, 0, 10);
assert_eq!(vls.len(), 2);
assert_eq!(vls[1].byte_start, 10);
assert_eq!(vls[1].byte_end, 10);
assert!(!vls[1].is_first);
let row = logical_to_visual_row(&vls, 0, 10);
assert_eq!(row, 1);
}
#[test]
fn edit_cursor_mid_line_no_extra_vl() {
let lines = vec!["abcdefghij"];
let vls = wrap_lines_for_edit(&lines, 10, 0, 5);
assert_eq!(vls.len(), 1);
}
#[test]
fn edit_cursor_at_short_line_end_no_extra_vl() {
let lines = vec!["hello"];
let vls = wrap_lines_for_edit(&lines, 10, 0, 5);
assert_eq!(vls.len(), 1);
}
#[test]
fn edit_cursor_at_end_of_wrapped_full_width_row() {
let lines = vec!["abcdefghijklmnopqrst"];
let vls = wrap_lines_for_edit(&lines, 10, 0, 20);
assert_eq!(vls.len(), 3);
let row = logical_to_visual_row(&vls, 0, 20);
assert_eq!(row, 2);
}
#[test]
fn edit_cursor_on_consumed_whitespace() {
let lines = vec!["abcdefghij k"];
let vls_no_edit = wrap_lines(&lines, 10);
let vls = wrap_lines_for_edit(&lines, 10, 0, 10);
assert_eq!(vls.len(), vls_no_edit.len());
assert_eq!(vls[1].byte_start, 10);
let text = &"abcdefghij k"[vls[1].byte_start..vls[1].byte_end];
assert_eq!(text, " k");
}
#[test]
fn edit_trailing_spaces_visible() {
let lines = vec!["abcdefghij "];
let vls = wrap_lines_for_edit(&lines, 10, 0, 13);
assert_eq!(vls.len(), 2);
let text = &"abcdefghij "[vls[1].byte_start..vls[1].byte_end];
assert_eq!(text, " ");
}
#[test]
fn edit_space_cursor_advances() {
let lines = vec!["abcdefghij "];
let vls = wrap_lines_for_edit(&lines, 10, 0, 11);
let row = logical_to_visual_row(&vls, 0, 11);
assert_eq!(row, 1);
let vcol = logical_to_visual_col(&vls, 0, 11, &lines);
assert_eq!(vcol, 1);
}
#[test]
fn edit_space_cursor_no_skip() {
let lines1 = vec!["abcdefghi "];
let vls1 = wrap_lines_for_edit(&lines1, 10, 0, 10);
let vcol1 = logical_to_visual_col(&vls1, 0, 10, &lines1);
assert_eq!(vcol1, 0);
let lines2 = vec!["abcdefghi "];
let vls2 = wrap_lines_for_edit(&lines2, 10, 0, 11);
let vcol2 = logical_to_visual_col(&vls2, 0, 11, &lines2);
assert_eq!(vcol2, 1); }
#[test]
fn edit_no_gap_fill_across_logical_lines() {
let lines = vec!["abcdefghij", "hello"];
let vls = wrap_lines_for_edit(&lines, 10, 0, 0);
assert_eq!(vls[0].logical_line, 0);
assert_eq!(vls[1].logical_line, 1);
assert_eq!(vls[1].byte_start, 0);
}
}