1use std::fmt;
41
42use iced::advanced::layout::{self, Layout};
43use iced::advanced::widget::{Operation, Tree, Widget, tree};
44use iced::advanced::{Clipboard, Shell, overlay, renderer};
45use iced::time::{Duration, Instant};
46use iced::touch::{self, Finger};
47use iced::widget::container;
48use iced::{
49 Background, Border, Color, Element, Event, Length, Padding, Point, Rectangle, Renderer, Size,
50 Theme, Vector, mouse, window,
51};
52
53use crate::theme::{Tokens, mix, radius};
54
55pub const DEFAULT_HOLD: Duration = Duration::from_millis(450);
57pub const DEFAULT_SLOP: f32 = 8.0;
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
62pub enum Status {
63 #[default]
65 Active,
66 Hovered,
68 Pressed,
70}
71
72impl Status {
73 pub const ALL: [Status; 3] = [Status::Active, Status::Hovered, Status::Pressed];
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum Gesture {
79 Tap,
80 LongPress,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum Pointer {
86 Finger(Finger),
87 Mouse,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq)]
91struct Press {
92 pointer: Pointer,
93 origin: Point,
94 at: Instant,
95 held: bool,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Default)]
101pub struct Tracker {
102 press: Option<Press>,
103 touched: bool,
104}
105
106impl Tracker {
107 pub fn press(&mut self, pointer: Pointer, position: Point, now: Instant) {
110 if let Pointer::Finger(_) = pointer {
111 self.touched = true;
112 }
113 if self.press.is_some() {
114 self.press = None;
115 return;
116 }
117 self.press = Some(Press {
118 pointer,
119 origin: position,
120 at: now,
121 held: false,
122 });
123 }
124
125 pub fn moved(&mut self, pointer: Pointer, position: Point, slop: f32) {
128 let Some(press) = self.press else {
129 return;
130 };
131 if press.pointer == pointer && press.origin.distance(position) > slop {
132 self.press = None;
133 }
134 }
135
136 pub fn release(&mut self, pointer: Pointer, inside: bool) -> Option<Gesture> {
139 let press = self.press.filter(|press| press.pointer == pointer)?;
140 self.press = None;
141 (!press.held && inside).then_some(Gesture::Tap)
142 }
143
144 pub fn lost(&mut self, pointer: Pointer) {
146 if self.press.is_some_and(|press| press.pointer == pointer) {
147 self.press = None;
148 }
149 }
150
151 pub fn tick(&mut self, now: Instant, hold: Duration) -> Option<Gesture> {
154 let press = self.press.as_mut()?;
155 if press.held || now.saturating_duration_since(press.at) < hold {
156 return None;
157 }
158 press.held = true;
159 Some(Gesture::LongPress)
160 }
161
162 pub fn deadline(&self, hold: Duration) -> Option<Instant> {
164 let press = self.press.filter(|press| !press.held)?;
165 Some(press.at + hold)
166 }
167
168 pub fn mouse_moved(&mut self) {
170 self.touched = false;
171 }
172
173 pub fn is_pressed(&self) -> bool {
174 self.press.is_some()
175 }
176
177 pub fn status(&self, cursor_over: bool) -> Status {
181 if self.press.is_some() {
182 return Status::Pressed;
183 }
184 if cursor_over && !self.touched {
185 return Status::Hovered;
186 }
187 Status::Active
188 }
189}
190
191pub fn style(tokens: &Tokens, status: Status) -> container::Style {
194 let background = match status {
195 Status::Active => None,
196 Status::Hovered => Some(mix(tokens.background, tokens.accent, 0.5)),
197 Status::Pressed => Some(tokens.accent),
198 };
199 container::Style {
200 background: background.map(Background::Color),
201 border: Border {
202 color: Color::TRANSPARENT,
203 width: 0.0,
204 radius: radius::MD.into(),
205 },
206 ..container::Style::default()
207 }
208}
209
210type StyleFn<'a> = Box<dyn Fn(&Theme, Status) -> container::Style + 'a>;
211
212pub struct Tappable<'a, Message> {
218 content: Element<'a, Message>,
219 on_tap: Option<Message>,
220 on_long_press: Option<Message>,
221 hold: Duration,
222 slop: f32,
223 style: StyleFn<'a>,
224 width: Length,
225 height: Length,
226 padding: Padding,
227}
228
229pub fn tappable<'a, Message>(content: impl Into<Element<'a, Message>>) -> Tappable<'a, Message> {
231 Tappable {
232 content: content.into(),
233 on_tap: None,
234 on_long_press: None,
235 hold: DEFAULT_HOLD,
236 slop: DEFAULT_SLOP,
237 style: Box::new(|theme, status| style(&Tokens::of(theme), status)),
238 width: Length::Shrink,
239 height: Length::Shrink,
240 padding: Padding::ZERO,
241 }
242}
243
244impl<Message> fmt::Debug for Tappable<'_, Message> {
245 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246 f.debug_struct("Tappable")
247 .field("on_tap", &self.on_tap.is_some())
248 .field("on_long_press", &self.on_long_press.is_some())
249 .field("hold", &self.hold)
250 .field("slop", &self.slop)
251 .field("width", &self.width)
252 .field("height", &self.height)
253 .field("padding", &self.padding)
254 .finish_non_exhaustive()
255 }
256}
257
258impl<'a, Message> Tappable<'a, Message> {
259 pub fn on_tap(mut self, message: Message) -> Self {
261 self.on_tap = Some(message);
262 self
263 }
264
265 pub fn on_long_press(mut self, message: Message) -> Self {
268 self.on_long_press = Some(message);
269 self
270 }
271
272 pub fn hold(mut self, hold: Duration) -> Self {
275 self.hold = hold;
276 self
277 }
278
279 pub fn slop(mut self, slop: f32) -> Self {
282 self.slop = slop.max(0.0);
283 self
284 }
285
286 pub fn style(mut self, style: impl Fn(&Theme, Status) -> container::Style + 'a) -> Self {
288 self.style = Box::new(style);
289 self
290 }
291
292 pub fn width(mut self, width: impl Into<Length>) -> Self {
293 self.width = width.into();
294 self
295 }
296
297 pub fn height(mut self, height: impl Into<Length>) -> Self {
298 self.height = height.into();
299 self
300 }
301
302 pub fn padding(mut self, padding: impl Into<Padding>) -> Self {
303 self.padding = padding.into();
304 self
305 }
306
307 pub fn is_enabled(&self) -> bool {
308 self.on_tap.is_some() || self.on_long_press.is_some()
309 }
310}
311
312impl<'a, Message: Clone + 'a> From<Tappable<'a, Message>> for Element<'a, Message> {
313 fn from(tappable: Tappable<'a, Message>) -> Self {
314 let Tappable {
315 content,
316 on_tap,
317 on_long_press,
318 hold,
319 slop,
320 style,
321 width,
322 height,
323 padding,
324 } = tappable;
325 Element::new(Surface {
326 content: container(content)
327 .width(width)
328 .height(height)
329 .padding(padding)
330 .into(),
331 on_tap,
332 on_long_press,
333 hold,
334 slop,
335 style,
336 })
337 }
338}
339
340struct Surface<'a, Message> {
341 content: Element<'a, Message>,
342 on_tap: Option<Message>,
343 on_long_press: Option<Message>,
344 hold: Duration,
345 slop: f32,
346 style: StyleFn<'a>,
347}
348
349#[derive(Debug, Default)]
350struct State {
351 tracker: Tracker,
352 drawn: Status,
353}
354
355impl<Message> Surface<'_, Message> {
356 fn is_enabled(&self) -> bool {
357 self.on_tap.is_some() || self.on_long_press.is_some()
358 }
359}
360
361fn over(cursor: mouse::Cursor, bounds: Rectangle, viewport: &Rectangle) -> bool {
363 cursor.is_over(bounds) && cursor.is_over(*viewport)
364}
365
366impl<Message: Clone> Widget<Message, Theme, Renderer> for Surface<'_, Message> {
367 fn tag(&self) -> tree::Tag {
368 tree::Tag::of::<State>()
369 }
370
371 fn state(&self) -> tree::State {
372 tree::State::new(State::default())
373 }
374
375 fn children(&self) -> Vec<Tree> {
376 vec![Tree::new(&self.content)]
377 }
378
379 fn diff(&self, tree: &mut Tree) {
380 tree.diff_children(std::slice::from_ref(&self.content));
381 }
382
383 fn size(&self) -> Size<Length> {
384 self.content.as_widget().size()
385 }
386
387 fn size_hint(&self) -> Size<Length> {
388 self.content.as_widget().size_hint()
389 }
390
391 fn layout(
392 &mut self,
393 tree: &mut Tree,
394 renderer: &Renderer,
395 limits: &layout::Limits,
396 ) -> layout::Node {
397 self.content
398 .as_widget_mut()
399 .layout(&mut tree.children[0], renderer, limits)
400 }
401
402 fn draw(
403 &self,
404 tree: &Tree,
405 renderer: &mut Renderer,
406 theme: &Theme,
407 style: &renderer::Style,
408 layout: Layout<'_>,
409 cursor: mouse::Cursor,
410 viewport: &Rectangle,
411 ) {
412 let state = tree.state.downcast_ref::<State>();
413 let bounds = layout.bounds();
414 let status = if self.is_enabled() {
415 state.tracker.status(over(cursor, bounds, viewport))
416 } else {
417 Status::Active
418 };
419 let appearance = (self.style)(theme, status);
420 container::draw_background(renderer, &appearance, bounds);
421 let style = renderer::Style {
422 text_color: appearance.text_color.unwrap_or(style.text_color),
423 };
424 self.content.as_widget().draw(
425 &tree.children[0],
426 renderer,
427 theme,
428 &style,
429 layout,
430 cursor,
431 viewport,
432 );
433 }
434
435 fn operate(
436 &mut self,
437 tree: &mut Tree,
438 layout: Layout<'_>,
439 renderer: &Renderer,
440 operation: &mut dyn Operation,
441 ) {
442 self.content
443 .as_widget_mut()
444 .operate(&mut tree.children[0], layout, renderer, operation);
445 }
446
447 fn update(
448 &mut self,
449 tree: &mut Tree,
450 event: &Event,
451 layout: Layout<'_>,
452 cursor: mouse::Cursor,
453 renderer: &Renderer,
454 clipboard: &mut dyn Clipboard,
455 shell: &mut Shell<'_, Message>,
456 viewport: &Rectangle,
457 ) {
458 self.content.as_widget_mut().update(
459 &mut tree.children[0],
460 event,
461 layout,
462 cursor,
463 renderer,
464 clipboard,
465 shell,
466 viewport,
467 );
468 if !self.is_enabled() {
469 return;
470 }
471
472 let claimed = self.content.as_widget().mouse_interaction(
475 &tree.children[0],
476 layout,
477 cursor,
478 viewport,
479 renderer,
480 ) != mouse::Interaction::None;
481 let state = tree.state.downcast_mut::<State>();
482 let bounds = layout.bounds();
483 let inside = over(cursor, bounds, viewport);
484 let free = !shell.is_event_captured() && !claimed;
485 let tracker = &mut state.tracker;
486 let mut gesture = None;
487 let mut now = Instant::now();
488
489 match event {
490 Event::Touch(touch::Event::FingerPressed { id, position }) => {
491 if tracker.is_pressed() || (inside && free) {
492 tracker.press(Pointer::Finger(*id), *position, now);
493 }
494 }
495 Event::Touch(touch::Event::FingerMoved { id, position }) => {
496 tracker.moved(Pointer::Finger(*id), *position, self.slop);
497 }
498 Event::Touch(touch::Event::FingerLifted { id, .. }) => {
499 gesture = tracker.release(Pointer::Finger(*id), true);
500 }
501 Event::Touch(touch::Event::FingerLost { id, .. }) => {
502 tracker.lost(Pointer::Finger(*id));
503 }
504 Event::Mouse(mouse::Event::CursorMoved { .. }) => {
505 tracker.mouse_moved();
506 if let Some(position) = cursor.position() {
507 tracker.moved(Pointer::Mouse, position, self.slop);
508 }
509 }
510 Event::Mouse(mouse::Event::CursorLeft) => tracker.lost(Pointer::Mouse),
511 Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if inside && free => {
512 if let Some(position) = cursor.position() {
513 tracker.press(Pointer::Mouse, position, now);
514 }
515 }
516 Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
517 gesture = tracker.release(Pointer::Mouse, inside);
518 }
519 Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right))
520 if inside && free && self.on_long_press.is_some() =>
521 {
522 gesture = Some(Gesture::LongPress);
523 shell.capture_event();
524 }
525 Event::Window(window::Event::RedrawRequested(at)) => now = *at,
526 _ => {}
527 }
528
529 if gesture.is_none() && self.on_long_press.is_some() {
530 gesture = tracker.tick(now, self.hold);
531 }
532 if self.on_long_press.is_some()
533 && let Some(deadline) = tracker.deadline(self.hold)
534 {
535 shell.request_redraw_at(deadline);
536 }
537
538 let message = match gesture {
539 Some(Gesture::Tap) => self.on_tap.clone(),
540 Some(Gesture::LongPress) => self.on_long_press.clone(),
541 None => None,
542 };
543 if let Some(message) = message {
544 shell.publish(message);
545 }
546
547 let status = tracker.status(inside);
548 if let Event::Window(window::Event::RedrawRequested(_)) = event {
549 state.drawn = status;
550 } else if status != state.drawn {
551 shell.request_redraw();
552 }
553 }
554
555 fn mouse_interaction(
556 &self,
557 tree: &Tree,
558 layout: Layout<'_>,
559 cursor: mouse::Cursor,
560 viewport: &Rectangle,
561 renderer: &Renderer,
562 ) -> mouse::Interaction {
563 let content = self.content.as_widget().mouse_interaction(
564 &tree.children[0],
565 layout,
566 cursor,
567 viewport,
568 renderer,
569 );
570 if content != mouse::Interaction::None {
571 return content;
572 }
573 if self.is_enabled() && over(cursor, layout.bounds(), viewport) {
574 return mouse::Interaction::Pointer;
575 }
576 content
577 }
578
579 fn overlay<'b>(
580 &'b mut self,
581 tree: &'b mut Tree,
582 layout: Layout<'b>,
583 renderer: &Renderer,
584 viewport: &Rectangle,
585 translation: Vector,
586 ) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
587 self.content.as_widget_mut().overlay(
588 &mut tree.children[0],
589 layout,
590 renderer,
591 viewport,
592 translation,
593 )
594 }
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600 use crate::theme::{dark, light};
601
602 const FINGER: Pointer = Pointer::Finger(Finger(1));
603
604 fn at(x: f32) -> Point {
605 Point::new(x, 10.0)
606 }
607
608 #[test]
609 fn builder_defaults() {
610 let t: Tappable<'_, u8> = tappable("Row");
611 assert_eq!(t.hold, DEFAULT_HOLD);
612 assert_eq!(t.slop, DEFAULT_SLOP);
613 assert_eq!(t.width, Length::Shrink);
614 assert!(!t.is_enabled());
615 assert!(tappable::<u8>("Row").on_tap(1).is_enabled());
616 assert!(tappable::<u8>("Row").on_long_press(1).is_enabled());
617 assert_eq!(tappable::<u8>("Row").slop(-3.0).slop, 0.0);
618 }
619
620 #[test]
621 fn a_press_and_lift_within_the_slop_is_a_tap() {
622 let mut tracker = Tracker::default();
623 let now = Instant::now();
624 tracker.press(FINGER, at(0.0), now);
625 assert_eq!(tracker.status(true), Status::Pressed);
626 tracker.moved(FINGER, at(DEFAULT_SLOP), DEFAULT_SLOP);
627 assert_eq!(tracker.release(FINGER, true), Some(Gesture::Tap));
628 assert_eq!(tracker.status(true), Status::Active);
629 }
630
631 #[test]
632 fn moving_beyond_the_slop_cancels() {
633 let mut tracker = Tracker::default();
634 tracker.press(FINGER, at(0.0), Instant::now());
635 tracker.moved(FINGER, at(DEFAULT_SLOP + 1.0), DEFAULT_SLOP);
636 assert!(!tracker.is_pressed());
637 assert_eq!(tracker.release(FINGER, true), None);
638 }
639
640 #[test]
641 fn other_fingers_are_ignored_and_a_second_press_cancels() {
642 let mut tracker = Tracker::default();
643 tracker.press(FINGER, at(0.0), Instant::now());
644 tracker.moved(Pointer::Finger(Finger(2)), at(100.0), DEFAULT_SLOP);
645 assert_eq!(tracker.release(Pointer::Finger(Finger(2)), true), None);
646 assert!(tracker.is_pressed());
647 tracker.press(Pointer::Finger(Finger(2)), at(50.0), Instant::now());
648 assert!(!tracker.is_pressed());
649 }
650
651 #[test]
652 fn holding_fires_one_long_press_and_swallows_the_lift() {
653 let mut tracker = Tracker::default();
654 let start = Instant::now();
655 tracker.press(FINGER, at(0.0), start);
656 assert_eq!(tracker.deadline(DEFAULT_HOLD), Some(start + DEFAULT_HOLD));
657 assert_eq!(tracker.tick(start, DEFAULT_HOLD), None);
658 let later = start + DEFAULT_HOLD;
659 assert_eq!(tracker.tick(later, DEFAULT_HOLD), Some(Gesture::LongPress));
660 assert_eq!(tracker.tick(later, DEFAULT_HOLD), None);
661 assert_eq!(tracker.deadline(DEFAULT_HOLD), None);
662 assert_eq!(tracker.release(FINGER, true), None);
663 }
664
665 #[test]
666 fn lost_and_released_outside_produce_nothing() {
667 let mut tracker = Tracker::default();
668 tracker.press(FINGER, at(0.0), Instant::now());
669 tracker.lost(FINGER);
670 assert!(!tracker.is_pressed());
671 tracker.press(Pointer::Mouse, at(0.0), Instant::now());
672 assert_eq!(tracker.release(Pointer::Mouse, false), None);
673 }
674
675 #[test]
676 fn hover_is_ignored_after_a_touch_until_the_mouse_moves() {
677 let mut tracker = Tracker::default();
678 assert_eq!(tracker.status(true), Status::Hovered);
679 tracker.press(FINGER, at(0.0), Instant::now());
680 let _ = tracker.release(FINGER, true);
681 assert_eq!(tracker.status(true), Status::Active);
682 tracker.mouse_moved();
683 assert_eq!(tracker.status(true), Status::Hovered);
684 assert_eq!(tracker.status(false), Status::Active);
685 }
686
687 #[test]
688 fn default_style_is_clear_at_rest_and_stronger_when_pressed() {
689 for theme in [light(), dark()] {
690 let tokens = Tokens::of(&theme);
691 assert!(style(&tokens, Status::Active).background.is_none());
692 let hovered = style(&tokens, Status::Hovered).background;
693 let pressed = style(&tokens, Status::Pressed).background;
694 assert!(hovered.is_some() && pressed.is_some());
695 assert_ne!(hovered, pressed);
696 assert_eq!(pressed, Some(Background::Color(tokens.accent)));
697 }
698 }
699}