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 // Widened: a virtual cell past i32::MAX (a host box corner) would wrap
291 // negative as i32 and hit column 0 instead of the line end.
292 let raw_cell = (i64::from(dc) + i64::from(extra)).clamp(0, i64::from(u32::MAX)) as u32;
293 display_map::collapse(&self.line, raw_cell, self.tab, bias)
294 }
295
296 /// The row's rendered display width in cells (tab-expanded, collapsed).
297 /// A collapsed block's inline tail begins [`FOLD_PLACEHOLDER_CELLS`] past
298 /// this — see [`HeaderLayout::tail_cell`].
299 #[must_use]
300 pub fn width(&self) -> u32 {
301 self.display_cell(self.line.len() as u32)
302 }
303
304 /// The row's collapsed chips, in display order.
305 pub fn chips(&self) -> impl Iterator<Item = Chip> + '_ {
306 self.spans.iter().map(|s| {
307 let cell = self.cell_of(s.open_cell + 1);
308 Chip {
309 cell,
310 center: cell as f32 + INLINE_CHIP_CELLS as f32 / 2.0,
311 open_col: s.fold.open - self.row_start,
312 close_col: s.fold.close - self.row_start,
313 }
314 })
315 }
316}
317
318/// A collapsed block fold's one-line display layout: the (inline-fold-
319/// aware) header text, the ` … ` placeholder gap, then the fold's real closing
320/// tail — `fn main() { … }`. The single source for the placeholder render,
321/// caret placement on the tail, selection washes, hit-testing, the hover-chip
322/// rect, and the preview anchor, so they agree to the pixel by construction.
323pub struct HeaderLayout<'a> {
324 /// The header row's own horizontal projection.
325 head: RowLayout<'a>,
326 /// The fold's last buffer row — the line the tail glyphs come from.
327 last: BufferRow,
328 tail_line: Cow<'a, str>,
329 /// Byte column where the visible tail starts on `last` (its leading ws).
330 tail_lead: u32,
331 tab: u32,
332}
333
334impl<'a> HeaderLayout<'a> {
335 /// The header row's projection (for hits resolving to [`HeaderHit::Head`]).
336 #[must_use]
337 pub fn head(&self) -> &RowLayout<'a> {
338 &self.head
339 }
340
341 /// Rendered display width of the header text — where the placeholder gap
342 /// begins. Inline-fold aware: a collapsed inline fold before the block's
343 /// opener shrinks it.
344 #[must_use]
345 pub fn head_cells(&self) -> u32 {
346 self.head.width()
347 }
348
349 /// The fractional cell at the center of the ` … ` gap, where the chip and
350 /// its `…` glyph both center.
351 #[must_use]
352 pub fn gap_center(&self) -> f32 {
353 self.head_cells() as f32 + FOLD_PLACEHOLDER_CELLS as f32 / 2.0
354 }
355
356 /// The display cell where the inline closing tail begins.
357 #[must_use]
358 pub fn tail_cell(&self) -> u32 {
359 self.head_cells() + FOLD_PLACEHOLDER_CELLS
360 }
361
362 /// The fold's last buffer row (the tail's real home).
363 #[must_use]
364 pub fn last_row(&self) -> BufferRow {
365 self.last
366 }
367
368 /// Byte column on the last row where the visible tail starts.
369 #[must_use]
370 pub fn tail_start_col(&self) -> u32 {
371 self.tail_lead
372 }
373
374 /// Tab-expanded cell of the tail's first visible column on its own row.
375 fn lead_cells(&self) -> u32 {
376 display_map::expand(&self.tail_line, self.tail_lead, self.tab)
377 }
378
379 /// Byte column on the *last* row → display cell on the header row, using
380 /// full-line tab stops (the caret/hit/selection convention). `None` for a
381 /// column in the leading whitespace before the visible tail.
382 #[must_use]
383 pub fn tail_col_cell(&self, col: u32) -> Option<u32> {
384 (col >= self.tail_lead)
385 .then(|| self.tail_cell() + display_map::expand(&self.tail_line, col, self.tab) - self.lead_cells())
386 }
387
388 /// The tail's rendered width in cells.
389 #[must_use]
390 pub fn tail_cells(&self) -> u32 {
391 display_map::expand(&self.tail_line, self.tail_line.len() as u32, self.tab) - self.lead_cells()
392 }
393
394 /// The collapsed line's total rendered width (head + gap + tail), in cells.
395 #[must_use]
396 pub fn width(&self) -> u32 {
397 self.tail_cell() + self.tail_cells()
398 }
399
400 /// The visible tail glyphs at their header-row display cells.
401 pub fn tail_glyphs(&self) -> impl Iterator<Item = TailGlyph> + '_ {
402 self.tail_line[self.tail_lead as usize..].char_indices().map(move |(i, ch)| {
403 let col = self.tail_lead + i as u32;
404 TailGlyph {
405 col,
406 cell: self.tail_col_cell(col).expect("tail glyph is at/after the lead"),
407 ch,
408 }
409 })
410 }
411
412 /// Resolve a (fractional) display cell on the collapsed header line to the
413 /// region it falls in, with the ±half-cell boundaries the hit-test has
414 /// always used: at/after the tail (minus half a cell) → the tail column;
415 /// past the header text (plus half a cell) → the gap; else the head.
416 #[must_use]
417 pub fn hit(&self, cell: f32, bias: Bias) -> HeaderHit {
418 if cell >= self.tail_cell() as f32 - 0.5 {
419 let cell_in_tail = (cell - self.tail_cell() as f32).round().max(0.0) as u32;
420 let col = display_map::collapse(&self.tail_line, self.lead_cells() + cell_in_tail, self.tab, bias);
421 HeaderHit::Tail(col)
422 } else if cell > self.head_cells() as f32 + 0.5 {
423 HeaderHit::Gap
424 } else {
425 HeaderHit::Head
426 }
427 }
428}
429
430impl FoldMap {
431 /// The horizontal projection of visible buffer `row` — built per use,
432 /// like the `FoldMap` itself; never store it.
433 #[must_use]
434 pub fn row_layout<'a>(&self, buffer: &'a Buffer, row: BufferRow, tab: u32) -> RowLayout<'a> {
435 RowLayout::new(self, buffer, row, tab)
436 }
437
438 /// The `head … tail` one-line layout of `row`, iff it is a collapsed block
439 /// fold's header.
440 #[must_use]
441 pub fn header_layout<'a>(&self, buffer: &'a Buffer, row: BufferRow, tab: u32) -> Option<HeaderLayout<'a>> {
442 let last = self.fold_at_header(row)?;
443 let head = self.row_layout(buffer, row, tab);
444 let tail_line = buffer.line(last.0);
445 let tail_lead = tail_start_col(&tail_line);
446 Some(HeaderLayout { head, last, tail_line, tail_lead, tab })
447 }
448
449 /// THE owner of "where does buffer `offset` render": its display row and
450 /// horizontal cell. Follows a collapsed block's closing tail to the header
451 /// row; clips a column hidden in an inline fold to its chip center. `None`
452 /// iff the offset is genuinely hidden — inside a block fold's gap, or on
453 /// the last row before the visible tail.
454 #[must_use]
455 pub fn display_position(&self, buffer: &Buffer, offset: u32, tab: u32) -> Option<DisplayPosition> {
456 #[cfg(any(test, debug_assertions))]
457 DISPLAY_POSITION_PROBES.with(|c| c.set(c.get() + 1));
458 crate::perf::charge(1); // complexity gate: one display-map probe
459 let p = buffer.offset_to_point(offset);
460 let row = BufferRow(p.row);
461 if !self.is_folded(row) {
462 let layout = self.row_layout(buffer, row, tab);
463 return Some(DisplayPosition { row: self.to_display_row(row), x: layout.caret_cell(p.col) });
464 }
465 // Hidden row: only a collapsed fold's closing tail is representable —
466 // it rides the header's display line.
467 let hdr = self.header_of_tail(row)?;
468 let layout = self.header_layout(buffer, hdr, tab)?;
469 let cell = layout.tail_col_cell(p.col)?;
470 Some(DisplayPosition { row: self.to_display_row(hdr), x: CaretCell::Cell(cell) })
471 }
472
473 /// Inverse of [`Self::display_position`] on one visible row: a (fractional,
474 /// unrounded) display cell → the byte offset a click there lands on,
475 /// resolving a collapsed header's gap (→ header line end) and tail
476 /// (→ the last row's column) before the plain row projection.
477 #[must_use]
478 pub fn hit_row(&self, buffer: &Buffer, row: BufferRow, cell: f32, bias: Bias, tab: u32) -> u32 {
479 if let Some(layout) = self.header_layout(buffer, row, tab) {
480 match layout.hit(cell, bias) {
481 HeaderHit::Tail(col) => return buffer.point_to_offset(Point::new(layout.last_row().0, col)),
482 HeaderHit::Gap => return buffer.point_to_offset(Point::new(row.0, buffer.line_len(row.0))),
483 HeaderHit::Head => {}
484 }
485 }
486 let layout = self.row_layout(buffer, row, tab);
487 buffer.point_to_offset(Point::new(row.0, layout.hit(cell, bias)))
488 }
489
490 /// THE pixel-y inversion policy, in row units: a (fractional) count of
491 /// display rows from the content top → the display row it falls on,
492 /// floored and clamped to the valid range. Every y-driven hit (clicks,
493 /// hover, gutter, boxes) routes through this one rule.
494 ///
495 /// `f64`, deliberately: `f32` holds fractional rows exactly only below
496 /// ~2²³ rows — past that, hits land on the wrong line and (via the
497 /// widget's px↔row maps) rendered rows visibly skip. `f64` is exact for
498 /// every u32-addressable document.
499 #[must_use]
500 pub fn display_row_at(&self, rows_from_top: f64) -> DisplayRow {
501 DisplayRow((rows_from_top.floor().max(0.0) as u32).min(self.display_row_count().saturating_sub(1)))
502 }
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508 use crate::document::Document;
509
510 /// A document with the inline pair at `inline_at` and/or the block pair at
511 /// `block_at` collapsed (each the byte offset of an opening bracket).
512 fn doc_with_folds(text: &str, fold_openers: &[u32]) -> Document {
513 let mut doc = Document::new(text).expect("test doc fits");
514 for &o in fold_openers {
515 assert!(doc.toggle_fold_opener(o), "opener {o} must be foldable");
516 }
517 doc
518 }
519
520 fn fold_map(doc: &Document) -> FoldMap {
521 FoldMap::new(doc.folds(), doc.brackets(), doc.buffer())
522 }
523
524 // ── RowLayout: the caret boundary rule — open+1 lands on the chip's left
525 // edge (a cell), never its center ──
526
527 #[test]
528 fn caret_cell_lands_on_chip_edge_not_center() {
529 // `a[bcdef]g` — fold the [..] pair (opener at byte 1).
530 let doc = doc_with_folds("a[bcdef]g", &[1]);
531 let fm = fold_map(&doc);
532 let rl = fm.row_layout(doc.buffer(), BufferRow(0), 4);
533 // col 2 == open+1: the LEFT landable edge — a cell, not the center.
534 assert_eq!(rl.caret_cell(2), CaretCell::Cell(2), "open+1 is landable at the chip's left edge");
535 // col 3 (strict interior) clips to the chip center: cell 2 + 1.5.
536 assert_eq!(rl.caret_cell(3), CaretCell::ChipCenter(2.0 + INLINE_CHIP_CELLS as f32 / 2.0));
537 // col 7 == close: the RIGHT landable edge.
538 assert_eq!(rl.caret_cell(7), CaretCell::Cell(rl.display_cell(7)));
539 // Glyphs: open+1's glyph hides even though its caret slot is landable.
540 assert!(rl.glyph_hidden(2));
541 assert!(!rl.glyph_hidden(7), "the closing bracket stays visible");
542 assert!(!rl.glyph_hidden(1), "the opening bracket stays visible");
543 }
544
545 // ── RowLayout: the inverse round-trips on multi-chip rows — the hit-test
546 // compares in display space, so a later chip resolves correctly ──
547
548 #[test]
549 fn hit_round_trips_display_cell_on_multi_chip_rows() {
550 // Two inline pairs on one row, a tab up front, a multibyte char after.
551 let text = "\tf([aa], [bb]) é";
552 let open1 = text.find("[aa").unwrap() as u32;
553 let open2 = text.find("[bb").unwrap() as u32;
554 let doc = doc_with_folds(text, &[open1, open2]);
555 let fm = fold_map(&doc);
556 let rl = fm.row_layout(doc.buffer(), BufferRow(0), 4);
557 assert_eq!(rl.chips().count(), 2);
558 // Every landable char-boundary column round-trips through the inverse.
559 let line = doc.buffer().line(0);
560 for (i, _) in line.char_indices() {
561 let col = i as u32;
562 if rl.glyph_hidden(col) {
563 continue; // interior columns resolve to the chip instead
564 }
565 assert_eq!(rl.hit(rl.display_cell(col) as f32, Bias::Left), col, "round-trip col {col}");
566 }
567 // Every cell strictly on a chip resolves to just after its `[`. The
568 // second chip is the discriminating case: it resolves correctly only
569 // because the compare is done in display space, not buffer space.
570 for chip in rl.chips().collect::<Vec<_>>() {
571 for dc in chip.cell..chip.cell + INLINE_CHIP_CELLS {
572 assert_eq!(rl.hit(dc as f32, Bias::Left), chip.open_col + 1, "chip cell {dc}");
573 }
574 }
575 }
576
577 // ── HeaderLayout: an inline fold preceding a block fold shrinks the head,
578 // so the tail is placed against the collapsed width, not the raw one ──
579
580 #[test]
581 fn header_layout_shrinks_with_preceding_inline_fold() {
582 let text = "\tcall([a, b, c]) {\n\tbody\n\t}\n";
583 let inline_open = text.find('[').unwrap() as u32;
584 let block_open = text.find('{').unwrap() as u32;
585 let doc = doc_with_folds(text, &[inline_open, block_open]);
586 let fm = fold_map(&doc);
587 let hl = fm.header_layout(doc.buffer(), BufferRow(0), 4).expect("row 0 is a collapsed header");
588 let raw = display_map::expand(&doc.buffer().line(0), doc.buffer().line(0).len() as u32, 4);
589 // The rendered head shrank: the [..] interior collapsed to a chip.
590 assert!(hl.head_cells() < raw, "head {} must be < raw {raw}", hl.head_cells());
591 assert_eq!(hl.head_cells(), fm.row_layout(doc.buffer(), BufferRow(0), 4).width());
592 assert_eq!(hl.tail_cell(), hl.head_cells() + FOLD_PLACEHOLDER_CELLS);
593 // The tail's `}` cell agrees between the glyph list and the col map.
594 let g: Vec<TailGlyph> = hl.tail_glyphs().collect();
595 assert_eq!(g.len(), 1);
596 assert_eq!(g[0].ch, '}');
597 assert_eq!(Some(g[0].cell), hl.tail_col_cell(g[0].col));
598 }
599
600 #[test]
601 fn tail_glyph_cells_match_tail_col_cell_with_tab_in_tail() {
602 // A tab INSIDE the visible tail: full-line tab stops, not trimmed-line.
603 let text = "f() {\nbody\n}\tx\n";
604 let block_open = text.find('{').unwrap() as u32;
605 let doc = doc_with_folds(text, &[block_open]);
606 let fm = fold_map(&doc);
607 let hl = fm.header_layout(doc.buffer(), BufferRow(0), 4).expect("collapsed header");
608 for g in hl.tail_glyphs() {
609 assert_eq!(Some(g.cell), hl.tail_col_cell(g.col), "glyph at col {} agrees with the col map", g.col);
610 }
611 // And the hit resolution lands back on each glyph's column.
612 for g in hl.tail_glyphs() {
613 assert_eq!(hl.hit(g.cell as f32, Bias::Left), HeaderHit::Tail(g.col));
614 }
615 }
616
617 // ── display_position: the one offset→(row, x) owner — an offset below a
618 // fold resolves onto its visible display-space row ──
619
620 #[test]
621 fn display_position_follows_tail_and_hides_gap() {
622 let text = "a {\nhidden\n} tail\nafter\n";
623 let block_open = text.find('{').unwrap() as u32;
624 let doc = doc_with_folds(text, &[block_open]);
625 let fm = fold_map(&doc);
626 let buffer = doc.buffer();
627 // An offset inside the fold's gap is unrepresentable.
628 let hidden = buffer.point_to_offset(Point::new(1, 2));
629 assert_eq!(fm.display_position(buffer, hidden, 4), None);
630 // The tail `}` rides the header's display row at the tail cell.
631 let tail = buffer.point_to_offset(Point::new(2, 0));
632 let p = fm.display_position(buffer, tail, 4).expect("tail is visible");
633 assert_eq!(p.row, DisplayRow(0));
634 let hl = fm.header_layout(buffer, BufferRow(0), 4).unwrap();
635 assert_eq!(p.x, CaretCell::Cell(hl.tail_cell()));
636 // A row below the fold is shifted up by the hidden count.
637 let after = buffer.point_to_offset(Point::new(3, 0));
638 let p = fm.display_position(buffer, after, 4).expect("visible");
639 assert_eq!(p.row, DisplayRow(1), "rows 1..=2 hidden ⇒ row 3 displays at 1");
640 }
641
642 #[test]
643 fn hit_row_resolves_head_gap_and_tail() {
644 let text = "ab {\nhidden\n}\n";
645 let block_open = text.find('{').unwrap() as u32;
646 let doc = doc_with_folds(text, &[block_open]);
647 let fm = fold_map(&doc);
648 let buffer = doc.buffer();
649 let hl = fm.header_layout(buffer, BufferRow(0), 4).unwrap();
650 // Head: cell 0 → offset 0.
651 assert_eq!(fm.hit_row(buffer, BufferRow(0), 0.0, Bias::Left, 4), 0);
652 // Gap: between head end and tail → clamps to the header line's end.
653 let gap_cell = hl.head_cells() as f32 + FOLD_PLACEHOLDER_CELLS as f32 / 2.0;
654 assert_eq!(fm.hit_row(buffer, BufferRow(0), gap_cell, Bias::Left, 4), buffer.line_len(0));
655 // Tail: the tail cell → the `}` on the last row.
656 let tail_off = buffer.point_to_offset(Point::new(2, 0));
657 assert_eq!(fm.hit_row(buffer, BufferRow(0), hl.tail_cell() as f32, Bias::Left, 4), tail_off);
658 }
659
660 #[test]
661 fn display_row_at_floors_and_clamps() {
662 let doc = doc_with_folds("a\nb\nc\n", &[]);
663 let fm = fold_map(&doc);
664 assert_eq!(fm.display_row_at(-2.0), DisplayRow(0));
665 assert_eq!(fm.display_row_at(0.9), DisplayRow(0));
666 assert_eq!(fm.display_row_at(1.0), DisplayRow(1));
667 assert_eq!(fm.display_row_at(99.0), fm.max_display_row());
668 }
669
670 // ── plain rows: the projection is the identity over tab expansion ──
671
672 #[test]
673 fn plain_row_layout_is_tab_expansion() {
674 let doc = doc_with_folds("\tx = 1\n", &[]);
675 let fm = fold_map(&doc);
676 let rl = fm.row_layout(doc.buffer(), BufferRow(0), 4);
677 assert!(rl.is_plain());
678 assert_eq!(rl.display_cell(0), 0);
679 assert_eq!(rl.display_cell(1), 4, "tab expands to the stop");
680 assert_eq!(rl.width(), 4 + "x = 1".len() as u32);
681 assert_eq!(rl.hit(4.0, Bias::Left), 1);
682 }
683}