packages feed

brillo-rendering-2.0.0: Brillo/Internals/Rendering/TrueTypeFont.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}

-- | Rendering support for TrueType fonts using the FreeType bindings.
module Brillo.Internals.Rendering.TrueTypeFont (
  renderTrueTypeText,
) where

import Brillo.Internals.Rendering.Common (gf)
import Control.Applicative ((<|>))
import Control.Concurrent.MVar (MVar, modifyMVar, newMVar)
import Control.Exception (try)
import Control.Monad (foldM, forM, forM_)
import Data.Either (partitionEithers)
import Data.Int (Int32)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as T
import Data.Word (Word8)
import Foreign (Ptr, nullPtr, peek)
import Foreign.ForeignPtr (ForeignPtr, mallocForeignPtrBytes, withForeignPtr)
import Foreign.Storable (peekElemOff, pokeElemOff)
import FreeType (
  FT_Bitmap (bBuffer, bRows, bWidth),
  FT_Face,
  FT_FaceRec (frGlyph),
  FT_GlyphSlotRec (
    gsrAdvance,
    gsrBitmap,
    gsrBitmap_left,
    gsrBitmap_top
  ),
  FT_Vector (vX),
  ft_Done_Face,
  ft_Done_FreeType,
  ft_Init_FreeType,
  ft_Load_Char,
  ft_New_Face,
  ft_Set_Pixel_Sizes,
  pattern FT_LOAD_RENDER,
 )
import FreeType.Exception (FtError)
import Graphics.Rendering.OpenGL (
  TextureFunction (Modulate),
  get,
  textureBinding,
  ($=),
 )
import Graphics.Rendering.OpenGL.GL qualified as GL
import Graphics.UI.GLFW qualified as GLFW
import System.IO.Unsafe (unsafePerformIO)


-- Glyph data ----------------------------------------------------------------

data Glyph
  = Glyph
  { glyphTexture :: !(Maybe GL.TextureObject)
  , glyphSize :: !(Int, Int)
  , glyphBearing :: !(Int32, Int32)
  , glyphAdvance :: !Int32
  }


type FontKey = (FilePath, Int)
type FontCache = Map FontKey (Map Char Glyph)


