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)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LineAffinity {
Upstream,
Downstream,
}
pub fn caret_visual_line(
ranges: &[std::ops::Range<usize>],
offset: usize,
affinity: LineAffinity,
) -> (usize, usize) {
let mut result = (0usize, 0usize);
for (index, range) in ranges.iter().enumerate() {
if range.start <= offset {
if affinity == LineAffinity::Upstream
&& index > 0
&& range.start == offset
&& ranges[index - 1].end == offset
&& ranges[index - 1].start < offset
{
break;
}
result = (index, range.start);
} else {
break;
}
}
result
}
pub const GRAB_DIRECT_FOLLOW_DISTANCE: f32 = 8.0;
pub const GRAB_VISIBILITY_DRIFT_DISTANCE: f32 = 48.0;
pub const GRAB_BIAS_VIEW_CLEARANCE: f32 = 4.0;
pub fn grab_bias_full_view() -> f32 {
-(2.0 * HANDLE_RADIUS + GRAB_BIAS_VIEW_CLEARANCE)
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct HandleGrabOffset {
initial_bias: f32,
bias: f32,
start_y: f32,
furthest_y: f32,
drift_progress: f32,
drifts: bool,
}
impl HandleGrabOffset {
pub fn begin(handle_tip_y: f32, finger_y: f32) -> Self {
Self::begin_for(handle_tip_y, finger_y, true)
}
pub fn begin_for(handle_tip_y: f32, finger_y: f32, drifts: bool) -> Self {
let initial_bias = handle_tip_y - finger_y;
Self {
initial_bias,
bias: initial_bias,
start_y: finger_y,
furthest_y: finger_y,
drift_progress: 0.0,
drifts,
}
}
pub fn track(&mut self, finger_y: f32) -> f32 {
if !self.drifts {
self.bias = self.initial_bias;
return self.bias;
}
self.furthest_y = self.furthest_y.max(finger_y);
let travel = (self.furthest_y - self.start_y - GRAB_DIRECT_FOLLOW_DISTANCE).max(0.0);
let t = (travel / GRAB_VISIBILITY_DRIFT_DISTANCE).clamp(0.0, 1.0);
self.drift_progress = t * t * (3.0 - 2.0 * t);
let full_view = self.initial_bias.min(grab_bias_full_view());
self.bias = self.initial_bias + (full_view - self.initial_bias) * self.drift_progress;
self.bias
}
pub fn bias(&self) -> f32 {
self.bias
}
pub fn drift_progress(&self) -> f32 {
self.drift_progress
}
}
#[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, LineAffinity::Downstream),
(0, 0)
);
assert_eq!(
caret_visual_line(&ranges, 3, LineAffinity::Downstream),
(0, 0)
);
assert_eq!(
caret_visual_line(&ranges, 5, LineAffinity::Downstream),
(1, 5)
);
assert_eq!(
caret_visual_line(&ranges, 7, LineAffinity::Downstream),
(1, 5)
);
assert_eq!(
caret_visual_line(&ranges, 9, LineAffinity::Downstream),
(1, 5)
);
assert_eq!(
caret_visual_line(&ranges, 11, LineAffinity::Downstream),
(2, 10)
);
assert_eq!(
caret_visual_line(&ranges, 12, LineAffinity::Downstream),
(2, 10)
);
}
#[test]
fn start_handle_grab_never_drifts() {
let mut grab = HandleGrabOffset::begin_for(108.0, 100.0, false);
assert_eq!(grab.track(108.0), 8.0);
assert_eq!(grab.track(160.0), 8.0, "no drift on long downward travel");
assert_eq!(grab.drift_progress(), 0.0);
}
#[test]
fn grab_offset_has_follow_drift_and_strict_phases() {
let mut grab = HandleGrabOffset::begin(108.0, 100.0);
assert_eq!(grab.bias(), 8.0);
let direct_bias = grab.track(108.0);
assert_eq!(direct_bias, 8.0, "initial travel follows exactly");
assert_eq!(108.0 + direct_bias, 116.0);
let drifting_bias = grab.track(132.0);
assert!(drifting_bias < 8.0 && drifting_bias > grab_bias_full_view());
assert!((0.0..1.0).contains(&grab.drift_progress()));
assert_eq!(grab.track(156.0), grab_bias_full_view());
assert_eq!(grab.drift_progress(), 1.0);
assert_eq!(grab.track(220.0), grab_bias_full_view());
}
#[test]
fn grab_offset_is_cadence_independent_and_never_unwinds() {
let mut single = HandleGrabOffset::begin(108.0, 100.0);
single.track(140.0);
let mut sampled = HandleGrabOffset::begin(108.0, 100.0);
for y in [104.0, 109.0, 116.0, 130.0, 140.0] {
sampled.track(y);
}
assert_eq!(sampled.bias(), single.bias());
assert_eq!(sampled.drift_progress(), single.drift_progress());
let migrated = sampled.bias();
sampled.track(90.0);
assert_eq!(
sampled.bias(),
migrated,
"upward travel cannot unwind drift"
);
let deep = grab_bias_full_view() - 10.0;
let mut already_visible = HandleGrabOffset::begin(deep, 0.0);
already_visible.track(100.0);
assert_eq!(already_visible.bias(), deep);
}
#[test]
fn caret_visual_line_handles_empty_ranges() {
assert_eq!(caret_visual_line(&[], 5, LineAffinity::Upstream), (0, 0));
assert_eq!(caret_visual_line(&[], 5, LineAffinity::Downstream), (0, 0));
}
#[test]
fn caret_visual_line_upstream_anchors_shared_wrap_boundary_to_upper_line() {
let ranges = vec![0..5usize, 5..9, 10..12];
assert_eq!(
caret_visual_line(&ranges, 5, LineAffinity::Upstream),
(0, 0)
);
assert_eq!(
caret_visual_line(&ranges, 5, LineAffinity::Downstream),
(1, 5)
);
assert_eq!(
caret_visual_line(&ranges, 3, LineAffinity::Upstream),
(0, 0)
);
assert_eq!(
caret_visual_line(&ranges, 7, LineAffinity::Upstream),
(1, 5)
);
assert_eq!(
caret_visual_line(&ranges, 10, LineAffinity::Upstream),
(2, 10)
);
assert_eq!(
caret_visual_line(&ranges, 12, LineAffinity::Upstream),
(2, 10)
);
}
#[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)
);
}
}