pub const MULTI_TAP_TIMEOUT_MS: u128 = 500;
pub const MULTI_TAP_SLOP_PX: f32 = 24.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SelectionGranularity {
Caret,
Word,
Line,
Paragraph,
}
pub fn classify_tap_count(
previous: Option<(u8, f32, f32)>,
elapsed_ms: u128,
x: f32,
y: f32,
timeout_ms: u128,
slop_px: f32,
) -> u8 {
let Some((prev_count, prev_x, prev_y)) = previous else {
return 1;
};
let within_time = elapsed_ms <= timeout_ms;
let dx = x - prev_x;
let dy = y - prev_y;
let within_slop = dx * dx + dy * dy <= slop_px * slop_px;
if !within_time || !within_slop {
return 1;
}
prev_count.saturating_add(1)
}
pub fn resolve_selection_tap_count(
raw_tap_count: u8,
previous_count: u8,
tap_in_selection: bool,
repeat_in_place: bool,
) -> u8 {
if raw_tap_count >= 2 {
raw_tap_count
} else if tap_in_selection {
if repeat_in_place {
previous_count.max(1).saturating_add(1)
} else {
2
}
} else {
raw_tap_count
}
}
pub fn tap_selection_granularity(tap_count: u8) -> SelectionGranularity {
match tap_count {
0 | 1 => SelectionGranularity::Caret,
n => match (n - 2) % 3 {
0 => SelectionGranularity::Word,
1 => SelectionGranularity::Line,
_ => SelectionGranularity::Paragraph,
},
}
}
pub fn find_line_boundaries(text: &str, pos: usize) -> (usize, usize) {
let pos = pos.min(text.len());
let start = text[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
let end = text[pos..]
.find('\n')
.map(|i| pos + i)
.unwrap_or(text.len());
(start, end)
}
pub fn find_paragraph_boundaries(text: &str, pos: usize) -> (usize, usize) {
let pos = pos.min(text.len());
let start = text[..pos]
.rfind("\n\n")
.map(|i| {
let mut s = i + 1;
while text[s..].starts_with('\n') {
s += 1;
}
s
})
.unwrap_or(0);
let end = text[pos..]
.find("\n\n")
.map(|i| pos + i)
.unwrap_or(text.len());
(start.min(end), end)
}
pub fn caret_visual_line(ranges: &[std::ops::Range<usize>], offset: usize) -> (usize, usize) {
let mut result = (0usize, 0usize);
for (index, range) in ranges.iter().enumerate() {
if range.start <= offset {
result = (index, range.start);
} else {
break;
}
}
result
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum HandleKind {
Cursor,
SelectionStart,
SelectionEnd,
}
pub const HANDLE_RADIUS: f32 = 8.0;
pub fn handle_path_data(kind: HandleKind, tip_x: f32, tip_y: f32, radius: f32) -> String {
let r = radius.max(0.0);
let cy = tip_y + r; match kind {
HandleKind::Cursor => {
format!(
"M {tip_x} {tip_y} L {left} {cy} A {r} {r} 0 1 0 {right} {cy} Z",
left = tip_x - r,
right = tip_x + r,
)
}
HandleKind::SelectionStart => {
format!(
"M {tip_x} {tip_y} L {tip_x} {cy} A {r} {r} 0 1 1 {left} {tip_y} Z",
left = tip_x - r,
)
}
HandleKind::SelectionEnd => {
format!(
"M {tip_x} {tip_y} L {tip_x} {cy} A {r} {r} 0 1 0 {right} {tip_y} Z",
right = tip_x + r,
)
}
}
}
pub const HANDLE_GRAB_SLOP: f32 = 24.0;
pub fn selection_after_handle_drag(
dragged: HandleKind,
fixed_edge: usize,
dragged_offset: usize,
text_len: usize,
) -> (usize, usize) {
let fixed = fixed_edge.min(text_len);
let dragged_offset = dragged_offset.min(text_len);
match dragged {
HandleKind::SelectionStart => {
let start = dragged_offset.min(fixed.saturating_sub(1));
(start, fixed)
}
HandleKind::SelectionEnd => {
let end = dragged_offset.max(fixed + 1).min(text_len);
(fixed, end)
}
HandleKind::Cursor => (dragged_offset, dragged_offset),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tap_classification_escalates_within_time_and_slop() {
assert_eq!(classify_tap_count(None, 0, 10.0, 10.0, 500, 24.0), 1);
assert_eq!(
classify_tap_count(Some((1, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
2
);
assert_eq!(
classify_tap_count(Some((2, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
3
);
assert_eq!(
classify_tap_count(Some((3, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
4
);
assert_eq!(
classify_tap_count(Some((4, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
5
);
}
#[test]
fn tap_classification_resets_past_timeout_or_slop() {
assert_eq!(
classify_tap_count(Some((1, 10.0, 10.0)), 600, 10.0, 10.0, 500, 24.0),
1
);
assert_eq!(
classify_tap_count(Some((1, 10.0, 10.0)), 50, 100.0, 10.0, 500, 24.0),
1
);
assert_eq!(
classify_tap_count(Some((3, 10.0, 10.0)), 600, 10.0, 10.0, 500, 24.0),
1
);
}
#[test]
fn tap_inside_selection_cycles_word_line_paragraph_by_location() {
use SelectionGranularity::*;
let mut count = resolve_selection_tap_count(1, 0, true, false);
assert_eq!(count, 2);
assert_eq!(tap_selection_granularity(count), Word);
count = resolve_selection_tap_count(1, count, true, true);
assert_eq!(count, 3);
assert_eq!(tap_selection_granularity(count), Line);
count = resolve_selection_tap_count(1, count, true, true);
assert_eq!(count, 4);
assert_eq!(tap_selection_granularity(count), Paragraph);
count = resolve_selection_tap_count(1, count, true, true);
assert_eq!(count, 5);
assert_eq!(tap_selection_granularity(count), Word);
let reset = resolve_selection_tap_count(1, count, true, false);
assert_eq!(reset, 2);
assert_eq!(tap_selection_granularity(reset), Word);
}
#[test]
fn resolve_tap_count_preserves_rapid_multitap_and_caret() {
assert_eq!(resolve_selection_tap_count(2, 1, false, false), 2);
assert_eq!(resolve_selection_tap_count(3, 2, true, true), 3);
assert_eq!(resolve_selection_tap_count(1, 4, false, true), 1);
}
#[test]
fn tap_granularity_grows_then_cycles() {
use SelectionGranularity::*;
assert_eq!(tap_selection_granularity(0), Caret);
assert_eq!(tap_selection_granularity(1), Caret);
assert_eq!(tap_selection_granularity(2), Word);
assert_eq!(tap_selection_granularity(3), Line);
assert_eq!(tap_selection_granularity(4), Paragraph);
assert_eq!(tap_selection_granularity(5), Word);
assert_eq!(tap_selection_granularity(6), Line);
assert_eq!(tap_selection_granularity(7), Paragraph);
assert_eq!(tap_selection_granularity(8), Word);
}
#[test]
fn paragraph_boundaries_span_blank_line_delimited_blocks() {
let text = "line one\nline two\n\nsecond para\nstill second\n\n\nthird";
let (s, e) = find_paragraph_boundaries(text, 3);
assert_eq!(&text[s..e], "line one\nline two");
let (s, e) = find_paragraph_boundaries(text, 20);
assert_eq!(&text[s..e], "second para\nstill second");
let (s, e) = find_paragraph_boundaries(text, text.len());
assert_eq!(&text[s..e], "third");
}
#[test]
fn paragraph_boundaries_no_blank_line_is_whole_text() {
let text = "just\none\nblock";
assert_eq!(find_paragraph_boundaries(text, 5), (0, text.len()));
}
#[test]
fn paragraph_boundaries_are_unicode_aware() {
let text = "\u{4e2d}\u{6587}\u{6bb5}\u{843d}\n\n\u{6b21}";
let first = "\u{4e2d}\u{6587}\u{6bb5}\u{843d}";
let (s, e) = find_paragraph_boundaries(text, 3);
assert_eq!(&text[s..e], first);
assert!(text.is_char_boundary(s) && text.is_char_boundary(e));
}
#[test]
fn line_boundaries_span_between_newlines() {
let text = "first line\nsecond line\nthird";
assert_eq!(find_line_boundaries(text, 15), (11, 22));
assert_eq!(find_line_boundaries(text, 0), (0, 10));
assert_eq!(find_line_boundaries(text, 25), (23, text.len()));
}
#[test]
fn line_boundaries_handle_unicode_and_empty_lines() {
let text = "\u{00e9}\u{00e8}\n\n\u{4e2d}\u{6587}";
let (start, end) = find_line_boundaries(text, "\u{00e9}\u{00e8}\n".len());
assert_eq!(start, end);
let last = find_line_boundaries(text, text.len());
assert_eq!(&text[last.0..last.1], "\u{4e2d}\u{6587}");
}
#[test]
fn handle_path_is_non_empty_and_contains_the_tip() {
for kind in [
HandleKind::Cursor,
HandleKind::SelectionStart,
HandleKind::SelectionEnd,
] {
let data = handle_path_data(kind, 40.0, 20.0, HANDLE_RADIUS);
let path = cranpose_ui_graphics::VectorPath::parse(&data)
.expect("handle path must be valid SVG");
assert!(!path.is_empty(), "{kind:?} handle must have geometry");
let bounds = path.bounds();
assert!(bounds.x <= 40.0 + 0.5 && bounds.x + bounds.width >= 40.0 - 0.5);
assert!(bounds.y <= 20.0 + 0.5);
assert!(bounds.y + bounds.height >= 20.0 + HANDLE_RADIUS);
}
}
#[test]
fn selection_handles_point_at_the_tip_with_the_bulb_below() {
let (tip_x, tip_y, r) = (40.0_f32, 20.0_f32, HANDLE_RADIUS);
let eps = 0.5_f32;
let sample_points = |kind: HandleKind| -> Vec<cranpose_ui_graphics::Point> {
let data = handle_path_data(kind, tip_x, tip_y, r);
let path = cranpose_ui_graphics::VectorPath::parse(&data).expect("valid handle path");
path.subpaths().iter().flatten().copied().collect()
};
for kind in [
HandleKind::Cursor,
HandleKind::SelectionStart,
HandleKind::SelectionEnd,
] {
for p in sample_points(kind) {
assert!(
p.y >= tip_y - eps,
"{kind:?}: point {p:?} is above the tip line y={tip_y} (teardrop inverted)"
);
}
}
let start = sample_points(HandleKind::SelectionStart);
assert!(
start.iter().all(|p| p.x <= tip_x + eps),
"start handle must not extend right of its tip"
);
assert!(
start.iter().any(|p| p.x <= tip_x - 2.0 * r + eps),
"start handle bulb must reach a full diameter to the LEFT of the tip"
);
let end = sample_points(HandleKind::SelectionEnd);
assert!(
end.iter().all(|p| p.x >= tip_x - eps),
"end handle must not extend left of its tip"
);
assert!(
end.iter().any(|p| p.x >= tip_x + 2.0 * r - eps),
"end handle bulb must reach a full diameter to the RIGHT of the tip"
);
let cursor = sample_points(HandleKind::Cursor);
assert!(
cursor.iter().any(|p| p.x <= tip_x - r + eps)
&& cursor.iter().any(|p| p.x >= tip_x + r - eps),
"cursor handle must be symmetric about the tip"
);
}
#[test]
fn caret_visual_line_resolves_wrapped_visual_lines() {
let ranges = vec![0..5usize, 5..9, 10..12];
assert_eq!(caret_visual_line(&ranges, 0), (0, 0));
assert_eq!(caret_visual_line(&ranges, 3), (0, 0));
assert_eq!(caret_visual_line(&ranges, 5), (1, 5));
assert_eq!(caret_visual_line(&ranges, 7), (1, 5));
assert_eq!(caret_visual_line(&ranges, 9), (1, 5));
assert_eq!(caret_visual_line(&ranges, 11), (2, 10));
assert_eq!(caret_visual_line(&ranges, 12), (2, 10));
}
#[test]
fn caret_visual_line_handles_empty_ranges() {
assert_eq!(caret_visual_line(&[], 5), (0, 0));
}
#[test]
fn handle_drag_keeps_edges_from_crossing() {
assert_eq!(
selection_after_handle_drag(HandleKind::SelectionEnd, 5, 2, 20),
(5, 6)
);
assert_eq!(
selection_after_handle_drag(HandleKind::SelectionEnd, 5, 12, 20),
(5, 12)
);
assert_eq!(
selection_after_handle_drag(HandleKind::SelectionStart, 8, 10, 20),
(7, 8)
);
assert_eq!(
selection_after_handle_drag(HandleKind::SelectionStart, 8, 3, 20),
(3, 8)
);
assert_eq!(
selection_after_handle_drag(HandleKind::Cursor, 4, 9, 20),
(9, 9)
);
}
}