nano-ui-0.1.0.0: lib/NanoUI/Context.hs
-- | The 'Context' a view runs against, and the operations on its state:
-- focus, dirty flags and damage, the widget store, drawing and measure
-- caches, overlays, and host hooks. Backends and advanced widgets use this
-- module; views normally only need "NanoUI".
module NanoUI.Context
( Context (..)
, MeasureCacheKey
, TextInputMenu (..)
, TextInputDrag (..)
, TextFieldClickCell (..)
, WindowResizeEdge (..)
, WindowResizeDrag (..)
, DamageState (..)
, OverlayState (..)
, AnimationState (..)
, DrawingCacheState (..)
, DrawingEntry (..)
, DrawFitCache (..)
, SpanCacheEntry (..)
, WidgetTextCacheEntry (..)
, WidgetTextPlacement (..)
, InteractionState (..)
, initialInteractionState
, initialDamageState
, initialOverlayState
, initialAnimationState
, initialScrollState
, initialDrawingCacheState
, getsInteraction
, modifyInteraction
, getsOverlay
, modifyOverlay
, getsDamage
, modifyDamage
, getScrollDrag
, setTextInputDrag
, getTextInputMenu
, setTextInputMenu
, takeTextEditLastAction
, getMenuPointerGesture
, setMenuPointerGesture
, getWindowDrag
, getWindowResize
, intKey
, markDirty
, clearDirty
, isDirty
, setWakeLoop
, takeDamage
, DamageRequest (..)
, requestDamage
, damageWidget
, damageKey
, damageRect
, damagePeers
, damageFull
, registerPopupConfig
, lookupPopupConfig
, registerDrawing
, lookupDrawing
, cachedDrawingOps
, cachedWidgetLayout
, lookupDrawFitEnvelope
, pruneDrawOpCache
, CustomMeasureFn
, CustomDrawContext (..)
, CustomDrawBuild
, registerCustomDrawing
, lookupCustomDrawing
, cachedCustomDrawingOps
, refreshCustomDrawingOps
, drawingOpsStale
, CustomDrawingEntry (..)
, registerCustomMeasure
, lookupCustomMeasure
, registerCustomCursor
, lookupCustomCursor
, registerCustomDamageSlop
, lookupCustomDamageSlop
, resetDrawingScopeCache
, getStore
, setStore
, modifyStore
, getStoreBool
, writeStoreInt
, writeStoreFloat
, writeStoreBool
, adoptStoreInt
, adoptStoreFloat
, adoptStoreText
, recordStoreInt
, recordStoreFloat
, recordStoreText
, isDisabled
, newThemeScopes
, beginThemeScopes
, pushThemeScope
, themeScopesChanged
, scopeTheme
, scopeRawTheme
, currentTheme
, nodeTheme
, widgetTheme
, getScrollOffset
, setScrollOffset
, getScrollOffset2D
, setScrollOffset2D
, setScrollConfig
, defaultScrollConfig
, linkScrollAxes
, ScrollTuning (..)
, defaultScrollTuning
, getScrollTuning
, setScrollTuning
, getScrollStep
, setScrollStep
, resolveScrollStep
, ScrollAxes (..)
, ScrollMetrics (..)
, getScrollMetrics
, cacheScrollMetrics
, beginScrollMetrics
, getScrollOffsetIn
, setScrollOffsetIn
, ScrollBehavior (..)
, ScrollAlign (..)
, scrollTo
, scrollBy
, scrollPages
, scrollToStart
, scrollToEnd
, scrollIntoView
, scrollRectIntoView
, applyScrollTarget
, scrollTargetOffset
, scrollGliding
, clampScrollOffset
, cancelScrollGlide
, stepScrollGlides
, getPrevRect
, getPrevClipRect
, atlasTextureId
, registerImage
, registerImages
, lookupImageUv
, atlasSnapshot
, withFontMetrics
, withMonoFontMetrics
, withMeasureText
, withFontResolver
, wrapMeasureCache
, clearMeasureCache
, ensureMetricCaches
, hasCustomLayoutInputs
, withExternalText
, withTheme
, setTheme
, getTheme
, withClipboard
, enableMeasureCache
, setHost
, setDrawSnapScale
, setDrawSquareGeometry
, setDrawExternalText
, askHostIO
, pushMessage
, drainMessages
-- Constructors
, newContext
, newPixelHostContext
-- Focus
, getFocusId
, getFocusVisible
, getHotId
, registerFocusable
, getFocusables
-- Modal & Overlay
, textInputEditActive
, modalActive
, overlayConsumesQuit
, markEscapeConsumed
, pointerBlockedByModal
, pointerBlockedByOverlay
, armMenuPointerCapture
, seedFloatingPanel
, beginModal
, endModal
, beginFrameModal
, modalDamageFlip
-- Animation
, anyAnimating
, getLiveAnimations
, takeAnimSettled
, lookupAnimation
, getAnimRectless
, setAnimRectless
, startAnimation
, startAnimationEase
, startAnimationEaseDelay
, startSpring
, setAnimationValue
, tickAnimations
, getAnimationValue
, getAnimRest
, pruneAnimRest
, FrameMsg (..)
, decodeMessages
, reduceMessages
, reduceUpdates
, WidgetStore (..)
, bumpMirror
, slotKey
, Slot (..)
, boolInt
, intBool
, anySelectOpen
, isSelectOpen
, setSelectOpen
, closeSelects
, Ease (..)
, Animation (..)
, SpringParams (..)
, presetBouncy
, presetSmooth
, presetStiff
, applyEase
, easeSameSpec
, approxEq
, animInProgress
) where
import Control.Monad (foldM, forM, when)
import Data.Bits ((.&.))
import Data.ByteString (ByteString)
import Data.Dynamic (fromDynamic, toDyn)
import Data.HashMap.Strict (HashMap)
import Data.HashMap.Strict qualified as HashMap
import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
import Data.IntMap.Strict qualified as IM
import Data.Map.Strict qualified as Map
import Data.Primitive.PrimArray
( newPrimArray
, readPrimArray
, writePrimArray
, getSizeofMutablePrimArray
, resizeMutablePrimArray
)
import Data.Proxy (Proxy (..))
import Data.Text (Text)
import Data.Typeable (Typeable, typeOf, typeRep)
import Data.Word (Word8)
import Foreign.ForeignPtr (ForeignPtr)
import NanoUI.Animation
( Animation (..)
, Ease (..)
, SpringParams (..)
, animInProgress
, applyEase
, approxEq
, easeSameSpec
, presetBouncy
, presetSmooth
, presetStiff
)
import NanoUI.Atlas (atlasTextureId)
import NanoUI.Atlas qualified as Atlas
import NanoUI.Context.Animation
import NanoUI.Context.Core
import NanoUI.Context.Drawing
import NanoUI.Context.Overlay
import NanoUI.Context.Scroll
import NanoUI.Context.Types
( AnimationState (..)
, Context (..)
, CustomDrawBuild
, CustomDrawContext (..)
, CustomDrawingEntry (..)
, CustomMeasureFn
, DamageRequest (..)
, DamageState (..)
, DrawFitCache (..)
, DrawingCacheState (..)
, DrawingEntry (..)
, FrameMsg (..)
, InteractionState (..)
, MeasureCacheKey
, MetricSource (..)
, OverlayState (..)
, SpanCacheEntry (..)
, TextFieldClickCell (..)
, TextInputDrag (..)
, TextInputMenu (..)
, WidgetTextCacheEntry (..)
, WidgetTextPlacement (..)
, WindowResizeDrag (..)
, WindowResizeEdge (..)
, decodeMessages
, initialAnimationState
, initialScrollState
, initialDamageState
, initialDrawingCacheState
, initialInteractionState
, initialOverlayState
, intKey
, reduceMessages
, reduceUpdates
)
import NanoUI.Draw (newDrawArena)
import NanoUI.Draw qualified as Draw
import NanoUI.Font (FontMetrics, fmLineHeight, measureTextIO, monospaceMetrics, scaleFontMetrics)
import NanoUI.Frame.SpanArena (newSpanArena)
import NanoUI.Frame.Scroll.Geometry (defaultScrollConfig)
import NanoUI.Id (WidgetId (..), initialIdContext)
import NanoUI.Layout.Arena (getArenaScope, newNodeArena)
import NanoUI.Store
( WidgetStore (..)
, anySelectOpen
, boolInt
, bumpMirror
, closeSelects
, emptyWidgetStore
, intBool
, isSelectOpen
, ptrEq
, setSelectOpen
, Slot (..)
, slotKey
)
import NanoUI.Style (FontStyle, FontVariant (..), FontWeight, Theme, defaultLayout, defaultTheme)
import NanoUI.Types (ImageId)
{-# INLINE registerImage #-}
registerImage :: Context -> ImageId -> Int -> Int -> ByteString -> IO Bool
registerImage ctx iid w h px = do
ok <- Atlas.registerImage (ctxImageAtlas ctx) iid w h px
when ok (markDirty ctx)
pure ok
registerImages :: Foldable f => Context -> f (ImageId, Int, Int, ByteString) -> IO Bool
registerImages ctx = foldM register True
where
register ok (iid, w, h, px) = do
result <- registerImage ctx iid w h px
pure (ok && result)
{-# INLINE lookupImageUv #-}
lookupImageUv :: Context -> ImageId -> IO (Maybe (Float, Float, Float, Float))
lookupImageUv ctx = Atlas.lookupImageUv (ctxImageAtlas ctx)
{-# INLINE atlasSnapshot #-}
atlasSnapshot :: Context -> IO (Maybe (Int, Int, ForeignPtr Word8, Int))
atlasSnapshot ctx = Atlas.atlasSnapshot (ctxImageAtlas ctx)
-- | Metrics for a font variant scaled to line height @sz@, and the scale
-- factor applied (1 when @sz@ or the base line height is not positive).
{-# INLINE resolveScale #-}
resolveScale :: Context -> Float -> FontVariant -> (FontMetrics, Float)
resolveScale ctx sz var =
let baseFm = if var == FontMono then ctxMonoFontMetrics ctx else ctxFontMetrics ctx
scale =
if sz > 0 && fmLineHeight baseFm > 0
then sz / fmLineHeight baseFm
else 1.0
in (if scale /= 1.0 then scaleFontMetrics scale baseFm else baseFm, scale)
defaultResolveFont :: Context -> Float -> FontWeight -> FontStyle -> FontVariant -> IO (FontMetrics, Bool)
defaultResolveFont ctx sz _w _st var = pure (fst (resolveScale ctx sz var), False)
defaultResolveMeasure :: Context -> Float -> FontWeight -> FontStyle -> FontVariant -> Text -> IO (Float, Float)
defaultResolveMeasure ctx sz _w _st var txt
| var == FontMono = measureTextIO textFm txt
| scale /= 1.0 = (\(w, h) -> (w * scale, h * scale)) <$> ctxMeasureText ctx txt
| otherwise = ctxMeasureText ctx txt
where
(textFm, scale) = resolveScale ctx sz var
{-# INLINE withFontResolver #-}
withFontResolver ::
Context ->
(Float -> FontWeight -> FontStyle -> FontVariant -> IO (FontMetrics, Bool)) ->
(Float -> FontWeight -> FontStyle -> FontVariant -> Text -> IO (Float, Float)) ->
Context
withFontResolver ctx rf rm = trackMetricSource ctx {ctxResolveFont = rf, ctxResolveMeasure = rm}
withFontMetrics :: Context -> FontMetrics -> Context
withFontMetrics ctx fm =
let ctx' =
ctx
{ ctxFontMetrics = fm
, ctxMeasureText = measureTextIO fm
}
in trackMetricSource ctx'
{ ctxResolveFont = defaultResolveFont ctx'
, ctxResolveMeasure = defaultResolveMeasure ctx'
}
withMonoFontMetrics :: Context -> FontMetrics -> Context
withMonoFontMetrics ctx mono =
let ctx' = ctx {ctxMonoFontMetrics = mono}
in trackMetricSource ctx'
{ ctxResolveFont = defaultResolveFont ctx'
, ctxResolveMeasure = defaultResolveMeasure ctx'
}
withMeasureText :: Context -> (Text -> IO (Float, Float)) -> Context
withMeasureText ctx fn =
let ctx' = ctx {ctxMeasureText = fn}
in trackMetricSource ctx'
{ ctxResolveMeasure = defaultResolveMeasure ctx'
}
-- Keep the identity boxed. No structural callback comparison or unsafe pure
-- mutation is needed, and repeated frames with the same Context do no work.
trackMetricSource :: Context -> Context
trackMetricSource ctx =
ctx {ctxMetricSource = MetricSource
(ctxFontMetrics ctx) (ctxMonoFontMetrics ctx) (ctxMeasureText ctx)
(ctxResolveFont ctx) (ctxResolveMeasure ctx)}
-- | Called once before building a frame. Context configuration remains pure;
-- cache invalidation happens at the IO boundary, including when alternating
-- between differently configured Contexts that share their backing stores.
ensureMetricCaches :: Context -> IO ()
ensureMetricCaches ctx = do
-- Compare evaluated identities: passed unevaluated, the selector application
-- is a fresh thunk without optimisation, so the check would miss every
-- frame and force full damage with cleared caches.
let !current = ctxMetricSource ctx
previous <- readIORef (ctxLastMetricSource ctx)
case previous of
Just source | ptrEq source current -> pure ()
_ -> do
clearMeasureCache ctx
damageFull ctx
markDirty ctx
cacheMeasureText ::
IORef (HashMap MeasureCacheKey (Float, Float)) ->
Float ->
(Text -> IO (Float, Float)) ->
Text ->
IO (Float, Float)
cacheMeasureText ref scale base txt = do
let key = (txt, scale)
m <- readIORef ref
case HashMap.lookup key m of
Just sz -> pure sz
Nothing -> do
sz <- base txt
modifyIORef' ref (HashMap.insert key sz)
pure sz
wrapMeasureCache :: Float -> Context -> (Text -> IO (Float, Float)) -> Context
wrapMeasureCache scale ctx measure =
case ctxMeasureCache ctx of
Nothing -> trackMetricSource ctx {ctxMeasureText = measure}
Just ref -> trackMetricSource ctx {ctxMeasureText = cacheMeasureText ref scale measure}
-- | Drop text span, widget text and whole-layout caches and bump the metric
-- generation, for changes that alter how text lays out.
invalidateTextCaches :: Context -> IO ()
invalidateTextCaches ctx = do
writeIORef (ctxSpanCache ctx) IM.empty
writeIORef (ctxWidgetTextCache ctx) IM.empty
writeIORef (ctxLayoutCache ctx) Nothing
modifyIORef' (ctxMetricGen ctx) (+ 1)
clearMeasureCache :: Context -> IO ()
clearMeasureCache ctx = do
-- Store the evaluated source so 'ensureMetricCaches' can match its identity.
let !source = ctxMetricSource ctx
writeIORef (ctxLastMetricSource ctx) (Just source)
invalidateTextCaches ctx
case ctxMeasureCache ctx of
Just ref -> writeIORef ref HashMap.empty
Nothing -> pure ()
withExternalText :: Context -> Bool -> Context
withExternalText ctx ext = ctx {ctxExternalText = ext}
-- | Configure a context's theme. Goes through 'setTheme' so a theme swapped
-- between frames invalidates the caches keyed on it, drawing-op caches
-- included, instead of leaving widgets painting the previous theme.
withTheme :: Context -> Theme -> IO Context
withTheme ctx theme = do
setTheme ctx theme
pure ctx
setTheme :: Context -> Theme -> IO ()
setTheme ctx th = do
cur <- readIORef (ctxTheme ctx)
when (cur /= th) $ do
writeIORef (ctxTheme ctx) th
invalidateTextCaches ctx
damageFull ctx
markDirty ctx
getTheme :: Context -> IO Theme
getTheme ctx = readIORef (ctxTheme ctx)
withClipboard :: Context -> IO (Maybe Text) -> (Text -> IO Bool) -> Context
withClipboard ctx getter setter = ctx {ctxClipboardGet = getter, ctxClipboardSet = setter}
enableMeasureCache :: Context -> IO Context
enableMeasureCache ctx =
case ctxMeasureCache ctx of
Just _ -> pure ctx
Nothing -> do
ref <- newIORef HashMap.empty
pure ctx {ctxMeasureCache = Just ref, ctxMeasureText = cacheMeasureText ref 0 (ctxMeasureText ctx)}
{-# INLINE setHost #-}
setHost :: forall a. (Typeable a) => Context -> a -> IO ()
setHost ctx val = do
m <- readIORef (ctxHost ctx)
let k = typeOf val
writeIORef (ctxHost ctx) (Map.insert k (toDyn val) m)
-- | Set the device pixel scale used to snap geometry origins/endpoints to
-- whole pixels. The SDL backend calls this when the display scale is synced.
{-# INLINE setDrawSnapScale #-}
setDrawSnapScale :: Context -> Float -> IO ()
setDrawSnapScale ctx s = Draw.setDrawSnapScale (ctxDrawArena ctx) s
-- | Emit rounded shapes and AA strokes as flat, axis-aligned fills. Software
-- framebuffer hosts enable this so every primitive is a solid quad.
{-# INLINE setDrawSquareGeometry #-}
setDrawSquareGeometry :: Context -> Bool -> IO ()
setDrawSquareGeometry ctx = Draw.setDrawSquareGeometry (ctxDrawArena ctx)
-- | Skip text quads in the draw buffer. Hosts that rasterize text from the
-- collected text spans enable this.
{-# INLINE setDrawExternalText #-}
setDrawExternalText :: Context -> Bool -> IO ()
setDrawExternalText ctx = Draw.setDrawExternalText (ctxDrawArena ctx)
{-# INLINE askHostIO #-}
askHostIO :: forall a. (Typeable a) => Context -> IO (Maybe a)
askHostIO ctx = do
m <- readIORef (ctxHost ctx)
let k = typeRep (Proxy :: Proxy a)
pure (Map.lookup k m >>= fromDynamic)
{-# INLINE pushMessage #-}
pushMessage :: Context -> FrameMsg -> IO ()
pushMessage ctx msg = modifyIORef' (ctxMessages ctx) (msg :)
{-# INLINE drainMessages #-}
drainMessages :: Context -> IO [FrameMsg]
drainMessages ctx = do
msgs <- readIORef (ctxMessages ctx)
writeIORef (ctxMessages ctx) []
pure (reverse msgs)
-- =============================================================================
-- Constructors
-- =============================================================================
newContext :: IO Context
newContext = do
nodeArena <- newNodeArena
drawArena <- newDrawArena
ctxHotId <- newIORef (WidgetId 0)
ctxLastHotId <- newIORef (WidgetId 0)
ctxActiveId <- newIORef (WidgetId 0)
ctxClickedId <- newIORef (WidgetId 0)
ctxReleaseClickedId <- newIORef (WidgetId 0)
ctxPressPos <- newIORef Nothing
ctxRightPressPos <- newIORef Nothing
ctxFocusId <- newIORef (WidgetId 0)
ctxFocusVisible <- newIORef False
ctxStore <- newIORef emptyWidgetStore
ctxDamageState <- newIORef initialDamageState
ctxOverlayState <- newIORef initialOverlayState
ctxAnimationState <- newIORef initialAnimationState
ctxScrollState <- newIORef initialScrollState
ctxDrawingCache <- newIORef initialDrawingCacheState
ctxIdContext <- newIORef initialIdContext
ctxContainerStack <- newIORef []
ctxMessages <- newIORef []
let initCap = 64
ctxFocusables <- newIORef =<< newPrimArray initCap
ctxFocusablesCount <- newIORef 0
ctxSpanBase <- newSpanArena 64
ctxSpanOverlay <- newSpanArena 64
ctxInteractionState <- newIORef initialInteractionState
ctxImageAtlas <- Atlas.newImageAtlas
ctxWakeLoop <- newIORef Nothing
ctxHost <- newIORef Map.empty
ctxDefaultLayout <- newIORef defaultLayout
ctxTheme <- newIORef defaultTheme
ctxThemeScopes <- newIORef =<< newThemeScopes
ctxSpanCache <- newIORef IM.empty
ctxWidgetTextCache <- newIORef IM.empty
ctxLayoutCache <- newIORef Nothing
ctxMetricGen <- newIORef 0
ctxLastMetricSource <- newIORef Nothing
ctxPaintFull <- newIORef True
let fm0 = monospaceMetrics 12
ctx = Context
{ ctxNodeArena = nodeArena
, ctxDrawArena = drawArena
, ctxHotId
, ctxLastHotId
, ctxActiveId
, ctxClickedId
, ctxReleaseClickedId
, ctxPressPos
, ctxRightPressPos
, ctxFocusId
, ctxFocusVisible
, ctxStore
, ctxDamageState
, ctxOverlayState
, ctxAnimationState
, ctxScrollState
, ctxDrawingCache
, ctxIdContext
, ctxFontMetrics = fm0
, ctxMonoFontMetrics = fm0
, ctxMeasureText = measureTextIO fm0
, ctxResolveFont = defaultResolveFont ctx
, ctxResolveMeasure = defaultResolveMeasure ctx
, ctxMeasureCache = Nothing
, ctxSpanCache
, ctxWidgetTextCache
, ctxLayoutCache
, ctxMetricGen
, ctxMetricSource = InitialMetricSource
, ctxLastMetricSource
, ctxPaintFull
, ctxExternalText = False
, ctxTheme
, ctxThemeScopes
, ctxContainerStack
, ctxMessages
, ctxFocusables
, ctxFocusablesCount
, ctxSpanBase
, ctxSpanOverlay
, ctxInteractionState
, ctxClipboardGet = pure Nothing
, ctxClipboardSet = \_ -> pure False
, ctxImageAtlas
, ctxWakeLoop
, ctxHost
, ctxDefaultLayout
}
pure ctx
newPixelHostContext :: IO Context
newPixelHostContext = do
ctx0 <- newContext
ctx <- enableMeasureCache ctx0
withTheme (withExternalText (withFontMetrics ctx (monospaceMetrics 16)) True) defaultTheme
-- =============================================================================
-- Focus
-- =============================================================================
{-# INLINE getFocusId #-}
getFocusId :: Context -> IO WidgetId
getFocusId ctx = readIORef (ctxFocusId ctx)
-- | Whether the focused widget shows its focus ring: focus moved by keyboard
-- since the last pointer press.
{-# INLINE getFocusVisible #-}
getFocusVisible :: Context -> IO Bool
getFocusVisible ctx = readIORef (ctxFocusVisible ctx)
{-# INLINE getHotId #-}
getHotId :: Context -> IO WidgetId
getHotId ctx = readIORef (ctxHotId ctx)
-- | Add @wid@ to this frame's keyboard focus order, unless it is declared in a
-- disabled scope.
registerFocusable :: Context -> WidgetId -> IO ()
registerFocusable ctx wid = do
scope <- getArenaScope (ctxNodeArena ctx)
when (scope .&. 1 == 0) $ do
idx <- readIORef (ctxFocusablesCount ctx)
arr <- readIORef (ctxFocusables ctx)
cap <- getSizeofMutablePrimArray arr
arr' <-
if idx >= cap
then do
grown <- resizeMutablePrimArray arr (max 16 (cap * 2))
writeIORef (ctxFocusables ctx) grown
pure grown
else pure arr
writePrimArray arr' idx wid
writeIORef (ctxFocusablesCount ctx) (idx + 1)
{-# INLINE getFocusables #-}
getFocusables :: Context -> IO [WidgetId]
getFocusables ctx = do
count <- readIORef (ctxFocusablesCount ctx)
arr <- readIORef (ctxFocusables ctx)
forM [0 .. count - 1] (readPrimArray arr)