gallery/vendor/i-slint-core/items/flickable.rs
Greg Pomerantz 2f4768f354 Fix 120Hz scroll jank end-to-end: render-ahead present + full-cache eviction off the UI thread
Three root causes, all fixed and verified on the Pixel 9 Pro:

1. Present phase (backend + renderer): VSYNC_LOCKED_RENDER defers the
   render to the vsync wake; RENDER_AHEAD presents the previous frame at
   the start of render() (phase = wake + ~1.1 ms) and holds the new
   frame one slot. Crash fix: a held wgpu frame must be PRESENTED, never
   merely dropped, before the next acquire (AlreadyAcquired panic at
   launch otherwise); discard_pending_frame presents too.

2. Full-res prefetch churn (src/lib.rs): the 8-slot full cache cycled
   through a ~20-cell viewport forever (137 decodes/20 s) and each
   eviction free()'d a 50 MB Vec on the UI thread (20-52 ms CPU spikes,
   ~7/s = the residual SLOWEV NonMain jank). Fix: 60 s tombstone
   cooldown on evicted paths (idle prefetch skips, urgent tap bypasses)
   + off-thread reaper for the freed blobs (insert: 21 ms -> 4 us).

3. (earlier) Skia final flush + Mailbox present mode (vendor, M3).

Instrumentation (all behind --features perf): OTHR/INVK/INVK_TAG/FLCK
lines for cross-thread closure timing and push-site tagging.

Warm measured swipe, 8580-file grid: cadence median 8.33 ms (exact
120 Hz), p99 15.5 ms, 5/552 gaps >16.7 ms (0.9%, max 28.3 ms, zero
>33 ms); PRES phase median ~994 us, zero late frames. Docs updated
(docs/BUGS/scroll-jank-120hz.md, full scorecard + line reference).

Also: vendored i-slint-core + wgpu-hal (vkshim interposition patch),
vkshim profiling tooling, backend made a non-optional dep, debuggable
flag for simpleperf (REMOVE before shipping).
2026-10-30 02:49:23 -04:00

1014 lines
43 KiB
Rust

// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
// cSpell: ignore tmax tmin
//! The implementation details behind the Flickable
//! The `Flickable` item
use super::{
Item, ItemConsts, ItemRc, ItemRendererRef, KeyEventResult, PointerEventButton, RenderingResult,
VoidArg,
};
use crate::animations::Instant;
use crate::animations::simulations::constant_deceleration::ConstantDecelerationParameters;
use crate::input::InternalKeyEvent;
use crate::input::{
FocusEvent, FocusEventResult, InputEventFilterResult, InputEventResult, MouseEvent, TouchPhase,
};
use crate::item_rendering::CachedRenderingData;
use crate::layout::{LayoutInfo, Orientation};
use crate::lengths::{
LogicalBorderRadius, LogicalLength, LogicalPoint, LogicalRect, LogicalSize, LogicalVector,
PointLengths, RectLengths,
};
#[cfg(feature = "rtti")]
use crate::rtti::*;
use crate::window::WindowAdapter;
use crate::{Callback, Coord, Property};
use alloc::boxed::Box;
use alloc::rc::Rc;
use const_field_offset::FieldOffsets;
use core::cell::RefCell;
use core::pin::Pin;
use core::time::Duration;
// Jank-hunt: microsecond timing for Flickable pointer handling, to isolate
// whether the 16 ms event spikes are in handle_mouse itself or in the
// `content_y.set()` re-evaluation of dependent bindings (all visible rows' y).
pub static FLICK_MOUSE_MAX_US: core::sync::atomic::AtomicU64 =
core::sync::atomic::AtomicU64::new(0);
pub static FLICK_CONTENTSET_MAX_US: core::sync::atomic::AtomicU64 =
core::sync::atomic::AtomicU64::new(0);
#[allow(non_snake_case)]
fn flk_now_us() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_micros() as u64)
.unwrap_or(0)
}
fn flk_rec_max(slot: &core::sync::atomic::AtomicU64, v: u64) {
loop {
let cur = slot.load(core::sync::atomic::Ordering::Relaxed);
if v <= cur || slot.compare_exchange_weak(cur, v, core::sync::atomic::Ordering::Relaxed, core::sync::atomic::Ordering::Relaxed).is_ok() { break; }
}
}
#[allow(unused)]
use euclid::num::Ceil;
use euclid::num::Zero;
use i_slint_core_macros::*;
#[allow(unused)]
use num_traits::Float;
mod data_ringbuffer;
use data_ringbuffer::VelocityRingBuffer;
/// Deceleration during the animation. It slows down the initial velocity of the simulation
/// so that the simulation stops at some point if it didn't reach the limit
/// The unit is: LogicalPixel/s^2
const DECELERATION: f32 = 2000.;
/// Fixed-duration animation used for wheel scrolling, where we don't have enough phase
/// information to derive a fling velocity.
/// The unit is: millisecond
const WHEEL_SCROLL_DURATION: Duration = Duration::from_millis(180);
/// The maximum duration between a move and a release event to start an animation
/// If the duration is larger than this value, no animation will be executed because
/// it is not desired
const MAX_DURATION: Duration = Duration::from_millis(100);
/// The implementation of the `Flickable` element
#[repr(C)]
#[derive(FieldOffsets, Default, SlintElement)]
#[pin]
pub struct Flickable {
pub content_x: Property<LogicalLength>,
pub content_y: Property<LogicalLength>,
pub content_width: Property<LogicalLength>,
pub content_height: Property<LogicalLength>,
pub interactive: Property<bool>,
pub mouse_drag_pan_enabled: Property<bool>,
pub flicked: Callback<VoidArg>,
data: FlickableDataBox,
/// FIXME: remove this
pub cached_rendering_data: CachedRenderingData,
}
impl Item for Flickable {
fn init(self: Pin<&Self>, self_rc: &ItemRc) {
self.data.in_bound_change_handler.init_delayed(
self_rc.downgrade(),
// Binding that returns if the Flickable is out of bounds:
|self_weak| {
let Some(flick_rc) = self_weak.upgrade() else {
return (false, false);
};
let Some(flick) = flick_rc.downcast::<Flickable>() else {
return (false, false);
};
let flick = flick.as_pin_ref();
let geo = Self::geometry_without_virtual_keyboard(&flick_rc);
let zero = LogicalLength::zero();
let vpx = flick.content_x();
let vpy = flick.content_y();
let x_out_of_bounds =
vpx > zero || vpx < (geo.width_length() - flick.content_width()).min(zero);
let y_out_of_bounds =
vpy > zero || vpy < (geo.height_length() - flick.content_height()).min(zero);
(x_out_of_bounds, y_out_of_bounds)
},
// Change event handler that puts the Flickable in bounds if it's not already
|self_weak, (x_out_of_bounds, y_out_of_bounds)| {
let Some(flick_rc) = self_weak.upgrade() else { return };
let Some(flick) = flick_rc.downcast::<Flickable>() else { return };
let flick = flick.as_pin_ref();
let vpx = flick.content_x();
let vpy = flick.content_y();
let p = ensure_in_bound(flick, LogicalPoint::from_lengths(vpx, vpy), &flick_rc);
let x = (Flickable::FIELD_OFFSETS.content_x()).apply_pin(flick);
if *x_out_of_bounds && !x.has_binding() {
x.set(p.x_length());
}
let y = (Flickable::FIELD_OFFSETS.content_y()).apply_pin(flick);
if *y_out_of_bounds && !y.has_binding() {
y.set(p.y_length());
}
},
);
}
fn deinit(self: Pin<&Self>, _window_adapter: &Rc<dyn WindowAdapter>) {}
fn layout_info(
self: Pin<&Self>,
_orientation: Orientation,
_cross_axis_constraint: Coord,
_window_adapter: &Rc<dyn WindowAdapter>,
_self_rc: &ItemRc,
) -> LayoutInfo {
LayoutInfo { stretch: 1., ..LayoutInfo::default() }
}
fn input_event_filter_before_children(
self: Pin<&Self>,
event: &MouseEvent,
window_adapter: &Rc<dyn WindowAdapter>,
self_rc: &ItemRc,
_: &mut super::MouseCursorInner,
) -> InputEventFilterResult {
if let Some(pos) = event.position() {
let geometry = Self::geometry_without_virtual_keyboard(self_rc);
if (pos.x < 0 as _
|| pos.y < 0 as _
|| pos.x_length() > geometry.width_length()
|| pos.y_length() > geometry.height_length())
&& self.data.inner.borrow().pressed_mouse_state.is_none()
{
return InputEventFilterResult::Intercept;
}
}
if !self.accepts_pan_event(event) {
return InputEventFilterResult::ForwardAndIgnore;
}
self.data.handle_mouse_filter(self, event, window_adapter, self_rc)
}
fn input_event(
self: Pin<&Self>,
event: &MouseEvent,
window_adapter: &Rc<dyn WindowAdapter>,
self_rc: &ItemRc,
_: &mut super::MouseCursorInner,
) -> InputEventResult {
if !self.accepts_pan_event(event) {
return InputEventResult::EventIgnored;
}
if let Some(pos) = event.position() {
let geometry = Self::geometry_without_virtual_keyboard(self_rc);
if matches!(event, MouseEvent::Wheel { .. } | MouseEvent::Pressed { .. })
&& (pos.x < 0 as _
|| pos.y < 0 as _
|| pos.x_length() > geometry.width_length()
|| pos.y_length() > geometry.height_length())
{
return InputEventResult::EventIgnored;
}
}
self.data.handle_mouse(self, event, window_adapter, self_rc)
}
fn capture_key_event(
self: Pin<&Self>,
_: &InternalKeyEvent,
_window_adapter: &Rc<dyn WindowAdapter>,
_self_rc: &ItemRc,
) -> KeyEventResult {
KeyEventResult::EventIgnored
}
fn key_event(
self: Pin<&Self>,
_: &InternalKeyEvent,
_window_adapter: &Rc<dyn WindowAdapter>,
_self_rc: &ItemRc,
) -> KeyEventResult {
KeyEventResult::EventIgnored
}
fn focus_event(
self: Pin<&Self>,
_: &FocusEvent,
_window_adapter: &Rc<dyn WindowAdapter>,
_self_rc: &ItemRc,
) -> FocusEventResult {
FocusEventResult::FocusIgnored
}
fn render(
self: Pin<&Self>,
backend: &mut ItemRendererRef,
_self_rc: &ItemRc,
size: LogicalSize,
) -> RenderingResult {
(*backend).combine_clip(
LogicalRect::new(LogicalPoint::default(), size),
LogicalBorderRadius::zero(),
);
RenderingResult::ContinueRenderingChildren
}
fn bounding_rect(
self: core::pin::Pin<&Self>,
_window_adapter: &Rc<dyn WindowAdapter>,
_self_rc: &ItemRc,
geometry: LogicalRect,
) -> LogicalRect {
geometry
}
fn clips_children(self: core::pin::Pin<&Self>) -> bool {
true
}
}
impl ItemConsts for Flickable {
const cached_rendering_data_offset: const_field_offset::FieldOffset<Self, CachedRenderingData> =
Self::FIELD_OFFSETS.cached_rendering_data().as_unpinned_projection();
}
impl Flickable {
/// Whether the event may pan this Flickable, given that `interactive` and
/// `mouse-drag-pan-enabled` can disable it.
fn accepts_pan_event(self: Pin<&Self>, event: &MouseEvent) -> bool {
match event {
MouseEvent::Wheel { .. } => true,
MouseEvent::Pressed { .. } | MouseEvent::Moved { .. } | MouseEvent::Released { .. } => {
self.interactive() && (event.is_from_touch() || self.mouse_drag_pan_enabled())
}
MouseEvent::Exit
| MouseEvent::DragMove { .. }
| MouseEvent::Drop { .. }
| MouseEvent::PinchGesture { .. }
| MouseEvent::RotationGesture { .. } => self.interactive(),
}
}
fn choose_min_move(
current_view_start: Coord, // cx or cy
view_len: Coord, // w or h
content_len: Coord, // cw or ch
points: impl Iterator<Item = Coord>,
) -> Coord {
// Feasible translations t such that for all p: cx+t <= p <= cx+t+w
// -> t in [max_i(p_i - (cx + w)), min_i(p_i - cx)]
let zero = 0 as Coord;
let mut lower = Coord::MIN;
let mut upper = Coord::MAX;
for p in points {
lower = lower.max(p - (current_view_start + view_len));
upper = upper.min(p - current_view_start);
}
if lower > upper {
// No translation can include all points simultaneously; pick nearest bound direction.
// This happens only with NaNs; guard anyway.
return zero;
}
// Allowed translation interval due to scroll limits
let max_scroll = (content_len - view_len).max(zero);
let tmin = -current_view_start; // cannot scroll before 0
let tmax = max_scroll - current_view_start; // cannot scroll past max
let i_min = lower.max(tmin);
let i_max = upper.min(tmax);
if i_min <= i_max {
if zero < i_min {
i_min
} else if zero > i_max {
i_max
} else {
zero
}
// Intervals disjoint: choose closest allowed translation to feasible interval
// either entirely left or right
} else if tmax < lower {
tmax
} else {
tmin
}
}
/// Scroll the Flickable so that all of the points are visible at the same time (if possible).
/// The points have to be in the parent's coordinate space.
pub(crate) fn reveal_points(self: Pin<&Self>, self_rc: &ItemRc, pts: &[LogicalPoint]) {
if pts.is_empty() {
return;
}
// visible viewport size from base Item
let geo = Self::geometry_without_virtual_keyboard(self_rc);
// content extents and current content origin
let cw = Self::FIELD_OFFSETS.content_width().apply_pin(self).get().0;
let ch = Self::FIELD_OFFSETS.content_height().apply_pin(self).get().0;
let cx = -Self::FIELD_OFFSETS.content_x().apply_pin(self).get().0;
let cy = -Self::FIELD_OFFSETS.content_y().apply_pin(self).get().0;
// choose minimal translation along each axis
let tx = Self::choose_min_move(cx, geo.width(), cw, pts.iter().map(|p| p.x));
let ty = Self::choose_min_move(cy, geo.height(), ch, pts.iter().map(|p| p.y));
let new_cx = cx + tx;
let new_cy = cy + ty;
Self::FIELD_OFFSETS.content_x().apply_pin(self).set(euclid::Length::new(-new_cx));
Self::FIELD_OFFSETS.content_y().apply_pin(self).set(euclid::Length::new(-new_cy));
}
fn geometry_without_virtual_keyboard(self_rc: &ItemRc) -> LogicalRect {
let mut geometry = self_rc.geometry();
// subtract keyboard rect if needed
if let Some(keyboard_rect) = self_rc.window_adapter().and_then(|window_adapter| {
window_adapter.window().virtual_keyboard(crate::InternalToken)
}) {
let keyboard_pos = keyboard_rect.0;
let self_in_window_coordinates = self_rc.map_to_native_window(geometry.origin);
if (keyboard_pos.y as Coord) < (self_in_window_coordinates.y + geometry.height()) {
// Keyboard is below the flickable and overlapping
geometry.size.height = keyboard_pos.y as Coord - self_in_window_coordinates.y;
}
}
geometry
}
}
#[repr(C)]
/// Wraps the internal data structure for the Flickable
pub struct FlickableDataBox(core::ptr::NonNull<FlickableData>);
impl Default for FlickableDataBox {
fn default() -> Self {
FlickableDataBox(Box::leak(Box::<FlickableData>::default()).into())
}
}
impl Drop for FlickableDataBox {
fn drop(&mut self) {
// Safety: the self.0 was constructed from a Box::leak in FlickableDataBox::default
drop(unsafe { Box::from_raw(self.0.as_ptr()) });
}
}
impl core::ops::Deref for FlickableDataBox {
type Target = FlickableData;
fn deref(&self) -> &Self::Target {
// Safety: initialized in FlickableDataBox::default
unsafe { self.0.as_ref() }
}
}
/// The distance required before it starts flicking if there is another item intercepting the mouse.
pub(super) const DISTANCE_THRESHOLD: LogicalLength = LogicalLength::new(8 as _);
/// Time required before we stop caring about child event if the mouse hasn't been moved
pub(super) const DURATION_THRESHOLD: Duration = Duration::from_millis(500);
/// The delay to which press are forwarded to the inner item
pub(super) const FORWARD_DELAY: Duration = Duration::from_millis(100);
/// Duration to filter scroll events from children after receiving a scroll event
/// Note: This needs to be rather long, as that makes it more intuitive when scrolling with the
/// mouse in concrete steps.
/// The user can always override this by moving the mouse
/// The value was tuned by hand, could be adjusted with further user feedback
pub(super) const SCROLL_FILTER_DURATION: Duration = Duration::from_millis(800);
/// Short duration for scroll event filtering, used when the end of the flickable is reached.
pub(super) const SHORT_SCROLL_FILTER_DURATION: Duration =
Duration::from_millis(SCROLL_FILTER_DURATION.as_millis() as u64 / 2);
/// How far the user has to move the mouse to stop filtering scroll event from children after receiving a scroll event
pub(super) const SCROLL_FILTER_DISTANCE_SQUARED: LogicalLength = LogicalLength::new(4 as _);
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
enum CaptureEvents {
MouseOrTouchScreen,
MouseWheel,
}
#[derive(Default)]
struct FlickableDataInner {
/// The time and position in which the press was made
///
/// The position is in the coordinate system of the flickable, not of the content element.
pressed_mouse_state: Option<(Instant, LogicalPoint)>,
/// The last mouse position received, used to calculate the delta when flicking with the mouse.
///
/// This position is in the coordinate system of the flickable, not of the content element.
last_mouse_position: LogicalPoint,
/// Set to true if the flickable is flicking and capturing all mouse event, not forwarding back to the children
capture_events: Option<CaptureEvents>,
/// Heuristics for filtering scroll events from children after we have scrolled ourselves.
/// We want to filter those to prevent the case where the user scrolls with the mouse wheel,
/// but the mouse now moves over a child item, and that item captures the scroll event.
/// We use two heuristics: First, a timeout after we received a scroll event, and second, if the mouse moves we
/// stop filtering scroll event until the next scroll event.
last_scroll_event: Option<(Instant, LogicalPoint)>,
/// When a touch pad gesture (wheel events with phases) that went through this Flickable started.
/// The first Flickable that can scroll it then takes it over.
/// A timestamp, because the Ended phase doesn't reach the children of the Flickable that took it over.
wheel_gesture_start: Option<Instant>,
/// Ringbuffer to store the last move deltas. From those data the velocity can be
/// calculated required for the animation after the release event
velocity_rb: VelocityRingBuffer<5>,
/// The animation details of the currently running animation for smooth mouse wheel scrolling.
/// This allows us to add the missing delta of the animation to the next scroll event if the user scrolls again
/// before the animation is finished.
running_animation: Option<(Instant, [Option<ConstantDecelerationParameters>; 2])>,
}
impl FlickableDataInner {
fn should_capture_scroll(&self, timeout: Duration, position: LogicalPoint) -> bool {
self.last_scroll_event.is_some_and(|(last_time, last_position)| {
// Note: Squared length for MCU support, which use i32 coords.
crate::animations::current_tick() - last_time < timeout
&& LogicalLength::new((last_position - position).square_length().abs())
< SCROLL_FILTER_DISTANCE_SQUARED
})
}
/// Whether the delta is a scroll in a orthogonal direction than what is allowed by the Flickable
#[allow(clippy::nonminimal_bool)] // more readable this way
fn is_allowed_scroll_direction(
flick: Pin<&Flickable>,
delta: LogicalVector,
flick_rc: &ItemRc,
) -> bool {
let geo = Flickable::geometry_without_virtual_keyboard(flick_rc);
(delta.y != 0 as Coord && flick.content_height() > geo.height_length())
|| (delta.x != 0 as Coord && flick.content_width() > geo.width_length())
}
fn process_wheel_event(
&mut self,
flick: Pin<&Flickable>,
mut delta: LogicalVector,
position: LogicalPoint,
phase: TouchPhase,
flick_rc: &ItemRc,
) -> InputEventResult {
if delta != LogicalVector::default()
&& !Self::is_allowed_scroll_direction(flick, delta, flick_rc)
{
// Release the capture immediately, this event is not meant for this Flickable.
self.capture_events = None;
self.last_scroll_event = None;
self.running_animation = None;
self.velocity_rb = VelocityRingBuffer::default();
return InputEventResult::EventIgnored;
}
if phase == TouchPhase::Moved
&& self.capture_events.is_none()
&& self.wheel_gesture_start.take().is_some_and(|start| {
crate::animations::current_tick() - start < SCROLL_FILTER_DURATION
})
{
self.velocity_rb = VelocityRingBuffer::default();
self.capture_events = Some(CaptureEvents::MouseWheel);
}
let content_x = (Flickable::FIELD_OFFSETS.content_x()).apply_pin(flick);
let content_y = (Flickable::FIELD_OFFSETS.content_y()).apply_pin(flick);
let current_pos = LogicalPoint::from_lengths(content_x.get(), content_y.get());
if self.capture_events.is_none()
&& matches!(phase, TouchPhase::Moved)
&& let Some((start_time, [x_simulation, y_simulation])) = &self.running_animation
{
// If the animation is not finished, we add the remaining animations delta.
let animation_duration = crate::animations::current_tick().duration_since(*start_time);
if let Some(x_simulation) = x_simulation {
delta.x += x_simulation.remaining_distance(animation_duration);
}
if let Some(y_simulation) = y_simulation {
delta.y += y_simulation.remaining_distance(animation_duration);
}
}
let new_pos = ensure_in_bound(flick, current_pos + delta, flick_rc);
delta = new_pos - current_pos;
if phase != TouchPhase::Ended {
content_x.remove_binding();
content_y.remove_binding();
self.running_animation = None;
}
match phase {
TouchPhase::Cancelled => {
content_x.set(new_pos.x_length());
content_y.set(new_pos.y_length());
self.last_scroll_event = Some((crate::animations::current_tick(), position));
}
TouchPhase::Started => {
self.last_scroll_event = Some((crate::animations::current_tick(), position));
}
TouchPhase::Moved => {
if self.capture_events.is_some_and(|capture| capture == CaptureEvents::MouseWheel) {
// Touchpad case with different phases
self.velocity_rb.push(crate::animations::current_tick(), new_pos - current_pos);
content_x.set(new_pos.x_length());
content_y.set(new_pos.y_length());
} else {
// Mousewheel case with no phase
// Add a short animation that covers the delta for smooth scrolling
//
// Note that this animation must support the content_x/_y and width/height
// changing, as e.g. the ListView might resize the content if it gets a new size
// estimate.
//
// At the time of writing, in practice this means we must use a physics animation.
let [limit_x, limit_y] = Self::flick_limits(flick_rc, delta);
let x_simulation = (delta.x != Coord::default()).then(|| {
let simulation = ConstantDecelerationParameters::new_with_distance(
delta.x as f32,
WHEEL_SCROLL_DURATION.as_secs_f32(),
);
content_x.set_physic_animation_value(limit_x, simulation.clone());
simulation
});
let y_simulation = (delta.y != Coord::default()).then(|| {
let simulation = ConstantDecelerationParameters::new_with_distance(
delta.y as f32,
WHEEL_SCROLL_DURATION.as_secs_f32(),
);
content_y.set_physic_animation_value(limit_y, simulation.clone());
simulation
});
if delta.x != 0 as Coord || delta.y != 0 as Coord {
(Flickable::FIELD_OFFSETS.flicked()).apply_pin(flick).call(&());
}
self.running_animation =
Some((crate::animations::current_tick(), [x_simulation, y_simulation]));
}
self.last_scroll_event = Some((crate::animations::current_tick(), position));
}
TouchPhase::Ended => {
if self.capture_events.is_some_and(|capture| capture == CaptureEvents::MouseWheel) {
self.animate(flick, flick_rc);
}
self.capture_events = None;
return if self.should_capture_scroll(SHORT_SCROLL_FILTER_DURATION, position) {
InputEventResult::EventAccepted
} else {
InputEventResult::EventIgnored
};
}
}
let flicked = current_pos.x_length() != new_pos.x_length()
|| current_pos.y_length() != new_pos.y_length();
if flicked {
(Flickable::FIELD_OFFSETS.flicked()).apply_pin(flick).call(&());
InputEventResult::EventAccepted
} else if self.should_capture_scroll(SHORT_SCROLL_FILTER_DURATION, position) {
// After reaching the end, keep accepting the input event for a while longer, then time
// out (by not updating the last_scroll_event)
InputEventResult::EventAccepted
} else {
self.last_scroll_event = None;
InputEventResult::EventIgnored
}
}
fn flick_limits(
flick_rc: &ItemRc,
flick_velocity: LogicalVector,
) -> [Pin<Box<Property<f32>>>; 2] {
let flick_weak = flick_rc.downgrade();
let calculate_limits = move || {
flick_weak
.upgrade()
.and_then(|flick_rc| {
flick_rc.downcast::<Flickable>().map(move |flick| (flick_rc, flick))
})
.map(|(flick_rc, flick)| {
let flick = flick.as_pin_ref();
ensure_in_bound(
flick,
LogicalPoint::from_lengths(-flick.content_width(), -flick.content_height()),
&flick_rc,
)
})
};
let limit_x = if flick_velocity.x < 0 as Coord {
let property = Box::pin(Property::new(0.0));
property.set_binding({
let calculate_limits = calculate_limits.clone();
move || calculate_limits().map(|limit| limit.x_length().get() as f32).unwrap_or(0.0)
});
property
} else {
Box::pin(Property::new(0.0))
};
let limit_y = if flick_velocity.y < 0 as Coord {
let property = Box::pin(Property::new(0.0));
property.set_binding(move || {
calculate_limits().map(|limit| limit.y_length().get() as f32).unwrap_or(0.0)
});
property
} else {
Box::pin(Property::new(0.0))
};
[limit_x, limit_y]
}
fn animate(&self, flick: Pin<&Flickable>, flick_rc: &ItemRc) {
if let Some(last_time) = self.velocity_rb.last_time() {
let mean_velocity = self.velocity_rb.mean_velocity();
if self.capture_events.is_some()
&& mean_velocity.square_length() > 0 as Coord
&& crate::animations::current_tick().duration_since(last_time) < MAX_DURATION
{
let content_x = (Flickable::FIELD_OFFSETS.content_x()).apply_pin(flick);
let content_y = (Flickable::FIELD_OFFSETS.content_y()).apply_pin(flick);
let [limit_x, limit_y] = Self::flick_limits(flick_rc, mean_velocity);
{
let simulation =
ConstantDecelerationParameters::new(mean_velocity.x as f32, DECELERATION);
content_x.set_physic_animation_value(limit_x, simulation);
}
{
let animation_y =
ConstantDecelerationParameters::new(mean_velocity.y as f32, DECELERATION);
content_y.set_physic_animation_value(limit_y, animation_y);
}
if mean_velocity.x != 0 as Coord || mean_velocity.y != 0 as Coord {
(Flickable::FIELD_OFFSETS.flicked()).apply_pin(flick).call(&());
}
}
}
}
}
#[derive(Default)]
pub struct FlickableData {
inner: RefCell<FlickableDataInner>,
/// Tracker that tracks the property to make sure that the flickable is in bounds
in_bound_change_handler: crate::properties::ChangeTracker,
}
impl FlickableData {
fn scroll_delta(
window_adapter: &Rc<dyn WindowAdapter>,
delta_x: Coord,
delta_y: Coord,
) -> LogicalVector {
if window_adapter.window().0.context().0.modifiers.get().shift()
&& !cfg!(target_os = "macos")
{
// Shift invert coordinate for the purpose of scrolling.
// But not on macOs because there the OS already take care of the change
LogicalVector::new(delta_y, delta_x)
} else {
LogicalVector::new(delta_x, delta_y)
}
}
fn handle_mouse_filter(
&self,
flick: Pin<&Flickable>,
event: &MouseEvent,
window_adapter: &Rc<dyn WindowAdapter>,
flick_rc: &ItemRc,
) -> InputEventFilterResult {
let mut inner = self.inner.borrow_mut();
match event {
MouseEvent::Pressed { position, button: PointerEventButton::Left, .. } => {
if inner.capture_events.is_none() && !Self::can_pan(flick, flick_rc) {
// There is nothing to pan in either direction: don't hold up the press waiting to see if it turns into a drag,
// just let it fall through to whatever is underneath,
// the same way wheel events already are when the Flickable can't scroll in their direction.
return InputEventFilterResult::ForwardAndIgnore;
}
inner.velocity_rb = VelocityRingBuffer::default();
inner.pressed_mouse_state = Some((crate::animations::current_tick(), *position));
inner.last_mouse_position = *position;
let content_x = (Flickable::FIELD_OFFSETS.content_x()).apply_pin(flick);
content_x.remove_binding(); // Stop animation by removing the binding
let content_y = (Flickable::FIELD_OFFSETS.content_y()).apply_pin(flick);
content_y.remove_binding(); // Stop animation by removing the binding
if inner.capture_events.is_some() {
InputEventFilterResult::Intercept
} else {
InputEventFilterResult::DelayForwarding(FORWARD_DELAY.as_millis() as _)
}
}
MouseEvent::Exit | MouseEvent::Released { button: PointerEventButton::Left, .. } => {
inner.pressed_mouse_state = None;
if inner.capture_events.is_some() {
InputEventFilterResult::Intercept
} else {
InputEventFilterResult::ForwardEvent
}
}
MouseEvent::Moved { position, .. } => {
let do_intercept = inner.capture_events.is_some()
|| inner.pressed_mouse_state.is_some_and(
|(pressed_time, pressed_mouse_position)| {
let mouse_delta = *position - pressed_mouse_position;
crate::animations::current_tick() - pressed_time <= DURATION_THRESHOLD
&& self.should_capture_mouse_direction(mouse_delta, flick, flick_rc)
},
);
if do_intercept {
InputEventFilterResult::Intercept
} else if inner.pressed_mouse_state.is_some() {
InputEventFilterResult::ForwardAndInterceptGrab
} else {
InputEventFilterResult::ForwardEvent
}
}
MouseEvent::Wheel { position, delta_x, delta_y, phase } => {
match phase {
TouchPhase::Started => {
inner.wheel_gesture_start = Some(crate::animations::current_tick())
}
TouchPhase::Ended => inner.wheel_gesture_start = None,
TouchPhase::Moved | TouchPhase::Cancelled => {}
}
if inner.capture_events.is_some() {
InputEventFilterResult::Intercept
} else if *phase == TouchPhase::Ended {
InputEventFilterResult::ForwardEvent
} else if inner.should_capture_scroll(SCROLL_FILTER_DURATION, *position)
&& FlickableDataInner::is_allowed_scroll_direction(
flick,
Self::scroll_delta(window_adapter, *delta_x, *delta_y),
flick_rc,
)
{
// If we recently handled a wheel event, intercept it to prevent children from grabbing
// the scroll event
InputEventFilterResult::Intercept
} else {
inner.last_scroll_event = None;
InputEventFilterResult::ForwardEvent
}
}
// Not the left button
MouseEvent::Pressed { .. } | MouseEvent::Released { .. } => {
InputEventFilterResult::ForwardAndIgnore
}
MouseEvent::PinchGesture { .. } | MouseEvent::RotationGesture { .. } => {
InputEventFilterResult::ForwardEvent
}
MouseEvent::DragMove { .. } | MouseEvent::Drop { .. } => {
InputEventFilterResult::ForwardAndIgnore
}
}
}
fn should_capture_mouse_direction(
&self,
mouse_delta: LogicalVector,
flick: Pin<&Flickable>,
flick_rc: &ItemRc,
) -> bool {
let flickable_geometry = Flickable::geometry_without_virtual_keyboard(flick_rc);
let flickable_width = flickable_geometry.width_length();
let flickable_height = flickable_geometry.height_length();
let content_width = flick.content_width();
let content_height = flick.content_height();
let zero = LogicalLength::zero();
// We should capture the mouse movement, if the flickable can move in this
// axis, and the mouse has moved more than the threshold in this axis.
((content_width > flickable_width || flick.content_x() != zero)
&& abs(mouse_delta.x_length()) > DISTANCE_THRESHOLD)
|| ((content_height > flickable_height || flick.content_y() != zero)
&& abs(mouse_delta.y_length()) > DISTANCE_THRESHOLD)
}
/// Whether the flickable has any content to pan in either direction, regardless of mouse movement.
/// Used to decide whether a press that no descendant claimed is worth grabbing,
/// mirroring the direction check wheel events already get in `handle_mouse_filter`.
fn can_pan(flick: Pin<&Flickable>, flick_rc: &ItemRc) -> bool {
let flickable_geometry = Flickable::geometry_without_virtual_keyboard(flick_rc);
let flickable_width = flickable_geometry.width_length();
let flickable_height = flickable_geometry.height_length();
let content_width = flick.content_width();
let content_height = flick.content_height();
let zero = LogicalLength::zero();
(content_width > flickable_width || flick.content_x() != zero)
|| (content_height > flickable_height || flick.content_y() != zero)
}
fn handle_mouse(
&self,
flick: Pin<&Flickable>,
event: &MouseEvent,
window_adapter: &Rc<dyn WindowAdapter>,
flick_rc: &ItemRc,
) -> InputEventResult {
let mut inner = self.inner.borrow_mut();
match event {
MouseEvent::Pressed { .. } => {
inner.capture_events = Some(CaptureEvents::MouseOrTouchScreen);
InputEventResult::GrabMouse
}
MouseEvent::Exit | MouseEvent::Released { .. } => {
if inner.capture_events.is_some_and(|f| f == CaptureEvents::MouseOrTouchScreen) {
let was_capturing = true;
inner.animate(flick, flick_rc);
inner.capture_events = None;
inner.pressed_mouse_state = None;
if was_capturing {
InputEventResult::EventAccepted
} else {
InputEventResult::EventIgnored
}
} else if inner.capture_events.is_none() {
inner.pressed_mouse_state = None;
InputEventResult::EventIgnored
} else {
InputEventResult::EventIgnored
}
}
MouseEvent::Moved { position, .. } => {
let __flk_t0 = flk_now_us();
// Important constraint: The content_y might not be stable, and might jump around
// wildly!
// This is especially the case if a ListView is involved, which will continuously
// update its own content_y to keep the current item visible, which can cause the
// content_y to jump.
//
// So to correctly calculate the mouse delta, we need to use the position of
// the mouse in the flickables coordinate system and never the content coordinate
// system.
if let Some((_pressed_time, _pressed_mouse_position)) = inner.pressed_mouse_state {
let mouse_delta = *position - inner.last_mouse_position;
inner.velocity_rb.push(crate::animations::current_tick(), mouse_delta);
let is_capturing = inner
.capture_events
.is_some_and(|f| f == CaptureEvents::MouseOrTouchScreen);
if is_capturing
|| self.should_capture_mouse_direction(mouse_delta, flick, flick_rc)
{
// The drag event is meant to move the content, set it to the new position
// and start capturing mouse events.
let content_x = (Flickable::FIELD_OFFSETS.content_x()).apply_pin(flick);
let content_y = (Flickable::FIELD_OFFSETS.content_y()).apply_pin(flick);
let current_content_position =
LogicalPoint::from_lengths(content_x.get(), content_y.get());
// We calculate the new content position by adding the mouse delta in the flickable
// coordinate system to the current content position.
// Do not rely on the existing content position to be stable, as e.g. the
// ListView will continuously update it.
// So we cannot calculate the delta in content coordinates.
let new_content_position = current_content_position + mouse_delta;
let new_content_position =
ensure_in_bound(flick, new_content_position, flick_rc);
let __flk_t_cs = flk_now_us();
content_x.set(new_content_position.x_length());
content_y.set(new_content_position.y_length());
let __flk_cs_dt = flk_now_us().saturating_sub(__flk_t_cs);
flk_rec_max(&FLICK_CONTENTSET_MAX_US, __flk_cs_dt);
flk_rec_max(&FLICK_MOUSE_MAX_US, flk_now_us().saturating_sub(__flk_t0));
if current_content_position != new_content_position {
(Flickable::FIELD_OFFSETS.flicked()).apply_pin(flick).call(&());
}
// Only update the mouse position if we are actually applying the delta.
// When the drag starts, there is a short dead zone that is determined by the
// DISTANCE_THRESHOLD. We want to apply that threshold to the
// delta once we've overcome it, so we need to update the position that we
// calculate the delta from only after we've cleared the dead zone and are
// actually moving.
//
// Note: As an alternative to updating the last_mouse_position to the new mouse position,
// we could also update it by the amount that the content actually moved.
// This would cause the mouse to stick to a given position in the content
// instead of starting to drift if the drag goes into the content limits.
// Then this code would need to be:
//
// inner.last_mouse_position += new_content_position - current_content_position;
//
// But at least for a touchscreen, the current behavior is more intuitive.
inner.last_mouse_position = *position;
inner.capture_events = Some(CaptureEvents::MouseOrTouchScreen);
InputEventResult::GrabMouse
} else if abs(mouse_delta.x_length()) > DISTANCE_THRESHOLD
|| abs(mouse_delta.y_length()) > DISTANCE_THRESHOLD
{
// drag in a unsupported direction gives up the grab
InputEventResult::EventIgnored
} else {
// the mouse was moved, but not enough to start the drag, we still want to accept further events
// so that we may pass the threshold at some point
InputEventResult::EventAccepted
}
} else {
InputEventResult::EventIgnored
}
}
MouseEvent::Wheel { delta_x, delta_y, position, phase } => {
let delta = Self::scroll_delta(window_adapter, *delta_x, *delta_y);
inner.process_wheel_event(flick, delta, *position, *phase, flick_rc)
}
MouseEvent::PinchGesture { .. } | MouseEvent::RotationGesture { .. } => {
InputEventResult::EventIgnored
}
MouseEvent::DragMove { .. } | MouseEvent::Drop { .. } => InputEventResult::EventIgnored,
}
}
}
fn abs(l: LogicalLength) -> LogicalLength {
LogicalLength::new(l.get().abs())
}
/// Make sure that the point is within the bounds
fn ensure_in_bound(flick: Pin<&Flickable>, p: LogicalPoint, flick_rc: &ItemRc) -> LogicalPoint {
let geo = Flickable::geometry_without_virtual_keyboard(flick_rc);
let w = geo.width_length();
let h = geo.height_length();
let cw = (Flickable::FIELD_OFFSETS.content_width()).apply_pin(flick).get();
let ch = (Flickable::FIELD_OFFSETS.content_height()).apply_pin(flick).get();
let min = LogicalPoint::from_lengths(w - cw, h - ch);
let max = LogicalPoint::default();
p.max(min).min(max)
}
/// # Safety
/// This must be called using a non-null pointer pointing to a chunk of memory big enough to
/// hold a FlickableDataBox
#[cfg(feature = "ffi")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn slint_flickable_data_init(data: *mut FlickableDataBox) {
unsafe { core::ptr::write(data, FlickableDataBox::default()) };
}
/// # Safety
/// This must be called using a non-null pointer pointing to an initialized FlickableDataBox
#[cfg(feature = "ffi")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn slint_flickable_data_free(data: *mut FlickableDataBox) {
unsafe {
core::ptr::drop_in_place(data);
}
}