#![allow(non_snake_case)]
use std::rc::Rc;
use crate::composable;
use crate::modifier::Modifier;
use crate::text_selection::{handle_path_data, HandleKind, HANDLE_GRAB_SLOP};
use crate::widgets::box_widget::{Box, BoxSpec};
use crate::widgets::popup::Popup;
use crate::PointerInputScope;
use cranpose_foundation::{PointerEvent, PointerEventKind};
use cranpose_ui_graphics::{Brush, Color, DrawScope, Point, Rect, Size, VectorPath};
pub(crate) const HANDLE_LONG_PRESS_TIMEOUT_MS: i64 = 500;
pub(crate) const HANDLE_LONG_PRESS_SLOP_PX: f32 = 12.0;
pub(crate) const HANDLE_TAP_SLOP_PX: f32 = 12.0;
struct HandleShape {
path_data: String,
box_size: Size,
tip_in_box: Point,
}
fn handle_shape(kind: HandleKind, radius: f32) -> HandleShape {
let slop = HANDLE_GRAB_SLOP.max(0.0);
let bounds = VectorPath::parse(&handle_path_data(kind, 0.0, 0.0, radius))
.map(|p| p.bounds())
.unwrap_or(Rect {
x: -radius,
y: 0.0,
width: 2.0 * radius,
height: 2.0 * radius,
});
let tip_in_box = Point {
x: slop - bounds.x,
y: -bounds.y,
};
let box_size = Size {
width: bounds.width + 2.0 * slop,
height: bounds.height + slop,
};
let path_data = handle_path_data(kind, tip_in_box.x, tip_in_box.y, radius);
HandleShape {
path_data,
box_size,
tip_in_box,
}
}
#[cfg(test)]
pub(crate) fn handle_grab_rect(kind: HandleKind, tip: Point, radius: f32) -> Rect {
let shape = handle_shape(kind, radius);
Rect {
x: tip.x - shape.tip_in_box.x,
y: tip.y - shape.tip_in_box.y,
width: shape.box_size.width,
height: shape.box_size.height,
}
}
#[allow(clippy::too_many_arguments)]
#[composable]
pub fn SelectionHandle(
kind: HandleKind,
tip: Point,
radius: f32,
color: Color,
on_drag: impl Fn(Point) + 'static,
on_drag_end: impl Fn() + 'static,
on_long_press: impl Fn() + 'static,
on_tap: impl Fn() + 'static,
) {
let shape = handle_shape(kind, radius);
let anchor = Rect {
x: tip.x - shape.tip_in_box.x,
y: tip.y - shape.tip_in_box.y,
width: 0.0,
height: 0.0,
};
let path_data = shape.path_data;
let box_size = shape.box_size;
let on_drag: Rc<dyn Fn(Point)> = Rc::new(on_drag);
let on_drag_end: Rc<dyn Fn()> = Rc::new(on_drag_end);
let on_long_press: Rc<dyn Fn()> = Rc::new(on_long_press);
let on_tap: Rc<dyn Fn()> = Rc::new(on_tap);
Popup(anchor, Point { x: 0.0, y: 0.0 }, move || {
let path_data = path_data.clone();
let on_drag = Rc::clone(&on_drag);
let on_drag_end = Rc::clone(&on_drag_end);
let on_long_press = Rc::clone(&on_long_press);
let on_tap = Rc::clone(&on_tap);
Box(
Modifier::empty()
.size(box_size)
.draw_behind(move |scope: &mut dyn DrawScope| {
if let Ok(path) = VectorPath::parse(&path_data) {
scope.draw_vector_path(&path, Brush::solid(color));
}
})
.then(selection_handle_pointer_input(
kind,
Rc::clone(&on_drag),
Rc::clone(&on_drag_end),
Rc::clone(&on_long_press),
Rc::clone(&on_tap),
)),
BoxSpec::default(),
|| {},
);
});
}
pub(crate) fn selection_handle_pointer_input(
kind: HandleKind,
on_drag: Rc<dyn Fn(Point)>,
on_drag_end: Rc<dyn Fn()>,
on_long_press: Rc<dyn Fn()>,
on_tap: Rc<dyn Fn()>,
) -> Modifier {
Modifier::empty().pointer_input(kind, move |scope: PointerInputScope| {
let on_drag = Rc::clone(&on_drag);
let on_drag_end = Rc::clone(&on_drag_end);
let on_long_press = Rc::clone(&on_long_press);
let on_tap = Rc::clone(&on_tap);
async move {
scope
.await_pointer_event_scope(|await_scope| async move {
let mut down_time: Option<i64> = None;
let mut down_pos = Point { x: 0.0, y: 0.0 };
let mut long_press_fired = false;
let mut dragged = false;
loop {
let event = await_scope.await_pointer_event().await;
match event.kind {
PointerEventKind::Down => {
down_time = event.time_ms;
down_pos = event.global_position;
long_press_fired = false;
dragged = false;
on_drag(event.global_position);
event.consume();
}
PointerEventKind::Move => {
if moved_beyond(down_pos, event.global_position, HANDLE_TAP_SLOP_PX)
{
dragged = true;
}
on_drag(event.global_position);
maybe_fire_long_press(
&event,
down_time,
down_pos,
&mut long_press_fired,
&on_long_press,
);
event.consume();
}
PointerEventKind::Up => {
maybe_fire_long_press(
&event,
down_time,
down_pos,
&mut long_press_fired,
&on_long_press,
);
if moved_beyond(down_pos, event.global_position, HANDLE_TAP_SLOP_PX)
{
dragged = true;
}
on_drag_end();
if !dragged && !long_press_fired {
on_tap();
}
down_time = None;
event.consume();
}
PointerEventKind::Cancel => {
on_drag_end();
down_time = None;
event.consume();
}
_ => {}
}
}
})
.await;
}
})
}
fn moved_beyond(origin: Point, now: Point, slop: f32) -> bool {
let dx = now.x - origin.x;
let dy = now.y - origin.y;
dx * dx + dy * dy > slop * slop
}
fn maybe_fire_long_press(
event: &PointerEvent,
down_time: Option<i64>,
down_pos: Point,
long_press_fired: &mut bool,
on_long_press: &Rc<dyn Fn()>,
) {
if *long_press_fired {
return;
}
let (Some(down_ms), Some(now_ms)) = (down_time, event.time_ms) else {
return;
};
if is_handle_long_press(down_ms, now_ms, down_pos, event.global_position) {
*long_press_fired = true;
on_long_press();
}
}
pub(crate) fn is_handle_long_press(
down_ms: i64,
now_ms: i64,
down_pos: Point,
now_pos: Point,
) -> bool {
let dx = now_pos.x - down_pos.x;
let dy = now_pos.y - down_pos.y;
let moved = (dx * dx + dy * dy).sqrt();
now_ms - down_ms >= HANDLE_LONG_PRESS_TIMEOUT_MS && moved <= HANDLE_LONG_PRESS_SLOP_PX
}
#[cfg(test)]
mod tests {
use super::*;
use crate::text_selection::HANDLE_RADIUS;
#[test]
fn cursor_handle_touch_box_sits_at_or_below_the_tip() {
let shape = handle_shape(HandleKind::Cursor, HANDLE_RADIUS);
assert!(
shape.tip_in_box.y.abs() < 0.01,
"cursor handle tip must sit at the top edge of its touch box \
(tip_in_box.y = {}), so it never overlaps the text line above",
shape.tip_in_box.y
);
assert!(
shape.box_size.height >= 2.0 * HANDLE_RADIUS,
"cursor handle box must extend below the tip so the bulb is grabbable"
);
}
#[test]
fn handles_have_no_padding_above_the_tip() {
let slop = HANDLE_GRAB_SLOP.max(0.0);
for kind in [
HandleKind::Cursor,
HandleKind::SelectionStart,
HandleKind::SelectionEnd,
] {
let shape = handle_shape(kind, HANDLE_RADIUS);
let bounds = cranpose_ui_graphics::VectorPath::parse(&handle_path_data(
kind,
0.0,
0.0,
HANDLE_RADIUS,
))
.map(|p| p.bounds())
.expect("valid handle path");
assert!(
(shape.tip_in_box.y - (-bounds.y)).abs() < 0.01,
"{kind:?}: expected no slop above the tip, tip_in_box.y = {} vs shape top {}",
shape.tip_in_box.y,
-bounds.y
);
assert!(
shape.box_size.width >= bounds.width + 2.0 * slop - 0.01,
"{kind:?}: side grab slop must be applied"
);
}
}
#[test]
fn grab_region_is_finger_sized() {
for kind in [
HandleKind::Cursor,
HandleKind::SelectionStart,
HandleKind::SelectionEnd,
] {
let rect = handle_grab_rect(kind, Point { x: 100.0, y: 100.0 }, HANDLE_RADIUS);
assert!(
rect.width >= 48.0,
"{kind:?}: grab region must be at least a fingertip wide, got {}",
rect.width
);
assert!(
rect.height >= 32.0,
"{kind:?}: grab region must be at least a fingertip tall, got {}",
rect.height
);
}
}
#[test]
fn start_and_end_grab_regions_are_mirror_images() {
let tip = Point { x: 100.0, y: 100.0 };
let start = handle_grab_rect(HandleKind::SelectionStart, tip, HANDLE_RADIUS);
let end = handle_grab_rect(HandleKind::SelectionEnd, tip, HANDLE_RADIUS);
assert!(
(start.width - end.width).abs() < 0.01 && (start.height - end.height).abs() < 0.01,
"start {start:?} and end {end:?} grab boxes must be the same size"
);
let start_left = tip.x - start.x;
let start_right = (start.x + start.width) - tip.x;
let end_left = tip.x - end.x;
let end_right = (end.x + end.width) - tip.x;
assert!(
(start_left - end_right).abs() < 0.01 && (start_right - end_left).abs() < 0.01,
"start (l={start_left}, r={start_right}) and end (l={end_left}, r={end_right}) \
grab boxes must be horizontal mirror images"
);
assert!(
start_left >= 2.0 * HANDLE_RADIUS && end_right >= 2.0 * HANDLE_RADIUS,
"each grab box must extend a full bulb-diameter toward its bulb"
);
}
#[test]
fn grab_region_covers_a_fingers_reach_but_not_the_glyph_line() {
let tip = Point { x: 100.0, y: 100.0 };
let contains = |r: Rect, x: f32, y: f32| {
x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height
};
for kind in [
HandleKind::Cursor,
HandleKind::SelectionStart,
HandleKind::SelectionEnd,
] {
let r = handle_grab_rect(kind, tip, HANDLE_RADIUS);
assert!(
contains(r, tip.x - 16.0, tip.y + 12.0),
"{kind:?}: a press below-left of the tip must grab the handle"
);
assert!(
contains(r, tip.x + 16.0, tip.y + 12.0),
"{kind:?}: a press below-right of the tip must grab the handle"
);
assert!(
!contains(r, tip.x, tip.y - HANDLE_GRAB_SLOP),
"{kind:?}: a press a finger's reach above the tip must not grab \
the handle (it belongs to the field)"
);
}
}
}