packages feed

nano-ui-0.1.0.0: lib/NanoUI/Context/Scroll.hs

-- | Scroll offsets, links and configuration kept in the widget store, the
-- wheel and glide tuning kept in the context, and the commands that move a
-- scroller: to an offset, by a delta or page, or onto a widget.
module NanoUI.Context.Scroll
  ( getScrollOffset
  , setScrollOffset
  , getScrollOffset2D
  , setScrollOffset2D
  , setScrollConfig
  , linkScrollAxes
    -- * Tuning
  , ScrollTuning (..)
  , defaultScrollTuning
  , getScrollTuning
  , setScrollTuning
  , getScrollStep
  , setScrollStep
  , resolveScrollStep
    -- * Geometry
  , ScrollAxes (..)
  , ScrollMetrics (..)
  , getScrollMetrics
  , cacheScrollMetrics
  , beginScrollMetrics
  , getScrollOffsetIn
  , setScrollOffsetIn
    -- * Commands
  , ScrollBehavior (..)
  , ScrollAlign (..)
  , scrollTo
  , scrollBy
  , scrollPages
  , scrollToStart
  , scrollToEnd
  , scrollIntoView
  , scrollRectIntoView
    -- * Glide
  , applyScrollTarget
  , scrollTargetOffset
  , scrollGliding
  , clampScrollOffset
  , cancelScrollGlide
  , stepScrollGlides
  ) where

