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/// Inverse of `visual_col_for_char` (which lives in `hjkl-engine`).
9///
10/// Walk `line`'s chars accumulating tab-expanded visual width until the
11/// accumulated width reaches or exceeds `visual_col`. Returns the char index
12/// where the cursor would land — clamped to the line's char count (i.e. the
13/// cursor can sit one past the last char, as in Insert mode).
14///
15/// # Tab expansion rule
16///
17/// A `\t` expands to the next `tab_width` stop:
18/// `width += tab_width - (current_visual % tab_width)`.
19///
20/// Clicking on any cell within the expanded run of a tab char lands on the
21/// tab's char index — matching Vim's behaviour where the cursor snaps to the
22/// tab character itself, not past it.
23///
24/// # Wide-char note
25///
26/// Wide-char support (CJK, emoji) is a separate concern and is NOT implemented
27/// here. This function treats every non-tab character as 1 visual cell wide,
28/// consistent with the engine's `visual_col_for_char` assumption.
29///
30/// # Examples
31///
32/// ```rust
33/// use hjkl_buffer::visual_col_to_char_col;
34///
35/// // ASCII line — exact match
36/// assert_eq!(visual_col_to_char_col("hello", 2, 4), 2);
37///
38/// // Past EOL clamps to char count
39/// assert_eq!(visual_col_to_char_col("hi", 99, 4), 2);
40///
41/// // Empty line always returns 0
42/// assert_eq!(visual_col_to_char_col("", 5, 4), 0);
43/// ```
44pub fn visual_col_to_char_col(line: &str, visual_col: usize, tab_width: usize) -> usize {
45    let tab_w = if tab_width == 0 { 1 } else { tab_width };
46    let mut visual = 0usize;
47    for (i, ch) in line.chars().enumerate() {
48        if visual >= visual_col {
49            // We've reached or passed the target before consuming this char.
50            return i;
51        }
52        let advance = if ch == '\t' {
53            tab_w - (visual % tab_w)
54        } else {
55            1
56        };
57        // If advancing this char would carry us to or past visual_col AND this
58        // char is a tab, the click landed inside the expanded tab run — vim
59        // lands the cursor on the tab char itself, so return its index.
60        if ch == '\t' && visual + advance > visual_col {
61            return i;
62        }
63        visual += advance;
64    }
65    // visual_col is past EOL — clamp to char count (Insert mode can sit there).
66    line.chars().count()
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn ascii_exact_visual_col() {
75        // "hello": each char is 1 cell wide
76        assert_eq!(visual_col_to_char_col("hello", 0, 4), 0);
77        assert_eq!(visual_col_to_char_col("hello", 1, 4), 1);
78        assert_eq!(visual_col_to_char_col("hello", 3, 4), 3);
79        assert_eq!(visual_col_to_char_col("hello", 4, 4), 4);
80    }
81
82    #[test]
83    fn tab_expansion_click_inside_run_lands_on_tab_char() {
84        // "x\tyz" with tab_width=4:
85        //   x  → visual 0
86        //   \t → visual 1..=3 (expands to stop 4, so 3 cells wide)
87        //   y  → visual 4
88        //   z  → visual 5
89        // Clicking on visual 1, 2, or 3 should all land on char index 1 (the tab).
90        let line = "x\tyz";
91        assert_eq!(visual_col_to_char_col(line, 1, 4), 1); // inside tab → tab char
92        assert_eq!(visual_col_to_char_col(line, 2, 4), 1); // inside tab → tab char
93        assert_eq!(visual_col_to_char_col(line, 3, 4), 1); // inside tab → tab char
94        assert_eq!(visual_col_to_char_col(line, 4, 4), 2); // y
95        assert_eq!(visual_col_to_char_col(line, 5, 4), 3); // z
96    }
97
98    #[test]
99    fn tab_at_column_boundary() {
100        // Tab at visual col 4 with tab_width=4 expands to the next stop at 8.
101        // "abcd\tefg": a=0,b=1,c=2,d=3 → \t at visual 4 → visual 8, then e=8,f=9,g=10
102        let line = "abcd\tefg";
103        assert_eq!(visual_col_to_char_col(line, 4, 4), 4); // tab char itself
104        assert_eq!(visual_col_to_char_col(line, 5, 4), 4); // inside tab run → tab char
105        assert_eq!(visual_col_to_char_col(line, 7, 4), 4); // still inside tab run
106        assert_eq!(visual_col_to_char_col(line, 8, 4), 5); // e
107    }
108
109    #[test]
110    fn past_eol_clamps_to_char_count() {
111        // Insert mode allows cursor at char_count (one past last char).
112        assert_eq!(visual_col_to_char_col("hi", 99, 4), 2);
113        assert_eq!(visual_col_to_char_col("x", 100, 4), 1);
114    }
115
116    #[test]
117    fn empty_line_always_zero() {
118        assert_eq!(visual_col_to_char_col("", 0, 4), 0);
119        assert_eq!(visual_col_to_char_col("", 5, 4), 0);
120    }
121
122    #[test]
123    fn multibyte_single_cell_chars() {
124        // Greek letters are single-cell (Latin Extended / Basic Greek block).
125        // visual col == char index for single-cell multi-byte chars.
126        let line = "αβγδε"; // 5 chars, each 1 visual cell
127        assert_eq!(visual_col_to_char_col(line, 0, 4), 0);
128        assert_eq!(visual_col_to_char_col(line, 2, 4), 2);
129        assert_eq!(visual_col_to_char_col(line, 4, 4), 4);
130        assert_eq!(visual_col_to_char_col(line, 5, 4), 5); // clamp = char_count
131    }
132
133    #[test]
134    fn tab_width_one_treats_tab_as_single_cell() {
135        // tab_width=1 → tab is 1 cell wide (stop at next multiple of 1 = always +1)
136        let line = "a\tb";
137        assert_eq!(visual_col_to_char_col(line, 0, 1), 0); // a
138        assert_eq!(visual_col_to_char_col(line, 1, 1), 1); // tab
139        assert_eq!(visual_col_to_char_col(line, 2, 1), 2); // b
140    }
141
142    #[test]
143    fn tab_width_zero_treated_as_one() {
144        // tab_width=0 is normalised to 1 to avoid divide-by-zero.
145        let line = "a\tb";
146        assert_eq!(visual_col_to_char_col(line, 0, 0), 0);
147        assert_eq!(visual_col_to_char_col(line, 1, 0), 1);
148        assert_eq!(visual_col_to_char_col(line, 2, 0), 2);
149    }
150
151    #[test]
152    fn leading_tab_then_text() {
153        // "\thello" with tab_width=4: tab occupies visual 0..3, h=4, e=5, ...
154        let line = "\thello";
155        assert_eq!(visual_col_to_char_col(line, 0, 4), 0); // tab char
156        assert_eq!(visual_col_to_char_col(line, 3, 4), 0); // inside tab → tab char
157        assert_eq!(visual_col_to_char_col(line, 4, 4), 1); // h
158        assert_eq!(visual_col_to_char_col(line, 8, 4), 5); // o
159    }
160}