1
//! Scroll physics timer callback — the core of the timer-based scroll architecture.
2
//!
3
//! This module implements the scroll physics as a regular timer callback, using
4
//! the same transactional `push_change(CallbackChange::ScrollTo)` pattern as all
5
//! other state modifications. There is nothing special about the scroll timer —
6
//! it is a normal user-space timer that happens to be started by the framework.
7
//!
8
//! # Architecture
9
//!
10
//! ```text
11
//! Platform Event Handler
12
//!   → ScrollManager.record_scroll_input(ScrollInput)
13
//!   → starts SCROLL_MOMENTUM_TIMER if not running
14
//!
15
//! Timer fires (every timer_interval_ms from ScrollPhysics):
16
//!   1. queue.take_recent(100) — consume up to 100 most recent inputs
17
//!   2. For each input:
18
//!      - TrackpadContinuous → set offset directly (OS handles momentum)
19
//!      - WheelDiscrete → add impulse to velocity
20
//!      - Programmatic → set target position
21
//!   3. Integrate physics: velocity decay, clamping
22
//!   4. push_change(CallbackChange::ScrollTo) for each updated node
23
//!   5. Return continue_and_update() or terminate_unchanged()
24
//! ```
25
//!
26
//! # Key Design Decisions
27
//!
28
//! - **No mutable access to LayoutWindow needed**: Uses `CallbackChange::ScrollTo`
29
//!   (the same transactional pattern as all other callbacks).
30
//! - **Shared queue via Arc<Mutex>**: The `ScrollInputQueue` is cloned into the
31
//!   timer's `RefAny` data. Event handlers push, timer pops.
32
//! - **Platform-independent**: Works on macOS, Windows, Linux — anywhere timers work.
33
//! - **Self-terminating**: When all velocities are below threshold and no inputs
34
//!   pending, the timer returns `TerminateTimer::Terminate`.
35

            
36
use alloc::collections::BTreeMap;
37

            
38
use azul_core::{
39
    callbacks::{TimerCallbackReturn, Update},
40
    dom::DomId,
41
    geom::LogicalPosition,
42
    refany::RefAny,
43
    styled_dom::NodeHierarchyItemId,
44
    task::TerminateTimer,
45
};
46

            
47
use crate::{
48
    managers::scroll_state::{ScrollInput, ScrollInputQueue, ScrollInputSource, ScrollNodeInfo},
49
    timer::TimerCallbackInfo,
50
};
51

            
52
use azul_css::props::style::scrollbar::{ScrollPhysics, OverflowScrolling, OverscrollBehavior};
53

            
54
/// Maximum number of scroll events processed per timer tick.
55
/// Older events beyond this limit are discarded to keep the physics
56
/// simulation bounded and testable.
57
const MAX_SCROLL_EVENTS_PER_TICK: usize = 100;
58

            
59
/// Assumed framerate for converting between per-frame and per-second quantities.
60
/// Used both in wheel impulse conversion and friction decay so the two stay coupled.
61
const ASSUMED_FPS: f32 = 60.0;
62

            
63
/// State stored in the timer's RefAny data.
64
///
65
/// Contains the shared input queue, per-node velocity state, and the global
66
/// scroll physics configuration from `SystemStyle`.
67
#[derive(Debug)]
68
pub struct ScrollPhysicsState {
69
    /// Shared input queue — same Arc as ScrollManager.scroll_input_queue
70
    pub input_queue: ScrollInputQueue,
71
    /// Per-node velocity tracking
72
    pub node_velocities: BTreeMap<(DomId, NodeId), NodeScrollPhysics>,
73
    /// Per-node "forced position" from programmatic scroll (hard-clamped)
74
    pub pending_positions: BTreeMap<(DomId, NodeId), LogicalPosition>,
75
    /// Per-node "forced position" from trackpad scroll (rubber-band clamped)
76
    pub pending_trackpad_positions: BTreeMap<(DomId, NodeId), LogicalPosition>,
77
    /// Global scroll physics configuration (from SystemStyle)
78
    pub scroll_physics: ScrollPhysics,
79
}
80

            
81
/// For convenience, re-export NodeId
82
use azul_core::id::NodeId;
83

            
84
/// Per-node scroll physics state
85
#[derive(Debug, Clone, Default)]
86
pub struct NodeScrollPhysics {
87
    /// Current velocity in pixels/second
88
    pub velocity: LogicalPosition,
89
    /// Whether this node is currently in a rubber-band overshoot state
90
    pub is_rubber_banding: bool,
91
}
92

            
93
impl ScrollPhysicsState {
94
    /// Create a new physics state with the shared input queue and global config
95
    pub fn new(input_queue: ScrollInputQueue, scroll_physics: ScrollPhysics) -> Self {
96
        Self {
97
            input_queue,
98
            node_velocities: BTreeMap::new(),
99
            pending_positions: BTreeMap::new(),
100
            pending_trackpad_positions: BTreeMap::new(),
101
            scroll_physics,
102
        }
103
    }
104

            
105
    /// Returns true if any node has non-zero velocity or there are pending inputs
106
    pub fn is_active(&self) -> bool {
107
        let threshold = self.scroll_physics.min_velocity_threshold;
108
        self.input_queue.has_pending()
109
            || self.node_velocities.values().any(|v| {
110
                v.velocity.x.abs() > threshold
111
                    || v.velocity.y.abs() > threshold
112
                    || v.is_rubber_banding
113
            })
114
            || !self.pending_positions.is_empty()
115
            || !self.pending_trackpad_positions.is_empty()
116
    }
117
}
118

            
119
/// The scroll physics timer callback.
120
///
121
/// This is a normal timer callback registered with `SCROLL_MOMENTUM_TIMER_ID`.
122
/// It consumes pending scroll inputs, applies physics, and pushes ScrollTo changes.
123
///
124
/// Uses the `ScrollPhysics` configuration from `SystemStyle` for friction,
125
/// velocity thresholds, wheel multiplier, and rubber-banding parameters.
126
/// Per-node `OverflowScrolling` and `OverscrollBehavior` CSS properties are
127
/// respected to decide whether each node gets rubber-banding.
128
///
129
/// # C API
130
///
131
/// This function has `extern "C"` ABI so it can be used as a `TimerCallbackType`.
132
pub extern "C" fn scroll_physics_timer_callback(
133
    mut data: RefAny,
134
    mut timer_info: TimerCallbackInfo,
135
) -> TimerCallbackReturn {
136
    // Downcast the RefAny to our physics state
137
    let mut physics = match data.downcast_mut::<ScrollPhysicsState>() {
138
        Some(p) => p,
139
        None => return TimerCallbackReturn::terminate_unchanged(),
140
    };
141

            
142
    // Extract physics config values
143
    let sp = &physics.scroll_physics;
144
    let dt = sp.timer_interval_ms.max(1) as f32 / 1000.0;
145
    let friction_rate = friction_from_deceleration(sp.deceleration_rate);
146
    let velocity_threshold = sp.min_velocity_threshold;
147
    let wheel_multiplier = sp.wheel_multiplier;
148
    let max_velocity = sp.max_velocity;
149
    let overscroll_elasticity = sp.overscroll_elasticity;
150
    let max_overscroll_distance = sp.max_overscroll_distance;
151
    let bounce_back_duration_ms = sp.bounce_back_duration_ms;
152

            
153
    // 1. Take at most MAX_SCROLL_EVENTS_PER_TICK recent inputs from the shared queue
154
    let inputs = physics.input_queue.take_recent(MAX_SCROLL_EVENTS_PER_TICK);
155

            
156
    for input in inputs {
157
        let key = (input.dom_id, input.node_id);
158
        match input.source {
159
            ScrollInputSource::TrackpadContinuous => {
160
                // Trackpad: OS handles momentum. Apply delta directly as position change.
161
                let current = timer_info
162
                    .get_scroll_node_info(input.dom_id, input.node_id)
163
                    .map(|info| info.current_offset)
164
                    .unwrap_or_default();
165

            
166
                let new_pos = LogicalPosition {
167
                    x: current.x + input.delta.x,
168
                    y: current.y + input.delta.y,
169
                };
170
                physics.pending_trackpad_positions.insert(key, new_pos);
171

            
172
                // Kill any existing velocity for this node (trackpad overrides momentum)
173
                physics.node_velocities.remove(&key);
174
            }
175
            ScrollInputSource::WheelDiscrete => {
176
                // Mouse wheel: Convert delta to velocity impulse
177
                let node_physics = physics
178
                    .node_velocities
179
                    .entry(key)
180
                    .or_insert_with(NodeScrollPhysics::default);
181

            
182
                // Add impulse (delta is in pixels, convert to pixels/second)
183
                node_physics.velocity.x += input.delta.x * wheel_multiplier * ASSUMED_FPS;
184
                node_physics.velocity.y += input.delta.y * wheel_multiplier * ASSUMED_FPS;
185

            
186
                // Clamp to max velocity
187
                node_physics.velocity.x = node_physics.velocity.x.clamp(-max_velocity, max_velocity);
188
                node_physics.velocity.y = node_physics.velocity.y.clamp(-max_velocity, max_velocity);
189
            }
190
            ScrollInputSource::Programmatic => {
191
                // Programmatic: Set position directly
192
                let current = timer_info
193
                    .get_scroll_node_info(input.dom_id, input.node_id)
194
                    .map(|info| info.current_offset)
195
                    .unwrap_or_default();
196

            
197
                let new_pos = LogicalPosition {
198
                    x: current.x + input.delta.x,
199
                    y: current.y + input.delta.y,
200
                };
201
                physics.pending_positions.insert(key, new_pos);
202
            }
203
            ScrollInputSource::TrackpadEnd => {
204
                // Trackpad gesture ended (fingers lifted).
205
                // If the scroll position is past the bounds (rubber-banding overshoot),
206
                // start a spring-back animation to snap back to the boundary.
207
                let pos = physics.pending_positions.remove(&key)
208
                    .or_else(|| timer_info.get_scroll_node_info(input.dom_id, input.node_id)
209
                        .map(|info| info.current_offset));
210

            
211
                if let Some(pos) = pos {
212
                    if let Some(info) = timer_info.get_scroll_node_info(input.dom_id, input.node_id) {
213
                        let overshoot_x = calculate_overshoot(pos.x, 0.0, info.max_scroll_x);
214
                        let overshoot_y = calculate_overshoot(pos.y, 0.0, info.max_scroll_y);
215

            
216
                        if overshoot_x.abs() > 0.01 || overshoot_y.abs() > 0.01 {
217
                            let node_phys = physics.node_velocities
218
                                .entry(key)
219
                                .or_insert_with(NodeScrollPhysics::default);
220
                            // Zero out velocity — the spring-back force in the
221
                            // velocity integration loop (step 2) will pull the
222
                            // position back to the boundary.
223
                            node_phys.velocity = LogicalPosition::zero();
224
                            node_phys.is_rubber_banding = true;
225
                        }
226

            
227
                        // Preserve the overshot position for the spring-back animation.
228
                        // Must use unclamped so the overshot position is NOT clamped to bounds.
229
                        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(input.node_id));
230
                        timer_info.scroll_to_unclamped(input.dom_id, hierarchy_id, pos);
231
                    }
232
                }
233
            }
234
        }
235
    }
