packages feed

aztecs-gl-text-0.1.0: src/Aztecs/GL/Text/Label.hs

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}

module Aztecs.GL.Text.Label
  ( -- * Labels
    Label (..),
    LabelStyle (..),
    defaultLabelStyle,
  )
where

import Aztecs
import Aztecs.GL.Internal
import Aztecs.GL.Material
import Aztecs.GL.Mesh
import Aztecs.GL.Text.Label.Internal
import Control.Exception (catch)
import Control.Monad
import Control.Monad.IO.Class
import qualified Data.Vector.Storable as SV
import Foreign
import FreeType.Core.Base
import FreeType.Core.Types
import FreeType.Exception (FtError)
import FreeType.Format.Multiple
import Graphics.Rendering.OpenGL (($=))
import qualified Graphics.Rendering.OpenGL as GL
import Linear
import Prelude hiding (lookup)

-- | Label styling options
data LabelStyle = LabelStyle
  { -- | Font size in pixels
    labelFontSize :: !Int,
    -- | Text color (RGBA)
    labelColor :: !(V4 Float),
    -- | Font weight (100-900, where 400 is normal, 700 is bold)
    labelFontWeight :: !Int,
    -- | Whether to render in italic style
    labelItalic :: !Bool
  }
  deriving (Eq)

-- | Default label style with given font entity
defaultLabelStyle :: LabelStyle
defaultLabelStyle =
  LabelStyle
    { labelFontSize = 16,
      labelColor = V4 1 1 1 1,
      labelFontWeight = 400,
      labelItalic = False
    }

-- | Label component
data Label = Label
  { -- | Text content
    labelText :: !String,
    -- | Entity ID of the @Font@ component to use
    labelFont :: !EntityID,
    -- | Text styling
    labelStyle :: !LabelStyle
  }
  deriving (Eq)