import Control.Monad (unless, when)
import Data.IORef (modifyIORef', readIORef, writeIORef)
import Data.IntMap.Strict qualified as IM
import Data.IntSet qualified as IS

import NanoUI.Context.Core (damageWidget, getPrevRect, getStore, setStore)
import NanoUI.Context.Types
  ( Context (..)
  , ScrollAxes (..)
  , ScrollGlide (..)
  , ScrollState (..)
  , ScrollTuning (..)
  , defaultScrollTuning
  , intKey
  )
import NanoUI.Draw qualified as Draw
import NanoUI.Frame.Scroll.Geometry
  ( ScrollConfig
  , decodeScrollConfig
  , defaultScrollConfig
  , encodeScrollConfig
  , scrollConfigNative2D
  )
import NanoUI.Id (WidgetId)
import NanoUI.Store
  ( WidgetStore (..)
  , slotKey
  , Slot (..)
  )
import NanoUI.Types (DamageBounds (..), Rect (..), V2 (..), clamp, onGrid, v2X, v2Y)

{-# INLINE snapScrollOffset #-}
snapScrollOffset :: Context -> Float -> IO Float
snapScrollOffset ctx v = do
  s <- Draw.getDrawSnapScale (ctxDrawArena ctx)
  pure (onGrid s v)

getScrollOffset :: Context -> WidgetId -> IO Float
getScrollOffset ctx wid = do
  s <- getStore ctx
  let key = intKey wid
      points = storePoint s
      cfgBits = IM.findWithDefault (encodeScrollConfig defaultScrollConfig) (slotKey SlotScrollCfg key) (storeInt s)
      -- Text areas keep both axes in their own slot; native 2D scrollers keep
      -- them in the offset slot, falling back to the main-axis float as
      -- 'getScrollOffset2D' does.
      off = case IM.lookup (slotKey SlotTextAreaScroll key) points of
        Just (_, sy) -> sy
        Nothing
          | scrollConfigNative2D (decodeScrollConfig cfgBits)
          , Just (_, y) <- IM.lookup (slotKey SlotScrollOff key) points ->
              y
          | otherwise -> IM.findWithDefault 0 key (storeFloat s)
  snapScrollOffset ctx off

-- | Move a scroller to an offset along its main axis. Cancels a glide in
-- flight: whoever sets an offset outright owns it.
setScrollOffset :: Context -> WidgetId -> Float -> IO ()
setScrollOffset ctx wid off = do
  cancelScrollGlide ctx wid
  writeScrollOffset ctx wid off

writeScrollOffset :: Context -> WidgetId -> Float -> IO ()
writeScrollOffset ctx wid off = do
  store <- getStore ctx
  let key = intKey wid
      sKey = slotKey SlotTextAreaScroll key
  case IM.lookup sKey (storePoint store) of
    Just (sx, sy) ->
      when (sy /= off) $ do
        setStore ctx (store {storePoint = IM.insert sKey (sx, off) (storePoint store)})
        damageWidget ctx wid DamageSelf
    Nothing -> do
      cfg <- getScrollConfig ctx wid
      if scrollConfigNative2D cfg
        then do
          cur <- getScrollOffset2D ctx wid
          writeScrollOffset2D ctx wid (V2 (v2X cur) off)
        else do
          let prev = IM.findWithDefault 0 key (storeFloat store)
          when (prev /= off) $ do
            let floats0 = IM.insert key off (storeFloat store)
                yKey = IM.findWithDefault 0 (slotKey SlotScrollLinkY key) (storeInt store)
            if yKey == 0
              then setStore ctx (store {storeFloat = floats0})
              else do
                let offKey = slotKey SlotScrollOff yKey
                    crossKey = slotKey SlotScrollCross yKey
                    prevY = IM.findWithDefault 0 yKey floats0
                    floats1 = IM.insert yKey prevY $ IM.insert crossKey off floats0
                    points = IM.insert offKey (off, prevY) (storePoint store)
                setStore ctx (store {storeFloat = floats1, storePoint = points})

getScrollOffset2D :: Context -> WidgetId -> IO V2
getScrollOffset2D ctx wid = do
  s <- getStore ctx
  let widKey = intKey wid
      sKey = slotKey SlotTextAreaScroll widKey
  v <-
    case IM.lookup sKey (storePoint s) of
      Just (sx, sy) -> pure (V2 sx sy)
      Nothing -> do
        let offKey = slotKey SlotScrollOff widKey
            crossKey = slotKey SlotScrollCross widKey
        case IM.lookup offKey (storePoint s) of
          Just (x, y) -> pure (V2 x y)
          Nothing ->
            pure
              ( V2
                  (IM.findWithDefault 0 crossKey (storeFloat s))
                  (IM.findWithDefault 0 widKey (storeFloat s))
              )
  sx <- snapScrollOffset ctx (v2X v)
  sy <- snapScrollOffset ctx (v2Y v)
  pure (V2 sx sy)

-- | Move a scroller to an offset on both axes. Cancels a glide in flight.
setScrollOffset2D :: Context -> WidgetId -> V2 -> IO ()
setScrollOffset2D ctx wid off = do
  cancelScrollGlide ctx wid
  writeScrollOffset2D ctx wid off

writeScrollOffset2D :: Context -> WidgetId -> V2 -> IO ()
writeScrollOffset2D ctx wid off = do
  store <- getStore ctx
  let widKey = intKey wid
      sKey = slotKey SlotTextAreaScroll widKey
  -- Text areas only reach the first branch because `textAreaWith` seeds this
  -- slot at init; without the seed a freshly mounted editor falls through to
  -- the container slots below and its offsets are never rendered.
  case IM.lookup sKey (storePoint store) of
    Just (sx, sy) -> do
      let sx' = v2X off
          sy' = v2Y off
      when (sx /= sx' || sy /= sy') $ do
        setStore ctx (store {storePoint = IM.insert sKey (sx', sy') (storePoint store)})
        damageWidget ctx wid DamageSelf
    Nothing -> do
      let offKey = slotKey SlotScrollOff widKey
          crossKey = slotKey SlotScrollCross widKey
          prev = IM.lookup offKey (storePoint store)
          next = (v2X off, v2Y off)
          prevY = IM.findWithDefault 0 widKey (storeFloat store)
          prevX = IM.findWithDefault 0 crossKey (storeFloat store)
          xLink = IM.findWithDefault 0 (slotKey SlotScrollLinkX widKey) (storeInt store)
      when (prev /= Just next || prevY /= v2Y off || prevX /= v2X off) $ do
        let floats0 =
              IM.insert widKey (v2Y off) $
                IM.insert crossKey (v2X off) (storeFloat store)
            floats1 =
              if xLink == 0 then floats0 else IM.insert xLink (v2X off) floats0
        setStore ctx
          ( store
              { storePoint = IM.insert offKey next (storePoint store)
              , storeFloat = floats1
              }
          )

linkScrollAxes :: Context -> WidgetId -> WidgetId -> IO ()
linkScrollAxes ctx yWid xWid = do
  store <- getStore ctx
  let yKey = intKey yWid
      xKey = intKey xWid
      ints =
        IM.insert (slotKey SlotScrollLinkX yKey) xKey $
          IM.insert (slotKey SlotScrollLinkY xKey) yKey (storeInt store)
  setStore ctx (store {storeInt = ints})
  V2 x2 y <- getScrollOffset2D ctx yWid
  x1 <- do
    s <- getStore ctx
    pure (IM.findWithDefault 0 xKey (storeFloat s))
  let x = if x2 == 0 && x1 /= 0 then x1 else x2
  when (x /= x2 || x /= x1) $
    setScrollOffset2D ctx yWid (V2 x y)

getScrollConfig :: Context -> WidgetId -> IO ScrollConfig
getScrollConfig ctx wid = do
  s <- getStore ctx
  let cfgKey = slotKey SlotScrollCfg (intKey wid)
      bits = IM.findWithDefault (encodeScrollConfig defaultScrollConfig) cfgKey (storeInt s)
  pure (decodeScrollConfig bits)

setScrollConfig :: Context -> WidgetId -> ScrollConfig -> IO ()
setScrollConfig ctx wid cfg = do
  store <- getStore ctx
  let cfgKey = slotKey SlotScrollCfg (intKey wid)
      bits = encodeScrollConfig cfg
      prev = IM.findWithDefault (encodeScrollConfig defaultScrollConfig) cfgKey (storeInt store)
  when (prev /= bits) $
    setStore ctx (store {storeInt = IM.insert cfgKey bits (storeInt store)})

-- =============================================================================
-- Tuning
-- =============================================================================

-- | Wheel step and glide time for every scroller in this context.
getScrollTuning :: Context -> IO ScrollTuning
getScrollTuning ctx = ssTuning <$> readIORef (ctxScrollState ctx)

-- | Set the wheel step and glide time. Raising 'scrollWheelStep' makes the
-- wheel cover more ground per notch; a nonzero 'scrollSmoothTime' turns every
-- wheel notch and every 'ScrollSmooth' command into a glide.
setScrollTuning :: Context -> ScrollTuning -> IO ()
setScrollTuning ctx tuning =
  modifyIORef' (ctxScrollState ctx) $ \st -> st {ssTuning = tuning}

-- | This scroller's own wheel step, or @0@ when it follows the context's.
getScrollStep :: Context -> WidgetId -> IO Float
getScrollStep ctx wid = do
  s <- getStore ctx
  pure (IM.findWithDefault 0 (slotKey SlotScrollStep (intKey wid)) (storeFloat s))

-- | Give one scroller its own wheel step, in pixels per notch. @0@ puts it
-- back on the context's step. A list whose rows are a fixed height reads best
-- at a whole number of rows per notch.
setScrollStep :: Context -> WidgetId -> Float -> IO ()
setScrollStep ctx wid px = do
  store <- getStore ctx
  let key = slotKey SlotScrollStep (intKey wid)
      prev = IM.findWithDefault 0 key (storeFloat store)
  when (prev /= px) $
    setStore ctx (store {storeFloat = IM.insert key px (storeFloat store)})

-- | Pixels one wheel notch scrolls this scroller.
resolveScrollStep :: Context -> WidgetId -> IO Float
resolveScrollStep ctx wid = do
  own <- getScrollStep ctx wid
  if own > 0
    then pure own
    else max 1 . scrollWheelStep <$> getScrollTuning ctx

-- =============================================================================
-- Geometry
-- =============================================================================

-- | What a scroller looked like on the frame it was last laid out on.
-- Offsets and ranges are in window axes: @x@ rightwards, @y@ downwards,
-- whichever way the scroller itself is built.
data ScrollMetrics = ScrollMetrics
  { scrollViewport :: !Rect
  -- ^ The visible content, in window coordinates, inside padding and clear of
  -- the scrollbars.
  , scrollRange :: !V2
  -- ^ Largest offset each axis reaches. @0@ on an axis that does not scroll.
  , scrollOffset :: !V2
  -- ^ Where the scroller is now.
  , scrollAxes :: !ScrollAxes
  }
  deriving (Eq, Show)

-- | Geometry of the scroller @wid@, or 'Nothing' before it has been laid out.
-- Reads the last frame's layout, so it is safe to call while building the
-- next one.
getScrollMetrics :: Context -> WidgetId -> IO (Maybe ScrollMetrics)
getScrollMetrics ctx wid = do
  s <- getStore ctx
  let key = intKey wid
      point slot = IM.lookup (slotKey slot key) (storePoint s)
  case (point SlotScrollViewPos, point SlotScrollViewSize, point SlotScrollRange) of
    (Just (vx, vy), Just (vw, vh), Just (mx, my)) -> do
      let axes = decodeScrollAxes (IM.findWithDefault 0 (slotKey SlotScrollAxes key) (storeInt s))
      off <- getScrollOffsetIn ctx wid axes
      pure $
        Just
          ScrollMetrics
            { scrollViewport = Rect vx vy vw vh
            , scrollRange = V2 mx my
            , scrollOffset = off
            , scrollAxes = axes
            }
    _ -> pure Nothing

-- | Start a frame's geometry pass: the first scroll node to publish under a
-- widget id wins for that frame.
beginScrollMetrics :: Context -> IO ()
beginScrollMetrics ctx =
  modifyIORef' (ctxScrollState ctx) $ \st ->
    if IS.null (ssCached st) then st else st {ssCached = IS.empty}

-- | Record what the scroll pass measured, so the commands and the app can
-- read it between frames. Writes nothing when nothing moved, and nothing at
-- all for a second node sharing this one's widget id. A table's frozen pane
-- and its body share theirs, and letting both publish would rewrite the store
-- every frame and hand the commands a viewport that alternates between panes.
cacheScrollMetrics :: Context -> WidgetId -> ScrollAxes -> Rect -> V2 -> IO ()
cacheScrollMetrics ctx wid axes viewport range = do
  taken <- claimScrollMetrics ctx (intKey wid)
  unless taken (writeScrollMetrics ctx wid axes viewport range)

-- | Whether this widget id has already published geometry this frame; marks
-- it published if not.
claimScrollMetrics :: Context -> Int -> IO Bool
claimScrollMetrics ctx key = do
  st <- readIORef (ctxScrollState ctx)
  if IS.member key (ssCached st)
    then pure True
    else do
      writeIORef (ctxScrollState ctx) $! st {ssCached = IS.insert key (ssCached st)}
      pure False

writeScrollMetrics :: Context -> WidgetId -> ScrollAxes -> Rect -> V2 -> IO ()
writeScrollMetrics ctx wid axes (Rect vx vy vw vh) range@(V2 mx my) = do
  -- A range that just shrank (a filtered list, a narrower window) would leave
  -- a glide heading past the new end.
  clampScrollGlide ctx wid range
  store <- getStore ctx
  let key = intKey wid
      axesKey = slotKey SlotScrollAxes key
      posKey = slotKey SlotScrollViewPos key
      sizeKey = slotKey SlotScrollViewSize key
      rangeKey = slotKey SlotScrollRange key
      code = encodeScrollAxes axes
      points = storePoint store
      ints = storeInt store
      samePoint k v = IM.lookup k points == Just v
  unless
    ( samePoint posKey (vx, vy)
        && samePoint sizeKey (vw, vh)
        && samePoint rangeKey (mx, my)
        && IM.lookup axesKey ints == Just code
    )
    $ setStore ctx
      ( store
          { storePoint =
              IM.insert posKey (vx, vy) $
                IM.insert sizeKey (vw, vh) $
                  IM.insert rangeKey (mx, my) points
          , storeInt = IM.insert axesKey code ints
          }
      )

encodeScrollAxes :: ScrollAxes -> Int
encodeScrollAxes = \case
  ScrollAxisY -> 0
  ScrollAxisX -> 1
  ScrollAxisXY -> 2

decodeScrollAxes :: Int -> ScrollAxes
decodeScrollAxes = \case
  1 -> ScrollAxisX
  2 -> ScrollAxisXY
  _ -> ScrollAxisY

-- | A 1D row scroller keeps its offset in the main-axis slot, so window and
-- stored axes are swapped for it and identical for everything else. The swap
-- is its own inverse.
{-# INLINE swapAxes #-}
swapAxes :: ScrollAxes -> V2 -> V2
swapAxes ScrollAxisX (V2 x y) = V2 y x
swapAxes _ v = v

-- | This scroller's offset in window axes.
getScrollOffsetIn :: Context -> WidgetId -> ScrollAxes -> IO V2
getScrollOffsetIn ctx wid axes = swapAxes axes <$> getScrollOffset2D ctx wid

-- | Move a scroller to an offset in window axes, cancelling any glide. A 1D
-- scroller ignores the axis it does not scroll on.
setScrollOffsetIn :: Context -> WidgetId -> ScrollAxes -> V2 -> IO ()
setScrollOffsetIn ctx wid axes off = do
  cancelScrollGlide ctx wid
  writeScrollOffsetIn ctx wid axes off

writeScrollOffsetIn :: Context -> WidgetId -> ScrollAxes -> V2 -> IO ()
writeScrollOffsetIn ctx wid axes off =
  case axes of
    ScrollAxisXY -> writeScrollOffset2D ctx wid off
    ScrollAxisY -> writeScrollOffset ctx wid (v2Y off)
    ScrollAxisX -> writeScrollOffset ctx wid (v2X off)

-- =============================================================================
-- Commands
-- =============================================================================

-- | Whether a scroll lands on its target at once or glides onto it.
-- 'ScrollSmooth' still lands at once when the context's 'scrollSmoothTime' is
-- @0@, so one setting turns smooth scrolling on for the whole app.
data ScrollBehavior = ScrollInstant | ScrollSmooth
  deriving (Eq, Show)

-- | Where a widget ends up in the viewport once it is scrolled into view.
data ScrollAlign
  = -- | Move as little as possible: nothing at all when it is already whole.
    ScrollNearest
  | -- | Against the leading edge, at the top or left.
    ScrollStart
  | ScrollCenter
  | -- | Against the trailing edge, at the bottom or right.
    ScrollEnd
  deriving (Eq, Show)

-- | Scroll to an absolute offset, clamped to the scroller's range.
scrollTo :: Context -> WidgetId -> V2 -> ScrollBehavior -> IO ()
scrollTo ctx wid off behavior =
  withScrollMetrics ctx wid $ \m ->
    applyScrollTarget ctx wid (scrollAxes m) (clampScrollOffset (scrollRange m) off) behavior

-- | Scroll by a delta in pixels. Deltas accumulate onto a glide already in
-- flight, so repeated calls keep up rather than fighting each other.
scrollBy :: Context -> WidgetId -> V2 -> ScrollBehavior -> IO ()
scrollBy ctx wid delta behavior =
  withScrollMetrics ctx wid $ \m -> scrollMetricsBy ctx wid m delta behavior

-- | Scroll by whole viewports: @V2 0 1@ is one page down, @V2 0 (-0.5)@ half
-- a page up.
scrollPages :: Context -> WidgetId -> V2 -> ScrollBehavior -> IO ()
scrollPages ctx wid (V2 px py) behavior =
  withScrollMetrics ctx wid $ \m -> do
    let Rect _ _ vw vh = scrollViewport m
    scrollMetricsBy ctx wid m (V2 (px * vw) (py * vh)) behavior

scrollMetricsBy :: Context -> WidgetId -> ScrollMetrics -> V2 -> ScrollBehavior -> IO ()
scrollMetricsBy ctx wid m (V2 dx dy) behavior = do
  V2 bx by <- scrollTargetOffset ctx wid (scrollOffset m)
  applyScrollTarget ctx wid (scrollAxes m) (clampScrollOffset (scrollRange m) (V2 (bx + dx) (by + dy))) behavior

-- | Scroll back to the top (and left).
scrollToStart :: Context -> WidgetId -> ScrollBehavior -> IO ()
scrollToStart ctx wid = scrollTo ctx wid (V2 0 0)

-- | Scroll to the end of the content.
scrollToEnd :: Context -> WidgetId -> ScrollBehavior -> IO ()
scrollToEnd ctx wid behavior =
  withScrollMetrics ctx wid $ \m ->
    applyScrollTarget ctx wid (scrollAxes m) (scrollRange m) behavior

-- | Scroll @target@ into the viewport of the scroller @wid@ it is built
-- inside. Both widgets are read from the last frame's layout, so a widget
-- that was not built then, such as a row a virtualized list left out, cannot
-- be found; scroll to its content rectangle with 'scrollRectIntoView' instead.
scrollIntoView :: Context -> WidgetId -> WidgetId -> ScrollAlign -> ScrollBehavior -> IO ()
scrollIntoView ctx wid target align behavior = do
  mMetrics <- getScrollMetrics ctx wid
  mRect <- getPrevRect ctx target
  case (mMetrics, mRect) of
    (Just m, Just (Rect rx ry rw rh)) -> do
      let Rect vx vy _ _ = scrollViewport m
          V2 ox oy = scrollOffset m
      scrollRectIntoView ctx wid (Rect (rx - vx + ox) (ry - vy + oy) rw rh) align behavior
    _ -> pure ()

-- | Scroll a rectangle of the content into view. The rectangle is in content
-- coordinates: the origin is where the content starts, which is where the
-- viewport shows it at offset @0@.
scrollRectIntoView :: Context -> WidgetId -> Rect -> ScrollAlign -> ScrollBehavior -> IO ()
scrollRectIntoView ctx wid (Rect rx ry rw rh) align behavior =
  withScrollMetrics ctx wid $ \m -> do
    let Rect _ _ vw vh = scrollViewport m
        V2 ox oy = scrollOffset m
        V2 mx my = scrollRange m
        target =
          V2
            (clamp 0 mx (alignAxis align vw rx rw ox))
            (clamp 0 my (alignAxis align vh ry rh oy))
    applyScrollTarget ctx wid (scrollAxes m) target behavior

-- | Offset that puts a span of the content where @align@ asks for it.
alignAxis :: ScrollAlign -> Float -> Float -> Float -> Float -> Float
alignAxis align viewSize start size cur =
  case align of
    ScrollStart -> start
    ScrollEnd -> start + size - viewSize
    ScrollCenter -> start + (size - viewSize) / 2
    ScrollNearest
      | start < cur -> start
      | start + size > cur + viewSize -> min start (start + size - viewSize)
      | otherwise -> cur

-- | Hold an offset inside @0@ and the scroller's range on each axis.
{-# INLINE clampScrollOffset #-}
clampScrollOffset :: V2 -> V2 -> V2
clampScrollOffset (V2 mx my) (V2 x y) = V2 (clamp 0 mx x) (clamp 0 my y)

-- | Drop the axis a 1D scroller does not move on. A table's paired panes link
-- their cross offsets, so a vertical scroller can carry a horizontal offset it
-- does not own; a glide that watched it would never settle.
{-# INLINE projectAxes #-}
projectAxes :: ScrollAxes -> V2 -> V2
projectAxes axes (V2 x y) =
  case axes of
    ScrollAxisY -> V2 0 y
    ScrollAxisX -> V2 x 0
    ScrollAxisXY -> V2 x y

withScrollMetrics :: Context -> WidgetId -> (ScrollMetrics -> IO ()) -> IO ()
withScrollMetrics ctx wid act = getScrollMetrics ctx wid >>= mapM_ act

-- =============================================================================
-- Glide
-- =============================================================================

-- | Send a scroller to an offset in window axes, gliding if the caller asked
-- for it and the context is tuned for it. The target must already be clamped
-- to the scroller's range.
applyScrollTarget :: Context -> WidgetId -> ScrollAxes -> V2 -> ScrollBehavior -> IO ()
applyScrollTarget ctx wid axes target0 behavior = do
  st <- readIORef (ctxScrollState ctx)
  let smooth = scrollSmoothTime (ssTuning st)
      target = projectAxes axes target0
  if behavior == ScrollInstant || smooth <= 0
    then setScrollOffsetIn ctx wid axes target
    else do
      cur <- getScrollOffsetIn ctx wid axes
      if nearOffset (projectAxes axes cur) target
        then setScrollOffsetIn ctx wid axes target
        else
          writeIORef (ctxScrollState ctx) $!
            st {ssGlides = IM.insert (intKey wid) (ScrollGlide wid target axes) (ssGlides st)}

-- | Where the scroller is headed: the glide's target if one is in flight, and
-- @fallback@ (normally the current offset) if not. Deltas add onto this so
-- that notches arriving mid-glide are not swallowed.
scrollTargetOffset :: Context -> WidgetId -> V2 -> IO V2
scrollTargetOffset ctx wid fallback = do
  st <- readIORef (ctxScrollState ctx)
  pure (maybe fallback sgTarget (IM.lookup (intKey wid) (ssGlides st)))

scrollGliding :: Context -> WidgetId -> IO Bool
scrollGliding ctx wid =
  IM.member (intKey wid) . ssGlides <$> readIORef (ctxScrollState ctx)

-- | Hold a glide in flight inside a range that has just been measured again.
-- Without this a list filtered down mid-glide coasts past its new end and
-- stops there, showing nothing, until something else scrolls it.
clampScrollGlide :: Context -> WidgetId -> V2 -> IO ()
clampScrollGlide ctx wid range =
  modifyIORef' (ctxScrollState ctx) $ \st ->
    if IM.null (ssGlides st)
      then st
      else st {ssGlides = IM.adjust clampGlide (intKey wid) (ssGlides st)}
  where
    clampGlide g = g {sgTarget = projectAxes (sgAxes g) (clampScrollOffset range (sgTarget g))}

cancelScrollGlide :: Context -> WidgetId -> IO ()
cancelScrollGlide ctx wid =
  modifyIORef' (ctxScrollState ctx) $ \st ->
    if IM.null (ssGlides st)
      then st
      else st {ssGlides = IM.delete (intKey wid) (ssGlides st)}

-- | Advance every glide by @dt@ seconds. Each one covers the same fraction of
-- what is left every second, so a long throw starts fast and eases in, and at
-- least a pixel a frame so a glide cannot stall on the pixel grid the offsets
-- snap to.
stepScrollGlides :: Context -> Float -> IO ()
stepScrollGlides ctx dt = do
  st <- readIORef (ctxScrollState ctx)
  unless (IM.null (ssGlides st)) $ do
    let alpha = glideAlpha (scrollSmoothTime (ssTuning st)) dt
    done <- mapM (stepGlide ctx alpha) (IM.toList (ssGlides st))
    let settled = [k | (k, True) <- done]
    unless (null settled) $
      modifyIORef' (ctxScrollState ctx) $ \s ->
        s {ssGlides = foldr IM.delete (ssGlides s) settled}

-- | Fraction of the remaining distance a glide covers in @dt@ seconds.
-- 'scrollSmoothTime' is the time to cover all but a twentieth of it.
glideAlpha :: Float -> Float -> Float
glideAlpha smooth dt
  | smooth <= 0 || dt <= 0 = 1
  | otherwise = clamp 0 1 (1 - exp (negate (3 * dt / smooth)))

stepGlide :: Context -> Float -> (Int, ScrollGlide) -> IO (Int, Bool)
stepGlide ctx alpha (key, ScrollGlide wid target axes) = do
  cur <- projectAxes axes <$> getScrollOffsetIn ctx wid axes
  let next = V2 (stepAxis (v2X cur) (v2X target)) (stepAxis (v2Y cur) (v2Y target))
  writeScrollOffsetIn ctx wid axes next
  pure (key, nearOffset next target)
  where
    stepAxis c t
      | abs (t - c) <= 1 = t
      | otherwise =
          let moved = c + (t - c) * alpha
           in if abs (moved - c) < 1
                then c + signum (t - c)
                else moved

nearOffset :: V2 -> V2 -> Bool
nearOffset (V2 ax ay) (V2 bx by) = abs (ax - bx) <= 0.01 && abs (ay - by) <= 0.01