Messages-area scroll state — one tagged value, no scattered flags.
The old model smeared scroll across four fields with two different
storage scopes (:messages-scroll workspace-local; the animation
target + two intent booleans top-level), plus a nil-vs-concrete
overload on :messages-scroll. Every handler had to hand-maintain
their consistency, and forgetting one dissoc produced the
"flash to the top, then scroll to the bottom" jump.
This namespace replaces all of it with ONE workspace-local value,
:scroll, that is a tagged variant:
{:mode :follow} ;; stick to the bottom (auto-track) {:mode :follow, :pos p} ;; …easing down toward the bottom {:mode :at, :offset n} ;; parked at row n (user scrolled up) {:mode :at, :offset n, :pos p} ;; …easing toward row n
Two orthogonal concerns, cleanly separated:
:mode (+ :offset). :follow means "desired = bottom";
:at means "desired = offset". The user's scroll-up intent is just
:at — no separate :scroll-pinned-up? flag.:pos, the eased on-screen row. Present only while an
animation is in flight (and held at the bottom in :follow so the
NEXT content growth has somewhere to ease FROM instead of
teleporting). Absent ⇒ snapped exactly to desired.Every transition REPLACES the whole value, so nothing can dangle.
ease is called once per render frame; in :follow mode the desired
row simply IS the growing bottom, so streaming content eases in for
free — there is no special "animated follow" path any more.
Pure: no re-frame, no atoms. state.clj events are thin wrappers and
screen.clj reads layout-offset / animating? to drive the loop.
Messages-area scroll state — one tagged value, no scattered flags.
The old model smeared scroll across four fields with two different
storage scopes (`:messages-scroll` workspace-local; the animation
target + two intent booleans top-level), plus a `nil`-vs-concrete
overload on `:messages-scroll`. Every handler had to hand-maintain
their consistency, and forgetting one `dissoc` produced the
"flash to the top, then scroll to the bottom" jump.
This namespace replaces all of it with ONE workspace-local value,
`:scroll`, that is a tagged variant:
{:mode :follow} ;; stick to the bottom (auto-track)
{:mode :follow, :pos p} ;; …easing down toward the bottom
{:mode :at, :offset n} ;; parked at row n (user scrolled up)
{:mode :at, :offset n, :pos p} ;; …easing toward row n
Two orthogonal concerns, cleanly separated:
- INTENT — `:mode` (+ `:offset`). `:follow` means "desired = bottom";
`:at` means "desired = offset". The user's scroll-up intent is just
`:at` — no separate `:scroll-pinned-up?` flag.
- DISPLAY — `:pos`, the eased on-screen row. Present only while an
animation is in flight (and held at the bottom in `:follow` so the
NEXT content growth has somewhere to ease FROM instead of
teleporting). Absent ⇒ snapped exactly to desired.
Every transition REPLACES the whole value, so nothing can dangle.
`ease` is called once per render frame; in `:follow` mode the desired
row simply IS the growing bottom, so streaming content eases in for
free — there is no special "animated follow" path any more.
Pure: no re-frame, no atoms. `state.clj` events are thin wrappers and
`screen.clj` reads `layout-offset` / `animating?` to drive the loop.(animating? sc max-s)True when an ease is still in flight (displayed ≠ desired). Drives the render loop's fast tick cadence; settles to false so an idle view stops repainting.
True when an ease is still in flight (displayed ≠ desired). Drives the render loop's fast tick cadence; settles to false so an idle view stops repainting.
(bottom-hidden? sc max-s)True when the live bottom sits BELOW the current viewport — i.e. there is
content to jump DOWN to. FALSE when following at the bottom, AND false when
nothing overflows (max-s 0: an empty or short session). The jump-to-bottom
affordance keys off this, NOT scrolled-up? — parking :at offset 0 in an
empty session is still 'at the bottom', so the chip must stay hidden.
True when the live bottom sits BELOW the current viewport — i.e. there is content to jump DOWN to. FALSE when following at the bottom, AND false when nothing overflows (`max-s` 0: an empty or short session). The jump-to-bottom affordance keys off this, NOT `scrolled-up?` — parking `:at` offset 0 in an empty session is still 'at the bottom', so the chip must stay hidden.
(decay-wheel-momentum momentum)Step the running momentum toward zero for one IDLE poll (no wheel event).
Returns 0 once small enough that the directional lock should release.
Step the running `momentum` toward zero for one IDLE poll (no wheel event). Returns 0 once small enough that the directional lock should release.
(desired sc max-s)The row the view WANTS to show: bottom in :follow, the clamped
offset in :at.
The row the view WANTS to show: bottom in `:follow`, the clamped offset in `:at`.
(displayed sc max-s)The row currently ON SCREEN: the eased :pos if mid-animation, else
desired.
The row currently ON SCREEN: the eased `:pos` if mid-animation, else `desired`.
(down sc amount max-s)Wheel/key scroll DOWN by amount. Landing within slack-rows of the
bottom re-arms FOLLOW (and eases the rest of the way); otherwise parks
amount rows below the COMMITTED offset.
As with up, the target anchors to the committed :offset (or the live
bottom while FOLLOWING) so an alternating momentum stream can't walk the
viewport backward off a half-eased position.
Wheel/key scroll DOWN by `amount`. Landing within `slack-rows` of the bottom re-arms FOLLOW (and eases the rest of the way); otherwise parks `amount` rows below the COMMITTED offset. As with `up`, the target anchors to the committed `:offset` (or the live bottom while FOLLOWING) so an alternating momentum stream can't walk the viewport backward off a half-eased position.
(ease sc max-s)Advance the on-screen :pos one step toward desired. Called once per
render frame.
:pos toward the target.:pos (clean snap; no further repaint).:pos pinned at the bottom so the
next content growth eases FROM here instead of teleporting.Advance the on-screen `:pos` one step toward `desired`. Called once per render frame. - Mid-ease ⇒ step `:pos` toward the target. - Settled while PARKED ⇒ drop `:pos` (clean snap; no further repaint). - Settled while FOLLOWING ⇒ KEEP `:pos` pinned at the bottom so the next content growth eases FROM here instead of teleporting.
The resting auto-bottom intent: track the latest message.
The resting auto-bottom intent: track the latest message.
(layout-offset sc max-s)The offset to feed virtual/layout and the scrollbar geometry.
nil ⇒ auto-bottom (never anchored, always tracks the latest message
— used when FOLLOW is settled at the bottom). A concrete row is
returned while parked or mid-ease so the painter anchors / animates
against it.
The offset to feed `virtual/layout` and the scrollbar geometry. `nil` ⇒ auto-bottom (never anchored, always tracks the latest message — used when FOLLOW is settled at the bottom). A concrete row is returned while parked or mid-ease so the painter anchors / animates against it.
(merge-wheel-delta momentum delta)Smooth a single raw wheel delta (signed rows, +down / -up) into a running
momentum, returning [new-momentum effective-delta].
This is the input-side debouncer that stops a slow macOS trackpad's
sign-flipping inertia tail from bouncing the viewport. It is the discrete,
phase-less analog of kitty's add_velocity (glfw/momentum-scroll.c) and
WebKit's kinetic-scroll velocity accumulation:
effective-delta is nil when the tick was fully absorbed — the caller drops
it. new-momentum is what the caller stores back for the next poll.
Smooth a single raw wheel `delta` (signed rows, +down / -up) into a running `momentum`, returning `[new-momentum effective-delta]`. This is the input-side debouncer that stops a slow macOS trackpad's sign-flipping inertia tail from bouncing the viewport. It is the discrete, phase-less analog of kitty's `add_velocity` (glfw/momentum-scroll.c) and WebKit's kinetic-scroll velocity accumulation: - SAME sign as the running momentum (or momentum at rest): accumulate, and the raw delta dispatches verbatim. A solo tap or a clean drag is unchanged. - OPPOSITE sign: the delta can only BRAKE the existing momentum toward zero, never cross it. A small opposing tick is absorbed (effective-delta nil) and just shaves momentum; only once the accumulated opposition exceeds the remaining momentum does the leftover re-seed in the new direction. `effective-delta` is nil when the tick was fully absorbed — the caller drops it. `new-momentum` is what the caller stores back for the next poll.
Upper bound on the running wheel-momentum used by merge-wheel-delta.
Caps how much a long, continuous same-direction drag can accumulate so a
single stray opposing tick can't be absorbed forever (which would make the
scroll feel sticky / unable to reverse). Chosen well above any realistic
coalesced batch (amount is (* 3 |wheel-delta|)), so normal scrolling
never clamps, but a frantic flail can't build unbounded inertia.
Upper bound on the running wheel-momentum used by `merge-wheel-delta`. Caps how much a long, continuous same-direction drag can accumulate so a single stray opposing tick can't be absorbed forever (which would make the scroll feel sticky / unable to reverse). Chosen well above any realistic coalesced batch (`amount` is `(* 3 |wheel-delta|)`), so normal scrolling never clamps, but a frantic flail can't build unbounded inertia.
Fraction of wheel-momentum retained per IDLE input poll (a poll with no wheel event). Halving each idle frame releases the directional lock within a few frames of the user lifting their fingers — so the NEXT gesture starts clean and a deliberate direction change dispatches immediately instead of being absorbed as 'opposition' to stale momentum. Mirrors kitty's 150ms sample window: momentum is only meaningful while events keep arriving.
Fraction of wheel-momentum retained per IDLE input poll (a poll with no wheel event). Halving each idle frame releases the directional lock within a few frames of the user lifting their fingers — so the NEXT gesture starts clean and a deliberate direction change dispatches immediately instead of being absorbed as 'opposition' to stale momentum. Mirrors kitty's 150ms sample window: momentum is only meaningful while events keep arriving.
(parked offset)A concrete parked intent at row offset (snapped, no in-flight ease).
A concrete parked intent at row `offset` (snapped, no in-flight ease).
(reanchor sc anchored delta)The layout corrected off-screen estimate→real heights and shifted the
anchor by delta; anchored is the new absolute on-screen row. Shift
the concrete fields so the anchored message stays visually put. Only
meaningful while parked or mid-ease (FOLLOW-at-bottom feeds nil and
never reaches here).
The layout corrected off-screen estimate→real heights and shifted the anchor by `delta`; `anchored` is the new absolute on-screen row. Shift the concrete fields so the anchored message stays visually put. Only meaningful while parked or mid-ease (FOLLOW-at-bottom feeds `nil` and never reaches here).
(scrolled-up? sc)True when the user has PARKED the viewport above the live bottom
(:at intent) — reading history rather than following new content.
The input cursor is hidden in this state so its terminal blink does
not jump around as the transcript repaints during a scroll.
True when the user has PARKED the viewport above the live bottom (`:at` intent) — reading history rather than following new content. The input cursor is hidden in this state so its terminal blink does not jump around as the transcript repaints during a scroll.
How close (in rows) to the bottom still counts as the bottom for the
purpose of re-arming :follow. Scrolling DOWN to within this band
sticks to the bottom again, so freshly streamed content keeps the
latest message in view. Kept small so a deliberate read-up even a
few rows above the live edge stays parked.
How close (in rows) to the bottom still counts as the bottom for the purpose of re-arming `:follow`. Scrolling DOWN to within this band sticks to the bottom again, so freshly streamed content keeps the latest message in view. Kept small so a deliberate read-up even a few rows above the live edge stays parked.
Ease-out fraction per animation step: bigger steps when far, smaller
as we close in. The render loop ticks ease a few times a second.
Ease-out fraction per animation step: bigger steps when far, smaller as we close in. The render loop ticks `ease` a few times a second.
(to-y offset max-s)Scrollbar drag/track click → offset (already mapped from cursor row).
1:1, so SNAP (no ease). The very bottom re-enters FOLLOW.
Scrollbar drag/track click → `offset` (already mapped from cursor row). 1:1, so SNAP (no ease). The very bottom re-enters FOLLOW.
(up sc amount max-s)Wheel/key scroll UP by amount: park amount rows above the current
COMMITTED offset and ease there. Scrolling up is always a deliberate
"let me read history" intent.
The target is anchored to the committed :offset (or the live bottom
while FOLLOWING) — NOT to the eased display row. An alternating momentum
stream (slow trackpad drags emit sign-flipping inertia tail events) would
otherwise re-anchor each event off a half-eased position and ratchet the
viewport backward, visibly fighting the user. :pos is seeded from the
display row only when no ease is already in flight, so a new ease begins
from where the view currently sits while a continuing ease keeps its
origin.
Wheel/key scroll UP by `amount`: park `amount` rows above the current COMMITTED offset and ease there. Scrolling up is always a deliberate "let me read history" intent. The target is anchored to the committed `:offset` (or the live bottom while FOLLOWING) — NOT to the eased display row. An alternating momentum stream (slow trackpad drags emit sign-flipping inertia tail events) would otherwise re-anchor each event off a half-eased position and ratchet the viewport backward, visibly fighting the user. `:pos` is seeded from the display row only when no ease is already in flight, so a new ease begins from where the view currently sits while a continuing ease keeps its origin.
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |