packages feed

nano-ui-0.1.0.0: lib/NanoUI/Damage.hs

module NanoUI.Damage
  ( updatePrevRects
  , floatingPanelRects
  , FrameSnapshot (..)
  , writeDamage
  ) where

import Control.Monad (forM_, join, unless, when)
import Data.IORef (readIORef)
import Data.IntMap.Strict qualified as IM
import Data.IntSet qualified as IS
import Data.Maybe (fromMaybe, isJust)
import Data.Primitive.PrimArray (MutablePrimArray, newPrimArray, readPrimArray, writePrimArray)
import Data.Text (Text)
import GHC.Exts (RealWorld)
import NanoUI.Context
  ( Animation
  , Context (..)
  , DamageRequest (..)
  , WidgetStore (..)
  , getHotId
  , getLiveAnimations
  , getAnimRest
  , pruneAnimRest
  , getAnimRectless
  , getPrevRect
  , getStore
  , getWindowDrag
  , getWindowResize
  , intKey
  , markDirty
  , modalDamageFlip
  , setAnimRectless
  , takeAnimSettled
  , lookupCustomDamageSlop
  , lookupCustomDrawing
  , lookupDrawing
  , refreshCustomDrawingOps
  , drawingOpsStale
  , CustomDrawingEntry (..)
  , DrawingEntry (..)
  , DamageState (..)
  , OverlayState (..)
  , getsDamage
  , modifyDamage
  , modifyOverlay
  )
import NanoUI.Id (WidgetId (..), hashWidgetId)
import NanoUI.Input
  ( Input (..)
  , inputWindowSize
  )
import NanoUI.Frame.Hit (findNodeByKey)
import NanoUI.Store (eqByPtr, mirrorStoresChanged, ptrEq, slotKey, Slot (..))
import NanoUI.Layout.Arena
  ( NodeArena
  , NodeType (..)
  , SizingTag (..)
  , arenaCount
  , foldNodeRevM
  , getClipRect
  , getHeightSizing
  , getNodeType
  , getParent
  , getRect
  , getStyleIdx
  , getText
  , getWidgetId
  , getWidthSizing
  , isFloatingNode
  , isScrollNode
  )
import NanoUI.Frame.Scroll.Geometry (decodeScrollConfig, scrollBare)
import NanoUI.Widgets.Custom (mkCustomDrawContext)
import NanoUI.Types
  ( Damage (..)
  , Rect (..)
  , Size (..)
  , defaultDamageSlop
  , rectArea
  , rectFullyInside
  , rectInflate
  , rectIntersect
  , rectNonEmpty
  , rectUnion
  , resolveDamageRect
  )

layoutSettleMinArea :: Float
layoutSettleMinArea = 0.25

-- | Bound on how many consecutive rect-less frames a live animation may force a
-- full-window repaint. An animation whose widget is about to be laid out for the
-- first time gets a couple of frames of DamageFull cover; a perpetual animation
-- whose widget has left the arena (e.g. `keepAnimating` behind a tab switch)
-- must stop repainting the whole window once it is clearly gone.
orphanEscalateFrames :: Int
orphanEscalateFrames = 2

-- Partial retain clears with themeWindow. Expand interaction clips to the painted
-- panel/window backdrop so slop pixels get the correct fill, not window color.
backdropRectFromNode :: Context -> Int -> IO (Maybe Rect)
backdropRectFromNode ctx idx = walkAncestors step (ctxNodeArena ctx) idx
  where
    step i = do
      let na = ctxNodeArena ctx
      nt <- getNodeType na i
      if nt == NodePanel || isFloatingNode nt
        then getNonzeroRect na i
        else case nt of
          NodeScrollContainer -> do
            (wTag, _) <- getWidthSizing na i
            (hTag, _) <- getHeightSizing na i
            si <- getStyleIdx na i
            if (wTag == SizingGrow && hTag == SizingGrow) || scrollBare (decodeScrollConfig si)
              then pure Nothing
              else getNonzeroRect na i
          _ -> pure Nothing

