Skip to main content

kimun_notes/components/text_editor/
word_wrap.rs

1#[derive(Debug, Clone, PartialEq)]
2pub struct VisualLine {
3    pub logical_row: usize,
4    /// Character offset (Unicode scalar) where this visual line begins in the original line.
5    pub start_col: usize,
6    /// Character offset (exclusive) where this visual line ends.
7    pub end_col: usize,
8    /// Byte offset in the original logical line where this visual line begins.
9    pub start_byte: usize,
10    /// Byte offset (exclusive) in the original logical line where this visual line ends.
11    pub end_byte: usize,
12    pub is_first_visual_line: bool,
13}
14
15impl VisualLine {
16    /// Borrow the content slice from the original logical line string.
17    /// This avoids storing a redundant `String` copy on each `VisualLine`.
18    pub fn content<'a>(&self, source: &'a str) -> &'a str {
19        &source[self.start_byte..self.end_byte]
20    }
21}
22
23/// One grapheme cluster's position and metrics within a logical line,
24/// cached in the reuse buffer so `wrap_one_row` breaks only on cluster
25/// boundaries (never mid-cluster) and measures fit by display columns.
26struct Cluster {
27    /// Starting char (Unicode scalar) offset in the logical line.
28    char_pos: usize,
29    /// Starting byte offset in the logical line.
30    byte_pos: usize,
31    /// Display-column width of the cluster, before visibility is applied.
32    width: usize,
33    /// True when the cluster is a single whitespace scalar (a wrap
34    /// opportunity). Multi-scalar clusters are never whitespace.
35    is_ws: bool,
36}
37
38/// Wrap a single logical row at the given width, appending the
39/// produced `VisualLine`s to `out` (always at least one entry).
40///
41/// Breaks land only on grapheme-cluster boundaries: a multi-codepoint
42/// cluster (ZWJ emoji, combining-mark sequence) is never split across
43/// two visual lines, so the byte slice each `VisualLine` borrows always
44/// reclusters identically to the full line (the renderer in
45/// `spanner.rs` walks `content.graphemes(true)`). Fit is measured in
46/// display columns via [`cluster_display_width`], so wide CJK clusters
47/// count as 2 and zero-width combining marks as 0 — matching the
48/// renderer instead of the old one-column-per-scalar count.
49///
50/// `rendered_row` is the per-char rendered mask for this row (empty
51/// slice if absent — every char treated as visible). A hidden cluster
52/// (markdown sigil) contributes 0 columns.
53///
54/// `scratch` is reused for the row's per-cluster buffer. Caller owns
55/// it; the function clears+refills on entry. Threading this buffer
56/// through `compute` / `splice_range` lets a 5000-row recompute reuse a
57/// single allocation instead of N transient `Vec`s — perf #11 in the
58/// holistic review.
59fn wrap_one_row(
60    logical_row: usize,
61    line: &str,
62    width: usize,
63    inset: usize,
64    rendered_row: &[bool],
65    scratch: &mut Vec<Cluster>,
66    out: &mut Vec<VisualLine>,
67) {
68    use unicode_segmentation::UnicodeSegmentation;
69
70    // Reduce the available wrap width by the per-row left gutter (inset).
71    // When there is a gutter, `.max(1)` keeps forward progress on tiny panes.
72    // A genuine width==0 pane (inset 0) is left at 0 so it still hits the
73    // degenerate single-empty-line guard below.
74    let width = if inset == 0 {
75        width
76    } else {
77        width.saturating_sub(inset).max(1)
78    };
79
80    scratch.clear();
81    let mut char_pos = 0usize;
82    for (byte_pos, g) in line.grapheme_indices(true) {
83        let char_len = g.chars().count();
84        let is_ws = char_len == 1 && g.chars().next().is_some_and(char::is_whitespace);
85        scratch.push(Cluster {
86            char_pos,
87            byte_pos,
88            width: super::markdown::cluster_display_width(g),
89            is_ws,
90        });
91        char_pos += char_len;
92    }
93    let total_chars = char_pos;
94    let cl: &[Cluster] = scratch.as_slice();
95    if cl.is_empty() || width == 0 {
96        out.push(VisualLine {
97            logical_row,
98            start_col: 0,
99            end_col: 0,
100            start_byte: 0,
101            end_byte: 0,
102            is_first_visual_line: true,
103        });
104        return;
105    }
106
107    let is_rendered = |char_pos: usize| -> bool {
108        if char_pos < rendered_row.len() {
109            rendered_row[char_pos]
110        } else {
111            true
112        }
113    };
114    // Cluster's display width with visibility applied (hidden → 0).
115    let vis_width = |idx: usize| -> usize {
116        if is_rendered(cl[idx].char_pos) {
117            cl[idx].width
118        } else {
119            0
120        }
121    };
122    // Char / byte offset at a cluster index (or the line end past it).
123    let char_at = |idx: usize| -> usize {
124        if idx < cl.len() {
125            cl[idx].char_pos
126        } else {
127            total_chars
128        }
129    };
130    let byte_at = |idx: usize| -> usize {
131        if idx < cl.len() {
132            cl[idx].byte_pos
133        } else {
134            line.len()
135        }
136    };
137
138    let total = cl.len(); // number of clusters
139    let mut start = 0; // cluster index
140    let mut is_first = true;
141
142    while start < total {
143        // Find the first cluster where the column count from `start`
144        // exceeds `width`.
145        let fit_end = {
146            let mut rcount = 0usize;
147            let mut pos = start;
148            while pos < total {
149                let r = vis_width(pos);
150                if rcount + r > width {
151                    break;
152                }
153                rcount += r;
154                pos += 1;
155            }
156            // Guarantee forward progress: a single cluster wider than
157            // `width` (e.g. a width-2 glyph in a width-1 column) must
158            // still advance by one cluster, else the loop never ends.
159            if pos == start { start + 1 } else { pos }
160        };
161
162        if fit_end >= total {
163            out.push(VisualLine {
164                logical_row,
165                start_col: char_at(start),
166                end_col: total_chars,
167                start_byte: byte_at(start),
168                end_byte: line.len(),
169                is_first_visual_line: is_first,
170            });
171            break;
172        }
173
174        // Find break point: prefer last whitespace cluster in [start..fit_end].
175        let (content_end, next_start) = if cl[fit_end].is_ws {
176            (fit_end, fit_end + 1)
177        } else {
178            match cl[start..fit_end]
179                .iter()
180                .enumerate()
181                .rev()
182                .find(|(_, c)| c.is_ws)
183            {
184                Some((i, _)) => (start + i, start + i + 1),
185                None => (fit_end, fit_end), // hard break (mid-word, on a cluster boundary)
186            }
187        };
188
189        out.push(VisualLine {
190            logical_row,
191            start_col: char_at(start),
192            end_col: char_at(content_end),
193            start_byte: byte_at(start),
194            end_byte: byte_at(content_end),
195            is_first_visual_line: is_first,
196        });
197        start = next_start;
198        is_first = false;
199    }
200}
201
202#[derive(Clone)]
203pub struct WordWrapLayout {
204    visual_lines: Vec<VisualLine>,
205    /// Maps logical row index → index of its first `VisualLine` in `visual_lines`.
206    /// Enables O(wrap-count) lookup in `logical_to_visual` instead of O(total visual lines).
207    row_starts: Vec<usize>,
208}
209
210impl WordWrapLayout {
211    /// Compute word-wrap layout.
212    /// `rendered`: per-line bitmask of which char positions are actually rendered (visible).
213    /// Pass `&[]` to use raw char widths (e.g. in tests that don't involve markdown).
214    pub fn compute(lines: &[String], width: u16, rendered: &[Vec<bool>], insets: &[usize]) -> Self {
215        let width = width as usize;
216        let mut visual_lines = Vec::new();
217        let mut row_starts = Vec::with_capacity(lines.len());
218
219        if lines.is_empty() {
220            return Self::default();
221        }
222
223        // One scratch buffer reused across every `wrap_one_row` call —
224        // a 5000-row recompute pays a single allocation instead of N.
225        let mut scratch: Vec<Cluster> = Vec::new();
226        for (row, line) in lines.iter().enumerate() {
227            row_starts.push(visual_lines.len());
228            let rendered_row = rendered.get(row).map(|v| v.as_slice()).unwrap_or(&[]);
229            let inset = insets.get(row).copied().unwrap_or(0);
230            wrap_one_row(
231                row,
232                line,
233                width,
234                inset,
235                rendered_row,
236                &mut scratch,
237                &mut visual_lines,
238            );
239        }
240
241        Self {
242            visual_lines,
243            row_starts,
244        }
245    }
246
247    /// Re-wrap only the rows in `row_range`, splicing the result into
248    /// `visual_lines` and updating `row_starts` accordingly.
249    ///
250    /// **Contract:** caller must pass the SAME `lines` and `width` as the
251    /// most recent `compute` call (or previous `splice_range`); only rows
252    /// in `row_range` are assumed to have changed. Other rows' content,
253    /// width, and rendered masks must be byte-identical.
254    ///
255    /// `row_range` is half-open in logical-row space. Empty ranges are
256    /// a no-op.
257    pub fn splice_range(
258        &mut self,
259        lines: &[String],
260        width: u16,
261        rendered: &[Vec<bool>],
262        insets: &[usize],
263        row_range: std::ops::Range<usize>,
264    ) {
265        if row_range.is_empty() {
266            return;
267        }
268        let width = width as usize;
269        debug_assert!(
270            row_range.end <= lines.len(),
271            "splice_range: row_range.end {} > lines.len() {}",
272            row_range.end,
273            lines.len(),
274        );
275        debug_assert!(
276            row_range.start <= self.row_starts.len(),
277            "splice_range: row_range.start {} > row_starts.len() {}",
278            row_range.start,
279            self.row_starts.len(),
280        );
281
282        // Compute the old visual-line index span for this row range.
283        let old_vstart = self.row_starts[row_range.start];
284        let old_vend = if row_range.end < self.row_starts.len() {
285            self.row_starts[row_range.end]
286        } else {
287            self.visual_lines.len()
288        };
289
290        // Wrap the new contents of the affected rows. Also record per-row
291        // starting indices inside the new slice so we can rebuild
292        // row_starts[row_range] without searching. One scratch buffer
293        // shared across every row in the range (perf #11).
294        let mut new_slice: Vec<VisualLine> = Vec::new();
295        let mut new_row_starts_for_range: Vec<usize> = Vec::with_capacity(row_range.len());
296        let mut scratch: Vec<Cluster> = Vec::new();
297        for row in row_range.clone() {
298            new_row_starts_for_range.push(new_slice.len());
299            let rendered_row = rendered.get(row).map(|v| v.as_slice()).unwrap_or(&[]);
300            let inset = insets.get(row).copied().unwrap_or(0);
301            wrap_one_row(
302                row,
303                &lines[row],
304                width,
305                inset,
306                rendered_row,
307                &mut scratch,
308                &mut new_slice,
309            );
310        }
311
312        // Splice visual_lines.
313        let new_vcount = new_slice.len();
314        self.visual_lines.splice(old_vstart..old_vend, new_slice);
315
316        // Shift row_starts for the range and for rows after the range.
317        let old_vcount = old_vend - old_vstart;
318        let delta_i = new_vcount as isize - old_vcount as isize;
319
320        // Update row_starts within the spliced range (absolute indices).
321        for (i, local_start) in new_row_starts_for_range.into_iter().enumerate() {
322            self.row_starts[row_range.start + i] = old_vstart + local_start;
323        }
324
325        // Shift row_starts for rows AFTER the spliced range.
326        if delta_i != 0 {
327            for rs in &mut self.row_starts[row_range.end..] {
328                *rs = ((*rs as isize) + delta_i) as usize;
329            }
330        }
331    }
332
333    pub fn total_visual_lines(&self) -> usize {
334        self.visual_lines.len()
335    }
336
337    /// Returns the number of logical rows tracked by this layout.
338    /// Used by `view.update` to detect line-count changes without exposing
339    /// `row_starts` directly.
340    pub fn row_starts_len(&self) -> usize {
341        self.row_starts.len()
342    }
343
344    pub fn visual_lines(&self) -> &[VisualLine] {
345        &self.visual_lines
346    }
347
348    /// Convert logical (row, col) to (visual_row, visual_col).
349    pub fn logical_to_visual(&self, row: usize, col: usize) -> (usize, usize) {
350        let row = row.min(self.row_starts.len().saturating_sub(1));
351        let first = self.row_starts.get(row).copied().unwrap_or(0);
352        let vrow = self.visual_lines[first..]
353            .iter()
354            .enumerate()
355            .take_while(|(_, vl)| vl.logical_row == row)
356            .filter(|(_, vl)| vl.start_col <= col)
357            .last()
358            .map(|(i, _)| first + i)
359            .unwrap_or(first);
360        let vl = &self.visual_lines[vrow];
361        (vrow, col.saturating_sub(vl.start_col))
362    }
363
364    /// Convert visual (vrow, vcol) to logical (row, col).
365    pub fn visual_to_logical(&self, vrow: usize, vcol: usize) -> (usize, usize) {
366        let vrow = vrow.min(self.visual_lines.len().saturating_sub(1));
367        let vl = &self.visual_lines[vrow];
368        let col = (vl.start_col + vcol).min(vl.end_col);
369        (vl.logical_row, col)
370    }
371}
372
373impl Default for WordWrapLayout {
374    fn default() -> Self {
375        Self {
376            visual_lines: vec![VisualLine {
377                logical_row: 0,
378                start_col: 0,
379                end_col: 0,
380                start_byte: 0,
381                end_byte: 0,
382                is_first_visual_line: true,
383            }],
384            row_starts: vec![0],
385        }
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    fn ls(s: &str) -> Vec<String> {
394        s.lines().map(str::to_owned).collect()
395    }
396
397    // Helper: get content string for a visual line from its source line.
398    fn content_of<'a>(vl: &VisualLine, source: &'a str) -> &'a str {
399        vl.content(source)
400    }
401
402    #[test]
403    fn left_inset_reduces_effective_wrap_width() {
404        // "aaaa bbbb" at width 9 with no inset → one visual line (9 cols fit).
405        let lines = vec!["aaaa bbbb".to_string()];
406        let no_inset = WordWrapLayout::compute(&lines, 9, &[], &[0]);
407        assert_eq!(no_inset.total_visual_lines(), 1);
408
409        // Same line, width 9, inset 2 → effective width 7 → wraps into 2 rows.
410        let inset = WordWrapLayout::compute(&lines, 9, &[], &[2]);
411        assert_eq!(inset.total_visual_lines(), 2);
412        assert_eq!(content_of(&inset.visual_lines()[0], &lines[0]), "aaaa");
413        assert_eq!(content_of(&inset.visual_lines()[1], &lines[0]), "bbbb");
414    }
415
416    #[test]
417    fn empty_input_produces_one_visual_line() {
418        let layout = WordWrapLayout::compute(&[], 40, &[], &[]);
419        assert_eq!(layout.total_visual_lines(), 1);
420        assert_eq!(layout.visual_lines()[0].logical_row, 0);
421        assert!(layout.visual_lines()[0].is_first_visual_line);
422    }
423
424    #[test]
425    fn empty_string_produces_one_visual_line() {
426        let src = String::new();
427        let layout = WordWrapLayout::compute(std::slice::from_ref(&src), 40, &[], &[]);
428        assert_eq!(layout.total_visual_lines(), 1);
429        assert_eq!(content_of(&layout.visual_lines()[0], &src), "");
430        assert!(layout.visual_lines()[0].is_first_visual_line);
431    }
432
433    #[test]
434    fn short_line_fits_on_one_visual_line() {
435        let lines = ls("hello world");
436        let layout = WordWrapLayout::compute(&lines, 40, &[], &[]);
437        assert_eq!(layout.total_visual_lines(), 1);
438        assert_eq!(
439            content_of(&layout.visual_lines()[0], &lines[0]),
440            "hello world"
441        );
442        assert!(layout.visual_lines()[0].is_first_visual_line);
443    }
444
445    #[test]
446    fn long_line_wraps_at_whitespace() {
447        // "hello world foo" width=11 → "hello world" (11) fits; " foo" wraps
448        let lines = ls("hello world foo");
449        let layout = WordWrapLayout::compute(&lines, 11, &[], &[]);
450        assert_eq!(layout.total_visual_lines(), 2);
451        assert_eq!(
452            content_of(&layout.visual_lines()[0], &lines[0]),
453            "hello world"
454        );
455        assert_eq!(content_of(&layout.visual_lines()[1], &lines[0]), "foo");
456        assert!(layout.visual_lines()[0].is_first_visual_line);
457        assert!(!layout.visual_lines()[1].is_first_visual_line);
458    }
459
460    #[test]
461    fn long_word_hard_breaks_at_width() {
462        let lines = vec!["abcdefgh".to_string()];
463        let layout = WordWrapLayout::compute(&lines, 4, &[], &[]);
464        assert_eq!(layout.total_visual_lines(), 2);
465        assert_eq!(content_of(&layout.visual_lines()[0], &lines[0]), "abcd");
466        assert_eq!(content_of(&layout.visual_lines()[1], &lines[0]), "efgh");
467    }
468
469    #[test]
470    fn two_logical_lines_have_correct_logical_rows() {
471        let layout = WordWrapLayout::compute(&ls("abc\nxyz"), 10, &[], &[]);
472        assert_eq!(layout.total_visual_lines(), 2);
473        assert_eq!(layout.visual_lines()[0].logical_row, 0);
474        assert_eq!(layout.visual_lines()[1].logical_row, 1);
475    }
476
477    #[test]
478    fn unicode_chars_counted_not_bytes() {
479        // "あいう" is 3 chars, 9 bytes. Each is a full-width CJK glyph
480        // (2 display columns), so at width=4 two fit per visual line —
481        // the break is by display width, never mid-byte.
482        let lines = vec!["あいう".to_string()];
483        let layout = WordWrapLayout::compute(&lines, 4, &[], &[]);
484        assert_eq!(layout.total_visual_lines(), 2);
485        assert_eq!(content_of(&layout.visual_lines()[0], &lines[0]), "あい");
486        assert_eq!(content_of(&layout.visual_lines()[1], &lines[0]), "う");
487    }
488
489    #[test]
490    fn full_width_glyph_counts_as_two_columns() {
491        // At width=2, a single full-width glyph fills the line on its own.
492        let lines = vec!["あい".to_string()];
493        let layout = WordWrapLayout::compute(&lines, 2, &[], &[]);
494        assert_eq!(layout.total_visual_lines(), 2);
495        assert_eq!(content_of(&layout.visual_lines()[0], &lines[0]), "あ");
496        assert_eq!(content_of(&layout.visual_lines()[1], &lines[0]), "い");
497    }
498
499    #[test]
500    fn multi_codepoint_cluster_never_split() {
501        // "e" + U+0301 (combining acute) = one grapheme cluster, two
502        // scalars, one display column. A narrow width must keep the
503        // cluster intact on one visual line — a mid-cluster break would
504        // leave the renderer reclustering a partial slice (review #3).
505        let combined = "e\u{0301}fg"; // é f g
506        let lines = vec![combined.to_string()];
507        let layout = WordWrapLayout::compute(&lines, 1, &[], &[]);
508        // Width 1: "é" (1 col, 2 scalars) | "f" | "g" → 3 visual lines,
509        // and the first never splits the cluster.
510        assert_eq!(layout.total_visual_lines(), 3);
511        assert_eq!(content_of(&layout.visual_lines()[0], combined), "e\u{0301}");
512        assert_eq!(content_of(&layout.visual_lines()[1], combined), "f");
513        assert_eq!(content_of(&layout.visual_lines()[2], combined), "g");
514    }
515
516    #[test]
517    fn logical_to_visual_start_of_line() {
518        let layout = WordWrapLayout::compute(&ls("hello world"), 40, &[], &[]);
519        assert_eq!(layout.logical_to_visual(0, 0), (0, 0));
520    }
521
522    #[test]
523    fn logical_to_visual_wrapped_cursor() {
524        let layout = WordWrapLayout::compute(&ls("hello world foo"), 11, &[], &[]);
525        let (vrow, vcol) = layout.logical_to_visual(0, 12);
526        assert_eq!(vrow, 1);
527        assert_eq!(vcol, 0);
528    }
529
530    #[test]
531    fn visual_to_logical_first_line() {
532        let layout = WordWrapLayout::compute(&ls("hello"), 40, &[], &[]);
533        assert_eq!(layout.visual_to_logical(0, 3), (0, 3));
534    }
535
536    #[test]
537    fn visual_to_logical_accounts_for_start_col() {
538        let layout = WordWrapLayout::compute(&ls("hello world foo"), 11, &[], &[]);
539        let (row, col) = layout.visual_to_logical(1, 0);
540        assert_eq!(row, 0);
541        assert_eq!(col, 12);
542    }
543
544    #[test]
545    fn row_starts_index_multi_line_multi_wrap() {
546        let lines = vec![
547            "abc".to_string(),
548            "hello world foo".to_string(),
549            "xyz".to_string(),
550        ];
551        let layout = WordWrapLayout::compute(&lines, 11, &[], &[]);
552        assert_eq!(layout.row_starts, vec![0, 1, 3]);
553        assert_eq!(layout.logical_to_visual(2, 0), (3, 0));
554    }
555
556    #[test]
557    fn coordinate_roundtrip_vrow_zero() {
558        let layout = WordWrapLayout::compute(&ls("hello world foo"), 11, &[], &[]);
559        let (row, col) = layout.visual_to_logical(0, 3);
560        let (vrow2, vcol2) = layout.logical_to_visual(row, col);
561        assert_eq!((vrow2, vcol2), (0, 3));
562    }
563
564    #[test]
565    fn byte_offsets_correct_for_unicode() {
566        // "あいう": あ=3 bytes, い=3 bytes, う=3 bytes; each 2 columns.
567        // At width=4 the first visual line holds "あい" (bytes 0..6).
568        let lines = vec!["あいう".to_string()];
569        let layout = WordWrapLayout::compute(&lines, 4, &[], &[]);
570        let vl0 = &layout.visual_lines()[0];
571        let vl1 = &layout.visual_lines()[1];
572        assert_eq!((vl0.start_byte, vl0.end_byte), (0, 6)); // "あい"
573        assert_eq!((vl1.start_byte, vl1.end_byte), (6, 9)); // "う"
574    }
575
576    #[test]
577    fn splice_range_full_buffer_equals_compute() {
578        let lines = ls("hello world\nfoo bar baz\nlast line");
579        let mut layout = WordWrapLayout::compute(&lines, 40, &[], &[]);
580        layout.splice_range(&lines, 40, &[], &[], 0..lines.len());
581        let fresh = WordWrapLayout::compute(&lines, 40, &[], &[]);
582        assert_eq!(layout.visual_lines(), fresh.visual_lines());
583        assert_eq!(layout.row_starts, fresh.row_starts);
584    }
585
586    #[test]
587    fn splice_range_middle_row_only() {
588        // Edit row 1 — splice should only re-wrap row 1.
589        let lines_before = ls("alpha beta\nfoo bar\ngamma delta");
590        let layout_before = WordWrapLayout::compute(&lines_before, 40, &[], &[]);
591
592        let lines_after = ls("alpha beta\nFOO BAR\ngamma delta");
593        let mut layout = layout_before.clone();
594        layout.splice_range(&lines_after, 40, &[], &[], 1..2);
595
596        let fresh = WordWrapLayout::compute(&lines_after, 40, &[], &[]);
597        assert_eq!(layout.visual_lines(), fresh.visual_lines());
598        assert_eq!(layout.row_starts, fresh.row_starts);
599    }
600
601    #[test]
602    fn splice_range_handles_wrap_count_change() {
603        // Row 0: "short" (1 visual line) → "a very long line that will wrap" (2 visual lines at width 10).
604        let lines_before = ls("short\ntail");
605        let mut layout = WordWrapLayout::compute(&lines_before, 10, &[], &[]);
606        let lines_after = ls("a very long line that will wrap\ntail");
607        layout.splice_range(&lines_after, 10, &[], &[], 0..1);
608
609        let fresh = WordWrapLayout::compute(&lines_after, 10, &[], &[]);
610        assert_eq!(layout.visual_lines(), fresh.visual_lines());
611        assert_eq!(layout.row_starts, fresh.row_starts);
612    }
613
614    #[test]
615    fn splice_range_at_buffer_start() {
616        let lines = ls("first line\nsecond line\nthird line");
617        let mut layout = WordWrapLayout::compute(&lines, 40, &[], &[]);
618        let edited = ls("first EDITED line\nsecond line\nthird line");
619        layout.splice_range(&edited, 40, &[], &[], 0..1);
620
621        let fresh = WordWrapLayout::compute(&edited, 40, &[], &[]);
622        assert_eq!(layout.visual_lines(), fresh.visual_lines());
623        assert_eq!(layout.row_starts, fresh.row_starts);
624    }
625
626    #[test]
627    fn splice_range_at_buffer_end() {
628        let lines = ls("first\nsecond\nthird");
629        let mut layout = WordWrapLayout::compute(&lines, 40, &[], &[]);
630        let edited = ls("first\nsecond\nthird EDITED");
631        layout.splice_range(&edited, 40, &[], &[], 2..3);
632
633        let fresh = WordWrapLayout::compute(&edited, 40, &[], &[]);
634        assert_eq!(layout.visual_lines(), fresh.visual_lines());
635        assert_eq!(layout.row_starts, fresh.row_starts);
636    }
637}