diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,12 +1,61 @@
 # Changelog
 
+## Unreleased
+
+### Added
+
+- Shaped text. Lines are laid out by SDL_ttf and HarfBuzz with ligatures,
+  contextual forms and marks, and drawn glyph by glyph from the atlas.
+  Arabic, Hebrew, Devanagari, CJK and other scripts the UI font lacks are
+  drawn from installed fallback fonts (Noto, DejaVu, and the Windows and
+  macOS system fonts), found the first time a text needs them.
+
+### Changed
+
+- Bold and italic are drawn by the core's synthetic weight and slant over the
+  regular face, since SDL_ttf's style flags do not match the glyph images
+  shaped text draws. Text measurement uses the shaped width, so layout and
+  drawing agree.
+- Fallback fonts for other scripts are shared across font sizes and opened
+  only when a text needs a character they cover. Each coverage font is
+  opened once a session as a probe, and each size draws from a copy that
+  shares the probe's file. Resolving 40 sizes that draw CJK, Arabic and
+  Devanagari opens 59 file descriptors instead of 959.
+- A shaped line is shaped once and shared by measuring, preparing and
+  drawing, and the font directories are walked once per process.
+- No longer depends on `vector`.
+- The framebuffer, glyph rasterization and snapping follow the window's
+  pixel density (`SDL_GetWindowPixelDensity`) instead of its display scale.
+  On Windows window coordinates are already pixels, so at 125% scaling every
+  frame drew 1.56 times the window's pixels and shrank them back when
+  presenting. Frames now draw at the window's size and text is no longer
+  resampled. Sizes are unchanged; geometry snaps to whole pixels rather
+  than to the 1.25x grid, so edges can move by up to half a pixel.
+- The retained framebuffer is allocated in 256 pixel blocks and reused while
+  the window fits, so a resize drag no longer creates a new render target
+  for every pixel the border moves.
+
+### Fixed
+
+- A resize drag no longer trails the border by a step. Each step was drawn
+  on `SDL_EVENT_WINDOW_RESIZED`, before the renderer had resized its swap
+  chain, so it went to the old-size backbuffer and showed cropped or with a
+  bare strip; frames are now drawn on the pixel size change that follows.
+
+### Removed
+
+- `newSdlContext`; the runners create their own context.
+- `isDebugActive`, `newSdlDebugSampler`, `readSdlDebug` and `takeDebugLive`
+  from `NanoUI.Backend.Sdl`. The core event loop samples debug timing; read
+  the readout with `askSdlDebug`.
+- The font debugging exports `saveFontRenderText`, `queryFontKerning`,
+  `queryFontPairKerning`, `debugFontPair` and `dumpFontLayout`.
+- `NanoUI.Sdl.Session` is no longer an exposed module.
+
+## 0.1.0.1 -- 2026-09-18
+
+* 
+
 ## 0.1.0.0
 
 First release.
-
-- `runSdlApp` and `runSdlAppReduce` run nano-ui views in an SDL3 window.
-- Text shaped with SDL_ttf and HarfBuzz, drawn from the bundled Inter font
-  or installed fonts looked up by family name, with fallback fonts for
-  scripts the UI font lacks.
-- Clipboard, cursors, display scaling, images, and native file dialogs.
-- The `simd` flag compiles the draw-batch culler with AVX2 on x86-64.
diff --git a/cbits/nano_ui_display.c b/cbits/nano_ui_display.c
--- a/cbits/nano_ui_display.c
+++ b/cbits/nano_ui_display.c
@@ -48,8 +48,12 @@
     if (!g_resize_cb || !event) {
         return true;
     }
+    /* Not SDL_EVENT_WINDOW_RESIZED: SDL sends it just before the pixel size
+     * change, which is what tells the renderer to resize its swap chain. A
+     * frame drawn on RESIZED goes to the old-size backbuffer, shown cropped
+     * or with a bare strip, and the size then counts as drawn, so the window
+     * trails the drag by a step. */
     if (event->type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED
-        || event->type == SDL_EVENT_WINDOW_RESIZED
         || event->type == SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED) {
         g_resize_cb();
     }
