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}