Skip to main content

cotis_interactivity/
mouse.rs

1//! Mouse hover hit-testing and click edge detection.
2//!
3//! This module provides traits for querying whether the cursor is over a layout
4//! element, strategies for turning raw button state into click signals, and
5//! per-element managers that combine hover with a [`MouseButton`].
6//!
7//! Raw mouse state comes from [`cotis_utils::interactivity::mouse::MouseProvider`];
8//! layout bounds come from [`cotis_utils::element_state::ElementBoundingBox`].
9
10use crate::utils::UpdatableSignal;
11use cotis::cotis_app::CotisApp;
12use cotis::layout::LayoutManagerCompatible;
13use cotis::renders::RenderCompatibleWith;
14use cotis::utils::ElementId;
15use cotis_utils::element_state::ElementBoundingBox;
16use cotis_utils::interactivity::mouse::{MouseButton, MouseProvider};
17
18/// Hit test `cursor` against `x, y, width, height` after expanding or shrinking the rect per side.
19///
20/// Positive margins move edges outward (larger hit area); negative margins inset (smaller hit area).
21///
22/// Uses half-open intervals on both axes: the right and bottom edges are excluded
23/// (`cursor_x < max_x`, `cursor_y < max_y`).
24///
25/// # Examples
26///
27/// ```
28/// use cotis_interactivity::mouse::cursor_in_rect_with_margins;
29///
30/// // Cursor inside the rect
31/// assert!(cursor_in_rect_with_margins(50.0, 50.0, 0.0, 0.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0));
32///
33/// // Right and bottom edges are exclusive
34/// assert!(!cursor_in_rect_with_margins(100.0, 50.0, 0.0, 0.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0));
35/// assert!(!cursor_in_rect_with_margins(50.0, 100.0, 0.0, 0.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0));
36///
37/// // Positive margin expands the hit area outward
38/// assert!(cursor_in_rect_with_margins(105.0, 50.0, 0.0, 0.0, 100.0, 100.0, 0.0, 0.0, 10.0, 0.0));
39/// ```
40#[allow(clippy::too_many_arguments)]
41pub fn cursor_in_rect_with_margins(
42    cursor_x: f32,
43    cursor_y: f32,
44    x: f32,
45    y: f32,
46    width: f32,
47    height: f32,
48    margin_left: f32,
49    margin_top: f32,
50    margin_right: f32,
51    margin_bottom: f32,
52) -> bool {
53    let min_x = x - margin_left;
54    let max_x = x + width + margin_right;
55    let min_y = y - margin_top;
56    let max_y = y + height + margin_bottom;
57    cursor_x >= min_x && cursor_x < max_x && cursor_y >= min_y && cursor_y < max_y
58}
59
60/// Query whether the cursor is over a layout element.
61///
62/// Implementors resolve the element's bounding box and compare it against the
63/// current cursor position. Returns `false` when the element is unknown.
64///
65/// # Blanket implementation
66///
67/// [`CotisApp`] implements this trait when:
68///
69/// - `Renderer: RenderCompatibleWith<Manager> + MouseProvider`
70/// - `Manager: LayoutManagerCompatible<Renderer> + ElementBoundingBox`
71pub trait ElementCursorState {
72    /// Same margin on every side (positive expands the hover region outward).
73    fn is_hovered_with_margin(&self, id: ElementId, margin: f32) -> bool {
74        self.is_hovered_with_margins(id, margin, margin, margin, margin)
75    }
76
77    /// Horizontal margin applied to left and right; vertical margin to top and bottom.
78    fn is_hovered_with_axis_margin(&self, id: ElementId, horizontal: f32, vertical: f32) -> bool {
79        self.is_hovered_with_margins(id, horizontal, vertical, horizontal, vertical)
80    }
81
82    /// Per-edge margins: left, top, right, bottom.
83    ///
84    /// Uses [`cursor_in_rect_with_margins`] against the element's layout bounding box.
85    fn is_hovered_with_margins(
86        &self,
87        id: ElementId,
88        margin_left: f32,
89        margin_top: f32,
90        margin_right: f32,
91        margin_bottom: f32,
92    ) -> bool;
93
94    /// Returns whether the cursor is inside the element's bounding box with zero margins.
95    fn is_hovered(&self, id: ElementId) -> bool {
96        self.is_hovered_with_margins(id, 0.0, 0.0, 0.0, 0.0)
97    }
98}
99
100/// Extends [`ElementCursorState`] with raw mouse button state.
101///
102/// Used by [`MouseButtonElementManager`] and scroll element managers to read
103/// button presses alongside hover. You can implement this trait on custom app
104/// shells; [`CotisApp`] provides the primary blanket implementation.
105pub trait FullElementCursorState: ElementCursorState {
106    /// Returns whether `mouse_button` is currently held down.
107    fn mouse_button_down(&self, mouse_button: MouseButton) -> bool;
108}
109
110impl<
111    Renderer: RenderCompatibleWith<Manager> + MouseProvider,
112    Manager: LayoutManagerCompatible<Renderer> + ElementBoundingBox,
113    Pipe,
114> ElementCursorState for CotisApp<Renderer, Manager, Pipe>
115{
116    fn is_hovered_with_margins(
117        &self,
118        id: ElementId,
119        margin_left: f32,
120        margin_top: f32,
121        margin_right: f32,
122        margin_bottom: f32,
123    ) -> bool {
124        let cursor_pos = self.render_borrow().get_mouse_position();
125        let bound_box = self.layout_manager_borrow().bounding_box(id);
126        if let Some(bound_box) = bound_box {
127            cursor_in_rect_with_margins(
128                cursor_pos.x,
129                cursor_pos.y,
130                bound_box.x,
131                bound_box.y,
132                bound_box.width,
133                bound_box.height,
134                margin_left,
135                margin_top,
136                margin_right,
137                margin_bottom,
138            )
139        } else {
140            false
141        }
142    }
143}
144
145impl<
146    Renderer: RenderCompatibleWith<Manager> + MouseProvider,
147    Manager: LayoutManagerCompatible<Renderer> + ElementBoundingBox,
148    Pipe,
149> FullElementCursorState for CotisApp<Renderer, Manager, Pipe>
150{
151    fn mouse_button_down(&self, mouse_button: MouseButton) -> bool {
152        MouseProvider::mouse_button_down(self.render_borrow(), mouse_button)
153    }
154}
155
156/// Determines when [`MouseButtonManager::is_clicked`] returns `true`.
157///
158/// Each variant tracks button state through an internal edge detector. All
159/// variants respect the manager's **cooldown**: while `cooldown > 0`,
160/// `is_clicked()` always returns `false` and button presses are ignored.
161///
162/// After handling a click, call [`MouseButtonManager::change_cooldown`] to
163/// set a debounce duration; the cooldown does not restart automatically.
164///
165/// # Variants
166///
167/// | Variant | `is_clicked` when |
168/// |---|---|
169/// | [`Pressed`](Self::Pressed) | Button is gated-pressed this frame |
170/// | [`NotPressed`](Self::NotPressed) | Button is not gated-pressed |
171/// | [`FallingEdge`](Self::FallingEdge) | Falling edge of gated press (release) |
172/// | [`RisingEdge`](Self::RisingEdge) | Rising edge of gated press (initial press) |
173/// | [`DoubleEdge`](Self::DoubleEdge) | Release after a valid press-while-hovered sequence |
174/// | [`RisingHold`](Self::RisingHold) | Hold timer reaches `goal` while armed |
175/// | [`Hold`](Self::Hold) | Hold timer reaches `goal` while pressed and hovered |
176pub enum MouseClickingStrategy {
177    /// `is_clicked` is `true` while the button is held (after cooldown).
178    Pressed,
179    /// `is_clicked` is `true` while the button is not held (after cooldown).
180    NotPressed,
181    /// `is_clicked` is `true` on the frame the button is released.
182    FallingEdge,
183    /// `is_clicked` is `true` on the frame the button is first pressed.
184    RisingEdge,
185    /// Click-on-release after a press-while-hovered sequence.
186    ///
187    /// During a press, `rising_edge` is armed when hovered and cooldown has
188    /// expired. `is_clicked` fires on the release (falling edge) when armed.
189    ///
190    /// # Note
191    ///
192    /// This is **not** an operating-system double-click detector.
193    DoubleEdge {
194        /// Internal arming flag; initialized to `false`.
195        rising_edge: bool,
196    },
197    /// Fires when `time >= goal` after the same arming logic as [`DoubleEdge`](Self::DoubleEdge).
198    ///
199    /// While armed, `time` increases each frame; when not armed, `time` decreases.
200    /// `time` resets to zero when the button is released.
201    RisingHold {
202        /// Internal arming flag; initialized to `false`.
203        rising_edge: bool,
204        /// Accumulated hold time in seconds.
205        time: f32,
206        /// Duration in seconds required for `is_clicked` to return `true`.
207        goal: f32,
208    },
209    /// Fires when `time >= goal` while the button is held and the element is hovered.
210    ///
211    /// `time` resets to zero when the button is released or the cursor leaves
212    /// the hover region.
213    Hold {
214        /// Accumulated hold time in seconds.
215        time: f32,
216        /// Duration in seconds required for `is_clicked` to return `true`.
217        goal: f32,
218    },
219}
220
221/// Tracks click signals for a single mouse button with a configurable strategy.
222///
223/// Call [`update`](Self::update) every frame with the raw button state, frame
224/// delta time, and whether the target region is hovered. Then query
225/// [`is_clicked`](Self::is_clicked).
226///
227/// # Cooldown
228///
229/// The cooldown decrements each `update`. While `cooldown > 0`, button presses
230/// are ignored and `is_clicked()` returns `false`. After handling a click, call
231/// [`change_cooldown`](Self::change_cooldown) to debounce subsequent clicks.
232pub struct MouseButtonManager {
233    strategy: MouseClickingStrategy,
234    signal: UpdatableSignal,
235    cooldown: f32,
236}
237
238impl MouseButtonManager {
239    /// Creates a manager with the given clicking strategy and initial cooldown.
240    ///
241    /// For hold strategies ([`MouseClickingStrategy::Hold`] and
242    /// [`MouseClickingStrategy::RisingHold`]), initialize struct-variant fields
243    /// before passing the strategy here, for example:
244    ///
245    /// ```rust
246    /// use cotis_interactivity::mouse::{MouseButtonManager, MouseClickingStrategy};
247    ///
248    /// let strategy = MouseClickingStrategy::Hold { time: 0.0, goal: 0.5 };
249    /// let manager = MouseButtonManager::new(strategy, 0.0);
250    /// ```
251    pub fn new(strategy: MouseClickingStrategy, cooldown: f32) -> MouseButtonManager {
252        Self {
253            strategy,
254            signal: UpdatableSignal::new(false),
255            cooldown,
256        }
257    }
258
259    /// Advances state for one frame.
260    ///
261    /// * `button_pressed` — raw button state from a [`MouseProvider`].
262    /// * `delta_time` — elapsed seconds since the last frame.
263    /// * `hovered` — whether the cursor is over the target region.
264    pub fn update(&mut self, button_pressed: bool, delta_time: f32, hovered: bool) {
265        self.cooldown -= delta_time;
266        let gated_press = button_pressed && self.cooldown <= 0.0;
267        self.signal.update(gated_press);
268        if button_pressed {
269            match &mut self.strategy {
270                MouseClickingStrategy::Hold { time, .. } => {
271                    if !hovered {
272                        *time = 0.0;
273                    } else {
274                        *time += delta_time
275                    }
276                }
277                MouseClickingStrategy::RisingHold {
278                    rising_edge, time, ..
279                } => {
280                    *rising_edge = self.cooldown <= 0.0
281                        && hovered
282                        && (*rising_edge || self.signal.rising_edge())
283                        && (button_pressed || self.signal.falling_edge());
284                    if *rising_edge {
285                        *time += delta_time;
286                    } else {
287                        *time -= delta_time;
288                    }
289                }
290                MouseClickingStrategy::DoubleEdge { rising_edge } => {
291                    *rising_edge = self.cooldown <= 0.0
292                        && hovered
293                        && (*rising_edge || self.signal.rising_edge())
294                        && (button_pressed || self.signal.falling_edge());
295                }
296                _ => {}
297            }
298        } else {
299            match &mut self.strategy {
300                MouseClickingStrategy::Hold { time, .. }
301                | MouseClickingStrategy::RisingHold { time, .. } => {
302                    *time = 0.0;
303                }
304                _ => {}
305            }
306        }
307    }
308
309    /// Clears the cooldown immediately, allowing the next press to register.
310    pub fn reset_cooldown(&mut self) {
311        self.cooldown = 0.0;
312    }
313
314    /// Sets the cooldown to `cooldown` seconds.
315    ///
316    /// Call this after handling a click to debounce further input.
317    pub fn change_cooldown(&mut self, cooldown: f32) {
318        self.cooldown = cooldown;
319    }
320
321    /// Returns whether a click signal is active this frame according to the strategy.
322    ///
323    /// Always returns `false` while the cooldown is positive.
324    pub fn is_clicked(&self) -> bool {
325        if self.cooldown > 0.0 {
326            return false;
327        }
328        match &self.strategy {
329            MouseClickingStrategy::Pressed => self.signal.is_on(),
330            MouseClickingStrategy::NotPressed => !self.signal.is_on(),
331            MouseClickingStrategy::FallingEdge => self.signal.falling_edge(),
332            MouseClickingStrategy::RisingEdge => self.signal.rising_edge(),
333            MouseClickingStrategy::DoubleEdge { rising_edge } => {
334                self.signal.falling_edge() && *rising_edge
335            }
336            MouseClickingStrategy::RisingHold { time, goal, .. } => time >= goal,
337            MouseClickingStrategy::Hold { time, goal } => time >= goal,
338        }
339    }
340}
341
342/// Per-element click manager that combines hover and a mouse button.
343///
344/// Wraps [`MouseButtonManager`] for a specific [`ElementId`]. Each frame, call
345/// [`update`](Self::update) with a [`FullElementCursorState`] provider (such as
346/// [`CotisApp`]).
347///
348/// # Examples
349///
350/// ```rust,ignore
351/// use cotis_interactivity::mouse::{
352///     FullElementCursorState, MouseButtonElementManager, MouseClickingStrategy,
353/// };
354/// use cotis_utils::interactivity::mouse::MouseButton;
355///
356/// // let mut click_manager = MouseButtonElementManager::new(
357/// //     element_id,
358/// //     MouseButton::Left,
359/// //     MouseClickingStrategy::RisingEdge,
360/// //     0.0,
361/// // );
362/// // click_manager.update(&app as &dyn FullElementCursorState, dt);
363/// // if click_manager.is_clicked() { ... }
364/// ```
365pub struct MouseButtonElementManager {
366    key: MouseButton,
367    manager: MouseButtonManager,
368    element: ElementId,
369}
370
371impl MouseButtonElementManager {
372    /// Creates a click manager bound to `element_id` and `key`.
373    pub fn new(
374        element_id: ElementId,
375        key: MouseButton,
376        strategy: MouseClickingStrategy,
377        cooldown: f32,
378    ) -> MouseButtonElementManager {
379        Self {
380            key,
381            element: element_id,
382            manager: MouseButtonManager::new(strategy, cooldown),
383        }
384    }
385
386    /// Advances state for one frame using hover and button state from `element_state_provider`.
387    pub fn update(&mut self, element_state_provider: &dyn FullElementCursorState, delta_time: f32) {
388        self.manager.update(
389            FullElementCursorState::mouse_button_down(element_state_provider, self.key),
390            delta_time,
391            element_state_provider.is_hovered(self.element),
392        );
393    }
394
395    /// Clears the inner manager's cooldown. See [`MouseButtonManager::reset_cooldown`].
396    pub fn reset_cooldown(&mut self) {
397        self.manager.reset_cooldown()
398    }
399
400    /// Sets the inner manager's cooldown. See [`MouseButtonManager::change_cooldown`].
401    pub fn change_cooldown(&mut self, cooldown: f32) {
402        self.manager.change_cooldown(cooldown)
403    }
404
405    /// Returns whether a click signal is active this frame.
406    pub fn is_clicked(&self) -> bool {
407        self.manager.is_clicked()
408    }
409}