diff --git a/data/inter-LICENSE.txt b/data/inter-LICENSE.txt
deleted file mode 100644
--- a/data/inter-LICENSE.txt
+++ /dev/null
@@ -1,92 +0,0 @@
-Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
-
-This Font Software is licensed under the SIL Open Font License, Version 1.1.
-This license is copied below, and is also available with a FAQ at:
-http://scripts.sil.org/OFL
-
------------------------------------------------------------
-SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
------------------------------------------------------------
-
-PREAMBLE
-The goals of the Open Font License (OFL) are to stimulate worldwide
-development of collaborative font projects, to support the font creation
-efforts of academic and linguistic communities, and to provide a free and
-open framework in which fonts may be shared and improved in partnership
-with others.
-
-The OFL allows the licensed fonts to be used, studied, modified and
-redistributed freely as long as they are not sold by themselves. The
-fonts, including any derivative works, can be bundled, embedded,
-redistributed and/or sold with any software provided that any reserved
-names are not used by derivative works. The fonts and derivatives,
-however, cannot be released under any other type of license. The
-requirement for fonts to remain under this license does not apply
-to any document created using the fonts or their derivatives.
-
-DEFINITIONS
-"Font Software" refers to the set of files released by the Copyright
-Holder(s) under this license and clearly marked as such. This may
-include source files, build scripts and documentation.
-
-"Reserved Font Name" refers to any names specified as such after the
-copyright statement(s).
-
-"Original Version" refers to the collection of Font Software components as
-distributed by the Copyright Holder(s).
-
-"Modified Version" refers to any derivative made by adding to, deleting,
-or substituting -- in part or in whole -- any of the components of the
-Original Version, by changing formats or by porting the Font Software to a
-new environment.
-
-"Author" refers to any designer, engineer, programmer, technical
-writer or other person who contributed to the Font Software.
-
-PERMISSION AND CONDITIONS
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of the Font Software, to use, study, copy, merge, embed, modify,
-redistribute, and sell modified and unmodified copies of the Font
-Software, subject to the following conditions:
-
-1) Neither the Font Software nor any of its individual components,
-in Original or Modified Versions, may be sold by itself.
-
-2) Original or Modified Versions of the Font Software may be bundled,
-redistributed and/or sold with any software, provided that each copy
-contains the above copyright notice and this license. These can be
-included either as stand-alone text files, human-readable headers or
-in the appropriate machine-readable metadata fields within text or
-binary files as long as those fields can be easily viewed by the user.
-
-3) No Modified Version of the Font Software may use the Reserved Font
-Name(s) unless explicit written permission is granted by the corresponding
-Copyright Holder. This restriction only applies to the primary font name as
-presented to the users.
-
-4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
-Software shall not be used to promote, endorse or advertise any
-Modified Version, except to acknowledge the contribution(s) of the
-Copyright Holder(s) and the Author(s) or with their explicit written
-permission.
-
-5) The Font Software, modified or unmodified, in part or in whole,
-must be distributed entirely under this license, and must not be
-distributed under any other license. The requirement for fonts to
-remain under this license does not apply to any document created
-using the Font Software.
-
-TERMINATION
-This license becomes null and void if any of the above conditions are
-not met.
-
-DISCLAIMER
-THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
-OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
-COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
-DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
-OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/lib/NanoUI/Backend/Sdl.hs b/lib/NanoUI/Backend/Sdl.hs
--- a/lib/NanoUI/Backend/Sdl.hs
+++ b/lib/NanoUI/Backend/Sdl.hs
@@ -6,6 +6,7 @@
   , SdlOptions (..)
   , askSdlDebug
   , setSdlUiFont
+  , setSdlUiScale
   , defaultSdlOptions
   , FileFilter (..)
   , FileDialogOptions (..)
@@ -38,7 +39,7 @@
 import Data.Primitive.SmallArray (SmallArray)
 import Data.Typeable (Typeable)
 import NanoUI (NanoUI)
-import NanoUI.Sdl.Runner (askSdlDebug, drawReduceEff, sdlDrawFrame, setSdlUiFont)
+import NanoUI.Sdl.Runner (askSdlDebug, drawReduceEff, sdlDrawFrame, setSdlUiFont, setSdlUiScale)
 import NanoUI.Sdl.Session (runSdlSession)
 import NanoUI.Sdl.Debug (SdlDebugSnapshot (..))
 import NanoUI.Sdl.Window (RgbaImage (..), SdlEnv (..), SdlOptions (..), defaultSdlOptions, saveScreenshot, syncDisplay, withSdl, withSdlBench)
