nano-ui-0.1.0.0: lib/NanoUI/Frame/Scroll.hs
{-# LANGUAGE DataKinds #-}
-- | Scroll input: offsets baked into the arena, wheel routing, and scrollbar
-- thumb drags and track jumps.
module NanoUI.Frame.Scroll
( applyScrollOffsets
, updateScrollWheel
, updateScrollDrag
, scrollBarsFor
, scrollBarLayout
, ScrollBarLayout (..)
) where
import Control.Applicative ((<|>))
import Control.Monad (forM_, void, when)
import Data.Foldable (find)
import Data.Maybe (fromMaybe)
import NanoUI.Context
( Context (..)
, ScrollAxes (..)
, ScrollBehavior (..)
, applyScrollTarget
, beginScrollMetrics
, cacheScrollMetrics
, clampScrollOffset
, getMenuPointerGesture
, getScrollDrag
, getScrollOffset
, getScrollOffset2D
, getScrollOffsetIn
, resolveScrollStep
, scrollTargetOffset
, setScrollOffset
, setScrollOffset2D
, nodeTheme
, InteractionState (..)
, getsInteraction
, modifyInteraction
)
import NanoUI.Frame.Hit (topmostModalAtMouse, topmostOverlayAtMouse)
import NanoUI.Frame.Node (ScrollNode (..), readScrollNode, scrollNodeViewport)
import NanoUI.Frame.Scroll.Geometry
( ScrollBarLayout (..)
, ScrollConfig
, borderContentClip
, decodeScrollConfig
, isScrollStyle2D
, scrollAxisRange
, scrollBarLayout
, scrollBarLayouts2D
, scrollChromeLane
, scrollChromeSuppressed
, scrollOffsetFromThumb
, scrollWheelSuppressed
)
import NanoUI.Frame.TextArea.Content (textAreaContentGeom)
import NanoUI.Frame.TextArea.Geometry (TextAreaBars (..), TextAreaScrollBarLayouts (..), textAreaBars, textAreaScrollBarLayouts)
import NanoUI.Id (WidgetId)
import NanoUI.Input (Input (..), inputMouseDown, inputMousePos, inputMousePressed, inputMouseReleased, inputScroll)
import NanoUI.Layout.Arena
( DirTag (..)
, NodeIdx
, NodeType (..)
, arenaCount
, findNodeM
, forChildNodes_
, getDirection
, getFirstChild
, getLayoutRect
, getNextSibling
, getNodeType
, getParent
, getRect
, getStyleIdx
, getWidgetId
, isFloatingNode
, isScrollNode
, setClipRect
, setRect
, snapshotLayoutRects
)
import NanoUI.Style (Padding (..), themePanel)
import NanoUI.Types (Rect (..), V2 (..), rectContains, rectIntersect, rectUnion)
applyScrollOffsets :: Context -> IO ()
applyScrollOffsets ctx = do
beginScrollMetrics ctx
snapshotLayoutRects (ctxNodeArena ctx)
-- A frame that added no widgets has no root to walk.
count <- arenaCount (ctxNodeArena ctx)
when (count > 0) $ do
(wx, wy, ww, wh) <- getRect (ctxNodeArena ctx) 0
transformSubtree ctx 0 0 0 (Rect wx wy ww wh)
transformSubtree :: Context -> NodeIdx -> Float -> Float -> Rect -> IO ()
transformSubtree ctx idx scrollX scrollY parentClip = do
let na = ctxNodeArena ctx
nt <- getNodeType na idx
(lx, ly, lw, lh) <- getLayoutRect na idx
let floating = isFloatingNode nt
(sx, sy) = if floating then (0, 0) else (scrollX, scrollY)
within r = fromMaybe parentClip (rectIntersect parentClip r)
(vx, vy, vw, vh) <-
if floating
then getRect na idx
else pure (lx + sx, ly + sy, lw, lh)
when (not floating) $ setRect na idx vx vy vw vh
(!childScrollX, !childScrollY, !childClip) <-
if isScrollNode nt
then do
(axes, viewport, range) <- scrollNodeGeometry ctx idx (Rect vx vy lw lh)
wid <- getWidgetId na idx
-- The only pass that sees a scroller's placed geometry. Everything
-- that scrolls one between frames reads it back from here.
cacheScrollMetrics ctx wid axes viewport range
V2 dx dy <- getScrollOffsetIn ctx wid axes
let clip = within viewport
setClipRect na idx clip
pure (sx - dx, sy - dy, clip)
else do
clip <-
case nt of
NodePanel -> do
theme <- nodeTheme ctx idx
pure (within (borderContentClip (themePanel theme) (Rect vx vy vw vh)))
_ -> pure $! if floating then Rect vx vy vw vh else parentClip
setClipRect na idx clip
pure (sx, sy, clip)
forChildNodes_ na idx $ \ci ->
transformSubtree ctx ci childScrollX childScrollY childClip
-- | Axes, content viewport and reachable offset range of the scroll container
-- at @idx@ placed at @rect@, in window axes. The wheel, the programmatic
-- commands and the transform pass all size a scroll off this, so a scroller
-- cannot disagree with itself about how far it reaches.
scrollNodeGeometry :: Context -> NodeIdx -> Rect -> IO (ScrollAxes, Rect, V2)
scrollNodeGeometry ctx idx (Rect x y w h) = do
sn@ScrollNode {snPad = pad, snContentMain = contentMain} <- readScrollNode (ctxNodeArena ctx) idx
let viewport = scrollNodeViewport sn x y w h
rangeH = scrollAxisRange contentMain (rectH viewport) (padB pad)
pure $
if sn2D sn
then (ScrollAxisXY, viewport, V2 (scrollAxisRange (snContentW sn) (rectW viewport) (padR pad)) rangeH)
else case snDir sn of
DirColumn -> (ScrollAxisY, viewport, V2 0 rangeH)
DirRow -> (ScrollAxisX, viewport, V2 (scrollAxisRange contentMain (rectW viewport) (padR pad)) 0)
updateScrollWheel :: Context -> Input -> IO ()
updateScrollWheel ctx inp = do
let scroll@(V2 wheelX wheelY) = inputScroll inp
when (wheelY /= 0 || wheelX /= 0) $ do
-- An open dropdown (select menu or combo suggestions) owns the wheel:
-- the combo widget scrolls its own window, and the scroller underneath
-- the floating list must not move with it.
mDrop <- getsInteraction ctx isOpenSelectDrop
let overDrop = maybe False (\(_, r) -> rectContains r (inputMousePos inp)) mDrop
when (not overDrop) $ do
mNode <- findScrollNodeUnderMouse ctx (inputMousePos inp)
forM_ mNode $ \idx -> do
wid <- getWidgetId (ctxNodeArena ctx) idx
void (tryApplyScrollWheelDelta ctx wid scroll)
applyCrossAxisScroll ctx idx scroll
-- Nested 2D: apply the unused axis to a paired scroller in the same panel.
-- Do not walk past panel/window/modal into the page scroller.
applyCrossAxisScroll :: Context -> NodeIdx -> V2 -> IO ()
applyCrossAxisScroll ctx idx scroll = do
dir <- getDirection (ctxNodeArena ctx) idx
mAnc <- walkOppositeAncestor ctx idx dir
case mAnc of
Just pwid -> void (tryApplyScrollWheelDelta ctx pwid scroll)
Nothing -> do
mDesc <- findOppositeScrollDescendant ctx idx dir
forM_ mDesc $ \dwid -> tryApplyScrollWheelDelta ctx dwid scroll
scrollCrossAxisStop :: NodeType -> Bool
scrollCrossAxisStop nt =
nt == NodePanel || nt == NodeWindow || nt == NodeModal
walkOppositeAncestor :: Context -> NodeIdx -> DirTag -> IO (Maybe WidgetId)
walkOppositeAncestor ctx idx childDir = do
p <- getParent (ctxNodeArena ctx) idx
if p < 0
then pure Nothing
else do
nt <- getNodeType (ctxNodeArena ctx) p
if scrollCrossAxisStop nt
then pure Nothing
else
if not (isScrollNode nt)
then walkOppositeAncestor ctx p childDir
else do
pdir <- getDirection (ctxNodeArena ctx) p
if pdir == childDir
then walkOppositeAncestor ctx p childDir
else Just <$> getWidgetId (ctxNodeArena ctx) p
findOppositeScrollDescendant :: Context -> NodeIdx -> DirTag -> IO (Maybe WidgetId)
findOppositeScrollDescendant ctx idx childDir = goChildren idx
where
want = if childDir == DirColumn then DirRow else DirColumn
goChildren parent = getFirstChild (ctxNodeArena ctx) parent >>= go
go ci
| ci < 0 = pure Nothing
| otherwise = do
nt <- getNodeType (ctxNodeArena ctx) ci
found <-
if isScrollNode nt
then do
d <- getDirection (ctxNodeArena ctx) ci
if d == want
then Just <$> getWidgetId (ctxNodeArena ctx) ci
else goChildren ci
else goChildren ci
case found of
Just w -> pure (Just w)
Nothing -> getNextSibling (ctxNodeArena ctx) ci >>= go
-- | Node owning scroller @wid@: its text area, or the first scroll container
-- with that id that the predicate does not rule out (table slave panes share
-- an id with their master). Thumb drags use the chrome predicate, since a
-- hidden bar has no lane to grab; the wheel uses the wider one, since a
-- hidden bar still scrolls.
scrollOwnerNode :: (ScrollConfig -> Bool -> DirTag -> Bool) -> Context -> WidgetId -> IO (Maybe NodeIdx)
scrollOwnerNode suppressed ctx wid =
findNodeM na $ \idx -> do
nt <- getNodeType na idx
if nt /= NodeTextArea && not (isScrollNode nt)
then pure False
else do
owner <- getWidgetId na idx
if owner /= wid
then pure False
else
if nt == NodeTextArea
then pure True
else do
si <- getStyleIdx na idx
dir <- getDirection na idx
pure (not (suppressed (decodeScrollConfig si) (isScrollStyle2D si) dir))
where
na = ctxNodeArena ctx
tryApplyScrollWheelDelta :: Context -> WidgetId -> V2 -> IO Bool
tryApplyScrollWheelDelta ctx wid (V2 wheelX wheelY) = do
mIdx <- scrollOwnerNode scrollWheelSuppressed ctx wid
case mIdx of
Nothing -> pure False
Just idx -> do
nt <- getNodeType na idx
(axes, range) <-
if nt == NodeTextArea
then do
(fm, field, contentW, contentH) <- textAreaContentGeom ctx idx
let bars = textAreaBars fm field contentW contentH
pure
( ScrollAxisXY
, V2 (max 0 (contentW - tabViewW bars)) (max 0 (contentH - tabViewH bars))
)
else do
(x, y, w, h) <- getRect na idx
(axes, _, range) <- scrollNodeGeometry ctx idx (Rect x y w h)
pure (axes, range)
step <- resolveScrollStep ctx wid
cur <- getScrollOffsetIn ctx wid axes
-- Notches land on where the scroller is headed, not on where it is, so
-- a flick mid-glide adds to the throw instead of restarting it.
base@(V2 baseX baseY) <- scrollTargetOffset ctx wid cur
let next = clampScrollOffset range (V2 (baseX + wheelX * step) (baseY + wheelY * step))
if next == base && next == cur
then pure False
else True <$ applyScrollTarget ctx wid axes next ScrollSmooth
where
na = ctxNodeArena ctx
findScrollNodeUnderMouse :: Context -> V2 -> IO (Maybe NodeIdx)
findScrollNodeUnderMouse ctx mouse = do
count <- arenaCount (ctxNodeArena ctx)
if count <= 0
then pure Nothing
else do
mModal <- topmostModalAtMouse ctx mouse
mTop <- topmostOverlayAtMouse ctx mouse
let start = fromMaybe 0 (mModal <|> mTop)
(x, y, w, h) <- getRect (ctxNodeArena ctx) start
queryScrollTarget ctx start mouse (Rect x y w h)
queryScrollTarget :: Context -> NodeIdx -> V2 -> Rect -> IO (Maybe NodeIdx)
queryScrollTarget ctx idx mouse parentClip = do
nt <- getNodeType (ctxNodeArena ctx) idx
mClipHere <- scrollHitClip ctx idx nt parentClip
case mClipHere of
Nothing -> pure Nothing
Just clip -> do
childHit <- walkScrollSiblings ctx idx mouse clip
case childHit of
Just hit -> pure (Just hit)
Nothing -> scrollHitSelf ctx idx nt mouse clip
walkScrollSiblings :: Context -> NodeIdx -> V2 -> Rect -> IO (Maybe NodeIdx)
walkScrollSiblings ctx parent mouse clip = getFirstChild (ctxNodeArena ctx) parent >>= go
where
go ci
| ci < 0 = pure Nothing
| otherwise = do
hit <- queryScrollTarget ctx ci mouse clip
case hit of
Just found -> pure (Just found)
Nothing -> getNextSibling (ctxNodeArena ctx) ci >>= go
scrollHitSelf :: Context -> NodeIdx -> NodeType -> V2 -> Rect -> IO (Maybe NodeIdx)
scrollHitSelf ctx idx nt mouse clip
| nt == NodeTextArea = do
(fm, field, contentW, contentH) <- textAreaContentGeom ctx idx
let bars = textAreaBars fm field contentW contentH
pure $ case rectIntersect clip field of
Just fclip
| visibleHit fclip && (tabVertical bars || tabHorizontal bars) -> Just idx
_ -> Nothing
| isScrollNode nt && visibleHit clip = pure (Just idx)
| otherwise = pure Nothing
where
visibleHit r@(Rect _ _ rw rh) = rw > 0 && rh > 0 && rectContains r mouse
-- Same clip stack as the span walk: scroll viewport (plus its bar lanes),
-- then panel bounds.
scrollHitClip :: Context -> NodeIdx -> NodeType -> Rect -> IO (Maybe Rect)
scrollHitClip ctx idx nt parentClip
| isScrollNode nt = do
(x, y, w, h) <- getRect na idx
sn <- readScrollNode na idx
let lane d = scrollChromeLane (snSlot sn) d x y w h (snPad sn)
viewport = scrollNodeViewport sn x y w h
hit
| sn2D sn = rectUnion viewport (rectUnion (lane DirColumn) (lane DirRow))
| otherwise = rectUnion viewport (lane (snDir sn))
pure (rectIntersect parentClip hit)
| nt == NodePanel = do
(x, y, w, h) <- getRect na idx
pure (rectIntersect parentClip (Rect x y w h))
| otherwise = pure (Just parentClip)
where
na = ctxNodeArena ctx
-- | Scrollbar layouts of the scroller at @idx@ (id @wid@), each paired with a
-- setter for that axis's offset that skips unchanged values. Covers text
-- areas and native 2D and 1D scroll containers; a 1D scroller with suppressed
-- chrome has none.
scrollBarsFor :: Context -> NodeIdx -> WidgetId -> IO [(DirTag, ScrollBarLayout, Float -> IO ())]
scrollBarsFor ctx idx wid = do
nt <- getNodeType na idx
if nt == NodeTextArea
then do
(fm, field, contentW, contentH) <- textAreaContentGeom ctx idx
cur@(V2 curX curY) <- getScrollOffset2D ctx wid
let layouts = textAreaScrollBarLayouts fm field contentW contentH curX curY
pure (axes2D cur (tasbVertical layouts) (tasbHorizontal layouts))
else do
(x, y, w, h) <- getRect na idx
ScrollNode slot cfg native2D dir pad contentMain contentW <- readScrollNode na idx
if native2D
then do
cur@(V2 offX offY) <- getScrollOffset2D ctx wid
let (mV, mH) = scrollBarLayouts2D slot cfg x y w h pad contentW contentMain offX offY
pure (axes2D cur mV mH)
else
if scrollChromeSuppressed cfg dir
then pure []
else do
off <- getScrollOffset ctx wid
pure
[ (dir, layout, \new -> when (new /= off) (setScrollOffset ctx wid new))
| Just layout <- [scrollBarLayout slot dir x y w h pad contentMain off]
]
where
na = ctxNodeArena ctx
axes2D (V2 curX curY) mV mH =
[(DirColumn, layout, \new -> when (new /= curY) (setScrollOffset2D ctx wid (V2 curX new))) | Just layout <- [mV]]
++ [(DirRow, layout, \new -> when (new /= curX) (setScrollOffset2D ctx wid (V2 new curY))) | Just layout <- [mH]]
updateScrollDrag :: Context -> Input -> IO ()
updateScrollDrag ctx inp
| inputMouseReleased inp = modifyInteraction ctx (\s -> s {isScrollDrag = Nothing})
| otherwise = do
gesture <- getMenuPointerGesture ctx
mDrag <- getScrollDrag ctx
case mDrag of
_ | gesture -> pure ()
Just (wid, dragDir, grabOff)
| inputMouseDown inp -> do
-- A hidden bar has no lane to grab.
bars <- maybe (pure []) (\idx -> scrollBarsFor ctx idx wid) =<< scrollOwnerNode (\cfg _ dir -> scrollChromeSuppressed cfg dir) ctx wid
forM_ bars $ \(dir, layout, setOffset) ->
when (dir == dragDir) $
setOffset (scrollOffsetFromThumb dir layout grabOff (inputMousePos inp))
Nothing | inputMousePressed inp -> tryStartScrollDrag ctx inp
_ -> pure ()
-- | Grab a thumb, or jump the thumb's center to a track press and keep
-- dragging from there.
tryStartScrollDrag :: Context -> Input -> IO ()
tryStartScrollDrag ctx inp = do
let mouse = inputMousePos inp
mIdx <- findScrollNodeUnderMouse ctx mouse
forM_ mIdx $ \hitIdx -> do
wid <- getWidgetId (ctxNodeArena ctx) hitIdx
bars <- maybe (pure []) (\idx -> scrollBarsFor ctx idx wid) =<< scrollOwnerNode (\cfg _ dir -> scrollChromeSuppressed cfg dir) ctx wid
forM_ (find (\(_, l, _) -> rectContains (sbThumb l) mouse || rectContains (sbTrack l) mouse) bars) $
\(dir, layout, setOffset) -> do
let thumb = sbThumb layout
along (V2 mx my) = if dir == DirColumn then my else mx
Rect tx ty tw th = thumb
if rectContains thumb mouse
then modifyInteraction ctx (\s -> s {isScrollDrag = Just (wid, dir, along mouse - along (V2 tx ty))})
else do
let half = along (V2 tw th) / 2
setOffset (scrollOffsetFromThumb dir layout half mouse)
modifyInteraction ctx (\s -> s {isScrollDrag = Just (wid, dir, half)})