{-# LANGUAGE LambdaCase #-} {-# LANGUAGE MultiWayIf #-} {-# LANGUAGE ViewPatterns #-} {-# LANGUAGE ParallelListComp #-} {-# LANGUAGE RecordWildCards #-} module Draw.RichText ( MessageData(..) , renderRichText , renderMessage , renderText , renderText' , renderElementSeq , cursorSentinel , addEllipsis , findVerbatimChunk ) where import Prelude () import Prelude.MH import Brick ( (<+>), Widget, hLimit, imageL , raw, render, Size(..), Widget(..) ) import qualified Brick as B import qualified Brick.Widgets.Border as B import qualified Brick.Widgets.Skylighting as BS import qualified Data.Foldable as F import qualified Data.Map.Strict as Map import qualified Data.Sequence as Seq import Data.Sequence ( ViewL(..) , ViewR(..) , (<|) , (|>) , viewl , viewr) import qualified Data.Sequence as S import qualified Data.Set as Set import qualified Data.Text as T import qualified Graphics.Vty as V import qualified Skylighting.Core as Sky import Network.Mattermost.Lenses ( postEditAtL, postCreateAtL ) import Network.Mattermost.Types ( ServerTime(..) ) import Constants ( normalChannelSigil, userSigil ) import Themes import Types ( Name, HighlightSet(..) ) import Types.Messages import Types.Posts import Types.RichText emptyHSet :: HighlightSet emptyHSet = HighlightSet Set.empty Set.empty mempty omittedUsernameType :: MessageType -> Bool omittedUsernameType = \case CP Join -> True CP Leave -> True CP TopicChange -> True _ -> False -- Add the edit sentinel to the end of the last block in the sequence. -- If the last block is a paragraph, append it to that paragraph. -- Otherwise, append a new block so it appears beneath the last -- block-level element. addEditSentinel :: ElementData -> Seq RichTextBlock -> Seq RichTextBlock addEditSentinel d bs = case viewr bs of EmptyR -> bs (rest :> b) -> rest <> appendEditSentinel d b appendEditSentinel :: ElementData -> RichTextBlock -> Seq RichTextBlock appendEditSentinel sentinel b = let s = Para (S.singleton m) m = Element Normal sentinel in case b of Para is -> S.singleton $ Para (is |> Element Normal ESpace |> m) _ -> S.fromList [b, s] -- | A bundled structure that includes all the information necessary -- to render a given message data MessageData = MessageData { mdEditThreshold :: Maybe ServerTime -- ^ If specified, any messages edited before this point -- in time are not indicated as edited. , mdShowOlderEdits :: Bool -- ^ Indicates whether "edited" markers should be shown -- for old messages (i.e., ignore the mdEditThreshold -- value). , mdShowReactions :: Bool -- ^ Whether to render reactions. , mdMessage :: Message -- ^ The message to render. , mdUserName :: Maybe Text -- ^ The username of the message's author, if any. This -- is passed here rather than obtaining from the message -- because we need to do lookups in the ChatState to -- compute this, and we don't pass the ChatState into -- renderMessage. , mdParentMessage :: Maybe Message -- ^ The parent message of this message, if any. , mdParentUserName :: Maybe Text -- ^ The author of the parent message, if any. , mdThreadState :: ThreadState -- ^ The thread state of this message. , mdRenderReplyParent :: Bool -- ^ Whether to render the parent message. , mdHighlightSet :: HighlightSet -- ^ The highlight set to use to highlight usernames, -- channel names, etc. , mdIndentBlocks :: Bool -- ^ Whether to indent the message underneath the -- author's name (True) or just display it to the right -- of the author's name (False). , mdMessageWidthLimit :: Maybe Int -- ^ A width override to use to wrap non-code blocks. If -- unspecified, all blocks in the message will be -- wrapped and truncated at the width specified by the -- rendering context. If specified, all non-code blocks -- will be wrapped at this width and code blocks will be -- rendered using the context's width. , mdMyUsername :: Text -- ^ The username of the user running Matterhorn. } -- | renderMessage performs markdown rendering of the specified message. renderMessage :: MessageData -> Widget Name renderMessage md@MessageData { mdMessage = msg, .. } = let msgUsr = case mdUserName of Just u -> if omittedUsernameType (msg^.mType) then Nothing else Just u Nothing -> Nothing botElem = if isBotMessage msg then B.txt "[BOT]" else B.emptyWidget nameElems = case msgUsr of Just un | isEmote msg -> [ B.withDefAttr pinnedMessageIndicatorAttr $ B.txt $ if msg^.mPinned then "[PIN]" else "" , B.txt $ (if msg^.mFlagged then "[!] " else "") <> "*" , colorUsername mdMyUsername un un , botElem , B.txt " " ] | otherwise -> [ B.withDefAttr pinnedMessageIndicatorAttr $ B.txt $ if msg^.mPinned then "[PIN] " else "" , colorUsername mdMyUsername un un , botElem , B.txt $ (if msg^.mFlagged then "[!]" else "") <> ": " ] Nothing -> [] -- Use the editing threshold to determine whether to append an -- editing indication to this message. maybeAugment bs = case msg^.mOriginalPost of Nothing -> bs Just p -> if p^.postEditAtL > p^.postCreateAtL then case mdEditThreshold of Just cutoff | p^.postEditAtL >= cutoff -> addEditSentinel (EEditSentinel True) bs _ -> if mdShowOlderEdits then addEditSentinel (EEditSentinel False) bs else bs else bs augmentedText = maybeAugment $ msg^.mText msgWidget = vBox $ (layout mdHighlightSet mdMessageWidthLimit nameElems augmentedText . viewl) augmentedText : catMaybes [msgAtch, msgReac] replyIndent = B.Widget B.Fixed B.Fixed $ do ctx <- B.getContext -- NB: The amount subtracted here must be the total padding -- added below (pad 1 + vBorder) w <- B.render $ B.hLimit (ctx^.B.availWidthL - 2) msgWidget B.render $ B.vLimit (V.imageHeight $ w^.B.imageL) $ B.padRight (B.Pad 1) B.vBorder B.<+> (B.Widget B.Fixed B.Fixed $ return w) msgAtch = if Seq.null (msg^.mAttachments) then Nothing else Just $ B.withDefAttr clientMessageAttr $ vBox [ B.txt (" [attached: `" <> a^.attachmentName <> "`]") | a <- toList (msg^.mAttachments) ] msgReac = if Map.null (msg^.mReactions) || (not mdShowReactions) then Nothing else let renderR e us = let n = Set.size us in if | n == 1 -> " [" <> e <> "]" | n > 1 -> " [" <> e <> " " <> T.pack (show n) <> "]" | otherwise -> "" reactionMsg = Map.foldMapWithKey renderR (msg^.mReactions) in Just $ B.withDefAttr emojiAttr $ B.txt (" " <> reactionMsg) withParent p = case mdThreadState of NoThread -> msgWidget InThreadShowParent -> p B.<=> replyIndent InThread -> replyIndent in if not mdRenderReplyParent then msgWidget else case msg^.mInReplyToMsg of NotAReply -> msgWidget InReplyTo _ -> case mdParentMessage of Nothing -> withParent (B.str "[loading...]") Just pm -> let parentMsg = renderMessage md { mdShowOlderEdits = False , mdMessage = pm , mdUserName = mdParentUserName , mdParentMessage = Nothing , mdRenderReplyParent = False , mdIndentBlocks = False } in withParent (addEllipsis $ B.forceAttr replyParentAttr parentMsg) where layout :: HighlightSet -> Maybe Int -> [Widget Name] -> Seq RichTextBlock -> ViewL RichTextBlock -> Widget Name layout hs w nameElems bs xs | length xs > 1 = multiLnLayout hs w nameElems bs layout hs w nameElems bs (Blockquote {} :< _) = multiLnLayout hs w nameElems bs layout hs w nameElems bs (CodeBlock {} :< _) = multiLnLayout hs w nameElems bs layout hs w nameElems bs (HTMLBlock {} :< _) = multiLnLayout hs w nameElems bs layout hs w nameElems bs (List {} :< _) = multiLnLayout hs w nameElems bs layout hs w nameElems bs (Para inlns :< _) | F.any breakCheck inlns = multiLnLayout hs w nameElems bs layout hs w nameElems bs _ = nameNextToMessage hs w nameElems bs multiLnLayout hs w nameElems bs = if mdIndentBlocks then vBox [ hBox nameElems , hBox [B.txt " ", renderRichText mdMyUsername hs ((subtract 2) <$> w) bs] ] else nameNextToMessage hs w nameElems bs nameNextToMessage hs w nameElems bs = Widget Fixed Fixed $ do nameResult <- render $ hBox nameElems let newW = subtract (V.imageWidth (nameResult^.imageL)) <$> w render $ hBox [raw (nameResult^.imageL), renderRichText mdMyUsername hs newW bs] breakCheck e = eData e `elem` [ELineBreak, ESoftBreak] addEllipsis :: Widget a -> Widget a addEllipsis w = B.Widget (B.hSize w) (B.vSize w) $ do ctx <- B.getContext let aw = ctx^.B.availWidthL result <- B.render w let withEllipsis = (B.hLimit (aw - 3) $ B.vLimit 1 $ (B.Widget B.Fixed B.Fixed $ return result)) <+> B.str "..." if (V.imageHeight (result^.B.imageL) > 1) || (V.imageWidth (result^.B.imageL) == aw) then B.render withEllipsis else return result -- Cursor sentinel for tracking the user's cursor position in previews. cursorSentinel :: Char cursorSentinel = '‸' -- Render markdown with username highlighting renderRichText :: Text -> HighlightSet -> Maybe Int -> Seq RichTextBlock -> Widget a renderRichText curUser hSet w = B.vBox . toList . fmap (blockToWidget curUser hSet w) . addBlankLines -- Add blank lines only between adjacent elements of the same type, to -- save space addBlankLines :: Seq RichTextBlock -> Seq RichTextBlock addBlankLines = go' . viewl where go' EmptyL = S.empty go' (x :< xs) = go x (viewl xs) go a@Para {} (b@Para {} :< rs) = a <| blank <| go b (viewl rs) go a@Header {} (b@Header {} :< rs) = a <| blank <| go b (viewl rs) go a@Blockquote {} (b@Blockquote {} :< rs) = a <| blank <| go b (viewl rs) go a@List {} (b@List {} :< rs) = a <| blank <| go b (viewl rs) go a@CodeBlock {} (b@CodeBlock {} :< rs) = a <| blank <| go b (viewl rs) go a@HTMLBlock {} (b@HTMLBlock {} :< rs) = a <| blank <| go b (viewl rs) go x (y :< rs) = x <| go y (viewl rs) go x (EmptyL) = S.singleton x blank = Para (S.singleton (Element Normal ESpace)) -- Render text to markdown without username highlighting renderText :: Text -> Widget a renderText txt = renderText' "" emptyHSet txt renderText' :: Text -> HighlightSet -> Text -> Widget a renderText' curUser hSet t = renderRichText curUser hSet Nothing rtBs where rtBs = parseMarkdown t 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 blockToWidget :: Text -> HighlightSet -> Maybe Int -> RichTextBlock -> Widget a blockToWidget curUser hSet w (Para is) = toElementChunk curUser w is hSet blockToWidget curUser hSet w (Header n is) = B.withDefAttr clientHeaderAttr $ hBox [B.padRight (B.Pad 1) $ header n, toElementChunk curUser (subtract 1 <$> w) is hSet] blockToWidget curUser hSet w (Blockquote is) = maybeHLimit w $ addQuoting (vBox $ fmap (blockToWidget curUser hSet w) is) blockToWidget curUser hSet w (List _ l bs) = maybeHLimit w $ blocksToList curUser l w bs hSet blockToWidget _ hSet _ (CodeBlock ci tx) = let f = maybe rawCodeBlockToWidget (codeBlockToWidget (hSyntaxMap hSet)) mSyntax mSyntax = do lang <- codeBlockLanguage ci Sky.lookupSyntax lang (hSyntaxMap hSet) in f tx blockToWidget _ _ w (HTMLBlock t) = maybeHLimit w $ textWithCursor t blockToWidget _ _ w (HRule) = maybeHLimit w $ B.vLimit 1 (B.fill '*') 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 ] codeBlockToWidget :: Sky.SyntaxMap -> Sky.Syntax -> Text -> Widget a codeBlockToWidget syntaxMap syntax tx = let result = Sky.tokenize cfg syntax tx cfg = Sky.TokenizerConfig syntaxMap False in case result of Left _ -> rawCodeBlockToWidget tx Right tokLines -> let padding = B.padLeftRight 1 (B.vLimit (length tokLines) B.vBorder) in (B.txt $ "[" <> Sky.sName syntax <> "]") B.<=> (padding <+> BS.renderRawSource textWithCursor tokLines) rawCodeBlockToWidget :: Text -> Widget a rawCodeBlockToWidget tx = B.withDefAttr codeAttr $ let padding = B.padLeftRight 1 (B.vLimit (length theLines) B.vBorder) theLines = expandEmpty <$> T.lines tx expandEmpty "" = " " expandEmpty s = s in padding <+> (B.vBox $ textWithCursor <$> theLines) toElementChunk :: Text -> Maybe Int -> Seq Element -> HighlightSet -> Widget a toElementChunk curUser w es hSet = B.Widget B.Fixed B.Fixed $ do ctx <- B.getContext let width = fromMaybe (ctx^.B.availWidthL) w ws = fmap (renderElementSeq curUser) (wrapLine width hSet es) B.render (vBox (fmap hBox ws)) blocksToList :: Text -> ListType -> Maybe Int -> Seq (Seq RichTextBlock) -> HighlightSet -> Widget a blocksToList curUser lt w bs hSet = vBox [ B.txt i <+> (vBox (fmap (blockToWidget curUser hSet w) b)) | b <- F.toList bs | i <- is ] where is = case lt of Bullet _ -> repeat ("• ") Numbered Period s -> [ T.pack (show (n :: Int)) <> ". " | n <- [s..] ] Numbered Paren s -> [ T.pack (show (n :: Int)) <> ") " | n <- [s..] ] data SplitState = SplitState { splitChunks :: Seq (Seq Element) , splitCurrCol :: Int } wrapLine :: Int -> HighlightSet -> Seq Element -> Seq (Seq Element) wrapLine maxCols hSet = splitChunks . go (SplitState (S.singleton S.empty) 0) where go st (viewl-> e :< es) = go st' es where HighlightSet { hUserSet = uSet, hChannelSet = cSet } = hSet -- Right before we check the width of the token, we see -- if the token is a user or channel reference. If so, we -- check if it is valid. If it is valid, we leave it in -- place; otherwise we translate it into an ordinary text -- element so that it does not render highlighted as a -- valid user or channel reference. newElement = e { eData = newEData } newEData = case eData e of EUser u -> if u `Set.member` uSet then EUser u else EText $ userSigil <> u EChannel c -> if c `Set.member` cSet then EChannel c else EText $ normalChannelSigil <> c d -> d addHyperlink url el = setElementStyle (Hyperlink url (eStyle el)) el st' = case newEData of EHyperlink url (Just labelEs) -> go st $ addHyperlink url <$> labelEs EImage url (Just labelEs) -> go st $ addHyperlink url <$> labelEs _ -> if | newEData == ESoftBreak || newEData == ELineBreak -> st { splitChunks = splitChunks st |> S.empty , splitCurrCol = 0 } | available >= eWidth -> st { splitChunks = addElement newElement (splitChunks st) , splitCurrCol = splitCurrCol st + eWidth } | newEData == ESpace -> st { splitChunks = splitChunks st |> S.empty , splitCurrCol = 0 } | otherwise -> st { splitChunks = splitChunks st |> S.singleton newElement , splitCurrCol = eWidth } available = maxCols - splitCurrCol st eWidth = elementWidth newElement addElement x (viewr-> ls :> l) = ( ls |> (l |> x)) addElement _ _ = error "[unreachable]" go st _ = st renderElementSeq :: Text -> Seq Element -> Seq (Widget a) renderElementSeq curUser es = renderElement curUser <$> es renderElement :: Text -> Element -> Widget a renderElement curUser e = addStyle sty widget where sty = eStyle e dat = eData e addStyle s = case s of Normal -> id Emph -> B.withDefAttr clientEmphAttr Strong -> B.withDefAttr clientStrongAttr Code -> B.withDefAttr codeAttr Hyperlink (URL url) innerSty -> B.hyperlink url . B.withDefAttr urlAttr . addStyle innerSty rawText = B.txt . removeCursor widget = case dat of -- Cursor sentinels get parsed as individual text nodes by -- Cheapskate, meaning that we get a text node with exactly -- one character in it. That's why we compare accordingly -- above. In addition, since that character has no width, we -- have to replace it with something that has a size (such -- as a space) to get Brick's visibility logic to scroll the -- viewport to get it into view. EText t -> if t == T.singleton (cursorSentinel) then B.visible $ B.txt " " else rawText t ESpace -> B.txt " " ERawHtml t -> rawText t EEditSentinel recent -> let attr = if recent then editedRecentlyMarkingAttr else editedMarkingAttr in B.withDefAttr attr $ B.txt editMarking EUser u -> colorUsername curUser u $ userSigil <> u EChannel c -> B.withDefAttr channelNameAttr $ B.txt $ normalChannelSigil <> c EHyperlink (URL url) Nothing -> rawText url EImage (URL url) Nothing -> rawText url EEmoji em -> B.withDefAttr emojiAttr $ B.txt $ ":" <> em <> ":" -- Hyperlink and image nodes with labels should not appear -- at this point because line-wrapping should break them up -- into normal text node sequences with hyperlink styles -- attached. EHyperlink {} -> rawText "(Report renderElement bug #1)" EImage {} -> rawText "(Report renderElement bug #2)" -- Line breaks should never need to get rendered since the -- line-wrapping algorithm removes them. ESoftBreak -> B.emptyWidget ELineBreak -> B.emptyWidget textWithCursor :: Text -> Widget a textWithCursor t | T.any (== cursorSentinel) t = B.visible $ B.txt $ removeCursor t | otherwise = B.txt t removeCursor :: Text -> Text removeCursor = T.filter (/= cursorSentinel) editMarking :: Text editMarking = "(edited)" elementWidth :: Element -> Int elementWidth e = case eData e of EText t -> B.textWidth t ERawHtml t -> B.textWidth t EUser t -> T.length userSigil + B.textWidth t EChannel t -> T.length normalChannelSigil + B.textWidth t EEditSentinel _ -> B.textWidth editMarking EImage (URL url) Nothing -> B.textWidth url EImage _ (Just is) -> sum $ elementWidth <$> is EHyperlink (URL url) Nothing -> B.textWidth url EHyperlink _ (Just is) -> sum $ elementWidth <$> is EEmoji t -> B.textWidth t + 2 ESpace -> 1 ELineBreak -> 0 ESoftBreak -> 0