236

            
237
    // 2. Integrate velocity physics for wheel-based momentum
238
    let mut velocity_updates: Vec<((DomId, NodeId), LogicalPosition)> = Vec::new();
239

            
240
    for ((dom_id, node_id), node_physics) in physics.node_velocities.iter_mut() {
241
        // Get current scroll info for clamping and per-node CSS config
242
        let info = match timer_info.get_scroll_node_info(*dom_id, *node_id) {
243
            Some(i) => i,
244
            None => continue,
245
        };
246

            
247
        // Determine if this node allows rubber-banding
248
        let rubber_band_x = node_allows_rubber_band_x(&info, overscroll_elasticity);
249
        let rubber_band_y = node_allows_rubber_band_y(&info, overscroll_elasticity);
250

            
251
        // Calculate current overshoot amounts
252
        let overshoot_x = calculate_overshoot(info.current_offset.x, 0.0, info.max_scroll_x);
253
        let overshoot_y = calculate_overshoot(info.current_offset.y, 0.0, info.max_scroll_y);
254

            
255
        let is_overshooting_x = overshoot_x.abs() > 0.01;
256
        let is_overshooting_y = overshoot_y.abs() > 0.01;
257

            
258
        // If we're in a rubber-band overshoot, apply critically-damped spring force.
259
        // F = -k*x - c*v  where c = 2*sqrt(k) for critical damping (no oscillation).
260
        if is_overshooting_x && rubber_band_x {
261
            let spring_k = spring_constant_from_bounce_duration(bounce_back_duration_ms);
262
            let damping = 2.0 * spring_k.sqrt(); // critical damping coefficient
263
            let spring_force_x = -spring_k * overshoot_x - damping * node_physics.velocity.x;
264
            node_physics.velocity.x += spring_force_x * dt;
265
            node_physics.is_rubber_banding = true;
266
        }
267
        if is_overshooting_y && rubber_band_y {
268
            let spring_k = spring_constant_from_bounce_duration(bounce_back_duration_ms);
269
            let damping = 2.0 * spring_k.sqrt(); // critical damping coefficient
270
            let spring_force_y = -spring_k * overshoot_y - damping * node_physics.velocity.y;
271
            node_physics.velocity.y += spring_force_y * dt;
272
            node_physics.is_rubber_banding = true;
273
        }
274

            
275
        // Skip if velocity is negligible and not rubber-banding
276
        if !node_physics.is_rubber_banding
277
            && node_physics.velocity.x.abs() < velocity_threshold
278
            && node_physics.velocity.y.abs() < velocity_threshold
279
        {
280
            node_physics.velocity = LogicalPosition::zero();
281
            continue;
282
        }
283

            
284
        // Apply velocity to position
285
        let displacement = LogicalPosition {
286
            x: node_physics.velocity.x * dt,
287
            y: node_physics.velocity.y * dt,
288
        };
289

            
290
        let raw_new_x = info.current_offset.x + displacement.x;
291
        let raw_new_y = info.current_offset.y + displacement.y;
292

            
293
        // Clamp with or without rubber-banding
294
        let new_x = if rubber_band_x && max_overscroll_distance > 0.0 {
295
            // Allow overshoot with diminishing returns (elasticity)
296
            rubber_band_clamp(raw_new_x, 0.0, info.max_scroll_x, max_overscroll_distance, overscroll_elasticity)
297
        } else {
298
            raw_new_x.clamp(0.0, info.max_scroll_x)
299
        };
300

            
301
        let new_y = if rubber_band_y && max_overscroll_distance > 0.0 {
302
            rubber_band_clamp(raw_new_y, 0.0, info.max_scroll_y, max_overscroll_distance, overscroll_elasticity)
303
        } else {
304
            raw_new_y.clamp(0.0, info.max_scroll_y)
305
        };
306

            
307
        let new_pos = LogicalPosition { x: new_x, y: new_y };
308

            
309
        // Apply exponential friction decay
310
        let decay = (-friction_rate * dt * ASSUMED_FPS).exp();
311
        node_physics.velocity.x *= decay;
312
        node_physics.velocity.y *= decay;
313

            
314
        // At edges without rubber-banding: kill velocity
315
        if !rubber_band_x {
316
            if new_pos.x <= 0.0 || new_pos.x >= info.max_scroll_x {
317
                node_physics.velocity.x = 0.0;
318
            }
319
        }
320
        if !rubber_band_y {
321
            if new_pos.y <= 0.0 || new_pos.y >= info.max_scroll_y {
322
                node_physics.velocity.y = 0.0;
323
            }
324
        }
325

            
326
        // Check if rubber-banding spring-back is almost complete
327
        let new_overshoot_x = calculate_overshoot(new_pos.x, 0.0, info.max_scroll_x);
328
        let new_overshoot_y = calculate_overshoot(new_pos.y, 0.0, info.max_scroll_y);
329
        if new_overshoot_x.abs() < 0.5 && new_overshoot_y.abs() < 0.5 {
330
            node_physics.is_rubber_banding = false;
331
        }
332

            
333
        // Snap to zero if below threshold after decay
334
        if node_physics.velocity.x.abs() < velocity_threshold {
335
            node_physics.velocity.x = 0.0;
336
        }
337
        if node_physics.velocity.y.abs() < velocity_threshold {
338
            node_physics.velocity.y = 0.0;
339
        }
340

            
341
        velocity_updates.push(((*dom_id, *node_id), new_pos));
342
    }
