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}