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/// Returns the index into `segments` whose `[start, end)` covers
101/// `col`. The past-end cursor (`col == last segment's end`) maps to
102/// the last segment, matching vim's "EOL on the visual row that
103/// holds the line's last char" behaviour.
104pub fn segment_for_col(segments: &[(usize, usize)], col: usize) -> usize {
105    if segments.is_empty() {
106        return 0;
107    }
108    if let Some(idx) = segments.iter().position(|&(s, e)| col >= s && col < e) {
109        return idx;
110    }
111    segments.len() - 1
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn none_returns_full_line_segment() {
120        let segs = wrap_segments("hello world", 4, Wrap::None);
121        assert_eq!(segs, vec![(0, 11)]);
122    }
123
124    #[test]
125    fn wide_char_wider_than_width_terminates() {
126        // Regression: a double-width char in a 1-cell area used to spin forever
127        // (i == start → break_at == start → start never advances → OOM). Each
128        // wide char must become its own overflowing segment and progress.
129        let segs = wrap_segments("你好", 1, Wrap::Char);
130        assert_eq!(segs, vec![(0, 1), (1, 2)]);
131        let segs = wrap_segments("你好", 1, Wrap::Word);
132        assert_eq!(segs, vec![(0, 1), (1, 2)]);
133        // Mixed narrow + wide with a 1-cell width still fully covers the line.
134        let segs = wrap_segments("a你b", 1, Wrap::Char);
135        assert_eq!(segs, vec![(0, 1), (1, 2), (2, 3)]);
136    }
137
138    #[test]
139    fn segment_for_col_finds_containing_segment() {
140        let segs = vec![(0, 4), (4, 8), (8, 10)];
141        assert_eq!(segment_for_col(&segs, 0), 0);
142        assert_eq!(segment_for_col(&segs, 3), 0);
143        assert_eq!(segment_for_col(&segs, 4), 1);
144        assert_eq!(segment_for_col(&segs, 7), 1);
145        assert_eq!(segment_for_col(&segs, 9), 2);
146        // Past-end col clamps to last segment.
147        assert_eq!(segment_for_col(&segs, 10), 2);
148        assert_eq!(segment_for_col(&segs, 99), 2);
149    }
150}