Skip to main content

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}