packages feed

nano-ui-sdl-0.1.0.1: lib/NanoUI/Sdl/Runner.hs

{-# LANGUAGE DataKinds #-}

-- | SDL3 draw path: retained damage updates or direct continuous presentation.
module NanoUI.Sdl.Runner
  ( sdlDrawFrame
  , drawReduceEff
  , askSdlDebug
  , setSdlUiFont
  , setSdlUiScale
  ) where

import Control.Exception (finally, mask_)
import Control.Monad (unless, void, when)
import Data.IORef (IORef, readIORef, writeIORef)
import Data.Typeable (Typeable)
import GHC.Clock (getMonotonicTime)
import NanoUI
  ( Input (..)
  , NanoUI
  , Size (..)
  , V2 (..)
  , themeWindow
  )
import Effectful (Eff, IOE, type (:>))
import NanoUI.Testing
  ( Context
  , Damage (..)
  , DrawData (..)
  , Ui
  , askHost
  , ctxPaintFull
  , ctxTheme
  , damageFull
  , damageIsEmpty
  , drawCmdCount
  , markDirty
  , runEff
  , runFrameEff
  , runFrameReduceEff
  , takeDamage
  , uiIO
  )
import NanoUI.Debug (CoreDebugSnapshot (..), noteDebugPresent, noteDebugSkip, refreshDebugSnapshot)
import NanoUI.Sdl.Debug
  ( SdlDebugSampler (..)
  , SdlDebugSnapshot (..)
  , emptySdlDebug
  , traceFrame
  )
import NanoUI.Sdl.Display (pushRefreshEvent, queryMouseWindowPos, queryWindowLogicalSize)
import NanoUI.Sdl.Font
  ( fontSourceLabel
  , glyphAtlasTexture
  , sdlFontCacheSource
  , prepareGlyphAtlasForFrame
  , takeGlyphAtlasResetFlag
  )
import NanoUI.Sdl.NanoUIFont (NanoUIFont)
import NanoUI.Sdl.Render (flushRenderBatch, renderDrawDataPass, snapDamage)
import NanoUI.Sdl.Window (Retain (..), SdlEnv (..))
import Foreign.Marshal.Alloc (alloca)
import Foreign.Marshal.Utils (with)
import Foreign.Ptr (Ptr, nullPtr)
import Foreign.Storable (peek)
import qualified NanoUI.Sdl.Image as SdlImage
import SDL3.Sys.Bindgen.Blendmode (sDL_BLENDMODE_NONE)
import SDL3.Sys.Bindgen.Pixels (data SDL_PIXELFORMAT_RGBA32)
import SDL3.Sys.Bindgen.Rect (SDL_FRect (..))
import SDL3.Sys.Bindgen.Render (SDL_Texture, data SDL_TEXTUREACCESS_TARGET)
import SDL3.Sys.Bindgen.Runtime.PtrConst qualified as PtrConst
import SDL3.Sys.Render
  ( createTexture
  , destroyTexture
  , getRenderOutputSize
  , renderPresentSafe
  , renderTexture
  , setRenderClipRect
  , setRenderScale
  , setRenderTarget
  , setTextureBlendMode
  )

sdlDrawFrame :: Context -> NanoUI () -> SdlEnv -> Input -> Bool -> IO (Bool, Input)
sdlDrawFrame ctx ui env inp forceFull =
  drawFrameWith ctx env inp forceFull $ do
    (_, _, drawData, dirtyAfterUi) <- runFrameEff runEff ctx inp ui
    pure (drawData, dirtyAfterUi)

-- | Both application styles share atlas maintenance, retain preparation,
-- timing, and presentation. Only evaluation of the UI differs.
drawFrameWith :: Context -> SdlEnv -> Input -> Bool -> IO (DrawData, Bool) -> IO (Bool, Input)
drawFrameWith ctx env inp forceFull evaluateUi = do
  SdlImage.syncImageAtlas (sdlRenderer env) (sdlImages env) ctx
  (tex, presentFull) <- prepareRetain ctx env inp forceFull
  t0 <- getMonotonicTime
  (drawData, dirtyAfterUi) <- evaluateUi
  t1 <- getMonotonicTime
  finishDraw ctx env inp tex presentFull t0 t1 drawData dirtyAfterUi

-- | Choose the render target and whether to repaint everything. Must run
-- before the frame so paint can cull to damage for retained partial updates.
prepareRetain :: Context -> SdlEnv -> Input -> Bool -> IO (Ptr SDL_Texture, Bool)
prepareRetain ctx env inp forceFull = do
  -- Glyph-atlas maintenance before any quad is recorded: if the atlas ran
  -- out of space during the previous frame, reset it now (re-warming the
  -- base fonts) so a reset can never wipe the texture underneath
  -- already-recorded text mid-frame.
  prepareGlyphAtlasForFrame (sdlGlyphAtlas env)
  scale <- readIORef (sdlScaleRef env)
  let Size lw lh = inputWindowSize inp
      pw = max 1 (round (lw * scale))
      ph = max 1 (round (lh * scale))
  -- Continuous sessions repaint every pixel, so retaining and copying a
  -- second framebuffer only adds a target switch and a full-window blit.
  -- A null target selects the window backbuffer directly. Direct drawing is
  -- equivalent to the retained blit only at the same pixel dimensions;
  -- content scale and window pixel density can differ.
  direct <-
    if sdlContinuous env
      then
        alloca $ \wp ->
          alloca $ \hp -> do
            ok <- getRenderOutputSize (sdlRenderer env) wp hp
            ow <- peek wp
            oh <- peek hp
            pure (ok && fromIntegral ow == pw && fromIntegral oh == ph)
      else pure False
  (tex, retainNew) <-
    if direct
      then pure (nullPtr, False)
      else ensureRetain env pw ph scale
  let presentFull = forceFull || retainNew || sdlContinuous env || inputWindowRedraw inp
  writeIORef (ctxPaintFull ctx) presentFull
  pure (tex, presentFull)

drawReduceEff ::
  (IOE :> es, Typeable msg, Eq model) =>
  (forall x. Eff es x -> IO x) ->
  (msg -> model -> model) ->
  IORef model ->
  (model -> Eff (Ui : es) ()) ->
  Context ->
  SdlEnv ->
  Input ->
  Bool ->
  IO (Bool, Input)
drawReduceEff unlift update modelRef view ctx env inp forceFull =
  drawFrameWith ctx env inp forceFull $ do
    m <- readIORef modelRef
    (_, m', _, drawData, dirtyAfterUi) <- runFrameReduceEff unlift update ctx inp m view
    writeIORef modelRef m'
    pure (drawData, dirtyAfterUi)

finishDraw :: Context -> SdlEnv -> Input -> Ptr SDL_Texture -> Bool -> Double -> Double -> DrawData -> Bool -> IO (Bool, Input)
finishDraw ctx env inp tex presentFull t0 t1 drawData dirtyAfterUi = do
  let uiMs = (t1 - t0) * 1000
  scale <- readIORef (sdlScaleRef env)
  dmg0 <- takeDamage ctx
  let Size lw lh = inputWindowSize inp
  -- Frame damage from writeDamage is authoritative: a live animation whose
  -- key is out of view or scroll-clipped produces empty damage, and forcing
  -- DamageFull here would turn every skip frame into a full present. A
  -- window redraw event (expose/restore) is the exception: the backbuffer
  -- is gone, so the next present must be full.
  let damage =
        if presentFull
          then DamageFull
          else snapDamage scale dmg0
  writeIORef (sdlLastPresented env) False
  -- A glyph-atlas reset or exhaustion during the UI pass means quads
  -- recorded before that point hold stale (or unplaceable) UVs. Drop the
  -- frame instead of presenting it: the screen keeps the previous valid
  -- frame, 'damageFull' forces a full repaint, and
  -- 'prepareGlyphAtlasForFrame' resets the atlas before the next frame
  -- records any quads, so text never flickers or vanishes for a frame.
  atlasReset <- takeGlyphAtlasResetFlag (sdlGlyphAtlas env)
  if atlasReset || damageIsEmpty damage || lw <= 0 || lh <= 0
    then do
      when atlasReset $ do
        damageFull ctx
        markDirty ctx
      noteDebugSkip (sdsSampler (sdlDebug env))
      pure (atlasReset || dirtyAfterUi, inp)
    else do
      -- A null texture draws full-repaint sessions straight to the window.
      okBegin <- setRenderTarget (sdlRenderer env) tex
      okScale <- setRenderScale (sdlRenderer env) scale scale
      unless (okBegin && okScale) $ fail "SDL_SetRenderTarget/Scale failed"
      theme <- readIORef (ctxTheme ctx)
      glyphTex <- glyphAtlasTexture (sdlGlyphAtlas env)
      -- Persistent batch created once per session (sdlBatch): no C
      -- calloc/free pair per presented frame. Flush unconditionally so an
      -- aborted pass cannot leak pending geometry into the next frame.
      --
      -- Full repaints clear the target, including bare backdrop regions.
      -- Partial updates preserve the undamaged part of the retained texture.
      let batch = sdlBatch env
      renderDrawDataPass
        batch
        (sdlRenderer env)
        (if damage == DamageFull then Just (themeWindow theme) else Nothing)
        drawData
        (sdlImages env)
        glyphTex
        damage
        `finally` flushRenderBatch batch
      t2 <- getMonotonicTime
      -- Damage limits updates to the retained texture, not the final copy:
      -- SDL leaves the window backbuffer undefined after each present.
      -- Restore the window's pixel coordinate system before polling events.
      -- Retained sessions do this as part of their final texture copy.
      okBlit <-
        if tex == nullPtr
          then setRenderScale (sdlRenderer env) 1 1
          else do
            okTarget <- setRenderTarget (sdlRenderer env) nullPtr
            okClip <- setRenderClipRect (sdlRenderer env) (PtrConst.unsafeFromPtr nullPtr)
            void $ setRenderScale (sdlRenderer env) 1 1
            -- The texture is larger than the window: copy only the used area.
            r <- readIORef (sdlRetain env)
            let src = SDL_FRect 0 0 (fromIntegral (retainW r)) (fromIntegral (retainH r))
            okCopy <- with src $ \srcP ->
              renderTexture (sdlRenderer env) tex (PtrConst.unsafeFromPtr srcP) (PtrConst.unsafeFromPtr nullPtr)
            pure (okTarget && okClip && okCopy)
      unless okBlit $ fail "SDL window presentation preparation failed"
      void $ renderPresentSafe (sdlRenderer env)
      t3 <- getMonotonicTime
      let renderMs = (t2 - t1) * 1000
          presentMs = (t3 - t2) * 1000
          frameMs = (t3 - t0) * 1000
      noteDebugPresent (sdsSampler (sdlDebug env)) uiMs renderMs presentMs frameMs
        (drawVertexCount drawData) (drawIndexCount drawData) (drawCmdCount drawData)
      writeIORef (sdlLastPresented env) True
      pure (dirtyAfterUi, inp)

-- | Pixels a retained texture is rounded up to, in each dimension.
retainBlock :: Int
retainBlock = 256

ensureRetain :: SdlEnv -> Int -> Int -> Float -> IO (Ptr SDL_Texture, Bool)
ensureRetain env w h scale = do
  r <- readIORef (sdlRetain env)
  let tex = retainTexture r
      fits = w <= retainCapW r && h <= retainCapH r
      -- Give memory back once the window is well inside the texture, but not
      -- for a shrink of a block or so, which a drag back out would undo.
      roomy = retainCapW r - w > 2 * retainBlock || retainCapH r - h > 2 * retainBlock
      -- A new used size or density holds none of the frame to be drawn.
      stale = w /= retainW r || h /= retainH r || abs (retainScale r - scale) > 0.001
  if tex /= nullPtr && fits && not roomy
    then do
      when stale $ writeIORef (sdlRetain env) r {retainW = w, retainH = h, retainScale = scale}
      pure (tex, stale)
    else mask_ $ do
      let cw = roundUp w
          ch = roundUp h
      -- Allocate before replacing: failure leaves the owned texture valid.
      tex' <- createTexture (sdlRenderer env) SDL_PIXELFORMAT_RGBA32 SDL_TEXTUREACCESS_TARGET (fromIntegral cw) (fromIntegral ch)
      when (tex' == nullPtr) $ fail "SDL_CreateTexture(retain) failed"
      void $ setTextureBlendMode tex' (fromIntegral sDL_BLENDMODE_NONE)
      writeIORef (sdlRetain env) (Retain tex' cw ch w h scale)
      unless (tex == nullPtr) $ destroyTexture tex
      pure (tex', True)
  where
    roundUp n = max retainBlock (((n + retainBlock - 1) `div` retainBlock) * retainBlock)

askSdlDebug :: Ui :> es => Eff es SdlDebugSnapshot
askSdlDebug = do
  menv <- askHost @SdlEnv
  case menv of
    Nothing -> pure emptySdlDebug
    Just env -> uiIO $ do
      let sampler = sdlDebug env
      -- The display is queried only when the snapshot refreshes.
      refreshDebugSnapshot (sdsSampler sampler) (sdsSnapshot sampler) $ \core -> do
        scale <- readIORef (sdlScaleRef env)
        fontSource <- sdlFontCacheSource (sdlFontCache env)
        Size ww wh <- queryWindowLogicalSize (sdlWindow env)
        V2 mx my <- queryMouseWindowPos
        let snap =
              SdlDebugSnapshot
                { dbgCore = core {dbgWinW = ww, dbgWinH = wh, dbgMouseX = mx, dbgMouseY = my}
                , dbgScale = scale
                , dbgFontPath = fontSourceLabel fontSource
                , dbgRenderer = sdlRendererName env
                , dbgVsync = sdlVsync env
                , dbgRefreshHz = round (1 / sdlRefreshPeriod env)
                }
        when (sdsTrace sampler) (traceFrame snap)
        pure snap

-- | Request a UI font family. The SDL display thread resolves and applies it
-- before the next frame (see 'NanoUI.Sdl.Window.syncDisplay'), rebuilding the
-- glyph atlas and text resolver. A no-op on non-SDL hosts.
setSdlUiFont :: Ui :> es => NanoUIFont -> Eff es ()
setSdlUiFont font = do
  menv <- askHost @SdlEnv
  case menv of
    Nothing -> pure ()
    Just env -> uiIO $ do
      cur <- readIORef (sdlFontRequestRef env)
      when (cur /= font) $ writeIORef (sdlFontRequestRef env) font

-- | Set the UI scale (see 'NanoUI.Sdl.Window.sdlAppUiScale'): a zoom on top
-- of the pixel density, or zero or less to follow the display. The display
-- thread applies it before the next frame, which this wakes. A no-op on
-- non-SDL hosts.
setSdlUiScale :: Ui :> es => Float -> Eff es ()
setSdlUiScale s = do
  menv <- askHost @SdlEnv
  case menv of
    Nothing -> pure ()
    Just env -> uiIO $ do
      cur <- readIORef (sdlUiScaleRef env)
      when (cur /= s) $ do
        writeIORef (sdlUiScaleRef env) s
        pushRefreshEvent