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}