diff --git a/lib/NanoUI/Sdl/Display.hs b/lib/NanoUI/Sdl/Display.hs
--- a/lib/NanoUI/Sdl/Display.hs
+++ b/lib/NanoUI/Sdl/Display.hs
@@ -2,10 +2,11 @@
 
 module NanoUI.Sdl.Display
   ( defaultFontSize
-  , queryWindowDisplayScale
+  , queryWindowPixelDensity
   , queryWindowRefreshHz
   , queryWindowLogicalSize
   , queryMouseWindowPos
+  , zoomWindow
   , installResizeWatch
   , refreshEventType
   , initRefreshEvent
@@ -25,15 +26,23 @@
 import SDL3.Sys.Bindgen.Video (SDL_Window)
 import SDL3.Sys.Events (pushEvent, registerEvents)
 import SDL3.Sys.Mouse (getMouseState)
-import SDL3.Sys.Video (getWindowDisplayScale, getWindowSize)
+import SDL3.Sys.Bindgen.Rect (SDL_Rect (..))
+import SDL3.Sys.Video (getDisplayForWindow, getDisplayUsableBounds, getWindowPixelDensity, getWindowSize, setWindowPosition, setWindowSize)
 import System.IO.Unsafe (unsafePerformIO)
 
 defaultFontSize :: Float
 defaultFontSize = 16
 
-queryWindowDisplayScale :: Ptr SDL_Window -> IO Float
-queryWindowDisplayScale win = do
-  s <- getWindowDisplayScale win
+-- | Backbuffer pixels per window coordinate: the factor the retained
+-- framebuffer, glyph rasterization and snapping need. This is not
+-- 'SDL_GetWindowDisplayScale', which also folds in the desktop's content
+-- scale. On Windows window coordinates are already pixels, so at 125%
+-- scaling the display scale is 1.25 while the density is 1; sizing the
+-- framebuffer by the display scale rendered 1.56x the window's pixels and
+-- squeezed them back down on every present.
+queryWindowPixelDensity :: Ptr SDL_Window -> IO Float
+queryWindowPixelDensity win = do
+  s <- getWindowPixelDensity win
   pure (if s > 0 then s else 1)
 
 -- | Vertical refresh rate of the window's current display mode, in Hz
@@ -122,3 +131,20 @@
 
 foreign import ccall safe "nano_ui_remove_resize_watch"
   removeResizeWatchC :: IO ()
+
+-- | Grow a window just opened at a logical size by the UI zoom, as far as
+-- the usable area of its display allows, and centre it again.
+zoomWindow :: Ptr SDL_Window -> Size -> Float -> IO ()
+zoomWindow win (Size w h) zoom = do
+  display <- getDisplayForWindow win
+  usable <- alloca $ \rp -> do
+    ok <- getDisplayUsableBounds display rp
+    if ok then Just <$> peek rp else pure Nothing
+  let fit want avail = case avail of
+        Just a | a > 0 -> min want (fromIntegral a)
+        _ -> want
+      zw = fit (w * zoom) ((.w) <$> usable)
+      zh = fit (h * zoom) ((.h) <$> usable)
+      centred = 0x2FFF0000 -- SDL_WINDOWPOS_CENTERED
+  void $ setWindowSize win (round zw) (round zh)
+  void $ setWindowPosition win centred centred
diff --git a/lib/NanoUI/Sdl/Font.hs b/lib/NanoUI/Sdl/Font.hs
--- a/lib/NanoUI/Sdl/Font.hs
+++ b/lib/NanoUI/Sdl/Font.hs
@@ -1272,12 +1272,12 @@
   , sfcGlyphAtlas     :: !GlyphAtlas
   , sfcBasePt         :: !Float
   , sfcScaleRef       :: !(IORef Float)
-  -- ^ The display scale, owned by the window and read here.
+  -- ^ The window pixel density, owned by the window and read here.
   , sfcBaseEntries    :: !(IORef (CachedFontEntry, CachedFontEntry))
   , sfcDynamicCache   :: !(IORef (BoundedCache FontCacheKey CachedFontEntry))
   }
 
--- | Open the base sans and mono fonts at the display scale, and re-warm them
+-- | Open the base sans and mono fonts at the pixel density, and re-warm them
 -- into the glyph atlas after every atlas reset.
 newSdlFontCache ::
   FontSource -> -- ^ primary font source
@@ -1286,7 +1286,7 @@
   FontSource -> -- ^ mono fallback font source
   GlyphAtlas ->
   Float ->      -- ^ base font size (pt)
