Skip to main content

hjkl_buffer/
wrap.rs

1//! Soft-wrap helpers shared between the renderer, viewport scroll,
2//! and the buffer's vertical motion code.
3
4use unicode_width::UnicodeWidthChar;
5
6/// Soft-wrap mode controlling how doc rows wider than the text area
7/// turn into multiple visual rows. Default is [`Wrap::None`] — every
8/// doc row is exactly one screen row and `top_col` clips the left
9/// side, mirroring vim's `set nowrap` default for sqeel today.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum Wrap {
12    /// Single screen row per doc row; clip with `top_col`.
13    #[default]
14    None,
15    /// Break at the cell boundary regardless of word edges.
16    Char,
17    /// Break at the last whitespace inside the visible width when
18    /// possible; falls back to a char break for runs longer than the
19    /// width.
20    Word,
21}
22
23/// Split `line` into char-index segments `[start, end)` such that
24/// each segment's display width fits within `width` cells.
25/// `Wrap::Word` rewinds to the last whitespace inside the candidate
26/// segment when a break would otherwise split a word; falls through
27/// to a char break for runs longer than `width`. `Wrap::None` is not
28/// expected here — callers branch before calling — but is handled
29/// for completeness as a single segment covering the full line.
30pub fn wrap_segments(line: &str, width: u16, mode: Wrap) -> Vec<(usize, usize)> {
31    let total = line.chars().count();
32    if matches!(mode, Wrap::None) || width == 0 || line.is_empty() {
33        return vec![(0, total)];
34    }
35    let chars: Vec<(char, u16)> = line
36        .chars()
37        .map(|c| (c, c.width().unwrap_or(1).max(1) as u16))
38        .collect();
39    let mut segs = Vec::new();
40    let mut start = 0usize;
41    while start < total {
42        let mut cells: u16 = 0;
43        let mut i = start;
44        while i < total {
45            let w = chars[i].1;
46            if cells + w > width {
47                break;
48            }
49            cells += w;
50            i += 1;
51        }
52        if i == total {
53            segs.push((start, total));
54            break;
55        }
56        let break_at = if matches!(mode, Wrap::Word) {
57            // Look for the last whitespace inside [start, i] so the
58            // segment ends *after* that whitespace. Falls back to a
59            // hard char break when the segment has no whitespace.
60            (start..i)
61                .rev()
62                .find(|&k| chars[k].0.is_whitespace())
63                .map(|k| k + 1)
64                .filter(|&end| end > start)
65                .unwrap_or(i)
66        } else {
67            i
68        };
69        segs.push((start, break_at));
70        start = break_at;
71    }
72    if segs.is_empty() {
73        segs.push((0, 0));
74    }
75    segs
76}
77
78/// Returns the index into `segments` whose `[start, end)` covers
79/// `col`. The past-end cursor (`col == last segment's end`) maps to
80/// the last segment, matching vim's "EOL on the visual row that
81/// holds the line's last char" behaviour.
82pub fn segment_for_col(segments: &[(usize, usize)], col: usize) -> usize {
83    if segments.is_empty() {
84        return 0;
85    }
86    if let Some(idx) = segments.iter().position(|&(s, e)| col >= s && col < e) {
87        return idx;
88    }
89    segments.len() - 1
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn none_returns_full_line_segment() {
98        let segs = wrap_segments("hello world", 4, Wrap::None);
99        assert_eq!(segs, vec![(0, 11)]);
100    }
101
102    #[test]
103    fn segment_for_col_finds_containing_segment() {
104        let segs = vec![(0, 4), (4, 8), (8, 10)];
105        assert_eq!(segment_for_col(&segs, 0), 0);
106        assert_eq!(segment_for_col(&segs, 3), 0);
107        assert_eq!(segment_for_col(&segs, 4), 1);
108        assert_eq!(segment_for_col(&segs, 7), 1);
109        assert_eq!(segment_for_col(&segs, 9), 2);
110        // Past-end col clamps to last segment.
111        assert_eq!(segment_for_col(&segs, 10), 2);
112        assert_eq!(segment_for_col(&segs, 99), 2);
113    }
114}