packages feed

ktx-font-0.2.0.0: demo/Main.hs

module Main (main) where

import Brillo.Data.Bitmap
import Brillo.Interface.Environment (getScreenSize)
import Brillo.Interface.IO.Game
import Codec.Compression.Zstd qualified as Zstd
import Codec.Ktx2.Font qualified as Ktxf
import Codec.Ktx2.Font.Layout qualified as Layout
import Codec.Ktx2.Font.Shaping qualified as Shaping
import Codec.Ktx2.Header qualified as Ktx2
import Codec.Ktx2.Read qualified as Ktx2
import Control.Exception (bracket)
import Data.ByteString (ByteString)
import Data.ByteString.Internal qualified as ByteString
import Data.Foldable
import Data.Maybe
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Traversable
import Foreign.ForeignPtr (touchForeignPtr)
import Graphics.MSDF.Atlas.Compact qualified as Atlas

import Debug.Trace

-- | Demo bundles built from @assets.yaml@ with an uncompressed (rgba8) texture.
--
-- The shaper tries the fonts from the top of the stack, so the last one
-- is the default and the fallbacks go before it.
sources :: [FilePath]
sources =
  [ "assets/demo/NotoEmoji-Regular.ktxf" -- fallback
  , "assets/demo/NotoSans-Regular.ktxf" -- fallback
  , "assets/demo/MapleMono-Regular.ktxf" -- default
  ]

main :: IO ()
main = do
  -- fonts are loaded, but detached
  allFontsTextures <- for sources \ktxf -> do
    bundle <- Ktxf.loadBundleFile ktxf
    texture <- readFontTexture ktxf -- load and prepare for Brillo
    pure (bundle, texture)

  let allFonts = fmap fst allFontsTextures
  bracket (Ktxf.createStackContext allFonts) Ktxf.destroyStackContext \ctx' -> do
    -- ctx is a stack for the particular collection of fonts.
    -- We still have to front-load all the content for fonts used in the withFont_ sections.
    let ctx = Ktxf.mapWithBundle (fmap const <$> allFontsTextures) ctx'

    let
      txt = Text.unlines
        [ "<=> ->> =/= transduce_ Hff" -- ligatures welcome
        , "" -- empty line should be present
        , "Sphinx of black quartz, judge my vow."
        , "Příliš žluťoučký kůň úpěl ďábelské ódy"
        , "Eĥoŝanĝoj ĉiuĵaŭde ☝️🤪."
        , "Not in MapleMono: Ꞹ₿₪ᵺꭒ"
        , "🤯Ебучие шрифты! Как они вообще работают?!"
        , "Victor jagt zwölf Boxkämpfer quer über den großen Sylter Deich"
        ]
      lineHeight = 2.0 -- distance between baselines, set to 2x cap height and ignoring ascenders/descenders
      targetSize = 32 -- pixels per cap height
      wrapPx = 1280 -- maximum line width, in pixels
      strategy = Layout.Greedy
      mpos = (0, 0)
      placed = []
    style <- Layout.bundleStyle ctx (last allFonts)
    world <- relayout World{..}
    playIO
      FullScreen
      (greyN 0.125)
      2
      world
      (\w -> getScreenSize >>= render w)
      (flip onEvent)
      (const pure)
      -- (\_dt w -> handleKey w $ SpecialKey KeyTab)

    -- XXX: also, prevents FontData inside bundles from slipping away
    for_ allFontsTextures \(bundle, texture) -> do
      traceM $ "Letting go of " <> show texture
      Ktxf.freeBundle bundle
      touchTexture texture

woop :: Text -> Text
woop t = case Text.splitAt 1 t of (a, b) -> b <> a

