packages feed

spectacle-1.0.0: src/Language/Spectacle/Interaction/Diagram.hs

{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

module Language.Spectacle.Interaction.Diagram where

import Control.Applicative (liftA2)
import Control.Arrow (Kleisli (Kleisli))
import Control.Comonad (Comonad (duplicate))
import Control.Comonad.Store (extract)
import Data.Foldable (Foldable (fold))
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.IntMap.Strict (IntMap)
import qualified Data.IntMap.Strict as IntMap
import Data.Maybe ()
import qualified Data.Sequence as Seq
import Data.Set (Set)
import qualified Data.Set as Set
import Lens.Micro ((^.))
import Lens.Micro.Extras (view)
import Prettyprinter (Doc, pretty)
import qualified Prettyprinter as Doc
import qualified Prettyprinter.Internal as Doc.Internal
import Prettyprinter.Render.Terminal (AnsiStyle, Color (Blue, Cyan, Green, Magenta, Red, Yellow))
import qualified Prettyprinter.Render.Terminal as Doc

import Control.Comonad.Tape (Tape (Tape), after, before, focus, tabulatel, tabulater, viewl)
import Data.Fingerprint (Fingerprint)
import Data.Traversable (for)
import Language.Spectacle.Interaction.Doc
  ( Cardinal (CLeft, CRight),
    cline,
    hline,
    tab,
    tack,
    turnLeftUp,
    turnRightUp,
    turnUpLeft,
    turnUpRight,
  )
import qualified Language.Spectacle.Interaction.Doc as Doc
import Language.Spectacle.Interaction.Paths (takeMinRow)
import Language.Spectacle.Interaction.Point (Point, column, extent, fields, label, parent)

-- ---------------------------------------------------------------------------------------------------------------------

data LogNode = LogNode
  { nodeColor :: Color
  , nodeLabel :: Fingerprint
  }

-- | @'traverseRowsOf' f ps@ is a traversal of @f@ on rows of @ps@, collecting the results in a list.
--
-- @since 1.0.0
traverseRowsOf :: (Set Point -> DiagramM a) -> Set Point -> DiagramM [a]
traverseRowsOf f ps =
  case takeMinRow ps of
    (next, paths')
      | Set.null paths' -> pure @[] <$> f next
      | otherwise -> liftA2 (:) (f next) (traverseRowsOf f paths')

diagramFull :: Set Point -> DiagramM (Doc AnsiStyle)
diagramFull points = do
  rowDocs <- traverseRowsOf diagramRow points
  pure (Doc.concatWith (flip mappend) rowDocs)

diagramRow :: Set Point -> DiagramM (Doc AnsiStyle)
diagramRow rows =
  case viewl (foldMap Seq.singleton rows) of
    Nothing -> pure mempty
    Just points -> do
      branchDoc <- branchSection points
      subtreeDoc <- subtreeDiagram points
      curveDocs <- curveSection points
      return (branchDoc <> Doc.line <> curveDocs <> subtreeDoc)

subtreeDiagram :: Tape Point -> DiagramM (Doc AnsiStyle)
subtreeDiagram = fmap (foldMap (`mappend` Doc.line)) . traverse pointSection . duplicate

pointSection :: Tape Point -> DiagramM (Doc AnsiStyle)
pointSection ps = do
  lx <- labelSection ps
  fs <- fieldSection ps
  pure (lx <> Doc.vsep fs)

-- | @'labelSection' points@ documents the label of the focused points as well as neighboring paths, for example:
--
-- @
-- | * | 0x8f46a202
-- @
--
-- @since 1.0.0
labelSection :: Tape Point -> DiagramM (Doc AnsiStyle)
labelSection ps = do
  paths <- labelPaths ps
  let lx = Doc.annotate (Doc.colorDull Yellow) (pretty (focus ps ^. label))
  pure (paths <> lx <> Doc.line)

-- | @'fieldSection' points@ documents each of the fields in the focused point as well a neighboring paths, for example:
--
-- @
-- | | |   #field1 = "foo"
-- | | |   #field2 = 12345
-- @
--
-- @since 1.0.0
fieldSection :: Tape Point -> DiagramM [Doc AnsiStyle]
fieldSection points = do
  paths <- fieldPaths points
  let fs = extract points ^. fields
  pure (map (mappend paths . Doc.indent 2) fs)

branchSection :: Tape Point -> DiagramM (Doc AnsiStyle)
branchSection = foldr cons (pure mempty) . duplicate
  where
    cons points
      | focus points ^. extent == 0 = id
      | otherwise = liftA2 (<>) (go points)

    go points@Tape {..} = do
      doc <- branchSegment points lcol ucol
      withColor doc (focus ^. label)
      where
        lcol = sum (view extent <$> before)
        ucol = lcol + focus ^. extent

curveSection :: Tape Point -> DiagramM (Doc AnsiStyle)
curveSection = foldr cons (pure mempty) . duplicate
  where
    cons points
      | focus points ^. extent == 0 = id
      | otherwise = liftA2 (mappend) (go points)

    go points@Tape {..}
      | lcol <= col && col < ucol = pure mempty
      | otherwise = do
        curve <- curveSegment points lcol ucol
        withColor (curve <> Doc.line) (focus ^. label)
      where
        lcol = sum (view extent <$> before)
        ucol = lcol + focus ^. extent
        col = focus ^. column

curveSegment :: Tape Point -> Int -> Int -> DiagramM (Doc AnsiStyle)
curveSegment points lcol ucol
  | col < lcol = do
    let curve = rightTurnSegment (lcol - col) <> turnRightUp
    ls <- for (tabulatel points) \ps -> do
      if focus ps ^. extent == 0
        then pure tab
        else withColor (verticalSegment 1) (focus ps ^. label)

    withColor (fold ls <> curve) lx
  | ucol < 1 + col = do
    let curve = turnLeftUp <> Doc.copies (2 * (col - ucol) + 1) hline <> turnUpLeft
    rs <- for (tabulater points) \ps -> do
      if focus ps ^. extent == 0
        then pure tab
        else withColor (verticalSegment 1) (focus ps ^. label)
    ls <- for (tabulatel points) \ps -> do
      if focus ps ^. extent == 0
        then pure mempty
        else do
          let spc = Doc.tabs (focus ps ^. extent - 1)
          doc <- withColor (verticalSegment 1) (focus ps ^. label)
          pure (spc <> doc)

    let spc = Doc.tabs (focus points ^. extent - 1)
    withColor (fold ls <> spc <> curve <> Doc.space <> fold rs) lx
  | otherwise = pure mempty
  where
    col = focus points ^. column
    lx = focus points ^. label

-- | @'labelPaths' points@ inserts a line segment for each point in @points@, and a asterrisk for the point under
-- focus.
--
-- @since 1.0.0
labelPaths :: Tape Point -> DiagramM (Doc AnsiStyle)
labelPaths = iteratePaths (const (pure "* "))

-- | @'labelPaths' points@ inserts a line segment for each point in @points@.
--
-- @since 1.0.0
fieldPaths :: Tape Point -> DiagramM (Doc AnsiStyle)
fieldPaths = iteratePaths foci
  where
    foci point = case point ^. parent of
      Nothing -> pure tab
      Just lx -> withColor (verticalSegment 1) lx

branchSegment :: Tape Point -> Int -> Int -> DiagramM (Doc AnsiStyle)
branchSegment points lcol ucol
  | col <= lcol = pure $ rightFanoutSegment len
  | ucol <= 1 + col = pure $ leftFanoutSegment len
  | otherwise = do
    let ls = leavesSegment (col - lcol - 1)
    let rs = leavesSegment (ucol - col - 2)
    let fans = Doc.cat [turnLeftUp, hline, ls, cline, Doc.hline, rs, turnRightUp]
    pure (fans <> Doc.space)
  where
    col = focus points ^. column
    len = focus points ^. extent

iteratePaths :: (Point -> DiagramM (Doc AnsiStyle)) -> Tape Point -> DiagramM (Doc AnsiStyle)
iteratePaths f = iterateLine groupBefore f groupAfter

groupAfter :: Point -> DiagramM (Doc AnsiStyle)
groupAfter point = do
  let len = min (point ^. extent) 1
  let lx = point ^. label
  withColor (verticalSegment len) lx

groupBefore :: Point -> DiagramM (Doc AnsiStyle)
groupBefore point =
  case point ^. parent of
    Nothing -> pure Doc.tab
    Just lx -> withColor (verticalSegment 1) lx

iterateLine ::
  (Point -> DiagramM (Doc AnsiStyle)) ->
  (Point -> DiagramM (Doc AnsiStyle)) ->
  (Point -> DiagramM (Doc AnsiStyle)) ->
  Tape Point ->
  DiagramM (Doc AnsiStyle)
iterateLine handleLT handleEQ handleGT points = do
  lt <- foldr (liftA2 (<>) . handleLT) (pure mempty) (before points)
  gt <- foldr (liftA2 (<>) . handleGT) (pure mempty) (after points)
  eq <- handleEQ (focus points)
  pure (lt <> eq <> gt)

-- | @'verticalSegment' len@ fills a line of length @len@ comprised of segments "│ ".
--
-- @since 1.0.0
verticalSegment :: Int -> Doc AnsiStyle
verticalSegment len
  | len <= 0 = Doc.tab
  | len == 1 = Doc.Internal.Text 2 "│ "
  | otherwise = Doc.copies len (Doc.Internal.Text 2 "│ ")

leftFanoutSegment :: Int -> Doc AnsiStyle
leftFanoutSegment len
  | len <= 0 = mempty
  | len == 1 = Doc.Internal.Text 2 "│ "
  | otherwise =
    let begin = Doc.Internal.Text 2 "╰─"
        end = tack CLeft <> Doc.space
     in begin <> leavesSegment (len - 2) <> end

rightFanoutSegment :: Int -> Doc AnsiStyle
rightFanoutSegment len
  | len <= 0 = mempty
  | len == 1 = Doc.Internal.Text 2 "│ "
  | otherwise =
    let begin = tack CRight <> Doc.hline
        end = Doc.Internal.Text 2 "╯ "
     in begin <> leavesSegment (len - 2) <> end

rightTurnSegment :: Int -> Doc AnsiStyle
rightTurnSegment len
  | len <= 0 = mempty
  | len == 1 = Doc.Internal.Text 2 "╭─"
  | otherwise = turnUpRight <> Doc.copies (2 * len - 1) (Doc.Internal.Char '─')

-- | @'leavesPaths' len@ fills a line of length @len@ comprised of segments "┴─".
--
-- @since 1.0.0
leavesSegment :: Int -> Doc AnsiStyle
leavesSegment len
  | len <= 0 = mempty
  | len == 1 = Doc.Internal.Text 2 "┴─"
  | otherwise = Doc.copies len (Doc.Internal.Text 2 "┴─")

withColor :: Doc AnsiStyle -> Fingerprint -> DiagramM (Doc AnsiStyle)
withColor doc hash = fmap (\c -> Doc.annotate (Doc.color c) doc) (colorOf hash)

-- ---------------------------------------------------------------------------------------------------------------------

newtype DiagramM a = DiagramM
  {runDiagramM :: IORef DiagramCtx -> IO a}
  deriving
    (Functor, Applicative, Monad)
    via Kleisli IO (IORef DiagramCtx)

runDiagram :: DiagramM a -> IO a
runDiagram (DiagramM io) = newIORef (DiagramCtx (ColorSrc mempty mempty)) >>= io

newtype DiagramCtx = DiagramCtx {ctxColorSource :: ColorSrc}

colorOf :: Fingerprint -> DiagramM Color
colorOf hash =
  DiagramM \ref -> do
    ctx <- readIORef ref
    case lookupColor hash (ctxColorSource ctx) of
      Nothing -> do
        let (c, cs') = takeColor hash (ctxColorSource ctx)
        writeIORef ref ctx {ctxColorSource = cs'}
        pure c
      Just c -> pure c

-- ---------------------------------------------------------------------------------------------------------------------

data ColorSrc = ColorSrc
  { srcColorCtx :: {-# UNPACK #-} !ColorCtx
  , srcColorMap :: !(IntMap Color)
  }

lookupColor :: Fingerprint -> ColorSrc -> Maybe Color
lookupColor hash (ColorSrc _ cs) = IntMap.lookup (fromIntegral hash) cs

takeColor :: Fingerprint -> ColorSrc -> (Color, ColorSrc)
takeColor hash (ColorSrc !cctx cmap) =
  let c = getColorCtx cctx
      cctx' = succ cctx
      cmap' = IntMap.insert (fromIntegral hash) c cmap
   in (c, ColorSrc cctx' cmap')

-- ---------------------------------------------------------------------------------------------------------------------

newtype ColorCtx = ColorCtx Int
  deriving (Eq, Ord)

getColorCtx :: ColorCtx -> Color
getColorCtx (ColorCtx i) =
  case mod i 6 of
    0 -> Cyan
    1 -> Red
    2 -> Green
    3 -> Yellow
    4 -> Blue
    _ -> Magenta
{-# INLINE getColorCtx #-}

-- | @since 1.0.0
instance Semigroup ColorCtx where
  -- Note: We use addition in Z mod 6 as the semigroup for 'ColorCtx' since the 'Enum' instance is cycles through 6
  -- colors.

  ColorCtx x <> ColorCtx y = ColorCtx (mod (x + y) 6)
  {-# INLINE (<>) #-}

-- | @since 1.0.0
instance Monoid ColorCtx where
  mempty = ColorCtx 0
  {-# INLINE mempty #-}

-- | @since 1.0.0
instance Enum ColorCtx where
  -- Note: AnsiStyle have six unique colors that are not black or white (cyan, red, green, yellow, blue and magenta).
  -- 'DiagramM' uses this 'Enum' instance and 'succ' to cycle through these six colors since the 'Color' datatype
  -- exported does not provide one out of the box.

  toEnum i = ColorCtx (mod i 6)
  {-# INLINE toEnum #-}

  fromEnum (ColorCtx i) = mod i 6
  {-# INLINE fromEnum #-}

  succ (ColorCtx i) = ColorCtx (mod (succ i) 6)
  {-# INLINE succ #-}