-  IORef Float -> -- ^ display scale
+  IORef Float -> -- ^ pixel density
   IO SdlFontCache
 newSdlFontCache primary fallback mono monoFb ga basePt scaleRef = do
   scale <- readIORef scaleRef
@@ -1317,7 +1317,7 @@
       }
 
 -- | A font from a source (or its fallback) at a point size, rasterised at
--- the display scale, with its glyph metrics.
+-- the pixel density, with its glyph metrics.
 openCachedFont :: GlyphAtlas -> Float -> FontSource -> FontSource -> Float -> IO CachedFontEntry
 openCachedFont ga scale primary fallback pt = do
   font <- openFontSourceWithFallback primary fallback (pt * scale)
@@ -1344,7 +1344,7 @@
   (sans, mono) <- readIORef (sfcBaseEntries cache)
   closeCachedFonts (sfcGlyphAtlas cache) (cfeFont sans : cfeFont mono : map cfeFont (HM.elems (bcEntries dynamic)))
 
--- | Reopen the base fonts from @source@ at the current display scale, close
+-- | Reopen the base fonts from @source@ at the current pixel density, close
 -- every dynamic size, and reset the glyph atlas, which re-warms the new base
 -- fonts.
 reloadSdlFontCache :: SdlFontCache -> FontSource -> IO ()
diff --git a/lib/NanoUI/Sdl/Image.hs b/lib/NanoUI/Sdl/Image.hs
--- a/lib/NanoUI/Sdl/Image.hs
+++ b/lib/NanoUI/Sdl/Image.hs
@@ -15,9 +15,8 @@
 import Foreign.Ptr (Ptr, castPtr, nullPtr)
 import NanoUI.Testing (Context, atlasSnapshot, atlasTextureId)
 import SDL3.Sys.Bindgen.Blendmode (sDL_BLENDMODE_BLEND)
-import SDL3.Sys.Bindgen.Pixels qualified as Pixels
-import SDL3.Sys.Bindgen.Render (SDL_Renderer, SDL_Texture)
-import SDL3.Sys.Bindgen.Render qualified as Render
+import SDL3.Sys.Bindgen.Pixels (data SDL_PIXELFORMAT_RGBA32)
+import SDL3.Sys.Bindgen.Render (SDL_Renderer, SDL_Texture, data SDL_TEXTUREACCESS_STATIC)
 import SDL3.Sys.Bindgen.Runtime.PtrConst qualified as PtrConst
 import SDL3.Sys.Render (createTextureSafe, destroyTexture, setTextureBlendMode, updateTextureSafe)
 
@@ -52,7 +51,7 @@
   Ptr SDL_Renderer -> ImageAtlas -> Int -> Int -> ForeignPtr Word8 -> Int -> IO ()
 uploadAtlas ren (ImageAtlas ref) w h pixels gen = mask_ $
   withForeignPtr pixels $ \ptr -> do
-    tex <- createTextureSafe ren Pixels.SDL_PIXELFORMAT_RGBA32 Render.SDL_TEXTUREACCESS_STATIC (fromIntegral w) (fromIntegral h)
+    tex <- createTextureSafe ren SDL_PIXELFORMAT_RGBA32 SDL_TEXTUREACCESS_STATIC (fromIntegral w) (fromIntegral h)
     ok <-
       if tex == nullPtr
         then pure False
diff --git a/lib/NanoUI/Sdl/Input.hs b/lib/NanoUI/Sdl/Input.hs
--- a/lib/NanoUI/Sdl/Input.hs
+++ b/lib/NanoUI/Sdl/Input.hs
@@ -42,8 +42,24 @@
   ( SDL_Event (..)
   , SDL_EventType (..)
   , SDL_KeyboardEvent
+  , data SDL_EVENT_DROP_BEGIN
+  , data SDL_EVENT_DROP_COMPLETE
+  , data SDL_EVENT_DROP_FILE
+  , data SDL_EVENT_DROP_POSITION
+  , data SDL_EVENT_DROP_TEXT
+  , data SDL_EVENT_KEY_DOWN
+  , data SDL_EVENT_MOUSE_BUTTON_DOWN
+  , data SDL_EVENT_MOUSE_BUTTON_UP
+  , data SDL_EVENT_MOUSE_MOTION
+  , data SDL_EVENT_MOUSE_WHEEL
+  , data SDL_EVENT_QUIT
+  , data SDL_EVENT_TEXT_INPUT
+  , data SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED
+  , data SDL_EVENT_WINDOW_EXPOSED
+  , data SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED
+  , data SDL_EVENT_WINDOW_RESIZED
+  , data SDL_EVENT_WINDOW_RESTORED
   )
