Skip to main content

lattice_cells/
coords.rs

1//! Source byte → display column, in one place.
2//!
3//! Three carriers hold the same two tables — `CellRow` (the cell
4//! path), `DisplayLine` (the display path), and the GPU peer's
5//! per-row arrays — and every one of them has to answer the same
6//! question: *given a source position, which column is it under?*
7//!
8//! Before conceal there was one term in that answer (inlay splices)
9//! and three copies of a four-line loop, which was survivable. With
10//! a second term the copies stop being survivable: an elision the
11//! cursor agrees with and the search highlight does not is a caret
12//! sitting off its own match, and the bug lives in whichever copy
13//! was not updated. So the arithmetic lands here once and the
14//! carriers delegate.
15//!
16//! Design anchor:
17//! [`docs/dev/architecture/conceal.md`](../../../docs/dev/architecture/conceal.md).
18
19/// A concealed source-byte range, `[start, end)`.
20///
21/// Hidden ranges occupy **zero** display columns. The list must be
22/// sorted ascending by `start` and non-overlapping — the builder
23/// coalesces before storing, because two overlapping ranges would
24/// have their shared width subtracted twice and every column past
25/// them on the line would be wrong.
26pub type ConcealRange = (u32, u32);
27
28/// Map a source byte to its display column.
29///
30/// `inlay_offsets` are `(orig_byte, extra_cols)` splices that *add*
31/// columns; `conceals` are ranges that *remove* them. Both are in
32/// the same already-char-resolved space the rest of the cell
33/// substrate uses — see the byte-vs-char note on
34/// [`crate::row::CellRow::byte_to_combined_col`]. In that space a
35/// hidden range removes exactly `end - start` columns, which is why
36/// conceal needs no width table of its own.
37///
38/// # A byte inside a concealed range
39///
40/// It has no column of its own, and it resolves to the column of
41/// its range's **start** — the first visible position at or before
42/// it. That is not a special case in the code below: subtracting
43/// only the concealed width that lies strictly before `byte` yields
44/// the range's start column on its own.
45///
46/// Landing there is deliberate. The alternative — letting the
47/// subtraction run past `byte` — produces a column *between* the
48/// range's endpoints, which is worse than either end precisely
49/// because it looks plausible: a caret one column into a hidden
50/// span reads as an off-by-one in the shaper rather than as a
51/// missing rule.
52pub fn source_byte_to_display_col(
53    byte: u32,
54    inlay_offsets: &[(u32, u32)],
55    conceals: &[ConcealRange],
56) -> u32 {
57    let mut col = byte;
58    for (orig_byte, width) in inlay_offsets {
59        if *orig_byte <= byte {
60            col = col.saturating_add(*width);
61        } else {
62            break;
63        }
64    }
65    subtract_conceals(col, byte, conceals)
66}
67
68/// Map a display column back to the source position under it — the
69/// inverse of [`source_byte_to_display_col`], and what a mouse click
70/// needs.
71///
72/// `max_source_byte` is the source line's length in the same
73/// already-char-resolved space the forward map uses (see the
74/// byte-vs-char note on [`crate::row::CellRow::byte_to_combined_col`]):
75/// the caller resolves char ↔ byte, this only undoes the column
76/// arithmetic. The result is clamped to `[0, max_source_byte]`, so a
77/// click past the end of a line lands on its end rather than failing.
78///
79/// # Derived from the forward map, not re-derived
80///
81/// This binary-searches [`source_byte_to_display_col`] for the largest
82/// source position whose column is still `<= col`, rather than running
83/// the inlay and conceal arithmetic backwards. That costs
84/// `O(log line_len)` forward evaluations — nothing, on a path driven by
85/// a human's hand — and buys the one property that matters: the inverse
86/// cannot disagree with the forward map, because it *is* the forward
87/// map. This module exists because three copies of the forward
88/// arithmetic drifted; a hand-written inverse would be a fourth copy
89/// with the same failure mode and a worse symptom, since a click that
90/// lands one column off reads as a shaping bug rather than a missing
91/// conceal rule.
92///
93/// The search is sound because the forward map is monotonic
94/// non-decreasing: each source position adds one column plus any inlay
95/// spliced ahead of it, and a position inside a hidden range adds zero.
96///
97/// # Where a click inside hidden or virtual text lands
98///
99/// **On a concealed range, the first VISIBLE position at that column.**
100/// Every position in `[start, end]` maps to the range's start column, so
101/// the largest of them wins — which is the position just past the hidden
102/// text, i.e. the character actually drawn there. That is the right
103/// answer for a click and the mirror image of the forward map's clamp,
104/// which sends a hidden position *back* to the range's start column.
105///
106/// **On inlay text, the source position before the splice.** Inlay
107/// columns have no source position of their own; the one before them is
108/// the only truthful answer, and it is where the caret would already be
109/// drawn.
110pub fn display_col_to_source_byte(
111    col: u32,
112    max_source_byte: u32,
113    inlay_offsets: &[(u32, u32)],
114    conceals: &[ConcealRange],
115) -> u32 {
116    let mut lo = 0u32;
117    let mut hi = max_source_byte;
118    while lo < hi {
119        // Bias the midpoint up so `lo = mid` always advances; with the
120        // usual rounding-down midpoint this loop fails to terminate on
121        // `hi == lo + 1`.
122        let mid = lo + (hi - lo).div_ceil(2);
123        if source_byte_to_display_col(mid, inlay_offsets, conceals) <= col {
124            lo = mid;
125        } else {
126            hi = mid - 1;
127        }
128    }
129    lo
130}
131
132/// Remove the concealed columns lying before `source_col` from an
133/// otherwise-computed display column.
134///
135/// Split out from [`source_byte_to_display_col`] because the GPU peer
136/// computes its inlay term differently — it resolves the source byte
137/// to a char column itself and filters inlays by *byte* rather than by
138/// column. Reconciling that is a separate question with its own
139/// non-ASCII risk; what must not happen meanwhile is two
140/// implementations of the conceal clamp, because the symptom of a
141/// stale one is a caret sitting off its own match on one renderer and
142/// not the other.
143///
144/// `col` is the display column before conceal; `source_col` is the
145/// position in the source line's own column space, which is what
146/// conceal ranges are expressed in.
147pub fn subtract_conceals(col: u32, source_col: u32, conceals: &[ConcealRange]) -> u32 {
148    let mut col = col;
149    for (start, end) in conceals {
150        if *start >= source_col {
151            break;
152        }
153        // Only the part of this range lying strictly before
154        // `source_col` is subtracted. For a position past the range
155        // that is its whole width; for one inside it, exactly enough
156        // to land on `start` — which is the clamp, falling out of the
157        // arithmetic rather than needing a branch.
158        col = col.saturating_sub(end.min(&source_col) - start);
159    }
160    col
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn no_tables_is_the_identity() {
169        for b in 0..8 {
170            assert_eq!(source_byte_to_display_col(b, &[], &[]), b);
171        }
172    }
173
174    #[test]
175    fn inlays_alone_behave_exactly_as_before() {
176        // Pinned against `CellRow::byte_to_combined_col`'s own
177        // cases, so the shared function is a drop-in for the loop
178        // it replaces rather than a re-derivation of it.
179        let inlays = [(1u32, 2u32), (3u32, 1u32)];
180        assert_eq!(source_byte_to_display_col(0, &inlays, &[]), 0);
181        assert_eq!(source_byte_to_display_col(1, &inlays, &[]), 3);
182        assert_eq!(source_byte_to_display_col(2, &inlays, &[]), 4);
183        assert_eq!(source_byte_to_display_col(3, &inlays, &[]), 6);
184        assert_eq!(source_byte_to_display_col(5, &inlays, &[]), 8);
185    }
186
187    #[test]
188    fn a_byte_before_a_concealed_range_is_untouched() {
189        let c = [(4u32, 9u32)];
190        assert_eq!(source_byte_to_display_col(0, &[], &c), 0);
191        assert_eq!(source_byte_to_display_col(3, &[], &c), 3);
192    }
193
194    #[test]
195    fn a_byte_at_the_start_of_a_concealed_range_is_its_own_column() {
196        let c = [(4u32, 9u32)];
197        assert_eq!(source_byte_to_display_col(4, &[], &c), 4);
198    }
199
200    #[test]
201    fn every_byte_inside_a_concealed_range_clamps_to_its_start() {
202        let c = [(4u32, 9u32)];
203        for b in 4..=9 {
204            assert_eq!(
205                source_byte_to_display_col(b, &[], &c),
206                4,
207                "byte {b} inside [4,9) must resolve to the range's start column"
208            );
209        }
210    }
211
212    #[test]
213    fn a_byte_after_a_concealed_range_loses_its_whole_width() {
214        let c = [(4u32, 9u32)];
215        // 5 columns hidden.
216        assert_eq!(source_byte_to_display_col(10, &[], &c), 5);
217        assert_eq!(source_byte_to_display_col(20, &[], &c), 15);
218    }
219
220    #[test]
221    fn two_concealed_ranges_accumulate() {
222        let c = [(2u32, 4u32), (8u32, 11u32)];
223        assert_eq!(source_byte_to_display_col(1, &[], &c), 1);
224        assert_eq!(source_byte_to_display_col(6, &[], &c), 4); // -2
225        assert_eq!(source_byte_to_display_col(9, &[], &c), 6); // -2, clamped into the second
226        assert_eq!(source_byte_to_display_col(15, &[], &c), 10); // -2 -3
227    }
228
229    #[test]
230    fn an_inlay_and_a_conceal_compose_in_byte_order() {
231        // `[[x][hi]]`-shaped: hide [0,4) and [6,9), inlay +3 at 5.
232        let inlays = [(5u32, 3u32)];
233        let conceals = [(0u32, 4u32), (6u32, 9u32)];
234        // Byte 4 — first visible byte. Inlay is past it; 4 hidden before.
235        assert_eq!(source_byte_to_display_col(4, &inlays, &conceals), 0);
236        // Byte 5 — the inlay anchor: +3 for the inlay, -4 hidden.
237        assert_eq!(source_byte_to_display_col(5, &inlays, &conceals), 4);
238        // Byte 12 — past everything: +3 inlay, -4 -3 hidden.
239        assert_eq!(source_byte_to_display_col(12, &inlays, &conceals), 8);
240    }
241
242    #[test]
243    fn a_line_concealed_from_its_first_byte_never_goes_negative() {
244        // The saturating path: more hidden than there are columns
245        // cannot underflow into a huge u32.
246        let c = [(0u32, 40u32)];
247        assert_eq!(source_byte_to_display_col(0, &[], &c), 0);
248        assert_eq!(source_byte_to_display_col(40, &[], &c), 0);
249        assert_eq!(source_byte_to_display_col(41, &[], &c), 1);
250    }
251
252    #[test]
253    fn a_whole_line_hidden_leaves_every_byte_at_column_zero() {
254        let c = [(0u32, 12u32)];
255        for b in 0..=12 {
256            assert_eq!(source_byte_to_display_col(b, &[], &c), 0);
257        }
258    }
259
260    // ── display_col_to_source_byte ────────────────────────────────
261
262    /// Plain text: the inverse is the identity, and a click past the
263    /// end clamps to the end rather than running away.
264    #[test]
265    fn inverse_is_the_identity_without_inlays_or_conceals() {
266        for col in 0..12u32 {
267            assert_eq!(display_col_to_source_byte(col, 10, &[], &[]), col.min(10));
268        }
269    }
270
271    /// The property that makes this worth having: for every position
272    /// that HAS a column of its own, clicking that column selects it
273    /// back. Stated over a line carrying both an inlay splice and a
274    /// conceal, because the two terms move the column in opposite
275    /// directions and a sign error survives either one alone.
276    #[test]
277    fn every_visible_position_round_trips_through_its_own_column() {
278        let inlays = [(4u32, 6u32)];
279        let conceals = [(8u32, 12u32)];
280        let len = 20;
281        for byte in 0..=len {
282            // Hidden positions are `[start, end)` — INCLUDING the start,
283            // which is hidden text even though the forward map gives it a
284            // column of its own. None of them have a column to themselves:
285            // all share the range's start column with the first visible
286            // position after it, which is the one the inverse is
287            // documented to pick.
288            if byte >= conceals[0].0 && byte < conceals[0].1 {
289                continue;
290            }
291            let col = source_byte_to_display_col(byte, &inlays, &conceals);
292            assert_eq!(
293                display_col_to_source_byte(col, len, &inlays, &conceals),
294                byte,
295                "position {byte} sits at column {col} and must come back"
296            );
297        }
298    }
299
300    /// A click on a concealed span lands on the character actually
301    /// drawn there — the first position past the hidden text — not
302    /// somewhere in the middle of bytes the user cannot see.
303    #[test]
304    fn a_click_on_a_concealed_span_lands_past_the_hidden_text() {
305        let conceals = [(3u32, 9u32)];
306        let col = source_byte_to_display_col(3, &[], &conceals);
307        assert_eq!(
308            display_col_to_source_byte(col, 20, &[], &conceals),
309            9,
310            "columns 3.. show the text after the hidden range, so that is \
311             what clicking there selects"
312        );
313    }
314
315    /// A click on inlay text resolves to the source position the inlay
316    /// is spliced in front of. Inlay columns have no source position,
317    /// and the one before them is where the caret is already drawn.
318    #[test]
319    fn a_click_on_inlay_text_lands_on_the_position_before_the_splice() {
320        // 5 columns of virtual text spliced ahead of position 4.
321        let inlays = [(4u32, 5u32)];
322        assert_eq!(display_col_to_source_byte(3, 20, &inlays, &[]), 3);
323        for inlay_col in 4..9 {
324            assert_eq!(
325                display_col_to_source_byte(inlay_col, 20, &inlays, &[]),
326                3,
327                "column {inlay_col} is virtual text, so it belongs to the \
328                 position before it"
329            );
330        }
331        assert_eq!(
332            display_col_to_source_byte(9, 20, &inlays, &[]),
333            4,
334            "the first real column past the inlay is position 4"
335        );
336    }
337
338    /// A click beyond the last column clamps to the end of the line.
339    /// Terminals report a column for every cell in the row, including
340    /// the blank ones past the text, so this is the common case rather
341    /// than a defensive one.
342    #[test]
343    fn a_click_past_the_end_of_the_line_clamps_to_its_end() {
344        assert_eq!(display_col_to_source_byte(999, 7, &[], &[]), 7);
345        assert_eq!(
346            display_col_to_source_byte(999, 0, &[], &[]),
347            0,
348            "empty line"
349        );
350    }
351
352    /// Two hidden ranges on one line: the widths compose, and the
353    /// inverse keeps agreeing with the forward map across both.
354    #[test]
355    fn the_inverse_composes_across_two_hidden_ranges() {
356        let conceals = [(2u32, 5u32), (10u32, 14u32)];
357        let len = 20;
358        for byte in [0, 1, 2, 5, 6, 9, 10, 14, 15, 20] {
359            let col = source_byte_to_display_col(byte, &[], &conceals);
360            let back = display_col_to_source_byte(col, len, &[], &conceals);
361            assert_eq!(
362                source_byte_to_display_col(back, &[], &conceals),
363                col,
364                "position {byte} → column {col} → position {back}, which must \
365                 sit at the same column"
366            );
367        }
368    }
369}