Skip to main content

scrive_core/
row_layout.rs

1//! Per-row horizontal projection — the **one owner** of byte-column ↔
2//! display-cell math on rows with collapsed inline folds, and of the collapsed
3//! block header's `head … tail` one-line layout.
4//!
5//! Every consumer that needs to place something horizontally — render, caret
6//! placement, hit testing, selection, and movement — consults the functions
7//! here rather than recomputing the inline-collapse shift, its inverse, the
8//! chip-center formula, the caret/glyph boundary predicates, or the header
9//! width that feeds a collapsed block's inline tail. Because each of those
10//! facts has exactly one definition, a change to the chip layout or a boundary
11//! rule is a one-site edit and every site stays in agreement by construction.
12//!
13//! Everything here is in **cells and columns** — GUI-free. The widget's only
14//! remaining job is `x = origin + cell × advance` (and its inverse).
15
16use std::borrow::Cow;
17
18use crate::buffer::Buffer;
19use crate::coords::{Bias, Point};
20use crate::display_map::{self, BufferRow, DisplayRow};
21use crate::fold_map::{FoldMap, InlineFold};
22
23// Op-count canary: counts `display_position` probes on this thread so a test
24// can assert `expand_folds_touched` calls it O(edit points) per commit — once
25// per point, for the hidden-gap check — and never O(candidates · edits), which
26// would make a document-scale multi-caret edit over a folded document cost the
27// product of the two. Debug/test only; zero-cost in release.
28#[cfg(any(test, debug_assertions))]
29thread_local! {
30    pub(crate) static DISPLAY_POSITION_PROBES: std::cell::Cell<u64> =
31        const { std::cell::Cell::new(0) };
32}
33
34/// Display cells a collapsed inline fold's `…` chip occupies. The chip
35/// starts one cell past the opening bracket; the interior collapses to this.
36pub const INLINE_CHIP_CELLS: u32 = 3;
37
38/// Display cells between a collapsed block header's end and its inline closing
39/// tail — the ` … ` placeholder gap.
40pub const FOLD_PLACEHOLDER_CELLS: u32 = 4;
41
42/// Where a caret at some byte column renders horizontally: a whole display
43/// cell, or — for a column hidden inside a collapsed inline fold — the center
44/// of that fold's `…` chip. The chip center is the **only** fractional cell in
45/// the system; fencing it in this enum keeps `f32` out of the inverse maps.
46#[derive(Copy, Clone, PartialEq, Debug)]
47pub enum CaretCell {
48    /// An exact display cell.
49    Cell(u32),
50    /// The fractional cell at a collapsed chip's visual center.
51    ChipCenter(f32),
52}
53
54impl CaretCell {
55    /// The (possibly fractional) display-cell value, for pixel projection.
56    #[must_use]
57    pub fn cells(self) -> f32 {
58        match self {
59            Self::Cell(c) => c as f32,
60            Self::ChipCenter(c) => c,
61        }
62    }
63}
64
65/// Where a buffer offset renders: its display row plus its horizontal
66/// [`CaretCell`]. THE owner of "where does offset O show on screen" — a caret,
67/// selection endpoint, squiggle bound, popup anchor, and autoscroll target all
68/// read the same value (see [`FoldMap::display_position`]).
69#[derive(Copy, Clone, PartialEq, Debug)]
70pub struct DisplayPosition {
71    /// The visible display row (a hidden closing tail resolves to its header's).
72    pub row: DisplayRow,
73    /// The horizontal position on that row.
74    pub x: CaretCell,
75}
76
77/// One collapsed inline fold's `…` chip on a row, in display cells, as named
78/// fields the widget destructures to render and hit-test the chip.
79#[derive(Copy, Clone, PartialEq, Debug)]
80pub struct Chip {
81    /// The chip's first display cell (one past the opening bracket).
82    pub cell: u32,
83    /// The chip's visual center, in fractional display cells.
84    pub center: f32,
85    /// Byte column of the opening bracket on its line.
86    pub open_col: u32,
87    /// Byte column of the closing bracket on its line.
88    pub close_col: u32,
89}
90
91/// One visible glyph of a collapsed block's inline closing tail, resolved to
92/// the display cell it occupies on the *header* row.
93#[derive(Copy, Clone, PartialEq, Debug)]
94pub struct TailGlyph {
95    /// Byte column on the fold's *last* buffer row.
96    pub col: u32,
97    /// Display cell on the header's display row.
98    pub cell: u32,
99    /// The character itself.
100    pub ch: char,
101}
102
103/// Which region of a collapsed block header's display line a cell falls in.
104#[derive(Copy, Clone, PartialEq, Debug)]
105pub enum HeaderHit {
106    /// On the header's own text — resolve against the header row's [`RowLayout`].
107    Head,
108    /// In the ` … ` placeholder gap — clamps to the header line's end.
109    Gap,
110    /// On the inline closing tail: the byte column on the fold's *last* row.
111    Tail(u32),
112}
113
114/// The caret slot just after a pair's opening bracket — the LEFT landable edge
115/// of a collapsed inline gap. The one place `open + 1` is written.
116#[must_use]
117pub fn gap_left_edge(open: u32) -> u32 {
118    open + 1
119}
120
121/// Whether caret offset `off` is strictly inside the collapsed gap of the pair
122/// `(open, close)`. THE caret-boundary rule: `open+1` and `close` are
123/// landable; strictly between them hides. (Its off-by-one sibling is
124/// [`gap_hides_glyph`] — a *glyph* at `open+1` hides even though the caret slot
125/// there is landable. The two variants exist as exactly these two functions.)
126#[must_use]
127pub fn gap_hides_caret(open: u32, close: u32, off: u32) -> bool {
128    off > gap_left_edge(open) && off < close
129}
130
131/// Whether the glyph at offset `off` is hidden by the collapsed pair
132/// `(open, close)`: everything strictly between the brackets.
133#[must_use]
134pub fn gap_hides_glyph(open: u32, close: u32, off: u32) -> bool {
135    off > open && off < close
136}
137
138/// Byte column where a line's visible content starts (its leading whitespace
139/// length) — where a collapsed block's closing tail begins on its last row.
140/// The one `trim_start` rule, shared by movement and the widget.
141#[must_use]
142pub fn tail_start_col(line: &str) -> u32 {
143    (line.len() - line.trim_start().len()) as u32
144}
145
146/// Whether a bracket pair `(open, close)` has a *non-empty interior* — the one
147/// foldability rule: there must be something between the brackets to
148/// hide. Shared by the document's foldable-pair enumeration and the fold map's
149/// inline/block resolution.
150#[must_use]
151pub fn pair_has_interior(open: u32, close: u32) -> bool {
152    close > open + 1
153}
154
155/// Round a fractional display cell to the nearest cell boundary, floored at 0
156/// — the quantization for a column (box) selection's *virtual* corner cells,
157/// which may lie past any line's content and so can't resolve through
158/// [`RowLayout::hit`] yet. The same round-half-up rule `hit` applies over real
159/// content, so a box edge and a click at the same pixel agree on the boundary.
160#[must_use]
161pub fn virtual_cell(cell: f32) -> u32 {
162    cell.round().max(0.0) as u32
163}
164
165/// One collapsed inline fold on a row, with its bracket cells precomputed.
166#[derive(Copy, Clone, Debug)]
167struct InlineSpan {
168    /// Tab-expanded (pre-collapse) cell of the opening bracket.
169    open_cell: u32,
170    /// Tab-expanded (pre-collapse) cell of the closing bracket.
171    close_cell: u32,
172    fold: InlineFold,
173}
174
175/// A visible buffer row's horizontal projection: byte column ↔ display cell,
176/// with tab expansion *and* the horizontal collapse of every root inline fold
177/// on the row. Built per use by [`FoldMap::row_layout`]; holds only the row's
178/// text (a [`Cow`] — borrowed straight off the backing when the row is stored
179/// contiguously, owned only when it spans a chunk boundary) and copies of the
180/// row's folds, so it carries no derived state that could drift out of sync
181/// with the document. Like [`FoldMap`], it is cheap to rebuild and never
182/// stored.
183pub struct RowLayout<'a> {
184    line: Cow<'a, str>,
185    /// Byte offset of the row's first character (column 0), for offset-space
186    /// boundary predicates.
187    row_start: u32,
188    tab: u32,
189    /// This row's root inline folds, sorted by opening cell.
190    spans: Vec<InlineSpan>,
191}
192
193impl<'a> RowLayout<'a> {
194    fn new(fold_map: &FoldMap, buffer: &'a Buffer, row: BufferRow, tab: u32) -> Self {
195        let line = buffer.line(row.0);
196        let row_start = buffer.point_to_offset(Point::new(row.0, 0));
197        // This row's inline folds — an O(log n + hits) windowed descent into the
198        // fold tree, not an O(all inline folds) scan/materialize per row per frame.
199        let mut spans: Vec<InlineSpan> = fold_map
200            .inline_folds_on_row(row.0)
201            .into_iter()
202            .map(|fold| InlineSpan {
203                open_cell: display_map::expand(&line, fold.open - row_start, tab),
204                close_cell: display_map::expand(&line, fold.close - row_start, tab),
205                fold,
206            })
207            .collect();
208        spans.sort_by_key(|s| s.open_cell);
209        Self { line, row_start, tab, spans }
210    }
211
212    /// Whether the row has no collapsed inline folds (the identity projection).
213    #[must_use]
214    pub fn is_plain(&self) -> bool {
215        self.spans.is_empty()
216    }
217
218    /// Byte offset of the row's column 0 — for turning a chip's byte columns back
219    /// into document offsets (e.g. to test a chip against the selection set).
220    #[must_use]
221    pub fn row_start(&self) -> u32 {
222        self.row_start
223    }
224
225    /// Collapse shift at pre-collapse cell `cell`: every fold whose closing
226    /// bracket sits at/before it has hidden `close−open−1` cells behind an
227    /// [`INLINE_CHIP_CELLS`]-wide chip.
228    fn shift_at(&self, cell: u32) -> i32 {
229        self.spans
230            .iter()
231            .filter(|s| cell >= s.close_cell)
232            .map(|s| (s.close_cell as i32 - s.open_cell as i32 - 1) - INLINE_CHIP_CELLS as i32)
233            .sum()
234    }
235
236    /// Pre-collapse (tab-expanded) cell → post-collapse display cell.
237    fn cell_of(&self, raw_cell: u32) -> u32 {
238        (raw_cell as i32 - self.shift_at(raw_cell)).max(0) as u32
239    }
240
241    /// Byte column → display cell (tab-expanded, inline-collapsed). Total and
242    /// monotone; a column hidden inside a chip maps into the chip's span (use
243    /// [`Self::caret_cell`] for caret placement, which clips to the center).
244    #[must_use]
245    pub fn display_cell(&self, col: u32) -> u32 {
246        self.cell_of(display_map::expand(&self.line, col, self.tab))
247    }
248
249    /// Caret placement for a byte column: its display cell, or the chip center
250    /// when the column is hidden inside a collapsed inline fold's gap.
251    #[must_use]
252    pub fn caret_cell(&self, col: u32) -> CaretCell {
253        let off = self.row_start + col;
254        match self.spans.iter().find(|s| s.fold.hides_caret_at(off)) {
255            Some(s) => CaretCell::ChipCenter(
256                self.cell_of(s.open_cell + 1) as f32 + INLINE_CHIP_CELLS as f32 / 2.0,
257            ),
258            None => CaretCell::Cell(self.display_cell(col)),
259        }
260    }
261
262    /// Whether the glyph at byte column `col` is hidden inside a collapsed
263    /// inline fold (the deliberately-different sibling of the caret rule: the
264    /// glyph at `open+1` hides while its caret slot stays landable).
265    #[must_use]
266    pub fn glyph_hidden(&self, col: u32) -> bool {
267        let off = self.row_start + col;
268        self.spans.iter().any(|s| s.fold.hides_glyph_at(off))
269    }
270
271    /// Inverse projection: a (fractional, unrounded) display cell → the byte
272    /// column a click there lands on. Rounding policy lives HERE, not at call
273    /// sites. A cell on a chip resolves to just after the opening bracket;
274    /// past-EOL clamps; mid-tab snaps by `bias`.
275    #[must_use]
276    pub fn hit(&self, cell: f32, bias: Bias) -> u32 {
277        let dc = cell.round().max(0.0) as u32;
278        let mut extra = 0i32;
279        for s in &self.spans {
280            // Compare in DISPLAY space: prior collapsed folds shift this
281            // fold's bracket cells left before the click can be tested.
282            let d_open = self.cell_of(s.open_cell);
283            let d_chip_end = d_open + 1 + INLINE_CHIP_CELLS;
284            if dc >= d_chip_end {
285                extra += (s.close_cell as i32 - s.open_cell as i32 - 1) - INLINE_CHIP_CELLS as i32;
286            } else if dc > d_open {
287                return s.fold.left_edge() - self.row_start; // on the chip → just after `[`
288            }
289        }
290        let raw_cell = (dc as i32 + extra).max(0) as u32;
291        display_map::collapse(&self.line, raw_cell, self.tab, bias)
292    }
293
294    /// The row's rendered display width in cells (tab-expanded, collapsed).
295    /// A collapsed block's inline tail begins [`FOLD_PLACEHOLDER_CELLS`] past
296    /// this — see [`HeaderLayout::tail_cell`].
297    #[must_use]
298    pub fn width(&self) -> u32 {
299        self.display_cell(self.line.len() as u32)
300    }
301
302    /// The row's collapsed chips, in display order.
303    pub fn chips(&self) -> impl Iterator<Item = Chip> + '_ {
304        self.spans.iter().map(|s| {
305            let cell = self.cell_of(s.open_cell + 1);
306            Chip {
307                cell,
308                center: cell as f32 + INLINE_CHIP_CELLS as f32 / 2.0,
309                open_col: s.fold.open - self.row_start,
310                close_col: s.fold.close - self.row_start,
311            }
312        })
313    }
314}
315
316/// A collapsed block fold's one-line display layout: the (inline-fold-
317/// aware) header text, the ` … ` placeholder gap, then the fold's real closing
318/// tail — `fn main() { … }`. The single source for the placeholder render,
319/// caret placement on the tail, selection washes, hit-testing, the hover-chip
320/// rect, and the preview anchor, so they agree to the pixel by construction.
321pub struct HeaderLayout<'a> {
322    /// The header row's own horizontal projection.
323    head: RowLayout<'a>,
324    /// The fold's last buffer row — the line the tail glyphs come from.
325    last: BufferRow,
326    tail_line: Cow<'a, str>,
327    /// Byte column where the visible tail starts on `last` (its leading ws).
328    tail_lead: u32,
329    tab: u32,
330}
331
332impl<'a> HeaderLayout<'a> {
333    /// The header row's projection (for hits resolving to [`HeaderHit::Head`]).
334    #[must_use]
335    pub fn head(&self) -> &RowLayout<'a> {
336        &self.head
337    }
338
339    /// Rendered display width of the header text — where the placeholder gap
340    /// begins. Inline-fold aware: a collapsed inline fold before the block's
341    /// opener shrinks it.
342    #[must_use]
343    pub fn head_cells(&self) -> u32 {
344        self.head.width()
345    }
346
347    /// The fractional cell at the center of the ` … ` gap, where the chip and
348    /// its `…` glyph both center.
349    #[must_use]
350    pub fn gap_center(&self) -> f32 {
351        self.head_cells() as f32 + FOLD_PLACEHOLDER_CELLS as f32 / 2.0
352    }
353
354    /// The display cell where the inline closing tail begins.
355    #[must_use]
356    pub fn tail_cell(&self) -> u32 {
357        self.head_cells() + FOLD_PLACEHOLDER_CELLS
358    }
359
360    /// The fold's last buffer row (the tail's real home).
361    #[must_use]
362    pub fn last_row(&self) -> BufferRow {
363        self.last
364    }
365
366    /// Byte column on the last row where the visible tail starts.
367    #[must_use]
368    pub fn tail_start_col(&self) -> u32 {
369        self.tail_lead
370    }
371
372    /// Tab-expanded cell of the tail's first visible column on its own row.
373    fn lead_cells(&self) -> u32 {
374        display_map::expand(&self.tail_line, self.tail_lead, self.tab)
375    }
376
377    /// Byte column on the *last* row → display cell on the header row, using
378    /// full-line tab stops (the caret/hit/selection convention). `None` for a
379    /// column in the leading whitespace before the visible tail.
380    #[must_use]
381    pub fn tail_col_cell(&self, col: u32) -> Option<u32> {
382        (col >= self.tail_lead)
383            .then(|| self.tail_cell() + display_map::expand(&self.tail_line, col, self.tab) - self.lead_cells())
384    }
385
386    /// The tail's rendered width in cells.
387    #[must_use]
388    pub fn tail_cells(&self) -> u32 {
389        display_map::expand(&self.tail_line, self.tail_line.len() as u32, self.tab) - self.lead_cells()
390    }
391
392    /// The collapsed line's total rendered width (head + gap + tail), in cells.
393    #[must_use]
394    pub fn width(&self) -> u32 {
395        self.tail_cell() + self.tail_cells()
396    }
397
398    /// The visible tail glyphs at their header-row display cells.
399    pub fn tail_glyphs(&self) -> impl Iterator<Item = TailGlyph> + '_ {
400        self.tail_line[self.tail_lead as usize..].char_indices().map(move |(i, ch)| {
401            let col = self.tail_lead + i as u32;
402            TailGlyph {
403                col,
404                cell: self.tail_col_cell(col).expect("tail glyph is at/after the lead"),
405                ch,
406            }
407        })
408    }
409
410    /// Resolve a (fractional) display cell on the collapsed header line to the
411    /// region it falls in, with the ±half-cell boundaries the hit-test has
412    /// always used: at/after the tail (minus half a cell) → the tail column;
413    /// past the header text (plus half a cell) → the gap; else the head.
414    #[must_use]
415    pub fn hit(&self, cell: f32, bias: Bias) -> HeaderHit {
416        if cell >= self.tail_cell() as f32 - 0.5 {
417            let cell_in_tail = (cell - self.tail_cell() as f32).round().max(0.0) as u32;
418            let col = display_map::collapse(&self.tail_line, self.lead_cells() + cell_in_tail, self.tab, bias);
419            HeaderHit::Tail(col)
420        } else if cell > self.head_cells() as f32 + 0.5 {
421            HeaderHit::Gap
422        } else {
423            HeaderHit::Head
424        }
425    }
426}
427
428impl FoldMap {
429    /// The horizontal projection of visible buffer `row` — built per use,
430    /// like the `FoldMap` itself; never store it.
431    #[must_use]
432    pub fn row_layout<'a>(&self, buffer: &'a Buffer, row: BufferRow, tab: u32) -> RowLayout<'a> {
433        RowLayout::new(self, buffer, row, tab)
434    }
435
436    /// The `head … tail` one-line layout of `row`, iff it is a collapsed block
437    /// fold's header.
438    #[must_use]
439    pub fn header_layout<'a>(&self, buffer: &'a Buffer, row: BufferRow, tab: u32) -> Option<HeaderLayout<'a>> {
440        let last = self.fold_at_header(row)?;
441        let head = self.row_layout(buffer, row, tab);
442        let tail_line = buffer.line(last.0);
443        let tail_lead = tail_start_col(&tail_line);
444        Some(HeaderLayout { head, last, tail_line, tail_lead, tab })
445    }
446
447    /// THE owner of "where does buffer `offset` render": its display row and
448    /// horizontal cell. Follows a collapsed block's closing tail to the header
449    /// row; clips a column hidden in an inline fold to its chip center. `None`
450    /// iff the offset is genuinely hidden — inside a block fold's gap, or on
451    /// the last row before the visible tail.
452    #[must_use]
453    pub fn display_position(&self, buffer: &Buffer, offset: u32, tab: u32) -> Option<DisplayPosition> {
454        #[cfg(any(test, debug_assertions))]
455        DISPLAY_POSITION_PROBES.with(|c| c.set(c.get() + 1));
456        crate::perf::charge(1); // complexity gate: one display-map probe
457        let p = buffer.offset_to_point(offset);
458        let row = BufferRow(p.row);
459        if !self.is_folded(row) {
460            let layout = self.row_layout(buffer, row, tab);
461            return Some(DisplayPosition { row: self.to_display_row(row), x: layout.caret_cell(p.col) });
462        }
463        // Hidden row: only a collapsed fold's closing tail is representable —
464        // it rides the header's display line.
465        let hdr = self.header_of_tail(row)?;
466        let layout = self.header_layout(buffer, hdr, tab)?;
467        let cell = layout.tail_col_cell(p.col)?;
468        Some(DisplayPosition { row: self.to_display_row(hdr), x: CaretCell::Cell(cell) })
469    }
470
471    /// Inverse of [`Self::display_position`] on one visible row: a (fractional,
472    /// unrounded) display cell → the byte offset a click there lands on,
473    /// resolving a collapsed header's gap (→ header line end) and tail
474    /// (→ the last row's column) before the plain row projection.
475    #[must_use]
476    pub fn hit_row(&self, buffer: &Buffer, row: BufferRow, cell: f32, bias: Bias, tab: u32) -> u32 {
477        if let Some(layout) = self.header_layout(buffer, row, tab) {
478            match layout.hit(cell, bias) {
479                HeaderHit::Tail(col) => return buffer.point_to_offset(Point::new(layout.last_row().0, col)),
480                HeaderHit::Gap => return buffer.point_to_offset(Point::new(row.0, buffer.line_len(row.0))),
481                HeaderHit::Head => {}
482            }
483        }
484        let layout = self.row_layout(buffer, row, tab);
485        buffer.point_to_offset(Point::new(row.0, layout.hit(cell, bias)))
486    }
487
488    /// THE pixel-y inversion policy, in row units: a (fractional) count of
489    /// display rows from the content top → the display row it falls on,
490    /// floored and clamped to the valid range. Every y-driven hit (clicks,
491    /// hover, gutter, boxes) routes through this one rule.
492    ///
493    /// `f64`, deliberately: `f32` holds fractional rows exactly only below
494    /// ~2²³ rows — past that, hits land on the wrong line and (via the
495    /// widget's px↔row maps) rendered rows visibly skip. `f64` is exact for
496    /// every u32-addressable document.
497    #[must_use]
498    pub fn display_row_at(&self, rows_from_top: f64) -> DisplayRow {
499        DisplayRow((rows_from_top.floor().max(0.0) as u32).min(self.display_row_count().saturating_sub(1)))
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506    use crate::document::Document;
507
508    /// A document with the inline pair at `inline_at` and/or the block pair at
509    /// `block_at` collapsed (each the byte offset of an opening bracket).
510    fn doc_with_folds(text: &str, fold_openers: &[u32]) -> Document {
511        let mut doc = Document::new(text).expect("test doc fits");
512        for &o in fold_openers {
513            assert!(doc.toggle_fold_opener(o), "opener {o} must be foldable");
514        }
515        doc
516    }
517
518    fn fold_map(doc: &Document) -> FoldMap {
519        FoldMap::new(doc.folds(), doc.brackets(), doc.buffer())
520    }
521
522    // ── RowLayout: the caret boundary rule — open+1 lands on the chip's left
523    //    edge (a cell), never its center ──
524
525    #[test]
526    fn caret_cell_lands_on_chip_edge_not_center() {
527        // `a[bcdef]g` — fold the [..] pair (opener at byte 1).
528        let doc = doc_with_folds("a[bcdef]g", &[1]);
529        let fm = fold_map(&doc);
530        let rl = fm.row_layout(doc.buffer(), BufferRow(0), 4);
531        // col 2 == open+1: the LEFT landable edge — a cell, not the center.
532        assert_eq!(rl.caret_cell(2), CaretCell::Cell(2), "open+1 is landable at the chip's left edge");
533        // col 3 (strict interior) clips to the chip center: cell 2 + 1.5.
534        assert_eq!(rl.caret_cell(3), CaretCell::ChipCenter(2.0 + INLINE_CHIP_CELLS as f32 / 2.0));
535        // col 7 == close: the RIGHT landable edge.
536        assert_eq!(rl.caret_cell(7), CaretCell::Cell(rl.display_cell(7)));
537        // Glyphs: open+1's glyph hides even though its caret slot is landable.
538        assert!(rl.glyph_hidden(2));
539        assert!(!rl.glyph_hidden(7), "the closing bracket stays visible");
540        assert!(!rl.glyph_hidden(1), "the opening bracket stays visible");
541    }
542
543    // ── RowLayout: the inverse round-trips on multi-chip rows — the hit-test
544    //    compares in display space, so a later chip resolves correctly ──
545
546    #[test]
547    fn hit_round_trips_display_cell_on_multi_chip_rows() {
548        // Two inline pairs on one row, a tab up front, a multibyte char after.
549        let text = "\tf([aa], [bb]) é";
550        let open1 = text.find("[aa").unwrap() as u32;
551        let open2 = text.find("[bb").unwrap() as u32;
552        let doc = doc_with_folds(text, &[open1, open2]);
553        let fm = fold_map(&doc);
554        let rl = fm.row_layout(doc.buffer(), BufferRow(0), 4);
555        assert_eq!(rl.chips().count(), 2);
556        // Every landable char-boundary column round-trips through the inverse.
557        let line = doc.buffer().line(0);
558        for (i, _) in line.char_indices() {
559            let col = i as u32;
560            if rl.glyph_hidden(col) {
561                continue; // interior columns resolve to the chip instead
562            }
563            assert_eq!(rl.hit(rl.display_cell(col) as f32, Bias::Left), col, "round-trip col {col}");
564        }
565        // Every cell strictly on a chip resolves to just after its `[`. The
566        // second chip is the discriminating case: it resolves correctly only
567        // because the compare is done in display space, not buffer space.
568        for chip in rl.chips().collect::<Vec<_>>() {
569            for dc in chip.cell..chip.cell + INLINE_CHIP_CELLS {
570                assert_eq!(rl.hit(dc as f32, Bias::Left), chip.open_col + 1, "chip cell {dc}");
571            }
572        }
573    }
574
575    // ── HeaderLayout: an inline fold preceding a block fold shrinks the head,
576    //    so the tail is placed against the collapsed width, not the raw one ──
577
578    #[test]
579    fn header_layout_shrinks_with_preceding_inline_fold() {
580        let text = "\tcall([a, b, c]) {\n\tbody\n\t}\n";
581        let inline_open = text.find('[').unwrap() as u32;
582        let block_open = text.find('{').unwrap() as u32;
583        let doc = doc_with_folds(text, &[inline_open, block_open]);
584        let fm = fold_map(&doc);
585        let hl = fm.header_layout(doc.buffer(), BufferRow(0), 4).expect("row 0 is a collapsed header");
586        let raw = display_map::expand(&doc.buffer().line(0), doc.buffer().line(0).len() as u32, 4);
587        // The rendered head shrank: the [..] interior collapsed to a chip.
588        assert!(hl.head_cells() < raw, "head {} must be < raw {raw}", hl.head_cells());
589        assert_eq!(hl.head_cells(), fm.row_layout(doc.buffer(), BufferRow(0), 4).width());
590        assert_eq!(hl.tail_cell(), hl.head_cells() + FOLD_PLACEHOLDER_CELLS);
591        // The tail's `}` cell agrees between the glyph list and the col map.
592        let g: Vec<TailGlyph> = hl.tail_glyphs().collect();
593        assert_eq!(g.len(), 1);
594        assert_eq!(g[0].ch, '}');
595        assert_eq!(Some(g[0].cell), hl.tail_col_cell(g[0].col));
596    }
597
598    #[test]
599    fn tail_glyph_cells_match_tail_col_cell_with_tab_in_tail() {
600        // A tab INSIDE the visible tail: full-line tab stops, not trimmed-line.
601        let text = "f() {\nbody\n}\tx\n";
602        let block_open = text.find('{').unwrap() as u32;
603        let doc = doc_with_folds(text, &[block_open]);
604        let fm = fold_map(&doc);
605        let hl = fm.header_layout(doc.buffer(), BufferRow(0), 4).expect("collapsed header");
606        for g in hl.tail_glyphs() {
607            assert_eq!(Some(g.cell), hl.tail_col_cell(g.col), "glyph at col {} agrees with the col map", g.col);
608        }
609        // And the hit resolution lands back on each glyph's column.
610        for g in hl.tail_glyphs() {
611            assert_eq!(hl.hit(g.cell as f32, Bias::Left), HeaderHit::Tail(g.col));
612        }
613    }
614
615    // ── display_position: the one offset→(row, x) owner — an offset below a
616    //    fold resolves onto its visible display-space row ──
617
618    #[test]
619    fn display_position_follows_tail_and_hides_gap() {
620        let text = "a {\nhidden\n} tail\nafter\n";
621        let block_open = text.find('{').unwrap() as u32;
622        let doc = doc_with_folds(text, &[block_open]);
623        let fm = fold_map(&doc);
624        let buffer = doc.buffer();
625        // An offset inside the fold's gap is unrepresentable.
626        let hidden = buffer.point_to_offset(Point::new(1, 2));
627        assert_eq!(fm.display_position(buffer, hidden, 4), None);
628        // The tail `}` rides the header's display row at the tail cell.
629        let tail = buffer.point_to_offset(Point::new(2, 0));
630        let p = fm.display_position(buffer, tail, 4).expect("tail is visible");
631        assert_eq!(p.row, DisplayRow(0));
632        let hl = fm.header_layout(buffer, BufferRow(0), 4).unwrap();
633        assert_eq!(p.x, CaretCell::Cell(hl.tail_cell()));
634        // A row below the fold is shifted up by the hidden count.
635        let after = buffer.point_to_offset(Point::new(3, 0));
636        let p = fm.display_position(buffer, after, 4).expect("visible");
637        assert_eq!(p.row, DisplayRow(1), "rows 1..=2 hidden ⇒ row 3 displays at 1");
638    }
639
640    #[test]
641    fn hit_row_resolves_head_gap_and_tail() {
642        let text = "ab {\nhidden\n}\n";
643        let block_open = text.find('{').unwrap() as u32;
644        let doc = doc_with_folds(text, &[block_open]);
645        let fm = fold_map(&doc);
646        let buffer = doc.buffer();
647        let hl = fm.header_layout(buffer, BufferRow(0), 4).unwrap();
648        // Head: cell 0 → offset 0.
649        assert_eq!(fm.hit_row(buffer, BufferRow(0), 0.0, Bias::Left, 4), 0);
650        // Gap: between head end and tail → clamps to the header line's end.
651        let gap_cell = hl.head_cells() as f32 + FOLD_PLACEHOLDER_CELLS as f32 / 2.0;
652        assert_eq!(fm.hit_row(buffer, BufferRow(0), gap_cell, Bias::Left, 4), buffer.line_len(0));
653        // Tail: the tail cell → the `}` on the last row.
654        let tail_off = buffer.point_to_offset(Point::new(2, 0));
655        assert_eq!(fm.hit_row(buffer, BufferRow(0), hl.tail_cell() as f32, Bias::Left, 4), tail_off);
656    }
657
658    #[test]
659    fn display_row_at_floors_and_clamps() {
660        let doc = doc_with_folds("a\nb\nc\n", &[]);
661        let fm = fold_map(&doc);
662        assert_eq!(fm.display_row_at(-2.0), DisplayRow(0));
663        assert_eq!(fm.display_row_at(0.9), DisplayRow(0));
664        assert_eq!(fm.display_row_at(1.0), DisplayRow(1));
665        assert_eq!(fm.display_row_at(99.0), fm.max_display_row());
666    }
667
668    // ── plain rows: the projection is the identity over tab expansion ──
669
670    #[test]
671    fn plain_row_layout_is_tab_expansion() {
672        let doc = doc_with_folds("\tx = 1\n", &[]);
673        let fm = fold_map(&doc);
674        let rl = fm.row_layout(doc.buffer(), BufferRow(0), 4);
675        assert!(rl.is_plain());
676        assert_eq!(rl.display_cell(0), 0);
677        assert_eq!(rl.display_cell(1), 4, "tab expands to the stop");
678        assert_eq!(rl.width(), 4 + "x = 1".len() as u32);
679        assert_eq!(rl.hit(4.0, Bias::Left), 1);
680    }
681}