-import SDL3.Sys.Bindgen.Events qualified as Events
 import SDL3.Sys.Events (pollEventSafe, waitEventSafe, waitEventTimeoutSafe)
 import SDL3.Sys.Bindgen.Keycode
   ( SDL_Keycode (..)
@@ -116,27 +132,27 @@
   if refreshTy /= 0 && w == refreshTy
     then pure (Just EvRefresh)
     else case SDL_EventType (fromIntegral w) of
-      Events.SDL_EVENT_QUIT -> pure (Just EvQuit)
-      Events.SDL_EVENT_WINDOW_RESIZED -> Just <$> windowResized p
+      SDL_EVENT_QUIT -> pure (Just EvQuit)
+      SDL_EVENT_WINDOW_RESIZED -> Just <$> windowResized p
       -- Pixel size changes are ignored here; syncDisplay re-queries logical size.
-      Events.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED -> pure (Just EvDisplayScale)
-      Events.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED -> pure (Just EvDisplayScale)
+      SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED -> pure (Just EvDisplayScale)
+      SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED -> pure (Just EvDisplayScale)
       -- The window manager damaged our window surface (occlusion, compositor
       -- effects, restore). The backbuffer contents are gone; the next present
       -- must be full or stale regions flash.
-      Events.SDL_EVENT_WINDOW_EXPOSED -> pure (Just EvWindowRedraw)
-      Events.SDL_EVENT_WINDOW_RESTORED -> pure (Just EvWindowRedraw)
-      Events.SDL_EVENT_KEY_DOWN -> keyDown p
-      Events.SDL_EVENT_TEXT_INPUT -> textInput p
-      Events.SDL_EVENT_MOUSE_MOTION -> Just <$> mouseMotion p
-      Events.SDL_EVENT_MOUSE_BUTTON_DOWN -> mouseButton p True
-      Events.SDL_EVENT_MOUSE_BUTTON_UP -> mouseButton p False
-      Events.SDL_EVENT_MOUSE_WHEEL -> Just <$> mouseWheel p
-      Events.SDL_EVENT_DROP_FILE -> Just <$> dropEvent p DropFile
-      Events.SDL_EVENT_DROP_TEXT -> Just <$> dropEvent p DropText
-      Events.SDL_EVENT_DROP_BEGIN -> Just <$> dropEvent p DropBegin
-      Events.SDL_EVENT_DROP_COMPLETE -> Just <$> dropEvent p DropComplete
-      Events.SDL_EVENT_DROP_POSITION -> Just <$> dropEvent p DropPosition
+      SDL_EVENT_WINDOW_EXPOSED -> pure (Just EvWindowRedraw)
+      SDL_EVENT_WINDOW_RESTORED -> pure (Just EvWindowRedraw)
+      SDL_EVENT_KEY_DOWN -> keyDown p
+      SDL_EVENT_TEXT_INPUT -> textInput p
+      SDL_EVENT_MOUSE_MOTION -> Just <$> mouseMotion p
+      SDL_EVENT_MOUSE_BUTTON_DOWN -> mouseButton p True
+      SDL_EVENT_MOUSE_BUTTON_UP -> mouseButton p False
+      SDL_EVENT_MOUSE_WHEEL -> Just <$> mouseWheel p
+      SDL_EVENT_DROP_FILE -> Just <$> dropEvent p DropFile
+      SDL_EVENT_DROP_TEXT -> Just <$> dropEvent p DropText
+      SDL_EVENT_DROP_BEGIN -> Just <$> dropEvent p DropBegin
+      SDL_EVENT_DROP_COMPLETE -> Just <$> dropEvent p DropComplete
+      SDL_EVENT_DROP_POSITION -> Just <$> dropEvent p DropPosition
       _ -> pure Nothing
 
 keyDown :: Ptr SDL_Event -> IO (Maybe SdlEvent)
diff --git a/lib/NanoUI/Sdl/Runner.hs b/lib/NanoUI/Sdl/Runner.hs
--- a/lib/NanoUI/Sdl/Runner.hs
+++ b/lib/NanoUI/Sdl/Runner.hs
@@ -6,6 +6,7 @@
   , drawReduceEff
   , askSdlDebug
   , setSdlUiFont
+  , setSdlUiScale
   ) where
 
 import Control.Exception (finally, mask_)
