cotis-interactivity 0.1.0-alpha

Higher-level input managers for Cotis applications
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
//! Mouse hover hit-testing and click edge detection.
//!
//! This module provides traits for querying whether the cursor is over a layout
//! element, strategies for turning raw button state into click signals, and
//! per-element managers that combine hover with a [`MouseButton`].
//!
//! Raw mouse state comes from [`cotis_utils::interactivity::mouse::MouseProvider`];
//! layout bounds come from [`cotis_utils::element_state::ElementBoundingBox`].

use crate::utils::UpdatableSignal;
use cotis::cotis_app::CotisApp;
use cotis::layout::LayoutManagerCompatible;
use cotis::renders::RenderCompatibleWith;
use cotis::utils::ElementId;
use cotis_utils::element_state::ElementBoundingBox;
use cotis_utils::interactivity::mouse::{MouseButton, MouseProvider};

/// Hit test `cursor` against `x, y, width, height` after expanding or shrinking the rect per side.
///
/// Positive margins move edges outward (larger hit area); negative margins inset (smaller hit area).
///
/// Uses half-open intervals on both axes: the right and bottom edges are excluded
/// (`cursor_x < max_x`, `cursor_y < max_y`).
///
/// # Examples
///
/// ```
/// use cotis_interactivity::mouse::cursor_in_rect_with_margins;
///
/// // Cursor inside the rect
/// 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));
///
/// // Right and bottom edges are exclusive
/// 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));
/// 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));
///
/// // Positive margin expands the hit area outward
/// 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));
/// ```
#[allow(clippy::too_many_arguments)]
pub fn cursor_in_rect_with_margins(
    cursor_x: f32,
    cursor_y: f32,
    x: f32,
    y: f32,
    width: f32,
    height: f32,
    margin_left: f32,
    margin_top: f32,
    margin_right: f32,
    margin_bottom: f32,
) -> bool {
    let min_x = x - margin_left;
    let max_x = x + width + margin_right;
    let min_y = y - margin_top;
    let max_y = y + height + margin_bottom;
    cursor_x >= min_x && cursor_x < max_x && cursor_y >= min_y && cursor_y < max_y
}

/// Query whether the cursor is over a layout element.
///
/// Implementors resolve the element's bounding box and compare it against the
/// current cursor position. Returns `false` when the element is unknown.
///
/// # Blanket implementation
///
/// [`CotisApp`] implements this trait when:
///
/// - `Renderer: RenderCompatibleWith<Manager> + MouseProvider`
/// - `Manager: LayoutManagerCompatible<Renderer> + ElementBoundingBox`
pub trait ElementCursorState {
    /// Same margin on every side (positive expands the hover region outward).
    fn is_hovered_with_margin(&self, id: ElementId, margin: f32) -> bool {
        self.is_hovered_with_margins(id, margin, margin, margin, margin)
    }

    /// Horizontal margin applied to left and right; vertical margin to top and bottom.
    fn is_hovered_with_axis_margin(&self, id: ElementId, horizontal: f32, vertical: f32) -> bool {
        self.is_hovered_with_margins(id, horizontal, vertical, horizontal, vertical)
    }

    /// Per-edge margins: left, top, right, bottom.
    ///
    /// Uses [`cursor_in_rect_with_margins`] against the element's layout bounding box.
    fn is_hovered_with_margins(
        &self,
        id: ElementId,
        margin_left: f32,
        margin_top: f32,
        margin_right: f32,
        margin_bottom: f32,
    ) -> bool;

    /// Returns whether the cursor is inside the element's bounding box with zero margins.
    fn is_hovered(&self, id: ElementId) -> bool {
        self.is_hovered_with_margins(id, 0.0, 0.0, 0.0, 0.0)
    }
}

/// Extends [`ElementCursorState`] with raw mouse button state.
///
/// Used by [`MouseButtonElementManager`] and scroll element managers to read
/// button presses alongside hover. You can implement this trait on custom app
/// shells; [`CotisApp`] provides the primary blanket implementation.
pub trait FullElementCursorState: ElementCursorState {
    /// Returns whether `mouse_button` is currently held down.
    fn mouse_button_down(&self, mouse_button: MouseButton) -> bool;
}

impl<
    Renderer: RenderCompatibleWith<Manager> + MouseProvider,
    Manager: LayoutManagerCompatible<Renderer> + ElementBoundingBox,
    Pipe,
> ElementCursorState for CotisApp<Renderer, Manager, Pipe>
{
    fn is_hovered_with_margins(
        &self,
        id: ElementId,
        margin_left: f32,
        margin_top: f32,
        margin_right: f32,
        margin_bottom: f32,
    ) -> bool {
        let cursor_pos = self.render_borrow().get_mouse_position();
        let bound_box = self.layout_manager_borrow().bounding_box(id);
        if let Some(bound_box) = bound_box {
            cursor_in_rect_with_margins(
                cursor_pos.x,
                cursor_pos.y,
                bound_box.x,
                bound_box.y,
                bound_box.width,
                bound_box.height,
                margin_left,
                margin_top,
                margin_right,
                margin_bottom,
            )
        } else {
            false
        }
    }
}

