///|
/// An eased scroll glide: the transition between two scroll offsets,
/// evaluated at paint/event time from the runtime's animation clock
/// (`ViewPaintContext.now_ms` / `ViewEventContext.now_ms`).
///
/// Storing the glide instead of a resolved offset is what keeps scrolling
/// smooth: the offset shown is always sampled for the frame being drawn, so
/// there is no timer tick grid to beat against the display refresh, and the
/// animation runs in real time on 60Hz, 120Hz and variable-refresh displays
/// alike. A glide with `from_y == to_y` is at rest and evaluates to a
/// constant.
///
/// A glide is a *follower*, not a keyframe animation: re-keying one mid-flight
/// must always start from the offset currently being shown
/// (`offset_at(now)`), never from the pending target. Starting from the target
/// teleports the view by however far the ease still had to go, which reads as
/// a jump — and when the new target is behind the shown offset (a direction
/// reversal, or a clamp at the document edge) the jump is backwards.
///|
pub(all) struct MarkdownScrollGlide {
from_y : Double
to_y : Double
start_ms : Double
/// Exponential time constant for this glide, in milliseconds: how quickly
/// the shown offset closes the remaining distance to `to_y`. Callers pick it
/// per input kind — a deliberate wheel notch can afford a visible ease,
/// while a dense momentum stream wants only enough smoothing to even out its
/// own irregular event cadence.
tau_ms : Double
} derive(Eq, Debug, ToJson)
///|
pub extend MarkdownScrollGlide with Debug::{to_repr}
///|
pub extend MarkdownScrollGlide with Eq::{not_equal, equal}
///|
pub extend MarkdownScrollGlide with ToJson::{to_json}
///|
let markdown_scroll_geometry_compensation : Ref[Double] = Ref(0.0)
///|
let markdown_scroll_last_frame_time : Ref[Double] = Ref(0.0)
///|
/// Record the runtime clock used by the most recent paint. App-level view
/// construction has no paint context, so this is the only safe timestamp to
/// use when it must apply a measurement compensation between frames.
pub fn markdown_scroll_publish_frame_time(now_ms : Double) -> Unit {
markdown_scroll_last_frame_time.val = now_ms
}
///|
pub fn markdown_scroll_peek_frame_time() -> Double {
markdown_scroll_last_frame_time.val
}
///|
/// Publish a measurement compensation for the owner of the controlled editor
/// to consume on its next state/view build.
pub fn markdown_scroll_publish_geometry_compensation(delta : Double) -> Unit {
if delta != 0.0 {
markdown_scroll_geometry_compensation.val = markdown_scroll_geometry_compensation.val +
delta
}
}
///|
/// Consume pending measurement compensation exactly once.
pub fn markdown_scroll_take_geometry_compensation() -> Double {
let value = markdown_scroll_geometry_compensation.val
markdown_scroll_geometry_compensation.val = 0.0
value
}
///|
/// Read, without consuming, the compensation published by the last paint.
/// Retained view nodes use this while the owning app is between paints and
/// its next state rebuild: geometry has already moved, so rendering against
/// the old glide would expose one frame of backward displacement.
pub fn markdown_scroll_peek_geometry_compensation() -> Double {
markdown_scroll_geometry_compensation.val
}
///|
/// Sample the offset that the current frame is actually showing.
///
/// Geometry measurement is committed during paint, while the owning app may
/// not have rebuilt its glide yet. Every input and paint path must therefore
/// use the same frame-local value: the exact glide sample plus the pending
/// anchor translation, quantized once at the end.
pub fn MarkdownScrollGlide::display_offset(
self : MarkdownScrollGlide,
now_ms : Double,
compensation? : Double = 0.0,
scale? : Double = 1.0,
) -> Double {
markdown_scroll_glide_snap(self.exact_offset_at(now_ms) + compensation, scale)
}
///|
pub fn MarkdownScrollGlide::new(
from_y~ : Double,
to_y~ : Double,
start_ms~ : Double,
tau_ms? : Double = markdown_scroll_glide_time_constant_ms(),
) -> MarkdownScrollGlide {
{ from_y, to_y, start_ms, tau_ms, }
}
///|
/// A glide already at rest at `y`: paints `y` for every clock value.
pub fn MarkdownScrollGlide::settled(y : Double) -> MarkdownScrollGlide {
{
from_y: y,
to_y: y,
start_ms: 0.0,
tau_ms: markdown_scroll_glide_time_constant_ms(),
}
}
///|
/// Apply a geometry anchor compensation while preserving the animation's
/// direction and velocity. Both endpoints move together, so a height change
/// cannot create an input-free reverse jump.
pub fn MarkdownScrollGlide::with_anchor_compensation(
self : MarkdownScrollGlide,
compensation : Double,
max_offset : Double,
now_ms? : Double = 0.0,
) -> MarkdownScrollGlide {
let max_offset = max_offset.max(0.0)
let exact_shown = self.exact_offset_at(now_ms)
let from = (self.from_y + compensation).max(0.0).min(max_offset)
let translated_to = self.to_y + compensation
let unclamped_shifted_shown = exact_shown + compensation
let shifted_shown = unclamped_shifted_shown.max(0.0).min(max_offset)
let clamped_to = translated_to.max(0.0).min(max_offset)
// A range shrink can clamp the translated target past the point currently
// shown by the glide. Continuing to that target would manufacture a short
// reverse animation even though the user did not reverse direction. Keep
// the visible point as the new settled position in that case. Genuine user
// reversals are preserved because they already have the opposite direction
// in `self.to_y - exact_shown` before geometry is applied.
let old_direction = self.to_y - exact_shown
let new_direction = clamped_to - shifted_shown
let crosses_shown = old_direction.abs() > 0.0001 &&
new_direction.abs() > 0.0001 &&
(
(old_direction > 0.0 && new_direction < 0.0) ||
(old_direction < 0.0 && new_direction > 0.0)
)
let to = if crosses_shown { shifted_shown } else { clamped_to }
let candidate : MarkdownScrollGlide = {
from_y: from,
to_y: to,
start_ms: self.start_ms,
tau_ms: self.tau_ms,
}
// Pure translation preserves the existing clock phase exactly. When range
// clamping clips only one endpoint, however, evaluating the clipped glide
// at `now_ms` would move the content away from the anchor. Re-key from the
// already visible, compensated position and keep the clamped target; this
// changes no displayed frame and only removes the impossible tail.
if (candidate.exact_offset_at(now_ms) - shifted_shown).abs() > 0.0001 {
{ from_y: shifted_shown, to_y: to, start_ms: now_ms, tau_ms: self.tau_ms, }
} else {
candidate
}
}
///|
/// Time constant of the default ease, in milliseconds: the fraction of the
/// remaining distance still ahead after this much time is 1/e ≈ 37%. 90ms
/// settles a wheel notch with the same crisp glide VS Code's editor uses.
fn markdown_scroll_glide_time_constant_ms() -> Double {
90.0
}
///|
/// Minimum glide speed in px/ms for the ease's tail. The exponential step
/// shrinks below a pixel per frame as the offset nears the target; with
/// whole-pixel snapping that motion rounds away and the glide would stall
/// (or, with an aggressive settle rule, visibly jump). The floor keeps the
/// last few pixels moving at a constant ~125px/s so every notch lands
/// exactly, decelerating imperceptibly.
fn markdown_scroll_glide_min_velocity_px_per_ms() -> Double {
0.125
}
///|
/// Once the remaining distance drops below this many logical pixels the
/// glide counts as settled: sub-pixel creep is invisible and would only keep
/// the host's animation-driven redraw loop alive.
fn markdown_scroll_glide_settle_px() -> Double {
0.5
}
///|
/// The distance covered `elapsed_ms` into the glide: exponential decay
/// composed with a constant-velocity floor, never overshooting the target.
/// Time-based rather than step-based, so a late frame simply covers more
/// time instead of slowing the motion.
fn markdown_scroll_glide_travelled(
remaining_abs : Double,
elapsed_ms : Double,
tau_ms : Double,
) -> Double {
if elapsed_ms <= 0.0 || remaining_abs <= 0.0 {
return 0.0
}
let decay = @math.exp(
-elapsed_ms /
(if tau_ms > 0.0 {
tau_ms
} else {
markdown_scroll_glide_time_constant_ms()
}),
)
let exponential = remaining_abs * (1.0 - decay)
let floored = max_double(
exponential,
markdown_scroll_glide_min_velocity_px_per_ms() * elapsed_ms,
)
min_double(floored, remaining_abs)
}
///|
/// The scroll offset to draw at `now_ms`, snapped to whole device pixels.
/// Fractional offsets make glyph rasterization shimmer while scrolling;
/// snapping to the physical pixel grid keeps text crisp. The scale factor is
/// the device pixel ratio (`Environment.scale_factor`): at 2x, `scale=2.0`
/// quantizes to 0.5 logical pixels, matching how browsers scroll — a whole
/// *logical* pixel is 2 physical pixels and reads as coarse stepping on a
/// Retina display. The internal state machine (`from_y`/`to_y`, the settle
/// test) stays in exact logical units; only the displayed value is quantized,
/// so the target never accumulates rounding error.
pub fn MarkdownScrollGlide::offset_at(
self : MarkdownScrollGlide,
now_ms : Double,
scale? : Double = 1.0,
) -> Double {
markdown_scroll_glide_snap(self.exact_offset_at(now_ms), scale)
}
///|
/// Sample the displayed offset after a pending geometry translation and snap
/// that combined value once. Snapping before adding the translation can differ
/// by one device pixel on a scaled display, which is visible as a one-frame
/// backwards nudge when a measured block commits during a glide.
pub fn MarkdownScrollGlide::offset_at_with_compensation(
self : MarkdownScrollGlide,
now_ms : Double,
compensation : Double,
scale? : Double = 1.0,
) -> Double {
markdown_scroll_glide_snap(self.exact_offset_at(now_ms) + compensation, scale)
}
///|
/// The un-quantized offset at `now_ms`. Re-keying a mid-flight glide (feeding
/// the shown position back as the new `from_y`) must use this, not the
/// device-snapped `offset_at`: rounding the hidden state would shift the
/// position by up to half a device pixel on every wheel event and reintroduce
/// the sub-pixel jitter the snap exists to remove.
pub fn MarkdownScrollGlide::exact_offset_at(
self : MarkdownScrollGlide,
now_ms : Double,
) -> Double {
let remaining = self.to_y - self.from_y
let travelled = markdown_scroll_glide_travelled(
remaining.abs(),
now_ms - self.start_ms,
self.tau_ms,
)
let directed = if remaining >= 0.0 { travelled } else { -travelled }
self.from_y + directed
}
///|
/// Round to the nearest device pixel: `scale` logical pixels per device pixel
/// inverted. `scale <= 0` or `1.0` is a whole logical pixel (the default), and
/// larger scales are progressively finer. Round-to-nearest, not floor, so
/// quantization stays unbiased — a floored glide drifts visibly behind target.
fn markdown_scroll_glide_snap(y : Double, scale : Double) -> Double {
if scale <= 0.0 || scale == 1.0 {
y.round()
} else {
(y * scale).round() / scale
}
}
///|
/// Whether the glide is still in flight at `now_ms`. False once the offset
/// has reached the target (within the settle threshold), which is what stops
/// the host's animation-driven redraw loop.
pub fn MarkdownScrollGlide::is_animating(
self : MarkdownScrollGlide,
now_ms : Double,
) -> Bool {
let remaining_abs = (self.to_y - self.from_y).abs()
if remaining_abs <= 0.0 {
return false
}
let travelled = markdown_scroll_glide_travelled(
remaining_abs,
now_ms - self.start_ms,
self.tau_ms,
)
remaining_abs - travelled > markdown_scroll_glide_settle_px()
}