tessera_ui/cursor.rs
1//! Cursor state management and event handling system.
2//!
3//! This module provides comprehensive cursor and touch event handling for the Tessera UI framework.
4//! It manages cursor position tracking, event queuing, touch gesture recognition, and inertial
5//! scrolling for smooth user interactions.
6
7use std::{
8 collections::{HashMap, VecDeque},
9 time::{Duration, Instant},
10};
11
12use crate::PxPosition;
13
14/// Maximum number of events to keep in the queue to prevent memory issues during UI jank.
15const KEEP_EVENTS_COUNT: usize = 10;
16
17/// Controls how quickly inertial scrolling decelerates (higher = faster slowdown).
18const INERTIA_DECAY_CONSTANT: f32 = 5.0;
19
20/// Minimum velocity threshold below which inertial scrolling stops (pixels per second).
21const MIN_INERTIA_VELOCITY: f32 = 10.0;
22
23/// Minimum velocity from a gesture required to start inertial scrolling (pixels per second).
24const INERTIA_MIN_VELOCITY_THRESHOLD_FOR_START: f32 = 50.0;
25
26/// Multiplier applied to initial inertial velocity (typically 1.0 for natural feel).
27const INERTIA_MOMENTUM_FACTOR: f32 = 1.0;
28
29/// Maximum inertial velocity to keep flicks controllable (pixels per second).
30const MAX_INERTIA_VELOCITY: f32 = 6000.0;
31
32/// Tracks the state of a single touch point for gesture recognition and velocity calculation.
33///
34/// This struct maintains the necessary information to track touch movement, calculate
35/// velocities, and determine when to trigger inertial scrolling.
36#[derive(Debug, Clone)]
37struct TouchPointState {
38 /// The last recorded position of this touch point.
39 last_position: PxPosition,
40 /// Timestamp of the last position update.
41 last_update_time: Instant,
42 /// Tracks recent velocity samples and temporal metadata for momentum calculation.
43 velocity_tracker: VelocityTracker,
44 /// Tracks whether this touch gesture generated a scroll event.
45 ///
46 /// When set, the gesture should be treated as a drag/scroll rather than a tap.
47 generated_scroll_event: bool,
48}
49
50/// Maintains a short window of velocity samples for inertia calculations.
51#[derive(Debug, Clone)]
52struct VelocityTracker {
53 samples: VecDeque<(Instant, f32, f32)>,
54 last_sample_time: Instant,
55}
56
57const VELOCITY_SAMPLE_WINDOW: Duration = Duration::from_millis(90);
58const VELOCITY_IDLE_CUTOFF: Duration = Duration::from_millis(65);
59
60/// Represents an active inertial scrolling session.
61///
62/// When a touch gesture ends with sufficient velocity, this struct tracks
63/// the momentum and gradually decelerates the scroll movement over time.
64#[derive(Debug, Clone)]
65struct ActiveInertia {
66 /// Current horizontal velocity in pixels per second.
67 velocity_x: f32,
68 /// Current vertical velocity in pixels per second.
69 velocity_y: f32,
70 /// Timestamp of the last inertia calculation update.
71 last_tick_time: Instant,
72}
73
74fn clamp_inertia_velocity(vx: f32, vy: f32) -> (f32, f32) {
75 if !vx.is_finite() || !vy.is_finite() {
76 return (0.0, 0.0);
77 }
78
79 let magnitude_sq = vx * vx + vy * vy;
80 if !magnitude_sq.is_finite() {
81 return (0.0, 0.0);
82 }
83
84 let magnitude = magnitude_sq.sqrt();
85 if magnitude > MAX_INERTIA_VELOCITY && MAX_INERTIA_VELOCITY > 0.0 {
86 let scale = MAX_INERTIA_VELOCITY / magnitude;
87 return (vx * scale, vy * scale);
88 }
89
90 (vx, vy)
91}
92
93/// Configuration settings for touch scrolling behavior.
94///
95/// This struct controls various aspects of how touch gestures are interpreted
96/// and converted into scroll events.
97#[derive(Debug, Clone)]
98struct TouchScrollConfig {
99 /// Minimum movement distance in pixels required to trigger a scroll event.
100 ///
101 /// Smaller values make scrolling more sensitive but may cause jitter.
102 /// Larger values require more deliberate movement but provide stability.
103 min_move_threshold: f32,
104 /// Whether touch scrolling is currently enabled.
105 enabled: bool,
106}
107
108impl Default for TouchScrollConfig {
109 fn default() -> Self {
110 Self {
111 // Reduced threshold for more responsive touch
112 min_move_threshold: 5.0,
113 enabled: true,
114 }
115 }
116}
117
118/// Central state manager for cursor and touch interactions.
119///
120/// `CursorState` is the main interface for handling all cursor-related events in the Tessera
121/// UI framework. It manages cursor position tracking, event queuing, multi-touch support,
122/// and provides smooth inertial scrolling for touch gestures.
123#[derive(Default)]
124pub struct CursorState {
125 /// Current cursor position, if any cursor is active.
126 position: Option<PxPosition>,
127 /// Bounded queue of cursor events awaiting processing.
128 events: VecDeque<CursorEvent>,
129 /// Active touch points mapped by their unique touch IDs.
130 touch_points: HashMap<u64, TouchPointState>,
131 /// Configuration settings for touch scrolling behavior.
132 touch_scroll_config: TouchScrollConfig,
133 /// Current inertial scrolling state, if active.
134 active_inertia: Option<ActiveInertia>,
135 /// If true, the cursor position will be cleared on the next frame.
136 clear_position_on_next_frame: bool,
137}
138
139impl CursorState {
140 /// Cleans up the cursor state at the end of a frame.
141 pub(crate) fn frame_cleanup(&mut self) {
142 if self.clear_position_on_next_frame {
143 self.update_position(None);
144 self.clear_position_on_next_frame = false;
145 }
146 }
147
148 /// Adds a cursor event to the processing queue.
149 ///
150 /// Events are stored in a bounded queue to prevent memory issues during UI performance
151 /// problems. If the queue exceeds [`KEEP_EVENTS_COUNT`], the oldest events are discarded.
152 ///
153 /// # Arguments
154 ///
155 /// * `event` - The cursor event to add to the queue
156 pub fn push_event(&mut self, event: CursorEvent) {
157 self.events.push_back(event);
158
159 // Maintain bounded queue size to prevent memory issues during UI jank
160 if self.events.len() > KEEP_EVENTS_COUNT {
161 self.events.pop_front();
162 }
163 }
164
165 /// Updates the current cursor position.
166 ///
167 /// This method accepts any type that can be converted into `Option<PxPosition>`,
168 /// allowing for flexible position updates including clearing the position by
169 /// passing `None`.
170 ///
171 /// # Arguments
172 ///
173 /// * `position` - New cursor position or `None` to clear the position
174 pub fn update_position(&mut self, position: impl Into<Option<PxPosition>>) {
175 self.position = position.into();
176 }
177
178 /// Processes active inertial scrolling and generates scroll events.
179 ///
180 /// This method is called internally to update inertial scrolling state and generate
181 /// appropriate scroll events. It handles velocity decay over time and stops inertia
182 /// when velocity falls below the minimum threshold.
183 ///
184 /// The method calculates scroll deltas based on current velocity and elapsed time,
185 /// applies exponential decay to the velocity, and queues scroll events for processing.
186 ///
187 /// # Implementation Details
188 ///
189 /// - Uses exponential decay with [`INERTIA_DECAY_CONSTANT`] for natural deceleration
190 /// - Stops inertia when velocity drops below [`MIN_INERTIA_VELOCITY`]
191 /// - Generates scroll events with calculated position deltas
192 /// - Handles edge cases like zero delta time gracefully
193 fn process_and_queue_inertial_scroll(&mut self) {
194 // Handle active inertia with clear, small responsibilities.
195 if let Some(mut inertia) = self.active_inertia.take() {
196 let now = Instant::now();
197 let delta_time = now.duration_since(inertia.last_tick_time).as_secs_f32();
198
199 if delta_time <= 0.0 {
200 // Called multiple times in the same instant; reinsert for next frame.
201 self.active_inertia = Some(inertia);
202 return;
203 }
204
205 // Compute scroll delta and emit event if meaningful.
206 let scroll_delta_x = inertia.velocity_x * delta_time;
207 let scroll_delta_y = inertia.velocity_y * delta_time;
208 if scroll_delta_x.abs() > 0.01 || scroll_delta_y.abs() > 0.01 {
209 self.push_scroll_event(now, scroll_delta_x, scroll_delta_y);
210 }
211
212 // Apply exponential decay to velocities.
213 let decay = (-INERTIA_DECAY_CONSTANT * delta_time).exp();
214 inertia.velocity_x *= decay;
215 inertia.velocity_y *= decay;
216 inertia.last_tick_time = now;
217
218 // Reinsert inertia only if still above threshold.
219 if inertia.velocity_x.abs() >= MIN_INERTIA_VELOCITY
220 || inertia.velocity_y.abs() >= MIN_INERTIA_VELOCITY
221 {
222 self.active_inertia = Some(inertia);
223 }
224 }
225 }
226
227 // Helper: push a scroll event with consistent construction.
228 fn push_scroll_event(&mut self, timestamp: Instant, dx: f32, dy: f32) {
229 self.push_event(CursorEvent {
230 timestamp,
231 content: CursorEventContent::Scroll(ScrollEventConent {
232 delta_x: dx,
233 delta_y: dy,
234 }),
235 gesture_state: GestureState::Dragged,
236 });
237 }
238
239 /// Retrieves and clears all pending cursor events.
240 ///
241 /// This method processes any active inertial scrolling, then returns all queued
242 /// cursor events and clears the internal event queue. Events are returned in
243 /// chronological order (oldest first).
244 ///
245 /// This is typically called once per frame by the UI framework to process
246 /// all accumulated input events.
247 ///
248 /// # Returns
249 ///
250 /// A vector of [`CursorEvent`]s ordered from oldest to newest.
251 ///
252 /// # Note
253 ///
254 /// Events are ordered from oldest to newest to ensure proper event processing order.
255 pub fn take_events(&mut self) -> Vec<CursorEvent> {
256 self.process_and_queue_inertial_scroll();
257 self.events.drain(..).collect()
258 }
259
260 /// Clears all cursor state and pending events.
261 ///
262 /// This is typically used when the UI context changes significantly,
263 /// such as when switching between different UI screens or when input
264 /// focus changes.
265 pub fn clear(&mut self) {
266 self.events.clear();
267 self.update_position(None);
268 self.active_inertia = None;
269 self.touch_points.clear();
270 self.clear_position_on_next_frame = false;
271 }
272
273 /// Returns the current cursor position, if any.
274 ///
275 /// The position represents the last known location of the cursor or active touch point.
276 /// Returns `None` if no cursor is currently active or if the position has been cleared.
277 ///
278 /// # Returns
279 ///
280 /// - `Some(PxPosition)` if a cursor position is currently tracked
281 /// - `None` if no cursor is active
282 pub fn position(&self) -> Option<PxPosition> {
283 self.position
284 }
285
286 /// Handles the start of a touch gesture.
287 ///
288 /// This method registers a new touch point and generates a press event. It also
289 /// stops any active inertial scrolling since a new touch interaction has begun.
290 ///
291 /// # Arguments
292 ///
293 /// * `touch_id` - Unique identifier for this touch point
294 /// * `position` - Initial position of the touch in pixel coordinates
295 pub fn handle_touch_start(&mut self, touch_id: u64, position: PxPosition) {
296 self.active_inertia = None; // Stop any existing inertia on new touch
297 let now = Instant::now();
298
299 self.touch_points.insert(
300 touch_id,
301 TouchPointState {
302 last_position: position,
303 last_update_time: now,
304 velocity_tracker: VelocityTracker::new(now),
305 generated_scroll_event: false,
306 },
307 );
308 self.update_position(position);
309 let press_event = CursorEvent {
310 timestamp: now,
311 content: CursorEventContent::Pressed(PressKeyEventType::Left),
312 gesture_state: GestureState::TapCandidate,
313 };
314 self.push_event(press_event);
315 }
316
317 /// Handles touch movement and generates scroll events when appropriate.
318 ///
319 /// This method tracks touch movement, calculates velocities for inertial scrolling,
320 /// and generates scroll events when the movement exceeds the minimum threshold.
321 /// It also maintains a velocity history for momentum calculation.
322 ///
323 /// # Arguments
324 ///
325 /// * `touch_id` - Unique identifier for the touch point being moved
326 /// * `current_position` - New position of the touch in pixel coordinates
327 ///
328 /// # Returns
329 ///
330 /// - `Some(CursorEvent)` containing a scroll event if movement exceeds threshold
331 /// - `None` if movement is below threshold or touch scrolling is disabled
332 pub fn handle_touch_move(
333 &mut self,
334 touch_id: u64,
335 current_position: PxPosition,
336 ) -> Option<CursorEvent> {
337 let now = Instant::now();
338 self.update_position(current_position);
339
340 if !self.touch_scroll_config.enabled {
341 return None;
342 }
343
344 if let Some(touch_state) = self.touch_points.get_mut(&touch_id) {
345 let delta_x = (current_position.x - touch_state.last_position.x).to_f32();
346 let delta_y = (current_position.y - touch_state.last_position.y).to_f32();
347 let move_distance = (delta_x * delta_x + delta_y * delta_y).sqrt();
348 let time_delta = now
349 .duration_since(touch_state.last_update_time)
350 .as_secs_f32();
351
352 touch_state.last_position = current_position;
353 touch_state.last_update_time = now;
354
355 if move_distance >= self.touch_scroll_config.min_move_threshold {
356 // Stop any active inertia when user actively moves the touch.
357 self.active_inertia = None;
358
359 if time_delta > 0.0 {
360 let velocity_x = delta_x / time_delta;
361 let velocity_y = delta_y / time_delta;
362 touch_state
363 .velocity_tracker
364 .push(now, velocity_x, velocity_y);
365 }
366
367 touch_state.generated_scroll_event = true;
368
369 // Return a scroll event for immediate feedback.
370 return Some(CursorEvent {
371 timestamp: now,
372 content: CursorEventContent::Scroll(ScrollEventConent {
373 delta_x, // Direct scroll delta for touch move
374 delta_y,
375 }),
376 gesture_state: GestureState::Dragged,
377 });
378 }
379 }
380 None
381 }
382
383 /// Handles the end of a touch gesture and potentially starts inertial scrolling.
384 ///
385 /// This method processes the end of a touch interaction by:
386 /// - Calculating average velocity from recent touch movement
387 /// - Starting inertial scrolling if velocity exceeds the threshold
388 /// - Generating a release event
389 /// - Cleaning up touch point tracking
390 ///
391 /// # Arguments
392 ///
393 /// * `touch_id` - Unique identifier for the touch point that ended
394 pub fn handle_touch_end(&mut self, touch_id: u64) {
395 let now = Instant::now();
396 let mut was_drag = false;
397
398 if let Some(touch_state) = self.touch_points.get_mut(&touch_id) {
399 was_drag |= touch_state.generated_scroll_event;
400 if self.touch_scroll_config.enabled {
401 if let Some((avg_vx, avg_vy)) = touch_state.velocity_tracker.resolve(now) {
402 let velocity_magnitude = (avg_vx * avg_vx + avg_vy * avg_vy).sqrt();
403 if velocity_magnitude > INERTIA_MIN_VELOCITY_THRESHOLD_FOR_START {
404 let (inertia_vx, inertia_vy) = clamp_inertia_velocity(
405 avg_vx * INERTIA_MOMENTUM_FACTOR,
406 avg_vy * INERTIA_MOMENTUM_FACTOR,
407 );
408 self.active_inertia = Some(ActiveInertia {
409 velocity_x: inertia_vx,
410 velocity_y: inertia_vy,
411 last_tick_time: now,
412 });
413 } else {
414 self.active_inertia = None;
415 }
416 } else {
417 self.active_inertia = None;
418 }
419 } else {
420 self.active_inertia = None; // Scrolling disabled
421 }
422 } else {
423 self.active_inertia = None; // No touch state present
424 }
425
426 if self.active_inertia.is_some() {
427 was_drag = true;
428 }
429
430 self.touch_points.remove(&touch_id);
431 let release_event = CursorEvent {
432 timestamp: now,
433 content: CursorEventContent::Released(PressKeyEventType::Left),
434 gesture_state: if was_drag {
435 GestureState::Dragged
436 } else {
437 GestureState::TapCandidate
438 },
439 };
440 self.push_event(release_event);
441
442 if self.touch_points.is_empty() && self.active_inertia.is_none() {
443 self.clear_position_on_next_frame = true;
444 }
445 }
446}
447
448impl VelocityTracker {
449 fn new(now: Instant) -> Self {
450 Self {
451 samples: VecDeque::new(),
452 last_sample_time: now,
453 }
454 }
455
456 fn push(&mut self, now: Instant, vx: f32, vy: f32) {
457 let (vx, vy) = clamp_inertia_velocity(vx, vy);
458 self.samples.push_back((now, vx, vy));
459 self.last_sample_time = now;
460 self.prune(now);
461 }
462
463 fn resolve(&mut self, now: Instant) -> Option<(f32, f32)> {
464 self.prune(now);
465
466 if self.samples.is_empty() {
467 return None;
468 }
469
470 let idle_time = now.duration_since(self.last_sample_time);
471 if idle_time >= VELOCITY_IDLE_CUTOFF {
472 self.samples.clear();
473 return None;
474 }
475
476 let mut weighted_sum_x = 0.0f32;
477 let mut weighted_sum_y = 0.0f32;
478 let mut total_weight = 0.0f32;
479 let window_secs = VELOCITY_SAMPLE_WINDOW.as_secs_f32().max(f32::EPSILON);
480
481 for &(timestamp, vx, vy) in &self.samples {
482 let age_secs = now
483 .duration_since(timestamp)
484 .as_secs_f32()
485 .clamp(0.0, window_secs);
486 let weight = (window_secs - age_secs).max(0.0);
487 if weight > 0.0 {
488 weighted_sum_x += vx * weight;
489 weighted_sum_y += vy * weight;
490 total_weight += weight;
491 }
492 }
493
494 if total_weight <= f32::EPSILON {
495 self.samples.clear();
496 return None;
497 }
498
499 let avg_x = weighted_sum_x / total_weight;
500 let avg_y = weighted_sum_y / total_weight;
501
502 let damping = 1.0 - idle_time.as_secs_f32() / VELOCITY_IDLE_CUTOFF.as_secs_f32();
503 let damping = damping.clamp(0.0, 1.0);
504 let (avg_x, avg_y) = clamp_inertia_velocity(avg_x * damping, avg_y * damping);
505
506 Some((avg_x, avg_y))
507 }
508
509 fn prune(&mut self, now: Instant) {
510 while let Some(&(timestamp, _, _)) = self.samples.front() {
511 if now.duration_since(timestamp) > VELOCITY_SAMPLE_WINDOW {
512 self.samples.pop_front();
513 } else {
514 break;
515 }
516 }
517 }
518}
519
520/// Represents a single cursor or touch event with timing information.
521///
522/// `CursorEvent` encapsulates all types of cursor interactions including presses,
523/// releases, and scroll actions. Each event includes a timestamp for precise
524/// timing and ordering of input events.
525#[derive(Debug, Clone)]
526pub struct CursorEvent {
527 /// Timestamp indicating when this event occurred.
528 pub timestamp: Instant,
529 /// The specific type and data of this cursor event.
530 pub content: CursorEventContent,
531 /// Classification of the gesture associated with this event.
532 ///
533 /// Events originating from touch scrolling will mark this as [`GestureState::Dragged`],
534 /// allowing downstream components to distinguish tap candidates from scroll gestures.
535 pub gesture_state: GestureState,
536}
537
538/// Contains scroll movement data for scroll events.
539///
540/// `ScrollEventConent` represents the amount of scrolling that occurred,
541/// with positive values typically indicating rightward/downward movement
542/// and negative values indicating leftward/upward movement.
543#[derive(Debug, Clone, PartialEq)]
544pub struct ScrollEventConent {
545 /// Horizontal scroll distance in pixels.
546 pub delta_x: f32,
547 /// Vertical scroll distance in pixels.
548 pub delta_y: f32,
549}
550
551/// Enumeration of all possible cursor event types.
552///
553/// `CursorEventContent` represents the different kinds of interactions
554/// that can occur with cursor or touch input, including button presses,
555/// releases, and scroll actions.
556#[derive(Debug, Clone, PartialEq)]
557pub enum CursorEventContent {
558 /// A cursor button or touch point was pressed.
559 Pressed(PressKeyEventType),
560 /// A cursor button or touch point was released.
561 Released(PressKeyEventType),
562 /// A scroll action occurred (mouse wheel, touch drag, or inertial scroll).
563 Scroll(ScrollEventConent),
564}
565
566/// Describes the high-level gesture classification of a cursor event.
567#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
568pub enum GestureState {
569 /// Indicates the event is part of a potential tap/click interaction.
570 #[default]
571 TapCandidate,
572 /// Indicates the event happened during a drag/scroll gesture.
573 Dragged,
574}
575
576impl CursorEventContent {
577 /// Creates a cursor press/release event from winit mouse button events.
578 ///
579 /// This method converts winit's mouse button events into Tessera's cursor event format.
580 /// It handles the three standard mouse buttons (left, right, middle) and ignores
581 /// any additional buttons that may be present on some mice.
582 ///
583 /// # Arguments
584 ///
585 /// * `state` - Whether the button was pressed or released
586 /// * `button` - Which mouse button was affected
587 ///
588 /// # Returns
589 ///
590 /// - `Some(CursorEventContent)` for supported mouse buttons
591 /// - `None` for unsupported mouse buttons
592 pub fn from_press_event(
593 state: winit::event::ElementState,
594 button: winit::event::MouseButton,
595 ) -> Option<Self> {
596 let event_type = match button {
597 winit::event::MouseButton::Left => PressKeyEventType::Left,
598 winit::event::MouseButton::Right => PressKeyEventType::Right,
599 winit::event::MouseButton::Middle => PressKeyEventType::Middle,
600 _ => return None, // Ignore other buttons
601 };
602 let state = match state {
603 winit::event::ElementState::Pressed => Self::Pressed(event_type),
604 winit::event::ElementState::Released => Self::Released(event_type),
605 };
606 Some(state)
607 }
608
609 /// Creates a scroll event from winit mouse wheel events.
610 ///
611 /// This method converts winit's mouse scroll delta into Tessera's scroll event format.
612 /// It handles both line-based scrolling (typical mouse wheels) and pixel-based
613 /// scrolling (trackpads, precision mice) by applying appropriate scaling.
614 ///
615 /// # Arguments
616 ///
617 /// * `delta` - The scroll delta from winit
618 ///
619 /// # Returns
620 ///
621 /// A `CursorEventContent::Scroll` event with scaled delta values.
622 pub fn from_scroll_event(delta: winit::event::MouseScrollDelta) -> Self {
623 let (delta_x, delta_y) = match delta {
624 winit::event::MouseScrollDelta::LineDelta(x, y) => (x, y),
625 winit::event::MouseScrollDelta::PixelDelta(delta) => (delta.x as f32, delta.y as f32),
626 };
627
628 const MOUSE_WHEEL_SPEED_MULTIPLIER: f32 = 50.0;
629 Self::Scroll(ScrollEventConent {
630 delta_x: delta_x * MOUSE_WHEEL_SPEED_MULTIPLIER,
631 delta_y: delta_y * MOUSE_WHEEL_SPEED_MULTIPLIER,
632 })
633 }
634}
635
636/// Represents the different types of cursor buttons or touch interactions.
637#[derive(Debug, Clone, PartialEq, Eq)]
638pub enum PressKeyEventType {
639 /// The primary mouse button (typically left button) or primary touch.
640 Left,
641 /// The secondary mouse button (typically right button).
642 Right,
643 /// The middle mouse button (typically scroll wheel click).
644 Middle,
645}