nano-ui-sdl-0.1.0.1: lib/NanoUI/Sdl/Window.hs
module NanoUI.Sdl.Window
( RgbaImage (..)
, SdlEnv (..)
, Retain (..)
, noRetain
, SdlOptions (..)
, defaultSdlOptions
, withSdl
, withSdlBench
, syncDisplay
, saveScreenshot
) where
import Control.Exception (bracket)
import Control.Monad (unless, void, when)
import Data.Bits ((.|.))
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.Maybe (isJust, isNothing)
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import System.Environment (lookupEnv)
import Text.Read (readMaybe)
import Data.Primitive.SmallArray (SmallArray)
import Data.Text (Text)
import Data.Text.Foreign qualified as TextForeign
import Foreign.C.String (withCString)
import Foreign.Marshal.Alloc (alloca)
import Foreign.Ptr (Ptr, nullPtr)
import Foreign.Storable (peek)
import NanoUI (ImageId, Input (..), Size (..), Theme, V2 (..))
import NanoUI.Context (Context (..), setDrawSnapScale)
import NanoUI.Testing (clearMeasureCache, markDirty, setHost, setWakeLoop)
import NanoUI.Sdl.Display
( defaultFontSize
, initRefreshEvent
, pushRefreshEvent
, queryMouseWindowPos
, queryWindowPixelDensity
, queryWindowLogicalSize
, queryWindowRefreshHz
, zoomWindow
)
import NanoUI.Sdl.Clipboard (withSdlClipboard)
import NanoUI.Sdl.Cursor (SdlCursors (..), destroyCursors, initCursors)
import NanoUI.Sdl.Font
( FontSource (..)
, GlyphAtlas
, SdlFontCache
, destroyGlyphAtlas
, destroySdlFontCache
, newGlyphAtlas
, newSdlFontCache
, reloadSdlFontCache
, sdlFontCacheSource
, withSdlFontCache
, withTtf
)
import NanoUI.Sdl.Font.Resolve
( embeddedFontSource
, resolveNanoUIFont
, defaultFontSearch
, defaultFontSearchMono
)
import NanoUI.Sdl.NanoUIFont (NanoUIFont (..))
import NanoUI.Sdl.Debug (SdlDebugSampler, newSdlDebugSampler)
import NanoUI.Sdl.Dialog.Types (DialogState (..), clearDialogState, newDialogState)
import NanoUI.Sdl.Image (ImageAtlas, destroyImageAtlas, newImageAtlas)
import NanoUI.Sdl.Render (RenderBatch, destroyRenderBatch, newRenderBatch)
import SDL3.Sys.Bindgen.Hints (sDL_HINT_ASSERT, sDL_HINT_RENDER_VSYNC, sDL_HINT_VIDEO_DRIVER)
import SDL3.Sys.Bindgen.Render (SDL_Renderer, SDL_Texture)
import SDL3.Sys.Bindgen.Runtime.PtrConst qualified as PtrConst
import SDL3.Sys.Bindgen.Video (SDL_Window, SDL_WindowFlags (..))
import SDL3.Sys.Bindgen.Init (SDL_InitFlags (..), sDL_INIT_VIDEO)
import SDL3.Sys.Hints (setHint)
import SDL3.Sys.Init (initSafe, quitSafe)
import SDL3.Sys.Keyboard (startTextInputSafe, stopTextInputSafe)
import SDL3.Sys.Render
( createWindowAndRendererSafe
, destroyRendererSafe
, destroyTexture
, getRendererName
, renderReadPixels
, setRenderScale
, setRenderVSync
)
import SDL3.Sys.Surface (destroySurface, saveBMP)
import SDL3.Sys.Video (destroyWindowSafe, getWindowDisplayScale)
-- | Initial RGBA asset uploaded before the first frame.
data RgbaImage = RgbaImage
{ rgbaImageId :: !ImageId
, rgbaImageWidth :: !Int
, rgbaImageHeight :: !Int
, rgbaImagePixels :: !ByteString
}
-- | Application-owned SDL settings.
data SdlOptions = SdlOptions
{ sdlWindowTitle :: !Text
-- ^ Window title (default: @"nano-ui"@).
, sdlWindowSize :: !Size
-- ^ Initial window size in logical units (default: 1280x800).
, sdlWindowResizable :: !Bool
-- ^ Allow the window to be resized (default: 'True').
, sdlWindowFullscreen :: !Bool
-- ^ Open the window in fullscreen mode (default: 'False').
, sdlWindowBorderless :: !Bool
-- ^ Create a borderless window (default: 'False').
, sdlWindowAlwaysOnTop :: !Bool
-- ^ Keep the window on top of other windows (default: 'False').
, sdlWindowHidden :: !Bool
-- ^ Start the window hidden (default: 'False').
, sdlAppVsync :: !Bool
-- ^ Enable vertical synchronization (default: 'True').
, sdlAppContinuous :: !Bool
-- ^ Continuous unthrottled rendering without waiting for events (default: 'False').
, sdlAppFont :: !NanoUIFont
-- ^ UI font (default: embedded Inter).
, sdlAppMonoFont :: !NanoUIFont
-- ^ Monospace font (default: embedded Inter).
, sdlAppFontSize :: !Float
-- ^ Base font size in points (default: 16).
, sdlAppTheme :: !(Maybe Theme)
-- ^ Initial UI theme override (default: 'Nothing').
, sdlAppShouldQuit :: !(Input -> Bool)
-- ^ Predicate on user input to trigger application exit (default: @const False@).\
, sdlAppImages :: !(SmallArray RgbaImage)
-- ^ Initial RGBA textures registered before the first frame.
, sdlAppUiScale :: !Float
-- ^ Zoom of the whole UI, on top of the window's pixel density (default:
-- 1). Zero or less follows the display's content scale, which on Windows
-- is the desktop's scaling setting. 'NanoUI.Backend.Sdl.setSdlUiScale'
-- changes it at runtime.
}
defaultSdlOptions :: SdlOptions
defaultSdlOptions =
SdlOptions
{ sdlWindowTitle = "nano-ui"
, sdlWindowSize = defaultWindowSize
, sdlWindowResizable = True
, sdlWindowFullscreen = False
, sdlWindowBorderless = False
, sdlWindowAlwaysOnTop = False
, sdlWindowHidden = False
, sdlAppVsync = True
, sdlAppContinuous = False
, sdlAppFont = defaultFontSearch
, sdlAppMonoFont = defaultFontSearchMono
, sdlAppFontSize = defaultFontSize
, sdlAppTheme = Nothing
, sdlAppShouldQuit = const False
, sdlAppImages = mempty
, sdlAppUiScale = 1
}
-- | SDL_WINDOW_HIGH_PIXEL_DENSITY (0x2000): without it the window's surface
-- gets scale 1.0 even on a 2x / HiDPI output, so the compositor upscales the
-- whole window (blurry "looks upscaled"). With it, SDL_GetWindowPixelDensity
-- returns the real output scale where window coordinates are points (macOS,
-- Wayland), the window keeps its logical size, and the pixel buffer (and
-- therefore the retain texture, glyph atlas, and fonts) rasterizes at the
-- native pixel density. On Windows window coordinates are already pixels, so
-- the density stays 1 whatever the desktop scaling.
windowFlags :: SdlOptions -> SDL_WindowFlags
windowFlags opts =
SDL_WindowFlags $
0x0000000000002000
.|. flag sdlWindowResizable 0x0000000000000020
.|. flag sdlWindowFullscreen 0x0000000000000001
.|. flag sdlWindowBorderless 0x0000000000000010
.|. flag sdlWindowAlwaysOnTop 0x0000000000010000
.|. flag sdlWindowHidden 0x0000000000000008
where
flag field bit = if field opts then bit else 0
-- Hidden only. Do not combine with resizable for bench windows on Windows.
sdlWindowHiddenFlag :: SDL_WindowFlags
sdlWindowHiddenFlag = SDL_WindowFlags 0x0000000000000008
scaleEpsilon :: Float
scaleEpsilon = 0.001
data SdlEnv = SdlEnv
{ sdlWindow :: Ptr SDL_Window
, sdlRenderer :: Ptr SDL_Renderer
, sdlRendererName :: !Text
, sdlBatch :: RenderBatch
, sdlFontRequestRef :: !(IORef NanoUIFont)
, sdlFontAppliedRef :: !(IORef NanoUIFont)
, sdlForcedScale :: !(Maybe Float)
-- ^ NANO_FORCE_SCALE override of the pixel density, read at startup.
, sdlScaleRef :: IORef Float
-- ^ Backbuffer pixels per layout unit: the pixel density times the zoom.
, sdlUiScaleRef :: !(IORef Float)
-- ^ The requested UI scale; see 'sdlAppUiScale'.
, sdlGlyphAtlas :: GlyphAtlas
, sdlImages :: ImageAtlas
, sdlCursors :: SdlCursors
, sdlDebug :: SdlDebugSampler
, sdlRetain :: IORef Retain
, sdlLastPresented :: IORef Bool
, sdlVsync :: !Bool
, sdlRefreshPeriod :: !Double
, sdlContinuous :: !Bool
, sdlCachedCtx :: !(IORef Context)
, sdlFontCache :: !SdlFontCache
, sdlDialogState :: !DialogState
}
-- | The retained framebuffer. The texture is allocated in blocks larger than
-- the window, so a resize drag reuses it instead of creating a render target
-- per pixel of movement; only the top-left used area is drawn and presented.
data Retain = Retain
{ retainTexture :: !(Ptr SDL_Texture)
, retainCapW :: !Int
, retainCapH :: !Int
-- ^ The texture's allocated size in pixels.
, retainW :: !Int
, retainH :: !Int
-- ^ The pixel size the last frame used.
, retainScale :: !Float
}
noRetain :: Retain
noRetain = Retain nullPtr 0 0 0 0 0
defaultWindowSize :: Size
defaultWindowSize = Size 1280 800
-- | The zoom a UI scale setting asks for: the setting itself, or for zero or
-- less the display's content scale beyond the pixel density.
resolveZoom :: Ptr SDL_Window -> Float -> IO Float
resolveZoom win setting
| setting > 0 = pure setting
| otherwise = do
display <- getWindowDisplayScale win
density <- queryWindowPixelDensity win
pure (if display > 0 then max 0.25 (display / density) else 1)
-- Layout in logical coordinates (window coordinates over the zoom);
-- draw/text rasterize at native pixel density.
syncDisplay :: Context -> SdlEnv -> Input -> IO (Context, Input)
syncDisplay ctx env inp = do
density <- maybe (queryWindowPixelDensity (sdlWindow env)) pure (sdlForcedScale env)
zoom <- resolveZoom (sdlWindow env) =<< readIORef (sdlUiScaleRef env)
let scale = density * zoom
oldScale <- readIORef (sdlScaleRef env)
let scaleChanged = abs (scale - oldScale) > scaleEpsilon
when scaleChanged $ do
-- Presents leave the renderer at 1:1 pixels; re-assert it only when the
-- pixel density moves.
ok <- setRenderScale (sdlRenderer env) 1 1
unless ok $ fail "SDL_SetRenderScale failed"
writeIORef (sdlScaleRef env) scale
setDrawSnapScale ctx scale
-- Runtime font-family switch: the app publishes its requested family through
-- 'setSdlUiFont'; resolve and apply it here, on the display thread, before
-- the next frame so the atlas, metrics, and text resolver agree.
requested <- readIORef (sdlFontRequestRef env)
applied <- readIORef (sdlFontAppliedRef env)
let fontChanged = requested /= applied
when (scaleChanged || fontChanged) $ do
source <-
if fontChanged
then resolveNanoUIFont requested
else sdlFontCacheSource (sdlFontCache env)
writeIORef (sdlFontAppliedRef env) requested
reloadSdlFontCache (sdlFontCache env) source
writeIORef (sdlCachedCtx env) . withSdlClipboard =<< withSdlFontCache (sdlFontCache env) ctx
clearMeasureCache ctx
markDirty ctx
queried <- queryWindowLogicalSize (sdlWindow env)
let unzoom (Size sw sh) = Size (sw / zoom) (sh / zoom)
winSize =
case unzoom queried of
Size 0 0 ->
case inputWindowSize inp of
Size 0 0 -> defaultWindowSize
s -> s
s -> s
V2 mx my <- queryMouseWindowPos
let mouse = V2 (mx / zoom) (my / zoom)
ctxMeasured <- readIORef (sdlCachedCtx env)
pure (ctxMeasured, inp {inputWindowSize = winSize, inputMousePos = mouse})
-- | Everything a window session is opened with, besides the context.
data WindowConfig = WindowConfig
{ wcTitle :: !Text
, wcSize :: !Size
, wcFlags :: !SDL_WindowFlags
, wcBench :: !Bool
-- ^ Hidden benchmark window: bench hints, no vsync setup or text input.
, wcVsync :: !Bool
, wcContinuous :: !Bool
, wcUiFont :: !NanoUIFont
, wcMonoFont :: !NanoUIFont
, wcFontSize :: !Float
, wcUiScale :: !Float
}
withSdl :: SdlOptions -> Context -> (Context -> SdlEnv -> IO a) -> IO a
withSdl opts ctx =
withSdlWindow
ctx
WindowConfig
{ wcTitle = sdlWindowTitle opts
, wcSize = sdlWindowSize opts
, wcFlags = windowFlags opts
, wcBench = False
, wcVsync = sdlAppVsync opts
, wcContinuous = sdlAppContinuous opts
, wcUiFont = sdlAppFont opts
, wcMonoFont = sdlAppMonoFont opts
, wcFontSize = sdlAppFontSize opts
, wcUiScale = sdlAppUiScale opts
}
withSdlBench :: Context -> (Context -> SdlEnv -> IO a) -> IO a
withSdlBench ctx =
withSdlWindow
ctx
WindowConfig
{ wcTitle = "nano-ui-bench"
, wcSize = Size 800 600
, wcFlags = sdlWindowHiddenFlag
, wcBench = True
, wcVsync = False
, wcContinuous = True
, wcUiFont = DefaultFont
, wcMonoFont = DefaultFont
, wcFontSize = defaultFontSize
, wcUiScale = 1
}
withSdlWindow :: Context -> WindowConfig -> (Context -> SdlEnv -> IO a) -> IO a
withSdlWindow ctx cfg act =
withTtf $ do
let hint name value =
BS.useAsCString name $ \cname ->
BS.useAsCString value $ \cvalue ->
void $ setHint (PtrConst.unsafeFromPtr cname) (PtrConst.unsafeFromPtr cvalue)
if wcBench cfg
then do
hint sDL_HINT_ASSERT "always_ignore"
hint sDL_HINT_RENDER_VSYNC "0"
else do
hint sDL_HINT_RENDER_VSYNC (if wcVsync cfg then "1" else "0")
-- SDL3 only auto-picks Wayland when the compositor has the fifo-v1 /
-- commit-timing-v1 protocols. Without them (sway, wlroots, many
-- others) it selects X11/XWayland, giving a scale-1 window on a
-- scale-2 (or fractional) output that the compositor upscales, so
-- text looks blurred. Native Wayland with
-- SDL_WINDOW_HIGH_PIXEL_DENSITY rasterizes at the real output scale.
-- An explicit SDL_VIDEO_DRIVER wins, and pure X11 sessions are left
-- alone.
wayland <- lookupEnv "WAYLAND_DISPLAY"
driver <- lookupEnv "SDL_VIDEO_DRIVER"
when (isJust wayland && isNothing driver) $
hint sDL_HINT_VIDEO_DRIVER "wayland"
fontSource <- resolveNanoUIFont (wcUiFont cfg)
monoSource <- resolveNanoUIFont (wcMonoFont cfg)
bracket
(startSdlWindow ctx cfg fontSource monoSource)
(\(_, env) -> stopSdlWindow (wcBench cfg) env)
(uncurry act)
startSdlWindow :: Context -> WindowConfig -> FontSource -> FontSource -> IO (Context, SdlEnv)
startSdlWindow ctx cfg fontSource monoSource = do
videoOk <- initSafe (SDL_InitFlags (fromIntegral sDL_INIT_VIDEO))
unless videoOk $ fail "SDL_Init(SDL_INIT_VIDEO) failed"
refreshOk <- initRefreshEvent
unless refreshOk $ fail "SDL_RegisterEvents failed for refresh wake"
let Size w h = wcSize cfg
bench = wcBench cfg
-- NANO_FORCE_SCALE: debug override of the pixel density.
forcedEnv <- lookupEnv "NANO_FORCE_SCALE"
let forcedScale = case forcedEnv >>= readMaybe of
Just s | s > 0 -> Just s
_ -> Nothing
env <-
TextForeign.withCString (wcTitle cfg) $ \titlePtr ->
alloca $ \winPtr ->
alloca $ \renPtr -> do
ok <-
createWindowAndRendererSafe
(PtrConst.unsafeFromPtr titlePtr)
(round w)
(round h)
(wcFlags cfg)
winPtr
renPtr
unless ok $ fail "SDL_CreateWindowAndRenderer failed"
win <- peek winPtr
ren <- peek renPtr
density <- queryWindowPixelDensity win
zoom <- resolveZoom win (wcUiScale cfg)
-- The requested size is logical, so the window grows with the zoom.
when (abs (zoom - 1) > scaleEpsilon) $
zoomWindow win (wcSize cfg) zoom
let scale = density * zoom
setDrawSnapScale ctx scale
refreshHz <- queryWindowRefreshHz win
rendererName <- getRendererName ren >>= \name ->
if PtrConst.unsafeToPtr name == nullPtr then pure "unknown" else TextForeign.peekCString (PtrConst.unsafeToPtr name)
scaleRef <- newIORef scale
uiScaleRef <- newIORef (wcUiScale cfg)
fontRequestRef <- newIORef (wcUiFont cfg)
fontAppliedRef <- newIORef (wcUiFont cfg)
glyphAtlas <- newGlyphAtlas ren
images <- newImageAtlas
cursors <- initCursors
debug <- newSdlDebugSampler
retain <- newIORef noRetain
fontCache <-
newSdlFontCache
fontSource
embeddedFontSource
monoSource
embeddedFontSource
glyphAtlas
(wcFontSize cfg)
scaleRef
cachedCtx <- newIORef . withSdlClipboard =<< withSdlFontCache fontCache ctx
let refreshPeriod =
if refreshHz > 0
then 1 / fromIntegral refreshHz
else 1 / 60
scaleOk <- setRenderScale ren 1 1
unless scaleOk $ fail "SDL_SetRenderScale failed"
unless bench $ do
void $ setRenderVSync ren (if wcVsync cfg then 1 else 0)
void $ startTextInputSafe win
dialogState <- newDialogState
lastPresented <- newIORef False
batch <- newRenderBatch ren
pure
SdlEnv
{ sdlWindow = win
, sdlRenderer = ren
, sdlRendererName = rendererName
, sdlBatch = batch
, sdlFontRequestRef = fontRequestRef
, sdlFontAppliedRef = fontAppliedRef
, sdlForcedScale = forcedScale
, sdlScaleRef = scaleRef
, sdlUiScaleRef = uiScaleRef
, sdlGlyphAtlas = glyphAtlas
, sdlImages = images
, sdlCursors = cursors
, sdlDebug = debug
, sdlRetain = retain
, sdlLastPresented = lastPresented
, sdlVsync = wcVsync cfg
, sdlRefreshPeriod = refreshPeriod
, sdlContinuous = wcContinuous cfg
, sdlCachedCtx = cachedCtx
, sdlFontCache = fontCache
, sdlDialogState = dialogState
}
ctx' <- readIORef (sdlCachedCtx env)
setHost ctx' env
setWakeLoop ctx' pushRefreshEvent
pure (ctx', env)
stopSdlWindow :: Bool -> SdlEnv -> IO ()
stopSdlWindow bench env = do
clearDialogState (sdlDialogState env)
tex <- retainTexture <$> readIORef (sdlRetain env)
unless (tex == nullPtr) $ destroyTexture tex
destroyRenderBatch (sdlBatch env)
destroyCursors (sdlCursors env)
destroyImageAtlas (sdlImages env)
destroySdlFontCache (sdlFontCache env)
destroyGlyphAtlas (sdlGlyphAtlas env)
unless bench $ void $ stopTextInputSafe (sdlWindow env)
void $ setRenderScale (sdlRenderer env) 1 1
destroyRendererSafe (sdlRenderer env)
destroyWindowSafe (sdlWindow env)
quitSafe
saveScreenshot :: SdlEnv -> FilePath -> IO Bool
saveScreenshot env path = do
surface <- renderReadPixels (sdlRenderer env) (PtrConst.unsafeFromPtr nullPtr)
if surface == nullPtr
then pure False
else withCString path $ \cpath -> do
ok <- saveBMP surface (PtrConst.unsafeFromPtr cpath)
destroySurface surface
pure ok