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}