343

            
344
    // Clean up nodes with zero velocity and not rubber-banding
345
    physics
346
        .node_velocities
347
        .retain(|_, v| v.velocity.x.abs() > 0.0 || v.velocity.y.abs() > 0.0 || v.is_rubber_banding);
348

            
349
    // 3. Push ScrollTo changes for all updated positions
350
    let mut any_changes = false;
351

            
352
    // Apply programmatic position changes (hard-clamped to bounds)
353
    let direct_positions: Vec<_> = physics.pending_positions.iter().map(|(k, v)| (*k, *v)).collect();
354
    physics.pending_positions.clear();
355
    for ((dom_id, node_id), position) in direct_positions {
356
        let clamped = match timer_info.get_scroll_node_info(dom_id, node_id) {
357
            Some(info) => LogicalPosition {
358
                x: position.x.clamp(0.0, info.max_scroll_x),
359
                y: position.y.clamp(0.0, info.max_scroll_y),
360
            },
361
            None => position,
362
        };
363
        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
364
        timer_info.scroll_to(dom_id, hierarchy_id, clamped);
365
        any_changes = true;
366
    }
367

            
368
    // Apply trackpad position changes (rubber-band clamped for elastic overshoot)
369
    // Uses scroll_to_unclamped because the physics timer does its own rubber-band clamping.
