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 std::cell::RefCell;
5
6use unicode_width::UnicodeWidthChar;
7
8thread_local! {
9    /// Reused `(char, width)` scratch for [`wrap_segments`]. `wrap_segments` is
10    /// called once per visible row per frame; reusing this buffer avoids a
11    /// per-call heap allocation (it grows to the widest line seen, then stays).
12    static WRAP_SCRATCH: RefCell<Vec<(char, u16)>> = const { RefCell::new(Vec::new()) };
13}
14
15/// Soft-wrap mode controlling how doc rows wider than the text area
16/// turn into multiple visual rows. Default is [`Wrap::None`] — every
17/// doc row is exactly one screen row and `top_col` clips the left
18/// side, mirroring vim's `set nowrap` default for sqeel today.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum Wrap {
21    /// Single screen row per doc row; clip with `top_col`.
22    #[default]
23    None,
24    /// Break at the cell boundary regardless of word edges.
25    Char,
26    /// Break at the last whitespace inside the visible width when
27    /// possible; falls back to a char break for runs longer than the
28    /// width.
29    Word,
30}
31
32/// Split `line` into char-index segments `[start, end)` such that
33/// each segment's display width fits within `width` cells.
34/// `Wrap::Word` rewinds to the last whitespace inside the candidate
35/// segment when a break would otherwise split a word; falls through
36/// to a char break for runs longer than `width`. `Wrap::None` is not
37/// expected here — callers branch before calling — but is handled
38/// for completeness as a single segment covering the full line.
39pub fn wrap_segments(line: &str, width: u16, mode: Wrap) -> Vec<(usize, usize)> {
40    if matches!(mode, Wrap::None) || width == 0 || line.is_empty() {
41        return vec![(0, line.chars().count())];
42    }
43    WRAP_SCRATCH.with(|scratch| {
44        let mut chars = scratch.borrow_mut();
45        chars.clear();
46        chars.extend(
47            line.chars()
48                .map(|c| (c, c.width().unwrap_or(1).max(1) as u16)),
49        );
50        let total = chars.len();
51        let mut segs = Vec::new();
52        let mut start = 0usize;
53        while start < total {
54            let mut cells: u16 = 0;
55            let mut i = start;
56            while i < total {
57                let w = chars[i].1;
58                if cells + w > width {
59                    break;
60                }
61                cells += w;
62                i += 1;
63            }
64            // A single char wider than `width` (e.g. a double-width CJK/emoji
65            // char in a 1-cell text area) consumes zero cells above, leaving
66            // `i == start`. Force progress by emitting it as its own
67            // overflowing segment; without this `break_at` collapses to
68            // `start`, `start` never advances, and the loop spins forever
69            // pushing `(start, start)` until the process OOMs.
70            if i == start {
71                i = start + 1;
72            }
73            if i == total {
74                segs.push((start, total));
75                break;
76            }
77            let break_at = if matches!(mode, Wrap::Word) {
78                // Look for the last whitespace inside [start, i] so the
79                // segment ends *after* that whitespace. Falls back to a
80                // hard char break when the segment has no whitespace.
81                (start..i)
82                    .rev()
83                    .find(|&k| chars[k].0.is_whitespace())
84                    .map(|k| k + 1)
85                    .filter(|&end| end > start)
86                    .unwrap_or(i)
87            } else {
88                i
89            };
90            segs.push((start, break_at));
91            start = break_at;
92        }
93        if segs.is_empty() {
94            segs.push((0, 0));
95        }
96        segs
97    })
98}
99
100/// Inverse of the per-char accounting `wrap_segments` uses to find where a
101/// segment breaks: map a visual x offset (`visual_offset`, cells counted
102/// from the segment's OWN left edge — i.e. 0 at `seg.0`, matching how the
103/// renderer paints each wrapped row starting at its text area's left
104/// column regardless of `seg.0`) to the char index within `seg = [start,
105/// end)` it lands on.
106///
107/// Uses the exact same per-char width formula `wrap_segments` sums to find
108/// segment boundaries (`char::width().unwrap_or(1).max(1)`), so a click
109/// that `wrap_segments` would consider inside this segment resolves to the
110/// same character `wrap_segments` wrapped around. This does NOT reproduce
111/// the renderer's real tab-stop expansion (`wrap_segments` itself counts a
112/// tab as 1 cell, not `tabstop` cells) — that mismatch predates this
113/// function and is `wrap_segments`' own documented simplification; mouse
114/// click mapping mirrors it for round-trip consistency with the wrap
115/// engine rather than inventing a different tab model for wrapped rows.
116///
117/// Clamps to `end` (one past the segment's last char) once cumulative
118/// width reaches or exceeds `visual_offset` — vim's "past EOL on this
119/// visual row" landing spot.
120pub fn char_col_for_visual_offset(line: &str, seg: (usize, usize), visual_offset: usize) -> usize {
121    let (start, end) = seg;
122    let mut cells = 0usize;
123    for (i, ch) in line
124        .chars()
125        .enumerate()
126        .skip(start)
127        .take(end.saturating_sub(start))
128    {
129        let w = ch.width().unwrap_or(1).max(1);
130        if cells + w > visual_offset {
131            return i;
132        }
133        cells += w;
134    }
135    end
136}
137
138/// Forward companion to [`char_col_for_visual_offset`]: map a char column
139/// (within a segment starting at `seg_start`) to its visual x offset, in
140/// cells counted from the segment's OWN left edge — the exact inverse
141/// relationship, using the same per-char width formula. `char_col` should
142/// be `>= seg_start` (typically chosen via [`segment_for_col`] first); a
143/// `char_col` before `seg_start` is treated as `seg_start` (offset 0).
144pub fn visual_offset_for_char_col(line: &str, seg_start: usize, char_col: usize) -> usize {
145    if char_col <= seg_start {
146        return 0;
147    }
148    line.chars()
149        .enumerate()
150        .skip(seg_start)
151        .take(char_col - seg_start)
152        .map(|(_, c)| c.width().unwrap_or(1).max(1))
153        .sum()
154}
155
156/// Returns the index into `segments` whose `[start, end)` covers
157/// `col`. The past-end cursor (`col == last segment's end`) maps to
158/// the last segment, matching vim's "EOL on the visual row that
159/// holds the line's last char" behaviour.
160pub fn segment_for_col(segments: &[(usize, usize)], col: usize) -> usize {
161    if segments.is_empty() {
162        return 0;
163    }
164    if let Some(idx) = segments.iter().position(|&(s, e)| col >= s && col < e) {
165        return idx;
166    }
167    segments.len() - 1
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn none_returns_full_line_segment() {
176        let segs = wrap_segments("hello world", 4, Wrap::None);
177        assert_eq!(segs, vec![(0, 11)]);
178    }
179
180    #[test]
181    fn wide_char_wider_than_width_terminates() {
182        // Regression: a double-width char in a 1-cell area used to spin forever
183        // (i == start → break_at == start → start never advances → OOM). Each
184        // wide char must become its own overflowing segment and progress.
185        let segs = wrap_segments("你好", 1, Wrap::Char);
186        assert_eq!(segs, vec![(0, 1), (1, 2)]);
187        let segs = wrap_segments("你好", 1, Wrap::Word);
188        assert_eq!(segs, vec![(0, 1), (1, 2)]);
189        // Mixed narrow + wide with a 1-cell width still fully covers the line.
190        let segs = wrap_segments("a你b", 1, Wrap::Char);
191        assert_eq!(segs, vec![(0, 1), (1, 2), (2, 3)]);
192    }
193
194    #[test]
195    fn segment_for_col_finds_containing_segment() {
196        let segs = vec![(0, 4), (4, 8), (8, 10)];
197        assert_eq!(segment_for_col(&segs, 0), 0);
198        assert_eq!(segment_for_col(&segs, 3), 0);
199        assert_eq!(segment_for_col(&segs, 4), 1);
200        assert_eq!(segment_for_col(&segs, 7), 1);
201        assert_eq!(segment_for_col(&segs, 9), 2);
202        // Past-end col clamps to last segment.
203        assert_eq!(segment_for_col(&segs, 10), 2);
204        assert_eq!(segment_for_col(&segs, 99), 2);
205    }
206
207    // ── char_col_for_visual_offset (Fix 3: cell_to_doc soft-wrap inverse) ──
208
209    #[test]
210    fn char_col_for_visual_offset_first_segment_start() {
211        // "abcdefghij" @ width=4, Char wrap → segs (0,4)(4,8)(8,10).
212        let line = "abcdefghij";
213        let segs = wrap_segments(line, 4, Wrap::Char);
214        assert_eq!(segs, vec![(0, 4), (4, 8), (8, 10)]);
215        // Offset 0 in segment 0 → char 'a' (index 0).
216        assert_eq!(char_col_for_visual_offset(line, segs[0], 0), 0);
217        // Offset 3 in segment 0 → char 'd' (index 3, last char of the segment).
218        assert_eq!(char_col_for_visual_offset(line, segs[0], 3), 3);
219    }
220
221    #[test]
222    fn char_col_for_visual_offset_is_relative_to_segment_start_not_line_start() {
223        // A click on the SECOND visual row (segment 1, chars [4,8) = "efgh")
224        // at offset 0 must resolve to char index 4 ('e'), not 0 ('a') — the
225        // whole point of Fix 3: continuation-row clicks must not collapse
226        // back to the start of the line.
227        let line = "abcdefghij";
228        let segs = wrap_segments(line, 4, Wrap::Char);
229        assert_eq!(
230            char_col_for_visual_offset(line, segs[1], 0),
231            4,
232            "offset 0 within segment 1 must land on 'e' (char index 4), not the line start"
233        );
234        assert_eq!(
235            char_col_for_visual_offset(line, segs[1], 2),
236            6,
237            "offset 2 within segment 1 must land on 'g' (char index 6)"
238        );
239    }
240
241    #[test]
242    fn char_col_for_visual_offset_past_segment_end_clamps() {
243        let line = "abcdefghij";
244        let segs = wrap_segments(line, 4, Wrap::Char);
245        // Offset past the segment's width clamps to `end` (one past the
246        // segment's last char) — vim's past-EOL-on-this-row landing spot.
247        assert_eq!(char_col_for_visual_offset(line, segs[0], 99), segs[0].1);
248        assert_eq!(char_col_for_visual_offset(line, segs[2], 99), segs[2].1);
249    }
250
251    #[test]
252    fn char_col_for_visual_offset_wide_char_consumes_two_cells() {
253        // "你好" @ width=1, Char wrap → segs (0,1)(1,2), one wide char each.
254        let line = "你好";
255        let segs = wrap_segments(line, 1, Wrap::Char);
256        assert_eq!(segs, vec![(0, 1), (1, 2)]);
257        assert_eq!(char_col_for_visual_offset(line, segs[0], 0), 0);
258        assert_eq!(char_col_for_visual_offset(line, segs[1], 0), 1);
259    }
260
261    #[test]
262    fn char_col_for_visual_offset_round_trips_with_wrap_segments() {
263        // For every segment, every offset inside [0, segment_display_width)
264        // must resolve to a char whose OWN segment (per `segment_for_col`)
265        // is the same segment — i.e. this never "escapes" into a
266        // neighboring segment's characters.
267        let line = "the quick brown fox jumps over lazy dogs";
268        for width in [3u16, 5, 8, 12] {
269            let segs = wrap_segments(line, width, Wrap::Word);
270            for (idx, &seg) in segs.iter().enumerate() {
271                let seg_width: usize = line
272                    .chars()
273                    .skip(seg.0)
274                    .take(seg.1 - seg.0)
275                    .map(|c| c.width().unwrap_or(1).max(1))
276                    .sum();
277                for off in 0..seg_width {
278                    let col = char_col_for_visual_offset(line, seg, off);
279                    assert_eq!(
280                        segment_for_col(&segs, col),
281                        idx,
282                        "width={width} seg={seg:?} off={off} resolved to char {col} \
283                         which segment_for_col assigns to a different segment"
284                    );
285                }
286            }
287        }
288    }
289
290    // ── visual_offset_for_char_col (Fix 4: doc_to_cell wrap inverse) ───────
291
292    #[test]
293    fn visual_offset_for_char_col_at_segment_start_is_zero() {
294        let line = "abcdefghij";
295        let segs = wrap_segments(line, 4, Wrap::Char);
296        assert_eq!(visual_offset_for_char_col(line, segs[1].0, segs[1].0), 0);
297    }
298
299    #[test]
300    fn visual_offset_for_char_col_is_relative_to_segment_start() {
301        // char 6 ('g') is inside segment 1 ([4,8)); its offset from THAT
302        // segment's own left edge is 2, not 6 (offset from the line start).
303        let line = "abcdefghij";
304        let segs = wrap_segments(line, 4, Wrap::Char);
305        assert_eq!(visual_offset_for_char_col(line, segs[1].0, 6), 2);
306    }
307
308    #[test]
309    fn visual_offset_for_char_col_before_seg_start_clamps_to_zero() {
310        let line = "abcdefghij";
311        let segs = wrap_segments(line, 4, Wrap::Char);
312        assert_eq!(visual_offset_for_char_col(line, segs[1].0, 0), 0);
313    }
314
315    #[test]
316    fn visual_offset_and_char_col_for_visual_offset_round_trip() {
317        // For every segment, every char inside it must round-trip through
318        // visual_offset_for_char_col → char_col_for_visual_offset back to
319        // itself — the two are exact inverses of each other.
320        let line = "the quick brown fox jumps over lazy dogs";
321        for width in [3u16, 5, 8, 12] {
322            let segs = wrap_segments(line, width, Wrap::Word);
323            for &seg in &segs {
324                for col in seg.0..seg.1 {
325                    let off = visual_offset_for_char_col(line, seg.0, col);
326                    let back = char_col_for_visual_offset(line, seg, off);
327                    assert_eq!(
328                        back, col,
329                        "seg={seg:?} col={col} → offset {off} → back {back}, \
330                         expected round-trip to {col}"
331                    );
332                }
333            }
334        }
335    }
336}