instance (MonadIO m) => Component m Label where
  componentOnInsert e lbl = inAnyWindowContext $ do
    let style = labelStyle lbl
        text = labelText lbl
        fontE = labelFont lbl
        fontSize = labelFontSize style
        V4 r g b a = labelColor style

    -- Look up the font state from the font entity
    mFontState <- lookup fontE
    case mFontState of
      Just fontState -> do
        -- Set the font size for this label
        liftIO $ ft_Set_Pixel_Sizes (fontStateFace fontState) 0 (fromIntegral fontSize)

        -- Set variable font weight
        let weight = labelFontWeight style
            italic = labelItalic style
        liftIO $ setVariableFontWeight (fontStateFace fontState) weight

        -- Get glyphs for the text and create a texture atlas
        (glyphs, atlasInfo) <- liftIO $ getGlyphsAndAtlasIOFromFace (fontStateFace fontState) text weight italic

        -- Calculate total dimensions
        let totalWidth = sum $ map (fromIntegral . glyphAdvance) glyphs
            totalHeight = maximum' $ map (fromIntegral . glyphHeight) glyphs

        -- Store label state (including atlas texture for cleanup)
        insert e $
          bundle
            LabelState
              { labelGlyphs = glyphs,
                labelTotalWidth = totalWidth,
                labelTotalHeight = totalHeight,
                labelAtlasTexture = atlasTexture atlasInfo
              }

        -- Build mesh
        let mesh = compileLabelMesh glyphs atlasInfo

        -- Build material
        let mat = labelMaterial (atlasTexture atlasInfo) r g b a

        -- Insert references to mesh and material
        insert e $ bundle mesh <> bundle mat
      Nothing -> return ()

  componentOnChange e old newLabel = when (old /= newLabel) $ inAnyWindowContext $ do
    -- Now recreate resources with new label
    let style = labelStyle newLabel
        text = labelText newLabel
        fontE = labelFont newLabel
        fontSize = labelFontSize style
        V4 r g b a = labelColor style

    mFontState <- lookup fontE
    case mFontState of
      Just fontState -> do
        liftIO $ ft_Set_Pixel_Sizes (fontStateFace fontState) 0 (fromIntegral fontSize)

        let weight = labelFontWeight style
            italic = labelItalic style
        liftIO $ setVariableFontWeight (fontStateFace fontState) weight

        (glyphs, atlasInfo) <- liftIO $ getGlyphsAndAtlasIOFromFace (fontStateFace fontState) text weight italic

        let totalWidth = sum $ map (fromIntegral . glyphAdvance) glyphs
            totalHeight = maximum' $ map (fromIntegral . glyphHeight) glyphs

        -- Update label state in place
        insert e $
          bundle
            LabelState
              { labelGlyphs = glyphs,
                labelTotalWidth = totalWidth,
                labelTotalHeight = totalHeight,
                labelAtlasTexture = atlasTexture atlasInfo
              }

        -- Create new mesh and material data, and UPDATE in place (don't remove/re-insert)
        let mesh = compileLabelMesh glyphs atlasInfo
        meshData <- liftIO $ unMesh mesh
        insert e $ bundle meshData -- Update MeshState in place
        let mat = labelMaterial (atlasTexture atlasInfo) r g b a
        matData <- liftIO $ unMaterial mat
        insert e $ bundle matData -- Update MaterialState in place
      Nothing -> return ()

  componentOnRemove e _ = inAnyWindowContext $ do
    -- Delete atlas texture from label state
    mLabelState <- lookup e
    case mLabelState of
      Just ls -> liftIO $ GL.deleteObjectName (labelAtlasTexture ls)
      Nothing -> return ()

    -- Delete VBO from mesh state
    mMeshState <- lookup @_ @MeshState e
    case mMeshState of
      Just ms -> liftIO $ GL.deleteObjectName (meshVbo ms)
      Nothing -> return ()

    -- Remove all components
    _ <- remove @_ @Mesh e
    _ <- remove @_ @MeshState e
    _ <- remove @_ @Material e
    _ <- remove @_ @MaterialState e
    _ <- remove @_ @LabelState e
    return ()

-- | Maximum helper that handles empty lists
maximum' :: (Ord a, Num a) => [a] -> a
maximum' [] = 0
maximum' xs = maximum xs

-- | Set the weight axis for a variable font
-- The 'wght' axis tag is 0x77676874 in FreeType
setVariableFontWeight :: FT_Face -> Int -> IO ()
setVariableFontWeight face weight = do
  -- Try to set the weight - this will silently fail for non-variable fonts
  let weightFixed = fromIntegral weight * 65536 -- Convert to 16.16 fixed point
  -- The weight axis is typically the first axis in variable fonts
  -- We use ft_Set_Var_Design_Coordinates with just the weight value
  -- FreeType will throw an error for non-variable fonts, which we catch
  ft_Set_Var_Design_Coordinates face [weightFixed] `catch` handleError
  where
    handleError :: FtError -> IO ()
    handleError _ = return () -- Silently ignore errors (non-variable fonts)

-- | Atlas info for a label's text
data AtlasInfo = AtlasInfo
  { atlasTexture :: !GL.TextureObject,
    atlasGlyphUVs :: ![(Float, Float, Float, Float)] -- (u0, v0, u1, v1) for each glyph
  }

-- | Get glyphs and create a texture atlas for the text using a pre-loaded font face
getGlyphsAndAtlasIOFromFace :: FT_Face -> String -> Int -> Bool -> IO ([GlyphInfo], AtlasInfo)
getGlyphsAndAtlasIOFromFace face text weight italic = do
  -- Render all glyphs and collect their bitmap data
  glyphData <- forM text $ \char -> do
    glyph <- renderGlyphMetrics face char weight italic
    bitmapData <- getGlyphBitmapData face char weight italic
    return (glyph, bitmapData)

  let glyphs = map fst glyphData
      bitmaps = map snd glyphData

  -- Calculate atlas dimensions (simple horizontal layout)
  let atlasWidth' = sum $ map (\(w, _, _) -> max 1 w) bitmaps
      atlasHeight' = maximum' $ map (\(_, h, _) -> max 1 h) bitmaps
      atlasW = max 1 atlasWidth'
      atlasH = max 1 atlasHeight'

  -- Create the atlas texture
  [tex] <- GL.genObjectNames 1
  GL.textureBinding GL.Texture2D $= Just tex
  GL.textureFilter GL.Texture2D $= ((GL.Linear', Nothing), GL.Linear')
  GL.textureWrapMode GL.Texture2D GL.S $= (GL.Repeated, GL.ClampToEdge)
  GL.textureWrapMode GL.Texture2D GL.T $= (GL.Repeated, GL.ClampToEdge)

  -- Allocate atlas buffer (RGBA for proper blending)
  atlasBuffer <- mallocBytes (atlasW * atlasH * 4)
  -- Clear to transparent black
  forM_ [0 .. atlasW * atlasH * 4 - 1] $ \i ->
    pokeByteOff atlasBuffer i (0 :: Word8)

  -- Copy each glyph into the atlas and calculate UVs
  let copyGlyphs :: [(Int, Int, Ptr Word8)] -> Int -> [(Float, Float, Float, Float)] -> IO [(Float, Float, Float, Float)]
      copyGlyphs [] _ uvs = return (reverse uvs)
      copyGlyphs ((w, h, buf) : rest) xOffset uvs = do
        -- Copy glyph bitmap to atlas (convert grayscale to RGBA)
        when (w > 0 && h > 0) $
          forM_ [0 .. h - 1] $ \row ->
            forM_ [0 .. w - 1] $ \col -> do
              let srcIdx = row * w + col
                  dstIdx = (row * atlasW + (xOffset + col)) * 4
              alpha <- peekByteOff buf srcIdx :: IO Word8
              -- Set RGBA (white with alpha from glyph)
              pokeByteOff atlasBuffer dstIdx (255 :: Word8) -- R
              pokeByteOff atlasBuffer (dstIdx + 1) (255 :: Word8) -- G
              pokeByteOff atlasBuffer (dstIdx + 2) (255 :: Word8) -- B
              pokeByteOff atlasBuffer (dstIdx + 3) alpha -- A

        -- Free the copied bitmap buffer
        free buf

        -- Calculate UV coordinates for this glyph
        let u0 = fromIntegral xOffset / fromIntegral atlasW
            v0 = 0
            u1 = fromIntegral (xOffset + w) / fromIntegral atlasW
            v1 = fromIntegral h / fromIntegral atlasH

        copyGlyphs rest (xOffset + max 1 w) ((u0, v0, u1, v1) : uvs)

  uvs <- copyGlyphs bitmaps 0 []

  -- Upload atlas to GPU
  GL.rowAlignment GL.Unpack $= 1
  GL.texImage2D
    GL.Texture2D
    GL.NoProxy
    0
    GL.RGBA8
    (GL.TextureSize2D (fromIntegral atlasW) (fromIntegral atlasH))
    0
    (GL.PixelData GL.RGBA GL.UnsignedByte atlasBuffer)

  free atlasBuffer
  GL.textureBinding GL.Texture2D $= Nothing

  return (glyphs, AtlasInfo tex uvs)

-- | Get raw bitmap data for a glyph (width, height, buffer pointer)
-- Note: weight parameter is reserved for future synthetic bold implementation
getGlyphBitmapData :: FT_Face -> Char -> Int -> Bool -> IO (Int, Int, Ptr Word8)
getGlyphBitmapData face char _weight italic = do
  let charCode = fromIntegral $ fromEnum char
  glyphIndex <- ft_Get_Char_Index face charCode

  -- Apply italic transformation via FT_Set_Transform if needed
  when italic $ do
    let shear = 0.2 :: Double -- ~12 degree slant
        matrix =
          FT_Matrix
            { mXx = 0x10000,
              mXy = round (shear * 0x10000),
              mYx = 0,
              mYy = 0x10000
            }
    ft_Set_Transform face (Just matrix) Nothing

  ft_Load_Glyph face glyphIndex FT_LOAD_RENDER

  -- Reset transform after loading
  when italic $ ft_Set_Transform face Nothing Nothing

  faceRec <- peek face
  let glyphSlot = frGlyph faceRec
  slotRec <- peek glyphSlot

  let bitmap = gsrBitmap slotRec
      width = fromIntegral $ bWidth bitmap
      height = fromIntegral $ bRows bitmap
      buffer = bBuffer bitmap
      pitch = fromIntegral $ bPitch bitmap

  -- Copy the bitmap data since FreeType may overwrite it
  if width > 0 && height > 0
    then do
      bufCopy <- mallocBytes (width * height)
      forM_ [0 .. height - 1] $ \row -> do
        let srcOffset = row * pitch
            dstOffset = row * width
        copyBytes (plusPtr bufCopy dstOffset) (plusPtr buffer srcOffset) width
      return (width, height, bufCopy)
    else do
      emptyBuf <- mallocBytes 1
      pokeByteOff emptyBuf 0 (0 :: Word8)
      return (0, 0, emptyBuf)

-- | Render a glyph and get its metrics
-- Note: weight parameter is reserved for future synthetic bold implementation
renderGlyphMetrics :: FT_Face -> Char -> Int -> Bool -> IO GlyphInfo
renderGlyphMetrics face char _weight italic = do
  let charCode = fromIntegral $ fromEnum char
  glyphIndex <- ft_Get_Char_Index face charCode

  -- Apply italic transformation via FT_Set_Transform if needed
  when italic $ do
    let shear = 0.2 :: Double -- ~12 degree slant
        matrix =
          FT_Matrix
            { mXx = 0x10000,
              mXy = round (shear * 0x10000),
              mYx = 0,
              mYy = 0x10000
            }
    ft_Set_Transform face (Just matrix) Nothing

  ft_Load_Glyph face glyphIndex FT_LOAD_RENDER

  -- Reset transform after loading
  when italic $ ft_Set_Transform face Nothing Nothing

  -- Get glyph slot from face
  faceRec <- peek face
  let glyphSlot = frGlyph faceRec
  slotRec <- peek glyphSlot

  let bitmap = gsrBitmap slotRec
      width = fromIntegral $ bWidth bitmap
      height = fromIntegral $ bRows bitmap

  let metrics = gsrMetrics slotRec
      bitmapLeft = fromIntegral (gmHoriBearingX metrics) `div` 64
      bitmapTop = fromIntegral (gmHoriBearingY metrics) `div` 64
      advanceX = fromIntegral (gmHoriAdvance metrics) `div` 64

  -- We don't need individual textures anymore, just return glyph metrics
  -- Use a dummy texture object since we use atlas textures instead
  return $
    GlyphInfo
      { glyphTexture = GL.TextureObject 0,
        glyphWidth = width,
        glyphHeight = height,
        glyphBearingX = bitmapLeft,
        glyphBearingY = bitmapTop,
        glyphAdvance = advanceX
      }

-- | Compile mesh for rendering label glyphs
compileLabelMesh :: [GlyphInfo] -> AtlasInfo -> Mesh
compileLabelMesh glyphs atlasInfo = Mesh $ do
  [vbo] <- GL.genObjectNames 1
  GL.bindBuffer GL.ArrayBuffer $= Just vbo

  -- Generate vertices for all glyphs with atlas UVs
  let vertices = generateLabelVertices glyphs (atlasGlyphUVs atlasInfo) 0
  SV.unsafeWith (SV.fromList vertices) $ \ptr -> do
    let size = fromIntegral $ length vertices * sizeOf (undefined :: GL.GLfloat)
    GL.bufferData GL.ArrayBuffer $= (size, ptr, GL.StaticDraw)

  GL.bindBuffer GL.ArrayBuffer $= Nothing

  let numQuads = length glyphs
      vertexCount = fromIntegral $ numQuads * 6
  return $
    MeshState
      { meshVbo = vbo,
        meshPush = do
          GL.bindBuffer GL.ArrayBuffer $= Just vbo
          -- Position: 2 floats, stride 16 bytes (4 floats), offset 0
          GL.clientState GL.VertexArray $= GL.Enabled
          GL.arrayPointer GL.VertexArray $= GL.VertexArrayDescriptor 2 GL.Float 16 nullPtr
          -- UV: 2 floats, stride 16 bytes, offset 8 bytes
          GL.clientState GL.TextureCoordArray $= GL.Enabled
          GL.arrayPointer GL.TextureCoordArray $= GL.VertexArrayDescriptor 2 GL.Float 16 (plusPtr nullPtr 8)
          GL.drawArrays GL.Triangles 0 vertexCount,
        meshPop = do
          GL.clientState GL.TextureCoordArray $= GL.Disabled
          GL.clientState GL.VertexArray $= GL.Disabled
          GL.bindBuffer GL.ArrayBuffer $= Nothing
      }

-- | Generate vertices for label glyphs (position + UV)
generateLabelVertices :: [GlyphInfo] -> [(Float, Float, Float, Float)] -> Float -> [GL.GLfloat]
generateLabelVertices [] _ _ = []
generateLabelVertices _ [] _ = []
generateLabelVertices (g : gs) ((u0, v0, u1, v1) : uvs) xOffset =
  let w = fromIntegral $ glyphWidth g
      h = fromIntegral $ glyphHeight g
      bx = fromIntegral $ glyphBearingX g
      by = fromIntegral $ glyphBearingY g
      x = xOffset + bx
      y = by - h

      -- Two triangles for the quad, using atlas UVs
      quadVerts =
        [ -- First triangle (bottom-left, bottom-right, top-right)
          x,
          y,
          u0,
          v1, -- bottom-left
          x + w,
          y,
          u1,
          v1, -- bottom-right
          x + w,
          y + h,
          u1,
          v0, -- top-right
          -- Second triangle (bottom-left, top-right, top-left)
          x,
          y,
          u0,
          v1, -- bottom-left
          x + w,
          y + h,
          u1,
          v0, -- top-right
          x,
          y + h,
          u0,
          v0 -- top-left
        ]
      nextOffset = xOffset + fromIntegral (glyphAdvance g)
   in map realToFrac quadVerts ++ generateLabelVertices gs uvs nextOffset

-- | Create material for rendering label with color tint
labelMaterial :: GL.TextureObject -> Float -> Float -> Float -> Float -> Material
labelMaterial tex r g b a =
  Material . pure $
    MaterialState
      { materialPush = do
          GL.blend $= GL.Enabled
          GL.blendFunc $= (GL.SrcAlpha, GL.OneMinusSrcAlpha)
          GL.texture GL.Texture2D $= GL.Enabled
          GL.textureBinding GL.Texture2D $= Just tex
          GL.color $ GL.Color4 (realToFrac r) (realToFrac g) (realToFrac b) (realToFrac a :: GL.GLfloat),
        materialPop = do
          GL.textureBinding GL.Texture2D $= Nothing
          GL.texture GL.Texture2D $= GL.Disabled
          GL.blend $= GL.Disabled
      }