370
    let trackpad_positions: Vec<_> = physics.pending_trackpad_positions.iter().map(|(k, v)| (*k, *v)).collect();
371
    physics.pending_trackpad_positions.clear();
372
    for ((dom_id, node_id), position) in trackpad_positions {
373
        let clamped = match timer_info.get_scroll_node_info(dom_id, node_id) {
374
            Some(info) => {
375
                let rubber_x = node_allows_rubber_band_x(&info, physics.scroll_physics.overscroll_elasticity);
376
                let rubber_y = node_allows_rubber_band_y(&info, physics.scroll_physics.overscroll_elasticity);
377
                let max_over = physics.scroll_physics.max_overscroll_distance;
378
                let elasticity = physics.scroll_physics.overscroll_elasticity;
379
                LogicalPosition {
380
                    x: if rubber_x {
381
                        rubber_band_clamp(position.x, 0.0, info.max_scroll_x, max_over, elasticity)
382
                    } else {
383
                        position.x.clamp(0.0, info.max_scroll_x)
384
                    },
385
                    y: if rubber_y {
386
                        rubber_band_clamp(position.y, 0.0, info.max_scroll_y, max_over, elasticity)
387
                    } else {
388
                        position.y.clamp(0.0, info.max_scroll_y)
389
                    },
390
                }
391
            },
392
            None => position,
393
        };
394
        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
395
        timer_info.scroll_to_unclamped(dom_id, hierarchy_id, clamped);
396
        any_changes = true;
397
    }
