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}