1use alloc::collections::BTreeMap;
37
38use azul_core::{
39 callbacks::{TimerCallbackReturn, Update},
40 dom::DomId,
41 geom::LogicalPosition,
42 refany::RefAny,
43 styled_dom::NodeHierarchyItemId,
44 task::TerminateTimer,
45};
46
47use crate::{
48 managers::scroll_state::{ScrollInput, ScrollInputQueue, ScrollInputSource, ScrollNodeInfo},
49 timer::TimerCallbackInfo,
50};
51
52use azul_css::props::style::scrollbar::{ScrollPhysics, OverflowScrolling, OverscrollBehavior};
53
54const MAX_SCROLL_EVENTS_PER_TICK: usize = 100;
58
59const ASSUMED_FPS: f32 = 60.0;
62
63#[derive(Debug)]
68pub struct ScrollPhysicsState {
69 pub input_queue: ScrollInputQueue,
71 pub node_velocities: BTreeMap<(DomId, NodeId), NodeScrollPhysics>,
73 pub pending_positions: BTreeMap<(DomId, NodeId), LogicalPosition>,
75 pub pending_trackpad_positions: BTreeMap<(DomId, NodeId), LogicalPosition>,
77 pub scroll_physics: ScrollPhysics,
79}
80
81use azul_core::id::NodeId;
83
84#[derive(Copy, Debug, Clone, Default)]
86pub struct NodeScrollPhysics {
87 pub velocity: LogicalPosition,
89 pub is_rubber_banding: bool,
91}
92
93impl ScrollPhysicsState {
94 #[must_use] pub const 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 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#[allow(clippy::suboptimal_flops)] #[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_lines, clippy::cognitive_complexity)] pub extern "C" fn scroll_physics_timer_callback(
136 mut data: RefAny,
137 mut timer_info: TimerCallbackInfo,
138) -> TimerCallbackReturn {
139 let Some(mut physics) = data.downcast_mut::<ScrollPhysicsState>() else {
141 return TimerCallbackReturn::terminate_unchanged();
142 };
143
144 let sp = &physics.scroll_physics;
146 let dt = sp.timer_interval_ms.max(1) as f32 / 1000.0;
147 let friction_rate = friction_from_deceleration(sp.deceleration_rate);
148 let velocity_threshold = sp.min_velocity_threshold;
149 let wheel_multiplier = sp.wheel_multiplier;
150 let max_velocity = sp.max_velocity;
151 let overscroll_elasticity = sp.overscroll_elasticity;
152 let max_overscroll_distance = sp.max_overscroll_distance;
153 let bounce_back_duration_ms = sp.bounce_back_duration_ms;
154
155 let inputs = physics.input_queue.take_recent(MAX_SCROLL_EVENTS_PER_TICK);
157
158 for input in inputs {
159 let key = (input.dom_id, input.node_id);
160 match input.source {
161 ScrollInputSource::TrackpadContinuous => {
162 let current = timer_info
164 .get_scroll_node_info(input.dom_id, input.node_id)
165 .map(|info| info.current_offset)
166 .unwrap_or_default();
167
168 let new_pos = LogicalPosition {
169 x: current.x + input.delta.x,
170 y: current.y + input.delta.y,
171 };
172 physics.pending_trackpad_positions.insert(key, new_pos);
173
174 physics.node_velocities.remove(&key);
176 }
177 ScrollInputSource::WheelDiscrete => {
178 let node_physics = physics
180 .node_velocities
181 .entry(key)
182 .or_insert_with(NodeScrollPhysics::default);
183
184 node_physics.velocity.x += input.delta.x * wheel_multiplier * ASSUMED_FPS;
186 node_physics.velocity.y += input.delta.y * wheel_multiplier * ASSUMED_FPS;
187
188 node_physics.velocity.x = node_physics.velocity.x.clamp(-max_velocity, max_velocity);
190 node_physics.velocity.y = node_physics.velocity.y.clamp(-max_velocity, max_velocity);
191 }
192 ScrollInputSource::Programmatic => {
193 let current = timer_info
195 .get_scroll_node_info(input.dom_id, input.node_id)
196 .map(|info| info.current_offset)
197 .unwrap_or_default();
198
199 let new_pos = LogicalPosition {
200 x: current.x + input.delta.x,
201 y: current.y + input.delta.y,
202 };
203 physics.pending_positions.insert(key, new_pos);
204 }
205 ScrollInputSource::TrackpadEnd => {
206 let pos = physics.pending_positions.remove(&key)
210 .or_else(|| timer_info.get_scroll_node_info(input.dom_id, input.node_id)
211 .map(|info| info.current_offset));
212
213 if let Some(pos) = pos {
214 if let Some(info) = timer_info.get_scroll_node_info(input.dom_id, input.node_id) {
215 let overshoot_x = calculate_overshoot(pos.x, 0.0, info.max_scroll_x);
216 let overshoot_y = calculate_overshoot(pos.y, 0.0, info.max_scroll_y);
217
218 if overshoot_x.abs() > 0.01 || overshoot_y.abs() > 0.01 {
219 let node_phys = physics.node_velocities
220 .entry(key)
221 .or_insert_with(NodeScrollPhysics::default);
222 node_phys.velocity = LogicalPosition::zero();
226 node_phys.is_rubber_banding = true;
227 }
228
229 let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(input.node_id));
232 timer_info.scroll_to_unclamped(input.dom_id, hierarchy_id, pos);
233 }
234 }
235 }
236 }
237 }
238
239 let mut velocity_updates: Vec<((DomId, NodeId), LogicalPosition)> = Vec::new();
241 let mut momentum_handoffs: Vec<((DomId, NodeId), LogicalPosition)> = Vec::new();
245
246 for ((dom_id, node_id), node_physics) in &mut physics.node_velocities {
247 let Some(info) = timer_info.get_scroll_node_info(*dom_id, *node_id) else {
249 continue;
250 };
251
252 let rubber_band_x = node_allows_rubber_band(info.max_scroll_x, info.overscroll_behavior_x, info.overflow_scrolling, overscroll_elasticity);
254 let rubber_band_y = node_allows_rubber_band(info.max_scroll_y, info.overscroll_behavior_y, info.overflow_scrolling, overscroll_elasticity);
255
256 let overshoot_x = calculate_overshoot(info.current_offset.x, 0.0, info.max_scroll_x);
258 let overshoot_y = calculate_overshoot(info.current_offset.y, 0.0, info.max_scroll_y);
259
260 let is_overshooting_x = overshoot_x.abs() > 0.01;
261 let is_overshooting_y = overshoot_y.abs() > 0.01;
262
263 if is_overshooting_x && rubber_band_x {
266 let spring_k = spring_constant_from_bounce_duration(bounce_back_duration_ms);
267 let damping = 2.0 * spring_k.sqrt(); let spring_force_x = -spring_k * overshoot_x - damping * node_physics.velocity.x;
269 node_physics.velocity.x += spring_force_x * dt;
270 node_physics.is_rubber_banding = true;
271 }
272 if is_overshooting_y && rubber_band_y {
273 let spring_k = spring_constant_from_bounce_duration(bounce_back_duration_ms);
274 let damping = 2.0 * spring_k.sqrt(); let spring_force_y = -spring_k * overshoot_y - damping * node_physics.velocity.y;
276 node_physics.velocity.y += spring_force_y * dt;
277 node_physics.is_rubber_banding = true;
278 }
279
280 if !node_physics.is_rubber_banding
282 && node_physics.velocity.x.abs() < velocity_threshold
283 && node_physics.velocity.y.abs() < velocity_threshold
284 {
285 node_physics.velocity = LogicalPosition::zero();
286 continue;
287 }
288
289 let displacement = LogicalPosition {
291 x: node_physics.velocity.x * dt,
292 y: node_physics.velocity.y * dt,
293 };
294
295 let raw_new_x = info.current_offset.x + displacement.x;
296 let raw_new_y = info.current_offset.y + displacement.y;
297
298 let new_x = if rubber_band_x && max_overscroll_distance > 0.0 {
300 rubber_band_clamp(raw_new_x, 0.0, info.max_scroll_x, max_overscroll_distance, overscroll_elasticity)
302 } else {
303 raw_new_x.clamp(0.0, info.max_scroll_x)
304 };
305
306 let new_y = if rubber_band_y && max_overscroll_distance > 0.0 {
307 rubber_band_clamp(raw_new_y, 0.0, info.max_scroll_y, max_overscroll_distance, overscroll_elasticity)
308 } else {
309 raw_new_y.clamp(0.0, info.max_scroll_y)
310 };
311
312 let new_pos = LogicalPosition { x: new_x, y: new_y };
313
314 let decay = (-friction_rate * dt * ASSUMED_FPS).exp();
316 node_physics.velocity.x *= decay;
317 node_physics.velocity.y *= decay;
318
319 if !rubber_band_x && (new_pos.x <= 0.0 || new_pos.x >= info.max_scroll_x) {
326 let into_edge = (new_pos.x <= 0.0 && node_physics.velocity.x < 0.0)
327 || (new_pos.x >= info.max_scroll_x && node_physics.velocity.x > 0.0);
328 if into_edge
329 && info.overscroll_behavior_x == OverscrollBehavior::Auto
330 && node_physics.velocity.x.abs() > velocity_threshold
331 {
332 momentum_handoffs.push((
333 (*dom_id, *node_id),
334 LogicalPosition { x: node_physics.velocity.x, y: 0.0 },
335 ));
336 }
337 node_physics.velocity.x = 0.0;
338 }
339 if !rubber_band_y && (new_pos.y <= 0.0 || new_pos.y >= info.max_scroll_y) {
340 let into_edge = (new_pos.y <= 0.0 && node_physics.velocity.y < 0.0)
341 || (new_pos.y >= info.max_scroll_y && node_physics.velocity.y > 0.0);
342 if into_edge
343 && info.overscroll_behavior_y == OverscrollBehavior::Auto
344 && node_physics.velocity.y.abs() > velocity_threshold
345 {
346 momentum_handoffs.push((
347 (*dom_id, *node_id),
348 LogicalPosition { x: 0.0, y: node_physics.velocity.y },
349 ));
350 }
351 node_physics.velocity.y = 0.0;
352 }
353
354 let new_overshoot_x = calculate_overshoot(new_pos.x, 0.0, info.max_scroll_x);
356 let new_overshoot_y = calculate_overshoot(new_pos.y, 0.0, info.max_scroll_y);
357 if new_overshoot_x.abs() < 0.5 && new_overshoot_y.abs() < 0.5 {
358 node_physics.is_rubber_banding = false;
359 }
360
361 if node_physics.velocity.x.abs() < velocity_threshold {
363 node_physics.velocity.x = 0.0;
364 }
365 if node_physics.velocity.y.abs() < velocity_threshold {
366 node_physics.velocity.y = 0.0;
367 }
368
369 velocity_updates.push(((*dom_id, *node_id), new_pos));
370 }
371
372 physics
374 .node_velocities
375 .retain(|_, v| v.velocity.x.abs() > 0.0 || v.velocity.y.abs() > 0.0 || v.is_rubber_banding);
376
377 for ((dom_id, node_id), vel) in momentum_handoffs {
383 let mut cur = node_id;
384 for _ in 0..64 {
385 let Some(parent) = timer_info.find_scroll_parent(dom_id, cur) else {
386 break;
387 };
388 let Some(pinfo) = timer_info.get_scroll_node_info(dom_id, parent) else {
389 break;
390 };
391 let can_x = vel.x != 0.0
392 && ((vel.x > 0.0 && pinfo.current_offset.x < pinfo.max_scroll_x - 0.5)
393 || (vel.x < 0.0 && pinfo.current_offset.x > 0.5));
394 let can_y = vel.y != 0.0
395 && ((vel.y > 0.0 && pinfo.current_offset.y < pinfo.max_scroll_y - 0.5)
396 || (vel.y < 0.0 && pinfo.current_offset.y > 0.5));
397 if can_x || can_y {
398 let entry = physics
399 .node_velocities
400 .entry((dom_id, parent))
401 .or_insert_with(NodeScrollPhysics::default);
402 if can_x {
403 entry.velocity.x += vel.x;
404 }
405 if can_y {
406 entry.velocity.y += vel.y;
407 }
408 break;
409 }
410 let stop_x = vel.x != 0.0 && pinfo.overscroll_behavior_x != OverscrollBehavior::Auto;
413 let stop_y = vel.y != 0.0 && pinfo.overscroll_behavior_y != OverscrollBehavior::Auto;
414 if stop_x || stop_y {
415 break;
416 }
417 cur = parent;
418 }
419 }
420
421 let mut any_changes = false;
423
424 let direct_positions: Vec<_> = physics.pending_positions.iter().map(|(k, v)| (*k, *v)).collect();
426 physics.pending_positions.clear();
427 for ((dom_id, node_id), position) in direct_positions {
428 let clamped = timer_info.get_scroll_node_info(dom_id, node_id).map_or(position, |info| LogicalPosition {
429 x: position.x.clamp(0.0, info.max_scroll_x),
430 y: position.y.clamp(0.0, info.max_scroll_y),
431 });
432 let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
433 timer_info.scroll_to(dom_id, hierarchy_id, clamped);
434 any_changes = true;
435 }
436
437 let trackpad_positions: Vec<_> = physics.pending_trackpad_positions.iter().map(|(k, v)| (*k, *v)).collect();
440 physics.pending_trackpad_positions.clear();
441 for ((dom_id, node_id), position) in trackpad_positions {
442 let clamped = timer_info.get_scroll_node_info(dom_id, node_id).map_or(position, |info| {
443 let rubber_x = node_allows_rubber_band(info.max_scroll_x, info.overscroll_behavior_x, info.overflow_scrolling, physics.scroll_physics.overscroll_elasticity);
444 let rubber_y = node_allows_rubber_band(info.max_scroll_y, info.overscroll_behavior_y, info.overflow_scrolling, physics.scroll_physics.overscroll_elasticity);
445 let max_over = physics.scroll_physics.max_overscroll_distance;
446 let elasticity = physics.scroll_physics.overscroll_elasticity;
447 LogicalPosition {
448 x: if rubber_x {
449 rubber_band_clamp(position.x, 0.0, info.max_scroll_x, max_over, elasticity)
450 } else {
451 position.x.clamp(0.0, info.max_scroll_x)
452 },
453 y: if rubber_y {
454 rubber_band_clamp(position.y, 0.0, info.max_scroll_y, max_over, elasticity)
455 } else {
456 position.y.clamp(0.0, info.max_scroll_y)
457 },
458 }
459 });
460 let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
461 timer_info.scroll_to_unclamped(dom_id, hierarchy_id, clamped);
462 any_changes = true;
463 }
464
465 for ((dom_id, node_id), position) in velocity_updates {
467 let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
468 timer_info.scroll_to_unclamped(dom_id, hierarchy_id, position);
469 any_changes = true;
470 }
471
472 if physics.is_active() || any_changes {
474 TimerCallbackReturn {
475 should_update: Update::DoNothing, should_terminate: TerminateTimer::Continue,
477 }
478 } else {
479 TimerCallbackReturn::terminate_unchanged()
481 }
482}
483
484fn node_allows_rubber_band(
494 max_scroll: f32,
495 overscroll_behavior: OverscrollBehavior,
496 overflow_scrolling: OverflowScrolling,
497 global_elasticity: f32,
498) -> bool {
499 if max_scroll <= 0.0 {
500 return false;
501 }
502 if overscroll_behavior == OverscrollBehavior::None {
503 return false;
504 }
505 if overflow_scrolling == OverflowScrolling::Touch {
506 return true;
507 }
508 global_elasticity > 0.0
509}
510
511fn calculate_overshoot(pos: f32, min: f32, max: f32) -> f32 {
514 if pos < min {
515 pos - min } else if pos > max {
517 pos - max } else {
519 0.0
520 }
521}
522
523fn rubber_band_clamp(
528 raw_pos: f32,
529 min: f32,
530 max: f32,
531 max_overscroll: f32,
532 elasticity: f32,
533) -> f32 {
534 if raw_pos >= min && raw_pos <= max {
535 return raw_pos;
536 }
537
538 let (boundary, overshoot) = if raw_pos < min {
539 (min, min - raw_pos) } else {
541 (max, raw_pos - max)
542 };
543
544 let clamped_overscroll = if max_overscroll > 0.0 {
547 max_overscroll * (1.0 - (-elasticity * overshoot / max_overscroll).exp())
548 } else {
549 0.0
550 };
551
552 if raw_pos < min {
553 boundary - clamped_overscroll
554 } else {
555 boundary + clamped_overscroll
556 }
557}
558
559fn friction_from_deceleration(deceleration_rate: f32) -> f32 {
562 (1.0 - deceleration_rate.clamp(0.0, 0.999)).max(0.001)
565}
566
567#[allow(clippy::cast_precision_loss)] #[allow(clippy::similar_names)] fn spring_constant_from_bounce_duration(duration_ms: u32) -> f32 {
572 let duration_s = duration_ms.max(50) as f32 / 1000.0;
573 let omega = core::f32::consts::TAU / duration_s;
574 omega * omega
575}