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}