fontCache :: MVar FontCache
fontCache = unsafePerformIO (newMVar Map.empty)
{-# NOINLINE fontCache #-}


-- | Detect the content scale factor (DPI scale) for HiDPI/Retina displays
getContentScale :: IO Float
getContentScale = do
  mWindow <- GLFW.getCurrentContext
  case mWindow of
    Nothing -> pure 1.0 -- No window context, assume scale 1.0
    Just win -> do
      (fbWidth, fbHeight) <- GLFW.getFramebufferSize win
      (winWidth, winHeight) <- GLFW.getWindowSize win
      -- Calculate scale factor from framebuffer vs window size
      -- Use width as primary, fall back to height if width is zero
      let scaleX = if winWidth > 0 then fromIntegral fbWidth / fromIntegral winWidth else 1.0
          scaleY = if winHeight > 0 then fromIntegral fbHeight / fromIntegral winHeight else 1.0
      -- Use the maximum scale factor to ensure crisp rendering
      pure $ max scaleX scaleY


renderTrueTypeText :: FilePath -> Int -> Text -> IO ()
renderTrueTypeText fontPath pixelHeight str
  | T.null str = pure ()
  | otherwise = do
      -- Detect DPI scale factor for HiDPI/Retina displays
      scaleFactor <- getContentScale
      let scaledHeight = round (fromIntegral pixelHeight * scaleFactor)

      glyphs <- getOrLoadFont fontPath scaledHeight
      let fallbackGlyph = Map.lookup '?' glyphs
      GL.texture GL.Texture2D $= GL.Enabled
      oldTexFunc <- get GL.textureFunction
      GL.textureFunction $= Modulate
      GL.preservingMatrix $ do
        -- Scale down by the same factor to maintain logical size
        GL.scale
          (1.0 / realToFrac scaleFactor)
          (1.0 / realToFrac scaleFactor)
          (1.0 :: GL.GLfloat)
        _ <-
          foldM
            (renderGlyph glyphs fallbackGlyph scaledHeight)
            (0, 0)
            (T.unpack str)
        pure ()
      GL.textureFunction $= oldTexFunc
      GL.texture GL.Texture2D $= GL.Disabled


renderGlyph ::
  Map Char Glyph ->
  Maybe Glyph ->
  Int ->
  (Float, Float) ->
  Char ->
  IO (Float, Float)
renderGlyph glyphs fallback lineHeight (penX, penY) = \case
  '\n' ->
    pure (0, penY - fromIntegral lineHeight)
  ch ->
    do
      let glyph = Map.lookup ch glyphs <|> fallback
      case glyph of
        Nothing ->
          pure (penX, penY)
        Just
          Glyph{glyphTexture, glyphSize = (w, h), glyphBearing = (bx, by), glyphAdvance} ->
            do
              let advance = fromIntegral glyphAdvance / 64
                  xpos = penX + fromIntegral bx
                  ypos = penY + fromIntegral by - fromIntegral h
                  width = fromIntegral w
                  height = fromIntegral h
              case glyphTexture of
                Nothing ->
                  pure (penX + advance, penY)
                Just tex -> do
                  textureBinding GL.Texture2D $= Just tex
                  GL.renderPrimitive GL.Quads $ do
                    GL.texCoord $ GL.TexCoord2 0 (0 :: GL.GLfloat)
                    GL.vertex $ GL.Vertex3 (gf xpos) (gf (ypos + height)) 0
                    GL.texCoord $ GL.TexCoord2 0 (1 :: GL.GLfloat)
                    GL.vertex $ GL.Vertex3 (gf xpos) (gf ypos) 0
                    GL.texCoord $ GL.TexCoord2 1 (1 :: GL.GLfloat)
                    GL.vertex $ GL.Vertex3 (gf (xpos + width)) (gf ypos) 0
                    GL.texCoord $ GL.TexCoord2 1 (0 :: GL.GLfloat)
                    GL.vertex $ GL.Vertex3 (gf (xpos + width)) (gf (ypos + height)) 0
                  pure (penX + advance, penY)


-- Font loading --------------------------------------------------------------

getOrLoadFont :: FilePath -> Int -> IO (Map Char Glyph)
getOrLoadFont fontPath pixelHeight =
  modifyMVar fontCache $
    \cache ->
      case Map.lookup key cache of
        Just font ->
          pure (cache, font)
        Nothing -> do
          font <- loadFont fontPath pixelHeight
          pure (Map.insert key font cache, font)
  where
    key = (fontPath, pixelHeight)


loadFont :: FilePath -> Int -> IO (Map Char Glyph)
loadFont fontPath pixelHeight = do
  library <- ft_Init_FreeType
  face <- ft_New_Face library fontPath 0
  ft_Set_Pixel_Sizes face 0 (fromIntegral pixelHeight)
  GL.rowAlignment GL.Unpack $= 1
  glyphs <- forM [0 .. 255] $ loadGlyph face
  ft_Done_Face face
  ft_Done_FreeType library
  let (errs, okGlyphs) = partitionEithers glyphs
  case errs of
    [] -> pure (Map.fromList okGlyphs)
    (err : _) -> ioError (userError err)


loadGlyph :: FT_Face -> Int -> IO (Either String (Char, Glyph))
loadGlyph face codepoint = do
  let ch = toEnum codepoint
  loadResult <-
    try (ft_Load_Char face (fromIntegral codepoint) FT_LOAD_RENDER) ::
      IO (Either FtError ())
  case loadResult of
    Left err ->
      pure . Left $
        "FreeType load error for char " <> show ch <> ": " <> show err
    Right () -> do
      faceRec <- peek face
      glyphSlot <- peek (frGlyph faceRec)
      let bitmap = gsrBitmap glyphSlot
          width = fromIntegral (bWidth bitmap) :: Int
          rows = fromIntegral (bRows bitmap) :: Int
          bearingX = fromIntegral (gsrBitmap_left glyphSlot) :: Int32
          bearingY = fromIntegral (gsrBitmap_top glyphSlot) :: Int32
          advance = fromIntegral (vX (gsrAdvance glyphSlot)) :: Int32
          buffer = bBuffer bitmap

      texture <-
        if width == 0 || rows == 0 || buffer == nullPtr
          then pure Nothing
          else do
            rgbaData <- buildRgbaBuffer width rows buffer
            tex <- GL.genObjectName
            GL.textureBinding GL.Texture2D $= Just tex
            withForeignPtr rgbaData $ \ptr ->
              GL.texImage2D
                GL.Texture2D
                GL.NoProxy
                0
                GL.RGBA8
                (GL.TextureSize2D (fromIntegral width) (fromIntegral rows))
                0
                (GL.PixelData GL.RGBA GL.UnsignedByte ptr)
            GL.textureWrapMode GL.Texture2D GL.S $= (GL.Repeated, GL.ClampToEdge)
            GL.textureWrapMode GL.Texture2D GL.T $= (GL.Repeated, GL.ClampToEdge)
            GL.textureFilter GL.Texture2D $= ((GL.Linear', Nothing), GL.Linear')
            pure (Just tex)

      pure $
        Right
          ( ch
          , Glyph
              { glyphTexture = texture
              , glyphSize = (width, rows)
              , glyphBearing = (bearingX, bearingY)
              , glyphAdvance = advance
              }
          )


buildRgbaBuffer :: Int -> Int -> Ptr Word8 -> IO (ForeignPtr Word8)
buildRgbaBuffer width rows src = do
  let pixelCount = width * rows
      byteCount = pixelCount * 4
  dest <- mallocForeignPtrBytes byteCount
  withForeignPtr dest $ \ptr -> do
    forM_ [0 .. pixelCount - 1] $ \i -> do
      alpha <- peekElemOff src i
      let base = i * 4
      pokeElemOff ptr base (255 :: Word8)
      pokeElemOff ptr (base + 1) (255 :: Word8)
      pokeElemOff ptr (base + 2) (255 :: Word8)
      pokeElemOff ptr (base + 3) alpha
  pure dest