use cranpose_ui_graphics::Rect;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TapCount {
Single,
Double,
Triple,
}
impl TapCount {
pub fn as_u8(self) -> u8 {
match self {
TapCount::Single => 1,
TapCount::Double => 2,
TapCount::Triple => 3,
}
}
}
impl TryFrom<u8> for TapCount {
type Error = ();
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
1 => Ok(TapCount::Single),
2 => Ok(TapCount::Double),
3 => Ok(TapCount::Triple),
_ => Err(()),
}
}
}
pub const MULTI_TAP_TIMEOUT_MS: u128 = 500;
pub const MULTI_TAP_SLOP_PX: f32 = 24.0;
pub fn classify_tap(
previous: Option<(TapCount, f32, f32)>,
elapsed_ms: u128,
x: f32,
y: f32,
timeout_ms: u128,
slop_px: f32,
) -> TapCount {
let Some((prev_count, prev_x, prev_y)) = previous else {
return TapCount::Single;
};
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 TapCount::Single;
}
match prev_count {
TapCount::Single => TapCount::Double,
TapCount::Double => TapCount::Triple,
TapCount::Triple => TapCount::Single,
}
}
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)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HandleKind {
Cursor,
SelectionStart,
SelectionEnd,
}
pub const HANDLE_RADIUS: f32 = 8.0;
pub const HANDLE_TOUCH_SLOP: f32 = 12.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 0 {left} {tip_y} Z",
left = tip_x - r,
)
}
HandleKind::SelectionEnd => {
format!(
"M {tip_x} {tip_y} L {right} {tip_y} A {r} {r} 0 1 0 {tip_x} {cy} Z",
right = tip_x + r,
)
}
}
}
pub const HANDLE_TOUCH_PADDING: f32 = 12.0;
pub fn handle_hit_rect(kind: HandleKind, tip_x: f32, tip_y: f32, radius: f32, slop: f32) -> Rect {
let r = radius.max(0.0);
let slop = slop.max(0.0);
let (left, right) = match kind {
HandleKind::Cursor => (tip_x - r, tip_x + r),
HandleKind::SelectionStart => (tip_x - 2.0 * r, tip_x + r),
HandleKind::SelectionEnd => (tip_x - r, tip_x + 2.0 * r),
};
Rect {
x: left - slop,
y: tip_y - slop,
width: (right - left) + 2.0 * slop,
height: 2.0 * r + 2.0 * slop,
}
}
pub fn hit_test_handles(
handles: &[(HandleKind, f32, f32)],
x: f32,
y: f32,
radius: f32,
slop: f32,
) -> Option<HandleKind> {
let mut best: Option<(HandleKind, f32)> = None;
for &(kind, tip_x, tip_y) in handles {
let rect = handle_hit_rect(kind, tip_x, tip_y, radius, slop);
let inside =
x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
if !inside {
continue;
}
let cx = tip_x;
let cy = tip_y + radius;
let dist_sq = (x - cx) * (x - cx) + (y - cy) * (y - cy);
if best
.map(|(_, best_dist)| dist_sq < best_dist)
.unwrap_or(true)
{
best = Some((kind, dist_sq));
}
}
best.map(|(kind, _)| kind)
}
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(None, 0, 10.0, 10.0, 500, 24.0),
TapCount::Single
);
assert_eq!(
classify_tap(
Some((TapCount::Single, 10.0, 10.0)),
100,
11.0,
12.0,
500,
24.0
),
TapCount::Double
);
assert_eq!(
classify_tap(
Some((TapCount::Double, 10.0, 10.0)),
100,
11.0,
12.0,
500,
24.0
),
TapCount::Triple
);
assert_eq!(
classify_tap(
Some((TapCount::Triple, 10.0, 10.0)),
100,
11.0,
12.0,
500,
24.0
),
TapCount::Single
);
}
#[test]
fn tap_classification_resets_past_timeout_or_slop() {
assert_eq!(
classify_tap(
Some((TapCount::Single, 10.0, 10.0)),
600,
10.0,
10.0,
500,
24.0
),
TapCount::Single
);
assert_eq!(
classify_tap(
Some((TapCount::Single, 10.0, 10.0)),
50,
100.0,
10.0,
500,
24.0
),
TapCount::Single
);
}
#[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 handle_hit_rect_covers_tip_and_bulb_with_slop() {
let rect = handle_hit_rect(
HandleKind::Cursor,
40.0,
20.0,
HANDLE_RADIUS,
HANDLE_TOUCH_SLOP,
);
assert!(rect.x <= 40.0 && 40.0 <= rect.x + rect.width);
assert!(rect.y <= 20.0 && 20.0 + HANDLE_RADIUS <= rect.y + rect.height);
assert!(rect.width >= 2.0 * HANDLE_RADIUS + 2.0 * HANDLE_TOUCH_SLOP - 0.01);
}
#[test]
fn hit_test_prefers_the_nearest_handle() {
let handles = [
(HandleKind::SelectionStart, 20.0, 20.0),
(HandleKind::SelectionEnd, 120.0, 20.0),
];
assert_eq!(
hit_test_handles(&handles, 20.0, 28.0, HANDLE_RADIUS, HANDLE_TOUCH_SLOP),
Some(HandleKind::SelectionStart)
);
assert_eq!(
hit_test_handles(&handles, 120.0, 28.0, HANDLE_RADIUS, HANDLE_TOUCH_SLOP),
Some(HandleKind::SelectionEnd)
);
assert_eq!(
hit_test_handles(&handles, 300.0, 300.0, HANDLE_RADIUS, HANDLE_TOUCH_SLOP),
None
);
}
#[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)
);
}
}