Skip to main content

iced_cube/primitives/
touch_clicks.rs

1//! Lets content that only reads the mouse, such as a text editor or rich
2//! text with links, respond to touch.
3//!
4//! The content gets every event unchanged. In addition, the first finger
5//! is translated for the content alone:
6//!
7//! | Finger | The content also gets |
8//! | --- | --- |
9//! | Pressed and lifted within the slop | The cursor moved to the touch point, then a left press and release there |
10//! | Moved beyond the slop | Nothing, so a scrollable around it scrolls as usual |
11//! | Moved beyond the slop, with [`scroll`](TouchClicks::scroll) on | Wheel scrolling that follows the finger, and the moves are captured |
12//!
13//! A tap anywhere in the window is translated, so a tap outside a focused
14//! editor unfocuses it, as a click would. The touch press and lift are
15//! never captured, and a second finger cancels the tap.
16//!
17//! Wrap only the content that needs it. Widgets that already read touch,
18//! such as buttons, would react twice if they were wrapped too, and so
19//! would content on a platform that also turns touches into mouse events.
20//!
21//! ```no_run
22//! use iced::widget::{rich_text, span};
23//! use iced_cube::touch_clicks;
24//!
25//! #[derive(Debug, Clone)]
26//! enum Message { Open(&'static str) }
27//!
28//! let text: iced::Element<'_, Message> = touch_clicks(
29//!     rich_text![span("Read the "), span("guide").link("guide")]
30//!         .on_link_click(Message::Open),
31//! )
32//! .into();
33//! ```
34
35use std::fmt;
36
37use iced::advanced::layout::{self, Layout};
38use iced::advanced::widget::{Operation, Tree, Widget, tree};
39use iced::advanced::{Clipboard, Shell, overlay, renderer};
40use iced::touch::{self, Finger};
41use iced::{Element, Event, Length, Point, Rectangle, Renderer, Size, Theme, Vector, mouse};
42
43/// How far, in logical pixels, a finger may move and still be a tap.
44pub const DEFAULT_SLOP: f32 = 8.0;
45/// The line height a scroll-mode drag is measured against: that of a
46/// textarea's 14 pixel text at 1.5 line height.
47pub const DEFAULT_LINE_HEIGHT: f32 = 21.0;
48/// How many wheel pixels a text editor reads as one line.
49const WHEEL_PIXELS_PER_LINE: f32 = 4.0;
50
51/// The wheel delta that scrolls a text editor with lines `line_height`
52/// tall by the distance a finger moved, so the text follows the finger.
53pub fn wheel_delta(finger_dy: f32, line_height: f32) -> mouse::ScrollDelta {
54    let line_height = line_height.max(1.0);
55    mouse::ScrollDelta::Pixels {
56        x: 0.0,
57        y: finger_dy * WHEEL_PIXELS_PER_LINE / line_height,
58    }
59}
60
61/// What a finger move meant.
62#[derive(Debug, Clone, Copy, PartialEq)]
63pub enum Move {
64    /// Not the tracked finger.
65    Ignored,
66    /// The tracked finger, still within the slop.
67    Held,
68    /// The tracked finger is dragging. `dy` is the vertical distance since
69    /// the last move; `inside` is whether the drag started on the content.
70    Dragged { dy: f32, inside: bool },
71}
72
73#[derive(Debug, Clone, Copy, PartialEq)]
74struct Track {
75    finger: Finger,
76    origin: Point,
77    last: Point,
78    target: mouse::Cursor,
79    inside: bool,
80    dragging: bool,
81    cancelled: bool,
82}
83
84/// The finger logic of [`TouchClicks`], free of any widget.
85#[derive(Debug, Clone, Copy, PartialEq, Default)]
86pub struct Translator {
87    track: Option<Track>,
88}
89
90impl Translator {
91    /// A finger went down at `position` in window coordinates. `target` is
92    /// the cursor a click would reach the content with, in the content's
93    /// coordinates, and `inside` whether it is over the content. A layer
94    /// above the content hides the cursor from it, as it would a mouse.
95    pub fn press(&mut self, finger: Finger, position: Point, target: mouse::Cursor, inside: bool) {
96        if let Some(track) = self.track.as_mut() {
97            track.cancelled = true;
98            return;
99        }
100        self.track = Some(Track {
101            finger,
102            origin: position,
103            last: position,
104            target,
105            inside,
106            dragging: false,
107            cancelled: false,
108        });
109    }
110
111    pub fn moved(&mut self, finger: Finger, position: Point, slop: f32) -> Move {
112        let Some(track) = self.track.as_mut().filter(|track| track.finger == finger) else {
113            return Move::Ignored;
114        };
115        if !track.dragging && track.origin.distance(position) > slop {
116            track.dragging = true;
117        }
118        let dy = position.y - track.last.y;
119        track.last = position;
120        if track.dragging {
121            Move::Dragged {
122                dy,
123                inside: track.inside,
124            }
125        } else {
126            Move::Held
127        }
128    }
129
130    /// The finger lifted. Returns the cursor to click with when it was a
131    /// tap.
132    pub fn lift(&mut self, finger: Finger) -> Option<mouse::Cursor> {
133        let track = self.track.filter(|track| track.finger == finger)?;
134        self.track = None;
135        (!track.dragging && !track.cancelled).then_some(track.target)
136    }
137
138    pub fn lost(&mut self, finger: Finger) {
139        if self.track.is_some_and(|track| track.finger == finger) {
140            self.track = None;
141        }
142    }
143
144    /// Whether the tracked finger started over the content.
145    pub fn is_inside(&self, finger: Finger) -> bool {
146        self.track
147            .is_some_and(|track| track.finger == finger && track.inside)
148    }
149}
150
151/// A touch-clicks builder. Convert it into an [`Element`] to render.
152pub struct TouchClicks<'a, Message> {
153    content: Element<'a, Message>,
154    slop: f32,
155    scroll: bool,
156    line_height: f32,
157}
158
159/// Wraps mouse-only `content` so taps reach it as clicks.
160pub fn touch_clicks<'a, Message>(
161    content: impl Into<Element<'a, Message>>,
162) -> TouchClicks<'a, Message> {
163    TouchClicks {
164        content: content.into(),
165        slop: DEFAULT_SLOP,
166        scroll: false,
167        line_height: DEFAULT_LINE_HEIGHT,
168    }
169}
170
171impl<Message> fmt::Debug for TouchClicks<'_, Message> {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        f.debug_struct("TouchClicks")
174            .field("slop", &self.slop)
175            .field("scroll", &self.scroll)
176            .field("line_height", &self.line_height)
177            .finish_non_exhaustive()
178    }
179}
180
181impl<Message> TouchClicks<'_, Message> {
182    /// How far a finger may move and still be a tap. Defaults to
183    /// [`DEFAULT_SLOP`].
184    pub fn slop(mut self, slop: f32) -> Self {
185        self.slop = slop.max(0.0);
186        self
187    }
188
189    /// For content that scrolls itself, such as a text editor: a vertical
190    /// drag that starts on it becomes wheel scrolling, and the finger's
191    /// moves are captured so a scrollable around it stays put.
192    pub fn scroll(mut self, scroll: bool) -> Self {
193        self.scroll = scroll;
194        self
195    }
196
197    /// The content's line height, so a scroll-mode drag moves its text
198    /// with the finger. Defaults to [`DEFAULT_LINE_HEIGHT`].
199    pub fn line_height(mut self, line_height: f32) -> Self {
200        self.line_height = line_height.max(1.0);
201        self
202    }
203}
204
205impl<'a, Message: 'a> From<TouchClicks<'a, Message>> for Element<'a, Message> {
206    fn from(clicks: TouchClicks<'a, Message>) -> Self {
207        Element::new(Wrapper { clicks })
208    }
209}
210
211struct Wrapper<'a, Message> {
212    clicks: TouchClicks<'a, Message>,
213}
214
215impl<Message> Wrapper<'_, Message> {
216    /// Sends synthetic events to the content with `cursor`. They get their
217    /// own shell, so nothing they capture marks the real touch event as
218    /// captured.
219    #[allow(clippy::too_many_arguments)]
220    fn deliver(
221        &mut self,
222        tree: &mut Tree,
223        events: &[Event],
224        cursor: mouse::Cursor,
225        layout: Layout<'_>,
226        renderer: &Renderer,
227        clipboard: &mut dyn Clipboard,
228        shell: &mut Shell<'_, Message>,
229        viewport: &Rectangle,
230    ) {
231        let mut messages = Vec::new();
232        let mut inner = Shell::new(&mut messages);
233        for event in events {
234            self.clicks.content.as_widget_mut().update(
235                &mut tree.children[0],
236                event,
237                layout,
238                cursor,
239                renderer,
240                clipboard,
241                &mut inner,
242                viewport,
243            );
244        }
245        let layout_invalid = inner.is_layout_invalid();
246        let widgets_invalid = inner.are_widgets_invalid();
247        let redraw = inner.redraw_request();
248        for message in messages {
249            shell.publish(message);
250        }
251        if layout_invalid {
252            shell.invalidate_layout();
253        }
254        if widgets_invalid {
255            shell.invalidate_widgets();
256        }
257        shell.request_redraw_at(redraw);
258    }
259}
260
261impl<Message> Widget<Message, Theme, Renderer> for Wrapper<'_, Message> {
262    fn tag(&self) -> tree::Tag {
263        tree::Tag::of::<Translator>()
264    }
265
266    fn state(&self) -> tree::State {
267        tree::State::new(Translator::default())
268    }
269
270    fn children(&self) -> Vec<Tree> {
271        vec![Tree::new(&self.clicks.content)]
272    }
273
274    fn diff(&self, tree: &mut Tree) {
275        tree.diff_children(std::slice::from_ref(&self.clicks.content));
276    }
277
278    fn size(&self) -> Size<Length> {
279        self.clicks.content.as_widget().size()
280    }
281
282    fn size_hint(&self) -> Size<Length> {
283        self.clicks.content.as_widget().size_hint()
284    }
285
286    fn layout(
287        &mut self,
288        tree: &mut Tree,
289        renderer: &Renderer,
290        limits: &layout::Limits,
291    ) -> layout::Node {
292        self.clicks
293            .content
294            .as_widget_mut()
295            .layout(&mut tree.children[0], renderer, limits)
296    }
297
298    fn draw(
299        &self,
300        tree: &Tree,
301        renderer: &mut Renderer,
302        theme: &Theme,
303        style: &renderer::Style,
304        layout: Layout<'_>,
305        cursor: mouse::Cursor,
306        viewport: &Rectangle,
307    ) {
308        self.clicks.content.as_widget().draw(
309            &tree.children[0],
310            renderer,
311            theme,
312            style,
313            layout,
314            cursor,
315            viewport,
316        );
317    }
318
319    fn operate(
320        &mut self,
321        tree: &mut Tree,
322        layout: Layout<'_>,
323        renderer: &Renderer,
324        operation: &mut dyn Operation,
325    ) {
326        self.clicks.content.as_widget_mut().operate(
327            &mut tree.children[0],
328            layout,
329            renderer,
330            operation,
331        );
332    }
333
334    fn update(
335        &mut self,
336        tree: &mut Tree,
337        event: &Event,
338        layout: Layout<'_>,
339        cursor: mouse::Cursor,
340        renderer: &Renderer,
341        clipboard: &mut dyn Clipboard,
342        shell: &mut Shell<'_, Message>,
343        viewport: &Rectangle,
344    ) {
345        self.clicks.content.as_widget_mut().update(
346            &mut tree.children[0],
347            event,
348            layout,
349            cursor,
350            renderer,
351            clipboard,
352            shell,
353            viewport,
354        );
355
356        let Event::Touch(touch) = event else {
357            return;
358        };
359        let bounds = layout.bounds();
360        match *touch {
361            touch::Event::FingerPressed { id, position } => {
362                let inside = cursor
363                    .position()
364                    .is_some_and(|target| bounds.contains(target) && viewport.contains(target));
365                tree.state
366                    .downcast_mut::<Translator>()
367                    .press(id, position, cursor, inside);
368            }
369            touch::Event::FingerMoved { id, position } => {
370                let translator = tree.state.downcast_mut::<Translator>();
371                let inside = translator.is_inside(id);
372                let change = translator.moved(id, position, self.clicks.slop);
373                if !self.clicks.scroll || !inside || change == Move::Ignored {
374                    return;
375                }
376                shell.capture_event();
377                let Move::Dragged { dy, .. } = change else {
378                    return;
379                };
380                let at = cursor
381                    .position()
382                    .filter(|position| bounds.contains(*position))
383                    .unwrap_or(bounds.center());
384                let wheel = Event::Mouse(mouse::Event::WheelScrolled {
385                    delta: wheel_delta(dy, self.clicks.line_height),
386                });
387                self.deliver(
388                    tree,
389                    &[wheel],
390                    mouse::Cursor::Available(at),
391                    layout,
392                    renderer,
393                    clipboard,
394                    shell,
395                    viewport,
396                );
397            }
398            touch::Event::FingerLifted { id, .. } => {
399                let Some(target) = tree.state.downcast_mut::<Translator>().lift(id) else {
400                    return;
401                };
402                let moved = target
403                    .position()
404                    .map(|position| Event::Mouse(mouse::Event::CursorMoved { position }));
405                let click: Vec<Event> = moved
406                    .into_iter()
407                    .chain([
408                        Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
409                        Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
410                    ])
411                    .collect();
412                self.deliver(
413                    tree, &click, target, layout, renderer, clipboard, shell, viewport,
414                );
415            }
416            touch::Event::FingerLost { id, .. } => {
417                tree.state.downcast_mut::<Translator>().lost(id);
418            }
419        }
420    }
421
422    fn mouse_interaction(
423        &self,
424        tree: &Tree,
425        layout: Layout<'_>,
426        cursor: mouse::Cursor,
427        viewport: &Rectangle,
428        renderer: &Renderer,
429    ) -> mouse::Interaction {
430        self.clicks.content.as_widget().mouse_interaction(
431            &tree.children[0],
432            layout,
433            cursor,
434            viewport,
435            renderer,
436        )
437    }
438
439    fn overlay<'b>(
440        &'b mut self,
441        tree: &'b mut Tree,
442        layout: Layout<'b>,
443        renderer: &Renderer,
444        viewport: &Rectangle,
445        translation: Vector,
446    ) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
447        self.clicks.content.as_widget_mut().overlay(
448            &mut tree.children[0],
449            layout,
450            renderer,
451            viewport,
452            translation,
453        )
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    const ONE: Finger = Finger(1);
462
463    fn at(y: f32) -> Point {
464        Point::new(10.0, y)
465    }
466
467    #[test]
468    fn builder_defaults_and_clamps() {
469        let clicks: TouchClicks<'_, ()> = touch_clicks("Text");
470        assert_eq!(clicks.slop, DEFAULT_SLOP);
471        assert!(!clicks.scroll);
472        assert_eq!(clicks.line_height, DEFAULT_LINE_HEIGHT);
473        let clamped: TouchClicks<'_, ()> = touch_clicks("Text").slop(-1.0).line_height(0.0);
474        assert_eq!(clamped.slop, 0.0);
475        assert_eq!(clamped.line_height, 1.0);
476    }
477
478    fn cursor(y: f32) -> mouse::Cursor {
479        mouse::Cursor::Available(at(y))
480    }
481
482    #[test]
483    fn a_tap_clicks_where_the_content_was_touched() {
484        let mut translator = Translator::default();
485        translator.press(ONE, at(0.0), cursor(40.0), true);
486        assert_eq!(
487            translator.moved(ONE, at(DEFAULT_SLOP), DEFAULT_SLOP),
488            Move::Held
489        );
490        assert_eq!(translator.lift(ONE), Some(cursor(40.0)));
491        assert_eq!(translator.lift(ONE), None);
492    }
493
494    #[test]
495    fn a_tap_on_hidden_content_clicks_without_a_position() {
496        let mut translator = Translator::default();
497        let hidden = mouse::Cursor::Levitating(at(40.0));
498        translator.press(ONE, at(40.0), hidden, false);
499        assert_eq!(translator.lift(ONE), Some(hidden));
500    }
501
502    #[test]
503    fn a_drag_is_not_a_click_and_reports_its_movement() {
504        let mut translator = Translator::default();
505        translator.press(ONE, at(0.0), cursor(0.0), true);
506        assert_eq!(
507            translator.moved(ONE, at(20.0), DEFAULT_SLOP),
508            Move::Dragged {
509                dy: 20.0,
510                inside: true
511            }
512        );
513        assert_eq!(
514            translator.moved(ONE, at(15.0), DEFAULT_SLOP),
515            Move::Dragged {
516                dy: -5.0,
517                inside: true
518            }
519        );
520        assert_eq!(translator.lift(ONE), None);
521    }
522
523    #[test]
524    fn a_second_finger_cancels_and_others_are_ignored() {
525        let mut translator = Translator::default();
526        translator.press(ONE, at(0.0), cursor(0.0), true);
527        assert_eq!(
528            translator.moved(Finger(2), at(90.0), DEFAULT_SLOP),
529            Move::Ignored
530        );
531        translator.press(Finger(2), at(0.0), cursor(0.0), true);
532        assert_eq!(translator.lift(Finger(2)), None);
533        assert_eq!(translator.lift(ONE), None);
534
535        translator.press(ONE, at(0.0), cursor(0.0), false);
536        assert!(!translator.is_inside(ONE));
537        translator.lost(ONE);
538        assert_eq!(translator.lift(ONE), None);
539    }
540
541    #[test]
542    fn wheel_deltas_follow_the_finger_in_lines() {
543        let mouse::ScrollDelta::Pixels { x, y } = wheel_delta(-21.0, 21.0) else {
544            panic!("expected pixels");
545        };
546        assert_eq!(x, 0.0);
547        assert_eq!(y, -WHEEL_PIXELS_PER_LINE);
548        let mouse::ScrollDelta::Pixels { y, .. } = wheel_delta(10.0, 0.0) else {
549            panic!("expected pixels");
550        };
551        assert_eq!(y, 40.0);
552    }
553}