-- XXX: BitmapData keeps the texture ForeignPointer
readFontTexture :: FilePath -> IO Texture
readFontTexture path = do
  ktx <- Ktx2.open path
  let
    Ktx2.Header{supercompressionScheme, pixelWidth, pixelHeight} = Ktx2.header ktx
    wh = (fromIntegral pixelWidth, fromIntegral pixelHeight)
  print (Ktx2.header ktx, wh)
  levels <- Ktx2.levels ktx
  mip0' <-
    case toList levels of
      [] -> error $ "No mip levels in " <> path
      level0 : _ -> Ktx2.levelData ktx level0
  mip0 <-
    case supercompressionScheme of
      0 ->
        pure mip0'
      2 ->
        case Zstd.decompress mip0' of
          Zstd.Decompress bs -> pure bs
          Zstd.Error err -> error err
          Zstd.Skip -> error "empty level data"
      huh ->
        error $ "unsupported supercompressionScheme: " <> show huh
  Ktx2.close ktx
  let ByteString.BS fptr len = mip0
  pure $! Texture path mip0 wh $
   BitmapData len (BitmapFormat TopToBottom PxRGBA) (fromIntegral pixelWidth, fromIntegral pixelHeight) True fptr

data World = World
  { ctx :: Ktxf.StackContext Texture
  , mpos :: (Float, Float)
  , txt :: Text
  , style :: Layout.TextStyle
  , lineHeight :: Float
  , wrapPx :: Float
  , strategy :: Layout.Strategy
  , placed :: [Layout.PlacedLine]
  , targetSize :: Float
  }

relayout :: World -> IO World
relayout w@World{..} = do
  results <- Layout.layoutTextWith
    Layout.LayoutOptions
      { cursor = Shaping.initialCursorDown lineHeight
      , strategy
      , align = Layout.AlignLeft
      }
    ctx style (wrapPx / targetSize) txt
  pure w{placed = results}

data Texture = Texture FilePath ByteString (Float, Float) BitmapData

touchTexture :: Texture -> IO ()
touchTexture (Texture _ bs _ _) = touchForeignPtr fp
  where
    (fp, _, _) = ByteString.toForeignPtr bs

instance Show Texture where
  show (Texture src _ _ _) = show src

textureSection :: Texture -> Atlas.Box -> Picture
textureSection (Texture _ _ (tw, th) bd) ab = BitmapSection rect bd
  where
    Atlas.Box{x=ax, y=ay, w=aw, h=ah} = ab
    rect = Rectangle{rectPos, rectSize}
    -- rectPos = (round $ ax * tw, round $ th - (ay + ah) * th) -- when atlas is yBottom
    rectPos = (round $ ax * tw, round $ ay * th) -- when atlas is yTop
    rectSize = (round $ aw * tw, round $ ah * th)