@@ -46,7 +47,7 @@
   , emptySdlDebug
   , traceFrame
   )
-import NanoUI.Sdl.Display (queryMouseWindowPos, queryWindowLogicalSize)
+import NanoUI.Sdl.Display (pushRefreshEvent, queryMouseWindowPos, queryWindowLogicalSize)
 import NanoUI.Sdl.Font
   ( fontSourceLabel
   , glyphAtlasTexture
@@ -56,15 +57,16 @@
   )
 import NanoUI.Sdl.NanoUIFont (NanoUIFont)
 import NanoUI.Sdl.Render (flushRenderBatch, renderDrawDataPass, snapDamage)
-import NanoUI.Sdl.Window (SdlEnv (..))
+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 qualified as Pixels
-import SDL3.Sys.Bindgen.Render (SDL_Texture)
-import SDL3.Sys.Bindgen.Render qualified as Render
+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
@@ -214,7 +216,11 @@
             okTarget <- setRenderTarget (sdlRenderer env) nullPtr
             okClip <- setRenderClipRect (sdlRenderer env) (PtrConst.unsafeFromPtr nullPtr)
             void $ setRenderScale (sdlRenderer env) 1 1
-            okCopy <- renderTexture (sdlRenderer env) tex (PtrConst.unsafeFromPtr nullPtr) (PtrConst.unsafeFromPtr nullPtr)
+            -- 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)
@@ -227,23 +233,36 @@
       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
-  (tex, ow, oh, oldScale) <- readIORef (sdlRetain env)
-  let scaleChanged = abs (oldScale - scale) > 0.001
-  if tex /= nullPtr && ow == w && oh == h
+  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 scaleChanged $ writeIORef (sdlRetain env) (tex, w, h, scale)
-      -- Same pixel size after a DPI change still holds the old present.
-      pure (tex, scaleChanged)
+      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) Pixels.SDL_PIXELFORMAT_RGBA32 Render.SDL_TEXTUREACCESS_TARGET (fromIntegral w) (fromIntegral h)
+      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) (tex', w, h, scale)
+      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
@@ -281,3 +300,18 @@
     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
diff --git a/lib/NanoUI/Sdl/Window.hs b/lib/NanoUI/Sdl/Window.hs
--- a/lib/NanoUI/Sdl/Window.hs
+++ b/lib/NanoUI/Sdl/Window.hs
@@ -1,6 +1,8 @@
 module NanoUI.Sdl.Window
   ( RgbaImage (..)
   , SdlEnv (..)
+  , Retain (..)
+  , noRetain
   , SdlOptions (..)
   , defaultSdlOptions
   , withSdl
@@ -25,7 +27,7 @@
 import Foreign.Marshal.Alloc (alloca)
 import Foreign.Ptr (Ptr, nullPtr)
 import Foreign.Storable (peek)
-import NanoUI (ImageId, Input (..), Size (..), Theme)
+import NanoUI (ImageId, Input (..), Size (..), Theme, V2 (..))
 import NanoUI.Context (Context (..), setDrawSnapScale)
 import NanoUI.Testing (clearMeasureCache, markDirty, setHost, setWakeLoop)
 import NanoUI.Sdl.Display
@@ -33,9 +35,10 @@
   , initRefreshEvent
   , pushRefreshEvent
   , queryMouseWindowPos
-  , queryWindowDisplayScale
+  , queryWindowPixelDensity
   , queryWindowLogicalSize
   , queryWindowRefreshHz
+  , zoomWindow
   )
 import NanoUI.Sdl.Clipboard (withSdlClipboard)
 import NanoUI.Sdl.Cursor (SdlCursors (..), destroyCursors, initCursors)
@@ -81,7 +84,7 @@
   , setRenderVSync
   )
 import SDL3.Sys.Surface (destroySurface, saveBMP)
-import SDL3.Sys.Video (destroyWindowSafe)
+import SDL3.Sys.Video (destroyWindowSafe, getWindowDisplayScale)
 
 -- | Initial RGBA asset uploaded before the first frame.
 data RgbaImage = RgbaImage
@@ -123,6 +126,11 @@
   -- ^ 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
@@ -143,14 +151,17 @@
     , 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_GetWindowDisplayScale
--- returns the real output scale, 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.
+-- 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 $
@@ -178,13 +189,16 @@
   , sdlFontRequestRef :: !(IORef NanoUIFont)
   , sdlFontAppliedRef :: !(IORef NanoUIFont)
   , sdlForcedScale :: !(Maybe Float)