398

            
399
    // Apply velocity-based position changes (uses unclamped: physics already handles rubber-band clamping)
400
    for ((dom_id, node_id), position) in velocity_updates {
401
        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
402
        timer_info.scroll_to_unclamped(dom_id, hierarchy_id, position);
403
        any_changes = true;
404
    }
405

            
406
    // 4. Decide whether to continue or terminate
407
    if physics.is_active() || any_changes {
408
        TimerCallbackReturn {
409
            should_update: Update::DoNothing, // Scroll changes are handled via nodes_scrolled_in_callbacks, not DOM refresh
410
            should_terminate: TerminateTimer::Continue,
411
        }
412
    } else {
413
        // No more velocity, no pending inputs → terminate the timer
414
        TimerCallbackReturn::terminate_unchanged()
415
    }
416
}
417

            
418
// ============================================================================
419
// Rubber-banding Helper Functions
420
// ============================================================================
421

            
422
/// Determines if a node allows rubber-banding on the X axis based on:
423
/// 1. Whether the axis actually has overflow (max_scroll_x > 0)
424
/// 2. Per-node `overflow_scrolling` CSS property (-azul-overflow-scrolling)
425
/// 3. Per-node `overscroll_behavior_x` CSS property (overscroll-behavior-x)
426
/// 4. Global `overscroll_elasticity` from ScrollPhysics
427
fn node_allows_rubber_band_x(info: &ScrollNodeInfo, global_elasticity: f32) -> bool {
428
    // No rubber-banding on an axis that doesn't scroll
429
    if info.max_scroll_x <= 0.0 {
430
        return false;
431
    }
432
    if info.overscroll_behavior_x == OverscrollBehavior::None {
433
        return false;
434
    }
435
    if info.overflow_scrolling == OverflowScrolling::Touch {
436
        return true;
437
    }
438
    global_elasticity > 0.0
439
}
440

            
441
/// Determines if a node allows rubber-banding on the Y axis
442
fn node_allows_rubber_band_y(info: &ScrollNodeInfo, global_elasticity: f32) -> bool {
443
    if info.max_scroll_y <= 0.0 {
444
        return false;
445
    }
446
    if info.overscroll_behavior_y == OverscrollBehavior::None {
447
        return false;
448
    }
449
    if info.overflow_scrolling == OverflowScrolling::Touch {
450
        return true;
451
    }
452
    global_elasticity > 0.0
453
}
454

            
455
/// Calculate how far a position has overshot the valid scroll range.
456
/// Returns positive for overshoot past max, negative for overshoot past min, 0 if in range.
457
fn calculate_overshoot(pos: f32, min: f32, max: f32) -> f32 {
458
    if pos < min {
459
        pos - min // negative
460
    } else if pos > max {
461
        pos - max // positive
462
    } else {
463
        0.0
464
    }
465
}
466

            
467
/// Rubber-band clamping: allows overshoot up to `max_overscroll`, with
468
/// diminishing returns (elasticity) so it feels "springy".
469
///
470
/// The further you overshoot, the harder it becomes to scroll further.
471
fn rubber_band_clamp(
472
    raw_pos: f32,
473
    min: f32,
474
    max: f32,
475
    max_overscroll: f32,
476
    elasticity: f32,
477
) -> f32 {
478
    if raw_pos >= min && raw_pos <= max {
479
        return raw_pos;
480
    }
481

            
482
    let (boundary, overshoot) = if raw_pos < min {
483
        (min, min - raw_pos) // overshoot is positive distance past boundary
484
    } else {
485
        (max, raw_pos - max)
486
    };
487

            
488
    // Diminishing returns: as overshoot increases, actual displacement decreases
489
    // Formula: actual = max_overscroll * (1 - e^(-elasticity * overshoot / max_overscroll))
490
    let clamped_overscroll = if max_overscroll > 0.0 {
491
        max_overscroll * (1.0 - (-elasticity * overshoot / max_overscroll).exp())
492
    } else {
493
        0.0
494
    };
495

            
496
    if raw_pos < min {
497
        boundary - clamped_overscroll
498
    } else {
499
        boundary + clamped_overscroll
500
    }
501
}
502

            
503
/// Convert deceleration_rate (0.0 - 1.0) to a friction constant for exponential decay.
504
/// Higher deceleration_rate = less friction (slower deceleration).
505
fn friction_from_deceleration(deceleration_rate: f32) -> f32 {
506
    // deceleration_rate ~0.95 (fast) → friction ~0.05
507
    // deceleration_rate ~0.998 (iOS-like) → friction ~0.002
508
    (1.0 - deceleration_rate.clamp(0.0, 0.999)).max(0.001)
509
}
510

            
511
/// Calculate spring constant from bounce-back duration.
512
/// Higher k = faster spring back. Approximate: k ≈ (2π / duration)²
513
fn spring_constant_from_bounce_duration(duration_ms: u32) -> f32 {
514
    let duration_s = duration_ms.max(50) as f32 / 1000.0;
515
    let omega = core::f32::consts::TAU / duration_s;
516
    omega * omega
517
}