render :: World -> (Int, Int) -> IO Picture
render World{ctx, mpos = (mx, my), lineHeight, targetSize, wrapPx, placed} (screenW, screenH) = pure . mconcat $ measures : letters
  where
    lineSize = lineHeight * targetSize

    measures = mconcat $ drop 2
      [ Translate (-1920) 250 . Color red . Scale 0.5 0.5 $ Text (Text.pack $ show (targetSize, bb))
      , Translate bx by . Color yellow $ rectangleWire bw bh <> Circle 3 <> Circle 5 -- the "natural position of the box"
      -- , Color yellow $ Line [(ax, ay), (bx, by)] -- the offset
      , Color white $ Circle 2 <> Circle 7 -- middle of the screen
      , Translate ax ay $ Color red $ rectangleWire bw bh -- the aligned box that should contain the text
      , Translate gx gy  $ mconcat
        [ Color green $ Line [(0, 0), (bw, 0)]
        , Color yellow $ Line [(0, 0), (bx, by)]
        , Color blue $ Line [(0, targetSize), (bw, targetSize)]
        , Color cyan $ Circle 4 <> Circle targetSize <> Circle (targetSize * lineHeight)
        , Color magenta $ Line [(wrapPx, targetSize), (wrapPx, targetSize - bh)] -- wrap width guide
        ]
      ]

    annRuns = flip mapMaybe (Layout.placedRuns placed) \((font, atlas_), glyphs) -> do
      tex <- Ktxf.lookupBundled font ctx
      Atlas.Compact{_type, _size} <- atlas_
      pure (tex, 1 / _size, glyphs)

    letters = foldMap drawRun annRuns

    drawRun (tex, pixelsToNorm, glyphs) = map (drawGlyph tex pixelsToNorm) glyphs

    drawGlyph tex pixelsToNorm Shaping.PlacedGlyph{glyph, plane=Atlas.Box{x, y}} = -- w/h are used from ab, in pixels
      Translate gx gy $ -- move around in pixels to fit into the aligned box
        Scale targetSize targetSize $ -- scale to target
        Translate x y $ -- text layout in normalized units (static!)
          -- flip mappend (Color yellow $ rectangleWire w h) $ -- a box of each glyph
          Scale pixelsToNorm pixelsToNorm $ -- bitmap sections are in pixels, move to normalized units
            textureSection tex glyph

    gx = ax - bw * 0.5
    gy = ay + bh * 0.5 - targetSize -- first caps line at the box top, then the cursor goes down
    ax = mx * (fromIntegral screenW - bw)
    ay = my * (fromIntegral screenH - bh)
    nLines = fromIntegral $ length placed
    bh = nLines * lineSize

    bb@(bx, by, bw, _bh) = toBox $ foldl' grow (-1e6, -1e6, 1e6, 1e6) trbls
      where
        toBox (t, r, b, l) =
          ( l * 0.5 + r * 0.5
          , b * 0.5 + t * 0.5
          , abs $ r - l
          , abs $ b - t
          )

        grow (t1, r1, b1, l1) (t2, r2, b2, l2) =
          ( max t1 t2
          , max r1 r2
          , min b1 b2
          , min l1 l2
          )

        trbls = do
          (_, _, glyphs) <- annRuns
          Shaping.PlacedGlyph{plane} <- glyphs
          let Atlas.Box{x, y, w} = Atlas.scaleBox targetSize plane
          pure
            ( y + 0.5 -- XXX: ignoring glyph height and using cap height (the sizes are normalized to it)
            , x + w * 0.5
            , y - 0.5 -- ditto
            , x - w * 0.5
            )

onEvent :: World -> Event -> IO World
onEvent w = \case
  EventKey key Down _ _pos -> handleKey w key
  EventMotion (mx, my) -> pure w{mpos = (mx / 2 / 1920, my / 2 / 1080)}
  _ -> pure w

handleKey :: World -> Key -> IO World
handleKey w@World{txt = old, ..} = \case
  SpecialKey KeyTab ->
    relayout w{txt = woop old}
  Char c ->
    relayout w{txt = old `Text.snoc` c}
  SpecialKey KeySpace ->
    relayout w{txt = old `Text.snoc` ' '}
  SpecialKey KeyEnter ->
    relayout w{txt = old `Text.snoc` '\n'}
  SpecialKey KeyBackspace ->
    if Text.null old then
      pure w
    else
      relayout w{txt = Text.init old}
  SpecialKey KeyPageUp ->
    relayout w{targetSize = targetSize + 1}
  SpecialKey KeyPageDown ->
    relayout w{targetSize = targetSize - 1}
  SpecialKey KeyHome ->
    relayout w{targetSize = targetSize * 2}
  SpecialKey KeyEnd ->
    relayout w{targetSize = targetSize / 2}
  SpecialKey KeyLeft ->
    relayout w{wrapPx = max 40 $ wrapPx - 40}
  SpecialKey KeyRight ->
    relayout w{wrapPx = wrapPx + 40}
  SpecialKey KeyF1 ->
    relayout w{strategy = flipStrategy strategy}
  eh -> do
    print eh
    pure w
  where
    flipStrategy = \case
      Layout.Greedy -> Layout.Optimal
      Layout.Optimal -> Layout.Greedy