packages feed

matterhorn-50200.13.0: src/Matterhorn/Draw/RichText/Flatten.hs

-- | This module implements a "flattening" pass over RichText 'Inline'
-- values. This means that a tree structure such as
--
-- @
--   EStrong
--     [ EStrikethrough
--       [ EText "inside"
--       ]
--     , EText "outside"
--     ]
-- @
--
-- will be converted into a "flat" representation without a tree
-- structure so that the style information encoded in the tree is
-- available at each node:
--
-- @
--   [
--     [ SingleInline (FlattenedInline (FText "inside") [Strong, Strikethrough] Nothing
--     , SingleInline (FlattenedInline (FText "outside") [Strong] Nothing
--     ]
--   ]
-- @
--
-- The outer sequence is a sequence of lines (since inline lists can
-- introduce line breaks). Each inner sequence is a single line.
-- Each 'SingleInline' can be rendered as-is; if a 'NonBreaking' is
-- encountered, that group of inlines should be treated as a unit for
-- the purposes of line-wrapping (to happen in the Wrap module). The
-- above representation example shows how the tree path including the
-- 'EStrong' and 'EStrikethrough' nodes is flattened into a list of
-- styles to accompany each inline value. This makes it trivial to carry
-- that style information along with each node during line-wrapping
-- rather than needing to deal with the tree structure.
module Matterhorn.Draw.RichText.Flatten
  ( FlattenedContent(..)
  , FlattenedInline(..)
  , InlineStyle(..)
  , FlattenedValue(..)
  , flattenInlineSeq
  )
where

import           Prelude ()
import           Matterhorn.Prelude

import           Control.Monad.Reader
import           Control.Monad.State
import           Data.List ( nub )
import qualified Data.Sequence as Seq
import           Data.Sequence ( ViewL(..)
                               , ViewR(..)
                               , (<|)
                               , (|>)
                               )
import qualified Data.Set as Set
import qualified Data.Text as T

import           Matterhorn.Constants ( normalChannelSigil, userSigil )
import           Matterhorn.Types ( HighlightSet(..), SemEq(..) )
import           Matterhorn.Types.RichText


-- | A piece of text in a sequence of flattened RichText elements. This
-- type represents the lowest-level kind of data that we can get from a
-- rich text document.
data FlattenedContent =
    FText Text
    -- ^ Some text
    | FSpace
    -- ^ A space
    | FUser Text
    -- ^ A user reference
    | FChannel Text
    -- ^ A channel reference
    | FEmoji Text
    -- ^ An emoji
    | FEditSentinel Bool
    -- ^ An "edited" marking
    deriving (Eq, Show)

-- | A flattened inline value.
data FlattenedInline a =
    FlattenedInline { fiValue :: FlattenedContent
                    -- ^ The content of the value.
                    , fiStyles :: [InlineStyle]
                    -- ^ The styles that should be applied to this
                    -- value.
                    , fiURL :: Maybe URL
                    -- ^ If present, the URL to which we should
                    -- hyperlink this value.
                    , fiName :: Maybe a
                    -- ^ The resource name, if any, that should be used
                    -- to make this inline clickable once rendered.
                    }
                    deriving (Show)

-- | A flattened value.
data FlattenedValue a =
    SingleInline (FlattenedInline a)
    -- ^ A single flattened value
    | NonBreaking (Seq (Seq (FlattenedValue a)))
    -- ^ A sequence of flattened values that MUST be kept together and
    -- never broken up by line-wrapping
    deriving (Show)

-- | The visual styles of inline values.
data InlineStyle =
    Strong
    | Emph
    | Strikethrough
    | Code
    | Permalink
    deriving (Eq, Show)

type FlattenM n a = ReaderT (FlattenEnv n) (State (FlattenState n)) a

-- | The flatten monad state
data FlattenState a =
    FlattenState { fsCompletedLines :: Seq (Seq (FlattenedValue a))
                 -- ^ The lines that we have accumulated so far in the
                 -- flattening process
                 , fsCurLine :: Seq (FlattenedValue a)
                 -- ^ The current line we are accumulating in the
                 -- flattening process
                 , fsNameIndex :: Int
                 -- ^ The index used to generate a new unique name (of
                 -- type 'a') to make a region of text clickable.
                 }

-- | The flatten monad environment
data FlattenEnv a =
    FlattenEnv { flattenStyles :: [InlineStyle]
               -- ^ The styles that should apply to the current value
               -- being flattened
               , flattenURL :: Maybe URL
               -- ^ The hyperlink URL, if any, that should be applied to
               -- the current value being flattened
               , flattenHighlightSet :: HighlightSet
               -- ^ The highlight set to use to check for valid user or
               -- channel references
               , flattenNameGen :: Maybe (Int -> Inline -> Maybe a)
               -- ^ The function to use to generate resource names
               -- for clickable inlines. If provided, this is used to
               -- determine whether a given Inline should be augmented
               -- with a resource name.
               , flattenNameFunc :: Maybe (Int -> Maybe a)
               -- ^ The currently active function to generate a resource
               -- name for any inline. In practice this is just the
               -- value of flattenNameGen, but partially applied with a
               -- specific Inline prior to flattening that Inline.
               }

-- | Given a sequence of inlines, flatten it into a list of lines of
-- flattened values.
--
-- The flattening process also validates user and channel references
-- against a 'HighlightSet'. For example, if an 'EUser' node is found,
-- its username argument is looked up in the 'HighlightSet'. If the
-- username is found, the 'EUser' node is preserved as an 'FUser' node.
-- Otherwise it is rewritten as an 'FText' node so that the username
-- does not get highlighted. Channel references ('EChannel') are handled
-- similarly.
--
-- The optional name generator function argument is used to assign
-- resource names to each inline that should be clickable once rendered.
-- The result of the name generator function will be stored in the
-- 'fiName' field of each 'FlattenedInline' that results from calling
-- that function on an 'Inline'.
flattenInlineSeq :: SemEq a
                 => HighlightSet
                 -> Maybe (Int -> Inline -> Maybe a)
                 -- ^ A name generator function for clickable inlines.
                 -- The integer argument is a unique (to this inline
                 -- sequence) sequence number.
                 -> Inlines
                 -> Seq (Seq (FlattenedValue a))
flattenInlineSeq hs nameGen is =
    snd $ flattenInlineSeq' initialEnv 0 is
    where
        initialEnv = FlattenEnv { flattenStyles = []
                                , flattenURL = Nothing
                                , flattenHighlightSet = hs
                                , flattenNameGen = nameGen
                                , flattenNameFunc = Nothing
                                }

flattenInlineSeq' :: SemEq a
                  => FlattenEnv a
                  -> Int
                  -> Inlines
                  -> (Int, Seq (Seq (FlattenedValue a)))
flattenInlineSeq' env c is =
    (fsNameIndex finalState, fsCompletedLines finalState)
    where
        finalState = execState stBody initialState
        initialState = FlattenState { fsCompletedLines = mempty
                                    , fsCurLine = mempty
                                    , fsNameIndex = c
                                    }
        stBody = runReaderT body env
        body = do
            flattenInlines is
            pushFLine

flattenInlines :: SemEq a => Inlines -> FlattenM a ()
flattenInlines is = do
    pairs <- nameInlinePairs
    mapM_ wrapFlatten pairs
    where
        wrapFlatten (nameFunc, i) = withNameFunc nameFunc $ flatten i

        -- For each inline, prior to flattening it, obtain the resource
        -- name (if any) that should be assigned to each flattened
        -- fragment of the inline.
        nameInlinePairs = forM (unInlines is) $ \i -> do
            nameFunc <- nameGenWrapper i
            return (nameFunc, i)

        -- Determine whether the name generation function will produce
        -- a name for this inline. If it does (using a fake sequence
        -- number) then return a new name generation function to use for
        -- all flattened fragments of this inline.
        nameGenWrapper :: Inline -> FlattenM a (Maybe (Int -> Maybe a))
        nameGenWrapper i = do
            c <- gets fsNameIndex
            nameGen <- asks flattenNameGen
            return $ case nameGen of
                Nothing -> Nothing
                Just f -> if isJust (f c i) then Just (flip f i) else Nothing

withNameFunc :: Maybe (Int -> Maybe a) -> FlattenM a () -> FlattenM a ()
withNameFunc f@(Just _) = withReaderT (\e -> e { flattenNameFunc = f })
withNameFunc Nothing = id

withInlineStyle :: InlineStyle -> FlattenM a () -> FlattenM a ()
withInlineStyle s =
    withReaderT (\e -> e { flattenStyles = nub (s : flattenStyles e) })

withHyperlink :: URL -> FlattenM a () -> FlattenM a ()
withHyperlink u = withReaderT (\e -> e { flattenURL = Just u })

-- | Push a FlattenedContent value onto the current line.
pushFC :: SemEq a => FlattenedContent -> FlattenM a ()
pushFC v = do
    env <- ask
    name <- getNextName
    let styles = flattenStyles env
        mUrl = flattenURL env
        fi = FlattenedInline { fiValue = v
                             , fiStyles = styles
                             , fiURL = mUrl
                             , fiName = name
                             }
    pushFV $ SingleInline fi

getNextName :: FlattenM a (Maybe a)
getNextName = do
    nameGen <- asks flattenNameFunc
    case nameGen of
        Nothing -> return Nothing
        Just f -> f <$> getNextNameIndex

getNextNameIndex :: FlattenM a Int
getNextNameIndex = do
    c <- gets fsNameIndex
    modify ( \s -> s { fsNameIndex = c + 1} )
    return c

setNextNameIndex :: Int -> FlattenM a ()
setNextNameIndex i = modify ( \s -> s { fsNameIndex = i } )

-- | Push a FlattenedValue onto the current line.
pushFV :: SemEq a => FlattenedValue a -> FlattenM a ()
pushFV fv = lift $ modify $ \s -> s { fsCurLine = appendFV fv (fsCurLine s) }

-- | Append the value to the sequence.
--
-- If the both the value to append AND the sequence's last value are
-- both text nodes, AND if those nodes both have the same style and URL
-- metadata, then merge them into one text node. This keeps adjacent
-- non-whitespace text together as one logical token (e.g. "(foo" rather
-- than "(" followed by "foo") to avoid undesirable line break points in
-- the wrapping process.
appendFV :: SemEq a => FlattenedValue a -> Seq (FlattenedValue a) -> Seq (FlattenedValue a)
appendFV v line =
    case (Seq.viewr line, v) of
        (h :> SingleInline a, SingleInline b) ->
            case (fiValue a, fiValue b) of
                (FText aT, FText bT) ->
                    if fiStyles a == fiStyles b && fiURL a == fiURL b && fiName a `semeq` fiName b
                    then h |> SingleInline (FlattenedInline (FText $ aT <> bT)
                                                            (fiStyles a)
                                                            (fiURL a)
                                                            (max (fiName a) (fiName b)))
                    else line |> v
                _ -> line |> v
        _ -> line |> v

-- | Push the current line onto the finished lines list and start a new
-- line.
pushFLine :: FlattenM a ()
pushFLine =
    lift $ modify $ \s -> s { fsCompletedLines = fsCompletedLines s |> fsCurLine s
                            , fsCurLine = mempty
                            }

isKnownUser :: T.Text -> FlattenM a Bool
isKnownUser u = do
    hSet <- asks flattenHighlightSet
    let uSet = hUserSet hSet
    return $ u `Set.member` uSet

isKnownChannel :: T.Text -> FlattenM a Bool
isKnownChannel c = do
    hSet <- asks flattenHighlightSet
    let cSet = hChannelSet hSet
    return $ c `Set.member` cSet

flatten :: SemEq a => Inline -> FlattenM a ()
flatten i =
    case i of
        EUser u -> do
            known <- isKnownUser u
            if known then pushFC (FUser u)
                     else pushFC (FText $ userSigil <> u)
        EChannel c -> do
            known <- isKnownChannel c
            if known then pushFC (FChannel c)
                     else pushFC (FText $ normalChannelSigil <> c)

        ENonBreaking is -> do
            env <- ask
            ni <- getNextNameIndex
            let (ni', s) = flattenInlineSeq' env ni is
            pushFV $ NonBreaking s
            setNextNameIndex ni'

        ESoftBreak                  -> pushFLine
        ELineBreak                  -> pushFLine

        EText t                     -> pushFC $ FText t
        ESpace                      -> pushFC FSpace
        ERawHtml h                  -> pushFC $ FText h
        EEmoji e                    -> pushFC $ FEmoji e
        EEditSentinel r             -> pushFC $ FEditSentinel r

        EEmph es                    -> withInlineStyle Emph $ flattenInlines es
        EStrikethrough es           -> withInlineStyle Strikethrough $ flattenInlines es
        EStrong es                  -> withInlineStyle Strong $ flattenInlines es
        ECode es                    -> withInlineStyle Code $ flattenInlines es

        EPermalink _ _ mLabel ->
            let label' = fromMaybe (Inlines $ Seq.fromList [EText "post", ESpace, EText "link"])
                                   mLabel
            in withInlineStyle Permalink $ flattenInlines $ decorateLinkLabel label'

        EHyperlink u label@(Inlines ls) ->
            let label' = if Seq.null ls
                         then Inlines $ Seq.singleton $ EText $ unURL u
                         else label
            in withHyperlink u $ flattenInlines $ decorateLinkLabel label'

        EImage u label@(Inlines ls) ->
            let label' = if Seq.null ls
                         then Inlines $ Seq.singleton $ EText $ unURL u
                         else label
            in withHyperlink u $ flattenInlines $ decorateLinkLabel label'

linkOpenBracket :: Inline
linkOpenBracket = EText "<"

linkCloseBracket :: Inline
linkCloseBracket = EText ">"

addOpenBracket :: Inlines -> Inlines
addOpenBracket (Inlines l) =
    case Seq.viewl l of
        EmptyL -> Inlines l
        h :< t ->
            let h' = ENonBreaking $ Inlines $ Seq.fromList [linkOpenBracket, h]
            in Inlines $ h' <| t

addCloseBracket :: Inlines -> Inlines
addCloseBracket (Inlines l) =
    case Seq.viewr l of
        EmptyR -> Inlines l
        h :> t ->
            let t' = ENonBreaking $ Inlines $ Seq.fromList [t, linkCloseBracket]
            in Inlines $ h |> t'

decorateLinkLabel :: Inlines -> Inlines
decorateLinkLabel = addOpenBracket .  addCloseBracket