Skip to main content

telar_ui_core/
pointer.rs

1use std::cell::Cell;
2
3use geometry_core::Rect;
4use platform_core::{Event, PointerButton};
5use ui_tree::EventResult;
6
7/// Which pointer buttons are held right now.
8///
9/// The pointer's half of [`crate::modifiers`], and there for the same reason: a gesture that behaves one way
10/// per button has to ask, and the callbacks it is written against report *where* the pointer is, not *what*
11/// started it. A modeller is the case — drag to orbit, right-drag to pan — and widening `on_drag` to carry a
12/// button would make the whole catalogue pay for a question two widgets ask.
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14pub struct PointerButtons {
15    pub primary: bool,
16    pub secondary: bool,
17    pub auxiliary: bool,
18}
19
20impl PointerButtons {
21    /// Whether any button at all is down.
22    pub fn any(self) -> bool {
23        self.primary || self.secondary || self.auxiliary
24    }
25
26    fn slot(&mut self, button: &PointerButton) -> &mut bool {
27        match button {
28            PointerButton::Primary => &mut self.primary,
29            PointerButton::Secondary => &mut self.secondary,
30            PointerButton::Auxiliary => &mut self.auxiliary,
31        }
32    }
33
34    pub(crate) fn holds(self, button: &PointerButton) -> bool {
35        match button {
36            PointerButton::Primary => self.primary,
37            PointerButton::Secondary => self.secondary,
38            PointerButton::Auxiliary => self.auxiliary,
39        }
40    }
41
42    pub(crate) fn with(mut self, button: &PointerButton) -> Self {
43        *self.slot(button) = true;
44        self
45    }
46}
47
48thread_local! {
49    /// Set while a move is being dispatched into a subtree that something else is drawn over. See
50    /// [`pointer_occluded`].
51    static OCCLUDED: Cell<bool> = const { Cell::new(false) };
52    static BUTTONS: Cell<PointerButtons> = const {
53        Cell::new(PointerButtons { primary: false, secondary: false, auxiliary: false })
54    };
55}
56
57/// Records what `event` says about the buttons. The runner calls this for every event before dispatch, so a
58/// handler running on this very event already sees the state it establishes.
59pub fn observe_pointer(event: &Event) {
60    BUTTONS.with(|b| {
61        let mut held = b.get();
62        match event {
63            Event::PointerPressed { button, .. } => *held.slot(button) = true,
64            Event::PointerReleased { button, .. } => *held.slot(button) = false,
65            // A window that loses focus never sends the releases for what was down. `CursorLeft` is deliberately not here: crossing the border does not lift a button, and forgetting it would leave a live drag unable to say which button started it.
66            Event::FocusChanged { is_focused: false } => held = PointerButtons::default(),
67            _ => return,
68        }
69        b.set(held);
70    });
71}
72
73/// The pointer buttons held right now.
74pub fn pointer_buttons() -> PointerButtons {
75    BUTTONS.with(|b| b.get())
76}
77
78/// Drops the button state; parallels the other per-tree resets on teardown and hot reload.
79pub fn reset_pointer() {
80    BUTTONS.with(|b| b.set(PointerButtons::default()));
81}
82
83/// Whether the move being dispatched right now landed on something drawn in front of this widget.
84///
85/// A move is broadcast to every child, not only the one under the pointer, because a widget that armed a
86/// press or began a drag has to keep receiving them after the pointer leaves its box (pointer capture). That
87/// is right for the gesture and wrong for hover: two overlapping boxes would both read the same move as
88/// *the pointer is over me*, and a viewport would highlight the face behind the panel the user is pointing
89/// at. The container marks the covered subtrees as it broadcasts, and the widgets that track hover ask here.
90pub(crate) fn pointer_occluded() -> bool {
91    OCCLUDED.with(|c| c.get())
92}
93
94struct OccludedGuard(bool);
95
96impl Drop for OccludedGuard {
97    fn drop(&mut self) {
98        OCCLUDED.with(|c| c.set(self.0));
99    }
100}
101
102/// Marks everything dispatched until the guard drops as covered. Set-only on the way down: a subtree inside
103/// something covered is covered too, whatever its own children are stacked like.
104fn occlude() -> OccludedGuard {
105    OccludedGuard(OCCLUDED.with(|c| c.replace(true)))
106}
107
108pub(crate) fn pointer_coords(event: &Event) -> Option<(f64, f64)> {
109    match event {
110        Event::PointerMoved { x, y, .. } => Some((*x, *y)),
111        Event::PointerPressed { x, y, .. } => Some((*x, *y)),
112        Event::PointerReleased { x, y, .. } => Some((*x, *y)),
113        Event::Scrolled { x, y, .. } => Some((*x, *y)),
114        _ => None,
115    }
116}
117
118/// Applies the full affine inverse of `matrix` to all pointer-coordinate events. Returns `None` for
119/// non-pointer events or when `matrix` is degenerate (det ≈ 0), so callers fall back to the original.
120///
121/// Public because a component that paints a subtree under a [`RenderNode::Transform`] it chose itself — a
122/// hand-placed rail or panel, rather than a laid-out one — has to put the same transform's inverse on the
123/// events it forwards there, or its hit-testing drifts from what is on screen.
124pub fn transform_pointer(event: &Event, matrix: [f32; 6]) -> Option<Event> {
125    let inv = geometry_core::Transform::from_array(matrix).invert()?;
126    // Map in f64 so pointer coordinates keep their precision; Transform::apply would round-trip through f32.
127    let apply = |world_x: f64, world_y: f64| -> (f64, f64) {
128        let local_x = inv.a as f64 * world_x + inv.c as f64 * world_y + inv.e as f64;
129        let local_y = inv.b as f64 * world_x + inv.d as f64 * world_y + inv.f as f64;
130        (local_x, local_y)
131    };
132    match event {
133        Event::PointerMoved { x, y, source } => {
134            let (local_x, local_y) = apply(*x, *y);
135            Some(Event::PointerMoved {
136                x: local_x,
137                y: local_y,
138                source: source.clone(),
139            })
140        }
141        Event::PointerPressed {
142            x,
143            y,
144            button,
145            source,
146        } => {
147            let (local_x, local_y) = apply(*x, *y);
148            Some(Event::PointerPressed {
149                x: local_x,
150                y: local_y,
151                button: button.clone(),
152                source: source.clone(),
153            })
154        }
155        Event::PointerReleased {
156            x,
157            y,
158            button,
159            source,
160        } => {
161            let (local_x, local_y) = apply(*x, *y);
162            Some(Event::PointerReleased {
163                x: local_x,
164                y: local_y,
165                button: button.clone(),
166                source: source.clone(),
167            })
168        }
169        // The delta is untouched: it is a distance in wheel notches or screen pixels, not a point in the
170        // space this maps out of. Only where the wheel turned moves with the subtree.
171        Event::Scrolled { delta, x, y } => {
172            let (local_x, local_y) = apply(*x, *y);
173            Some(Event::Scrolled {
174                delta: delta.clone(),
175                x: local_x,
176                y: local_y,
177            })
178        }
179        _ => None,
180    }
181}
182
183pub(crate) fn offset_pointer(event: &Event, dx: f64, dy: f64) -> Option<Event> {
184    transform_pointer(event, [1.0, 0.0, 0.0, 1.0, dx as f32, dy as f32])
185}
186
187// Returns a reference to `event` when the pointer is inside `rect`, or None when it is outside. Non-pointer events always pass through (returns Some). Callers use None to short-circuit to Ignored.
188pub(crate) fn clip_pointer_event<'a>(event: &'a Event, rect: Rect) -> Option<&'a Event> {
189    match pointer_coords(event) {
190        Some((x, y)) if !rect.contains(x as f32, y as f32) => None,
191        _ => Some(event),
192    }
193}
194
195pub(crate) fn dispatch_container_event(
196    children: &mut crate::layout_item::TrackedChildren,
197    event: &Event,
198) -> EventResult {
199    // Moves AND releases broadcast to every child regardless of position: a widget that armed a press or
200    // began a drag inside its bounds must still receive the release even when the pointer has since moved
201    // outside (pointer-capture semantics). Each widget's release handler is guarded by its own armed/drag
202    // state, so broadcasting never double-fires an unrelated widget. Hit-testing (below) applies to presses
203    // and to the wheel, where the target is chosen by where the pointer is.
204    if matches!(
205        event,
206        Event::PointerMoved { .. } | Event::PointerReleased { .. }
207    ) {
208        // The topmost child containing the point is the one the pointer is *over*; every other child is
209        // dispatched the same move (its gesture may still be running) but under the occlusion mark.
210        let over = pointer_coords(event).and_then(|(x, y)| {
211            children.iter().rposition(|c| {
212                c.rect
213                    .as_ref()
214                    .is_some_and(|sig| sig.get().contains(x as f32, y as f32))
215                    && c.item.borrow().pointer_opaque()
216            })
217        });
218        let mut any_handled = false;
219        for (i, child) in children.iter().enumerate() {
220            // Covered means something is drawn *over* it, so only a later sibling occludes an earlier one.
221            // Comparing for inequality instead marked the children drawn on top as covered too, which is
222            // invisible while every sibling is opaque — the topmost is always the last — and wrong the moment
223            // one is not: a `click_through` bar declines to shadow the pane under it and was then told the
224            // pane was shadowing *it*, so nothing inside it could be hovered.
225            let _covered = (over.is_some_and(|top| top > i)).then(occlude);
226            if child.item.borrow_mut().on_event(event) == EventResult::Handled {
227                any_handled = true;
228            }
229        }
230        return if any_handled {
231            EventResult::Handled
232        } else {
233            EventResult::Ignored
234        };
235    }
236    let Some((x, y)) = pointer_coords(event).map(|(x, y)| (x as f32, y as f32)) else {
237        return dispatch_to_children(children, event);
238    };
239    // Back to front, because that is the order they are painted in: where two children overlap, the one
240    // drawn on top is the one the user aimed at, and it takes the event whether or not it wants it — a box
241    // covers what is behind it, exactly as a browser hit-tests. Falling sideways to a covered sibling is
242    // what made a wheel over a floating panel zoom the pane underneath it. In flow layout siblings cannot
243    // overlap and none of this is observable; `absolute` is what makes it real.
244    for child in children.iter_mut().rev() {
245        // A child with no laid-out rect cannot be hit-tested, so it is offered the event but never blocks.
246        let rect = child.rect.as_ref().map(|sig| sig.get());
247        if !rect.is_none_or(|r| r.contains(x, y)) {
248            continue;
249        }
250        let result = child.item.borrow_mut().on_event(event);
251        // A widget that is not there for hit-testing purposes (an overlay, routed by its own registry) lets
252        // the search carry on to whatever it was drawn over.
253        if result == EventResult::Handled
254            || (rect.is_some() && child.item.borrow().pointer_opaque())
255        {
256            return result;
257        }
258    }
259    EventResult::Ignored
260}
261
262fn dispatch_to_children(
263    children: &mut crate::layout_item::TrackedChildren,
264    event: &Event,
265) -> EventResult {
266    for child in children.iter_mut() {
267        if child.item.borrow_mut().on_event(event) == EventResult::Handled {
268            return EventResult::Handled;
269        }
270    }
271    EventResult::Ignored
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::container::Container;
278    use crate::context::{compute_layout, reset_layout_runtime};
279    use crate::layout_item::LayoutItem;
280    use crate::styled_container::StyledContainer;
281    use layout_core::{AvailableSpace, LayoutStyle};
282    use platform_core::ScrollDelta;
283    use renderer_core::RectStyle;
284    use std::cell::Cell;
285    use std::rc::Rc;
286    use ui_tree::Component;
287
288    /// A panel floating over a pane covers it: a wheel that lands on the panel is not the pane's, whether or
289    /// not the panel wants it. Without this the pane — declared first, painted underneath — takes the event
290    /// that visually belongs to what is drawn on top of it.
291    #[test]
292    fn a_covering_sibling_takes_the_pointer_from_the_one_beneath() {
293        reset_layout_runtime();
294        let pane_wheels = Rc::new(Cell::new(0u32));
295        let sink = pane_wheels.clone();
296        let pane = StyledContainer::new(
297            LayoutStyle::new().width(400.0).height(400.0),
298            |_r| RectStyle::default(),
299            vec![],
300        )
301        .unwrap()
302        .on_scroll(move |_dx, _dy| sink.set(sink.get() + 1));
303        // Declared after the pane and out of flow, so it is painted over it rather than beside it.
304        let panel = Container::new(
305            LayoutStyle::new()
306                .absolute()
307                .inset_end(0.0)
308                .inset_top(0.0)
309                .width(100.0)
310                .height(400.0),
311            vec![],
312        )
313        .unwrap();
314        let mut root = Container::new(
315            LayoutStyle::new().flex_row().width(400.0).height(400.0),
316            vec![Box::new(pane), Box::new(panel)],
317        )
318        .unwrap();
319        compute_layout(
320            root.layout_node(),
321            AvailableSpace::Definite(400.0),
322            AvailableSpace::Definite(400.0),
323        )
324        .unwrap();
325
326        let wheel = |x: f64| Event::Scrolled {
327            delta: ScrollDelta::Lines { x: 0.0, y: -3.0 },
328            x,
329            y: 200.0,
330        };
331        root.on_event(&wheel(50.0));
332        assert_eq!(pane_wheels.get(), 1, "over the pane it is the pane's");
333        root.on_event(&wheel(350.0));
334        assert_eq!(
335            pane_wheels.get(),
336            1,
337            "over the panel it is the panel's, even though the panel ignored it"
338        );
339    }
340
341    /// The same rule for hover: the pane still receives the move (a drag it started must keep tracking) but
342    /// must not read a move over the panel as *the pointer is over me*.
343    #[test]
344    fn a_covered_pane_is_not_hovered_by_a_move_over_the_panel() {
345        use platform_core::PointerSource;
346        reset_layout_runtime();
347        let at: Rc<Cell<Option<(f32, f32)>>> = Rc::new(Cell::new(None));
348        let sink = at.clone();
349        let pane = StyledContainer::new(
350            LayoutStyle::new().width(400.0).height(400.0),
351            |_r| RectStyle::default(),
352            vec![],
353        )
354        .unwrap()
355        .on_pointer_move(move |x, y| sink.set(Some((x, y))));
356        let panel = Container::new(
357            LayoutStyle::new()
358                .absolute()
359                .inset_end(0.0)
360                .inset_top(0.0)
361                .width(100.0)
362                .height(400.0),
363            vec![],
364        )
365        .unwrap();
366        let mut root = Container::new(
367            LayoutStyle::new().flex_row().width(400.0).height(400.0),
368            vec![Box::new(pane), Box::new(panel)],
369        )
370        .unwrap();
371        compute_layout(
372            root.layout_node(),
373            AvailableSpace::Definite(400.0),
374            AvailableSpace::Definite(400.0),
375        )
376        .unwrap();
377
378        let moved = |x: f64| Event::PointerMoved {
379            x,
380            y: 200.0,
381            source: PointerSource::Mouse,
382        };
383        root.on_event(&moved(50.0));
384        assert_eq!(at.get(), Some((50.0, 200.0)), "over the pane it tracks");
385        at.set(None);
386        root.on_event(&moved(350.0));
387        assert_eq!(at.get(), None, "the panel is in front of it there");
388    }
389
390    /// A readout drawn over a pane is there to be *read*, not to be pointed at: `click_through` is the box
391    /// saying so. Both halves of the rule have to let go of it — the wheel must reach the pane under it, and
392    /// a move over it must still count as a move over the pane, or the operation the readout is describing
393    /// stops the moment the pointer passes beneath it.
394    #[test]
395    fn a_click_through_label_does_not_stand_between_the_pointer_and_the_pane() {
396        use platform_core::PointerSource;
397        reset_layout_runtime();
398        let wheels = Rc::new(Cell::new(0u32));
399        let at: Rc<Cell<Option<(f32, f32)>>> = Rc::new(Cell::new(None));
400        let (wheel_sink, move_sink) = (wheels.clone(), at.clone());
401        let pane = StyledContainer::new(
402            LayoutStyle::new().width(400.0).height(400.0),
403            |_r| RectStyle::default(),
404            vec![],
405        )
406        .unwrap()
407        .on_scroll(move |_dx, _dy| wheel_sink.set(wheel_sink.get() + 1))
408        .on_pointer_move(move |x, y| move_sink.set(Some((x, y))));
409        let readout = StyledContainer::new(
410            LayoutStyle::new()
411                .absolute()
412                .inset_end(0.0)
413                .inset_top(0.0)
414                .width(100.0)
415                .height(400.0),
416            |_r| RectStyle::default(),
417            vec![],
418        )
419        .unwrap()
420        .click_through(true);
421        let mut root = Container::new(
422            LayoutStyle::new().flex_row().width(400.0).height(400.0),
423            vec![Box::new(pane), Box::new(readout)],
424        )
425        .unwrap();
426        compute_layout(
427            root.layout_node(),
428            AvailableSpace::Definite(400.0),
429            AvailableSpace::Definite(400.0),
430        )
431        .unwrap();
432
433        root.on_event(&Event::Scrolled {
434            delta: ScrollDelta::Lines { x: 0.0, y: -3.0 },
435            x: 350.0,
436            y: 200.0,
437        });
438        assert_eq!(wheels.get(), 1, "the wheel reached the pane underneath");
439        root.on_event(&Event::PointerMoved {
440            x: 350.0,
441            y: 200.0,
442            source: PointerSource::Mouse,
443        });
444        assert_eq!(
445            at.get(),
446            Some((350.0, 200.0)),
447            "and the pane is still the thing the pointer is over"
448        );
449    }
450
451    /// The other half of `click_through`, and the half that made it useless on its own: a control *inside* a
452    /// click-through bar still hovers. The bar declining to shadow the pane is not the pane shadowing the bar
453    /// — the bar is the one drawn on top. A floating toolbar over a canvas is exactly this shape, and without
454    /// it none of its buttons could be pointed at.
455    #[test]
456    fn a_control_inside_a_click_through_bar_is_still_hovered() {
457        use platform_core::PointerSource;
458        reset_layout_runtime();
459        let pane_at: Rc<Cell<Option<(f32, f32)>>> = Rc::new(Cell::new(None));
460        let button_at: Rc<Cell<Option<(f32, f32)>>> = Rc::new(Cell::new(None));
461        let (pane_sink, button_sink) = (pane_at.clone(), button_at.clone());
462        let pane = StyledContainer::new(
463            LayoutStyle::new().width(400.0).height(400.0),
464            |_r| RectStyle::default(),
465            vec![],
466        )
467        .unwrap()
468        .on_pointer_move(move |x, y| pane_sink.set(Some((x, y))));
469        let button = StyledContainer::new(
470            LayoutStyle::new().width(60.0).height(30.0),
471            |_r| RectStyle::default(),
472            vec![],
473        )
474        .unwrap()
475        .on_pointer_move(move |x, y| button_sink.set(Some((x, y))));
476        let bar = StyledContainer::new(
477            LayoutStyle::new()
478                .absolute()
479                .inset_top(0.0)
480                .width(400.0)
481                .height(40.0),
482            |_r| RectStyle::default(),
483            vec![Box::new(button)],
484        )
485        .unwrap()
486        .click_through(true);
487        let mut root = Container::new(
488            LayoutStyle::new().flex_row().width(400.0).height(400.0),
489            vec![Box::new(pane), Box::new(bar)],
490        )
491        .unwrap();
492        compute_layout(
493            root.layout_node(),
494            AvailableSpace::Definite(400.0),
495            AvailableSpace::Definite(400.0),
496        )
497        .unwrap();
498
499        root.on_event(&Event::PointerMoved {
500            x: 30.0,
501            y: 15.0,
502            source: PointerSource::Mouse,
503        });
504        assert_eq!(
505            button_at.get(),
506            Some((30.0, 15.0)),
507            "the button in the bar is hovered"
508        );
509        assert_eq!(
510            pane_at.get(),
511            Some((30.0, 15.0)),
512            "and the pane under it goes on tracking"
513        );
514    }
515
516    /// Crossing the window border does not lift a button. A drag that outlives the border — which is the
517    /// point of measuring one from its press — asks this registry which button started it on every move, and
518    /// clearing here would answer "none" in the middle of the gesture. Losing the *focus* is the case where
519    /// the release genuinely never arrives, and that one still clears.
520    #[test]
521    fn cursor_leaving_the_window_does_not_forget_a_held_button() {
522        use platform_core::{PointerButton, PointerSource};
523
524        reset_pointer();
525        observe_pointer(&Event::PointerPressed {
526            x: 10.0,
527            y: 10.0,
528            button: PointerButton::Secondary,
529            source: PointerSource::Mouse,
530        });
531        observe_pointer(&Event::CursorLeft);
532        assert!(
533            pointer_buttons().secondary,
534            "the button that armed the drag is still down"
535        );
536
537        observe_pointer(&Event::FocusChanged { is_focused: false });
538        assert!(!pointer_buttons().any());
539    }
540}