hjkl_buffer/geom.rs
1//! Pure geometry helpers for host-driven mouse translation.
2//!
3//! These helpers are host-agnostic: they operate on doc-space coordinates
4//! (row/col in chars) and tab-expanded visual columns. The TUI host and any
5//! future GUI host use them independently after doing their own
6//! pixel-or-cell → visual-column conversion.
7//!
8//! # Cell-width semantics
9//!
10//! Both helpers measure a char with exactly the rule `hjkl-buffer-tui`'s
11//! `paint_row` uses to advance the cursor across the terminal, because being
12//! consistent with the cell the glyph was actually painted in is the entire
13//! point of these functions:
14//!
15//! | char | cells | why |
16//! | ---- | ----- | --- |
17//! | `\t` | to the next `tab_width` stop, measured from the **visual** column | tab stops are screen positions, so a preceding wide char shifts them |
18//! | `width() == Some(w)` | `w` | the `unicode-width` table — 2 for CJK and most emoji, 1 for ASCII |
19//! | `width() == Some(0)` | 0 | combining marks and variation selectors compose onto the preceding cell |
20//! | `width() == None` | 1 | control characters; see below |
21//!
22//! **Zero-width chars** (combining marks, `U+FE0F`) advance nothing and are
23//! never a cursor landing site — [`visual_col_to_char_col`] skips them, so a
24//! click or a `j` into that column lands on the base char they compose onto
25//! (or, past the composed cell, on the next real char). Verified against
26//! neovim 0.12.4: on `"ae\u{301}b"`, `virtcol` reports the mark and the `e` at
27//! the same column, and `j` from column 3 of an ASCII line lands on the `b`,
28//! never on the mark.
29//!
30//! **Control chars** (`width() == None`) count as **one** cell. This
31//! deliberately diverges from vim, which renders `^A` in two: `paint_row`
32//! resolves them through `sanitize_control`, which maps every C0/C1 control to
33//! a single-width Control Pictures glyph (`U+0001` → `␁`), and then advances
34//! by `ch.width().unwrap_or(1)` — one cell. Matching the renderer is the
35//! contract here; matching vim's `^X` notation would put the cursor one cell
36//! left of its own glyph on every line containing a control char.
37//!
38//! **Known divergence from vim: emoji presentation sequences.** vim widens
39//! `U+2764 U+FE0F` ("❤️") to two cells because it resolves the sequence as a
40//! grapheme cluster. `unicode-width` is consulted per `char` here (and in
41//! `paint_row`), so `U+2764` measures 1 (East Asian Ambiguous) and the
42//! variation selector 0 — one cell total. The two sides of hjkl agree with
43//! each other, which keeps the cursor on its glyph; they are both one cell
44//! narrower than vim would be. Fixing that means grapheme segmentation in the
45//! renderer first.
46
47use unicode_width::UnicodeWidthChar;
48
49/// Cells occupied by `ch` when it is painted starting at visual column
50/// `visual`. See the module docs for the full rule; `tab_w` must already be
51/// normalised to be non-zero.
52#[inline]
53fn cell_width(ch: char, visual: usize, tab_w: usize) -> usize {
54 if ch == '\t' {
55 tab_w - (visual % tab_w)
56 } else {
57 // `unwrap_or(1)` covers only `None` (control chars); `Some(0)` stays 0.
58 ch.width().unwrap_or(1)
59 }
60}
61
62/// Inverse of [`char_col_to_visual_col`].
63///
64/// Walk `line`'s chars accumulating painted cell width until the run of cells
65/// belonging to a char contains `visual_col`. Returns that char's index —
66/// clamped to the line's char count (i.e. the cursor can sit one past the last
67/// char, as in Insert mode).
68///
69/// Landing anywhere inside a char's cell run yields that char: the middle of a
70/// tab's expansion, or the trailing cell of a double-width glyph. This matches
71/// vim, which snaps the cursor to the character itself rather than past it —
72/// confirmed with neovim, where `j` from column 4 of `abcdefgh` onto
73/// `ab世界cd` lands on `世` (whose cells are 2 and 3).
74///
75/// Zero-width chars are skipped rather than returned; see the module docs.
76///
77/// # Examples
78///
79/// ```rust
80/// use hjkl_buffer::visual_col_to_char_col;
81///
82/// // ASCII line — exact match
83/// assert_eq!(visual_col_to_char_col("hello", 2, 4), 2);
84///
85/// // Both cells of a double-width char map back to it
86/// assert_eq!(visual_col_to_char_col("ab世界cd", 2, 4), 2);
87/// assert_eq!(visual_col_to_char_col("ab世界cd", 3, 4), 2);
88///
89/// // Past EOL clamps to char count
90/// assert_eq!(visual_col_to_char_col("hi", 99, 4), 2);
91///
92/// // Empty line always returns 0
93/// assert_eq!(visual_col_to_char_col("", 5, 4), 0);
94/// ```
95pub fn visual_col_to_char_col(line: &str, visual_col: usize, tab_width: usize) -> usize {
96 let tab_w = if tab_width == 0 { 1 } else { tab_width };
97 // Char 0 always starts at visual column 0 — including the degenerate case
98 // of a line that opens with a combining mark, where the "skip zero-width"
99 // rule below would otherwise hand back char 1.
100 if visual_col == 0 {
101 return 0;
102 }
103 let mut visual = 0usize;
104 for (i, ch) in line.chars().enumerate() {
105 let advance = cell_width(ch, visual, tab_w);
106 if advance == 0 {
107 // Composes onto the preceding cell — not a landing site.
108 continue;
109 }
110 if visual + advance > visual_col {
111 // The target cell falls inside this char's run.
112 return i;
113 }
114 visual += advance;
115 }
116 // visual_col is past EOL — clamp to char count (Insert mode can sit there).
117 line.chars().count()
118}
119
120/// Map a character index in `line` to its starting visual column (the
121/// screen-cell offset from line start of the char's FIRST cell). The inverse of
122/// [`visual_col_to_char_col`].
123///
124/// Used to anchor screen overlays (e.g. the K-key hover popup) at a document
125/// position — the doc→cell counterpart of mouse-click translation — and to
126/// store vim's `curswant`. Returning the first cell matches vim: with the
127/// cursor on `世` in `ab世界cd`, `getcurpos()[4]` (curswant) is 3 in vim's
128/// 1-based columns, i.e. the leading cell, not the trailing one.
129///
130/// `tab_width == 0` is treated as 1. `char_col` past the line end clamps to the
131/// line's full visual width. See the module docs for the per-char width rule.
132///
133/// # Examples
134///
135/// ```rust
136/// use hjkl_buffer::char_col_to_visual_col;
137///
138/// // ASCII line — visual col == char col
139/// assert_eq!(char_col_to_visual_col("hello", 2, 4), 2);
140///
141/// // A leading tab pushes the next char to the tab stop
142/// assert_eq!(char_col_to_visual_col("\tx", 1, 4), 4);
143///
144/// // A double-width char takes two cells
145/// assert_eq!(char_col_to_visual_col("ab世界cd", 3, 4), 4);
146///
147/// // Past EOL clamps to the line's visual width
148/// assert_eq!(char_col_to_visual_col("hi", 99, 4), 2);
149/// ```
150pub fn char_col_to_visual_col(line: &str, char_col: usize, tab_width: usize) -> usize {
151 let tab_w = if tab_width == 0 { 1 } else { tab_width };
152 let mut visual = 0usize;
153 for (i, ch) in line.chars().enumerate() {
154 if i == char_col {
155 return visual;
156 }
157 visual += cell_width(ch, visual, tab_w);
158 }
159 visual
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 #[test]
167 fn ascii_exact_visual_col() {
168 // "hello": each char is 1 cell wide
169 assert_eq!(visual_col_to_char_col("hello", 0, 4), 0);
170 assert_eq!(visual_col_to_char_col("hello", 1, 4), 1);
171 assert_eq!(visual_col_to_char_col("hello", 3, 4), 3);
172 assert_eq!(visual_col_to_char_col("hello", 4, 4), 4);
173 }
174
175 #[test]
176 fn tab_expansion_click_inside_run_lands_on_tab_char() {
177 // "x\tyz" with tab_width=4:
178 // x → visual 0
179 // \t → visual 1..=3 (expands to stop 4, so 3 cells wide)
180 // y → visual 4
181 // z → visual 5
182 // Clicking on visual 1, 2, or 3 should all land on char index 1 (the tab).
183 let line = "x\tyz";
184 assert_eq!(visual_col_to_char_col(line, 1, 4), 1); // inside tab → tab char
185 assert_eq!(visual_col_to_char_col(line, 2, 4), 1); // inside tab → tab char
186 assert_eq!(visual_col_to_char_col(line, 3, 4), 1); // inside tab → tab char
187 assert_eq!(visual_col_to_char_col(line, 4, 4), 2); // y
188 assert_eq!(visual_col_to_char_col(line, 5, 4), 3); // z
189 }
190
191 #[test]
192 fn tab_at_column_boundary() {
193 // Tab at visual col 4 with tab_width=4 expands to the next stop at 8.
194 // "abcd\tefg": a=0,b=1,c=2,d=3 → \t at visual 4 → visual 8, then e=8,f=9,g=10
195 let line = "abcd\tefg";
196 assert_eq!(visual_col_to_char_col(line, 4, 4), 4); // tab char itself
197 assert_eq!(visual_col_to_char_col(line, 5, 4), 4); // inside tab run → tab char
198 assert_eq!(visual_col_to_char_col(line, 7, 4), 4); // still inside tab run
199 assert_eq!(visual_col_to_char_col(line, 8, 4), 5); // e
200 }
201
202 #[test]
203 fn past_eol_clamps_to_char_count() {
204 // Insert mode allows cursor at char_count (one past last char).
205 assert_eq!(visual_col_to_char_col("hi", 99, 4), 2);
206 assert_eq!(visual_col_to_char_col("x", 100, 4), 1);
207 }
208
209 #[test]
210 fn empty_line_always_zero() {
211 assert_eq!(visual_col_to_char_col("", 0, 4), 0);
212 assert_eq!(visual_col_to_char_col("", 5, 4), 0);
213 }
214
215 #[test]
216 fn multibyte_single_cell_chars() {
217 // Greek letters are single-cell (Latin Extended / Basic Greek block).
218 // visual col == char index for single-cell multi-byte chars.
219 let line = "αβγδε"; // 5 chars, each 1 visual cell
220 assert_eq!(visual_col_to_char_col(line, 0, 4), 0);
221 assert_eq!(visual_col_to_char_col(line, 2, 4), 2);
222 assert_eq!(visual_col_to_char_col(line, 4, 4), 4);
223 assert_eq!(visual_col_to_char_col(line, 5, 4), 5); // clamp = char_count
224 }
225
226 #[test]
227 fn tab_width_one_treats_tab_as_single_cell() {
228 // tab_width=1 → tab is 1 cell wide (stop at next multiple of 1 = always +1)
229 let line = "a\tb";
230 assert_eq!(visual_col_to_char_col(line, 0, 1), 0); // a
231 assert_eq!(visual_col_to_char_col(line, 1, 1), 1); // tab
232 assert_eq!(visual_col_to_char_col(line, 2, 1), 2); // b
233 }
234
235 #[test]
236 fn tab_width_zero_treated_as_one() {
237 // tab_width=0 is normalised to 1 to avoid divide-by-zero.
238 let line = "a\tb";
239 assert_eq!(visual_col_to_char_col(line, 0, 0), 0);
240 assert_eq!(visual_col_to_char_col(line, 1, 0), 1);
241 assert_eq!(visual_col_to_char_col(line, 2, 0), 2);
242 }
243
244 #[test]
245 fn leading_tab_then_text() {
246 // "\thello" with tab_width=4: tab occupies visual 0..3, h=4, e=5, ...
247 let line = "\thello";
248 assert_eq!(visual_col_to_char_col(line, 0, 4), 0); // tab char
249 assert_eq!(visual_col_to_char_col(line, 3, 4), 0); // inside tab → tab char
250 assert_eq!(visual_col_to_char_col(line, 4, 4), 1); // h
251 assert_eq!(visual_col_to_char_col(line, 8, 4), 5); // o
252 }
253
254 // ── Wide / zero-width characters ─────────────────────────────────────
255 //
256 // Every expectation below was read out of neovim 0.12.4 (which is
257 // wide-char correct) via `virtcol('.', true)`, then converted from vim's
258 // 1-based columns to our 0-based ones.
259
260 #[test]
261 fn cjk_char_col_to_visual_col() {
262 // nvim, `ab世界cd`, virtcol start per char (1-based): 1,2,3,5,7,8
263 // → 0-based starts: 0,1,2,4,6,7; strdisplaywidth = 8.
264 let line = "ab世界cd";
265 assert_eq!(char_col_to_visual_col(line, 0, 4), 0); // a
266 assert_eq!(char_col_to_visual_col(line, 1, 4), 1); // b
267 assert_eq!(char_col_to_visual_col(line, 2, 4), 2); // 世
268 assert_eq!(char_col_to_visual_col(line, 3, 4), 4); // 界
269 assert_eq!(char_col_to_visual_col(line, 4, 4), 6); // c
270 assert_eq!(char_col_to_visual_col(line, 5, 4), 7); // d
271 assert_eq!(char_col_to_visual_col(line, 6, 4), 8); // past EOL = full width
272 }
273
274 #[test]
275 fn cjk_visual_col_to_char_col() {
276 // nvim `j` from `abcdefgh` onto `ab世界cd`: narrow byte col 4
277 // (0-based visual 3, the trailing cell of 世) lands on 世, and byte
278 // col 6 (0-based visual 5, trailing cell of 界) lands on 界.
279 let line = "ab世界cd";
280 assert_eq!(visual_col_to_char_col(line, 0, 4), 0); // a
281 assert_eq!(visual_col_to_char_col(line, 1, 4), 1); // b
282 assert_eq!(visual_col_to_char_col(line, 2, 4), 2); // 世, leading cell
283 assert_eq!(visual_col_to_char_col(line, 3, 4), 2); // 世, trailing cell
284 assert_eq!(visual_col_to_char_col(line, 4, 4), 3); // 界, leading cell
285 assert_eq!(visual_col_to_char_col(line, 5, 4), 3); // 界, trailing cell
286 assert_eq!(visual_col_to_char_col(line, 6, 4), 4); // c
287 assert_eq!(visual_col_to_char_col(line, 7, 4), 5); // d
288 assert_eq!(visual_col_to_char_col(line, 8, 4), 6); // past EOL → char count
289 }
290
291 #[test]
292 fn emoji_is_two_cells() {
293 // nvim `a🦀b`: 0-based starts 0,1,3; strdisplaywidth = 4.
294 let line = "a🦀b";
295 assert_eq!(char_col_to_visual_col(line, 1, 4), 1);
296 assert_eq!(char_col_to_visual_col(line, 2, 4), 3);
297 assert_eq!(char_col_to_visual_col(line, 3, 4), 4);
298 assert_eq!(visual_col_to_char_col(line, 1, 4), 1); // crab, leading cell
299 assert_eq!(visual_col_to_char_col(line, 2, 4), 1); // crab, trailing cell
300 assert_eq!(visual_col_to_char_col(line, 3, 4), 2); // b
301 }
302
303 #[test]
304 fn variation_selector_is_zero_width() {
305 // `a❤️b` = a, U+2764, U+FE0F, b. `unicode-width` gives U+2764 = 1
306 // (East Asian Ambiguous) and U+FE0F = 0, so the pair occupies ONE
307 // cell here and `paint_row` paints it in one cell. nvim renders the
308 // emoji-presentation sequence two cells wide; see the doc comment —
309 // matching the renderer is the contract, not matching nvim.
310 let line = "a\u{2764}\u{fe0f}b";
311 assert_eq!(char_col_to_visual_col(line, 1, 4), 1); // U+2764
312 assert_eq!(char_col_to_visual_col(line, 2, 4), 2); // U+FE0F starts after it
313 assert_eq!(char_col_to_visual_col(line, 3, 4), 2); // b — VS16 added nothing
314 assert_eq!(char_col_to_visual_col(line, 4, 4), 3);
315 // The VS16 is never a landing site: cell 2 is `b`.
316 assert_eq!(visual_col_to_char_col(line, 1, 4), 1);
317 assert_eq!(visual_col_to_char_col(line, 2, 4), 3);
318 }
319
320 #[test]
321 fn combining_mark_is_zero_width() {
322 // nvim `aéb` (a, e, U+0301, b): 0-based starts 0,1,1,2;
323 // strdisplaywidth = 3. And `j` from `abcdefgh` col 3 (0-based visual
324 // 2) onto this line lands on `b` — never on the combining mark.
325 let line = "ae\u{301}b";
326 assert_eq!(char_col_to_visual_col(line, 0, 4), 0); // a
327 assert_eq!(char_col_to_visual_col(line, 1, 4), 1); // e
328 assert_eq!(char_col_to_visual_col(line, 2, 4), 2); // U+0301 (zero width)
329 assert_eq!(char_col_to_visual_col(line, 3, 4), 2); // b
330 assert_eq!(char_col_to_visual_col(line, 4, 4), 3); // past EOL
331
332 assert_eq!(visual_col_to_char_col(line, 0, 4), 0); // a
333 assert_eq!(visual_col_to_char_col(line, 1, 4), 1); // e (the mark composes on it)
334 assert_eq!(visual_col_to_char_col(line, 2, 4), 3); // b, NOT the mark at 2
335 assert_eq!(visual_col_to_char_col(line, 3, 4), 4); // past EOL → char count
336 }
337
338 #[test]
339 fn tab_stop_measured_from_visual_not_char_column() {
340 // nvim `世\tx` with tabstop=4: 世 occupies cells 0-1, the tab
341 // expands from visual 2 to the stop at 4 (so 2 cells), x is at 4.
342 // Counting 世 as one char would have put the tab at visual 1 and x
343 // at 4 by accident — but `世\t\tx` separates the two.
344 let line = "世\tx";
345 assert_eq!(char_col_to_visual_col(line, 0, 4), 0); // 世
346 assert_eq!(char_col_to_visual_col(line, 1, 4), 2); // tab starts at 2
347 assert_eq!(char_col_to_visual_col(line, 2, 4), 4); // x
348 assert_eq!(char_col_to_visual_col(line, 3, 4), 5);
349
350 assert_eq!(visual_col_to_char_col(line, 0, 4), 0); // 世 leading
351 assert_eq!(visual_col_to_char_col(line, 1, 4), 0); // 世 trailing
352 assert_eq!(visual_col_to_char_col(line, 2, 4), 1); // inside tab run
353 assert_eq!(visual_col_to_char_col(line, 3, 4), 1); // inside tab run
354 assert_eq!(visual_col_to_char_col(line, 4, 4), 2); // x
355 }
356
357 #[test]
358 fn wide_char_before_tab_shifts_the_stop() {
359 // `ab世\tx` tab_width=4: a=0 b=1 世=2..3, so the tab starts exactly
360 // ON a stop and expands a full 4 cells to 8; x lands at 8.
361 // The naive (1-cell-per-char) rule put the tab at visual 3 and x at
362 // 4 — off by four.
363 let line = "ab世\tx";
364 assert_eq!(char_col_to_visual_col(line, 3, 4), 4); // tab
365 assert_eq!(char_col_to_visual_col(line, 4, 4), 8); // x
366 assert_eq!(visual_col_to_char_col(line, 8, 4), 4); // x
367 assert_eq!(visual_col_to_char_col(line, 7, 4), 3); // inside the tab run
368 }
369
370 #[test]
371 fn control_char_is_one_cell_matching_the_renderer() {
372 // `paint_row` uses `ch.width().unwrap_or(1)` and substitutes a
373 // single-width Control Pictures glyph (`sanitize_control`), so a C0
374 // control occupies ONE cell here. nvim renders `^A` in two; we match
375 // the renderer deliberately (see the doc comment).
376 let line = "a\u{1}b";
377 assert_eq!(char_col_to_visual_col(line, 1, 4), 1); // U+0001
378 assert_eq!(char_col_to_visual_col(line, 2, 4), 2); // b
379 assert_eq!(visual_col_to_char_col(line, 1, 4), 1);
380 assert_eq!(visual_col_to_char_col(line, 2, 4), 2);
381 }
382
383 #[test]
384 fn round_trip_char_to_visual_to_char() {
385 // Every char index that starts a cell must survive the round trip.
386 // Zero-width chars are excluded by construction: they share a cell
387 // with the char before them, so they are not landing sites.
388 for line in ["ab世界cd", "世\tx", "a🦀b", "\tab世", "abcd"] {
389 let mut visual = 0usize;
390 for (i, ch) in line.chars().enumerate() {
391 let v = char_col_to_visual_col(line, i, 4);
392 assert_eq!(v, visual, "start col for char {i} of {line:?}");
393 let w = if ch == '\t' {
394 4 - (visual % 4)
395 } else {
396 unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1)
397 };
398 if w > 0 {
399 assert_eq!(
400 visual_col_to_char_col(line, v, 4),
401 i,
402 "round trip for char {i} of {line:?}"
403 );
404 }
405 visual += w;
406 }
407 }
408 }
409}