nano-ui-0.1.0.0: lib/NanoUI/Context/Core.hs
-- | Accessors the other Context modules build on: interaction, overlay and
-- damage state, damage requests, the dirty flag, and widget store writes.
module NanoUI.Context.Core
( getsInteraction
, modifyInteraction
, getsOverlay
, modifyOverlay
, getsDamage
, modifyDamage
-- Interaction
, getScrollDrag
, setTextInputDrag
, getTextInputMenu
, setTextInputMenu
, takeTextEditLastAction
, getMenuPointerGesture
, setMenuPointerGesture
, getWindowDrag
, getWindowResize
-- Damage
, markDirty
, clearDirty
, isDirty
, setWakeLoop
, takeDamage
, requestDamage
, damageWidget
, damageKey
, damageRect
, damagePeers
, damageFull
, getPrevRect
, getPrevClipRect
-- Store
, getStore
, setStore
, modifyStore
, getStoreBool
, writeStoreInt
, writeStoreFloat
, writeStoreBool
, adoptStoreInt
, adoptStoreFloat
, adoptStoreText
, recordStoreInt
, recordStoreFloat
, recordStoreText
, isDisabled
-- Theme scopes
, newThemeScopes
, beginThemeScopes
, pushThemeScope
, themeScopesChanged
, scopeTheme
, scopeRawTheme
, currentTheme
, nodeTheme
, widgetTheme
) where
import Control.Monad (forM_, when)
import Data.Bits (shiftR, (.&.))
import Data.IORef (modifyIORef', readIORef, writeIORef)
import Data.Primitive.SmallArray (copySmallMutableArray, newSmallArray, readSmallArray, getSizeofSmallMutableArray, writeSmallArray)
import Data.IntMap.Strict (IntMap)
import Data.IntMap.Strict qualified as IM
import Data.Text (Text)
import NanoUI.Context.Types
( Context (..)
, DamageRequest (..)
, DamageState (..)
, InteractionState (..)
, OverlayState
, TextInputDrag
, TextInputMenu
, ThemeScopes (..)
, WindowResizeDrag
, intKey
)
import NanoUI.Id (WidgetId, hashWidgetId)
import NanoUI.Layout.Arena (DirTag, NodeIdx, getArenaScope, getNodeScope, getScopeSignature, lookupNodeByWidgetId)
import NanoUI.Store
( WidgetStore (..)
, boolInt
, intBool
, ptrEq
, slotKey
, Slot (..)
)
import NanoUI.Style (Theme)
import NanoUI.Types (Damage, DamageBounds (..), Rect, defaultDamageSlop, rectH, rectW)
import NanoUI.Widgets.TextCommand (TextCommand)
-- =============================================================================
-- State records
-- =============================================================================
{-# INLINE getsInteraction #-}
getsInteraction :: Context -> (InteractionState -> a) -> IO a
getsInteraction ctx f = f <$> readIORef (ctxInteractionState ctx)
{-# INLINE modifyInteraction #-}
modifyInteraction :: Context -> (InteractionState -> InteractionState) -> IO ()
modifyInteraction ctx = modifyIORef' (ctxInteractionState ctx)
{-# INLINE getsOverlay #-}
getsOverlay :: Context -> (OverlayState -> a) -> IO a
getsOverlay ctx f = f <$> readIORef (ctxOverlayState ctx)
{-# INLINE modifyOverlay #-}
modifyOverlay :: Context -> (OverlayState -> OverlayState) -> IO ()
modifyOverlay ctx = modifyIORef' (ctxOverlayState ctx)
{-# INLINE getsDamage #-}
getsDamage :: Context -> (DamageState -> a) -> IO a
getsDamage ctx f = f <$> readIORef (ctxDamageState ctx)
{-# INLINE modifyDamage #-}
modifyDamage :: Context -> (DamageState -> DamageState) -> IO ()
modifyDamage ctx = modifyIORef' (ctxDamageState ctx)
-- =============================================================================
-- Interaction
-- =============================================================================
{-# INLINE getScrollDrag #-}
getScrollDrag :: Context -> IO (Maybe (WidgetId, DirTag, Float))
getScrollDrag ctx = getsInteraction ctx isScrollDrag
{-# INLINE setTextInputDrag #-}
setTextInputDrag :: Context -> Maybe TextInputDrag -> IO ()
setTextInputDrag ctx v = modifyInteraction ctx (\s -> s {isTextInputDrag = v})
{-# INLINE getTextInputMenu #-}
getTextInputMenu :: Context -> IO (Maybe TextInputMenu)
getTextInputMenu ctx = getsInteraction ctx isTextInputMenu
{-# INLINE setTextInputMenu #-}
setTextInputMenu :: Context -> Maybe TextInputMenu -> IO ()
setTextInputMenu ctx v = modifyInteraction ctx (\s -> s {isTextInputMenu = v})
takeTextEditLastAction :: Context -> IO (Maybe (WidgetId, TextCommand))
takeTextEditLastAction ctx = do
act <- getsInteraction ctx isTextEditLastAction
modifyInteraction ctx (\s -> s {isTextEditLastAction = Nothing})
pure act
{-# INLINE getMenuPointerGesture #-}
getMenuPointerGesture :: Context -> IO Bool
getMenuPointerGesture ctx = getsInteraction ctx isMenuPointerGesture
{-# INLINE setMenuPointerGesture #-}
setMenuPointerGesture :: Context -> Bool -> IO ()
setMenuPointerGesture ctx v = modifyInteraction ctx (\s -> s {isMenuPointerGesture = v})
{-# INLINE getWindowDrag #-}
getWindowDrag :: Context -> IO (Maybe (WidgetId, Float, Float))
getWindowDrag ctx = getsInteraction ctx isWindowDrag
{-# INLINE getWindowResize #-}
getWindowResize :: Context -> IO (Maybe WindowResizeDrag)
getWindowResize ctx = getsInteraction ctx isWindowResize
-- =============================================================================
-- Damage
-- =============================================================================
{-# INLINE requestDamage #-}
requestDamage :: Context -> DamageRequest -> IO ()
requestDamage ctx req = modifyDamage ctx (\ds -> ds {dsRequests = req : dsRequests ds})
{-# INLINE damageWidget #-}
damageWidget :: Context -> WidgetId -> DamageBounds -> IO ()
damageWidget ctx wid bounds
| hashWidgetId wid == 0 = pure ()
| otherwise = requestDamage ctx (ReqWidget wid bounds)
{-# INLINE damageKey #-}
damageKey :: Context -> Int -> DamageBounds -> IO ()
damageKey ctx k bounds
| k == 0 = pure ()
| otherwise = requestDamage ctx (ReqKey k bounds)
{-# INLINE damageRect #-}
damageRect :: Context -> Rect -> IO ()
damageRect ctx r
| rectW r <= 0 || rectH r <= 0 = pure ()
| otherwise = requestDamage ctx (ReqRect r)
{-# INLINE damagePeers #-}
damagePeers :: Context -> [WidgetId] -> DamageBounds -> IO ()
damagePeers ctx wids bounds =
case filter (\w -> hashWidgetId w /= 0) wids of
[] -> pure ()
valid -> requestDamage ctx (ReqPeers valid bounds)
{-# INLINE damageFull #-}
damageFull :: Context -> IO ()
damageFull ctx = requestDamage ctx ReqFull
{-# INLINE markDirty #-}
markDirty :: Context -> IO ()
markDirty ctx = do
modifyDamage ctx (\ds -> ds {dsDirty = True})
readIORef (ctxWakeLoop ctx) >>= sequence_
{-# INLINE clearDirty #-}
clearDirty :: Context -> IO ()
clearDirty ctx = modifyDamage ctx (\ds -> ds {dsDirty = False})
{-# INLINE isDirty #-}
isDirty :: Context -> IO Bool
isDirty ctx = getsDamage ctx dsDirty
{-# INLINE setWakeLoop #-}
setWakeLoop :: Context -> IO () -> IO ()
setWakeLoop ctx wake = writeIORef (ctxWakeLoop ctx) (Just wake)
{-# INLINE takeDamage #-}
takeDamage :: Context -> IO Damage
takeDamage ctx = getsDamage ctx dsDamage
{-# INLINE getPrevRect #-}
getPrevRect :: Context -> WidgetId -> IO (Maybe Rect)
getPrevRect ctx wid = getsDamage ctx (IM.lookup (intKey wid) . dsPrevRects)
{-# INLINE getPrevClipRect #-}
getPrevClipRect :: Context -> WidgetId -> IO (Maybe Rect)
getPrevClipRect ctx wid = getsDamage ctx (IM.lookup (intKey wid) . dsPrevClips)
-- =============================================================================
-- Store
-- =============================================================================
{-# INLINE getStore #-}
getStore :: Context -> IO WidgetStore
getStore ctx = readIORef (ctxStore ctx)
setStore :: Context -> WidgetStore -> IO ()
setStore ctx store = modifyStore ctx (const store)
-- | Replace the store with @f@ of it, damaging the keys whose values changed
-- and waking the loop when anything did.
modifyStore :: Context -> (WidgetStore -> WidgetStore) -> IO ()
modifyStore ctx f = do
prev <- readIORef (ctxStore ctx)
-- WHNF-force the new record: record-update arguments are unevaluated
-- thunks, and writeIORef would otherwise park one in the long-lived store
-- every frame.
let !store = f prev
writeIORef (ctxStore ctx) store
let changedKeys =
diffKeys (storeInt prev) (storeInt store)
++ diffKeys (storeFloat prev) (storeFloat store)
++ diffKeys (storeDouble prev) (storeDouble store)
++ diffKeys (storePoint prev) (storePoint store)
++ diffKeys (storeText prev) (storeText store)
++ diffKeys (storeFloatList prev) (storeFloatList store)
++ diffKeys (storeIntList prev) (storeIntList store)
++ diffKeys (storeIntSet prev) (storeIntSet store)
++ diffKeysBy ptrEq (storeDyn prev) (storeDyn store)
-- The key diff doubles as the store comparison: checking 'prev /= store'
-- first would walk every changed map twice. Its lazy concatenation stops at
-- the first changed key and allocates less than a list per map.
when
( storeMirrorGen prev /= storeMirrorGen store
|| storeOpenSelect prev /= storeOpenSelect store
|| not (null changedKeys)
)
$ do
forM_ changedKeys $ \k -> damageKey ctx k (DamageInflated defaultDamageSlop)
markDirty ctx
diffKeysBy :: (a -> a -> Bool) -> IntMap a -> IntMap a -> [Int]
diffKeysBy eq old new
-- Unchanged maps keep their identity through a record update; skip the
-- whole merge when the caller only rebuilt a different field.
| ptrEq old new = []
| otherwise =
IM.keys
( IM.mergeWithKey
(\_ a b -> if eq a b then Nothing else Just ())
(IM.map (const ()))
(IM.map (const ()))
old
new
)
diffKeys :: Eq a => IntMap a -> IntMap a -> [Int]
diffKeys = diffKeysBy (==)
-- | Targeted single-slot write: compares only the target slot, updates one map
-- field, damages the owning widget and wakes the loop. Unlike 'setStore' it
-- never diffs the whole store, and an equal write is a no-op.
{-# INLINE writeSlot #-}
writeSlot ::
Eq a =>
(WidgetStore -> IntMap a) ->
(IntMap a -> WidgetStore -> WidgetStore) ->
Context ->
WidgetId ->
Int ->
a ->
IO ()
writeSlot field setField ctx owner k v = do
st <- readIORef (ctxStore ctx)
case IM.lookup k (field st) of
Just old | old == v -> pure ()
_ -> do
writeIORef (ctxStore ctx) $! setField (IM.insert k v (field st)) st
damageWidget ctx owner DamageSelf
markDirty ctx
writeStoreInt :: Context -> WidgetId -> Int -> Int -> IO ()
writeStoreInt = writeSlot storeInt (\m st -> st {storeInt = m})
writeStoreFloat :: Context -> WidgetId -> Int -> Float -> IO ()
writeStoreFloat = writeSlot storeFloat (\m st -> st {storeFloat = m})
{-# INLINE writeStoreBool #-}
writeStoreBool :: Context -> WidgetId -> Bool -> IO ()
writeStoreBool ctx owner v = writeStoreInt ctx owner (intKey owner) (boolInt v)
-- | Controlled widgets take their value from the caller every frame. The
-- caller's value replaces the stored one only when it differs from the value
-- the widget last returned ('recordSlot'). An edit applied between frames,
-- such as a menu cut, then survives a caller that passes the previous result
-- back, while a value changed by the application still wins. Returns the
-- slot's value after adopting.
{-# INLINE adoptSlot #-}
adoptSlot ::
Eq a =>
(WidgetStore -> IntMap a) ->
(IntMap a -> WidgetStore -> WidgetStore) ->
Context ->
WidgetId ->
Int ->
a ->
IO a
adoptSlot field setField ctx owner k v = do
st <- readIORef (ctxStore ctx)
let
m = field st
seenK = slotKey SlotSeen k
if IM.lookup seenK m == Just v
then pure $! IM.findWithDefault v k m
else do
writeIORef (ctxStore ctx) $! setField (IM.insert seenK v (IM.insert k v m)) st
when (IM.lookup k m /= Just v) $ do
damageWidget ctx owner DamageSelf
markDirty ctx
pure v
-- | Remember the value a controlled widget returned this frame.
{-# INLINE recordSlot #-}
recordSlot ::
Eq a =>
(WidgetStore -> IntMap a) ->
(IntMap a -> WidgetStore -> WidgetStore) ->
Context ->
Int ->
a ->
IO ()
recordSlot field setField ctx k v = do
st <- readIORef (ctxStore ctx)
let seenK = slotKey SlotSeen k
when (IM.lookup seenK (field st) /= Just v) $
writeIORef (ctxStore ctx) $! setField (IM.insert seenK v (field st)) st
adoptStoreInt :: Context -> WidgetId -> Int -> Int -> IO Int
adoptStoreInt = adoptSlot storeInt (\m st -> st {storeInt = m})
adoptStoreFloat :: Context -> WidgetId -> Int -> Float -> IO Float
adoptStoreFloat = adoptSlot storeFloat (\m st -> st {storeFloat = m})
adoptStoreText :: Context -> WidgetId -> Int -> Text -> IO Text
adoptStoreText = adoptSlot storeText (\m st -> st {storeText = m})
recordStoreInt :: Context -> Int -> Int -> IO ()
recordStoreInt = recordSlot storeInt (\m st -> st {storeInt = m})
recordStoreFloat :: Context -> Int -> Float -> IO ()
recordStoreFloat = recordSlot storeFloat (\m st -> st {storeFloat = m})
recordStoreText :: Context -> Int -> Text -> IO ()
recordStoreText = recordSlot storeText (\m st -> st {storeText = m})
{-# INLINE getStoreBool #-}
getStoreBool :: Context -> WidgetId -> Bool -> IO Bool
getStoreBool ctx wid def =
intBool . IM.findWithDefault (boolInt def) (intKey wid) . storeInt <$> getStore ctx
-- | Whether @wid@ was declared inside a disabled scope. A widget asks before
-- its node exists, while the scope it is declared in is still the arena's.
{-# INLINE isDisabled #-}
isDisabled :: Context -> WidgetId -> IO Bool
isDisabled ctx wid = do
ts <- readIORef (ctxThemeScopes ctx)
if tsDisabled ts then scopeDisabled ctx wid else pure False
{-# NOINLINE scopeDisabled #-}
scopeDisabled :: Context -> WidgetId -> IO Bool
scopeDisabled ctx wid = do
let na = ctxNodeArena ctx
mIdx <- lookupNodeByWidgetId na wid
scope <- maybe (getArenaScope na) (getNodeScope na) mIdx
pure (scope .&. 1 /= 0)
-- =============================================================================
-- Theme scopes
-- =============================================================================
newThemeScopes :: IO ThemeScopes
newThemeScopes = do
let unset = error "theme scope: unset"
cur <- newSmallArray 8 unset
raw <- newSmallArray 8 unset
prev <- newSmallArray 8 unset
prevRaw <- newSmallArray 8 unset
pure
ThemeScopes
{ tsCount = 0
, tsThemes = cur
, tsRaw = raw
, tsPrevCount = 0
, tsPrev = prev
, tsPrevRaw = prevRaw
, tsDisabled = False
, tsChanged = False
, tsPrevSig = 0
}
-- | Start a view pass with no pushed themes. The first pass of a frame keeps
-- last frame's themes to compare against; a rebuild pass keeps comparing
-- against the same ones.
beginThemeScopes :: Context -> Bool -> IO ()
beginThemeScopes ctx newFrame = do
ts <- readIORef (ctxThemeScopes ctx)
if newFrame
then do
sig <- getScopeSignature (ctxNodeArena ctx)
writeIORef (ctxThemeScopes ctx) $!
ts
{ tsCount = 0
, tsThemes = tsPrev ts
, tsRaw = tsPrevRaw ts
, tsPrevCount = tsCount ts
, tsPrev = tsThemes ts
, tsPrevRaw = tsRaw ts
, tsDisabled = False
, tsChanged = False
, tsPrevSig = sig
}
else writeIORef (ctxThemeScopes ctx) $! ts {tsCount = 0, tsDisabled = False, tsChanged = False}
-- | Add a scope drawn with @theme@, whose nested scopes modify @raw@ and which
-- is disabled or not, and
-- return its theme index. A theme equal to last frame's at the same index
-- keeps last frame's value.
pushThemeScope :: Context -> Bool -> Theme -> Theme -> IO Int
pushThemeScope ctx disabled raw theme = do
ts <- readIORef (ctxThemeScopes ctx)
let !i = tsCount ts
cap <- getSizeofSmallMutableArray (tsThemes ts)
(themes, raws) <-
if i < cap
then pure (tsThemes ts, tsRaw ts)
else do
grown <- newSmallArray (cap * 2) theme
copySmallMutableArray grown 0 (tsThemes ts) 0 i
grownRaw <- newSmallArray (cap * 2) raw
copySmallMutableArray grownRaw 0 (tsRaw ts) 0 i
pure (grown, grownRaw)
same <-
if i < tsPrevCount ts
then (== theme) <$> readSmallArray (tsPrev ts) i
else pure False
writeSmallArray themes i theme
writeSmallArray raws i raw
writeIORef (ctxThemeScopes ctx) $!
ts {tsCount = i + 1, tsThemes = themes, tsRaw = raws, tsDisabled = tsDisabled ts || disabled, tsChanged = tsChanged ts || not same}
pure (i + 1)
-- | Whether this frame's scopes look different from last frame's: a theme
-- changed, scopes were added or dropped, or nodes moved between scopes.
themeScopesChanged :: Context -> IO Bool
themeScopesChanged ctx = do
ts <- readIORef (ctxThemeScopes ctx)
sig <- getScopeSignature (ctxNodeArena ctx)
pure (tsChanged ts || tsCount ts /= tsPrevCount ts || sig /= tsPrevSig ts)
{-# INLINE scopeTheme #-}
scopeTheme :: Context -> Int -> IO Theme
scopeTheme ctx scope
| ti == 0 = readIORef (ctxTheme ctx)
| otherwise = do
ts <- readIORef (ctxThemeScopes ctx)
readSmallArray (tsThemes ts) (ti - 1)
where
!ti = scope `shiftR` 1
-- | A scope's theme before any disabled scope faded it.
scopeRawTheme :: Context -> Int -> IO Theme
scopeRawTheme ctx scope
| ti == 0 = readIORef (ctxTheme ctx)
| otherwise = do
ts <- readIORef (ctxThemeScopes ctx)
readSmallArray (tsRaw ts) (ti - 1)
where
!ti = scope `shiftR` 1
-- | The theme of the scope the view is declaring in.
{-# INLINE currentTheme #-}
currentTheme :: Context -> IO Theme
currentTheme ctx = getArenaScope (ctxNodeArena ctx) >>= scopeTheme ctx
{-# INLINE nodeTheme #-}
nodeTheme :: Context -> NodeIdx -> IO Theme
nodeTheme ctx idx = getNodeScope (ctxNodeArena ctx) idx >>= scopeTheme ctx
-- | The theme of @wid@'s node, or of the current scope before it has one.
widgetTheme :: Context -> WidgetId -> IO Theme
widgetTheme ctx wid = do
ts <- readIORef (ctxThemeScopes ctx)
if tsCount ts == 0
then readIORef (ctxTheme ctx)
else lookupNodeByWidgetId (ctxNodeArena ctx) wid >>= maybe (currentTheme ctx) (nodeTheme ctx)