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}