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 const HANDLE_STEM_WIDTH: f32 = 2.0;
pub const HANDLE_DOT_LINE_OVERLAP: f32 = 2.0;
pub fn handle_path_data(
kind: HandleKind,
anchor_x: f32,
line_top: f32,
line_bottom: f32,
radius: f32,
) -> String {
let r = radius.max(0.0);
let half_stem = HANDLE_STEM_WIDTH * 0.5;
let (left, right) = (anchor_x - half_stem, anchor_x + half_stem);
let stem = |top: f32, bottom: f32| {
format!("M {left} {top} L {right} {top} L {right} {bottom} L {left} {bottom} Z")
};
let dot = |cy: f32| {
format!(
"M {x0} {cy} A {r} {r} 0 1 1 {x1} {cy} A {r} {r} 0 1 1 {x0} {cy} Z",
x0 = anchor_x - r,
x1 = anchor_x + r,
)
};
match kind {
HandleKind::SelectionStart => {
let cy = line_top - r + HANDLE_DOT_LINE_OVERLAP;
format!("{} {}", stem(line_top, line_bottom), dot(cy))
}
HandleKind::SelectionEnd | HandleKind::Cursor => {
let cy = line_bottom + r - HANDLE_DOT_LINE_OVERLAP;
format!("{} {}", stem(line_top, line_bottom), dot(cy))
}
}
}
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_valid_and_spans_the_line_box() {
let (x, top, bottom) = (40.0_f32, 20.0_f32, 40.0_f32);
for kind in [
HandleKind::Cursor,
HandleKind::SelectionStart,
HandleKind::SelectionEnd,
] {
let data = handle_path_data(kind, x, top, bottom, 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.y <= top + 0.5, "{kind:?} must reach the line top");
assert!(
bounds.y + bounds.height >= bottom - 0.5,
"{kind:?} must reach the line bottom"
);
assert!((bounds.x - (x - HANDLE_RADIUS)).abs() <= 0.5);
assert!((bounds.x + bounds.width - (x + HANDLE_RADIUS)).abs() <= 0.5);
}
}
#[test]
fn selection_handle_dots_sit_on_the_correct_side_of_the_line() {
let (x, top, bottom, r) = (40.0_f32, 20.0_f32, 40.0_f32, HANDLE_RADIUS);
let eps = 0.5_f32;
let bounds = |kind: HandleKind| {
let data = handle_path_data(kind, x, top, bottom, r);
cranpose_ui_graphics::VectorPath::parse(&data)
.expect("valid handle path")
.bounds()
};
let start = bounds(HandleKind::SelectionStart);
assert!(
(start.y - (top - 2.0 * r + HANDLE_DOT_LINE_OVERLAP)).abs() <= eps,
"start dot must ride on top of the line (top at {}, expected {})",
start.y,
top - 2.0 * r + HANDLE_DOT_LINE_OVERLAP
);
assert!(
start.y + start.height <= bottom + eps,
"start handle must not extend below the line box"
);
for kind in [HandleKind::SelectionEnd, HandleKind::Cursor] {
let b = bounds(kind);
assert!(
(b.y + b.height - (bottom + 2.0 * r - HANDLE_DOT_LINE_OVERLAP)).abs() <= eps,
"{kind:?} dot must hang below the line (bottom at {}, expected {})",
b.y + b.height,
bottom + 2.0 * r - HANDLE_DOT_LINE_OVERLAP
);
assert!(
b.y >= top - eps,
"{kind:?} handle must not extend above the line box"
);
}
}
#[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)
);
}
}