packages feed

nano-ui-diagrams-0.1.0.0: lib/NanoUI/Diagrams/Backend.hs

-- | A diagrams-lib backend that renders a diagram to nano-ui @DrawOp@ values,
-- scaled into a target size.
module NanoUI.Diagrams.Backend
  ( NanoUIBackend (..)
  , B
  , diagramOps
  , diagramTextOps
  , letterbox
  )
where

import Control.Lens (Lens', (^.), (^?))
import Data.Colour (AlphaColour, alphaChannel, black, over)
import Data.Colour.SRGB (RGB (..), toSRGB)
import Data.DList (DList)
import Data.DList qualified as DL
import Data.Maybe (fromMaybe)
import Data.Text qualified as T
import Data.Tree (Tree (Node))
import Data.Typeable (Typeable)
import Data.Primitive.SmallArray (SmallArray, emptySmallArray, mapSmallArray', smallArrayFromList)
import Diagrams.Attributes (_lineWidthU)
import Diagrams.Core
  ( Backend (..)
  , N
  , QDiagram
  , Renderable (..)
  , V
  , renderDia
  )
import Diagrams.Core qualified as DiaCore
import Diagrams.Core.Types (Annotation, RNode (..), RTree)
import Diagrams.Located (Located, unLoc)
import Diagrams.Path (Path, pathTrails)
import Diagrams.Prelude
  ( Any
  , P2
  , SizeSpec
  , Trail
  , V2 (..)
  , origin
  , p2
  , papply
  , reflectY
  , reflectionY
  , size
  , unp2
  , (#)
  )
import Diagrams.Segment (FixedSegment (..))
import Diagrams.Trail (fixTrail, isLoop)
import Diagrams.TwoD.Adjust (adjustDia2D)
import Diagrams.TwoD.Attributes (_AC, _fillTexture, _lineTexture)
import Diagrams.TwoD.Size (mkHeight)
import Diagrams.TwoD.Text (Text (..), TextAlignment (..))
import NanoUI
  ( Color
  , DrawOp (..)
  , colorA
  , colorRGBA
  , defaultTheme
  , shiftDrawOp
  , themeMuted
  )
import NanoUI.Diagrams.Tessellation
  ( fillPolygon
  , flattenCubic
  , strokePolyline
  )

data NanoUIBackend = NanoUIBackend
  deriving (Eq, Show)

type B = NanoUIBackend

type instance V NanoUIBackend = V2

type instance N NanoUIBackend = Double

fullSize :: Lens' (Options NanoUIBackend V2 n) (SizeSpec V2 n)
fullSize f (NanoUIOptions sz textOnly) = fmap (\sz' -> NanoUIOptions sz' textOnly) (f sz)

instance (Typeable n, RealFloat n) => Backend NanoUIBackend V2 n where
  newtype Render NanoUIBackend V2 n
    = NRenderFull (Bool -> DiaCore.Style V2 n -> DList DrawOp)
  type Result NanoUIBackend V2 n = SmallArray DrawOp
  data Options NanoUIBackend V2 n = NanoUIOptions (SizeSpec V2 n) Bool
  renderRTree _ (NanoUIOptions _ textOnly) rt = smallArrayFromList (DL.toList (walkFull textOnly mempty rt))
  adjustDia c opts d = (sz, t <> reflectionY, d')
   where
    (sz, t, d') = adjustDia2D fullSize c opts (d # reflectY)

instance Semigroup (Render NanoUIBackend V2 n) where
  NRenderFull f <> NRenderFull g = NRenderFull (\textOnly sty -> f textOnly sty <> g textOnly sty)

instance Monoid (Render NanoUIBackend V2 n) where
  mempty = NRenderFull (\_ _ -> DL.empty)

walkFull ::
  (Typeable n, RealFloat n) =>
  Bool
  -> DiaCore.Style V2 n
  -> RTree NanoUIBackend V2 n Annotation
  -> DList DrawOp
walkFull textOnly sty (Node n cs) =
  case n of
    RPrim prim ->
      let
        NRenderFull f = render NanoUIBackend prim
       in
        f textOnly sty
    RStyle s -> foldMap (walkFull textOnly (sty <> s)) cs
    _ -> foldMap (walkFull textOnly sty) cs

instance (Typeable n, RealFloat n) => Renderable (Path V2 n) NanoUIBackend where
  render _ path = NRenderFull $ \textOnly sty ->
    if textOnly
      then DL.empty
      else foldMap (DL.fromList . trailOps sty) (pathTrails path)

textOps ::
  (Typeable n, RealFloat n) => Text n -> DiaCore.Style V2 n -> DList DrawOp
textOps (Text tr align str) sty
  | null str = DL.empty
  | otherwise =
      let
        p = papply tr origin
        (x, y) = unp2 p
        (ax, ay) =
          case align of
            BaselineText -> (0, -1)
            BoxAlignedText bx by -> (toF bx, toF by)
        col =
          case solidColour (sty ^? (_fillTexture . _AC)) of
            Just c -> c
            Nothing ->
              fromMaybe
                (themeMuted defaultTheme)
                (solidColour (sty ^? (_lineTexture . _AC)))
       in
        DL.singleton (DrawText (toF x) (toF y) ax ay (T.pack str) col)

instance (Typeable n, RealFloat n) => Renderable (Text n) NanoUIBackend where
  render _ t = NRenderFull (const (textOps t))

trailOps ::
  (Typeable n, RealFloat n) =>
  DiaCore.Style V2 n -> Located (Trail V2 n) -> [DrawOp]
trailOps sty lt =
  let
    pts = [(toF x, toF y) | (x, y) <- map unp2 (trailSamples lt)]
    lineW0 = sty ^. _lineWidthU
    lineW =
      case fmap toF lineW0 of
        Nothing -> 1
        Just w
          | w <= 0 -> 0
          | w < 1 -> 1
          | otherwise -> w
    fillC = solidColour (sty ^? (_fillTexture . _AC))
    lineC = solidColour (sty ^? (_lineTexture . _AC))
    closed = isLoop (unLoc lt)
    fills =
      case fillC of
        Just c
          | colorA c > 0 && closed && length pts >= 3 -> fillPolygon c pts
        _ -> []
    strokes =
      case lineC of
        Just c
          | colorA c > 0 && lineW > 0 && length pts >= 2 ->
              strokePolyline c lineW closed pts
        _ -> []
   in
    fills ++ strokes

trailSamples :: RealFloat n => Located (Trail V2 n) -> [P2 n]
trailSamples lt =
  case map sampleSeg (fixTrail lt) of
    [] -> []
    (firstSeg : rest) ->
      let
        pts = firstSeg ++ concatMap (drop 1) rest
       in
        if isLoop (unLoc lt) && not (null pts)
          then pts ++ take 1 pts
          else pts

sampleSeg :: RealFloat n => FixedSegment V2 n -> [P2 n]
sampleSeg (FLinear p0 p1) = [p0, p1]
sampleSeg (FCubic p0 c1 c2 p1) =
  [ p2 (realToFrac x, realToFrac y)
  | (x, y) <- flattenCubic (f p0) (f c1) (f c2) (f p1)
  ]
 where
  f p = let (x, y) = unp2 p in (toF x, toF y)

solidColour :: Maybe (AlphaColour Double) -> Maybe Color
solidColour mc = do
  ac <- mc
  let
    a = alphaChannel ac
  if a <= 0
    then Nothing
    else
      let
        RGB r g b = toSRGB (ac `over` black)
        q x = round (clamp01 x * 255)
       in
        Just (colorRGBA (q r) (q g) (q b) (q a))

clamp01 :: Double -> Double
clamp01 x = max 0 (min 1 x)

toF :: Real n => n -> Float
toF = realToFrac

diagramOps ::
  Double -> Double -> QDiagram NanoUIBackend V2 Double Any -> SmallArray DrawOp
diagramOps = renderFull False

diagramTextOps ::
  Double -> Double -> QDiagram NanoUIBackend V2 Double Any -> SmallArray DrawOp
-- Text uses the same backend and viewport as geometry. In particular, do not
-- coerce a QDiagram between backends: its primitives carry Renderable dictionaries.
diagramTextOps = renderFull True

renderFull ::
  Bool
  -> Double
  -> Double
  -> QDiagram NanoUIBackend V2 Double Any
  -> SmallArray DrawOp
renderFull textOnly w h d
  | w <= 0 || h <= 0 = emptySmallArray
  | otherwise =
      let
        V2 dw dh = size d
        (_, outH, dx, dy) = letterbox dw dh w h
        ops = renderDia NanoUIBackend (NanoUIOptions (mkHeight outH) textOnly) d
       in
        mapSmallArray' (shiftDrawOp (realToFrac dx) (realToFrac dy)) ops

-- | Scale a @dw@ by @dh@ diagram uniformly to fit a @w@ by @h@ box and centre
-- it: the drawn width and height, and the x and y offsets inside the box.
letterbox :: Double -> Double -> Double -> Double -> (Double, Double, Double, Double)
letterbox dw dh w h =
  let
    outH = if dw <= 1e-9 || dh <= 1e-9 then h else min h (w * dh / dw)
    outW = if dh <= 1e-9 then w else outH * dw / dh
   in
    (outW, outH, (w - outW) / 2, (h - outH) / 2)