impl<
    Renderer: RenderCompatibleWith<Manager> + MouseProvider,
    Manager: LayoutManagerCompatible<Renderer> + ElementBoundingBox,
    Pipe,
> FullElementCursorState for CotisApp<Renderer, Manager, Pipe>
{
    fn mouse_button_down(&self, mouse_button: MouseButton) -> bool {
        MouseProvider::mouse_button_down(self.render_borrow(), mouse_button)
    }
}

/// Determines when [`MouseButtonManager::is_clicked`] returns `true`.
///
/// Each variant tracks button state through an internal edge detector. All
/// variants respect the manager's **cooldown**: while `cooldown > 0`,
/// `is_clicked()` always returns `false` and button presses are ignored.
///
/// After handling a click, call [`MouseButtonManager::change_cooldown`] to
/// set a debounce duration; the cooldown does not restart automatically.
///
/// # Variants
///
/// | Variant | `is_clicked` when |
/// |---|---|
/// | [`Pressed`](Self::Pressed) | Button is gated-pressed this frame |
/// | [`NotPressed`](Self::NotPressed) | Button is not gated-pressed |
/// | [`FallingEdge`](Self::FallingEdge) | Falling edge of gated press (release) |
/// | [`RisingEdge`](Self::RisingEdge) | Rising edge of gated press (initial press) |
/// | [`DoubleEdge`](Self::DoubleEdge) | Release after a valid press-while-hovered sequence |
/// | [`RisingHold`](Self::RisingHold) | Hold timer reaches `goal` while armed |
/// | [`Hold`](Self::Hold) | Hold timer reaches `goal` while pressed and hovered |
pub enum MouseClickingStrategy {
    /// `is_clicked` is `true` while the button is held (after cooldown).
    Pressed,
    /// `is_clicked` is `true` while the button is not held (after cooldown).
    NotPressed,
    /// `is_clicked` is `true` on the frame the button is released.
    FallingEdge,
    /// `is_clicked` is `true` on the frame the button is first pressed.
    RisingEdge,
    /// Click-on-release after a press-while-hovered sequence.
    ///
    /// During a press, `rising_edge` is armed when hovered and cooldown has
    /// expired. `is_clicked` fires on the release (falling edge) when armed.
    ///
    /// # Note
    ///
    /// This is **not** an operating-system double-click detector.
    DoubleEdge {
        /// Internal arming flag; initialized to `false`.
        rising_edge: bool,
    },
    /// Fires when `time >= goal` after the same arming logic as [`DoubleEdge`](Self::DoubleEdge).
    ///
    /// While armed, `time` increases each frame; when not armed, `time` decreases.
    /// `time` resets to zero when the button is released.
    RisingHold {
        /// Internal arming flag; initialized to `false`.
        rising_edge: bool,
        /// Accumulated hold time in seconds.
        time: f32,
        /// Duration in seconds required for `is_clicked` to return `true`.
        goal: f32,
    },
    /// Fires when `time >= goal` while the button is held and the element is hovered.
    ///
    /// `time` resets to zero when the button is released or the cursor leaves
    /// the hover region.
    Hold {
        /// Accumulated hold time in seconds.
        time: f32,
        /// Duration in seconds required for `is_clicked` to return `true`.
        goal: f32,
    },
}

/// Tracks click signals for a single mouse button with a configurable strategy.
///
/// Call [`update`](Self::update) every frame with the raw button state, frame
/// delta time, and whether the target region is hovered. Then query
/// [`is_clicked`](Self::is_clicked).
///
/// # Cooldown
///
/// The cooldown decrements each `update`. While `cooldown > 0`, button presses
/// are ignored and `is_clicked()` returns `false`. After handling a click, call
/// [`change_cooldown`](Self::change_cooldown) to debounce subsequent clicks.
pub struct MouseButtonManager {
    strategy: MouseClickingStrategy,
    signal: UpdatableSignal,
    cooldown: f32,
}

impl MouseButtonManager {
    /// Creates a manager with the given clicking strategy and initial cooldown.
    ///
    /// For hold strategies ([`MouseClickingStrategy::Hold`] and
    /// [`MouseClickingStrategy::RisingHold`]), initialize struct-variant fields
    /// before passing the strategy here, for example:
    ///
    /// ```rust
    /// use cotis_interactivity::mouse::{MouseButtonManager, MouseClickingStrategy};
    ///
    /// let strategy = MouseClickingStrategy::Hold { time: 0.0, goal: 0.5 };
    /// let manager = MouseButtonManager::new(strategy, 0.0);
    /// ```
    pub fn new(strategy: MouseClickingStrategy, cooldown: f32) -> MouseButtonManager {
        Self {
            strategy,
            signal: UpdatableSignal::new(false),
            cooldown,
        }
    }

