monomer-1.3.0.0: src/Monomer/Graphics/Text.hs
{-|
Module : Monomer.Graphics.Text
Copyright : (c) 2018 Francisco Vallarino
License : BSD-3-Clause (see the LICENSE file)
Maintainer : fjvallarino@gmail.com
Stability : experimental
Portability : non-portable
Helper functions for calculating text size.
-}
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE Strict #-}
module Monomer.Graphics.Text (
calcTextSize,
calcTextSize_,
fitTextToSize,
fitTextToWidth,
alignTextLines,
moveTextLines,
getTextLinesSize,
getGlyphsMin,
getGlyphsMax
) where
import Control.Lens ((&), (^.), (^?), (+~), ix, non)
import Data.Default
import Data.List (foldl')
import Data.Maybe
import Data.Sequence (Seq(..), (<|), (|>))
import Data.Text (Text)
import qualified Data.Sequence as Seq
import qualified Data.Text as T
import Monomer.Common
import Monomer.Core.StyleTypes
import Monomer.Core.StyleUtil
import Monomer.Graphics.Types
import Monomer.Helper
import qualified Monomer.Common.Lens as L
import qualified Monomer.Graphics.Lens as L
type GlyphGroup = Seq GlyphPos
-- | Returns the size a given text an style will take.
calcTextSize
:: FontManager -- ^ The font manager.
-> StyleState -- ^ The style.
-> Text -- ^ The text to calculate.
-> Size -- ^ The calculated size.
calcTextSize fontMgr style !text = size where
size = calcTextSize_ fontMgr style SingleLine KeepSpaces Nothing Nothing text
-- | Returns the size a given text an style will take.
calcTextSize_
:: FontManager -- ^ The font manager.
-> StyleState -- ^ The style.
-> TextMode -- ^ Single or multiline.
-> TextTrim -- ^ Whether to trim or keep spaces.
-> Maybe Double -- ^ Optional max width (needed for multiline).
-> Maybe Int -- ^ Optional max lines.
-> Text -- ^ The text to calculate.
-> Size -- ^ The calculated size.
calcTextSize_ fontMgr style mode trim mwidth mlines text = newSize where
font = styleFont style
fontSize = styleFontSize style
!metrics = computeTextMetrics fontMgr font fontSize
width = fromMaybe maxNumericValue mwidth
textLinesW = fitTextToWidth fontMgr style width trim text
textLines
| mode == SingleLine = Seq.take 1 textLinesW
| isJust mlines = Seq.take (fromJust mlines) textLinesW
| otherwise = textLinesW
newSize
| not (Seq.null textLines) = getTextLinesSize textLines
| otherwise = Size 0 (_txmLineH metrics)
{-|
Fits the given text to a determined size, splitting on multiple lines as needed.
Since the function returns glyphs that may be partially visible, the text can
overflow vertically or horizontally and a scissor is needed. The rectangles are
returned with zero offset (i.e., x = 0 and first line y = 0), and a translation
transform is needed when rendering.
-}
fitTextToSize
:: FontManager -- ^ The font manager.
-> StyleState -- ^ The style.
-> TextOverflow -- ^ Whether to clip or use ellipsis.
-> TextMode -- ^ Single or multiline.
-> TextTrim -- ^ Whether to trim or keep spaces.
-> Maybe Int -- ^ Optional max lines.
-> Size -- ^ The bounding size.
-> Text -- ^ The text to fit.
-> Seq TextLine -- ^ The fitted text lines.
fitTextToSize fontMgr style ovf mode trim mlines !size !text = newLines where
Size cw ch = size
font = styleFont style
fontSize = styleFontSize style
textMetrics = computeTextMetrics fontMgr font fontSize
fitW
| mode == MultiLine = cw
| otherwise = maxNumericValue
maxH = case mlines of
Just maxLines -> min ch (fromIntegral maxLines * textMetrics ^. L.lineH)
_ -> ch
textLinesW = fitTextToWidth fontMgr style fitW trim text
firstLine = Seq.take 1 textLinesW
isMultiline = mode == MultiLine
ellipsisReq = ovf == Ellipsis && getTextLinesSize firstLine ^. L.w > cw
newLines
| isMultiline = fitLinesToH fontMgr style ovf cw maxH textLinesW
| ellipsisReq = addEllipsisToTextLine fontMgr style cw <$> firstLine
| otherwise = clipTextLine fontMgr style trim cw <$> firstLine
-- | Fits a single line of text to the given width, potencially spliting into
-- several lines.
fitTextToWidth
:: FontManager -- ^ The fontManager.
-> StyleState -- ^ The style.
-> Double -- ^ The maximum width.
-> TextTrim -- ^ Whether to trim or keep spaces.
-> Text -- ^ The text to calculate.
-> Seq TextLine -- ^ The fitted text lines.
fitTextToWidth fontMgr style width trim text = resultLines where
font = styleFont style
fSize = styleFontSize style
fSpcH = styleFontSpaceH style
fSpcV = styleFontSpaceV style
lineH = _txmLineH metrics
!metrics = computeTextMetrics fontMgr font fSize
fitToWidth = fitLineToW fontMgr font fSize fSpcH fSpcV metrics
helper acc line = (cLines <> newLines, newTop) where
(cLines, cTop) = acc
newLines = fitToWidth cTop width trim line
vspc = unFontSpace fSpcV
newTop = cTop + fromIntegral (Seq.length newLines) * (lineH + vspc)
(resultLines, _) = foldl' helper (Empty, 0) (T.lines text)
-- | Aligns a Seq of TextLines to the given rect.
alignTextLines
:: StyleState -- ^ The style.
-> Rect -- ^ The bounding rect. Text may overflow.
-> Seq TextLine -- ^ The TextLines to align.
-> Seq TextLine -- ^ The aligned TextLines.
alignTextLines style parentRect textLines = newTextLines where
Rect _ py _ ph = parentRect
Size _ th = getTextLinesSize textLines
TextMetrics asc _ lineH lowerX = (textLines ^? ix 0) ^. non def . L.metrics
isSingle = length textLines == 1
alignH = styleTextAlignH style
alignV = styleTextAlignV style
alignOffsetY = case alignV of
ATTop -> 0
ATAscender
| isSingle -> (ph - asc) / 2
ATLowerX
| isSingle -> (ph - lowerX) / 2 - (asc - lowerX)
ATBottom -> ph - th
ATBaseline -> ph - th
_ -> (ph - th) / 2 -- ATMiddle
offsetY = py + alignOffsetY
newTextLines = fmap (alignTextLine parentRect offsetY alignH) textLines
-- | Moves a Seq of TextLines by the given offset.
moveTextLines
:: Point -- ^ The offset.
-> Seq TextLine -- ^ The TextLines.
-> Seq TextLine -- ^ The displaced TextLines.
moveTextLines (Point offsetX offsetY) textLines = newTextLines where
moveTextLine tl = tl
& L.rect . L.x +~ offsetX
& L.rect . L.y +~ offsetY
newTextLines = fmap moveTextLine textLines
-- | Returns the combined size of a sequence of text lines.
getTextLinesSize :: Seq TextLine -> Size
getTextLinesSize textLines = size where
-- Excludes last line vertical spacing
spaceV = unFontSpace $ maybe def _tlFontSpaceV (textLines ^? ix 0)
lineW line = line ^. L.size . L.w
lineH line = line ^. L.size . L.h + unFontSpace (_tlFontSpaceV line)
~width = maximum (fmap lineW textLines)
height = sum (fmap lineH textLines) - spaceV
size
| Seq.null textLines = def
| otherwise = Size width height
-- | Gets the minimum x a Seq of Glyphs will use.
getGlyphsMin :: Seq GlyphPos -> Double
getGlyphsMin Empty = 0
getGlyphsMin (g :<| gs) = _glpXMin g
-- | Gets the maximum x a Seq of Glyphs will use.
getGlyphsMax :: Seq GlyphPos -> Double
getGlyphsMax Empty = 0
getGlyphsMax (gs :|> g) = _glpXMax g
-- Helpers
alignTextLine :: Rect -> Double -> AlignTH -> TextLine -> TextLine
alignTextLine parentRect offsetY alignH textLine = newTextLine where
Rect px _ pw _ = parentRect
Rect tx ty tw th = _tlRect textLine
alignOffsetX = case alignH of
ATLeft -> 0
ATCenter -> (pw - tw) / 2
ATRight -> pw - tw
offsetX = px + alignOffsetX
newTextLine = textLine {
_tlRect = Rect (tx + offsetX) (ty + offsetY) tw th
}
fitLinesToH
:: FontManager
-> StyleState
-> TextOverflow
-> Double
-> Double
-> Seq TextLine
-> Seq TextLine
fitLinesToH fontMgr style overflow w h Empty = Empty
fitLinesToH fontMgr style overflow w h (g1 :<| g2 :<| gs)
| overflow == Ellipsis && h >= g1H + g2H = g1 :<| rest
| overflow == Ellipsis && h >= g1H = Seq.singleton ellipsisG1
| overflow == ClipText && h >= g1H = g1 :<| rest
where
g1H = _sH (_tlSize g1)
g2H = _sH (_tlSize g2)
newH = h - g1H
rest = fitLinesToH fontMgr style overflow w newH (g2 :<| gs)
ellipsisG1 = addEllipsisToTextLine fontMgr style w g1
fitLinesToH fontMgr style overflow w h (g :<| gs)
| h > 0 = Seq.singleton newG
| otherwise = Empty
where
gW = _sW (_tlSize g)
newG
| overflow == Ellipsis && w < gW = addEllipsisToTextLine fontMgr style w g
| otherwise = g
fitLineToW
:: FontManager
-> Font
-> FontSize
-> FontSpace
-> FontSpace
-> TextMetrics
-> Double
-> Double
-> TextTrim
-> Text
-> Seq TextLine
fitLineToW fontMgr font fSize fSpcH fSpcV metrics top width trim text = res where
spaces = T.replicate 4 " "
newText = T.replace "\t" spaces text
!glyphs = computeGlyphsPos fontMgr font fSize fSpcH newText
-- Do not break line on trailing spaces, they are removed in the next step
-- In the case of KeepSpaces, lines with only spaces (empty looking) are valid
keepTailSpaces = trim == TrimSpaces
groups = fitGroups (splitGroups glyphs) width keepTailSpaces
resetGroups
| trim == TrimSpaces = fmap (resetGlyphs . trimGlyphs) groups
| otherwise = fmap resetGlyphs groups
buildLine = buildTextLine font fSize fSpcH fSpcV metrics top
res
| text /= "" = Seq.mapWithIndex buildLine resetGroups
| otherwise = Seq.singleton (buildLine 0 Empty)
buildTextLine
:: Font
-> FontSize
-> FontSpace
-> FontSpace
-> TextMetrics
-> Double
-> Int
-> Seq GlyphPos
-> TextLine
buildTextLine font fSize fSpcH fSpcV metrics top idx glyphs = textLine where
lineH = _txmLineH metrics
x = 0
y = top + fromIntegral idx * (lineH + unFontSpace fSpcV)
width = getGlyphsWidth glyphs
height = lineH
text = T.pack . reverse $ foldl' (\ac g -> _glpGlyph g : ac) [] glyphs
textLine = TextLine {
_tlFont = font,
_tlFontSize = fSize,
_tlFontSpaceH = fSpcH,
_tlFontSpaceV = fSpcV,
_tlMetrics = metrics,
_tlText = text,
_tlSize = Size width height,
_tlRect = Rect x y width height,
_tlGlyphs = glyphs
}
addEllipsisToTextLine
:: FontManager
-> StyleState
-> Double
-> TextLine
-> TextLine
addEllipsisToTextLine fontMgr style width textLine = newTextLine where
TextLine{..} = textLine
Size tw th = _tlSize
Size dw dh = calcTextSize fontMgr style "..."
font = styleFont style
fontSize = styleFontSize style
fontSpcH = styleFontSpaceH style
targetW = width - tw
dropHelper (idx, w) g
| isSafeLE (_glpW g + w) dw = (idx + 1, _glpW g + w)
| otherwise = (idx, w)
(dropChars, _) = foldl' dropHelper (0, targetW) (Seq.reverse _tlGlyphs)
newText = T.dropEnd dropChars _tlText <> "..."
!newGlyphs = computeGlyphsPos fontMgr font fontSize fontSpcH newText
newW = getGlyphsWidth newGlyphs
newTextLine = textLine {
_tlText = newText,
_tlSize = _tlSize { _sW = newW },
_tlRect = _tlRect { _rW = newW },
_tlGlyphs = newGlyphs
}
clipTextLine
:: FontManager
-> StyleState
-> TextTrim
-> Double
-> TextLine
-> TextLine
clipTextLine fontMgr style trim width textLine = newTextLine where
TextLine{..} = textLine
Size tw th = _tlSize
font = styleFont style
fontSize = styleFontSize style
fontSpcH = styleFontSpaceH style
takeHelper (idx, w) g
| isSafeLE (_glpW g + w) width = (idx + 1, _glpW g + w)
| otherwise = (idx, w)
(takeChars, _) = foldl' takeHelper (0, 0) _tlGlyphs
validGlyphs = Seq.takeWhileL (\g -> isSafeLE (_glpXMax g) width) _tlGlyphs
newText
| trim == KeepSpaces = T.take (length validGlyphs) _tlText
| otherwise = T.dropWhileEnd (== ' ') $ T.take (length validGlyphs) _tlText
!newGlyphs = computeGlyphsPos fontMgr font fontSize fontSpcH newText
newW = getGlyphsWidth newGlyphs
newTextLine = textLine {
_tlText = newText,
_tlSize = _tlSize { _sW = newW },
_tlRect = _tlRect { _rW = newW },
_tlGlyphs = newGlyphs
}
fitGroups :: Seq GlyphGroup -> Double -> Bool -> Seq GlyphGroup
fitGroups Empty _ _ = Empty
fitGroups (g :<| gs) !width !keepTailSpaces = currentLine <| extraLines where
gW = getGlyphsWidth g
gMax = getGlyphsMax g
extraGroups = fitExtraGroups gs (width - gW) gMax keepTailSpaces
(lineGroups, remainingGroups) = extraGroups
currentLine = g <> lineGroups
extraLines = fitGroups remainingGroups width keepTailSpaces
fitExtraGroups
:: Seq GlyphGroup
-> Double
-> Double
-> Bool
-> (Seq GlyphPos, Seq GlyphGroup)
fitExtraGroups Empty _ _ _ = (Empty, Empty)
fitExtraGroups (g :<| gs) !width !prevGMax !keepTailSpaces
| isSafeLE (gW + wDiff) width || keepSpace = (g <> newFit, newRest)
| otherwise = (Empty, g :<| gs)
where
gW = getGlyphsWidth g
gMin = getGlyphsMin g
gMax = getGlyphsMax g
wDiff = gMin - prevGMax
remWidth = width - (gW + wDiff)
keepSpace = keepTailSpaces && isSpaceGroup g
(newFit, newRest) = fitExtraGroups gs remWidth gMax keepTailSpaces
getGlyphsWidth :: Seq GlyphPos -> Double
getGlyphsWidth glyphs = getGlyphsMax glyphs - getGlyphsMin glyphs
isSpaceGroup :: Seq GlyphPos -> Bool
isSpaceGroup Empty = False
isSpaceGroup (g :<| gs) = isSpace (_glpGlyph g)
splitGroups :: Seq GlyphPos -> Seq GlyphGroup
splitGroups Empty = Empty
splitGroups glyphs = group <| splitGroups rest where
g :<| gs = glyphs
groupWordFn = not . isWordDelimiter . _glpGlyph
(group, rest)
| isWordDelimiter (_glpGlyph g) = (Seq.singleton g, gs)
| otherwise = Seq.spanl groupWordFn glyphs
resetGlyphs :: Seq GlyphPos -> Seq GlyphPos
resetGlyphs Empty = Empty
resetGlyphs gs@(g :<| _) = resetGlyphsPos gs (_glpXMin g)
resetGlyphsPos :: Seq GlyphPos -> Double -> Seq GlyphPos
resetGlyphsPos Empty _ = Empty
resetGlyphsPos (g :<| gs) offset = newG <| resetGlyphsPos gs offset where
newG = g {
_glpXMin = _glpXMin g - offset,
_glpXMax = _glpXMax g - offset
}
trimGlyphs :: Seq GlyphPos -> Seq GlyphPos
trimGlyphs glyphs = newGlyphs where
isSpaceGlyph g = _glpGlyph g == ' '
newGlyphs = Seq.dropWhileL isSpaceGlyph $ Seq.dropWhileR isSpaceGlyph glyphs
isWordDelimiter :: Char -> Bool
isWordDelimiter = (== ' ')
isSpace :: Char -> Bool
isSpace = (== ' ')
isSafeLE :: Double -> Double -> Bool
isSafeLE width target = width <= target || abs (target - width) < 0.001