faststep 0.1.0

UIKit-inspired embedded UI framework built on embedded-graphics
Documentation
use core::ops::Range;

use super::{TouchEvent, list_velocity::VelocityTracker};

const MIN_VELOCITY: f32 = 12.0;
const MAX_VELOCITY: f32 = 9_600.0;
const FLING_GAIN: f32 = 1.75;
const DECELERATION_RATE: f32 = 0.9990;
const SNAP_BACK_RATE: f32 = 16.0;
const MAX_DT_SECONDS: f32 = 1.0 / 30.0;
const RUBBER_BAND_COEFFICIENT: f32 = 0.65;
const MAX_OVERSCROLL_PX: f32 = 72.0;
const SETTLE_DISTANCE_PX: f32 = 0.5;
const DRAG_SLOP_PX: f32 = 8.0;

/// Shared inertial scrolling state used by [`crate::ScrollView`] and [`crate::ListView`].
#[derive(Clone, Copy, Debug, Default)]
pub struct ScrollViewState {
    position_px: f32,
    drag_anchor_y: f32,
    drag_anchor_position_px: f32,
    velocity_px_per_s: f32,
    touch_active: bool,
    scrolling: bool,
    tracker: VelocityTracker,
}

impl ScrollViewState {
    /// Creates an empty scroll state.
    pub const fn new() -> Self {
        Self {
            position_px: 0.0,
            drag_anchor_y: 0.0,
            drag_anchor_position_px: 0.0,
            velocity_px_per_s: 0.0,
            touch_active: false,
            scrolling: false,
            tracker: VelocityTracker::new(),
        }
    }

    /// Returns whether a drag is currently active.
    pub const fn is_dragging(&self) -> bool {
        self.touch_active
    }

    /// Returns whether drag slop has been crossed and scrolling is active.
    pub const fn is_scrolling(&self) -> bool {
        self.scrolling
    }

    /// Returns the rounded integer content offset.
    pub fn content_offset_y(&self) -> i32 {
        -to_pixels(self.position_px)
    }

    /// Returns the raw floating-point content offset.
    pub fn content_offset(&self) -> f32 {
        self.position_px
    }

    /// Starts a drag sequence.
    pub fn begin_drag(&mut self, touch: TouchEvent) {
        self.touch_active = true;
        self.scrolling = false;
        self.velocity_px_per_s = 0.0;
        self.drag_anchor_y = touch.point.y as f32;
        self.drag_anchor_position_px = self.position_px;
        self.tracker.reset(touch);
    }

    /// Applies a drag update.
    pub fn drag(&mut self, touch: TouchEvent, content_height: u32, viewport_height: u32) -> bool {
        if !self.touch_active {
            self.begin_drag(touch);
            return false;
        }

        let before = self.content_offset_y();
        let max = max_position_px(content_height, viewport_height);
        let delta_y = touch.point.y as f32 - self.drag_anchor_y;
        let delta_abs = delta_y.abs();
        if !self.scrolling {
            if delta_abs < DRAG_SLOP_PX {
                return false;
            }
            self.scrolling = true;
        }
        self.tracker.add(touch);

        let effective_delta_y = delta_y.signum() * (delta_abs - DRAG_SLOP_PX).max(0.0);
        let raw_position = self.drag_anchor_position_px - effective_delta_y;
        self.position_px = apply_rubber_band(raw_position, max, viewport_height as f32);
        before != self.content_offset_y()
    }

    /// Ends the drag sequence and computes fling state.
    pub fn end_drag(
        &mut self,
        touch: TouchEvent,
        content_height: u32,
        viewport_height: u32,
    ) -> bool {
        if !self.touch_active {
            return false;
        }

        let changed = self.drag(touch, content_height, viewport_height);
        self.touch_active = false;
        let max = max_position_px(content_height, viewport_height);
        self.velocity_px_per_s = if !self.scrolling || outside_bounds(self.position_px, max) {
            0.0
        } else {
            release_velocity(self.tracker.velocity())
        };
        self.scrolling = false;
        changed
    }