{-# INLINE walkAncestors #-}
walkAncestors :: (Int -> IO (Maybe a)) -> NodeArena -> Int -> IO (Maybe a)
walkAncestors step arena idx = loop idx
  where
    loop i
      | i < 0 = pure Nothing
      | otherwise = do
          mr <- step i
          case mr of
            Just x -> pure (Just x)
            Nothing -> getParent arena i >>= loop

{-# INLINE getNonzeroRect #-}
getNonzeroRect :: NodeArena -> Int -> IO (Maybe Rect)
getNonzeroRect arena i = do
  (x, y, w, h) <- getRect arena i
  let r = Rect x y w h
  pure (if rectNonEmpty r then Just r else Nothing)

updatePrevRects :: Context -> IO ()
updatePrevRects ctx = do
  live <- getLiveAnimations ctx
  prevRectless <- getAnimRectless ctx
  oldRects <- getsDamage ctx dsPrevRects
  oldClips <- getsDamage ctx dsPrevClips
  oldTexts <- getsDamage ctx dsPrevNodeTexts
  let na = ctxNodeArena ctx
      bump rects = do
        rest <- getAnimRest ctx
        let rectless' =
              IM.fromSet
                (\k -> if IM.member k rects then 0 else IM.findWithDefault 0 k prevRectless + 1)
                (IM.keysSet live <> IM.keysSet rest)
            deadRest = IM.filterWithKey (\k _ -> IM.findWithDefault 0 k rectless' > 300) rest
        unless (IM.null deadRest) $
          pruneAnimRest ctx (\k -> IM.notMember k deadRest)
        -- Every key is live or resting, so this drops exactly the dead resting
        -- keys that are not live again.
        setAnimRectless ctx (rectless' `IM.difference` (deadRest `IM.difference` live))
  count <- arenaCount na
  if count <= 0
    then do
      modifyDamage ctx (\ds -> ds {dsPrevRects = IM.empty, dsPrevClips = IM.empty, dsPrevNodeTexts = IM.empty})
      bump IM.empty
    else do
      -- Walk the arena from base maps, touching only entries whose value
      -- changed. Seeded with last frame's maps, frames with stable rects
      -- (hover, text churn, animations) allocate nothing. The walk cannot
      -- delete keys that vanished from the arena, so when the key set changed
      -- it reruns from empty maps, where no key counts as old.
      let go olds !i !m !cm !tm !foundOld !dropped
            | i >= count =
                if dropped || foundOld /= IM.size olds
                  then go IM.empty 0 IM.empty IM.empty IM.empty 0 False
                  else do
                    modifyDamage ctx (\ds -> ds {dsPrevRects = m, dsPrevClips = cm, dsPrevNodeTexts = tm})
                    bump m
            | otherwise = do
                wid <- getWidgetId na i
                if hashWidgetId wid == 0
                  then go olds (i + 1) m cm tm foundOld dropped
                  else do
                    let !k = intKey wid
                        isOld = IM.member k olds
                    mRect <- getNonzeroRect na i
                    case mRect of
                      Nothing ->
                        let dropped' = dropped || isOld
                            m' = if isOld then IM.delete k m else m
                            cm' = if IM.member k cm then IM.delete k cm else cm
                            tm' = if IM.member k tm then IM.delete k tm else tm
                         in go olds (i + 1) m' cm' tm' foundOld dropped'
                      Just r -> do
                        mClip <- getClipRect na i
                        nt <- getNodeType na i
                        let !m' = if IM.lookup k m == Just r then m else IM.insert k r m
                            !cm' = case mClip of
                              Just c -> if IM.lookup k cm == Just c then cm else IM.insert k c cm
                              Nothing -> if IM.member k cm then IM.delete k cm else cm
                        -- Text nodes, and images, whose text is their image
                        -- id: switching an image repaints it like new text.
                        tm' <-
                          if nt == NodeText || nt == NodeImage
                            then do
                              txt <- getText na i
                              pure $! if IM.lookup k tm == Just txt then tm else IM.insert k txt tm
                            else pure $! if IM.member k tm then IM.delete k tm else tm
                        go olds (i + 1) m' cm' tm' (foundOld + if isOld then 1 else 0) dropped
      go oldRects 0 oldRects oldClips oldTexts 0 False

floatingPanelsInOrder :: Context -> IO [(Int, Rect)]
floatingPanelsInOrder ctx = foldNodeRevM na step []
  where
    na = ctxNodeArena ctx
    step acc idx = do
      nt <- getNodeType na idx
      if not (isFloatingNode nt)
        then pure acc
        else do
          wid <- getWidgetId na idx
          if hashWidgetId wid == 0
            then pure acc
            else do
              (x, y, w, h) <- getRect na idx
              pure ((intKey wid, Rect x y w h) : acc)

floatingPanelRects :: Context -> IO (IM.IntMap Rect)
floatingPanelRects ctx = IM.fromList <$> floatingPanelsInOrder ctx

-- | State 'NanoUI.Frame' captures before the UI pass; 'writeDamage' compares
-- it against the finished frame.
data FrameSnapshot = FrameSnapshot
  { fsWasDirty :: !Bool
  , fsSize :: !Size
  , fsStore :: !WidgetStore
  , fsHot :: !WidgetId
  , fsActive :: !WidgetId
  , fsFocus :: !WidgetId
  , fsHotRect :: !(Maybe Rect)
  , fsActiveRect :: !(Maybe Rect)
  , fsFocusRect :: !(Maybe Rect)
  , fsFloatingRects :: !(IM.IntMap Rect)
  , fsRects :: !(IM.IntMap Rect)
  , fsTexts :: !(IM.IntMap Text)
  , fsAnimKeys :: !IS.IntSet
  }

-- | What the finished frame looks like and what changed since the snapshot.
-- Derived fields stay lazy: a frame that is already 'DamageFull' for a cheap
-- reason never pays for them.
data FrameDelta = FrameDelta
  { fdWinSize :: !Size
  , fdOverlayOpen :: !Bool
  , fdStore :: !WidgetStore
  , fdRects :: !(IM.IntMap Rect)
  , fdTexts :: !(IM.IntMap Text)
  , fdFloatingRects :: !(IM.IntMap Rect)
  , fdModalFlip :: !Bool
  , fdLiveAnims :: !(IM.IntMap Animation)
  , fdRectless :: !(IM.IntMap Int)
  , fdWindowLive :: !Bool
  , fdRequests :: ![DamageRequest]
  , fdAnimLive :: Bool
  , fdFloatingChanged :: Bool
  , fdScrollChanged :: Bool
  , fdPointsChanged :: Bool
  , fdScrollOnly :: Bool
  -- ^ Only 'storeFloat' changed in the store, e.g. a floating pane scrolled.
  , fdSettledMoved :: !RectGroup
  -- ^ Changed key rects, clipped to their scroll viewports, that cover some
  -- area.
  , fdChurn :: !RectGroup
  -- ^ Rects of keys that left or joined the arena.
  , fdRedrawn :: ![Int]
  -- ^ Keys of drawings whose ops changed at an unchanged rect.
  }

writeDamage :: Context -> Input -> Bool -> FrameSnapshot -> IO ()
writeDamage ctx inp overlayOpen snap = do
  newStore <- getStore ctx
  panels <- floatingPanelsInOrder ctx
  newRects <- getsDamage ctx dsPrevRects
  newTexts <- getsDamage ctx dsPrevNodeTexts
  modalFlip <- modalDamageFlip ctx
  liveAnims <- getLiveAnimations ctx
  settled <- takeAnimSettled ctx
  rectless <- getAnimRectless ctx
  winDragActive <- isJust <$> getWindowDrag ctx
  winResizeActive <- isJust <$> getWindowResize ctx
  requests <- getsDamage ctx dsRequests
  redrawn <- refreshCustomDrawings ctx
  let oldRects = fsRects snap
      oldStore = fsStore snap
      newFloatingRects = IM.fromList panels
  (settledMoved, churn) <- rectDeltas ctx (map snd panels) oldRects newRects
  let scrollChanged = not (eqByPtr (storeFloat oldStore) (storeFloat newStore))
      delta =
        FrameDelta
          { fdWinSize = inputWindowSize inp
          , fdOverlayOpen = overlayOpen
          , fdStore = newStore
          , fdRects = newRects
          , fdTexts = newTexts
          , fdFloatingRects = newFloatingRects
          , fdModalFlip = modalFlip
          , fdLiveAnims = liveAnims
          , fdRectless = rectless
          , fdWindowLive = winDragActive || winResizeActive
          , fdRequests = requests
          , fdAnimLive = not (IM.null liveAnims) || settled
          , fdFloatingChanged = fsFloatingRects snap /= newFloatingRects
          , fdScrollChanged = scrollChanged
          , fdPointsChanged = not (eqByPtr (storePoint oldStore) (storePoint newStore))
          , fdScrollOnly =
              scrollChanged && oldStore == newStore {storeFloat = storeFloat oldStore}
          , fdSettledMoved = settledMoved
          , fdChurn = churn
          , fdRedrawn = redrawn
          }
  dmg <-
    if needsFullDamage snap delta
      then pure DamageFull
      else clipDamage ctx snap delta
  modifyDamage ctx (\ds -> ds {dsDamage = dmg, dsLastWindowSize = inputWindowSize inp, dsRequests = []})
  modifyOverlay ctx (\os -> os {osPrevFloatingRects = newFloatingRects, osPrevFloatingOrder = map fst panels})
  when modalFlip (markDirty ctx)
  when (fdFloatingChanged delta && not (IM.null (fsFloatingRects snap) && not (IM.null newFloatingRects))) $
    markDirty ctx

-- | Settle every drawing's ops for this frame and return the keys of those
-- that now draw something else at an unchanged rect. A drawing follows state
-- the arena does not hold, so nothing else damages it, and paint must not
-- replay the previous frame's ops for it. What this costs per widget is the
-- widget's own choice: see 'refreshCustomDrawingOps'.
refreshCustomDrawings :: Context -> IO [Int]
refreshCustomDrawings ctx = arenaCount na >>= \count -> go count 0 []
  where
    na = ctxNodeArena ctx
    go count !i acc
      | i >= count = pure acc
      | otherwise = do
          nt <- getNodeType na i
          if nt /= NodeDrawing
            then go count (i + 1) acc
            else do
              wid <- getWidgetId na i
              (x, y, w, h) <- getRect na i
              let rect = Rect x y w h
              mCustom <- lookupCustomDrawing ctx wid
              changed <- case mCustom of
                Just (CustomDrawingEntry content build) -> do
                  cdc <- mkCustomDrawContext ctx (ctxFontMetrics ctx) wid
                  refreshCustomDrawingOps ctx wid content rect cdc build
                Nothing -> do
                  -- A versioned drawing rebuilds in paint once its version
                  -- changes, but the pixels it covered still need damage. An
                  -- unversioned one is cached by contract, so it stays put.
                  mDrawing <- lookupDrawing ctx wid
                  case mDrawing of
                    Just (DrawingEntry content _) | content /= 0 -> drawingOpsStale ctx wid content rect
                    _ -> pure False
              go count (i + 1) (if changed then intKey wid : acc else acc)

-- | Whether the frame repaints the whole window rather than a clip.
needsFullDamage :: FrameSnapshot -> FrameDelta -> Bool
needsFullDamage snap d =
  ReqFull `elem` fdRequests d
    || not (fdScrollOnly d)
      && ( fsWasDirty snap
             || mirrorStoresChanged (fsStore snap) (fdStore d)
             || sizeChanged
             || fdOverlayOpen d
             || fdModalFlip d
             || fdFloatingChanged d
             || fdWindowLive d
             || (orphanAnim && fdAnimLive d)
             || keysChanged
             || layoutSettle
         )
    || (missingAnim && fdAnimLive d)
  where
    oldRects = fsRects snap
    newRects = fdRects d
    oldSize = fsSize snap
    sizeChanged = oldSize /= Size 0 0 && oldSize /= fdWinSize d
    recentlyRectless k = IM.findWithDefault 0 k (fdRectless d) < orphanEscalateFrames
    orphanAnim =
      any (\k -> IM.notMember k newRects && recentlyRectless k) (IM.keys (fdLiveAnims d))
    -- A live animation whose key has no rect this frame or last is not
    -- clipped: the retain texture may never have shown it.
    missingAnim =
      any
        (\k -> k /= 0 && IM.notMember k oldRects && IM.notMember k newRects && recentlyRectless k)
        (IS.toList (fsAnimKeys snap <> IM.keysSet (fdLiveAnims d)))
    keysChanged =
      not (IM.null oldRects)
        && rgAny (fdChurn d)
        && not (rgInPanels (fdChurn d))
    layoutSettle =
      not (IM.null oldRects)
        && rgAny (fdSettledMoved d)
        && not (fdAnimLive d)
        && not (fdScrollChanged d)
        && not (rgInPanels (fdSettledMoved d))

-- | The clip covering everything that changed, or 'DamageFull' once that clip
-- exceeds half the window.
clipDamage :: Context -> FrameSnapshot -> FrameDelta -> IO Damage
clipDamage ctx snap d = do
  let oldRects = fsRects snap
      newRects = fdRects d
      Size winW winH = fdWinSize d
      oldOf wid
        | wid == fsHot snap = fsHotRect snap
        | wid == fsActive snap = fsActiveRect snap
        | wid == fsFocus snap = fsFocusRect snap
        | otherwise = Nothing
  acc <- newRectUnion
  resolveDamageRequests ctx acc oldRects newRects (fdRequests d)
  -- Backdrop expansion covers interaction slop (hover/press halos) and
  -- explicit damage requests. Animation keys must not expand to their panel
  -- backdrop: an animated widget inside a large panel would damage the whole
  -- panel every frame, and once that union crosses half the window the frame
  -- degrades to DamageFull. The scissored replay redraws the backdrop fill
  -- inside the anim's own rect+slop, so no stale pixels remain.
  let addBackdrop k =
        unless (k == 0) $
          findNodeByKey ctx k
            >>= maybe (pure Nothing) (backdropRectFromNode ctx)
            >>= mapM_ (addRect acc . clipRectToWindow winW winH)
      addInteraction wid = do
        when (hashWidgetId wid /= 0) $ do
          newR <- getPrevRect ctx wid
          slop <- fromMaybe defaultDamageSlop <$> lookupCustomDamageSlop ctx wid
          let addSide = mapM_ (\r -> clipKeyRect ctx (intKey wid) (rectInflate slop r) >>= mapM_ (addRect acc))
          addSide (oldOf wid)
          addSide newR
        addBackdrop (intKey wid)
      -- A parked pointer must not re-damage its hot widget every frame: only
      -- an id change (hover in/out, press, focus move) or a rect move
      -- repaints. Unchanged interaction rects kept the steady state at
      -- DamageFull whenever the hot widget sat inside a panel whose backdrop
      -- covered over half the window.
      role oldW oldR newW = do
        newR <- getPrevRect ctx newW
        when (oldR /= newR) $ addInteraction oldW >> addInteraction newW
  role (fsHot snap) (fsHotRect snap) =<< getHotId ctx
  role (fsActive snap) (fsActiveRect snap) =<< readIORef (ctxActiveId ctx)
  role (fsFocus snap) (fsFocusRect snap) =<< readIORef (ctxFocusId ctx)
  forM_ (fdRequests d) $ \case
    ReqKey k _ -> addBackdrop k
    _ -> pure ()
  when (fdScrollChanged d || fdPointsChanged d) $
    scrollOffsetDamage ctx acc (fsStore snap) (fdStore d)
  let addAnim k =
        unless (k == 0) $
          forM_ [IM.lookup k oldRects, IM.lookup k newRects] $
            mapM_ (\r -> clipKeyRect ctx k (rectInflate defaultDamageSlop r) >>= mapM_ (addRect acc))
  IS.foldr (\k rest -> addAnim k >> rest) (pure ()) (fsAnimKeys snap)
  IM.foldrWithKey
    (\k _ rest -> unless (IS.member k (fsAnimKeys snap)) (addAnim k) >> rest)
    (pure ())
    (fdLiveAnims d)
  -- Same-key text changes that keep the rect (monospace counters, refreshed
  -- readouts) still repaint: rect-delta damage alone would leave them stale.
  -- New text keys inside floating panels also land here; outside panels the
  -- keysChanged predicate already forces full damage. updatePrevRects keeps
  -- last frame's map when no text changed.
  let addText k =
        forM_ (IM.lookup k newRects) $ \r -> do
          -- An image that switched to another image keeps its size, so only
          -- its own rect repaints. A text change can reflow the enclosing
          -- scroller's content and reactivate/resize its chrome (thumb, caps)
          -- outside the text rect; damage the scroll node's full rect so the
          -- lane repaints.
          addRect acc r
          mIdx <- findNodeByKey ctx k
          isImage <- maybe (pure False) (fmap (== NodeImage) . getNodeType (ctxNodeArena ctx)) mIdx
          unless isImage $ scrollAncestorRect ctx k >>= mapM_ (addRect acc)
  unless (ptrEq (fdTexts d) (fsTexts snap)) $
    IM.foldrWithKey (\k _ rest -> addText k >> rest) (pure ()) $
      IM.mergeWithKey
        (\_ new old -> if new /= old then Just () else Nothing)
        (IM.map (const ()))
        (const IM.empty)
        (fdTexts d)
        (fsTexts snap)
  -- Drawings redrawn in place repaint their own rects, like a text change
  -- that keeps its rect.
  forM_ (fdRedrawn d) $ \k ->
    forM_ (IM.lookup k newRects) $ \r -> clipKeyRect ctx k r >>= mapM_ (addRect acc)
  unless (fdScrollOnly d) $ addGroup acc (fdSettledMoved d)
  -- Keys that left repaint as the current backdrop over their old rects. Keys
  -- that arrived must repaint inside their new rects too: the retain texture
  -- has never shown that content, and nothing else covers it (mirror writes
  -- escalate these frames to DamageFull, but layout-driven churn inside
  -- floating panels does not).
  addGroup acc (fdChurn d)
  -- Floating panels that moved, opened or closed repaint where they were and
  -- where they are.
  let addFloating other k r rest = unless (IM.lookup k other == Just r) (addRect acc r) >> rest
  IM.foldrWithKey (addFloating (fdFloatingRects d)) (pure ()) (fsFloatingRects snap)
  IM.foldrWithKey (addFloating (fsFloatingRects snap)) (pure ()) (fdFloatingRects d)
  base <- readRectUnion acc
  let clip = clipRectToWindow winW winH base
      winArea = winW * winH
  -- A live animation with an empty clip is not DamageFull: its
  -- key was either scroll-clipped out of view (nothing visible
  -- changes; scrolling back in damages via the scroll delta) or
  -- rect-less, which missingAnim already promoted to full.
  pure $
    if winArea > 0 && rectArea clip > winArea * 0.5
      then DamageFull
      else DamageClip clip

resolveDamageRequests ::
  Context ->
  RectUnion ->
  IM.IntMap Rect ->
  IM.IntMap Rect ->
  [DamageRequest] ->
  IO ()
resolveDamageRequests ctx acc oldRects newRects reqs =
  forM_ reqs $ \case
    ReqFull -> pure ()
    ReqRect r -> addRect acc r
    ReqWidget wid bounds -> resolveKey (intKey wid) bounds
    ReqKey k bounds -> resolveKey k bounds
    ReqPeers wids bounds -> forM_ wids $ \wid -> resolveKey (intKey wid) bounds
  where
    resolveKey k bounds =
      forM_ [IM.lookup k oldRects, IM.lookup k newRects] $
        mapM_ $ \r -> do
          clipped <- clipDeltaToScrollViewport ctx k (resolveDamageRect bounds r)
          when (rectNonEmpty clipped) $ addRect acc clipped

-- | A running union of rects, as @x0, y0, x1, y1@ followed by how many of
-- them lie outside every floating panel. The bounds start inverted, so the
-- first rect sets them and an empty union reads back as the zero rect.
newtype RectUnion = RectUnion (MutablePrimArray RealWorld Float)

newRectUnion :: IO RectUnion
newRectUnion = do
  a <- newPrimArray 5
  writePrimArray a 0 infinity
  writePrimArray a 1 infinity
  writePrimArray a 2 (-infinity)
  writePrimArray a 3 (-infinity)
  writePrimArray a 4 0
  pure (RectUnion a)
  where
    infinity = 1 / 0

{-# INLINE addRect #-}
addRect :: RectUnion -> Rect -> IO ()
addRect (RectUnion a) (Rect x y w h) = do
  x0 <- readPrimArray a 0
  y0 <- readPrimArray a 1
  x1 <- readPrimArray a 2
  y1 <- readPrimArray a 3
  writePrimArray a 0 (min x0 x)
  writePrimArray a 1 (min y0 y)
  writePrimArray a 2 (max x1 (x + w))
  writePrimArray a 3 (max y1 (y + h))

readRectUnion :: RectUnion -> IO Rect
readRectUnion (RectUnion a) = do
  x0 <- readPrimArray a 0
  y0 <- readPrimArray a 1
  x1 <- readPrimArray a 2
  y1 <- readPrimArray a 3
  pure $! if x0 > x1 then Rect 0 0 0 0 else Rect x0 y0 (x1 - x0) (y1 - y0)

-- | A set of rects reduced to what damage needs from it.
data RectGroup = RectGroup
  { rgAny :: !Bool
  , rgInPanels :: !Bool
  -- ^ Some floating panel fully contains each rect; False for an empty group.
  , rgBounds :: !Rect
  }

addGroup :: RectUnion -> RectGroup -> IO ()
addGroup acc g = when (rgAny g) $ addRect acc (rgBounds g)

-- | One pass over the keys whose rect changed, reduced to the settled moves
-- (clipped to scroll viewports, above 'layoutSettleMinArea') and the keys
-- that left or joined the arena.
rectDeltas :: Context -> [Rect] -> IM.IntMap Rect -> IM.IntMap Rect -> IO (RectGroup, RectGroup)
rectDeltas ctx panelRects old new
  | ptrEq old new = pure (emptyGroup, emptyGroup)
  | otherwise = do
      settled <- newRectUnion
      churn <- newRectUnion
      let note acc@(RectUnion a) r = do
            addRect acc r
            unless (any (rectFullyInside r) panelRects) $
              readPrimArray a 4 >>= writePrimArray a 4 . (+ 1)
      IM.foldrWithKey
        ( \k r rest -> do
            when (rectNonEmpty r) $ do
              when (IM.notMember k new || IM.notMember k old) $ note churn r
              clipped <- clipDeltaToScrollViewport ctx k r
              when (rectArea clipped >= layoutSettleMinArea) $ note settled clipped
            rest
        )
        (pure ())
        (IM.mergeWithKey (\_ a b -> if a /= b then Just (rectUnion a b) else Nothing) id id old new)
      (,) <$> freeze settled <*> freeze churn
  where
    emptyGroup = RectGroup False False (Rect 0 0 0 0)
    freeze acc@(RectUnion a) = do
      bounds <- readRectUnion acc
      x0 <- readPrimArray a 0
      x1 <- readPrimArray a 2
      outside <- readPrimArray a 4
      let !present = x0 <= x1
      pure (RectGroup present (present && not (null panelRects) && outside == 0) bounds)

clipDeltaToScrollViewport :: Context -> Int -> Rect -> IO Rect
clipDeltaToScrollViewport ctx k r = do
  findNodeByKey ctx k >>= \case
    Nothing -> pure r
    Just idx -> do
      mClip <- getClipRect (ctxNodeArena ctx) idx
      pure $
        case mClip of
          Nothing -> r
          Just clip -> fromMaybe (Rect 0 0 0 0) (rectIntersect r clip)

clipRectToWindow :: Float -> Float -> Rect -> Rect
clipRectToWindow winW winH r =
  fromMaybe (Rect 0 0 0 0) (rectIntersect r (Rect 0 0 winW winH))

clipKeyRect :: Context -> Int -> Rect -> IO (Maybe Rect)
clipKeyRect ctx k r
  | k == 0 = pure (Just r)
  | otherwise = do
      clipped <- clipDeltaToScrollViewport ctx k r
      pure (if rectNonEmpty clipped then Just clipped else Nothing)

-- | Rect of the nearest scroll-container ancestor of a keyed node, covering
-- the content viewport and the scrollbar lane its chrome paints in. The walk
-- stops at the first scroll node even when its rect is empty.
scrollAncestorRect :: Context -> Int -> IO (Maybe Rect)
scrollAncestorRect ctx k =
  findNodeByKey ctx k >>= maybe (pure Nothing) (fmap join . walkAncestors step na)
  where
    na = ctxNodeArena ctx
    step i = do
      nt <- getNodeType na i
      if isScrollNode nt
        then Just <$> getNonzeroRect na i
        else pure Nothing

scrollOffsetDamage :: Context -> RectUnion -> WidgetStore -> WidgetStore -> IO ()
scrollOffsetDamage ctx acc oldStore newStore =
  unless (IM.null changedKeys) $ do
    -- Every store key that holds a scroll node's offset, mapped to the first
    -- such node. Built once, only on frames where an offset changed.
    owners <- foldNodeRevM na addOwner IM.empty
    IM.foldrWithKey
      ( \k _ rest -> do
          forM_ (IM.lookup k owners) $ \idx -> do
            -- The scroll node's rect covers the content viewport AND the
            -- scrollbar lane: offset changes move the thumb, which paints
            -- outside the content clip.
            getNonzeroRect na idx >>= mapM_ (addRect acc)
            floatingAncestorRect ctx idx >>= mapM_ (addRect acc)
          rest
      )
      (pure ())
      changedKeys
  where
    na = ctxNodeArena ctx
    -- Floating-pane offsets live in storeFloat; wheel/keyboard offsets
    -- live under the SlotTextAreaScroll slot in storePoint. Both move the
    -- scroller's content and its chrome. New or removed float offsets only
    -- count when nonzero.
    changedKeys =
      changedKeysWith (fmap (const ()) . IM.filter (/= 0)) (storeFloat oldStore) (storeFloat newStore)
        `IM.union` changedKeysWith (fmap (const ())) (storePoint oldStore) (storePoint newStore)
    changedKeysWith :: Eq a => (IM.IntMap a -> IM.IntMap ()) -> IM.IntMap a -> IM.IntMap a -> IM.IntMap ()
    changedKeysWith oneSided old new =
      IM.mergeWithKey (\_ a b -> if a /= b then Just () else Nothing) oneSided oneSided old new
    addOwner m idx = do
      nt <- getNodeType na idx
      if not (isScrollNode nt)
        then pure m
        else do
          wid <- getWidgetId na idx
          let widKey = intKey wid
          pure $
            IM.insert widKey idx $
              IM.insert (slotKey SlotScrollCross widKey) idx $
                IM.insert (slotKey SlotTextAreaScroll widKey) idx m

floatingAncestorRect :: Context -> Int -> IO (Maybe Rect)
floatingAncestorRect ctx idx =
  walkAncestors check (ctxNodeArena ctx) idx
  where
    check i = do
      nt <- getNodeType (ctxNodeArena ctx) i
      if isFloatingNode nt
        then getNonzeroRect (ctxNodeArena ctx) i
        else pure Nothing