use std::time::Duration;
use glam::Vec2;
use martensite_core::Rect;
use winit::dpi::{LogicalPosition, LogicalSize};
pub const DEFAULT_DAMPING_FACTOR: f32 = 5.0;
pub const SCROLL_VELOCITY_THRESHOLD: f32 = 1.0;
pub const DEFAULT_EMA_ALPHA: f32 = 0.5;
pub const DEFAULT_DECELERATION: f32 = 2000.0;
#[inline]
fn is_finite_scalar(v: f32) -> bool {
v.is_finite()
}
#[inline]
fn is_finite_vec(v: Vec2) -> bool {
is_finite_scalar(v.x) && is_finite_scalar(v.y)
}
#[inline]
fn duration_to_secs(delta_time: Duration) -> f32 {
let secs = delta_time.as_secs_f32();
if secs.is_finite() {
secs
} else {
f32::INFINITY
}
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct Viewport {
pub rect: Rect,
}
impl Viewport {
#[inline]
pub fn new(rect: Rect) -> Self {
Self { rect }
}
#[inline]
pub fn clamp_point(&self, point: Vec2) -> Vec2 {
let min_x = self.rect.min_x();
let max_x = self.rect.max_x();
let min_y = self.rect.min_y();
let max_y = self.rect.max_y();
let cx = if max_x <= min_x {
min_x
} else {
point.x.clamp(min_x, max_x)
};
let cy = if max_y <= min_y {
min_y
} else {
point.y.clamp(min_y, max_y)
};
Vec2::new(cx, cy)
}
}
impl From<Rect> for Viewport {
#[inline]
fn from(rect: Rect) -> Self {
Self::new(rect)
}
}
#[derive(Clone, Debug)]
pub struct ImePositioner {
caret_position: Vec2,
scroll_velocity: Vec2,
damping_factor: f32,
viewport: Rect,
}
impl ImePositioner {
#[inline]
pub fn new(caret_position: Vec2, viewport: Rect) -> Self {
Self {
caret_position: if is_finite_vec(caret_position) {
caret_position
} else {
Vec2::ZERO
},
scroll_velocity: Vec2::ZERO,
damping_factor: DEFAULT_DAMPING_FACTOR,
viewport,
}
}
#[inline]
pub fn caret_position(&self) -> Vec2 {
self.caret_position
}
#[inline]
pub fn scroll_velocity(&self) -> Vec2 {
self.scroll_velocity
}
#[inline]
pub fn damping_factor(&self) -> f32 {
self.damping_factor
}
#[inline]
pub fn viewport(&self) -> Rect {
self.viewport
}
#[inline]
pub fn set_caret_position(&mut self, pos: Vec2) {
if is_finite_vec(pos) {
self.caret_position = pos;
}
}
#[inline]
pub fn set_scroll_velocity(&mut self, velocity: Vec2) {
if is_finite_vec(velocity) {
self.scroll_velocity = velocity;
}
}
#[inline]
pub fn set_damping_factor(&mut self, lambda: f32) {
if is_finite_scalar(lambda) && lambda > 0.0 {
self.damping_factor = lambda;
}
}
#[inline]
pub fn set_viewport(&mut self, viewport: Rect) {
self.viewport = viewport;
}
#[inline]
pub fn compute_position(&self, delta_time: Duration) -> Vec2 {
let dt = delta_time.as_secs_f32();
if !dt.is_finite() || dt < 0.0 {
return self.caret_position;
}
if dt == 0.0 {
return self.caret_position;
}
let damping = (-self.damping_factor * dt).exp();
let displacement_scalar = dt * damping;
let offset = self.scroll_velocity * displacement_scalar;
let result = self.caret_position + offset;
if !is_finite_vec(result) {
return self.caret_position;
}
result
}
#[inline]
pub fn compute_bounds(
&self,
delta_time: Duration,
line_height: f32,
) -> (LogicalPosition<f64>, LogicalSize<f64>) {
let dt = delta_time.as_secs_f32();
if !dt.is_finite() || dt < 0.0 {
return (
LogicalPosition::new(
f64::from(self.caret_position.x),
f64::from(self.caret_position.y),
),
LogicalSize::new(0.0, 0.0),
);
}
if !line_height.is_finite() || line_height < 0.0 {
return (
LogicalPosition::new(
f64::from(self.caret_position.x),
f64::from(self.caret_position.y),
),
LogicalSize::new(0.0, 0.0),
);
}
let projected = self.compute_position(delta_time);
let clamped = Viewport::new(self.viewport).clamp_point(projected);
if !is_finite_vec(clamped) {
return (
LogicalPosition::new(
f64::from(self.caret_position.x),
f64::from(self.caret_position.y),
),
LogicalSize::new(0.0, 0.0),
);
}
(
LogicalPosition::new(f64::from(clamped.x), f64::from(clamped.y)),
LogicalSize::new(2.0, f64::from(line_height)),
)
}
}
#[derive(Clone, Debug)]
pub struct ScrollKinematics {
pub velocity: Vec2,
pub ema_alpha: f32,
pub deceleration: f32,
}
impl Default for ScrollKinematics {
#[inline]
fn default() -> Self {
Self {
velocity: Vec2::ZERO,
ema_alpha: DEFAULT_EMA_ALPHA,
deceleration: DEFAULT_DECELERATION,
}
}
}
impl ScrollKinematics {
#[inline]
pub fn new() -> Self {
Self::default()
}
#[inline]
pub fn update(&mut self, scroll_delta: Vec2, delta_time: Duration) {
if !is_finite_vec(scroll_delta) {
return;
}
let dt = duration_to_secs(delta_time);
if dt <= 0.0 || !dt.is_finite() {
return;
}
let instantaneous = scroll_delta / dt;
if !is_finite_vec(instantaneous) {
return;
}
let alpha = self.ema_alpha.clamp(0.0, 1.0);
self.velocity = self.velocity.lerp(instantaneous, alpha);
}
#[inline]
pub fn velocity(&self) -> Vec2 {
self.velocity
}
#[inline]
pub fn is_scrolling(&self) -> bool {
self.velocity.length() > SCROLL_VELOCITY_THRESHOLD
}
#[inline]
pub fn decay(&mut self, delta_time: Duration) {
let dt = duration_to_secs(delta_time);
if !dt.is_finite() || dt <= 0.0 {
return;
}
let speed = self.velocity.length();
if speed <= 0.0 || !speed.is_finite() {
return;
}
let reduction = self.deceleration * dt;
let new_speed = (speed - reduction).max(0.0);
if new_speed == 0.0 {
self.velocity = Vec2::ZERO;
} else {
self.velocity *= new_speed / speed;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
fn viewport_full() -> Rect {
Rect::new(0.0, 0.0, 1000.0, 1000.0)
}
#[test]
fn static_caret_zero_velocity() {
let pos = ImePositioner::new(Vec2::new(100.0, 200.0), viewport_full());
let p = pos.compute_position(Duration::from_millis(16));
assert_eq!(p, Vec2::new(100.0, 200.0));
}
#[test]
fn constant_velocity_offset_direction() {
let mut pos = ImePositioner::new(Vec2::new(100.0, 100.0), viewport_full());
pos.set_scroll_velocity(Vec2::new(1000.0, 0.0));
let dt = 0.1f32;
let damping = (-pos.damping_factor() * dt).exp();
let expected_x = 100.0 + 1000.0 * dt * damping;
let p = pos.compute_position(Duration::from_secs_f32(dt));
assert!(
(p.x - expected_x).abs() < 1e-3,
"got {} expected {}",
p.x,
expected_x
);
assert!(
p.x > 100.0,
"offset should be positive in velocity direction"
);
assert_eq!(p.y, 100.0);
}
#[test]
fn high_velocity_within_two_pixels_of_caret() {
let mut pos = ImePositioner::new(Vec2::new(500.0, 500.0), viewport_full());
pos.set_scroll_velocity(Vec2::new(1000.0, 0.0));
for &dt in &[1.5f32, 2.0, 5.0, 10.0] {
let p = pos.compute_position(Duration::from_secs_f32(dt));
let dist = (p - pos.caret_position()).length();
assert!(dist <= 2.0, "dt={} dist={} should be <= 2.0", dt, dist);
}
}
#[test]
fn high_velocity_16ms_frame_accuracy() {
let mut pos = ImePositioner::new(Vec2::new(500.0, 500.0), viewport_full());
pos.set_scroll_velocity(Vec2::new(1000.0, 0.0));
let p = pos.compute_position(Duration::from_millis(16));
assert!(p.x.is_finite(), "x not finite: {}", p.x);
assert!(p.y.is_finite(), "y not finite: {}", p.y);
let lag = (p - pos.caret_position()).length();
assert!(
lag < 20.0,
"lag {} should be small (< 20px) at 16ms/1000px-s",
lag
);
let (bp, _bs) = pos.compute_bounds(Duration::from_millis(16), 24.0);
assert!(bp.x >= 0.0 && bp.x <= 1000.0, "x {} out of viewport", bp.x);
assert!(bp.y >= 0.0 && bp.y <= 1000.0, "y {} out of viewport", bp.y);
}
#[test]
fn damping_decreases_offset_with_increasing_dt() {
let mut pos = ImePositioner::new(Vec2::new(0.0, 0.0), viewport_full());
pos.set_scroll_velocity(Vec2::new(1000.0, 0.0));
let dts = [0.3f32, 0.5, 1.0, 2.0, 5.0];
let mut prev = f32::INFINITY;
for &dt in &dts {
let p = pos.compute_position(Duration::from_secs_f32(dt));
let offset = p.x.abs();
assert!(
offset < prev + 1e-4,
"offset should not increase in damping regime: {} >= {}",
offset,
prev
);
prev = offset;
}
}
#[test]
fn zero_delta_time_equals_caret() {
let mut pos = ImePositioner::new(Vec2::new(123.0, 456.0), viewport_full());
pos.set_scroll_velocity(Vec2::new(1000.0, 1000.0));
assert_eq!(
pos.compute_position(Duration::ZERO),
Vec2::new(123.0, 456.0)
);
}
#[test]
fn nan_velocity_rejected() {
let mut pos = ImePositioner::new(Vec2::ZERO, viewport_full());
pos.set_scroll_velocity(Vec2::new(500.0, 500.0));
pos.set_scroll_velocity(Vec2::new(f32::NAN, 0.0));
assert_eq!(pos.scroll_velocity(), Vec2::new(500.0, 500.0));
pos.set_scroll_velocity(Vec2::new(0.0, f32::INFINITY));
assert_eq!(pos.scroll_velocity(), Vec2::new(500.0, 500.0));
pos.set_scroll_velocity(Vec2::new(f32::NEG_INFINITY, f32::NAN));
assert_eq!(pos.scroll_velocity(), Vec2::new(500.0, 500.0));
}
#[test]
fn negative_damping_factor_rejected() {
let mut pos = ImePositioner::new(Vec2::ZERO, viewport_full());
pos.set_damping_factor(-1.0);
assert_eq!(pos.damping_factor(), DEFAULT_DAMPING_FACTOR);
pos.set_damping_factor(0.0);
assert_eq!(pos.damping_factor(), DEFAULT_DAMPING_FACTOR);
pos.set_damping_factor(f32::NAN);
assert_eq!(pos.damping_factor(), DEFAULT_DAMPING_FACTOR);
pos.set_damping_factor(f32::INFINITY);
assert_eq!(pos.damping_factor(), DEFAULT_DAMPING_FACTOR);
pos.set_damping_factor(2.5);
assert_eq!(pos.damping_factor(), 2.5);
}
#[test]
fn viewport_clamping() {
let vp = Rect::new(100.0, 100.0, 200.0, 200.0);
let mut pos = ImePositioner::new(Vec2::new(150.0, 150.0), vp);
pos.set_scroll_velocity(Vec2::new(1_000_000.0, 0.0));
let (p, _s) = pos.compute_bounds(Duration::from_secs_f32(0.01), 20.0);
assert!(p.x <= 300.0, "x {} should be clamped to <= 300", p.x);
assert!(p.x >= 100.0);
assert!(p.y >= 100.0 && p.y <= 300.0);
}
#[test]
fn viewport_clamp_point_helper() {
let vp = Viewport::new(Rect::new(10.0, 10.0, 100.0, 100.0));
assert_eq!(vp.clamp_point(Vec2::new(50.0, 50.0)), Vec2::new(50.0, 50.0));
assert_eq!(
vp.clamp_point(Vec2::new(-5.0, 999.0)),
Vec2::new(10.0, 110.0)
);
assert_eq!(vp.clamp_point(Vec2::new(5.0, 5.0)), Vec2::new(10.0, 10.0));
}
#[test]
fn scroll_kinematics_velocity_estimation() {
let mut kin = ScrollKinematics::new();
kin.update(Vec2::new(160.0, 0.0), Duration::from_millis(16));
let v = kin.velocity();
assert!(v.x > 0.0, "velocity should be positive: {:?}", v);
assert!(kin.is_scrolling());
}
#[test]
fn scroll_kinematics_decay() {
let mut kin = ScrollKinematics::new();
kin.velocity = Vec2::new(1000.0, 0.0);
kin.decay(Duration::from_millis(16));
assert!(kin.velocity().x < 1000.0);
kin.decay(Duration::from_secs(10));
assert_eq!(kin.velocity(), Vec2::ZERO);
assert!(!kin.is_scrolling());
}
#[test]
fn scroll_kinematics_is_scrolling_threshold() {
let mut kin = ScrollKinematics::new();
assert!(!kin.is_scrolling());
kin.velocity = Vec2::new(0.5, 0.0);
assert!(!kin.is_scrolling());
kin.velocity = Vec2::new(2.0, 0.0);
assert!(kin.is_scrolling());
}
#[test]
fn scroll_kinematics_decay_preserves_direction() {
let mut kin = ScrollKinematics::new();
kin.velocity = Vec2::new(300.0, 400.0);
let dir_before = kin.velocity.normalize();
kin.decay(Duration::from_millis(10));
let dir_after = kin.velocity.normalize();
assert!((dir_before - dir_after).length() < 1e-4);
}
#[test]
fn scroll_kinematics_rejects_nan_delta() {
let mut kin = ScrollKinematics::new();
kin.update(Vec2::new(f32::NAN, 0.0), Duration::from_millis(16));
assert_eq!(kin.velocity(), Vec2::ZERO);
kin.update(Vec2::new(100.0, 0.0), Duration::ZERO);
assert_eq!(kin.velocity(), Vec2::ZERO);
}
proptest! {
#[test]
fn result_always_finite(vx in any::<f32>(), vy in any::<f32>(), dt_secs in 0.0f32..100.0) {
let mut pos = ImePositioner::new(Vec2::new(10.0, 10.0), viewport_full());
if vx.is_finite() && vy.is_finite() {
pos.set_scroll_velocity(Vec2::new(vx, vy));
}
let p = pos.compute_position(Duration::from_secs_f32(dt_secs));
prop_assert!(p.x.is_finite(), "x not finite: {} (vx={} dt={})", p.x, vx, dt_secs);
prop_assert!(p.y.is_finite(), "y not finite: {} (vy={} dt={})", p.y, vy, dt_secs);
}
#[test]
fn result_within_viewport(vx in -5000.0f32..5000.0, vy in -5000.0f32..5000.0, dt_secs in 0.0f32..5.0) {
let vp = Rect::new(50.0, 50.0, 200.0, 200.0);
let mut pos = ImePositioner::new(Vec2::new(150.0, 150.0), vp);
pos.set_scroll_velocity(Vec2::new(vx, vy));
let (p, _s) = pos.compute_bounds(Duration::from_secs_f32(dt_secs), 20.0);
prop_assert!(p.x >= 50.0 && p.x <= 250.0, "x {} out of viewport", p.x);
prop_assert!(p.y >= 50.0 && p.y <= 250.0, "y {} out of viewport", p.y);
}
}
#[test]
fn zero_viewport_size() {
let vp = Rect::new(100.0, 100.0, 0.0, 0.0);
let mut pos = ImePositioner::new(Vec2::new(100.0, 100.0), vp);
pos.set_scroll_velocity(Vec2::new(1000.0, 0.0));
let (p, _s) = pos.compute_bounds(Duration::from_secs_f32(0.01), 20.0);
assert_eq!(p.x, 100.0);
assert_eq!(p.y, 100.0);
}
#[test]
fn very_large_delta_time() {
let mut pos = ImePositioner::new(Vec2::new(100.0, 100.0), viewport_full());
pos.set_scroll_velocity(Vec2::new(1000.0, 1000.0));
let p = pos.compute_position(Duration::from_secs(1_000_000));
assert!((p - pos.caret_position()).length() < 1e-3);
}
#[test]
fn very_small_damping_factor() {
let mut pos = ImePositioner::new(Vec2::new(0.0, 0.0), viewport_full());
pos.set_damping_factor(0.001);
assert_eq!(pos.damping_factor(), 0.001);
pos.set_scroll_velocity(Vec2::new(1000.0, 0.0));
let dt = 0.01f32;
let p = pos.compute_position(Duration::from_secs_f32(dt));
let damping = (-0.001f32 * dt).exp();
let expected = 1000.0 * dt * damping;
assert!((p.x - expected).abs() < 1e-2);
}
#[test]
fn compute_bounds_size_uses_line_height() {
let pos = ImePositioner::new(Vec2::new(100.0, 100.0), viewport_full());
let (_p, s) = pos.compute_bounds(Duration::from_millis(16), 30.0);
assert_eq!(s.width, 2.0);
assert_eq!(s.height, 30.0);
}
}