    /// Advances inertial scrolling and overscroll recovery.
    pub fn tick(&mut self, dt_ms: u32, content_height: u32, viewport_height: u32) -> bool {
        if self.touch_active || dt_ms == 0 {
            return false;
        }

        let before = self.content_offset_y();
        let dt = (dt_ms as f32 / 1000.0).min(MAX_DT_SECONDS);
        let max = max_position_px(content_height, viewport_height);

        if outside_bounds(self.position_px, max) {
            self.snap_toward_bounds(dt, max);
        } else if self.velocity_px_per_s.abs() >= MIN_VELOCITY {
            let next = self.position_px + (self.velocity_px_per_s * dt);
            if next < 0.0 {
                self.position_px = 0.0;
                self.velocity_px_per_s = 0.0;
            } else if next > max {
                self.position_px = max;
                self.velocity_px_per_s = 0.0;
            } else {
                self.position_px = next;
                self.velocity_px_per_s = apply_decay(self.velocity_px_per_s, dt_ms);
            }
        }

        if self.velocity_px_per_s.abs() < MIN_VELOCITY {
            self.velocity_px_per_s = 0.0;
        }
        before != self.content_offset_y()
    }

    /// Returns whether the state is still animating after touch release.
    pub fn is_animating(&self, content_height: u32, viewport_height: u32) -> bool {
        let max = max_position_px(content_height, viewport_height);
        outside_bounds(self.position_px, max) || self.velocity_px_per_s.abs() >= MIN_VELOCITY
    }

    /// Returns the approximate visible index range for fixed-height rows.
    pub fn visible_range(
        &self,
        item_height: u32,
        item_count: usize,
        viewport_height: u32,
    ) -> Range<usize> {
        if item_height == 0 || item_count == 0 {
            return 0..0;
        }

        let top = self.position_px.max(0.0) as u32;
        let bottom = top.saturating_add(viewport_height);
        let start = (top / item_height) as usize;
        let end = bottom.div_ceil(item_height) as usize + 1;
        start.min(item_count)..end.min(item_count)
    }

    fn snap_toward_bounds(&mut self, dt: f32, max: f32) {
        let target = self.position_px.clamp(0.0, max);
        self.position_px += (target - self.position_px) * (SNAP_BACK_RATE * dt).min(1.0);
        self.velocity_px_per_s = 0.0;
        if (target - self.position_px).abs() <= SETTLE_DISTANCE_PX {
            self.position_px = target;
        }
    }
}

/// Backwards-compatible alias for list scrolling state.
pub type ScrollListState = ScrollViewState;

fn apply_rubber_band(position: f32, max: f32, viewport: f32) -> f32 {
    if position < 0.0 {
        -rubber_band_distance(-position, viewport)
    } else if position > max {
        max + rubber_band_distance(position - max, viewport)
    } else {
        position
    }
}

fn apply_decay(mut velocity: f32, dt_ms: u32) -> f32 {
    for _ in 0..dt_ms.min(32) {
        velocity *= DECELERATION_RATE;
    }
    velocity
}

fn release_velocity(velocity: f32) -> f32 {
    let boosted = (velocity * FLING_GAIN).clamp(-MAX_VELOCITY, MAX_VELOCITY);
    if boosted.abs() < MIN_VELOCITY {
        0.0
    } else {
        boosted
    }
}

fn rubber_band_distance(distance: f32, viewport: f32) -> f32 {
    let scaled = distance * RUBBER_BAND_COEFFICIENT / viewport.max(1.0);
    ((1.0 - (1.0 / (scaled + 1.0))) * viewport).min(MAX_OVERSCROLL_PX)
}

fn outside_bounds(position: f32, max: f32) -> bool {
    position < 0.0 || position > max
}

fn max_position_px(content_height: u32, viewport_height: u32) -> f32 {
    content_height.saturating_sub(viewport_height) as f32
}

fn to_pixels(position: f32) -> i32 {
    if position >= 0.0 {
        (position + 0.5) as i32
    } else {
        -((-position + 0.5) as i32)
    }
}