use std::{cell::Cell, rc::Rc};
use cranpose_core::remember;
use cranpose_foundation::{PointerEvent, PointerEventKind};
use cranpose_ui_graphics::{Brush, Color, DrawScope, Point, Rect, Size, VectorPath};
use crate::{
PointerInputScope, composable,
modifier::Modifier,
text_selection::{
HANDLE_DOT_LINE_OVERLAP, HANDLE_GRAB_SLOP, HANDLE_STEM_WIDTH, HandleKind, handle_path_data,
},
widgets::{
box_widget::{Box, BoxSpec},
popup::Popup,
},
};
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, line_height: f32) -> HandleShape {
let slop = HANDLE_GRAB_SLOP.max(0.0);
let line_height = line_height.max(1.0);
let half_width = radius.max(HANDLE_STEM_WIDTH * 0.5);
let (draw_top, draw_bottom) = match kind {
HandleKind::SelectionStart => (-line_height - 2.0 * radius + HANDLE_DOT_LINE_OVERLAP, 0.0),
HandleKind::SelectionEnd => (-line_height, 2.0 * radius - HANDLE_DOT_LINE_OVERLAP),
HandleKind::Cursor => (0.0, 2.0 * radius - HANDLE_DOT_LINE_OVERLAP),
};
let (slop_above, slop_below) = match kind {
HandleKind::SelectionStart => (slop, 0.0),
HandleKind::SelectionEnd | HandleKind::Cursor => (0.0, slop),
};
let box_top = draw_top - slop_above;
let box_bottom = draw_bottom + slop_below;
let tip_in_box = Point {
x: half_width + slop,
y: -box_top,
};
let box_size = Size {
width: 2.0 * half_width + 2.0 * slop,
height: box_bottom - box_top,
};
let (line_top_local, line_bottom_local) = (tip_in_box.y - line_height, tip_in_box.y);
let path_data = if kind == HandleKind::Cursor {
let cy = line_bottom_local + radius - HANDLE_DOT_LINE_OVERLAP;
format!(
"M {x0} {cy} A {r} {r} 0 1 1 {x1} {cy} A {r} {r} 0 1 1 {x0} {cy} Z",
x0 = tip_in_box.x - radius,
x1 = tip_in_box.x + radius,
r = radius,
)
} else {
handle_path_data(
kind,
tip_in_box.x,
line_top_local,
line_bottom_local,
radius,
)
};
HandleShape {
path_data,
box_size,
tip_in_box,
}
}
#[cfg(test)]
pub(crate) fn handle_grab_rect(
kind: HandleKind,
tip: Point,
radius: f32,
line_height: f32,
) -> Rect {
let shape = handle_shape(kind, radius, line_height);
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,
}
}
#[derive(Clone, Copy)]
struct GlideState {
x: f32,
y: f32,
vx: f32,
vy: f32,
last_nanos: u64,
}
fn glide_clock_nanos() -> u64 {
use std::sync::OnceLock;
use web_time::Instant;
static EPOCH: OnceLock<Instant> = OnceLock::new();
EPOCH.get_or_init(Instant::now).elapsed().as_nanos() as u64
}
#[expect(clippy::too_many_arguments)]
#[composable]
pub fn SelectionHandle(
kind: HandleKind,
tip: Point,
line_height: f32,
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, line_height);
let glide: Rc<Cell<GlideState>> = remember(|| {
Rc::new(Cell::new(GlideState {
x: f32::NAN,
y: f32::NAN,
vx: 0.0,
vy: 0.0,
last_nanos: 0,
}))
})
.with(Rc::clone);
{
let state = glide.get();
let snap_distance = (line_height * 1.5).max(24.0);
let jump = ((tip.x - state.x).powi(2) + (tip.y - state.y).powi(2)).sqrt();
if !state.x.is_finite() || !state.y.is_finite() || jump > snap_distance {
glide.set(GlideState {
x: tip.x,
y: tip.y,
vx: 0.0,
vy: 0.0,
last_nanos: 0,
});
}
}
let anchor = Rect {
x: (tip.x - shape.tip_in_box.x).round(),
y: (tip.y - shape.tip_in_box.y).round(),
width: 0.0,
height: 0.0,
};
let glide_tip = tip;
let glide_for_draw = Rc::clone(&glide);
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);
let glide_for_draw = Rc::clone(&glide_for_draw);
Box(
Modifier::empty()
.size(box_size)
.draw_behind(move |scope: &mut dyn DrawScope| {
if kind == HandleKind::Cursor {
if let Ok(path) = VectorPath::parse(&path_data) {
scope.draw_vector_path(&path, Brush::solid(color));
}
return;
}
let mut state = glide_for_draw.get();
let settled = state.x.is_finite()
&& (state.x - glide_tip.x).abs() < 0.25
&& (state.y - glide_tip.y).abs() < 0.25
&& state.vx.abs() < 2.0
&& state.vy.abs() < 2.0;
if settled {
if state.last_nanos != 0 {
state = GlideState {
x: glide_tip.x,
y: glide_tip.y,
vx: 0.0,
vy: 0.0,
last_nanos: 0,
};
glide_for_draw.set(state);
}
} else {
let now_nanos = glide_clock_nanos();
let dt = if state.last_nanos == 0 {
0.0
} else {
((now_nanos - state.last_nanos) as f32 / 1.0e9).min(0.05)
};
state.last_nanos = now_nanos;
if dt > 0.0 && state.x.is_finite() {
let omega = 40.0f32;
let decay = (-omega * dt).exp();
let sx = state.x - glide_tip.x;
let sy = state.y - glide_tip.y;
let cx = state.vx + omega * sx;
let cy = state.vy + omega * sy;
state.x = glide_tip.x + (sx + cx * dt) * decay;
state.y = glide_tip.y + (sy + cy * dt) * decay;
state.vx = (state.vx - omega * cx * dt) * decay;
state.vy = (state.vy - omega * cy * dt) * decay;
}
glide_for_draw.set(state);
crate::request_current_draw_redraw();
}
let (dx, dy) = if state.x.is_finite() {
(state.x - glide_tip.x, state.y - glide_tip.y)
} else {
(0.0, 0.0)
};
if let Ok(path) = VectorPath::parse(&path_data) {
scope.draw_vector_path(&path.translated(dx, dy), 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 pressed = false;
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;
pressed = true;
long_press_fired = false;
dragged = false;
on_drag(event.global_position);
event.consume();
}
PointerEventKind::Move => {
if !pressed {
continue;
}
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 => {
if !pressed {
continue;
}
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;
}
pressed = false;
on_drag_end();
if !dragged && !long_press_fired {
on_tap();
}
down_time = None;
event.consume();
}
PointerEventKind::Cancel => {
if pressed {
pressed = false;
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)]
#[path = "tests/selection_handle_tests.rs"]
mod tests;