1pub const MULTI_TAP_TIMEOUT_MS: u128 = 500;
14
15pub const MULTI_TAP_SLOP_PX: f32 = 24.0;
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum SelectionGranularity {
31 Caret,
33 Word,
35 Line,
37 Paragraph,
39}
40
41pub 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
71pub 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 previous_count.max(1).saturating_add(1)
106 } else {
107 2
108 }
109 } else {
110 raw_tap_count
111 }
112}
113
114pub fn tap_selection_granularity(tap_count: u8) -> SelectionGranularity {
121 match tap_count {
122 0 | 1 => SelectionGranularity::Caret,
123 n => match (n - 2) % 3 {
124 0 => SelectionGranularity::Word,
125 1 => SelectionGranularity::Line,
126 _ => SelectionGranularity::Paragraph,
127 },
128 }
129}
130
131pub fn find_line_boundaries(text: &str, pos: usize) -> (usize, usize) {
137 let pos = pos.min(text.len());
138 let start = text[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
139 let end = text[pos..]
140 .find('\n')
141 .map(|i| pos + i)
142 .unwrap_or(text.len());
143 (start, end)
144}
145
146pub fn find_paragraph_boundaries(text: &str, pos: usize) -> (usize, usize) {
155 let pos = pos.min(text.len());
156 let start = text[..pos]
157 .rfind("\n\n")
158 .map(|i| {
159 let mut s = i + 1;
160 while text[s..].starts_with('\n') {
161 s += 1;
162 }
163 s
164 })
165 .unwrap_or(0);
166 let end = text[pos..]
167 .find("\n\n")
168 .map(|i| pos + i)
169 .unwrap_or(text.len());
170 (start.min(end), end)
171}
172
173#[derive(Clone, Copy, Debug, PartialEq, Eq)]
185pub enum LineAffinity {
186 Upstream,
187 Downstream,
188}
189
190pub fn caret_visual_line(
205 ranges: &[std::ops::Range<usize>],
206 offset: usize,
207 affinity: LineAffinity,
208) -> (usize, usize) {
209 let mut result = (0usize, 0usize);
210 for (index, range) in ranges.iter().enumerate() {
211 if range.start <= offset {
212 if affinity == LineAffinity::Upstream
213 && index > 0
214 && range.start == offset
215 && ranges[index - 1].end == offset
216 && ranges[index - 1].start < offset
217 {
218 break;
219 }
220 result = (index, range.start);
221 } else {
222 break;
223 }
224 }
225 result
226}
227
228pub const GRAB_DIRECT_FOLLOW_DISTANCE: f32 = 8.0;
231pub const GRAB_VISIBILITY_DRIFT_DISTANCE: f32 = 48.0;
233pub const GRAB_BIAS_VIEW_CLEARANCE: f32 = 4.0;
236
237pub fn grab_bias_full_view() -> f32 {
240 -(2.0 * HANDLE_RADIUS + GRAB_BIAS_VIEW_CLEARANCE)
241}
242
243#[derive(Clone, Copy, Debug, PartialEq)]
249pub struct HandleGrabOffset {
250 initial_bias: f32,
251 bias: f32,
252 start_y: f32,
253 furthest_y: f32,
254 drift_progress: f32,
255 drifts: bool,
256}
257
258impl HandleGrabOffset {
259 pub fn begin(handle_tip_y: f32, finger_y: f32) -> Self {
260 Self::begin_for(handle_tip_y, finger_y, true)
261 }
262
263 pub fn begin_for(handle_tip_y: f32, finger_y: f32, drifts: bool) -> Self {
264 let initial_bias = handle_tip_y - finger_y;
265 Self {
266 initial_bias,
267 bias: initial_bias,
268 start_y: finger_y,
269 furthest_y: finger_y,
270 drift_progress: 0.0,
271 drifts,
272 }
273 }
274
275 pub fn track(&mut self, finger_y: f32) -> f32 {
276 if !self.drifts {
277 self.bias = self.initial_bias;
278 return self.bias;
279 }
280 self.furthest_y = self.furthest_y.max(finger_y);
281 let travel = (self.furthest_y - self.start_y - GRAB_DIRECT_FOLLOW_DISTANCE).max(0.0);
282 let t = (travel / GRAB_VISIBILITY_DRIFT_DISTANCE).clamp(0.0, 1.0);
283 self.drift_progress = t * t * (3.0 - 2.0 * t);
284 let full_view = self.initial_bias.min(grab_bias_full_view());
285 self.bias = self.initial_bias + (full_view - self.initial_bias) * self.drift_progress;
286 self.bias
287 }
288
289 pub fn bias(&self) -> f32 {
290 self.bias
291 }
292
293 pub fn drift_progress(&self) -> f32 {
294 self.drift_progress
295 }
296}
297
298#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
300pub enum HandleKind {
301 Cursor,
304 SelectionStart,
307 SelectionEnd,
310}
311
312pub const HANDLE_RADIUS: f32 = 8.0;
315
316pub const HANDLE_STEM_WIDTH: f32 = 2.0;
319
320pub const HANDLE_DOT_LINE_OVERLAP: f32 = 2.0;
324
325pub fn handle_path_data(
336 kind: HandleKind,
337 anchor_x: f32,
338 line_top: f32,
339 line_bottom: f32,
340 radius: f32,
341) -> String {
342 let r = radius.max(0.0);
343 let half_stem = HANDLE_STEM_WIDTH * 0.5;
344 let (left, right) = (anchor_x - half_stem, anchor_x + half_stem);
345 let stem = |top: f32, bottom: f32| {
346 format!("M {left} {top} L {right} {top} L {right} {bottom} L {left} {bottom} Z")
347 };
348 let dot = |cy: f32| {
349 format!(
350 "M {x0} {cy} A {r} {r} 0 1 1 {x1} {cy} A {r} {r} 0 1 1 {x0} {cy} Z",
351 x0 = anchor_x - r,
352 x1 = anchor_x + r,
353 )
354 };
355 match kind {
356 HandleKind::SelectionStart => {
357 let cy = line_top - r + HANDLE_DOT_LINE_OVERLAP;
358 format!("{} {}", stem(line_top, line_bottom), dot(cy))
359 }
360 HandleKind::SelectionEnd | HandleKind::Cursor => {
361 let cy = line_bottom + r - HANDLE_DOT_LINE_OVERLAP;
362 format!("{} {}", stem(line_top, line_bottom), dot(cy))
363 }
364 }
365}
366
367pub const HANDLE_GRAB_SLOP: f32 = 24.0;
377
378pub fn selection_after_handle_drag(
385 dragged: HandleKind,
386 fixed_edge: usize,
387 dragged_offset: usize,
388 text_len: usize,
389) -> (usize, usize) {
390 let fixed = fixed_edge.min(text_len);
391 let dragged_offset = dragged_offset.min(text_len);
392 match dragged {
393 HandleKind::SelectionStart => {
394 let start = dragged_offset.min(fixed.saturating_sub(1));
395 (start, fixed)
396 }
397 HandleKind::SelectionEnd => {
398 let end = dragged_offset.max(fixed + 1).min(text_len);
399 (fixed, end)
400 }
401 HandleKind::Cursor => (dragged_offset, dragged_offset),
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 #[test]
410 fn tap_classification_escalates_within_time_and_slop() {
411 assert_eq!(classify_tap_count(None, 0, 10.0, 10.0, 500, 24.0), 1);
412 assert_eq!(
413 classify_tap_count(Some((1, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
414 2
415 );
416 assert_eq!(
417 classify_tap_count(Some((2, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
418 3
419 );
420 assert_eq!(
421 classify_tap_count(Some((3, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
422 4
423 );
424 assert_eq!(
425 classify_tap_count(Some((4, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
426 5
427 );
428 }
429
430 #[test]
431 fn tap_classification_resets_past_timeout_or_slop() {
432 assert_eq!(
433 classify_tap_count(Some((1, 10.0, 10.0)), 600, 10.0, 10.0, 500, 24.0),
434 1
435 );
436 assert_eq!(
437 classify_tap_count(Some((1, 10.0, 10.0)), 50, 100.0, 10.0, 500, 24.0),
438 1
439 );
440 assert_eq!(
441 classify_tap_count(Some((3, 10.0, 10.0)), 600, 10.0, 10.0, 500, 24.0),
442 1
443 );
444 }
445
446 #[test]
447 fn tap_inside_selection_cycles_word_line_paragraph_by_location() {
448 use SelectionGranularity::*;
449
450 let mut count = resolve_selection_tap_count(1, 0, true, false);
451 assert_eq!(count, 2);
452 assert_eq!(tap_selection_granularity(count), Word);
453
454 count = resolve_selection_tap_count(1, count, true, true);
455 assert_eq!(count, 3);
456 assert_eq!(tap_selection_granularity(count), Line);
457
458 count = resolve_selection_tap_count(1, count, true, true);
459 assert_eq!(count, 4);
460 assert_eq!(tap_selection_granularity(count), Paragraph);
461
462 count = resolve_selection_tap_count(1, count, true, true);
463 assert_eq!(count, 5);
464 assert_eq!(tap_selection_granularity(count), Word);
465
466 let reset = resolve_selection_tap_count(1, count, true, false);
467 assert_eq!(reset, 2);
468 assert_eq!(tap_selection_granularity(reset), Word);
469 }
470
471 #[test]
472 fn resolve_tap_count_preserves_rapid_multitap_and_caret() {
473 assert_eq!(resolve_selection_tap_count(2, 1, false, false), 2);
474 assert_eq!(resolve_selection_tap_count(3, 2, true, true), 3);
475 assert_eq!(resolve_selection_tap_count(1, 4, false, true), 1);
476 }
477
478 #[test]
479 fn tap_granularity_grows_then_cycles() {
480 use SelectionGranularity::*;
481 assert_eq!(tap_selection_granularity(0), Caret);
482 assert_eq!(tap_selection_granularity(1), Caret);
483 assert_eq!(tap_selection_granularity(2), Word);
484 assert_eq!(tap_selection_granularity(3), Line);
485 assert_eq!(tap_selection_granularity(4), Paragraph);
486 assert_eq!(tap_selection_granularity(5), Word);
487 assert_eq!(tap_selection_granularity(6), Line);
488 assert_eq!(tap_selection_granularity(7), Paragraph);
489 assert_eq!(tap_selection_granularity(8), Word);
490 }
491
492 #[test]
493 fn paragraph_boundaries_span_blank_line_delimited_blocks() {
494 let text = "line one\nline two\n\nsecond para\nstill second\n\n\nthird";
495 let (s, e) = find_paragraph_boundaries(text, 3);
496 assert_eq!(&text[s..e], "line one\nline two");
497 let (s, e) = find_paragraph_boundaries(text, 20);
498 assert_eq!(&text[s..e], "second para\nstill second");
499 let (s, e) = find_paragraph_boundaries(text, text.len());
500 assert_eq!(&text[s..e], "third");
501 }
502
503 #[test]
504 fn paragraph_boundaries_no_blank_line_is_whole_text() {
505 let text = "just\none\nblock";
506 assert_eq!(find_paragraph_boundaries(text, 5), (0, text.len()));
507 }
508
509 #[test]
510 fn paragraph_boundaries_are_unicode_aware() {
511 let text = "\u{4e2d}\u{6587}\u{6bb5}\u{843d}\n\n\u{6b21}";
512 let first = "\u{4e2d}\u{6587}\u{6bb5}\u{843d}";
513 let (s, e) = find_paragraph_boundaries(text, 3);
514 assert_eq!(&text[s..e], first);
515 assert!(text.is_char_boundary(s) && text.is_char_boundary(e));
516 }
517
518 #[test]
519 fn line_boundaries_span_between_newlines() {
520 let text = "first line\nsecond line\nthird";
521 assert_eq!(find_line_boundaries(text, 15), (11, 22));
522 assert_eq!(find_line_boundaries(text, 0), (0, 10));
523 assert_eq!(find_line_boundaries(text, 25), (23, text.len()));
524 }
525
526 #[test]
527 fn line_boundaries_handle_unicode_and_empty_lines() {
528 let text = "\u{00e9}\u{00e8}\n\n\u{4e2d}\u{6587}";
529 let (start, end) = find_line_boundaries(text, "\u{00e9}\u{00e8}\n".len());
530 assert_eq!(start, end);
531 let last = find_line_boundaries(text, text.len());
532 assert_eq!(&text[last.0..last.1], "\u{4e2d}\u{6587}");
533 }
534
535 #[test]
536 fn handle_path_is_valid_and_spans_the_line_box() {
537 let (x, top, bottom) = (40.0_f32, 20.0_f32, 40.0_f32);
538 for kind in [
539 HandleKind::Cursor,
540 HandleKind::SelectionStart,
541 HandleKind::SelectionEnd,
542 ] {
543 let data = handle_path_data(kind, x, top, bottom, HANDLE_RADIUS);
544 let path = cranpose_ui_graphics::VectorPath::parse(&data)
545 .expect("handle path must be valid SVG");
546 assert!(!path.is_empty(), "{kind:?} handle must have geometry");
547 let bounds = path.bounds();
548 assert!(bounds.y <= top + 0.5, "{kind:?} must reach the line top");
549 assert!(
550 bounds.y + bounds.height >= bottom - 0.5,
551 "{kind:?} must reach the line bottom"
552 );
553 assert!((bounds.x - (x - HANDLE_RADIUS)).abs() <= 0.5);
554 assert!((bounds.x + bounds.width - (x + HANDLE_RADIUS)).abs() <= 0.5);
555 }
556 }
557
558 #[test]
559 fn selection_handle_dots_sit_on_the_correct_side_of_the_line() {
560 let (x, top, bottom, r) = (40.0_f32, 20.0_f32, 40.0_f32, HANDLE_RADIUS);
561 let eps = 0.5_f32;
562
563 let bounds = |kind: HandleKind| {
564 let data = handle_path_data(kind, x, top, bottom, r);
565 cranpose_ui_graphics::VectorPath::parse(&data)
566 .expect("valid handle path")
567 .bounds()
568 };
569
570 let start = bounds(HandleKind::SelectionStart);
571 assert!(
572 (start.y - (top - 2.0 * r + HANDLE_DOT_LINE_OVERLAP)).abs() <= eps,
573 "start dot must ride on top of the line (top at {}, expected {})",
574 start.y,
575 top - 2.0 * r + HANDLE_DOT_LINE_OVERLAP
576 );
577 assert!(
578 start.y + start.height <= bottom + eps,
579 "start handle must not extend below the line box"
580 );
581
582 for kind in [HandleKind::SelectionEnd, HandleKind::Cursor] {
583 let b = bounds(kind);
584 assert!(
585 (b.y + b.height - (bottom + 2.0 * r - HANDLE_DOT_LINE_OVERLAP)).abs() <= eps,
586 "{kind:?} dot must hang below the line (bottom at {}, expected {})",
587 b.y + b.height,
588 bottom + 2.0 * r - HANDLE_DOT_LINE_OVERLAP
589 );
590 assert!(
591 b.y >= top - eps,
592 "{kind:?} handle must not extend above the line box"
593 );
594 }
595 }
596
597 #[test]
598 fn caret_visual_line_resolves_wrapped_visual_lines() {
599 let ranges = vec![0..5usize, 5..9, 10..12];
600
601 assert_eq!(
602 caret_visual_line(&ranges, 0, LineAffinity::Downstream),
603 (0, 0)
604 );
605 assert_eq!(
606 caret_visual_line(&ranges, 3, LineAffinity::Downstream),
607 (0, 0)
608 );
609 assert_eq!(
610 caret_visual_line(&ranges, 5, LineAffinity::Downstream),
611 (1, 5)
612 );
613 assert_eq!(
614 caret_visual_line(&ranges, 7, LineAffinity::Downstream),
615 (1, 5)
616 );
617 assert_eq!(
618 caret_visual_line(&ranges, 9, LineAffinity::Downstream),
619 (1, 5)
620 );
621 assert_eq!(
622 caret_visual_line(&ranges, 11, LineAffinity::Downstream),
623 (2, 10)
624 );
625 assert_eq!(
626 caret_visual_line(&ranges, 12, LineAffinity::Downstream),
627 (2, 10)
628 );
629 }
630
631 #[test]
632 fn start_handle_grab_never_drifts() {
633 let mut grab = HandleGrabOffset::begin_for(108.0, 100.0, false);
634 assert_eq!(grab.track(108.0), 8.0);
635 assert_eq!(grab.track(160.0), 8.0, "no drift on long downward travel");
636 assert_eq!(grab.drift_progress(), 0.0);
637 }
638
639 #[test]
640 fn grab_offset_has_follow_drift_and_strict_phases() {
641 let mut grab = HandleGrabOffset::begin(108.0, 100.0);
642 assert_eq!(grab.bias(), 8.0);
643
644 let direct_bias = grab.track(108.0);
645 assert_eq!(direct_bias, 8.0, "initial travel follows exactly");
646 assert_eq!(108.0 + direct_bias, 116.0);
647
648 let drifting_bias = grab.track(132.0);
649 assert!(drifting_bias < 8.0 && drifting_bias > grab_bias_full_view());
650 assert!((0.0..1.0).contains(&grab.drift_progress()));
651
652 assert_eq!(grab.track(156.0), grab_bias_full_view());
653 assert_eq!(grab.drift_progress(), 1.0);
654 assert_eq!(grab.track(220.0), grab_bias_full_view());
655 }
656
657 #[test]
658 fn grab_offset_is_cadence_independent_and_never_unwinds() {
659 let mut single = HandleGrabOffset::begin(108.0, 100.0);
660 single.track(140.0);
661
662 let mut sampled = HandleGrabOffset::begin(108.0, 100.0);
663 for y in [104.0, 109.0, 116.0, 130.0, 140.0] {
664 sampled.track(y);
665 }
666 assert_eq!(sampled.bias(), single.bias());
667 assert_eq!(sampled.drift_progress(), single.drift_progress());
668
669 let migrated = sampled.bias();
670 sampled.track(90.0);
671 assert_eq!(
672 sampled.bias(),
673 migrated,
674 "upward travel cannot unwind drift"
675 );
676
677 let deep = grab_bias_full_view() - 10.0;
678 let mut already_visible = HandleGrabOffset::begin(deep, 0.0);
679 already_visible.track(100.0);
680 assert_eq!(already_visible.bias(), deep);
681 }
682
683 #[test]
684 fn caret_visual_line_handles_empty_ranges() {
685 assert_eq!(caret_visual_line(&[], 5, LineAffinity::Upstream), (0, 0));
686 assert_eq!(caret_visual_line(&[], 5, LineAffinity::Downstream), (0, 0));
687 }
688
689 #[test]
690 fn caret_visual_line_upstream_anchors_shared_wrap_boundary_to_upper_line() {
691 let ranges = vec![0..5usize, 5..9, 10..12];
692
693 assert_eq!(
694 caret_visual_line(&ranges, 5, LineAffinity::Upstream),
695 (0, 0)
696 );
697 assert_eq!(
698 caret_visual_line(&ranges, 5, LineAffinity::Downstream),
699 (1, 5)
700 );
701
702 assert_eq!(
703 caret_visual_line(&ranges, 3, LineAffinity::Upstream),
704 (0, 0)
705 );
706 assert_eq!(
707 caret_visual_line(&ranges, 7, LineAffinity::Upstream),
708 (1, 5)
709 );
710
711 assert_eq!(
712 caret_visual_line(&ranges, 10, LineAffinity::Upstream),
713 (2, 10)
714 );
715
716 assert_eq!(
717 caret_visual_line(&ranges, 12, LineAffinity::Upstream),
718 (2, 10)
719 );
720 }
721
722 #[test]
723 fn handle_drag_keeps_edges_from_crossing() {
724 assert_eq!(
725 selection_after_handle_drag(HandleKind::SelectionEnd, 5, 2, 20),
726 (5, 6)
727 );
728 assert_eq!(
729 selection_after_handle_drag(HandleKind::SelectionEnd, 5, 12, 20),
730 (5, 12)
731 );
732 assert_eq!(
733 selection_after_handle_drag(HandleKind::SelectionStart, 8, 10, 20),
734 (7, 8)
735 );
736 assert_eq!(
737 selection_after_handle_drag(HandleKind::SelectionStart, 8, 3, 20),
738 (3, 8)
739 );
740 assert_eq!(
741 selection_after_handle_drag(HandleKind::Cursor, 4, 9, 20),
742 (9, 9)
743 );
744 }
745}