1use cranpose_ui_graphics::Rect;
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum TapCount {
19 Single,
20 Double,
21 Triple,
22}
23
24impl TapCount {
25 pub fn as_u8(self) -> u8 {
27 match self {
28 TapCount::Single => 1,
29 TapCount::Double => 2,
30 TapCount::Triple => 3,
31 }
32 }
33}
34
35impl TryFrom<u8> for TapCount {
36 type Error = ();
37
38 fn try_from(value: u8) -> Result<Self, Self::Error> {
39 match value {
40 1 => Ok(TapCount::Single),
41 2 => Ok(TapCount::Double),
42 3 => Ok(TapCount::Triple),
43 _ => Err(()),
44 }
45 }
46}
47
48pub const MULTI_TAP_TIMEOUT_MS: u128 = 500;
50
51pub const MULTI_TAP_SLOP_PX: f32 = 24.0;
56
57pub fn classify_tap(
65 previous: Option<(TapCount, f32, f32)>,
66 elapsed_ms: u128,
67 x: f32,
68 y: f32,
69 timeout_ms: u128,
70 slop_px: f32,
71) -> TapCount {
72 let Some((prev_count, prev_x, prev_y)) = previous else {
73 return TapCount::Single;
74 };
75 let within_time = elapsed_ms <= timeout_ms;
76 let dx = x - prev_x;
77 let dy = y - prev_y;
78 let within_slop = dx * dx + dy * dy <= slop_px * slop_px;
79 if !within_time || !within_slop {
80 return TapCount::Single;
81 }
82 match prev_count {
83 TapCount::Single => TapCount::Double,
84 TapCount::Double => TapCount::Triple,
85 TapCount::Triple => TapCount::Single,
87 }
88}
89
90pub fn find_line_boundaries(text: &str, pos: usize) -> (usize, usize) {
96 let pos = pos.min(text.len());
97 let start = text[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
98 let end = text[pos..]
99 .find('\n')
100 .map(|i| pos + i)
101 .unwrap_or(text.len());
102 (start, end)
103}
104
105#[derive(Clone, Copy, Debug, PartialEq, Eq)]
107pub enum HandleKind {
108 Cursor,
111 SelectionStart,
113 SelectionEnd,
115}
116
117pub const HANDLE_RADIUS: f32 = 8.0;
119
120pub const HANDLE_TOUCH_SLOP: f32 = 12.0;
122
123pub fn handle_path_data(kind: HandleKind, tip_x: f32, tip_y: f32, radius: f32) -> String {
129 let r = radius.max(0.0);
130 let cy = tip_y + r; match kind {
132 HandleKind::Cursor => {
133 format!(
135 "M {tip_x} {tip_y} L {left} {cy} A {r} {r} 0 1 0 {right} {cy} Z",
136 left = tip_x - r,
137 right = tip_x + r,
138 )
139 }
140 HandleKind::SelectionStart => {
141 format!(
143 "M {tip_x} {tip_y} L {tip_x} {cy} A {r} {r} 0 1 0 {left} {tip_y} Z",
144 left = tip_x - r,
145 )
146 }
147 HandleKind::SelectionEnd => {
148 format!(
150 "M {tip_x} {tip_y} L {right} {tip_y} A {r} {r} 0 1 0 {tip_x} {cy} Z",
151 right = tip_x + r,
152 )
153 }
154 }
155}
156
157pub const HANDLE_TOUCH_PADDING: f32 = 12.0;
160
161pub fn handle_hit_rect(kind: HandleKind, tip_x: f32, tip_y: f32, radius: f32, slop: f32) -> Rect {
164 let r = radius.max(0.0);
165 let slop = slop.max(0.0);
166 let (left, right) = match kind {
168 HandleKind::Cursor => (tip_x - r, tip_x + r),
169 HandleKind::SelectionStart => (tip_x - 2.0 * r, tip_x + r),
170 HandleKind::SelectionEnd => (tip_x - r, tip_x + 2.0 * r),
171 };
172 Rect {
173 x: left - slop,
174 y: tip_y - slop,
175 width: (right - left) + 2.0 * slop,
176 height: 2.0 * r + 2.0 * slop,
177 }
178}
179
180pub fn hit_test_handles(
185 handles: &[(HandleKind, f32, f32)],
186 x: f32,
187 y: f32,
188 radius: f32,
189 slop: f32,
190) -> Option<HandleKind> {
191 let mut best: Option<(HandleKind, f32)> = None;
192 for &(kind, tip_x, tip_y) in handles {
193 let rect = handle_hit_rect(kind, tip_x, tip_y, radius, slop);
194 let inside =
195 x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
196 if !inside {
197 continue;
198 }
199 let cx = tip_x;
200 let cy = tip_y + radius;
201 let dist_sq = (x - cx) * (x - cx) + (y - cy) * (y - cy);
202 if best
203 .map(|(_, best_dist)| dist_sq < best_dist)
204 .unwrap_or(true)
205 {
206 best = Some((kind, dist_sq));
207 }
208 }
209 best.map(|(kind, _)| kind)
210}
211
212pub fn selection_after_handle_drag(
219 dragged: HandleKind,
220 fixed_edge: usize,
221 dragged_offset: usize,
222 text_len: usize,
223) -> (usize, usize) {
224 let fixed = fixed_edge.min(text_len);
225 let dragged_offset = dragged_offset.min(text_len);
226 match dragged {
227 HandleKind::SelectionStart => {
228 let start = dragged_offset.min(fixed.saturating_sub(1));
229 (start, fixed)
230 }
231 HandleKind::SelectionEnd => {
232 let end = dragged_offset.max(fixed + 1).min(text_len);
233 (fixed, end)
234 }
235 HandleKind::Cursor => (dragged_offset, dragged_offset),
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn tap_classification_escalates_within_time_and_slop() {
246 assert_eq!(
247 classify_tap(None, 0, 10.0, 10.0, 500, 24.0),
248 TapCount::Single
249 );
250 assert_eq!(
251 classify_tap(
252 Some((TapCount::Single, 10.0, 10.0)),
253 100,
254 11.0,
255 12.0,
256 500,
257 24.0
258 ),
259 TapCount::Double
260 );
261 assert_eq!(
262 classify_tap(
263 Some((TapCount::Double, 10.0, 10.0)),
264 100,
265 11.0,
266 12.0,
267 500,
268 24.0
269 ),
270 TapCount::Triple
271 );
272 assert_eq!(
274 classify_tap(
275 Some((TapCount::Triple, 10.0, 10.0)),
276 100,
277 11.0,
278 12.0,
279 500,
280 24.0
281 ),
282 TapCount::Single
283 );
284 }
285
286 #[test]
287 fn tap_classification_resets_past_timeout_or_slop() {
288 assert_eq!(
290 classify_tap(
291 Some((TapCount::Single, 10.0, 10.0)),
292 600,
293 10.0,
294 10.0,
295 500,
296 24.0
297 ),
298 TapCount::Single
299 );
300 assert_eq!(
302 classify_tap(
303 Some((TapCount::Single, 10.0, 10.0)),
304 50,
305 100.0,
306 10.0,
307 500,
308 24.0
309 ),
310 TapCount::Single
311 );
312 }
313
314 #[test]
315 fn line_boundaries_span_between_newlines() {
316 let text = "first line\nsecond line\nthird";
317 assert_eq!(find_line_boundaries(text, 15), (11, 22));
319 assert_eq!(find_line_boundaries(text, 0), (0, 10));
321 assert_eq!(find_line_boundaries(text, 25), (23, text.len()));
323 }
324
325 #[test]
326 fn line_boundaries_handle_unicode_and_empty_lines() {
327 let text = "\u{00e9}\u{00e8}\n\n\u{4e2d}\u{6587}";
328 let (start, end) = find_line_boundaries(text, "\u{00e9}\u{00e8}\n".len());
330 assert_eq!(start, end);
331 let last = find_line_boundaries(text, text.len());
333 assert_eq!(&text[last.0..last.1], "\u{4e2d}\u{6587}");
334 }
335
336 #[test]
337 fn handle_path_is_non_empty_and_contains_the_tip() {
338 for kind in [
339 HandleKind::Cursor,
340 HandleKind::SelectionStart,
341 HandleKind::SelectionEnd,
342 ] {
343 let data = handle_path_data(kind, 40.0, 20.0, HANDLE_RADIUS);
344 let path = cranpose_ui_graphics::VectorPath::parse(&data)
345 .expect("handle path must be valid SVG");
346 assert!(!path.is_empty(), "{kind:?} handle must have geometry");
347 let bounds = path.bounds();
348 assert!(bounds.x <= 40.0 + 0.5 && bounds.x + bounds.width >= 40.0 - 0.5);
350 assert!(bounds.y <= 20.0 + 0.5);
351 assert!(bounds.y + bounds.height >= 20.0 + HANDLE_RADIUS);
353 }
354 }
355
356 #[test]
357 fn handle_hit_rect_covers_tip_and_bulb_with_slop() {
358 let rect = handle_hit_rect(
359 HandleKind::Cursor,
360 40.0,
361 20.0,
362 HANDLE_RADIUS,
363 HANDLE_TOUCH_SLOP,
364 );
365 assert!(rect.x <= 40.0 && 40.0 <= rect.x + rect.width);
367 assert!(rect.y <= 20.0 && 20.0 + HANDLE_RADIUS <= rect.y + rect.height);
368 assert!(rect.width >= 2.0 * HANDLE_RADIUS + 2.0 * HANDLE_TOUCH_SLOP - 0.01);
370 }
371
372 #[test]
373 fn hit_test_prefers_the_nearest_handle() {
374 let handles = [
375 (HandleKind::SelectionStart, 20.0, 20.0),
376 (HandleKind::SelectionEnd, 120.0, 20.0),
377 ];
378 assert_eq!(
380 hit_test_handles(&handles, 20.0, 28.0, HANDLE_RADIUS, HANDLE_TOUCH_SLOP),
381 Some(HandleKind::SelectionStart)
382 );
383 assert_eq!(
385 hit_test_handles(&handles, 120.0, 28.0, HANDLE_RADIUS, HANDLE_TOUCH_SLOP),
386 Some(HandleKind::SelectionEnd)
387 );
388 assert_eq!(
390 hit_test_handles(&handles, 300.0, 300.0, HANDLE_RADIUS, HANDLE_TOUCH_SLOP),
391 None
392 );
393 }
394
395 #[test]
396 fn handle_drag_keeps_edges_from_crossing() {
397 assert_eq!(
399 selection_after_handle_drag(HandleKind::SelectionEnd, 5, 2, 20),
400 (5, 6)
401 );
402 assert_eq!(
404 selection_after_handle_drag(HandleKind::SelectionEnd, 5, 12, 20),
405 (5, 12)
406 );
407 assert_eq!(
409 selection_after_handle_drag(HandleKind::SelectionStart, 8, 10, 20),
410 (7, 8)
411 );
412 assert_eq!(
414 selection_after_handle_drag(HandleKind::SelectionStart, 8, 3, 20),
415 (3, 8)
416 );
417 assert_eq!(
419 selection_after_handle_drag(HandleKind::Cursor, 4, 9, 20),
420 (9, 9)
421 );
422 }
423}