packages feed

nano-ui-sdl-0.1.0.0: lib/NanoUI/Sdl/Dialog/Types.hs

-- | Types and pending-dialog state shared between the SDL dialog backend and
-- the SDL window lifecycle.
module NanoUI.Sdl.Dialog.Types
  ( FileDialogId (..)
  , FileDialogResult (..)
  , PendingDialog (..)
  , DialogCallback
  , DialogCallbackFunPtr
  , DialogState (..)
  , newDialogState
  , clearDialogState
  , drainRetired
  , retireDialogCallback
  ) where

import Data.Int (Int32)
import Data.IntMap.Strict (IntMap)
import Data.IntMap.Strict qualified as IM
import Data.IORef (IORef, atomicModifyIORef', newIORef, writeIORef)
import Foreign.Ptr (FunPtr, Ptr, freeHaskellFunPtr)

-- | Opaque handle returned by a non-blocking dialog launch. @0@ is never a
-- valid handle.
newtype FileDialogId = FileDialogId Int
  deriving (Eq, Ord, Show)

-- | Lifecycle state of a launched file dialog.
data FileDialogResult
  = FileDialogPending
  -- ^ Still waiting for the user.
  | FileDialogCancelled
  -- ^ The user dismissed the dialog without choosing.
  | FileDialogFailed
  -- ^ SDL reported an error.
  | FileDialogSelected [FilePath]
  -- ^ The user chose one or more paths.
  | FileDialogUnknown
  -- ^ No dialog with this handle is being tracked. A handle becomes unknown
  -- once its result has been delivered and consumed by 'pollFileDialog', or
  -- after the dialog was abandoned via 'cancelFileDialog'. Never poll a
  -- handle that returns 'FileDialogUnknown' again.
  deriving (Eq, Show)

-- | Shape of the SDL3 dialog callback, flattened to 'Ptr' at the FFI
-- boundary.
type DialogCallback = Ptr () -> Ptr () -> Int32 -> IO ()

-- | A marshalled 'DialogCallback' allocated once per dialog launch.
type DialogCallbackFunPtr = FunPtr DialogCallback

-- | A tracked dialog: its current status plus the FFI callback that owns its
-- completion. The callback is released only after the dialog completes and a
-- poll consumes the result, so it is never freed while SDL could still invoke
-- it.
data PendingDialog = PendingDialog
  { pendingStatus :: !FileDialogResult
  , pendingCallback :: !DialogCallbackFunPtr
  }

-- | Pending dialogs, keyed by 'FileDialogId'.
data DialogState = DialogState
  { dsNextId :: !(IORef Int)
  , dsPending :: !(IORef (IntMap PendingDialog))
  , dsRetiredCur :: !(IORef [DialogCallbackFunPtr])
  , dsRetiredPrev :: !(IORef [DialogCallbackFunPtr])
  }

-- | Create an empty dialog state.
newDialogState :: IO DialogState
newDialogState =
  DialogState
    <$> newIORef 0
    <*> newIORef IM.empty
    <*> newIORef []
    <*> newIORef []

-- | Forget every pending dialog. Used during SDL teardown: dialogs still
-- open on the OS side keep running and their callbacks are left to the
-- process, but all handles become 'FileDialogUnknown'. Entries still in the
-- current retirement batch are not freed here: SDL may still be
-- unwinding their wrappers during teardown; they leak to process exit.
clearDialogState :: DialogState -> IO ()
clearDialogState st = do
  writeIORef (dsPending st) IM.empty
  drainRetired st

-- | Retire a consumed dialog callback for freeing on a later poll.
retireDialogCallback :: DialogState -> DialogCallbackFunPtr -> IO ()
retireDialogCallback st cb =
  atomicModifyIORef' (dsRetiredCur st) (\cbs -> (cb : cbs, ()))

-- | Free callback 'FunPtr's retired before the previous poll. Retiring parks
-- them for one full poll first so the dialog callback thread has certainly
-- returned before 'freeHaskellFunPtr' runs (freeing a wrapper while it
-- executes is unsafe).
drainRetired :: DialogState -> IO ()
drainRetired st = do
  cbs <- atomicModifyIORef' (dsRetiredPrev st) (\cbs -> ([], cbs))
  mapM_ freeHaskellFunPtr cbs
  cur <- atomicModifyIORef' (dsRetiredCur st) (\cur -> ([], cur))
  writeIORef (dsRetiredPrev st) cur