cranpose_ui/text_selection.rs
1//! Native-grade text selection primitives for `BasicTextField`.
2//!
3//! This module holds the pure, unit-tested building blocks the text field uses
4//! to offer Android/iOS-style selection: tap-count classification, word and
5//! line/paragraph boundary detection, and the geometry of the draggable
6//! teardrop selection handles (their shapes, their hit regions, and the
7//! selection math that a handle drag produces).
8//!
9//! Keeping these as free functions makes the touch behavior testable without a
10//! renderer and keeps `TextFieldModifierNode` focused on wiring.
11
12/// Maximum time between taps that still counts as a multi-tap, in milliseconds.
13pub const MULTI_TAP_TIMEOUT_MS: u128 = 500;
14
15/// Maximum distance (px) between consecutive taps that still counts as a
16/// multi-tap. A tap that lands far from the previous one starts a fresh
17/// single tap even if it arrives quickly, matching Android's `ViewConfiguration`
18/// double-tap slop behavior.
19pub const MULTI_TAP_SLOP_PX: f32 = 24.0;
20
21/// The unit of text a tap gesture selects, growing with the tap count the way
22/// mature text editors do (Android `TextView`, iOS `UITextView`, VS Code):
23///
24/// * 1 tap → [`Caret`](SelectionGranularity::Caret) (place the cursor);
25/// * 2 taps → [`Word`](SelectionGranularity::Word);
26/// * 3 taps → [`Line`](SelectionGranularity::Line);
27/// * 4 taps → [`Paragraph`](SelectionGranularity::Paragraph);
28/// * 5+ taps → cycle back through word → line → paragraph.
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum SelectionGranularity {
31 /// Collapsed caret (a single tap places the cursor).
32 Caret,
33 /// The word under the tap.
34 Word,
35 /// The line under the tap (delimited by `\n`).
36 Line,
37 /// The paragraph under the tap (delimited by blank lines).
38 Paragraph,
39}
40
41/// Classifies a press into a 1-based tap count from the previous tap's count,
42/// the time since it, and the distance from it.
43///
44/// `previous` is the last tap's `(count, x, y)` or `None` for the first tap. A
45/// tap increments the count only when it lands within both the timeout and the
46/// slop radius; otherwise it restarts at `1`. The count is **not** wrapped here
47/// — the granularity mapping ([`tap_selection_granularity`]) cycles instead, so
48/// the field can keep escalating (word → line → paragraph → word …) as long as
49/// the finger keeps tapping in place.
50pub fn classify_tap_count(
51 previous: Option<(u8, f32, f32)>,
52 elapsed_ms: u128,
53 x: f32,
54 y: f32,
55 timeout_ms: u128,
56 slop_px: f32,
57) -> u8 {
58 let Some((prev_count, prev_x, prev_y)) = previous else {
59 return 1;
60 };
61 let within_time = elapsed_ms <= timeout_ms;
62 let dx = x - prev_x;
63 let dy = y - prev_y;
64 let within_slop = dx * dx + dy * dy <= slop_px * slop_px;
65 if !within_time || !within_slop {
66 return 1;
67 }
68 prev_count.saturating_add(1)
69}
70
71/// Resolves the effective tap count for a press, folding in the "tap inside an
72/// existing selection" gesture so it drives the same word → line → paragraph
73/// granularity ladder ([`tap_selection_granularity`]) as a rapid multi-tap.
74///
75/// Inputs:
76/// * `raw_tap_count` — the time-and-slop-gated multi-tap count from
77/// [`classify_tap_count`] (2+ means a genuine rapid multi-tap in progress);
78/// * `previous_count` — the effective count the *previous* press resolved to
79/// (the field remembers it as its click count);
80/// * `tap_in_selection` — the press landed inside the current, non-collapsed
81/// selection;
82/// * `repeat_in_place` — the press landed within the multi-tap slop of the
83/// previous press, **independent of timing** (the same spot, tapped again).
84///
85/// Behavior:
86/// * a rapid multi-tap (`raw_tap_count >= 2`) uses its own running count, so
87/// double→word, triple→line, … keep working exactly as before;
88/// * a lone tap inside a selection selects the word under the finger, and each
89/// further tap at the *same spot* climbs the ladder (word → line → paragraph →
90/// word …) even when it arrives slowly (the multi-tap timeout has lapsed) —
91/// users tap-then-look-then-tap, so the growth is keyed on location, not time;
92/// * a lone tap at a *new* spot inside the selection re-grabs that word (resets
93/// to word); and
94/// * a lone tap outside any selection is left as-is (a single tap → caret).
95pub fn resolve_selection_tap_count(
96 raw_tap_count: u8,
97 previous_count: u8,
98 tap_in_selection: bool,
99 repeat_in_place: bool,
100) -> u8 {
101 if raw_tap_count >= 2 {
102 raw_tap_count
103 } else if tap_in_selection {
104 if repeat_in_place {
105 // Keep climbing the granularity ladder at the same spot.
106 previous_count.max(1).saturating_add(1)
107 } else {
108 // First tap inside the selection (or a tap on a different word):
109 // grab the word under the finger.
110 2
111 }
112 } else {
113 raw_tap_count
114 }
115}
116
117/// Maps a 1-based tap count to the granularity it selects.
118///
119/// A single tap places the caret; two taps select the word, three the line,
120/// four the paragraph, and every further tap cycles back through
121/// word → line → paragraph so a resting finger keeps toggling between the three
122/// range granularities (matching desktop editors and iOS).
123pub fn tap_selection_granularity(tap_count: u8) -> SelectionGranularity {
124 match tap_count {
125 0 | 1 => SelectionGranularity::Caret,
126 n => match (n - 2) % 3 {
127 0 => SelectionGranularity::Word,
128 1 => SelectionGranularity::Line,
129 _ => SelectionGranularity::Paragraph,
130 },
131 }
132}
133
134/// Returns the byte range `[start, end)` of the line containing `pos`, delimited
135/// by `\n` (the newline itself is excluded from the range).
136///
137/// Used for triple-tap line selection. Byte offsets always land on `char`
138/// boundaries because `\n` is a single-byte ASCII character.
139pub fn find_line_boundaries(text: &str, pos: usize) -> (usize, usize) {
140 let pos = pos.min(text.len());
141 let start = text[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
142 let end = text[pos..]
143 .find('\n')
144 .map(|i| pos + i)
145 .unwrap_or(text.len());
146 (start, end)
147}
148
149/// Returns the byte range `[start, end)` of the paragraph containing `pos`.
150///
151/// Paragraphs are delimited by blank lines — a run of two or more consecutive
152/// `\n` — so a fourth tap grows the selection from one line to the whole block
153/// of text around it. Text with no blank line is a single paragraph (the whole
154/// string). Byte offsets land on `char` boundaries because `\n` is single-byte
155/// ASCII. Unicode-aware: multi-byte characters inside the paragraph are spanned
156/// whole.
157pub fn find_paragraph_boundaries(text: &str, pos: usize) -> (usize, usize) {
158 let pos = pos.min(text.len());
159 // Start: just after the last blank-line separator at or before `pos`.
160 let start = text[..pos]
161 .rfind("\n\n")
162 .map(|i| {
163 // Skip the whole run of blank lines so the paragraph starts on its
164 // first non-empty line.
165 let mut s = i + 1;
166 while text[s..].starts_with('\n') {
167 s += 1;
168 }
169 s
170 })
171 .unwrap_or(0);
172 // End: the next blank-line separator at or after `pos`.
173 let end = text[pos..]
174 .find("\n\n")
175 .map(|i| pos + i)
176 .unwrap_or(text.len());
177 (start.min(end), end)
178}
179
180/// Given the source byte ranges of the **visual** (wrapped) lines and a caret
181/// byte `offset`, returns the `(visual_line_index, line_start_byte)` the caret
182/// sits on.
183///
184/// The caret belongs to the last visual line whose start is at or before
185/// `offset`, so:
186/// * a caret in the middle of a visual line resolves to that line;
187/// * a caret at a soft-wrap boundary sits at the start of the lower line;
188/// * a caret at the very end of the text sits on the last visual line.
189///
190/// This is the wrap-aware replacement for counting logical `\n` lines: without
191/// it, a caret on a wrapped line's second visual line is drawn on the first (and
192/// its x runs off the right edge), even though typing and the magnifier place it
193/// correctly. Returns `(0, 0)` when there are no ranges.
194pub fn caret_visual_line(ranges: &[std::ops::Range<usize>], offset: usize) -> (usize, usize) {
195 let mut result = (0usize, 0usize);
196 for (index, range) in ranges.iter().enumerate() {
197 if range.start <= offset {
198 result = (index, range.start);
199 } else {
200 break;
201 }
202 }
203 result
204}
205
206/// Which selection handle a lollipop represents.
207#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
208pub enum HandleKind {
209 /// The cursor handle shown for a collapsed selection: the caret stem with a
210 /// round grab dot hanging below the line (like the end handle).
211 Cursor,
212 /// The start (leftmost) selection handle: dot ON TOP of the line, stem
213 /// spanning the line box below it.
214 SelectionStart,
215 /// The end (rightmost) selection handle: stem spanning the line box, dot
216 /// hanging BELOW it.
217 SelectionEnd,
218}
219
220/// Radius of a selection/cursor handle dot in dp (the reference dot is
221/// 16.2 physical px at 3x ≈ a 16 dp circle).
222pub const HANDLE_RADIUS: f32 = 8.0;
223
224/// Width of the handle stem in dp (measured 6 px at 3x = 2 dp — the same
225/// weight as the caret).
226pub const HANDLE_STEM_WIDTH: f32 = 2.0;
227
228/// How far the dot dips INTO the line box (dp): the reference start dot's
229/// bottom sits ~5 px (1.7 dp) below the line-box top, the end dot's top ~6 px
230/// above the line-box bottom, so dot and stem read as one continuous shape.
231pub const HANDLE_DOT_LINE_OVERLAP: f32 = 2.0;
232
233/// SVG path data for a handle lollipop at a text edge.
234///
235/// `anchor_x` is the text edge (caret / selection endpoint) x; the line box
236/// spans `line_top .. line_bottom`. The stem (width
237/// [`HANDLE_STEM_WIDTH`]) always spans the line box, centered on `anchor_x`;
238/// the dot (radius `radius`) sits tangent just outside the line box — above it
239/// for [`SelectionStart`](HandleKind::SelectionStart), below it for
240/// [`SelectionEnd`](HandleKind::SelectionEnd) and
241/// [`Cursor`](HandleKind::Cursor) — overlapping the box edge by
242/// [`HANDLE_DOT_LINE_OVERLAP`] so the two read as one shape.
243pub fn handle_path_data(
244 kind: HandleKind,
245 anchor_x: f32,
246 line_top: f32,
247 line_bottom: f32,
248 radius: f32,
249) -> String {
250 let r = radius.max(0.0);
251 let half_stem = HANDLE_STEM_WIDTH * 0.5;
252 let (left, right) = (anchor_x - half_stem, anchor_x + half_stem);
253 let stem = |top: f32, bottom: f32| {
254 format!("M {left} {top} L {right} {top} L {right} {bottom} L {left} {bottom} Z")
255 };
256 let dot = |cy: f32| {
257 // Sweep flag 1 keeps the circle CLOCKWISE like the stem rectangle:
258 // with the NonZero fill rule, same-direction subpaths union; opposite
259 // windings cancel where dot and stem overlap, punching a notch at
260 // the joint.
261 format!(
262 "M {x0} {cy} A {r} {r} 0 1 1 {x1} {cy} A {r} {r} 0 1 1 {x0} {cy} Z",
263 x0 = anchor_x - r,
264 x1 = anchor_x + r,
265 )
266 };
267 match kind {
268 HandleKind::SelectionStart => {
269 // Dot on top: center a radius above the line top, minus the overlap.
270 let cy = line_top - r + HANDLE_DOT_LINE_OVERLAP;
271 format!("{} {}", stem(line_top, line_bottom), dot(cy))
272 }
273 HandleKind::SelectionEnd | HandleKind::Cursor => {
274 // Dot below: center a radius under the line bottom, minus overlap.
275 let cy = line_bottom + r - HANDLE_DOT_LINE_OVERLAP;
276 format!("{} {}", stem(line_top, line_bottom), dot(cy))
277 }
278 }
279}
280
281/// Finger-sized grab slop (px) added around a handle's drawn teardrop to enlarge
282/// its touch target, matching Android's generous handle hit area. A bare
283/// teardrop (~2·[`HANDLE_RADIUS`] across) is far smaller than a fingertip, so a
284/// touch-DOWN aimed at a handle routinely lands a few px off it; without this
285/// slop the press falls through to the field below and places a caret, which
286/// collapses the selection. The slop is applied to the sides and BELOW the tip
287/// (where the bulb and the grabbing finger sit) but never ABOVE the tip — see
288/// [`crate::widgets::selection_handle`], which keeps the box off the glyph line
289/// so a double-tap still reaches the field to escalate into a word selection.
290pub const HANDLE_GRAB_SLOP: f32 = 24.0;
291
292/// Computes the selection `(min, max)` that results from dragging one handle to
293/// a new text `offset`, keeping the opposite (fixed) edge anchored.
294///
295/// Dragging never lets the two edges cross: a dragged start clamps to just
296/// before the fixed end, and a dragged end clamps to just after the fixed
297/// start, so the selection keeps at least one selected unit.
298pub fn selection_after_handle_drag(
299 dragged: HandleKind,
300 fixed_edge: usize,
301 dragged_offset: usize,
302 text_len: usize,
303) -> (usize, usize) {
304 let fixed = fixed_edge.min(text_len);
305 let dragged_offset = dragged_offset.min(text_len);
306 match dragged {
307 HandleKind::SelectionStart => {
308 let start = dragged_offset.min(fixed.saturating_sub(1));
309 (start, fixed)
310 }
311 HandleKind::SelectionEnd => {
312 let end = dragged_offset.max(fixed + 1).min(text_len);
313 (fixed, end)
314 }
315 // The cursor handle just moves the collapsed caret.
316 HandleKind::Cursor => (dragged_offset, dragged_offset),
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn tap_classification_escalates_within_time_and_slop() {
326 assert_eq!(classify_tap_count(None, 0, 10.0, 10.0, 500, 24.0), 1);
327 assert_eq!(
328 classify_tap_count(Some((1, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
329 2
330 );
331 assert_eq!(
332 classify_tap_count(Some((2, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
333 3
334 );
335 // A fourth in-place tap keeps counting up (the granularity mapping is
336 // what cycles, not the raw count).
337 assert_eq!(
338 classify_tap_count(Some((3, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
339 4
340 );
341 assert_eq!(
342 classify_tap_count(Some((4, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
343 5
344 );
345 }
346
347 #[test]
348 fn tap_classification_resets_past_timeout_or_slop() {
349 // Too slow: restarts.
350 assert_eq!(
351 classify_tap_count(Some((1, 10.0, 10.0)), 600, 10.0, 10.0, 500, 24.0),
352 1
353 );
354 // Too far: restarts even though it is quick.
355 assert_eq!(
356 classify_tap_count(Some((1, 10.0, 10.0)), 50, 100.0, 10.0, 500, 24.0),
357 1
358 );
359 // A reset also applies from a higher count.
360 assert_eq!(
361 classify_tap_count(Some((3, 10.0, 10.0)), 600, 10.0, 10.0, 500, 24.0),
362 1
363 );
364 }
365
366 /// The tap-inside-selection ladder (bug c): a lone tap inside an existing
367 /// selection grabs the word, and every further tap AT THE SAME SPOT grows
368 /// the granularity word → line → paragraph, then cycles back to word — even
369 /// when the taps arrive too slowly to count as a rapid multi-tap (the growth
370 /// is keyed on location, not the double-tap timeout). Tapping a NEW spot
371 /// resets to word.
372 #[test]
373 fn tap_inside_selection_cycles_word_line_paragraph_by_location() {
374 use SelectionGranularity::*;
375
376 // Start: a lone (slow) tap inside a selection. raw_tap_count == 1
377 // (the timeout lapsed), but it still grabs the word.
378 let mut count = resolve_selection_tap_count(1, 0, true, false);
379 assert_eq!(count, 2);
380 assert_eq!(tap_selection_granularity(count), Word);
381
382 // Same spot again, still slow (raw == 1): grow to the line.
383 count = resolve_selection_tap_count(1, count, true, true);
384 assert_eq!(count, 3);
385 assert_eq!(tap_selection_granularity(count), Line);
386
387 // Same spot again: grow to the paragraph.
388 count = resolve_selection_tap_count(1, count, true, true);
389 assert_eq!(count, 4);
390 assert_eq!(tap_selection_granularity(count), Paragraph);
391
392 // Same spot again: cycle back to the word.
393 count = resolve_selection_tap_count(1, count, true, true);
394 assert_eq!(count, 5);
395 assert_eq!(tap_selection_granularity(count), Word);
396
397 // A tap at a NEW spot inside the selection resets to word.
398 let reset = resolve_selection_tap_count(1, count, true, false);
399 assert_eq!(reset, 2);
400 assert_eq!(tap_selection_granularity(reset), Word);
401 }
402
403 /// A genuine rapid multi-tap keeps using its own running count, so
404 /// [`resolve_selection_tap_count`] does not disturb the double→word,
405 /// triple→line ladder, and a lone tap outside a selection stays a caret.
406 #[test]
407 fn resolve_tap_count_preserves_rapid_multitap_and_caret() {
408 // Rapid multi-tap: pass the classify count straight through.
409 assert_eq!(resolve_selection_tap_count(2, 1, false, false), 2);
410 assert_eq!(resolve_selection_tap_count(3, 2, true, true), 3);
411 // Lone tap outside any selection: caret (count 1).
412 assert_eq!(resolve_selection_tap_count(1, 4, false, true), 1);
413 }
414
415 #[test]
416 fn tap_granularity_grows_then_cycles() {
417 use SelectionGranularity::*;
418 assert_eq!(tap_selection_granularity(0), Caret);
419 assert_eq!(tap_selection_granularity(1), Caret);
420 assert_eq!(tap_selection_granularity(2), Word);
421 assert_eq!(tap_selection_granularity(3), Line);
422 assert_eq!(tap_selection_granularity(4), Paragraph);
423 // Fifth tap cycles back to word, then line, then paragraph again.
424 assert_eq!(tap_selection_granularity(5), Word);
425 assert_eq!(tap_selection_granularity(6), Line);
426 assert_eq!(tap_selection_granularity(7), Paragraph);
427 assert_eq!(tap_selection_granularity(8), Word);
428 }
429
430 #[test]
431 fn paragraph_boundaries_span_blank_line_delimited_blocks() {
432 let text = "line one\nline two\n\nsecond para\nstill second\n\n\nthird";
433 // Inside the first paragraph (two lines).
434 let (s, e) = find_paragraph_boundaries(text, 3);
435 assert_eq!(&text[s..e], "line one\nline two");
436 // Inside the second paragraph.
437 let (s, e) = find_paragraph_boundaries(text, 20);
438 assert_eq!(&text[s..e], "second para\nstill second");
439 // Inside the third paragraph, after a run of THREE newlines.
440 let (s, e) = find_paragraph_boundaries(text, text.len());
441 assert_eq!(&text[s..e], "third");
442 }
443
444 #[test]
445 fn paragraph_boundaries_no_blank_line_is_whole_text() {
446 let text = "just\none\nblock";
447 assert_eq!(find_paragraph_boundaries(text, 5), (0, text.len()));
448 }
449
450 #[test]
451 fn paragraph_boundaries_are_unicode_aware() {
452 // Multi-byte characters must be spanned whole and offsets stay on char
453 // boundaries.
454 let text = "\u{4e2d}\u{6587}\u{6bb5}\u{843d}\n\n\u{6b21}";
455 let first = "\u{4e2d}\u{6587}\u{6bb5}\u{843d}";
456 let (s, e) = find_paragraph_boundaries(text, 3);
457 assert_eq!(&text[s..e], first);
458 assert!(text.is_char_boundary(s) && text.is_char_boundary(e));
459 }
460
461 #[test]
462 fn line_boundaries_span_between_newlines() {
463 let text = "first line\nsecond line\nthird";
464 // Inside the second line.
465 assert_eq!(find_line_boundaries(text, 15), (11, 22));
466 // Start of the first line.
467 assert_eq!(find_line_boundaries(text, 0), (0, 10));
468 // Inside the last (newline-terminated-absent) line.
469 assert_eq!(find_line_boundaries(text, 25), (23, text.len()));
470 }
471
472 #[test]
473 fn line_boundaries_handle_unicode_and_empty_lines() {
474 let text = "\u{00e9}\u{00e8}\n\n\u{4e2d}\u{6587}";
475 // Empty middle line: start == end at the byte after the first newline.
476 let (start, end) = find_line_boundaries(text, "\u{00e9}\u{00e8}\n".len());
477 assert_eq!(start, end);
478 // Last line spans the two CJK characters.
479 let last = find_line_boundaries(text, text.len());
480 assert_eq!(&text[last.0..last.1], "\u{4e2d}\u{6587}");
481 }
482
483 #[test]
484 fn handle_path_is_valid_and_spans_the_line_box() {
485 let (x, top, bottom) = (40.0_f32, 20.0_f32, 40.0_f32);
486 for kind in [
487 HandleKind::Cursor,
488 HandleKind::SelectionStart,
489 HandleKind::SelectionEnd,
490 ] {
491 let data = handle_path_data(kind, x, top, bottom, HANDLE_RADIUS);
492 let path = cranpose_ui_graphics::VectorPath::parse(&data)
493 .expect("handle path must be valid SVG");
494 assert!(!path.is_empty(), "{kind:?} handle must have geometry");
495 let bounds = path.bounds();
496 // The stem spans the line box, so the shape covers top..bottom.
497 assert!(bounds.y <= top + 0.5, "{kind:?} must reach the line top");
498 assert!(
499 bounds.y + bounds.height >= bottom - 0.5,
500 "{kind:?} must reach the line bottom"
501 );
502 // Horizontally centered on the anchor, a dot-radius each way.
503 assert!((bounds.x - (x - HANDLE_RADIUS)).abs() <= 0.5);
504 assert!((bounds.x + bounds.width - (x + HANDLE_RADIUS)).abs() <= 0.5);
505 }
506 }
507
508 /// The reference lollipop orientation: the start handle's dot rides ON TOP
509 /// of the line (center ~a radius above the line top), the end and cursor
510 /// dots hang BELOW it, and every dot dips [`HANDLE_DOT_LINE_OVERLAP`] into
511 /// the line box so dot + stem read as one continuous shape.
512 #[test]
513 fn selection_handle_dots_sit_on_the_correct_side_of_the_line() {
514 let (x, top, bottom, r) = (40.0_f32, 20.0_f32, 40.0_f32, HANDLE_RADIUS);
515 let eps = 0.5_f32;
516
517 let bounds = |kind: HandleKind| {
518 let data = handle_path_data(kind, x, top, bottom, r);
519 cranpose_ui_graphics::VectorPath::parse(&data)
520 .expect("valid handle path")
521 .bounds()
522 };
523
524 // Start: the shape extends a dot-diameter ABOVE the line top (minus the
525 // overlap), and not below the line bottom.
526 let start = bounds(HandleKind::SelectionStart);
527 assert!(
528 (start.y - (top - 2.0 * r + HANDLE_DOT_LINE_OVERLAP)).abs() <= eps,
529 "start dot must ride on top of the line (top at {}, expected {})",
530 start.y,
531 top - 2.0 * r + HANDLE_DOT_LINE_OVERLAP
532 );
533 assert!(
534 start.y + start.height <= bottom + eps,
535 "start handle must not extend below the line box"
536 );
537
538 // End and cursor: the shape extends a dot-diameter BELOW the line
539 // bottom (minus the overlap), and not above the line top.
540 for kind in [HandleKind::SelectionEnd, HandleKind::Cursor] {
541 let b = bounds(kind);
542 assert!(
543 (b.y + b.height - (bottom + 2.0 * r - HANDLE_DOT_LINE_OVERLAP)).abs() <= eps,
544 "{kind:?} dot must hang below the line (bottom at {}, expected {})",
545 b.y + b.height,
546 bottom + 2.0 * r - HANDLE_DOT_LINE_OVERLAP
547 );
548 assert!(
549 b.y >= top - eps,
550 "{kind:?} handle must not extend above the line box"
551 );
552 }
553 }
554
555 /// The wrap-aware caret line lookup (bug d): a caret on a wrapped line's
556 /// later visual line must resolve to that visual line (not the logical
557 /// line's first visual line), with the correct line-start byte so its x is
558 /// measured from the start of the visual line.
559 #[test]
560 fn caret_visual_line_resolves_wrapped_visual_lines() {
561 // "aaaa bbbb" wrapped into ["aaaa " (0..5), "bbbb" (5..9)], then a hard
562 // newline to a short line "cc" (10..12).
563 let ranges = vec![0..5usize, 5..9, 10..12];
564
565 // Start of the first visual line.
566 assert_eq!(caret_visual_line(&ranges, 0), (0, 0));
567 // Middle of the first visual line.
568 assert_eq!(caret_visual_line(&ranges, 3), (0, 0));
569 // Start of the second (wrapped) visual line.
570 assert_eq!(caret_visual_line(&ranges, 5), (1, 5));
571 // Middle of the second visual line — must NOT resolve to line 0.
572 assert_eq!(caret_visual_line(&ranges, 7), (1, 5));
573 // End of the wrapped logical line.
574 assert_eq!(caret_visual_line(&ranges, 9), (1, 5));
575 // The line after the hard newline.
576 assert_eq!(caret_visual_line(&ranges, 11), (2, 10));
577 // End of text.
578 assert_eq!(caret_visual_line(&ranges, 12), (2, 10));
579 }
580
581 #[test]
582 fn caret_visual_line_handles_empty_ranges() {
583 assert_eq!(caret_visual_line(&[], 5), (0, 0));
584 }
585
586 #[test]
587 fn handle_drag_keeps_edges_from_crossing() {
588 // Dragging the end handle left past the start clamps to start+1.
589 assert_eq!(
590 selection_after_handle_drag(HandleKind::SelectionEnd, 5, 2, 20),
591 (5, 6)
592 );
593 // Dragging the end handle right extends normally.
594 assert_eq!(
595 selection_after_handle_drag(HandleKind::SelectionEnd, 5, 12, 20),
596 (5, 12)
597 );
598 // Dragging the start handle right past the end clamps to end-1.
599 assert_eq!(
600 selection_after_handle_drag(HandleKind::SelectionStart, 8, 10, 20),
601 (7, 8)
602 );
603 // Dragging the start handle left extends normally.
604 assert_eq!(
605 selection_after_handle_drag(HandleKind::SelectionStart, 8, 3, 20),
606 (3, 8)
607 );
608 // The cursor handle moves a collapsed caret.
609 assert_eq!(
610 selection_after_handle_drag(HandleKind::Cursor, 4, 9, 20),
611 (9, 9)
612 );
613 }
614}