Skip to main content

lumen_text/
hit_test.rs

1//! W3.4: pointer -> caret hit-test + word/line expansion.
2//!
3//! No-dep helpers - these run without needing a shaped layout (the
4//! caller passes the text and an approximate average advance) so they're
5//! usable from unit tests and from the input crate even when no shaper
6//! is wired (e.g. headless tests).
7//!
8//! The real shaping-aware hit-test path is intended to consult the
9//! per-entity `ShapedRunIndex` cache (W3.6) - once that exists, the
10//! input crate can ask the shaper for `xy_to_byte(x, y)` and feed the
11//! result through [`select_word_at_byte`] / [`select_line_at_byte`]
12//! unchanged.
13
14#![allow(missing_docs)]
15
16use unicode_segmentation::UnicodeSegmentation;
17
18/// Hit-test against a single-line run with a uniform per-char advance.
19/// Returns the byte offset whose glyph the pointer landed nearest.
20///
21/// `pointer_x` is in the same coordinate space as `origin_x` (typically
22/// the text run's leading edge, in logical pixels). `avg_advance` is the
23/// average glyph advance for the run; callers without a real shaper can
24/// pass `size_px * 0.55` as a rough fallback.
25///
26/// This is a tide-over implementation for tests and for input.rs while
27/// the full ShapedRunIndex path is being wired; the renderer-side
28/// `cosmic-text Buffer::hit` is the canonical path once the cache lands.
29pub fn hit_test_text(text: &str, origin_x: f32, pointer_x: f32, avg_advance: f32) -> usize {
30    if text.is_empty() || avg_advance <= 0.0 {
31        return 0;
32    }
33    let dx = (pointer_x - origin_x).max(0.0);
34    let target_col = (dx / avg_advance).round() as usize;
35    let mut col = 0usize;
36    let mut last_idx = 0usize;
37    for (idx, _) in text.grapheme_indices(true) {
38        if col == target_col {
39            return idx;
40        }
41        last_idx = idx;
42        col += 1;
43    }
44    // Past end -> snap to end-of-text.
45    if target_col >= col {
46        text.len()
47    } else {
48        last_idx
49    }
50}
51
52/// Expand a byte offset into a word range using Unicode word boundaries.
53/// Returns `(start, end)` in bytes; `start == end == byte` when the
54/// click landed on whitespace.
55pub fn select_word_at_byte(text: &str, byte: usize) -> (usize, usize) {
56    let byte = clamp_to_char_boundary(text, byte);
57    for (off, w) in text.split_word_bound_indices() {
58        let end = off + w.len();
59        if byte >= off && byte < end {
60            // Filter pure-whitespace words.
61            if w.chars().all(|c| c.is_whitespace()) {
62                return (byte, byte);
63            }
64            return (off, end);
65        }
66        if byte == end && end == text.len() {
67            return (off, end);
68        }
69    }
70    (byte, byte)
71}
72
73/// Expand a byte offset into a line range. `\n` is excluded from the
74/// end of the range so a triple-click select-then-replace doesn't eat
75/// the newline.
76pub fn select_line_at_byte(text: &str, byte: usize) -> (usize, usize) {
77    let byte = clamp_to_char_boundary(text, byte);
78    let start = text[..byte].rfind('\n').map(|i| i + 1).unwrap_or(0);
79    let end = text[byte..]
80        .find('\n')
81        .map(|i| byte + i)
82        .unwrap_or(text.len());
83    (start, end)
84}
85
86fn clamp_to_char_boundary(s: &str, mut at: usize) -> usize {
87    if at > s.len() {
88        at = s.len();
89    }
90    while at > 0 && !s.is_char_boundary(at) {
91        at -= 1;
92    }
93    at
94}
95
96/// Byte offset of the start of the Unicode-word segment strictly before
97/// `from`. Used for directional word motion (ctrl+<-, ctrl+Backspace) -
98/// shares the `split_word_bound_indices` walk [`select_word_at_byte`]
99/// uses to expand a point into a range, but returns the boundary itself
100/// rather than a `(start, end)` pair.
101///
102/// Whitespace runs count as their own segment (matching
103/// `unicode-segmentation`'s word-boundary rules), so repeated calls step
104/// through a text one word *or* one whitespace run at a time - the same
105/// granularity `lumen-text-edit`'s `CursorMotion::WordLeft` uses.
106pub fn prev_word_boundary(text: &str, from: usize) -> usize {
107    let from = clamp_to_char_boundary(text, from);
108    let mut prev = 0usize;
109    for (off, _) in text.split_word_bound_indices() {
110        if off >= from {
111            return prev;
112        }
113        prev = off;
114    }
115    prev
116}
117
118/// Byte offset of the end of the Unicode-word segment at/after `from`.
119/// See [`prev_word_boundary`].
120pub fn next_word_boundary(text: &str, from: usize) -> usize {
121    let from = clamp_to_char_boundary(text, from);
122    for (off, w) in text.split_word_bound_indices() {
123        let end = off + w.len();
124        if end > from {
125            return end;
126        }
127    }
128    text.len()
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn hit_test_returns_zero_for_left_of_origin() {
137        let b = hit_test_text("hello", 100.0, 50.0, 8.0);
138        assert_eq!(b, 0);
139    }
140
141    #[test]
142    fn hit_test_lands_in_middle() {
143        // 5 chars at ~8px each -> middle (~16px in) = index 2.
144        let b = hit_test_text("hello", 0.0, 16.0, 8.0);
145        assert_eq!(b, 2);
146    }
147
148    #[test]
149    fn hit_test_past_end_snaps_to_end() {
150        let b = hit_test_text("hi", 0.0, 1000.0, 8.0);
151        assert_eq!(b, 2);
152    }
153
154    #[test]
155    fn select_word_expands_around_byte() {
156        let (s, e) = select_word_at_byte("hello world", 7);
157        assert_eq!(&"hello world"[s..e], "world");
158    }
159
160    #[test]
161    fn select_word_on_whitespace_collapses() {
162        let (s, e) = select_word_at_byte("hello world", 5);
163        assert_eq!(s, e);
164    }
165
166    #[test]
167    fn select_line_excludes_newline() {
168        let text = "line one\nline two\nline three";
169        let (s, e) = select_line_at_byte(text, 12);
170        assert_eq!(&text[s..e], "line two");
171    }
172
173    #[test]
174    fn select_line_first_line() {
175        let text = "first\nsecond";
176        let (s, e) = select_line_at_byte(text, 2);
177        assert_eq!(&text[s..e], "first");
178    }
179
180    #[test]
181    fn select_line_last_line_no_trailing_newline() {
182        let text = "a\nb\nc";
183        let (s, e) = select_line_at_byte(text, 4);
184        assert_eq!(&text[s..e], "c");
185    }
186
187    #[test]
188    fn prev_word_boundary_steps_back_one_word() {
189        let text = "hello world";
190        assert_eq!(prev_word_boundary(text, 11), 6); // end -> start of "world"
191        assert_eq!(prev_word_boundary(text, 6), 5); // start of "world" -> start of " "
192        assert_eq!(prev_word_boundary(text, 5), 0); // start of " " -> start of "hello"
193        assert_eq!(prev_word_boundary(text, 0), 0); // already at start
194    }
195
196    #[test]
197    fn next_word_boundary_steps_forward_one_word() {
198        let text = "hello world";
199        assert_eq!(next_word_boundary(text, 0), 5); // start -> end of "hello"
200        assert_eq!(next_word_boundary(text, 5), 6); // end of "hello" -> end of " "
201        assert_eq!(next_word_boundary(text, 6), 11); // end of " " -> end of "world"
202        assert_eq!(next_word_boundary(text, 11), 11); // already at end
203    }
204
205    #[test]
206    fn word_boundary_respects_multibyte_char_boundaries() {
207        // The accented letters are each 2 bytes; word segmentation must not split
208        // mid-codepoint even when `from` lands inside one.
209        let text = "h\u{e9}llo w\u{f6}rld"; // bytes: h(1) e-acute(2) l l o(1) ' '(1) w o-umlaut(2) r l d(1)
210        // byte 2 is mid-'\u{e9}' (starts at 1, len 2) - clamp down to 1.
211        assert_eq!(prev_word_boundary(text, 2), 0);
212        assert_eq!(next_word_boundary(text, 2), "h\u{e9}llo".len());
213        // Full round trip from the end lands back at the "w\u{f6}rld" start.
214        let world_start = text.rfind(' ').unwrap() + 1;
215        assert_eq!(prev_word_boundary(text, text.len()), world_start);
216    }
217}