Skip to main content

scrive_core/
display_map.rs

1//! The display map: buffer coordinates ↔ *display* coordinates, tab expansion
2//! only.
3//!
4//! This module owns both coordinate spaces and the sole converter between them.
5//! Buffer space is [`Point`] (row + **byte** column); display space is
6//! [`DisplayPoint`] (row + **cell** column, one cell = one Unicode scalar's
7//! monospace advance, a tab = `1..=tab_size` cells). The seam is a concrete
8//! type — [`TabMap`] — not a trait: the one other implementor,
9//! [`crate::fold_map::FoldMap`], *composes over* this surface rather than
10//! sharing a `dyn` bound, and word wrap is rejected (it belongs in a rich-text
11//! editor, not a code editor), so no further implementor is expected.
12//!
13//! What freezes the callable set against the widget crate is not a `dyn` bound
14//! but the `pub(crate)` constructor on [`DisplayPoint`]/[`DisplayRow`]: every
15//! display coordinate in existence came out of a [`TabMap`] method, so no code
16//! in `scrive-iced` can fabricate one — it must go through
17//! [`TabMap::to_display`]/[`TabMap::clip`]. Cross-space arithmetic is a compile
18//! error, which *is* the enforcement of "no widget code does row/column math
19//! across spaces."
20//!
21//! The `TabMap` itself is **row-preserving**: `DisplayRow(r)` ⇔ buffer row
22//! `r`, rows never move, only columns stretch. A caret can never rest inside a
23//! tab expansion — the bias snap in [`collapse`] forbids it. Folding sits as a
24//! layer above ([`crate::fold_map::FoldMap`] hides rows / collapses inline
25//! spans over this base) without changing any signature here; word wrap is
26//! rejected, not deferred.
27
28use std::ops::Range;
29
30use crate::buffer::Buffer;
31use crate::coords::{Bias, Point};
32use crate::patch::Patch;
33
34/// A display row, 0-based. In the tab-only map: `DisplayRow(r)` ⇔ buffer row
35/// `r`.
36///
37/// A distinct newtype because the `FoldMap` layer breaks that 1:1 — a folded
38/// row hides buffer rows beneath it — so every site that would otherwise assume
39/// display row equals buffer row is a compile error to revisit. The field is
40/// `pub(crate)`: consumer crates can *read* a display row
41/// ([`DisplayRow::index`]) but can never **mint** one from arithmetic — every
42/// display row in existence came out of a `FoldMap`/`TabMap` method, so "anchor
43/// something at a buffer row × line height" is a compile error rather than
44/// something to catch by eye. This keeps popups and other row-anchored chrome
45/// correctly offset when a fold sits above them.
46#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
47pub struct DisplayRow(pub(crate) u32);
48
49impl DisplayRow {
50    /// The 0-based row index — read-only; pair with the pixel projection's
51    /// one `row → y` map, never with hand arithmetic.
52    #[must_use]
53    pub const fn index(self) -> u32 {
54        self.0
55    }
56}
57
58/// A buffer row, 0-based — the gutter's *printed* line number.
59///
60/// A **different** newtype from [`DisplayRow`] so "which number do I print for
61/// this display row" can never be fed a display row by accident.
62/// [`TabMap::row_info`] returns `Some` for every row here; the `Option` in its
63/// signature lets a caller cope with a display row that has no printed line
64/// number.
65#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
66pub struct BufferRow(pub u32);
67
68/// A visual position: display row + **cell** column (one cell = one Unicode
69/// scalar's monospace advance; a tab occupies `1..=tab_size` cells).
70///
71/// Fields are private and the constructor is `pub(crate)`: no code in the
72/// *widget* crate can fabricate a display point — it must route through
73/// [`TabMap::to_display`]/[`TabMap::clip`] (the crate boundary is the
74/// enforcement line). Ordered row-major, so display points compare in
75/// visual document order for range and decoration math.
76#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
77pub struct DisplayPoint {
78    row: DisplayRow,
79    col: u32,
80}
81
82impl DisplayPoint {
83    /// A display point at `(row, col)`. `pub(crate)`: the widget crate can never
84    /// mint one directly.
85    pub(crate) fn new(row: DisplayRow, col: u32) -> Self {
86        Self { row, col }
87    }
88
89    /// The visual origin, `(row 0, col 0)`.
90    #[must_use]
91    pub fn zero() -> Self {
92        Self { row: DisplayRow(0), col: 0 }
93    }
94
95    /// The display row.
96    #[must_use]
97    pub fn row(self) -> DisplayRow {
98        self.row
99    }
100
101    /// The cell column.
102    #[must_use]
103    pub fn col(self) -> u32 {
104        self.col
105    }
106}
107
108/// One render run of a display row, split at every tab boundary so the renderer
109/// can draw tab invisibles and overlay highlight spans onto each run.
110#[derive(Clone, Debug)]
111pub struct DisplayChunk<'a> {
112    /// The run's source text. For `is_tab` this is the literal `"\t"`; the
113    /// caller draws `cells` blanks (plus an optional invisible glyph) and never
114    /// paints these bytes.
115    pub text: &'a str,
116    /// Byte range **within the buffer line** — so a per-line `HighlightSpan`
117    /// (also byte-within-line) overlays this run with no coordinate hop.
118    pub buffer_bytes: Range<u32>,
119    /// Width in cells (a tab: [`tab_width`] at the run's start cell).
120    pub cells: u32,
121    /// Whether this run is a single tab (the renderer draws it as blanks).
122    pub is_tab: bool,
123}
124
125/// Lazy per-row splitter yielding one [`DisplayChunk`] per run, tabs isolated.
126/// Constructed by [`chunks`] over a line the CALLER holds — the caller
127/// materializes the row (one [`Buffer::line`] `Cow`) and the runs borrow from
128/// it, so no iterator owns text it also lends out.
129#[derive(Clone, Debug)]
130pub struct DisplayChunks<'a> {
131    line: &'a str,
132    tab_size: u32,
133    byte: u32,
134    cell: u32,
135}
136
137/// Split one row's text into [`DisplayChunk`] runs at every tab boundary.
138#[must_use]
139pub fn chunks(line: &str, tab_size: u32) -> DisplayChunks<'_> {
140    DisplayChunks { line, tab_size, byte: 0, cell: 0 }
141}
142
143impl<'a> Iterator for DisplayChunks<'a> {
144    type Item = DisplayChunk<'a>;
145
146    fn next(&mut self) -> Option<Self::Item> {
147        let bytes = self.line.as_bytes();
148        let len = bytes.len() as u32;
149        if self.byte >= len {
150            return None;
151        }
152        let start = self.byte;
153        if bytes[start as usize] == b'\t' {
154            // A tab is its own run, so the renderer can size the gap exactly.
155            let cells = tab_width(self.cell, self.tab_size);
156            self.byte = start + 1;
157            self.cell += cells;
158            return Some(DisplayChunk {
159                text: &self.line[start as usize..start as usize + 1],
160                buffer_bytes: start..start + 1,
161                cells,
162                is_tab: true,
163            });
164        }
165        // A maximal run of non-tab scalars; each advances exactly one cell.
166        let mut cells = 0u32;
167        for ch in self.line[start as usize..].chars() {
168            if ch == '\t' {
169                break;
170            }
171            self.byte += ch.len_utf8() as u32;
172            cells += 1;
173        }
174        self.cell += cells;
175        Some(DisplayChunk {
176            text: &self.line[start as usize..self.byte as usize],
177            buffer_bytes: start..self.byte,
178            cells,
179            is_tab: false,
180        })
181    }
182}
183
184/// A display-space edit region: what [`TabMap::sync`] would return if a
185/// display-space invalidation reader ever appeared. There is none today — the
186/// `FoldMap` re-derives per frame and word wrap is rejected — so `sync` returns
187/// `()` and downstream caches (line cache, paragraph cache) invalidate off the
188/// buffer-space edit they already consume.
189///
190/// If it is ever produced, `old` ranges the pre-sync snapshot and `new` the
191/// post-sync one, read downstream as a *region to invalidate* rather than a
192/// description of the change. It stays row-preserving: within one line `old`
193/// and `new` share their row and differ only in cell columns; a line
194/// insert/delete shifts whole rows.
195#[derive(Clone, Debug)]
196pub struct DisplayEdit {
197    /// Pre-sync display-space range.
198    pub old: Range<DisplayPoint>,
199    /// Post-sync display-space range.
200    pub new: Range<DisplayPoint>,
201}
202
203/// Expansion width of a tab beginning at 0-based **cell** column `c`: advances
204/// to the next multiple of `tab_size`, always `1..=tab_size`, never zero.
205#[must_use]
206pub fn tab_width(c: u32, tab_size: u32) -> u32 {
207    tab_size - (c % tab_size)
208}
209
210/// Byte column → cell column, a per-line scan from column 0.
211///
212/// Each `\t` advances [`tab_width`]; every other Unicode **scalar** advances one
213/// cell (scalars, not bytes — a stray multibyte char shifts columns, never
214/// corrupts them). `byte_col` past the line length simply expands the whole
215/// line. `O(line)`; at typical short lines this is single-digit µs, so no
216/// sum-tree or cursor structure is needed.
217#[must_use]
218pub fn expand(line: &str, byte_col: u32, tab_size: u32) -> u32 {
219    let mut cell = 0u32;
220    let mut byte = 0u32;
221    for ch in line.chars() {
222        if byte >= byte_col {
223            break; // count only the cells strictly before byte_col
224        }
225        cell += if ch == '\t' { tab_width(cell, tab_size) } else { 1 };
226        byte += ch.len_utf8() as u32;
227    }
228    cell
229}
230
231/// Indentation depth (in guide levels) for one row — the number of indent
232/// guides drawn to its left.
233///
234/// A content row's level is `ceil(indent / indent_size)`, where `own` is its
235/// leading-whitespace width in display cells. A blank row (`own = None`) has no
236/// indent of its own, so its level is interpolated from the nearest non-blank
237/// rows `above` / `below` (their widths in cells): the blank inherits the
238/// *deeper* neighbour's ladder, biased so a dedent still shows the outgoing
239/// level. This is the convention for brace-delimited languages, where
240/// whitespace does not open or close a block. A row at the top/bottom edge of
241/// the file (one neighbour missing) is level 0. The guides drawn are at levels
242/// `1..=level`, i.e. cells `0, indent_size, …, (level-1)*indent_size`.
243#[must_use]
244pub fn indent_guide_level(
245    own: Option<u32>,
246    above: Option<u32>,
247    below: Option<u32>,
248    indent_size: u32,
249) -> u32 {
250    match own {
251        Some(indent) => indent.div_ceil(indent_size),
252        None => match (above, below) {
253            (None, _) | (_, None) => 0,
254            (Some(a), Some(b)) if a < b => 1 + a / indent_size,
255            (Some(a), Some(b)) if a == b => b.div_ceil(indent_size),
256            (Some(_), Some(b)) => 1 + b / indent_size, // a > b: dedenting, brace-delimited
257        },
258    }
259}
260
261/// Which indent guide is *active* (highlighted) for the caret's line.
262///
263/// Returns `(indent_level, start_row, end_row)` inclusive, or `None` when no
264/// guide is active (the caret sits at level 0 with no deeper neighbour).
265/// `level_at(row)` yields a row's indent level (content = `ceil`, blank =
266/// interpolated, matching [`indent_guide_level`]); rows are 0-based,
267/// `line_count` the total.
268///
269/// The scope special-cases are the whole point: on a line that *opens* a deeper
270/// scope (the next line is more indented) the **first child level** is
271/// highlighted — the opener's own level `+ 1`, i.e. one tab stop in from the
272/// opener, regardless of how deep the body actually sits — and symmetrically on
273/// a line that *closes* one (the line above is more indented). Off a brace line
274/// it is just the caret line's level. The chosen level is then extended over
275/// the contiguous run of rows at least that deep.
276///
277/// Pinning the highlight to `initial + 1` (not the body's real level) is
278/// deliberate: an over-indented body (opener at col 4, body at col 16) still
279/// lights the guide one stop inside the opener — cell 4 — rather than the
280/// body's own deep guide. For uniformly-indented code the jump *is* one level,
281/// so `initial + 1` already equals the body level.
282///
283/// `rows` bounds the outward walk: the guide only *paints* inside the caller's
284/// viewport, so the extent search never needs to leave that clamp (the widget
285/// passes its window ± slack; tests pass `0..line_count` for the unbounded
286/// behaviour). A caret row outside `rows` yields `None` — an off-screen caret's
287/// scope highlight is not painted anyway.
288#[must_use]
289pub fn active_indent_guide(
290    caret_row: u32,
291    line_count: u32,
292    rows: Range<u32>,
293    level_at: impl Fn(u32) -> u32,
294) -> Option<(u32, u32, u32)> {
295    let lo = rows.start;
296    let hi = rows.end.min(line_count);
297    if caret_row >= line_count || caret_row < lo || caret_row >= hi {
298        return None;
299    }
300    let initial = level_at(caret_row);
301    let down = (caret_row + 1 < line_count).then(|| level_at(caret_row + 1));
302    let up = caret_row.checked_sub(1).map(&level_at);
303
304    let (indent, mut start, mut end, go_up, go_down) = match (down, up) {
305        // Opener of a deeper scope: activate the first child level (opener + 1).
306        (Some(d), _) if d > initial => (initial + 1, caret_row + 1, caret_row + 1, false, true),
307        // Closer of a deeper scope: same, anchored on the line above.
308        (_, Some(u)) if u > initial => (initial + 1, caret_row - 1, caret_row - 1, true, false),
309        _ if initial == 0 => return None,
310        _ => (initial, caret_row, caret_row, true, true),
311    };
312
313    if go_up {
314        while start > lo && level_at(start - 1) >= indent {
315            start -= 1;
316        }
317    }
318    if go_down {
319        while end + 1 < hi && level_at(end + 1) >= indent {
320            end += 1;
321        }
322    }
323    Some((indent, start, end))
324}
325
326/// Cell column → byte column, with the mid-tab bias snap.
327///
328/// A cell landing strictly inside a tab is not a valid caret slot: `Bias::Left`
329/// yields the byte of the `\t`, `Bias::Right` the byte just after it.
330/// Only a tab has width > 1, so only a tab can trigger the snap. A `cell_col`
331/// past end-of-line clamps to the line's byte length. The result is always a
332/// UTF-8 char boundary.
333#[must_use]
334pub fn collapse(line: &str, cell_col: u32, tab_size: u32, bias: Bias) -> u32 {
335    let mut cell = 0u32;
336    let mut byte = 0u32;
337    for ch in line.chars() {
338        if cell >= cell_col {
339            return byte; // exact hit at a boundary
340        }
341        let w = if ch == '\t' { tab_width(cell, tab_size) } else { 1 };
342        if cell + w > cell_col {
343            // Target falls inside this char's span — only a tab reaches here.
344            return match bias {
345                Bias::Left => byte,
346                Bias::Right => byte + 1,
347            };
348        }
349        cell += w;
350        byte += ch.len_utf8() as u32;
351    }
352    byte // past EOL → line byte length
353}
354
355/// Default tab stop width — 4, the common indent width; a change is one
356/// whole-document edit through [`TabMap::sync`].
357#[must_use]
358pub const fn default_tab_size() -> u32 {
359    4
360}
361
362/// The coordinates seam — a **concrete** type, not a trait.
363///
364/// Its **entire** durable state is `tab_size`; it holds no persisted visual
365/// indices, so there is nothing derived that could drift out of sync with the
366/// text. Built per frame from the document's `tab_size` plus a borrow of the
367/// buffer's line index, so every conversion reads live text and the map is
368/// current by construction.
369///
370/// Every method that can land on a lossy position (mid-tab, or inside a fold at
371/// the layer above) threads [`Bias`] from the start, so no call site needs
372/// retrofitting when a new lossy case appears.
373pub struct TabMap<'a> {
374    tab_size: u32,
375    lines: &'a Buffer,
376}
377
378impl<'a> TabMap<'a> {
379    /// A map over `lines` with the given tab stop width. `tab_size` is expected
380    /// to be ≥ 1 (see [`default_tab_size`]).
381    #[must_use]
382    pub fn new(lines: &'a Buffer, tab_size: u32) -> Self {
383        Self { tab_size, lines }
384    }
385
386    /// Buffer → display. `point.col` is a char-boundary byte column (the buffer
387    /// guarantees this); the result column is cells. Total: every valid buffer
388    /// point maps to exactly one display point, so `bias` is irrelevant here.
389    #[must_use]
390    pub fn to_display(&self, point: Point, _bias: Bias) -> DisplayPoint {
391        DisplayPoint::new(
392            DisplayRow(point.row),
393            expand(&self.line(point.row), point.col, self.tab_size),
394        )
395    }
396
397    /// Display → buffer, with the bias snap: if `point.col` falls strictly
398    /// inside a tab expansion it is not a caret slot — `Left` yields the byte of
399    /// the `\t`, `Right` the byte after it.
400    #[must_use]
401    pub fn to_buffer(&self, point: DisplayPoint, bias: Bias) -> Point {
402        Point {
403            row: point.row().0,
404            col: collapse(&self.line(point.row().0), point.col(), self.tab_size, bias),
405        }
406    }
407
408    /// Snap any externally-sourced display position (mouse hit, diagnostic span,
409    /// find match) to the nearest valid caret slot — the guard every such
410    /// position passes before use. Steps: [`collapse`] by `bias` → clamp + char
411    /// snap in buffer space → re-[`expand`]. Idempotent.
412    #[must_use]
413    pub fn clip(&self, point: DisplayPoint, bias: Bias) -> DisplayPoint {
414        let buffer_point = self.lines.clip_point(self.to_buffer(point, bias), bias);
415        self.to_display(buffer_point, bias)
416    }
417
418    /// The bottom-right valid position: the last row and its cell length.
419    #[must_use]
420    pub fn max_point(&self) -> DisplayPoint {
421        let row = self.max_row();
422        DisplayPoint::new(row, self.line_len(row))
423    }
424
425    /// Number of display rows in this map — the buffer line count. (Hiding
426    /// rows is the `FoldMap` layer's job, above this one.)
427    #[must_use]
428    pub fn row_count(&self) -> u32 {
429        self.lines.line_count()
430    }
431
432    /// Cell width of a display row (the whole line expanded).
433    #[must_use]
434    pub fn line_len(&self, row: DisplayRow) -> u32 {
435        expand(&self.line(row.0), self.lines.line_len(row.0), self.tab_size)
436    }
437
438    /// Gutter protocol: the buffer line number to print. Always `Some` in this
439    /// map; the `Option` lets a caller handle a display row that has no printed
440    /// line number.
441    #[must_use]
442    pub fn row_info(&self, row: DisplayRow) -> Option<BufferRow> {
443        Some(BufferRow(row.0))
444    }
445
446    /// Consume the transaction's buffer-space edit patch and splice any internal
447    /// derived state. Returns `()`: downstream caches (line cache, paragraph
448    /// cache) invalidate off the buffer-space `LineSplice` they already consume,
449    /// so no display-space edit vector is emitted — widen to `Vec<DisplayEdit>`
450    /// only if a display-space reader ever appears. The map holds no derived
451    /// state, so this is a no-op; the borrow of live buffer text keeps every
452    /// conversion current by construction.
453    pub fn sync(&mut self, _buffer_patch: &Patch) {}
454
455    /// The last display row. `row_count − 1`, saturating.
456    #[must_use]
457    pub fn max_row(&self) -> DisplayRow {
458        DisplayRow(self.row_count().saturating_sub(1))
459    }
460
461    /// The text of buffer row `row`, excluding its `\n` (out-of-range → `""`).
462    /// A [`std::borrow::Cow`]: the rope backing lends a borrowed slice where it
463    /// can and allocates only when a line spans internal chunks.
464    fn line(&self, row: u32) -> std::borrow::Cow<'a, str> {
465        self.lines.line(row)
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    fn buf(s: &str) -> Buffer {
474        Buffer::new(s).unwrap()
475    }
476
477    /// A dumb per-char oracle for [`expand`]: independent of the implementation
478    /// under test, so the two cannot share a mistake and mask a disagreement.
479    fn oracle_expand(line: &str, byte_col: u32, tab_size: u32) -> u32 {
480        let mut cell = 0u32;
481        let mut byte = 0u32;
482        for ch in line.chars() {
483            if byte >= byte_col {
484                break;
485            }
486            cell += if ch == '\t' {
487                tab_size - (cell % tab_size)
488            } else {
489                1
490            };
491            byte += ch.len_utf8() as u32;
492        }
493        cell
494    }
495
496    #[test]
497    fn indent_guide_level_content_rows() {
498        // Content row: ceil(indent / size). size = 4.
499        assert_eq!(indent_guide_level(Some(0), None, None, 4), 0);
500        assert_eq!(indent_guide_level(Some(4), None, None, 4), 1);
501        assert_eq!(indent_guide_level(Some(8), None, None, 4), 2);
502        assert_eq!(indent_guide_level(Some(12), None, None, 4), 3);
503        // Off-grid indent rounds up (a 6-cell indent shows 2 guides).
504        assert_eq!(indent_guide_level(Some(6), None, None, 4), 2);
505    }
506
507    #[test]
508    fn indent_guide_level_blank_rows() {
509        // File edge (a neighbour missing) ⇒ level 0.
510        assert_eq!(indent_guide_level(None, None, Some(4), 4), 0);
511        assert_eq!(indent_guide_level(None, Some(4), None, 4), 0);
512        // Equal neighbours ⇒ carry that level straight through the blank.
513        assert_eq!(indent_guide_level(None, Some(12), Some(12), 4), 3);
514        // Indenting in (above < below): keep the shallower outer ladder + 1.
515        assert_eq!(indent_guide_level(None, Some(4), Some(8), 4), 2);
516        // Dedenting out (above > below, brace-delimited): follow the deeper
517        // outgoing block so the guide doesn't snap in early.
518        assert_eq!(indent_guide_level(None, Some(12), Some(4), 4), 2);
519    }
520
521    #[test]
522    fn active_indent_guide_scope_cases() {
523        // mod m {            0
524        //     fn f() {       1
525        //         if x {     2
526        //             a();   3
527        //                    3  (blank, interpolated)
528        //             b();   3
529        //         }          2
530        //     }              1
531        // }                  0
532        let levels = [0u32, 1, 2, 3, 3, 3, 2, 1, 0];
533        let n = levels.len() as u32;
534        let at = |r: u32| levels[r as usize];
535
536        // Caret on a body line ⇒ its own level, extended over the run.
537        assert_eq!(active_indent_guide(3, n, 0..n, at), Some((3, 3, 5)));
538        // Caret on the top opener `mod m {` (level 0) ⇒ child level 1 active
539        // across the whole body, NOT nothing.
540        assert_eq!(active_indent_guide(0, n, 0..n, at), Some((1, 1, 7)));
541        // Caret on an inner opener `fn f() {` ⇒ child level 2 over its body.
542        assert_eq!(active_indent_guide(1, n, 0..n, at), Some((2, 2, 6)));
543        // Caret on the final closer `}` (level 0) ⇒ end-of-scope, level 1 body.
544        assert_eq!(active_indent_guide(8, n, 0..n, at), Some((1, 1, 7)));
545        // Caret on an inner closer `}` (level 2), body above is level 3.
546        assert_eq!(active_indent_guide(6, n, 0..n, at), Some((3, 3, 5)));
547    }
548
549    #[test]
550    fn active_indent_guide_over_indented_body() {
551        // mod main {            0  col0
552        //     fn f() {          1  col4
553        //                 l00   4  col16 (body jumps 3 levels — over-indented)
554        //                 l01   4  col16
555        //     }                 1  col4
556        // }                     0  col0
557        let levels = [0u32, 1, 4, 4, 1, 0];
558        let n = levels.len() as u32;
559        let at = |r: u32| levels[r as usize];
560
561        // Caret on the opener `fn f() {` ⇒ the FIRST child level (2, cell 4) —
562        // one stop in from `fn f()` — regardless of the body's real depth (4).
563        assert_eq!(active_indent_guide(1, n, 0..n, at), Some((2, 2, 3)));
564        // Caret on a body line ⇒ that line's own level (4, cell 12).
565        assert_eq!(active_indent_guide(2, n, 0..n, at), Some((4, 2, 3)));
566        // Caret on `mod main {` ⇒ its first child level 1, cell 0.
567        assert_eq!(active_indent_guide(0, n, 0..n, at), Some((1, 1, 4)));
568    }
569
570    #[test]
571    fn active_indent_guide_none_at_flat_level_zero() {
572        // A flat, unindented document has no active guide anywhere.
573        let levels = [0u32, 0, 0];
574        assert_eq!(active_indent_guide(1, 3, 0..3, |r| levels[r as usize]), None);
575    }
576
577    #[test]
578    fn tab_width_advances_to_next_stop() {
579        // Width is 1..=tab_size, never zero; hits tab_size exactly at a stop.
580        assert_eq!(tab_width(0, 4), 4);
581        assert_eq!(tab_width(1, 4), 3);
582        assert_eq!(tab_width(3, 4), 1);
583        assert_eq!(tab_width(4, 4), 4); // back to a full step at the next stop
584        assert_eq!(tab_width(7, 4), 1);
585    }
586
587    #[test]
588    fn expand_no_tabs_is_scalar_count() {
589        assert_eq!(expand("hello", 5, 4), 5);
590        assert_eq!(expand("hello", 3, 4), 3);
591        assert_eq!(expand("hello", 0, 4), 0);
592        // Past EOL expands the whole line.
593        assert_eq!(expand("hi", 99, 4), 2);
594    }
595
596    #[test]
597    fn expand_counts_scalars_not_bytes() {
598        // "é" is 2 bytes but one cell; a stray multibyte char shifts, not
599        // corrupts, columns.
600        let line = "aé b"; // bytes: a(1) é(2) space(1) b(1) = 5
601        assert_eq!(line.len(), 5);
602        assert_eq!(expand(line, 5, 4), 4); // 4 scalars
603        assert_eq!(expand(line, 3, 4), 2); // after "aé" → 2 cells
604    }
605
606    #[test]
607    fn expand_stretches_at_tabs() {
608        // "\t" at col 0 → 4 cells; "a\t" → a(1) then tab to next stop (3) = 4.
609        assert_eq!(expand("\t", 1, 4), 4);
610        assert_eq!(expand("a\t", 2, 4), 4);
611        assert_eq!(expand("ab\tc", 4, 4), 5); // ab(2) tab→4 c→5
612        assert_eq!(expand("\t\t", 2, 4), 8); // two full tab stops
613    }
614
615    #[test]
616    fn expand_matches_oracle() {
617        for line in ["", "a", "\t", "a\tb\tc", "\t\ta", "aé\tb", "abcd\te"] {
618            for byte_col in 0..=line.len() as u32 + 1 {
619                assert_eq!(
620                    expand(line, byte_col, 4),
621                    oracle_expand(line, byte_col, 4),
622                    "line {line:?} byte_col {byte_col}"
623                );
624            }
625        }
626    }
627
628    #[test]
629    fn collapse_hits_boundaries_exactly() {
630        // No tabs: cell == byte on a boundary.
631        assert_eq!(collapse("hello", 0, 4, Bias::Left), 0);
632        assert_eq!(collapse("hello", 3, 4, Bias::Left), 3);
633        assert_eq!(collapse("hello", 5, 4, Bias::Left), 5);
634        // Past EOL clamps to the byte length.
635        assert_eq!(collapse("hi", 9, 4, Bias::Right), 2);
636    }
637
638    #[test]
639    fn collapse_snaps_mid_tab_by_bias() {
640        // "\t" spans cells 0..4; landing at 1/2/3 is inside the expansion.
641        for c in 1..4 {
642            assert_eq!(collapse("\t", c, 4, Bias::Left), 0); // to the '\t' byte
643            assert_eq!(collapse("\t", c, 4, Bias::Right), 1); // just after it
644        }
645        // Cell 0 and cell 4 are boundaries — no snap.
646        assert_eq!(collapse("\t", 0, 4, Bias::Left), 0);
647        assert_eq!(collapse("\tx", 4, 4, Bias::Left), 1); // after the tab, on 'x'
648    }
649
650    #[test]
651    fn collapse_lands_on_char_boundaries_after_multibyte() {
652        // "é" occupies bytes 0..2, one cell. Collapsing cell 1 lands on byte 2,
653        // the boundary after 'é'.
654        let line = "é\t";
655        assert_eq!(collapse(line, 1, 4, Bias::Left), 2);
656        // The tab starts at cell 1 (width 3) → mid-tab snaps to its byte (2) or
657        // just after (3).
658        assert_eq!(collapse(line, 2, 4, Bias::Left), 2);
659        assert_eq!(collapse(line, 2, 4, Bias::Right), 3);
660    }
661
662    #[test]
663    fn expand_collapse_round_trip_on_boundaries() {
664        // expand ∘ collapse is identity on valid (boundary) cell columns.
665        let line = "a\tbé\tc";
666        let mut byte = 0u32;
667        for ch in line.chars() {
668            let cell = expand(line, byte, 4);
669            assert_eq!(collapse(line, cell, 4, Bias::Left), byte, "byte {byte}");
670            assert_eq!(collapse(line, cell, 4, Bias::Right), byte, "byte {byte}");
671            byte += ch.len_utf8() as u32;
672        }
673    }
674
675    #[test]
676    fn default_tab_size_is_four() {
677        assert_eq!(default_tab_size(), 4);
678    }
679
680    #[test]
681    fn display_point_accessors() {
682        let p = DisplayPoint::new(DisplayRow(2), 7);
683        assert_eq!(p.row(), DisplayRow(2));
684        assert_eq!(p.col(), 7);
685        assert_eq!(DisplayPoint::zero(), DisplayPoint::new(DisplayRow(0), 0));
686    }
687
688    #[test]
689    fn display_point_orders_row_major() {
690        assert!(DisplayPoint::new(DisplayRow(0), 9) < DisplayPoint::new(DisplayRow(1), 0));
691        assert!(DisplayPoint::new(DisplayRow(1), 2) < DisplayPoint::new(DisplayRow(1), 3));
692    }
693
694    #[test]
695    fn to_display_expands_the_column() {
696        let b = buf("a\tb\nx");
697        let m = TabMap::new(&b, 4);
698        // "b" sits at byte 2, cell 4 (a=1, tab→4).
699        let d = m.to_display(Point::new(0, 2), Bias::Left);
700        assert_eq!(d.row(), DisplayRow(0));
701        assert_eq!(d.col(), 4);
702        // Row is preserved: buffer row 1 → display row 1.
703        assert_eq!(m.to_display(Point::new(1, 1), Bias::Left).row(), DisplayRow(1));
704    }
705
706    #[test]
707    fn to_buffer_snaps_out_of_the_tab() {
708        let b = buf("\tx");
709        let m = TabMap::new(&b, 4);
710        // Cell 2 is inside the tab expansion.
711        let mid = DisplayPoint::new(DisplayRow(0), 2);
712        assert_eq!(m.to_buffer(mid, Bias::Left), Point::new(0, 0));
713        assert_eq!(m.to_buffer(mid, Bias::Right), Point::new(0, 1));
714    }
715
716    #[test]
717    fn clip_pulls_a_caret_out_of_a_tab() {
718        let b = buf("\tab");
719        let m = TabMap::new(&b, 4);
720        let inside = DisplayPoint::new(DisplayRow(0), 2);
721        // Left snaps to the tab's start cell (0); Right to just after it (4).
722        assert_eq!(m.clip(inside, Bias::Left), DisplayPoint::new(DisplayRow(0), 0));
723        assert_eq!(m.clip(inside, Bias::Right), DisplayPoint::new(DisplayRow(0), 4));
724    }
725
726    #[test]
727    fn clip_is_idempotent() {
728        let b = buf("a\tbc\td");
729        let m = TabMap::new(&b, 4);
730        for col in 0..12 {
731            let once = m.clip(DisplayPoint::new(DisplayRow(0), col), Bias::Left);
732            let twice = m.clip(once, Bias::Left);
733            assert_eq!(once, twice, "col {col}");
734        }
735    }
736
737    #[test]
738    fn clip_clamps_past_end_of_line() {
739        let b = buf("hi");
740        let m = TabMap::new(&b, 4);
741        let far = DisplayPoint::new(DisplayRow(0), 99);
742        assert_eq!(m.clip(far, Bias::Left), DisplayPoint::new(DisplayRow(0), 2));
743    }
744
745    #[test]
746    fn row_count_and_max_row_track_lines() {
747        let b = buf("a\nb\nc");
748        let m = TabMap::new(&b, 4);
749        assert_eq!(m.row_count(), 3);
750        assert_eq!(m.max_row(), DisplayRow(2));
751        // An empty document is one row; max_row saturates at 0.
752        let e = buf("");
753        let me = TabMap::new(&e, 4);
754        assert_eq!(me.row_count(), 1);
755        assert_eq!(me.max_row(), DisplayRow(0));
756    }
757
758    #[test]
759    fn line_len_and_max_point_are_cells() {
760        let b = buf("a\tb\nlong");
761        let m = TabMap::new(&b, 4);
762        assert_eq!(m.line_len(DisplayRow(0)), 5); // a(1) tab→4 b→5
763        assert_eq!(m.line_len(DisplayRow(1)), 4);
764        assert_eq!(m.max_point(), DisplayPoint::new(DisplayRow(1), 4));
765    }
766
767    #[test]
768    fn row_info_is_identity_in_v1() {
769        let b = buf("x\ny");
770        let m = TabMap::new(&b, 4);
771        assert_eq!(m.row_info(DisplayRow(0)), Some(BufferRow(0)));
772        assert_eq!(m.row_info(DisplayRow(1)), Some(BufferRow(1)));
773    }
774
775    #[test]
776    fn chunks_split_at_every_tab() {
777        // The caller owns the line; the runs borrow from it.
778        let b = buf("ab\tc");
779        let line = b.line(0);
780        let runs: Vec<_> = chunks(&line, 4).collect();
781        assert_eq!(runs.len(), 3);
782        // "ab": bytes 0..2, 2 cells, not a tab.
783        assert_eq!(runs[0].text, "ab");
784        assert_eq!(runs[0].buffer_bytes, 0..2);
785        assert_eq!(runs[0].cells, 2);
786        assert!(!runs[0].is_tab);
787        // "\t": byte 2..3, width 2 (from cell 2 to the next stop at 4).
788        assert_eq!(runs[1].text, "\t");
789        assert_eq!(runs[1].buffer_bytes, 2..3);
790        assert_eq!(runs[1].cells, 2);
791        assert!(runs[1].is_tab);
792        // "c": byte 3..4, 1 cell.
793        assert_eq!(runs[2].text, "c");
794        assert_eq!(runs[2].buffer_bytes, 3..4);
795        assert_eq!(runs[2].cells, 1);
796    }
797
798    #[test]
799    fn chunks_of_empty_and_tab_only_lines() {
800        let b = buf("\n\t");
801        // Empty first line → no runs.
802        assert_eq!(chunks(&b.line(0), 4).count(), 0);
803        // Tab-only line → a single tab run of full width.
804        let line = b.line(1);
805        let runs: Vec<_> = chunks(&line, 4).collect();
806        assert_eq!(runs.len(), 1);
807        assert!(runs[0].is_tab);
808        assert_eq!(runs[0].cells, 4);
809    }
810
811    #[test]
812    fn chunk_cells_sum_to_line_len() {
813        let b = buf("x\t\tyz\tw");
814        let m = TabMap::new(&b, 4);
815        let total: u32 = chunks(&b.line(0), 4).map(|c| c.cells).sum();
816        assert_eq!(total, m.line_len(DisplayRow(0)));
817    }
818
819    #[test]
820    fn sync_is_a_noop_that_leaves_conversions_current() {
821        let b = buf("a\tb");
822        let m0 = TabMap::new(&b, 4);
823        let before = m0.line_len(DisplayRow(0));
824        let mut m = TabMap::new(&b, 4);
825        m.sync(&Patch::new());
826        assert_eq!(m.line_len(DisplayRow(0)), before);
827    }
828}