-  -- ^ NANO_FORCE_SCALE override of the display scale, read at startup.
+  -- ^ 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 (Ptr SDL_Texture, Int, Int, Float)
+  , sdlRetain :: IORef Retain
   , sdlLastPresented :: IORef Bool
   , sdlVsync :: !Bool
   , sdlRefreshPeriod :: !Double
@@ -194,18 +208,48 @@
   , 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
 
--- Layout in logical coordinates; draw/text rasterize at native pixel density.
+-- | 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
-  scale <- maybe (queryWindowDisplayScale (sdlWindow env)) pure (sdlForcedScale env)
+  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
-    -- display scale moves.
+    -- pixel density moves.
     ok <- setRenderScale (sdlRenderer env) 1 1
     unless ok $ fail "SDL_SetRenderScale failed"
     writeIORef (sdlScaleRef env) scale
@@ -227,14 +271,16 @@
     clearMeasureCache ctx
     markDirty ctx
   queried <- queryWindowLogicalSize (sdlWindow env)
-  let winSize =
-        case queried of
+  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
-  mouse <- queryMouseWindowPos
+  V2 mx my <- queryMouseWindowPos
+  let mouse = V2 (mx / zoom) (my / zoom)
   ctxMeasured <- readIORef (sdlCachedCtx env)
   pure (ctxMeasured, inp {inputWindowSize = winSize, inputMousePos = mouse})
 
@@ -250,6 +296,7 @@
   , wcUiFont :: !NanoUIFont
   , wcMonoFont :: !NanoUIFont
   , wcFontSize :: !Float
+  , wcUiScale :: !Float
   }
 
 withSdl :: SdlOptions -> Context -> (Context -> SdlEnv -> IO a) -> IO a
@@ -266,6 +313,7 @@
       , wcUiFont = sdlAppFont opts
       , wcMonoFont = sdlAppMonoFont opts
       , wcFontSize = sdlAppFontSize opts
+      , wcUiScale = sdlAppUiScale opts
       }
 
 withSdlBench :: Context -> (Context -> SdlEnv -> IO a) -> IO a
@@ -282,6 +330,7 @@
       , wcUiFont = DefaultFont
       , wcMonoFont = DefaultFont
       , wcFontSize = defaultFontSize
+      , wcUiScale = 1
       }
 
 withSdlWindow :: Context -> WindowConfig -> (Context -> SdlEnv -> IO a) -> IO a
@@ -324,7 +373,7 @@
   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 display scale.
+  -- 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
@@ -344,19 +393,25 @@
           unless ok $ fail "SDL_CreateWindowAndRenderer failed"
           win <- peek winPtr
           ren <- peek renPtr
-          scale <- queryWindowDisplayScale win
+          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 (nullPtr, 0, 0, 0)
+          retain <- newIORef noRetain
           fontCache <-
             newSdlFontCache
               fontSource
@@ -389,6 +444,7 @@
               , sdlFontAppliedRef = fontAppliedRef
               , sdlForcedScale = forcedScale
               , sdlScaleRef = scaleRef
+              , sdlUiScaleRef = uiScaleRef
               , sdlGlyphAtlas = glyphAtlas
               , sdlImages = images
               , sdlCursors = cursors
@@ -410,7 +466,7 @@
 stopSdlWindow :: Bool -> SdlEnv -> IO ()
 stopSdlWindow bench env = do
   clearDialogState (sdlDialogState env)
-  (tex, _, _, _) <- readIORef (sdlRetain env)
+  tex <- retainTexture <$> readIORef (sdlRetain env)
   unless (tex == nullPtr) $ destroyTexture tex
   destroyRenderBatch (sdlBatch env)
   destroyCursors (sdlCursors env)
diff --git a/nano-ui-sdl.cabal b/nano-ui-sdl.cabal
--- a/nano-ui-sdl.cabal
+++ b/nano-ui-sdl.cabal
@@ -1,33 +1,24 @@
 cabal-version:      3.4
 name:               nano-ui-sdl
-version:            0.1.0.0
+version:            0.1.0.1
 synopsis:           SDL3 window backend for nano-ui
 description:
     Runs nano-ui views in an SDL3 window with TrueType text and native file
     dialogs. Requires SDL3, SDL3_ttf, and pkg-config.
-license:            MIT AND OFL-1.1
+license:            MIT
 license-file:       LICENSE
 author:             goolord
 maintainer:         zacharyachurchill@gmail.com
 category:           Graphics
