packages feed

matterhorn-50200.16.0: src/Matterhorn/Draw/RichText.hs

{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE ViewPatterns #-}
{-# LANGUAGE ParallelListComp #-}
module Matterhorn.Draw.RichText
  ( renderRichText
  , renderText
  , renderText'
  , cursorSentinel
  , findVerbatimChunk
  )
where

import           Prelude ()
import           Matterhorn.Prelude

import           Brick ( (<+>), Widget, hLimit, imageL
                       , render, Size(..), Widget(..)
                       )
import qualified Brick as B
import qualified Brick.Widgets.Border as B
import qualified Brick.Widgets.Table as B
import qualified Brick.Widgets.Skylighting as BS
import           Control.Monad.Reader
import qualified Data.Foldable as F
import qualified Data.Sequence as S
import           Data.Sequence ( ViewL(..)
                               , (<|)
                               , viewl
                               )
import qualified Data.Text as T
import qualified Graphics.Vty as V
import qualified Skylighting.Core as Sky

import           Matterhorn.Constants ( normalChannelSigil, editMarking )
import           Matterhorn.Draw.RichText.Flatten
import           Matterhorn.Draw.RichText.Wrap
import           Matterhorn.Themes
import           Matterhorn.Types ( HighlightSet(..), emptyHSet, SemEq(..)
                                  , addUserSigil, resultToWidget )
import           Matterhorn.Types.RichText


-- | Render rich text.
renderRichText :: SemEq a
               => Text
               -- ^ The username of the currently-authenticated user.
               -> HighlightSet
               -- ^ A highlight set for highlighting channel and
               -- usernames and code blocks.
               -> Maybe Int
               -- ^ An optional maximum width.
               -> Bool
               -- ^ Whether to do line wrapping.
               -> Maybe Int
               -- ^ At what height to truncate long verbatim/code blocks
               -> Maybe (Int -> Inline -> Maybe a)
               -- ^ An optional function to build resource names for
               -- clickable regions.
               -> Blocks
               -- ^ The content to render.
               -> Widget a
renderRichText curUser hSet w doWrap doVerbTrunc nameGen (Blocks bs) =
    runReader (do
              blocks <- mapM renderBlock (addBlankLines bs)
              return $ B.vBox $ toList blocks)
              (DrawCfg { drawCurUser = curUser
                       , drawHighlightSet = hSet
                       , drawLineWidth = w
                       , drawDoLineWrapping = doWrap
                       , drawTruncateVerbatimBlocks = doVerbTrunc
                       , drawNameGen = nameGen
                       })

-- Render text to markdown without username highlighting, permalink
-- detection, or clickable links
renderText :: SemEq a => Text -> Widget a
renderText txt = renderText' Nothing "" emptyHSet Nothing txt

renderText' :: SemEq a
            => Maybe TeamBaseURL
            -- ^ An optional base URL against which to match post links.
            -> Text
            -- ^ The username of the currently-authenticated user.
            -> HighlightSet
            -- ^ A highlight set for highlighting channel and usernames.
            -> Maybe (Int -> Inline -> Maybe a)
            -- ^ An optional function to build resource names for
            -- clickable regions.
            -> Text
            -- ^ The text to parse and then render as rich text.
            -> Widget a
renderText' baseUrl curUser hSet nameGen t =
    renderRichText curUser hSet Nothing True Nothing nameGen $
        parseMarkdown baseUrl t

-- Add blank lines only between adjacent elements of the same type, to
-- save space
addBlankLines :: Seq Block -> Seq Block
addBlankLines = go' . viewl
  where go' EmptyL = S.empty
        go' (x :< xs) = go x (viewl xs)
        go a (b :< rs)
            | sameBlockType a b = a <| blank <| go b (viewl rs)
            | otherwise         = a <| go b (viewl rs)
        go x EmptyL = S.singleton x
        blank = Para (Inlines $ S.singleton ESpace)

vBox :: F.Foldable f => f (Widget a) -> Widget a
vBox = B.vBox . toList

hBox :: F.Foldable f => f (Widget a) -> Widget a
hBox = B.hBox . toList

header :: Int -> Widget a
header n = B.txt (T.replicate n "#")

maybeHLimit :: Maybe Int -> Widget a -> Widget a
maybeHLimit Nothing w = w
maybeHLimit (Just i) w = hLimit i w

type M a b = Reader (DrawCfg b) a

data DrawCfg a =
    DrawCfg { drawCurUser :: Text
            , drawHighlightSet :: HighlightSet
            , drawLineWidth :: Maybe Int
            , drawDoLineWrapping :: Bool
            , drawTruncateVerbatimBlocks :: Maybe Int
            , drawNameGen :: Maybe (Int -> Inline -> Maybe a)
            }

renderBlock :: (Ord a, SemEq a) => Block -> M (Widget a) a
renderBlock (Table aligns headings body) = do
    headingWs <- mapM renderInlines headings
    bodyWs <- forM body $ mapM renderInlines
    let t = B.table (headingWs : bodyWs)
        alignPairs = zip [0..] aligns
        align (_, LeftAlignedCol) = id
        align (_, DefaultAlignedCol) = id
        align (i, RightAlignedCol) = B.alignRight i
        align (i, CenterAlignedCol) = B.alignCenter i
        applyAlignment = foldr (.) id (align <$> alignPairs)
    return $ B.renderTable $ applyAlignment t
renderBlock (Para is) =
    renderInlines is
renderBlock (Header n is) = do
    headerTxt <- withReader (\c -> c { drawLineWidth = subtract 1 <$> drawLineWidth c }) $
                 renderInlines is
    return $ B.withDefAttr clientHeaderAttr $
        hBox [ B.padRight (B.Pad 1) $ header n
             , headerTxt
             ]
renderBlock (Blockquote bs) = do
    w <- asks drawLineWidth
    bws <- mapM renderBlock (unBlocks bs)
    return $ maybeHLimit w $ addQuoting $ vBox bws
renderBlock (List ty spacing bs) = do
    w <- asks drawLineWidth
    lst <- renderList ty spacing bs
    return $ maybeHLimit w lst
renderBlock (CodeBlock ci tx) = do
    hSet <- asks drawHighlightSet

    let f = maybe renderRawCodeBlock
                  (renderCodeBlock (hSyntaxMap hSet))
                  mSyntax
        mSyntax = do
            lang <- codeBlockLanguage ci
            Sky.lookupSyntax lang (hSyntaxMap hSet)
    w <- f tx
    trunc <- asks drawTruncateVerbatimBlocks
    case trunc of
        Nothing -> return w
        Just maxHeight -> return $ maybeTruncVerbatim maxHeight w
renderBlock (HTMLBlock t) = do
    w <- asks drawLineWidth
    return $ maybeHLimit w $ textWithCursor t
renderBlock (HRule) = do
    w <- asks drawLineWidth
    return $ maybeHLimit w $ B.vLimit 1 (B.fill '*')

maybeTruncVerbatim :: Int -> B.Widget n -> B.Widget n
maybeTruncVerbatim maxHeight w =
    Widget (B.hSize w) (B.vSize w) $ do
        result <- render w
        let h = V.imageHeight (result^.B.imageL)
        if h > maxHeight
           then render $ B.vBox [ B.vLimit maxHeight $ B.Widget B.Fixed B.Fixed $ return result
                                , B.withDefAttr verbatimTruncateMessageAttr $
                                  B.str $ "(Showing " <> show maxHeight <> " of " <> show h <> " lines)"
                                ]
           else return result

quoteChar :: Char
quoteChar = '>'

addQuoting :: B.Widget n -> B.Widget n
addQuoting w =
    B.Widget B.Fixed (B.vSize w) $ do
        ctx <- B.getContext
        childResult <- B.render $ B.hLimit (ctx^.B.availWidthL - 2) w

        let quoteBorder = B.raw $ V.charFill (ctx^.B.attrL) quoteChar 1 height
            height = V.imageHeight $ childResult^.B.imageL

        B.render $ B.hBox [ B.padRight (B.Pad 1) quoteBorder
                          , B.Widget B.Fixed B.Fixed $ return childResult
                          ]

renderCodeBlock :: Sky.SyntaxMap -> Sky.Syntax -> Text -> M (Widget a) b
renderCodeBlock syntaxMap syntax tx = do
    let result = Sky.tokenize cfg syntax tx
        cfg = Sky.TokenizerConfig syntaxMap False
    case result of
        Left _ -> renderRawCodeBlock tx
        Right tokLines -> do
            let padding = B.padLeftRight 1 (B.vLimit (length tokLines) B.vBorder)
            return $ (B.txt $ "[" <> Sky.sName syntax <> "]") B.<=>
                     (padding <+> BS.renderRawSource textWithCursor tokLines)

renderRawCodeBlock :: Text -> M (Widget a) b
renderRawCodeBlock tx = do
    doWrap <- asks drawDoLineWrapping

    let hPolicy = if doWrap then Greedy else Fixed
    return $ B.withDefAttr codeAttr $
        Widget hPolicy Fixed $ do
            c <- B.getContext
            let theLines = expandEmpty <$> T.lines tx
                expandEmpty "" = " "
                expandEmpty s  = s
                wrapFunc = if doWrap then wrappedTextWithCursor
                                     else textWithCursor
            renderedText <- render (B.hLimit (c^.B.availWidthL - 3) $ B.vBox $
                                    wrapFunc <$> theLines)

            let textHeight = V.imageHeight $ renderedText^.imageL
                padding = B.padLeftRight 1 (B.vLimit textHeight B.vBorder)

            render $ padding <+> (resultToWidget renderedText)

renderInlines :: (Ord a, SemEq a) => Inlines -> M (Widget a) a
renderInlines es = do
    w <- asks drawLineWidth
    hSet <- asks drawHighlightSet
    curUser <- asks drawCurUser
    nameGen <- asks drawNameGen

    return $ B.Widget B.Fixed B.Fixed $ do
        ctx <- B.getContext
        let width = fromMaybe (ctx^.B.availWidthL) w
            ws    = fmap (renderWrappedLine curUser) $
                    mconcat $
                    (doLineWrapping width <$> (F.toList $ flattenInlineSeq hSet nameGen es))
        B.render (vBox ws)

renderList :: (Ord a, SemEq a) => ListType -> ListSpacing -> Seq Blocks -> M (Widget a) a
renderList ty _spacing bs = do
    let is = case ty of
          BulletList _ -> repeat ("• ")
          OrderedList s _ Period ->
            [ T.pack (show (n :: Int)) <> ". " | n <- [s..] ]
          OrderedList s _ OneParen ->
            [ T.pack (show (n :: Int)) <> ") " | n <- [s..] ]
          OrderedList s _ TwoParens ->
            [ T.pack (show (n :: Int)) <> ")) " | n <- [s..] ]

    results <- forM (zip is $ unBlocks <$> (F.toList bs)) $ \(i, b) -> do
        blocks <- mapM renderBlock b
        return $ B.txt i <+> vBox blocks

    return $ vBox results

renderWrappedLine :: (Ord a, Show a) => Text -> WrappedLine a -> Widget a
renderWrappedLine curUser l = hBox $ F.toList $ renderFlattenedValue curUser <$> l

renderFlattenedValue :: (Ord a, Show a) => Text -> FlattenedValue a -> Widget a
renderFlattenedValue curUser (NonBreaking rs) =
    let renderLine = hBox . F.toList . fmap (renderFlattenedValue curUser)
    in vBox (F.toList $ renderLine <$> F.toList rs)
renderFlattenedValue curUser (SingleInline fi) = addClickable $ addHyperlink $ addStyles widget
    where
        val = fiValue fi
        mUrl = fiURL fi
        mName = fiName fi
        styles = fiStyles fi

        addStyles w = foldr addStyle w styles
        addStyle s =
            B.withDefAttr $ case s of
                Strong        -> clientStrongAttr
                Code          -> codeAttr
                Permalink     -> permalinkAttr
                Strikethrough -> strikeThroughAttr
                Emph          -> clientEmphAttr

        addHyperlink = case mUrl of
            Nothing -> id
            Just u -> B.withDefAttr urlAttr . B.hyperlink (unURL u)

        addClickable w = case mName of
            Nothing -> id w
            Just nm -> B.clickable nm w

        widget = case val of
            FSpace               -> B.txt " "
            FUser u              -> colorUsername curUser u $ addUserSigil u
            FChannel c           -> B.withDefAttr channelNameAttr $
                                    B.txt $ normalChannelSigil <> c
            FEmoji em            -> B.withDefAttr emojiAttr $
                                    B.txt $ ":" <> em <> ":"
            FText t              -> if t == T.singleton (cursorSentinel)
                                    then B.visible $ B.txt " "
                                    else textWithCursor t
            FEditSentinel recent -> let attr = if recent
                                               then editedRecentlyMarkingAttr
                                               else editedMarkingAttr
                                    in B.withDefAttr attr $ B.txt editMarking

textWithCursor :: Text -> Widget a
textWithCursor t
    | T.any (== cursorSentinel) t = B.visible $ B.txt $ removeCursor t
    | otherwise = B.txt t

wrappedTextWithCursor :: Text -> Widget a
wrappedTextWithCursor t
    | T.any (== cursorSentinel) t = B.visible $ B.txtWrap $ removeCursor t
    | otherwise = B.txtWrap t

removeCursor :: Text -> Text
removeCursor = T.filter (/= cursorSentinel)

-- Cursor sentinel for tracking the user's cursor position in previews.
cursorSentinel :: Char
cursorSentinel = '‸'