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}