packages feed

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

-- | Per-widget drawing registrations and the caches derived from them.
module NanoUI.Context.Drawing
  ( registerPopupConfig
  , lookupPopupConfig
  , registerDrawing
  , lookupDrawing
  , cachedDrawingOps
  , cachedWidgetLayout
  , lookupDrawFitEnvelope
  , pruneDrawOpCache
  , registerCustomDrawing
  , lookupCustomDrawing
  , cachedCustomDrawingOps
  , refreshCustomDrawingOps
  , drawingOpsStale
  , registerCustomMeasure
  , lookupCustomMeasure
  , registerCustomCursor
  , lookupCustomCursor
  , registerCustomDamageSlop
  , lookupCustomDamageSlop
  , resetDrawingScopeCache
  , hasCustomLayoutInputs
  ) where

import Data.IORef (modifyIORef', readIORef)
import Data.IntMap.Strict (IntMap)
import Data.IntMap.Strict qualified as IM
import Data.Primitive.SmallArray (SmallArray, mapSmallArray')

import NanoUI.Context.Animation (isAnimatingKey)
import NanoUI.Context.Types
  ( Context (..)
  , CustomDrawBuild
  , CustomDrawContext (..)
  , CustomDrawOpCacheEntry (..)
  , CustomDrawingEntry (..)
  , CustomMeasureFn
  , DrawFitCache (..)
  , DrawOpCacheEntry (..)
  , DrawingCacheState (..)
  , DrawingEntry (..)
  , PopupConfig (..)
  , intKey
  )
import NanoUI.Draw (DrawOp, DrawingBuild, shiftDrawOp)
import NanoUI.Id (WidgetId)
import NanoUI.Input (UiCursorKind)
import NanoUI.Style (Layout)
import NanoUI.Types (PopupAnchor, PopupPlacement, Rect (..), rectH, rectW)

{-# INLINE lookupIn #-}
lookupIn :: (DrawingCacheState -> IntMap a) -> Context -> WidgetId -> IO (Maybe a)
lookupIn field ctx wid = IM.lookup (intKey wid) . field <$> readIORef (ctxDrawingCache ctx)

{-# INLINE registerIn #-}
registerIn ::
  (DrawingCacheState -> IntMap a) ->
  (IntMap a -> DrawingCacheState -> DrawingCacheState) ->
  Context ->
  WidgetId ->
  a ->
  IO ()
registerIn field setField ctx wid v =
  modifyIORef' (ctxDrawingCache ctx) $ \dc ->
    setField (IM.insert (intKey wid) v (field dc)) dc

{-# INLINE registerPopupConfig #-}
registerPopupConfig :: Context -> WidgetId -> PopupAnchor -> PopupPlacement -> Float -> IO ()
registerPopupConfig ctx wid anchor placement offset =
  registerIn dcsPopupConfigs (\m dc -> dc {dcsPopupConfigs = m}) ctx wid (PopupConfig anchor placement offset)

{-# INLINE lookupPopupConfig #-}
lookupPopupConfig :: Context -> WidgetId -> IO (Maybe (PopupAnchor, PopupPlacement, Float))
lookupPopupConfig ctx wid =
  fmap (\(PopupConfig anchor placement offset) -> (anchor, placement, offset))
    <$> lookupIn dcsPopupConfigs ctx wid

{-# INLINE registerDrawing #-}
registerDrawing :: Context -> WidgetId -> Int -> DrawingBuild -> IO ()
registerDrawing ctx wid content build =
  registerIn dcsDrawings (\m dc -> dc {dcsDrawings = m}) ctx wid (DrawingEntry content build)

{-# INLINE lookupDrawing #-}
lookupDrawing :: Context -> WidgetId -> IO (Maybe DrawingEntry)
lookupDrawing = lookupIn dcsDrawings

-- | One draw-op cache step. @hit@ holds the bounds and ops of an entry whose
-- key still matches. A same-size hit is reused, translated if the widget
-- moved; a miss or a resize uses @rebuilt@. Ops that differ from the entry are
-- written back with @store@.
serveOps ::
  Maybe (Rect, SmallArray DrawOp) ->
  Rect ->
  SmallArray DrawOp ->
  (SmallArray DrawOp -> IO ()) ->
  IO (SmallArray DrawOp)
serveOps hit rect rebuilt store =
  case hit of
    Just (r, ops)
      | rectW r == rectW rect && rectH r == rectH rect ->
          if rectX r == rectX rect && rectY r == rectY rect
            then pure ops
            else keep (mapSmallArray' (shiftDrawOp (rectX rect - rectX r) (rectY rect - rectY r)) ops)
    _ -> keep rebuilt
  where
    keep ops = store ops >> pure ops

-- | Rebuild draw ops when the content version or width/height change. A move
-- only translates. An unversioned drawing (content 0) additionally drops its
-- cache while the widget is animating, since it has no other invalidation
-- signal; versioned drawings are invalidated by their content key alone.
cachedDrawingOps :: Context -> WidgetId -> Int -> Rect -> DrawingBuild -> IO (SmallArray DrawOp)
cachedDrawingOps ctx wid content rect build = do
  let k = intKey wid
  animated <-
    if content == 0
      then isAnimatingKey ctx k
      else pure False
  cached <- IM.lookup k . dcsDrawOpCache <$> readIORef (ctxDrawingCache ctx)
  let hit = case cached of
        Just DrawOpCacheEntry {doeContent = c, doeBounds = r, doeOps = ops}
          | c == content && not animated -> Just (r, ops)
        _ -> Nothing
  serveOps hit rect (build rect) $ \ops ->
    modifyIORef' (ctxDrawingCache ctx) $ \s ->
      s {dcsDrawOpCache = IM.insert k (DrawOpCacheEntry content rect ops) (dcsDrawOpCache s)}

-- | Reuse a derived layout while envelope, font, content key, and caller layout match.
cachedWidgetLayout ::
  Context ->
  WidgetId ->
  Double ->
  Double ->
  Float ->
  Int ->
  Layout ->
  IO Layout ->
  IO Layout
cachedWidgetLayout ctx wid dw dh lh content incoming compute = do
  let k = intKey wid
  dc <- readIORef (ctxDrawingCache ctx)
  case IM.lookup k (dcsDrawFitCache dc) of
    Just e
      | dfcDw e == dw
          && dfcDh e == dh
          && dfcLh e == lh
          && dfcContent e == content
          && dfcIn e == incoming ->
          pure (dfcOut e)
    _ -> do
      out <- compute
      modifyIORef' (ctxDrawingCache ctx) $ \s ->
        s { dcsDrawFitCache = IM.insert k (DrawFitCache dw dh lh content incoming out) (dcsDrawFitCache s)
          , dcsDrawOpCache = IM.delete k (dcsDrawOpCache s)
          }
      pure out

lookupDrawFitEnvelope ::
  Context ->
  WidgetId ->
  Float ->
  Int ->
  Layout ->
  IO (Maybe (Double, Double))
lookupDrawFitEnvelope ctx wid lh content incoming = do
  cached <- lookupIn dcsDrawFitCache ctx wid
  pure $ case cached of
    Just e
      | dfcLh e == lh
          && dfcContent e == content
          && dfcIn e == incoming ->
          Just (dfcDw e, dfcDh e)
    _ -> Nothing

-- | Drop cached ops for drawings that did not rebuild this frame.
pruneDrawOpCache :: Context -> IO ()
pruneDrawOpCache ctx =
  modifyIORef' (ctxDrawingCache ctx) $ \dc ->
    let live = dcsDrawings dc
        customLive = dcsCustomDrawings dc
     in dc
          { dcsDrawOpCache = dcsDrawOpCache dc `IM.intersection` live
          , dcsCustomDrawOpCache = dcsCustomDrawOpCache dc `IM.intersection` customLive
          , dcsDrawFitCache = dcsDrawFitCache dc `IM.intersection` live
          }

{-# INLINE registerCustomDrawing #-}
registerCustomDrawing :: Context -> WidgetId -> Int -> CustomDrawBuild -> IO ()
registerCustomDrawing ctx wid content build =
  registerIn dcsCustomDrawings (\m dc -> dc {dcsCustomDrawings = m}) ctx wid (CustomDrawingEntry content build)

{-# INLINE lookupCustomDrawing #-}
lookupCustomDrawing :: Context -> WidgetId -> IO (Maybe CustomDrawingEntry)
lookupCustomDrawing = lookupIn dcsCustomDrawings

-- | Whether a cache entry was built from these inputs, leaving aside where the
-- widget sits: ops built at another origin translate rather than rebuild.
{-# INLINE customEntryMatches #-}
customEntryMatches :: CustomDrawOpCacheEntry -> Int -> Rect -> CustomDrawContext -> Int -> Bool
customEntryMatches e content rect cdc gen =
  cdeContent e == content
    && rectW (cdeBounds e) == rectW rect
    && rectH (cdeBounds e) == rectH rect
    && cdeHovered e == cdcHovered cdc
    && cdePressed e == cdcPressed cdc
    && cdeFocused e == cdcFocused cdc
    && cdeDisabled e == cdcDisabled cdc
    && cdeGen e == gen

-- | Draw ops for a custom widget's paint: the ops 'refreshCustomDrawingOps'
-- settled on this frame while every input still matches, translated if the
-- widget only moved, else a fresh build.
cachedCustomDrawingOps ::
  Context ->
  WidgetId ->
  Int ->
  Rect ->
  CustomDrawContext ->
  CustomDrawBuild ->
  IO (SmallArray DrawOp)
cachedCustomDrawingOps ctx wid content rect cdc build = do
  let k = intKey wid
  gen <- readIORef (ctxMetricGen ctx)
  cached <- IM.lookup k . dcsCustomDrawOpCache <$> readIORef (ctxDrawingCache ctx)
  let hit = case cached of
        Just e | customEntryMatches e content rect cdc gen -> Just (cdeBounds e, cdeOps e)
        _ -> Nothing
  serveOps hit rect (build cdc rect) (storeCustomDrawingOps ctx k content rect cdc gen)

-- | Settle a custom widget's ops for this frame and cache them for paint,
-- returning whether what it draws changed at an unchanged rect.
--
-- A widget that declares a content key is taken at its word, as a versioned
-- drawing is: an unchanged key with unchanged size, interaction state and
-- metrics neither rebuilds the ops nor repaints them, animating or not, so a
-- drawing that reads an animated value has to fold it into its key. One that
-- only moved keeps its ops too; paint translates them. Without a key (0) the
-- build can read anything (a sort flag, a fraction), and nothing but building
-- it shows that its output changed, so it is rebuilt and compared.
--
-- Whatever forced a rebuild, the ops it produced decide the damage, so a key
-- bumped without a visible change repaints nothing and a rebuild the key never
-- mentioned still repaints. A new, moved or resized widget reports no change:
-- rect damage covers it.
refreshCustomDrawingOps ::
  Context ->
  WidgetId ->
  Int ->
  Rect ->
  CustomDrawContext ->
  CustomDrawBuild ->
  IO Bool
refreshCustomDrawingOps ctx wid content rect cdc build = do
  let k = intKey wid
  gen <- readIORef (ctxMetricGen ctx)
  cached <- IM.lookup k . dcsCustomDrawOpCache <$> readIORef (ctxDrawingCache ctx)
  let keyed = content /= 0
  case cached of
    -- A keyed widget that only moved keeps its ops: paint translates them, and
    -- the move is damaged by the rect delta.
    Just e | keyed && customEntryMatches e content rect cdc gen -> pure False
    _ -> do
      let ops = build cdc rect
          -- Whatever made this frame rebuild - the key, the interaction state,
          -- a theme or font change - the ops are built now, so ask them
          -- directly rather than trusting the key for damage as well.
          changed = case cached of
            Just e | cdeBounds e == rect -> cdeOps e /= ops
            _ -> False
      storeCustomDrawingOps ctx k content rect cdc gen ops
      pure changed

storeCustomDrawingOps :: Context -> Int -> Int -> Rect -> CustomDrawContext -> Int -> SmallArray DrawOp -> IO ()
storeCustomDrawingOps ctx k content rect cdc gen ops =
  modifyIORef' (ctxDrawingCache ctx) $ \s ->
    let entry =
          CustomDrawOpCacheEntry
            content
            rect
            (cdcHovered cdc)
            (cdcPressed cdc)
            (cdcFocused cdc)
            (cdcDisabled cdc)
            gen
            ops
     in s {dcsCustomDrawOpCache = IM.insert k entry (dcsCustomDrawOpCache s)}

-- | Whether a versioned drawing's cached ops are for another version at the
-- same rect. Paint rebuilds them; the pixels they covered must repaint too,
-- and checking the version costs nothing next to building the ops here.
drawingOpsStale :: Context -> WidgetId -> Int -> Rect -> IO Bool
drawingOpsStale ctx wid content rect = do
  cached <- IM.lookup (intKey wid) . dcsDrawOpCache <$> readIORef (ctxDrawingCache ctx)
  pure $ case cached of
    Just DrawOpCacheEntry {doeContent = c, doeBounds = r} -> c /= content && r == rect
    Nothing -> False

{-# INLINE registerCustomMeasure #-}
registerCustomMeasure :: Context -> WidgetId -> CustomMeasureFn -> IO ()
registerCustomMeasure = registerIn dcsCustomMeasures (\m dc -> dc {dcsCustomMeasures = m})

{-# INLINE lookupCustomMeasure #-}
lookupCustomMeasure :: Context -> WidgetId -> IO (Maybe CustomMeasureFn)
lookupCustomMeasure = lookupIn dcsCustomMeasures

{-# INLINE registerCustomCursor #-}
registerCustomCursor :: Context -> WidgetId -> (CustomDrawContext -> UiCursorKind) -> IO ()
registerCustomCursor = registerIn dcsCustomCursors (\m dc -> dc {dcsCustomCursors = m})

{-# INLINE lookupCustomCursor #-}
lookupCustomCursor :: Context -> WidgetId -> IO (Maybe (CustomDrawContext -> UiCursorKind))
lookupCustomCursor = lookupIn dcsCustomCursors

{-# INLINE registerCustomDamageSlop #-}
registerCustomDamageSlop :: Context -> WidgetId -> Float -> IO ()
registerCustomDamageSlop = registerIn dcsCustomDamageSlop (\m dc -> dc {dcsCustomDamageSlop = m})

{-# INLINE lookupCustomDamageSlop #-}
lookupCustomDamageSlop :: Context -> WidgetId -> IO (Maybe Float)
lookupCustomDamageSlop = lookupIn dcsCustomDamageSlop

resetDrawingScopeCache :: Context -> IO ()
resetDrawingScopeCache ctx =
  modifyIORef' (ctxDrawingCache ctx) $ \dc ->
    dc
      { dcsDrawings = IM.empty
      , dcsPopupConfigs = IM.empty
      , dcsCustomMeasures = IM.empty
      , dcsCustomCursors = IM.empty
      , dcsCustomDrawings = IM.empty
      , dcsCustomDamageSlop = IM.empty
      }

-- | True when any node has a custom measure function, whose output is not
-- captured by the arena descriptor comparison, so whole-layout reuse must be
-- disabled for the frame.
hasCustomLayoutInputs :: Context -> IO Bool
hasCustomLayoutInputs ctx =
  not . IM.null . dcsCustomMeasures <$> readIORef (ctxDrawingCache ctx)