packages feed

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

-- | SDL3 native file dialogs.
--
-- These wrap SDL3's asynchronous dialog API ('SDL_ShowOpenFileDialog',
-- 'SDL_ShowSaveFileDialog', and 'SDL_ShowOpenFolderDialog') into a
-- non-blocking, poll-based interface. Launching a dialog returns a
-- 'FileDialogId' immediately and the app keeps running its normal event loop;
-- poll the handle on later frames to observe completion.
--
-- Threading: SDL3 may invoke the dialog callback on a background thread, so
-- the callback here does little: it decodes the result, frees the
-- FFI buffers it owned, wakes the event loop, and records the outcome. All
-- UI-affecting work ('markDirty', releasing the callback 'FunPtr') is deferred
-- to the thread that polls the result.
module NanoUI.Sdl.Dialog
  ( FileFilter (..)
  , FileDialogOptions (..)
  , defaultFileDialogOptions
  , FileDialogId (..)
  , FileDialogResult (..)
  , openFileDialog
  , saveFileDialog
  , openFolderDialog
  , pollFileDialog
  , cancelFileDialog
  , clearDialogState
  , askOpenFileDialog
  , askSaveFileDialog
  , askOpenFolderDialog
  , pollFileDialogUi
  ) where

import Control.Monad (forM, forM_, unless, void)
import Data.Int (Int32)
import Data.IntMap.Strict qualified as IM
import Data.IORef (atomicModifyIORef', readIORef)
import Data.Text (Text)
import qualified Data.Text as T
import Effectful (Eff, type (:>))
import Foreign.C.String (CString, newCString, peekCString)
import Foreign.C.Types (CChar)
import Foreign.Marshal.Alloc (free)
import Foreign.Marshal.Array (mallocArray, peekArray0)
import Foreign.Ptr (FunPtr, Ptr, castFunPtr, castPtr, nullPtr)
import Foreign.Storable (pokeElemOff)
import NanoUI.Sdl.Dialog.Types
  ( DialogCallback
  , DialogCallbackFunPtr
  , DialogState (..)
  , FileDialogId (..)
  , FileDialogResult (..)
  , PendingDialog (..)
  , clearDialogState
  , drainRetired
  , retireDialogCallback
  )
import NanoUI.Sdl.Display (pushRefreshEvent)
import NanoUI.Sdl.Window (SdlEnv (..))
import NanoUI.Testing (Ui, askHost, markDirty, uiIO)
import SDL3.Sys.Bindgen.Dialog
  ( SDL_DialogFileCallback (..)
  , SDL_DialogFileCallback_Aux
  , SDL_DialogFileFilter (..)
  )
import SDL3.Sys.Bindgen.Runtime.PtrConst qualified as PtrConst
import SDL3.Sys.Dialog
  ( showOpenFileDialogSafe
  , showOpenFolderDialogSafe
  , showSaveFileDialogSafe
  )
import SDL3.Sys.Video (raiseWindowSafe, restoreWindowSafe)
import System.IO (hPutStrLn, stderr)

-- | A file type filter shown in open/save dialogs.
data FileFilter = FileFilter
  { filterName :: !Text
  -- ^ Human-readable label, e.g. @"Haskell source"@.
  , filterPattern :: !Text
  -- ^ Semicolon-separated extension list, e.g. @"hs;lhs"@, or @"*"@.
  }
  deriving (Eq, Show)

-- | Common options for native file dialogs.
data FileDialogOptions = FileDialogOptions
  { dialogFilters :: ![FileFilter]
  -- ^ File filters (ignored by folder dialogs).
  , dialogDefaultLocation :: !(Maybe FilePath)
  -- ^ Starting folder or file.
  , dialogAllowMany :: !Bool
  -- ^ Allow selecting more than one entry (ignored by save dialogs).
  }
  deriving (Eq, Show)

-- | Sensible defaults: no filters, no default location, single selection.
defaultFileDialogOptions :: FileDialogOptions
defaultFileDialogOptions = FileDialogOptions [] Nothing False

-- | Launch an open-file dialog. Returns a handle to poll for completion.
openFileDialog :: SdlEnv -> FileDialogOptions -> IO FileDialogId
openFileDialog env opts =
  launchDialog env OpenDialog (dialogFilters opts) (dialogDefaultLocation opts) (dialogAllowMany opts)

-- | Launch a save-file dialog. Returns a handle to poll for completion.
saveFileDialog :: SdlEnv -> FileDialogOptions -> IO FileDialogId
saveFileDialog env opts =
  launchDialog env SaveDialog (dialogFilters opts) (dialogDefaultLocation opts) (dialogAllowMany opts)

-- | Launch a folder-selection dialog. Returns a handle to poll for completion.
openFolderDialog :: SdlEnv -> FileDialogOptions -> IO FileDialogId
openFolderDialog env opts =
  launchDialog env FolderDialog [] (dialogDefaultLocation opts) (dialogAllowMany opts)

-- | Poll a previously launched dialog without blocking.
--
-- Each result is delivered exactly once: after the dialog completes, the
-- first poll that observes the finished state returns it and forgets the
-- handle, so later polls return 'FileDialogUnknown'.
pollFileDialog :: SdlEnv -> FileDialogId -> IO FileDialogResult
pollFileDialog env (FileDialogId did) = do
  let st = sdlDialogState env
  -- Callbacks retired by an earlier poll are now certainly returned.
  drainRetired st
  (mcb, result) <-
    atomicModifyIORef' (dsPending st) $ \pending ->
      case IM.lookup did pending of
        Nothing -> (pending, (Nothing, FileDialogUnknown))
        Just (PendingDialog FileDialogPending _) -> (pending, (Nothing, FileDialogPending))
        Just (PendingDialog status cb) -> (IM.delete did pending, (Just cb, status))
  case result of
    FileDialogPending -> pure ()
    FileDialogUnknown -> pure ()
    _ -> do
      -- Retire the callback for a later poll to free; the callback thread may
      -- still be unwinding right now, and freeing a running wrapper is unsafe.
      forM_ mcb (retireDialogCallback st)
      -- The native dialog stole window focus; reclaim it so the app keeps
      -- receiving hover/motion/wheel events without an extra click.
      -- Restoration is a best-effort no-op when the window was never
      -- minimized (its result is platform-dependent, so it is not a reliable
      -- failure signal); only a failed raise means the window may still lack
      -- focus and worth an audible warning.
      void (restoreWindowSafe (sdlWindow env))
      raised <- raiseWindowSafe (sdlWindow env)
      unless raised $
        hPutStrLn stderr "nano-ui: dialog completed but window raise failed; input may need a click"
      -- The dialog finished; request a redraw so the caller can reflect the
      -- result. Safe here: this runs on the polling (UI) thread.
      ctx <- readIORef (sdlCachedCtx env)
      markDirty ctx
  pure result

-- | Stop tracking a dialog handle without waiting for the native dialog to
-- finish. The handle returns 'FileDialogUnknown' if polled afterwards.
-- The native dialog keeps running until the user dismisses it; its result is
-- discarded. If a dialog is abandoned while still open, its small FFI
-- callback is left to be reclaimed at teardown or process exit.
cancelFileDialog :: SdlEnv -> FileDialogId -> IO ()
cancelFileDialog env (FileDialogId did) =
  atomicModifyIORef' (dsPending (sdlDialogState env)) $ \m -> (IM.delete did m, ())

-- | Open-file dialog, usable from within 'NanoUI' widget code. Returns
-- 'Nothing' when there is no SDL host to launch a dialog.
askOpenFileDialog :: Ui :> es => FileDialogOptions -> Eff es (Maybe FileDialogId)
askOpenFileDialog opts = askHost >>= traverse (uiIO . (`openFileDialog` opts))

-- | Save-file dialog, usable from within 'NanoUI' widget code. Returns
-- 'Nothing' when there is no SDL host to launch a dialog.
askSaveFileDialog :: Ui :> es => FileDialogOptions -> Eff es (Maybe FileDialogId)
askSaveFileDialog opts = askHost >>= traverse (uiIO . (`saveFileDialog` opts))

-- | Folder dialog, usable from within 'NanoUI' widget code. Returns
-- 'Nothing' when there is no SDL host to launch a dialog.
askOpenFolderDialog :: Ui :> es => FileDialogOptions -> Eff es (Maybe FileDialogId)
askOpenFolderDialog opts = askHost >>= traverse (uiIO . (`openFolderDialog` opts))

-- | Poll a dialog from within 'NanoUI' widget code.
pollFileDialogUi :: Ui :> es => FileDialogId -> Eff es FileDialogResult
pollFileDialogUi did = do
  menv <- askHost
  case menv of
    Nothing -> pure FileDialogUnknown
    Just env -> uiIO (pollFileDialog env did)

data DialogKind = OpenDialog | SaveDialog | FolderDialog

launchDialog ::
  SdlEnv ->
  DialogKind ->
  [FileFilter] ->
  Maybe FilePath ->
  Bool ->
  IO FileDialogId
launchDialog env kind filters mDefault allowMany = do
  (filtersPtr, filterStrs) <- allocFilters filters
  (defaultPtr, defaultStr) <- allocDefault mDefault
  let st = sdlDialogState env
  -- Free callbacks from dialogs that finished earlier.
  drainRetired st
  did <- nextDialogId st
  rawFp <- mkDialogCallback (onResult did st filterStrs filtersPtr defaultStr)
  -- Register the handle before showing: the callback may fire before this
  -- function returns, and it must find its entry.
  atomicModifyIORef' (dsPending st) $ \m ->
    (IM.insert did (PendingDialog FileDialogPending rawFp) m, ())
  let cb = SDL_DialogFileCallback (castFunPtr rawFp :: FunPtr SDL_DialogFileCallback_Aux)
      filtersConst = PtrConst.unsafeFromPtr filtersPtr
      nfilters = fromIntegral (length filters)
  case kind of
    OpenDialog ->
      showOpenFileDialogSafe cb nullPtr (sdlWindow env) filtersConst nfilters defaultPtr allowMany
    SaveDialog ->
      showSaveFileDialogSafe cb nullPtr (sdlWindow env) filtersConst nfilters defaultPtr
    FolderDialog ->
      showOpenFolderDialogSafe cb nullPtr (sdlWindow env) defaultPtr allowMany
  pure (FileDialogId did)

nextDialogId :: DialogState -> IO Int
nextDialogId st = atomicModifyIORef' (dsNextId st) $ \n -> (n + 1, n + 1)

-- | SDL invoked the callback: decode the file list, release the FFI buffers
-- this launch owned, record the outcome, and only then wake the (possibly idle)
-- event loop. The status must be visible before the wake, or the woken frame
-- polls 'FileDialogPending', skips, and the result waits for an unrelated
-- event.
onResult ::
  Int ->
  DialogState ->
  [CString] ->
  Ptr SDL_DialogFileFilter ->
  Maybe CString ->
  Ptr () ->
  Ptr () ->
  Int32 ->
  IO ()
onResult did st filterStrs filtersPtr defaultStr _userdata filelistRaw _filterIdx = do
  paths <- peekFileListRaw filelistRaw
  let outcome =
        case paths of
          Nothing -> FileDialogFailed
          Just [] -> FileDialogCancelled
          Just ps -> FileDialogSelected ps
  forM_ filterStrs free
  free filtersPtr
  forM_ defaultStr free
  atomicModifyIORef' (dsPending st) $ \pending ->
    (IM.adjust (\pl -> pl {pendingStatus = outcome}) did pending, ())
  pushRefreshEvent

-- | Decode SDL's null-terminated file list into a plain list of paths.
--
-- A null list pointer means SDL hit an error; a null first entry means the
-- user canceled.
peekFileListRaw :: Ptr () -> IO (Maybe [FilePath])
peekFileListRaw filelistRaw
  | filelistRaw == nullPtr = pure Nothing
  | otherwise = Just <$> (peekArray0 nullPtr (castPtr filelistRaw) >>= traverse peekCString)

allocFilters :: [FileFilter] -> IO (Ptr SDL_DialogFileFilter, [CString])
allocFilters [] = pure (nullPtr, [])
allocFilters fs = do
  arr <- mallocArray (length fs)
  strs <-
    fmap concat $
      forM (zip [0 ..] fs) $ \(i, FileFilter name pattern_) -> do
        namePtr <- newCString (T.unpack name)
        patternPtr <- newCString (T.unpack pattern_)
        pokeElemOff
          arr
          i
          ( SDL_DialogFileFilter
              (PtrConst.unsafeFromPtr namePtr)
              (PtrConst.unsafeFromPtr patternPtr)
          )
        pure [namePtr, patternPtr]
  pure (arr, strs)

allocDefault :: Maybe FilePath -> IO (PtrConst.PtrConst CChar, Maybe CString)
allocDefault Nothing = pure (PtrConst.unsafeFromPtr nullPtr, Nothing)
allocDefault (Just path) = do
  cstr <- newCString path
  pure (PtrConst.unsafeFromPtr cstr, Just cstr)

-- SDL_DialogFileCallback is `void (*)(void *, const char * const *, int)`,
-- flattened to `void *` pointers at the FFI boundary.
foreign import ccall "wrapper"
  mkDialogCallback :: DialogCallback -> IO DialogCallbackFunPtr