    /// Advances state for one frame.
    ///
    /// * `button_pressed` — raw button state from a [`MouseProvider`].
    /// * `delta_time` — elapsed seconds since the last frame.
    /// * `hovered` — whether the cursor is over the target region.
    pub fn update(&mut self, button_pressed: bool, delta_time: f32, hovered: bool) {
        self.cooldown -= delta_time;
        let gated_press = button_pressed && self.cooldown <= 0.0;
        self.signal.update(gated_press);
        if button_pressed {
            match &mut self.strategy {
                MouseClickingStrategy::Hold { time, .. } => {
                    if !hovered {
                        *time = 0.0;
                    } else {
                        *time += delta_time
                    }
                }
                MouseClickingStrategy::RisingHold {
                    rising_edge, time, ..
                } => {
                    *rising_edge = self.cooldown <= 0.0
                        && hovered
                        && (*rising_edge || self.signal.rising_edge())
                        && (button_pressed || self.signal.falling_edge());
                    if *rising_edge {
                        *time += delta_time;
                    } else {
                        *time -= delta_time;
                    }
                }
                MouseClickingStrategy::DoubleEdge { rising_edge } => {
                    *rising_edge = self.cooldown <= 0.0
                        && hovered
                        && (*rising_edge || self.signal.rising_edge())
                        && (button_pressed || self.signal.falling_edge());
                }
                _ => {}
            }
        } else {
            match &mut self.strategy {
                MouseClickingStrategy::Hold { time, .. }
                | MouseClickingStrategy::RisingHold { time, .. } => {
                    *time = 0.0;
                }
                _ => {}
            }
        }
    }

    /// Clears the cooldown immediately, allowing the next press to register.
    pub fn reset_cooldown(&mut self) {
        self.cooldown = 0.0;
    }

    /// Sets the cooldown to `cooldown` seconds.
    ///
    /// Call this after handling a click to debounce further input.
    pub fn change_cooldown(&mut self, cooldown: f32) {
        self.cooldown = cooldown;
    }

    /// Returns whether a click signal is active this frame according to the strategy.
    ///
    /// Always returns `false` while the cooldown is positive.
    pub fn is_clicked(&self) -> bool {
        if self.cooldown > 0.0 {
            return false;
        }
        match &self.strategy {
            MouseClickingStrategy::Pressed => self.signal.is_on(),
            MouseClickingStrategy::NotPressed => !self.signal.is_on(),
            MouseClickingStrategy::FallingEdge => self.signal.falling_edge(),
            MouseClickingStrategy::RisingEdge => self.signal.rising_edge(),
            MouseClickingStrategy::DoubleEdge { rising_edge } => {
                self.signal.falling_edge() && *rising_edge
            }
            MouseClickingStrategy::RisingHold { time, goal, .. } => time >= goal,
            MouseClickingStrategy::Hold { time, goal } => time >= goal,
        }
    }
}

/// Per-element click manager that combines hover and a mouse button.
///
/// Wraps [`MouseButtonManager`] for a specific [`ElementId`]. Each frame, call
/// [`update`](Self::update) with a [`FullElementCursorState`] provider (such as
/// [`CotisApp`]).
///
/// # Examples
///
/// ```rust,ignore
/// use cotis_interactivity::mouse::{
///     FullElementCursorState, MouseButtonElementManager, MouseClickingStrategy,
/// };
/// use cotis_utils::interactivity::mouse::MouseButton;
///
/// // let mut click_manager = MouseButtonElementManager::new(
/// //     element_id,
/// //     MouseButton::Left,
/// //     MouseClickingStrategy::RisingEdge,
/// //     0.0,
/// // );
/// // click_manager.update(&app as &dyn FullElementCursorState, dt);
/// // if click_manager.is_clicked() { ... }
/// ```
pub struct MouseButtonElementManager {
    key: MouseButton,
    manager: MouseButtonManager,
    element: ElementId,
}

impl MouseButtonElementManager {
    /// Creates a click manager bound to `element_id` and `key`.
    pub fn new(
        element_id: ElementId,
        key: MouseButton,
        strategy: MouseClickingStrategy,
        cooldown: f32,
    ) -> MouseButtonElementManager {
        Self {
            key,
            element: element_id,
            manager: MouseButtonManager::new(strategy, cooldown),
        }
    }

    /// Advances state for one frame using hover and button state from `element_state_provider`.
    pub fn update(&mut self, element_state_provider: &dyn FullElementCursorState, delta_time: f32) {
        self.manager.update(
            FullElementCursorState::mouse_button_down(element_state_provider, self.key),
            delta_time,
            element_state_provider.is_hovered(self.element),
        );
    }

    /// Clears the inner manager's cooldown. See [`MouseButtonManager::reset_cooldown`].
    pub fn reset_cooldown(&mut self) {
        self.manager.reset_cooldown()
    }

    /// Sets the inner manager's cooldown. See [`MouseButtonManager::change_cooldown`].
    pub fn change_cooldown(&mut self, cooldown: f32) {
        self.manager.change_cooldown(cooldown)
    }

    /// Returns whether a click signal is active this frame.
    pub fn is_clicked(&self) -> bool {
        self.manager.is_clicked()
    }
}