packages feed

nano-ui-0.1.0.0: lib/NanoUI/Widgets/RichText.hs

-- | Paragraphs of mixed-style text and links.
module NanoUI.Widgets.RichText
  ( Inline
  , inlineText
  , inlineWith
  , restyle
  , strong
  , emphasis
  , inlineCode
  , hyperlink
  , richText
  , richText'
  , richTextWith
  , richTextWith'
  ) where

import Control.Monad (unless)
import Data.Hashable (hashWithSalt)
import Data.IORef (IORef, modifyIORef', newIORef, readIORef)
import Data.IntMap.Strict qualified as IM
import Data.List (dropWhileEnd, groupBy)
import Data.Maybe (fromMaybe, isJust)
import Data.Primitive.SmallArray (SmallArray, indexSmallArray, smallArrayFromList)
import Data.String (IsString (..))
import Data.Text (Text)
import Data.Text qualified as T
import Effectful (Eff, type (:>))
import NanoUI.Context
  ( Context (..)
  , askHostIO
  , intKey
  , setHost
  , registerCustomCursor
  , registerCustomDrawing
  , registerCustomMeasure
  )
import NanoUI.Draw (DrawOp (..), TextFont (..))
import NanoUI.Font (FontMetrics (..), lineWidthIO)
import NanoUI.Frame.Node (resolveTextFont)
import NanoUI.Input (Input (..), UiCursorKind (..))
import NanoUI.Layout.Arena (NodeType (NodeDrawing))
import NanoUI.Monad (Ui, askContext, askDefaultLayout, askInput, nextId, uiIO, uiTheme)
import NanoUI.Style
  ( FontVariant (..)
  , Layout (..)
  , TextDecoration (..)
  , Theme (..)
  , fontBold
  , fontItalic
  , fontMono
  , styleFg
  )
import NanoUI.Types (Color (..), Rect (..), V2 (..))
import NanoUI.Widgets.Node (Response, addWidget, respClicked, respHovered, respRect)

-- | A piece of a paragraph: text in one style, and the hyperlink it follows when
-- it is one. A string literal is 'plain' text.
data Inline = Inline !Text (Layout -> Layout) !(Maybe Text)

instance IsString Inline where
  fromString = inlineText . T.pack

-- | Text in the paragraph's own style.
inlineText :: Text -> Inline
inlineText txt = Inline txt id Nothing

-- | Text styled by font modifiers (@fontBold@, @fontSize 20@,
-- @fontColor red . fontUnderline@), applied over the paragraph's layout.
inlineWith :: (Layout -> Layout) -> Text -> Inline
inlineWith f txt = Inline txt f Nothing

-- | Add font modifiers to a piece, a hyperlink included.
restyle :: (Layout -> Layout) -> Inline -> Inline
restyle f (Inline txt style target) = Inline txt (f . style) target

-- | Bold text.
strong :: Text -> Inline
strong = inlineWith fontBold

-- | Italic text.
emphasis :: Text -> Inline
emphasis = inlineWith fontItalic

-- | Monospaced text.
inlineCode :: Text -> Inline
inlineCode = inlineWith fontMono

-- | @hyperlink target label@: text in the theme's link colour, underlined while
-- hovered, whose click the paragraph reports as @target@.
hyperlink :: Text -> Text -> Inline
hyperlink target label = Inline label id (Just target)

-- | A paragraph of pieces, wrapped at its width. Returns the target of the
-- hyperlink clicked this frame.
richText :: Ui :> es => [Inline] -> Eff es (Maybe Text)
richText = richTextWith id

-- | 'richText' with a layout modifier, whose font choices are the default
-- for every piece.
richTextWith :: Ui :> es => (Layout -> Layout) -> [Inline] -> Eff es (Maybe Text)
richTextWith f pieces = snd <$> richTextWith' f pieces

richText' :: Ui :> es => [Inline] -> Eff es (Response, Maybe Text)
richText' = richTextWith' id

-- A resolved piece: its font, colour, line metrics and hyperlink.
data Run = Run
  { runFont :: !TextFont
  , runColor :: !Color
  , runLineHeight :: !Float
  , runAscent :: !Float
  , runTarget :: !(Maybe Text)
  }

data TokenKind = Word | Space | Break
  deriving (Eq)

-- A word, a run of spaces or a line break, with its width in its piece's font.
data Token = Token
  { _tokenText :: !Text
  , tokenRun :: !Int
  , tokenKind :: !TokenKind
  , tokenWidth :: !Float
  }

-- A laid-out line: its top, height and baseline offset, and its tokens with
-- their x positions.
data Line = Line
  { lineTop :: !Float
  , lineHeight :: !Float
  , lineAscent :: !Float
  , lineWidth :: !Float
  , lineTokens :: ![(Float, Token)]
  }

-- A paragraph's measured pieces and its lines at the width it last had,
-- kept between frames while its pieces, fonts and colours stay the same.
data Paragraph = Paragraph
  { paraKey :: !Int
  , paraRuns :: !(SmallArray Run)
  , paraTokens :: ![Token]
  , paraEmptyLine :: !(Float, Float)
  , paraNatural :: (Float, Float)
  , paraWidth :: !Float
  , paraLines :: [Line]
  }

newtype Paragraphs = Paragraphs (IORef (IM.IntMap Paragraph))

richTextWith' :: Ui :> es => (Layout -> Layout) -> [Inline] -> Eff es (Response, Maybe Text)
richTextWith' f pieces = do
  ctx <- askContext
  inp <- askInput
  base <- f <$> askDefaultLayout
  theme <- uiTheme
  wid <- nextId
  let styled = [(txt, pieceFont l, pieceColor theme l target, target) | Inline txt style target <- pieces, let l = style base]
  cacheRef <-
    uiIO $
      askHostIO ctx >>= \case
        Just (Paragraphs ref) -> pure ref
        Nothing -> do
          ref <- newIORef IM.empty
          setHost ctx (Paragraphs ref)
          pure ref
  gen <- uiIO (readIORef (ctxMetricGen ctx))
  let key =
        foldl'
          ( \h (txt, TextFont size variant weight fstyle deco, Color rgba, target) ->
              h `hashWithSalt` txt `hashWithSalt` size `hashWithSalt` fromEnum variant
                `hashWithSalt` fromEnum weight `hashWithSalt` fromEnum fstyle `hashWithSalt` fromEnum deco
                `hashWithSalt` rgba `hashWithSalt` target
          )
          gen
          styled
  cached <- uiIO (IM.lookup (intKey wid) <$> readIORef cacheRef)
  para0 <- case cached of
    Just para | paraKey para == key -> pure para
    _ -> uiIO $ do
      resolved <- mapM (measurePiece ctx) (zip [0 ..] styled)
      let runs = smallArrayFromList (map fst resolved)
          tokens = concatMap snd resolved
          emptyLine = case resolved of
            (run, _) : _ -> (runLineHeight run, runAscent run)
            [] -> (fmLineHeight (ctxFontMetrics ctx), fmAscent (ctxFontMetrics ctx))
      pure (Paragraph key runs tokens emptyLine (lineBoxes (layoutLines runs emptyLine 1e9 tokens)) (-1) [])
  resp <- addWidget wid NodeDrawing T.empty 0 base
  let Rect rx ry rw _ = respRect resp
      runs = paraRuns para0
      layoutAt width = layoutLines runs (paraEmptyLine para0) width (paraTokens para0)
      para
        | paraWidth para0 == rw = para0
        | otherwise = para0 {paraWidth = rw, paraLines = layoutAt rw}
      linesAt width
        | width == paraWidth para = paraLines para
        | otherwise = layoutAt width
      V2 mx my = inputMousePos inp
      hoveredRun
        | not (respHovered resp) = Nothing
        | otherwise =
            case [ tokenRun tok
                 | line <- paraLines para
                 , my >= ry + lineTop line && my < ry + lineTop line + lineHeight line
                 , (x, tok) <- lineTokens line
                 , tokenKind tok /= Break
                 , mx >= rx + x && mx < rx + x + tokenWidth tok
                 , isJust (runTarget (indexSmallArray runs (tokenRun tok)))
                 ] of
              run : _ -> Just run
              [] -> Nothing
      -- Words are drawn one by one, so a decoration is drawn once across a
      -- piece's words on a line and the spaces between them.
      draw _cdc (Rect x0 y0 w _) =
        smallArrayFromList $
          concat
            [ [ DrawTextStyled (x0 + x) (lineY line run) ((runFont run) {textFontDecoration = DecorationNone}) txt (runColor run)
              | (x, Token txt runIdx Word _) <- lineTokens line
              , let run = indexSmallArray runs runIdx
              ]
                ++ concat
                  [ [FillRect (Rect (x0 + x1) (y + offset) (x2 - x1) thick) (runColor run) | offset <- decorationOffsets deco run]
                  | group <- groupBy (\(_, a) (_, b) -> tokenRun a == tokenRun b) (lineTokens line)
                  , let trimmed = dropWhileEnd isSpaceToken (dropWhile isSpaceToken group)
                  , (x1, first) : _ <- [trimmed]
                  , let runIdx = tokenRun first
                        run = indexSmallArray runs runIdx
                        deco = decorationOf runIdx
                        (lastX, lastTok) = last trimmed
                        x2 = lastX + tokenWidth lastTok
                        y = lineY line run
                        thick = max 1 (0.06 * runLineHeight run)
                  , deco /= DecorationNone
                  ]
            | line <- linesAt w
            ]
        where
          lineY line run = y0 + lineTop line + lineAscent line - runAscent run
          isSpaceToken (_, tok) = tokenKind tok == Space
      decorationOf runIdx
        | Just runIdx == hoveredRun = underlined (textFontDecoration (runFont (indexSmallArray runs runIdx)))
        | otherwise = textFontDecoration (runFont (indexSmallArray runs runIdx))
      -- Where underline and strikethrough sit below a line box's top, as
      -- styled labels draw them.
      decorationOffsets deco run =
        let lh = runLineHeight run
            under = runAscent run + max 1 (0.1 * lh)
            strike = runAscent run * 0.65
         in case deco of
              DecorationUnderline -> [under]
              DecorationStrikethrough -> [strike]
              DecorationUnderlineStrike -> [under, strike]
              DecorationNone -> []
      drawKey = key `hashWithSalt` fromMaybe (-1) hoveredRun
  uiIO $ do
    unless (paraWidth para0 == rw && fmap paraKey cached == Just key) $
      modifyIORef' cacheRef $ \m ->
        -- Paragraphs no longer drawn are dropped all at once past a bound.
        IM.insert (intKey wid) para (if IM.size m > 4096 then IM.empty else m)
    registerCustomMeasure ctx wid $ \_ (availW, _) ->
      if availW >= 1e9 then paraNatural para else lineBoxes (linesAt availW)
    registerCustomDrawing ctx wid (if drawKey == 0 then 1 else drawKey) draw
    registerCustomCursor ctx wid (const (if isJust hoveredRun then UiCursorPointer else UiCursorDefault))
  let clicked
        | respClicked resp = hoveredRun >>= runTarget . indexSmallArray runs
        | otherwise = Nothing
  pure (resp, clicked)
  where
    underlined DecorationStrikethrough = DecorationUnderlineStrike
    underlined DecorationNone = DecorationUnderline
    underlined deco = deco
    lineBoxes lines' = (maximum (0 : map lineWidth lines'), sum (map lineHeight lines'))

-- | The font a piece's layout chooses.
pieceFont :: Layout -> TextFont
pieceFont l = TextFont (layoutFontSize l) (layoutFontVariant l) (layoutFontWeight l) (layoutFontStyle l) (layoutTextDecoration l)

-- | A piece's colour: its own, else the link colour for a link, else its
-- font variant's colour.
pieceColor :: Theme -> Layout -> Maybe Text -> Color
pieceColor theme l target =
  let variantColor = case layoutFontVariant l of
        FontHeading -> themeAccent theme
        FontMuted -> themeMuted theme
        FontDanger -> themeRed theme
        _ -> styleFg (themePanel theme)
   in fromMaybe (maybe variantColor (const (themeLink theme)) target) (layoutFontColor l)

-- | A piece's line metrics and its tokens measured in its font.
measurePiece :: Context -> (Int, (Text, TextFont, Color, Maybe Text)) -> IO (Run, [Token])
measurePiece ctx (i, (txt, font, color, target)) = do
  (fm, _) <- resolveTextFont ctx font
  tokens <- mapM (measure fm) (T.groupBy (\a b -> kindOf a == kindOf b && kindOf a /= Break) txt)
  pure (Run font color (fmLineHeight fm) (fmAscent fm) target, tokens)
  where
    kindOf c
      | c == '\n' = Break
      | c == ' ' || c == '\t' = Space
      | otherwise = Word
    measure fm part = do
      let kind = kindOf (T.head part)
      w <- if kind == Break then pure 0 else lineWidthIO fm part
      pure (Token part i kind w)

-- | Greedy lines at @width@: a break goes between words only at spaces or
-- line breaks, spaces at a wrap are dropped, and a word wider than the line
-- takes a line of its own.
layoutLines :: SmallArray Run -> (Float, Float) -> Float -> [Token] -> [Line]
layoutLines runs (emptyH, emptyAscent) width = go 0 [] 0 [] True
  where
    -- @placed@ holds the line's tokens in reverse, @pending@ the spaces since
    -- its last word; @fresh@ whether the line starts after a wrap.
    go top placed x pending fresh toks = case toks of
      [] -> [finish top placed x]
      tok : rest -> case tokenKind tok of
        Break -> let line = finish top placed x in line : go (top + lineHeight line) [] 0 [] False rest
        Space -> go top placed x (tok : pending) fresh rest
        Word ->
          let (word, rest') = span (\t -> tokenKind t == Word) toks
              wordW = sum (map tokenWidth word)
              spaceW = if null placed && fresh then 0 else sum (map tokenWidth pending)
           in if not (null placed) && x + spaceW + wordW > width
                then
                  let line = finish top placed x
                   in line : go (top + lineHeight line) [] 0 [] True toks
                else
                  let (placed', x') = foldl' place (placed, x) (if null placed && fresh then [] else reverse pending)
                      (placed'', x'') = foldl' place (placed', x') word
                   in go top placed'' x'' [] False rest'
    place (acc, x) tok = ((x, tok) : acc, x + tokenWidth tok)
    finish top placed x =
      let toks = reverse placed
          metrics = [indexSmallArray runs (tokenRun tok) | (_, tok) <- toks]
          (h, ascent) = case metrics of
            [] -> (emptyH, emptyAscent)
            _ ->
              let ascent' = maximum (map runAscent metrics)
                  descent = maximum [runLineHeight r - runAscent r | r <- metrics]
               in (ascent' + descent, ascent')
       in Line top h ascent x toks