-homepage:           https://github.com/goolord/nano-ui
-bug-reports:        https://github.com/goolord/nano-ui/issues
 build-type:         Simple
-tested-with:        GHC ==9.10.3 || ==9.14.1
 extra-doc-files:
     CHANGELOG.md
     README.md
-    data/inter-LICENSE.txt
 
 extra-source-files:
     data/inter.ttf
     cbits/*.h
 
-source-repository head
-    type:     git
-    location: https://github.com/goolord/nano-ui.git
-    subdir:   packages/nano-ui-sdl
-
 flag sdl
     description: Build the SDL3 backend. Needs SDL3, SDL3_ttf, and pkg-config.
     manual: True
@@ -92,21 +83,21 @@
         NanoUI.Sdl.Render
         NanoUI.Sdl.Window
     build-depends:
-        base >=4.20 && <4.23,
-        bytestring >=0.11 && <0.13,
-        containers >=0.6.7 && <0.9,
-        directory >=1.3.7 && <1.4,
-        dir-traverse >=0.2.3 && <0.3,
-        effectful-core >=2.5 && <2.8,
-        filepath >=1.4.100 && <1.6,
-        file-embed >=0.0.16 && <0.1,
-        hashable >=1.4 && <1.6,
+        base ^>=4.22.0.0,
+        bytestring ^>=0.12,
+        containers ^>=0.8,
+        directory ^>=1.3,
+        dir-traverse ^>=0.2.3.0,
+        effectful-core ^>=2.6,
+        filepath ^>=1.5,
+        file-embed ^>=0.0.16,
+        hashable ^>=1.5,
         nano-ui ^>=0.1,
-        primitive >=0.8 && <0.10,
-        record-hasfield >=1.0 && <1.1,
-        sdl3-bindgen-sys >=0.0.0.3 && <0.0.1,
-        text >=2.1.2 && <2.2,
-        unordered-containers >=0.2.19 && <0.3
+        primitive ^>=0.9,
+        record-hasfield ^>=1.0,
+        sdl3-bindgen-sys ^>=0.0.0.2,
+        text ^>=2.1,
+        unordered-containers ^>=0.2
     hs-source-dirs:   lib
     if !flag(sdl)
         buildable: False
@@ -128,11 +119,11 @@
     hs-source-dirs:   test, lib
     other-modules:    NanoUI.Sdl.Font.Search
     build-depends:
-        base >=4.20 && <4.23,
-        containers >=0.6.7 && <0.9,
-        directory >=1.3.7 && <1.4,
-        dir-traverse >=0.2.3 && <0.3,
-        filepath >=1.4.100 && <1.6
+        base ^>=4.22.0.0,
+        containers ^>=0.8,
+        directory ^>=1.3,
+        dir-traverse ^>=0.2.3.0,
+        filepath ^>=1.5
 
 test-suite nano-ui-font-effects-test
     import:           extensions, warnings, rts-options
@@ -140,11 +131,11 @@
     main-is:          FontEffects.hs
     hs-source-dirs:   test
     build-depends:
-        base >=4.20 && <4.23,
+        base ^>=4.22.0.0,
         nano-ui,
         nano-ui-sdl,
-        primitive >=0.8 && <0.10,
-        text >=2.1.2 && <2.2
+        primitive ^>=0.9,
+        text ^>=2.1
     if !flag(sdl)
         buildable: False
 
@@ -154,10 +145,10 @@
     import:           warnings
     main-is:          SdlAnim.hs
     build-depends:
-        base >=4.20 && <4.23,
+        base ^>=4.22.0.0,
         nano-ui,
         nano-ui-sdl,
-        text >=2.1.2 && <2.2
+        text ^>=2.1
     hs-source-dirs:   examples
     if os(windows)
         ghc-options: -optl-mconsole
@@ -172,12 +163,12 @@
     type:             exitcode-stdio-1.0
     main-is:          SdlBench.hs
     build-depends:
-        base >=4.20 && <4.23,
+        base ^>=4.22.0.0,
         nano-ui,
         nano-ui-sdl,
-        tasty-bench >=0.3 && <0.6
+        tasty-bench ^>=0.4
     if os(windows)
-        build-depends: Win32 >=2.13 && <2.15
+        build-depends: Win32 ^>=2.14
     hs-source-dirs:   benchmark
     if os(windows)
         ghc-options: -optl-mconsole
