packages feed

nano-ui-0.1.0.0: lib/NanoUI/Frame/TextArea/Content.hs

{-# LANGUAGE BangPatterns #-}

-- | Store-backed text-area content shared by painting, scrolling and hit
-- testing: the node font, the cached document buffer and the cached content
-- extent. Free of the editor widget modules so scroll code stays light.
module NanoUI.Frame.TextArea.Content
  ( resolveTextAreaFont
  , ensureTextAreaBuffer
  , textAreaContentMetrics
  , textAreaContentGeom
  , isMouseOnTextAreaScrollBarAt
  ) where

import Data.Dynamic (fromDynamic, toDyn)
import Data.IORef (readIORef)
import qualified Data.IntMap.Strict as IM
import Data.Sequence (Seq)
import qualified Data.Sequence as Seq
import Data.Text (Text)
import NanoUI.Context (Context (..), WidgetStore (..), getStore, intKey, setStore, slotKey)
import NanoUI.Font (FontMetrics (..), lineWidthIO)
import NanoUI.Frame.TextArea.Geometry (isMouseOnTextAreaScrollBar)
import NanoUI.Layout.Arena (NodeIdx, getNodeFontSize, getRect, getWidgetId)
import NanoUI.Store
  ( Slot (..)
  )
import NanoUI.Style (FontStyle (..), FontVariant (..), FontWeight (..))
import NanoUI.Types (Rect (..), V2, onGrid)
import qualified NanoUI.Widgets.TextBuffer as TB

-- | Font the text-area content is laid out and painted in. Honors the node's
-- @layoutFontSize@ (set via 'fontSize' on the editor layout) so a single text
-- area can zoom without changing the rest of the UI. A size of 0 means the
-- base UI font.
resolveTextAreaFont :: Context -> NodeIdx -> IO FontMetrics
resolveTextAreaFont ctx idx = do
  size <- getNodeFontSize (ctxNodeArena ctx) idx
  if size <= 0
    then pure (ctxFontMetrics ctx)
    else fst <$> ctxResolveFont ctx size WeightNormal FontStyleNormal FontRegular

-- | Return the text area's 'TB.TextBuffer', building it from the flat text only
-- when the cache is cold. Rebuilding splits the whole document into lines, so
-- caching it keeps loads and paint O(1) here. The cache is written together
-- with the flat text by 'saveTextAreaState', so a present entry is always the
-- buffer for the stored text.
ensureTextAreaBuffer :: Context -> Int -> Text -> IO TB.TextBuffer
ensureTextAreaBuffer ctx key text = do
  store <- getStore ctx
  case IM.lookup (slotKey SlotTextAreaBuffer key) (storeDyn store) >>= fromDynamic of
    Just buf -> pure buf
    Nothing -> do
      let buf = TB.fromText text
      setStore ctx store {storeDyn = IM.insert (slotKey SlotTextAreaBuffer key) (toDyn buf) (storeDyn store)}
      pure buf

-- | Content extent of a text area, @(contentWidth, contentHeight)@. Measuring
-- the width scans every character of the document, so the result is cached per
-- widget and only refreshed when the text changes (the editor clears
-- 'SlotTextAreaContentFont') or the node font changes.
textAreaContentMetrics :: Context -> NodeIdx -> IO (Float, Float)
textAreaContentMetrics ctx idx = do
  wid <- getWidgetId (ctxNodeArena ctx) idx
  size <- getNodeFontSize (ctxNodeArena ctx) idx
  store <- getStore ctx
  let key = intKey wid
      cacheKeyF = slotKey SlotTextAreaContentFont key
      cacheKeyW = slotKey SlotTextAreaContentW key
      cacheKeyH = slotKey SlotTextAreaContentH key
      widthsKey = slotKey SlotTextAreaWidths key
      cachedFont = IM.findWithDefault (-1) cacheKeyF (storeFloat store)
      cachedW = IM.findWithDefault (-1) cacheKeyW (storeFloat store)
  if cachedFont == size && cachedW >= 0
    then pure (cachedW, IM.findWithDefault 0 cacheKeyH (storeFloat store))
    else do
      fm <- resolveTextAreaFont ctx idx
      gen <- readIORef (ctxMetricGen ctx)
      buf <- ensureTextAreaBuffer ctx key (IM.findWithDefault "" key (storeText store))
      let lns = TB.bufferLines buf
          lineH = onGrid (fmSnapScale fm) (fmLineHeight fm)
          contentH = fromIntegral (max 1 (Seq.length lns)) * lineH
          (seenHead, seenTail) = TB.changedLines buf
          previous = case IM.lookup widthsKey (storeDyn store) >>= fromDynamic of
            Just lw@(LineWidths font fontGen _ _ _) | font == size && fontGen == gen -> lw
            _ -> LineWidths size gen Seq.empty (-1) 0
          LineWidths _ _ measured widest widestW = previous
          -- Keep the widths of the lines no edit touched since the last
          -- measurement and measure the rest.
          keepHead = min seenHead (Seq.length measured)
          keepTail = min seenTail (Seq.length measured - keepHead)
          changed = Seq.take (Seq.length lns - keepHead - keepTail) (Seq.drop keepHead lns)
      fresh <- traverse (lineWidthIO fm) changed
      let widths = Seq.take keepHead measured <> fresh <> Seq.drop (Seq.length measured - keepTail) measured
          shift = Seq.length lns - Seq.length measured
          freshWidest = Seq.foldlWithIndex (\best i w -> if w > snd best then (keepHead + i, w) else best) (-1, 0) fresh
          -- The widest line so far still counts when it was kept; only when
          -- an edit touched it do the widths need a full pass.
          -- A changed line at least as wide as the old widest also still wins.
          (widest', contentW)
            | widest >= 0 && widest < keepHead = pick (widest, widestW) freshWidest
            | widest >= 0 && widest >= Seq.length measured - keepTail = pick (widest + shift, widestW) freshWidest
            | widest >= 0 && snd freshWidest >= widestW = freshWidest
            | otherwise = Seq.foldlWithIndex (\best i w -> if w > snd best then (i, w) else best) (-1, 0) widths
          pick a b = if snd b > snd a then b else a
      store' <- getStore ctx
      setStore
        ctx
        ( store'
            { storeFloat =
                IM.insert cacheKeyF size $
                  IM.insert cacheKeyH contentH $
                    IM.insert cacheKeyW contentW (storeFloat store')
            , storeDyn =
                IM.insert widthsKey (toDyn (LineWidths size gen widths widest' contentW)) $
                  IM.insert (slotKey SlotTextAreaBuffer key) (toDyn (TB.markLinesSeen buf)) (storeDyn store')
            }
        )
      pure (contentW, contentH)

-- | Measured widths of a text area's lines, the font size and metric
-- generation they were measured at, and the widest line with its width.
data LineWidths = LineWidths !Float !Int !(Seq Float) !Int !Float

-- | Node font, field rect and content extent @(width, height)@ of a text area.
-- Zoom changes the node font, so scroll and hit math resolve it here rather
-- than using the base font, or the scroll range would clamp short.
textAreaContentGeom :: Context -> NodeIdx -> IO (FontMetrics, Rect, Float, Float)
textAreaContentGeom ctx idx = do
  fm <- resolveTextAreaFont ctx idx
  (x, y, w, h) <- getRect (ctxNodeArena ctx) idx
  (contentW, contentH) <- textAreaContentMetrics ctx idx
  pure (fm, Rect x y w h, contentW, contentH)

-- | Whether @mouse@ is over one of the text area's shown scrollbars. Uses the
-- cached content extent: this runs on every hover through the cursor query.
isMouseOnTextAreaScrollBarAt :: Context -> NodeIdx -> V2 -> IO Bool
isMouseOnTextAreaScrollBarAt ctx idx mouse = do
  (fm, field, contentW, contentH) <- textAreaContentGeom ctx idx
  wid <- getWidgetId (ctxNodeArena ctx) idx
  store <- getStore ctx
  let (sx, sy) = IM.findWithDefault (0, 0) (slotKey SlotTextAreaScroll (intKey wid)) (storePoint store)
  pure (isMouseOnTextAreaScrollBar fm field contentW contentH sx sy mouse)