packages feed

lipstick-0.1.0.0: src/Lipstick/Style.hs

-- | Styles: text attributes plus a box model (alignment, padding, border,
-- margins), applied to text or to an existing block.
--
-- A style is a @'Mod' 'Style'@, built from the modifiers in
-- "Lipstick.Mod". The examples print with 'Lipstick.Terminal.putBlock';
-- the output is shown without colours.
--
-- == Examples
--
-- Text in a box:
--
-- >>> putBlock (render (border roundedBorder <> padding 0 1) "Hello, kitty")
-- ╭──────────────╮
-- │ Hello, kitty │
-- ╰──────────────╯
--
-- With a 'width', text is word-wrapped and aligned inside it:
--
-- >>> putBlock (render (width 18 <> border normalBorder <> align center) "Text wraps to fit the width")
-- ┌────────────────┐
-- │ Text wraps to  │
-- │ fit the width  │
-- └────────────────┘
--
-- With 'noWrap', lines that are too long are cut short instead:
--
-- >>> putBlock (render (width 14 <> border normalBorder <> noWrap) "This line is too long")
-- ┌────────────┐
-- │This line i…│
-- └────────────┘
--
-- Padding is inside the border and margins outside it; 'paddingChar' and
-- 'marginChar' make them visible:
--
-- >>> putBlock (render (border normalBorder <> padding 1 2 <> paddingChar '·' <> margin 1 3 <> marginChar '░') "box")
-- ░░░░░░░░░░░░░░░
-- ░░░┌───────┐░░░
-- ░░░│·······│░░░
-- ░░░│··box··│░░░
-- ░░░│·······│░░░
-- ░░░└───────┘░░░
-- ░░░░░░░░░░░░░░░
--
-- Some sides of the border only:
--
-- >>> putBlock (render (border thickBorder <> borderLeft False <> borderRight False) "rules above and below")
-- ━━━━━━━━━━━━━━━━━━━━━
-- rules above and below
-- ━━━━━━━━━━━━━━━━━━━━━
--
-- Later settings win, so a style is extended with '<>':
--
-- >>> let card = border roundedBorder <> padding 0 1 <> width 20
-- >>> let wide = card <> width 30
-- >>> putBlock (vcat [render card "a card", render wide "a wider card"])
-- ╭──────────────────╮
-- │ a card           │
-- ╰──────────────────╯
-- ╭────────────────────────────╮
-- │ a wider card               │
-- ╰────────────────────────────╯
--
-- 'frame' puts a block that already exists in a box:
--
-- >>> putBlock (frame (border doubleBorder <> padding 0 1) (vcat ["one", "two", "three"]))
-- ╔═══════╗
-- ║ one   ║
-- ║ two   ║
-- ║ three ║
-- ╚═══════╝
--
-- The getters read a style back:
--
-- >>> frameSize (border normalBorder <> padding 1 2 <> margin 0 1)
-- (8,4)
module Lipstick.Style
  ( Style
  , render
  , frame
  , tabWidth
  , unsetTabWidth
  , inline
  , unsetInline
  , transform
  , unsetTransform
  , breakAfter
  , colorWhitespace
  , underlineSpaces
  , strikethroughSpaces
  , inherit

    -- * Getters
    -- | The getters read what a style sets, applied to the defaults. They
    -- do not see what it inherits where it is used: inside a 'frame', the
    -- 'align', 'noWrap', 'breakAfter', 'tabWidth' and 'transform' it leaves
    -- unset come from the enclosing style.
  , getAttrs
  , getMaxWidth
  , getMaxHeight
  , getPadding
  , getMargins
  , getMarginBg
  , getBorder
  , getBorderSides
  , getWidth
  , getHeight
  , getAlign
  , getVAlign
  , getTabWidth
  , getInline
  , horizontalPadding
  , verticalPadding
  , horizontalMargins
  , verticalMargins
  , horizontalBorderSize
  , verticalBorderSize
  , horizontalFrameSize
  , verticalFrameSize
  , frameSize
  ) where

import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Lipstick.Border
import Lipstick.Color (Color)
import Lipstick.Internal.Grid
import Lipstick.Internal.Layout (Assign (..), Block (..), Measured (..), Request (..), TextStyle (..), cropLayoutWidth, measureIn, takenWidth, textBlock)
import Lipstick.Internal.Types
import Lipstick.Internal.Mod
import Lipstick.Width (textWidth)

-- $setup
-- >>> import Lipstick
-- >>> import Data.Text qualified as T

-- | What 'render' and 'frame' draw with, set by a @'Mod' 'Style'@. Its
-- text attributes apply to the content. The background and reverse video
-- also fill the padding and alignment space (the background unless
-- @'colorWhitespace' False@). The border and margins have their own
-- colours ('borderFg', 'borderBg', 'marginBg').
data Style = Style
  { styleBox :: !Box
  , styleBorder :: !BorderProps
  , styleSize :: !Size
  , styleAlign :: !Align
  , styleTabWidth :: !Int
  , styleInline :: !(Maybe Bool)
  , styleWrap :: !Bool
  , styleBreaks :: ![Char]
  , styleColorWhitespace :: !Bool
  , styleUnderlineSpaces :: !(Maybe Bool)
  , styleStrikeSpaces :: !(Maybe Bool)
  , styleTransform :: !(Maybe (Text -> Text))
  }

instance HasBox Style where
  overBox f s = s {styleBox = f (styleBox s)}

instance HasBorder Style where
  overBorder f s = s {styleBorder = f (styleBorder s)}

instance HasSize Style where
  overSize f s = s {styleSize = f (styleSize s)}

instance HasAlign Style where
  overAlign f s = s {styleAlign = f (styleAlign s)}

instance HasWrap Style where
  overWrap f s = s {styleWrap = f (styleWrap s)}

instance HasCrop Style

defStyle :: Style
defStyle = Style defBox defBorderProps defSize defAlign 4 Nothing True [] True Nothing Nothing Nothing

-- | The style and properties a modifier produces from the defaults.
resolveStyle :: Mod Style -> (Style, Props)
resolveStyle m = runMod m defStyle

-- | How many spaces a tab becomes (default 4).
tabWidth :: Int -> Mod Style
tabWidth n = fieldMod (\s -> s {styleTabWidth = max 0 n})

-- | Back to the default of 4 spaces per tab, even inside a style that
-- sets another 'tabWidth'.
unsetTabWidth :: Mod Style
unsetTabWidth = fieldMod (\s -> s {styleTabWidth = styleTabWidth defStyle})

-- | No box: padding, margins, border, size and alignment are ignored, but
-- the 'maxWidth' and 'maxHeight' crops apply. Newlines are removed from
-- the text, including the text in blocks the style frames, unless
-- something nearer sets 'unsetInline'. Only text is joined into one line:
-- blocks inside, such as a 'Lipstick.Block.vcat' or another box, keep
-- their lines.
--
-- >>> putBlock (render (inline <> border normalBorder <> padding 1 1) "one\ntwo")
-- onetwo
inline :: Mod Style
inline = fieldMod (\s -> s {styleInline = Just True})

-- | Undo 'inline': render with the box, and keep the newlines even inside
-- a style that sets 'inline'.
unsetInline :: Mod Style
unsetInline = fieldMod (\s -> s {styleInline = Just False})

-- | Change the text before rendering it (each styled piece separately),
-- including text inside blocks the style frames.
--
-- >>> putBlock (render (transform T.toUpper) "raow!")
-- RAOW!
transform :: (Text -> Text) -> Mod Style
transform f = fieldMod (\s -> s {styleTransform = Just f})

-- | No 'transform', not even one from an enclosing style.
unsetTransform :: Mod Style
unsetTransform = fieldMod (\s -> s {styleTransform = Nothing})

-- | Also allow a line break after any of these characters (such as @-@),
-- besides at spaces.
--
-- >>> putBlock (render (width 9) "a long-winded sentence")
-- a
-- long-wind
-- ed
-- sentence
-- >>> putBlock (render (breakAfter "-" <> width 9) "a long-winded sentence")
-- a long-
-- winded
-- sentence
breakAfter :: [Char] -> Mod Style
breakAfter cs = fieldMod (\s -> s {styleBreaks = cs})

-- | Whether the background colours the padding and alignment space
-- (default 'True').
colorWhitespace :: Bool -> Mod Style
colorWhitespace v = fieldMod (\s -> s {styleColorWhitespace = v})

-- | Whether spaces in the text are underlined. By default they are when
-- the text is; @underlineSpaces True@ underlines them even when the text
-- is not.
underlineSpaces :: Bool -> Mod Style
underlineSpaces v = fieldMod (\s -> s {styleUnderlineSpaces = Just v})

-- | Like 'underlineSpaces', for strikethrough.
strikethroughSpaces :: Bool -> Mod Style
strikethroughSpaces v = fieldMod (\s -> s {styleStrikeSpaces = Just v})

-- | A parent style without its padding and margin amounts, to put before
-- a child style: in @inherit parent <> child@, the child gets the
-- parent's settings except those amounts, and its own settings win, as
-- with lipgloss's @child.Inherit(parent)@.
--
-- >>> let parent = border roundedBorder <> padding 1 4 <> width 20
-- >>> putBlock (render (inherit parent <> padding 0 1 <> width 9) "child")
-- ╭───────╮
-- │ child │
-- ╰───────╯
--
-- Settings that come after a modifier always win over it, so
-- @child <> inherit parent@ would let the parent override the child.
inherit :: Mod Style -> Mod Style
inherit (Mod f g) = Mod (\s -> keepSpacing s (f s)) g
  where
    keepSpacing s s' =
      s' {styleBox = (styleBox s') {boxPadding = boxPadding (styleBox s), boxMargin = boxMargin (styleBox s)}}

-- | Text in a box: @render m inl@ is
-- @'frame' m ('Lipstick.Block.fromInline' inl)@. The text wraps to fit
-- inside the padding and border, in a box that is its 'width' wide when
-- there is room for it, and otherwise no wider than the space it is laid
-- out in. With 'noWrap', lines that are too long are cut short with an
-- ellipsis instead.
render :: Mod Style -> Inline -> Block
render m = frame m . textBlock

-- | Put a block in a box. The block is laid out in the width left inside
-- the padding and border: text in it wraps to fit, and other blocks take
-- what they need, aligned by 'align'.
--
-- The style's text settings ('align', 'noWrap', 'breakAfter', 'tabWidth',
-- 'transform', 'inline') apply to all the text inside, unless something
-- nearer sets them again, as its colours do.
frame :: Mod Style -> Block -> Block
frame m b = Block $ \ctx ->
  let (s, p) = runMod m (inheriting ctx)
      n = measureIn (textStyleOf ctx s) b
   in if styleInline s == Just True then inlineNode p n else box s p n

-- | The defaults of a style inside inherited text settings. Whether the
-- style itself is 'inline' (has no box) is not inherited: 'styleInline'
-- stays unset, and 'textStyleOf' passes the inherited removal of newlines
-- on unless the style sets it.
inheriting :: TextStyle -> Style
inheriting ctx =
  defStyle
    { styleAlign = (styleAlign defStyle) {alignH = textAlign ctx}
    , styleWrap = textWrap ctx
    , styleBreaks = textBreaks ctx
    , styleTabWidth = textTabWidth ctx
    , styleTransform = textTransform ctx
    }

-- | The text settings a style passes on to what it frames.
textStyleOf :: TextStyle -> Style -> TextStyle
textStyleOf ctx s =
  TextStyle
    { textAlign = alignH (styleAlign s)
    , textWrap = styleWrap s
    , textBreaks = styleBreaks s
    , textTabWidth = styleTabWidth s
    , textTransform = styleTransform s
    , textInline = fromMaybe (textInline ctx) (styleInline s)
    }

-- | An 'inline' style: no box, only its attributes under the content and
-- its 'maxWidth' and 'maxHeight' crop.
inlineNode :: Props -> Measured -> Measured
inlineNode p n = Measured req run
  where
    r = blockRequest n
    capW x = maybe x (min x . max 0) (propMaxWidth p)
    capH x = maybe x (min x . max 0) (propMaxHeight p)
    req = r {reqMin = capW (reqMin r), reqMax = capW (reqMax r), reqLimit = max 0 <$> propMaxWidth p, reqFill = False}
    run (Assign w h) =
      let (g, natural) = blockLayout n (Assign (cropLayoutWidth (propMaxWidth p) (reqGrow r) (reqMax r) w) h)
       in (fitWidth w (withProps p (gridCells g)), capH natural)

-- | A box around a block, laid out at the width it is given: margins,
-- border and padding take their columns and the block gets the rest (all
-- of it if it is text, and what it needs otherwise).
box :: Style -> Props -> Measured -> Measured
box s p c = Measured req run
  where
    Box (Sides pt pr pb pl) (Sides mt mr mb ml) _ _ _ = styleBox s
    Size wSet hSet weight stretch = styleSize s
    (bl, br) = borderColumns (styleBorder s)
    Edges et _ eb _ = snd (borderEdges (styleBorder s))
    frameW = pl + pr + bl + br
    frameH = pt + pb + fromEnum et + fromEnum eb
    capW x = maybe x (min x . max 0) (propMaxWidth p)
    capH x = maybe x (min x . max 0) (propMaxHeight p)
    cr = blockRequest c
    natW = maybe (reqMax cr + frameW) id wSet + ml + mr
    req =
      Request
        { reqMin = capW (maybe (reqMin cr + frameW) id wSet + ml + mr)
        , reqMax = capW natW
        , reqGrow = weight
        , reqLimit = max 0 <$> propMaxWidth p
        , reqStretch = stretch
        , reqFill = False
        }
    run (Assign w h) = (fitWidth w grid, natural)
      where
        boxW = max 0 (cropLayoutWidth (propMaxWidth p) weight natW w - ml - mr)
        innerW = max 0 (boxW - frameW)
        -- The height the box (without margins) is drawn at; the natural
        -- height must not depend on it.
        target = max (fromMaybe 0 hSet) (if stretch then h - mt - mb else 0)
        childW = if reqFill cr then innerW else takenWidth cr innerW
        (g, n) = blockLayout c (Assign childW (max 0 (target - frameH)))
        natural = capH (max (n + frameH) (fromMaybe 0 hSet) + mt + mb)
        sized = (styleSize s) {sizeWidth = Just boxW, sizeHeight = Just target}
        grid = layoutBox s {styleSize = sized} p (gridCells g)

-- | Widths of the left and right border columns.
borderColumns :: BorderProps -> (Int, Int)
borderColumns p =
  let (b, e) = borderEdges p
      w on t = if on then max 1 (textWidth t) else 0
   in (w (edgeLeft e) (leftEdge b), w (edgeRight e) (rightEdge b))

contentWidth :: Style -> Maybe Int
contentWidth s = do
  w <- sizeWidth (styleSize s)
  let Sides _ pr _ pl = boxPadding (styleBox s)
      (bl, br) = borderColumns (styleBorder s)
  pure (max 0 (w - pl - pr - bl - br))

-- | Draw a box around lines of content, at the style's width and height.
layoutBox :: Style -> Props -> [[Cell]] -> Grid
layoutBox s props content0 =
  let attrs = propAttrs props
      fill
        | styleColorWhitespace s = fillAttrs attrs
        | otherwise = (fillAttrs attrs) {attrBg = Nothing}
      Box (Sides pt pr pb pl) (Sides mt mr mb ml) mbg pch mch = styleBox s
      hgt = sizeHeight (styleSize s)
      Align ah av = styleAlign s
      bp = styleBorder s
      (chars, edges) = borderEdges bp
      (bl, br) = borderColumns bp
      bt = if edgeTop edges then 1 else 0
      bb = if edgeBottom edges then 1 else 0

      content = map (map spaceRule) (underlay attrs fill content0)
      spaceRule c
        | cellText c == T.singleton ' ' && not (cellFill c) = c {cellAttrs = spaceAttrs (cellAttrs c)}
        | otherwise = c
      spaceAttrs a =
        a
          { attrUnderline = case styleUnderlineSpaces s of
              Just True -> Just (fromMaybe SingleUnderline (attrUnderline a >>= visible))
              Just False -> Just NoUnderline
              Nothing -> attrUnderline a
          , attrStrikethrough = maybe (attrStrikethrough a) Just (styleStrikeSpaces s)
          }
      visible u = if u == NoUnderline then Nothing else Just u

      -- Horizontal alignment within the content width.
      innerW = maximum (fromMaybe 0 (contentWidth s) : map cellsWidth content)
      aligned = map (alignCells fill ah innerW) content

      -- Vertical alignment within the content height.
      innerH = maximum (length aligned : [h - pt - pb - bt - bb | Just h <- [hgt]])
      (above, below) = splitExtra av (innerH - length aligned)
      blankLine = blankCells fill innerW
      tall = replicate above blankLine ++ aligned ++ replicate below blankLine

      padW = pl + innerW + pr
      padCells = fillCells fill (T.singleton pch)
      padded =
        replicate pt (padCells padW)
          ++ [padCells pl ++ l ++ padCells pr | l <- tall]
          ++ replicate pb (padCells padW)

      sideAttrs side = mempty {attrFg = side (borderFgColors bp), attrBg = side (borderBgColors bp)}
      piece a t n = fillCells a t n
      corner a on t n = if on then piece a t n else []
      hRule a l edge r = corner a (edgeLeft edges) l bl ++ piece a edge padW ++ corner a (edgeRight edges) r br
      topRow = hRule (sideAttrs sideTop) (topLeftCorner chars) (topEdge chars) (topRightCorner chars)
      bottomRow = hRule (sideAttrs sideBottom) (bottomLeftCorner chars) (bottomEdge chars) (bottomRightCorner chars)
      sideL = if edgeLeft edges then piece (sideAttrs sideLeft) (leftEdge chars) bl else []
      sideR = if edgeRight edges then piece (sideAttrs sideRight) (rightEdge chars) br else []
      bordered =
        blendBorder (borderFgStops bp) (borderFgOffset bp) edges (length sideL) (length sideR) $
          [topRow | edgeTop edges]
            ++ [sideL ++ l ++ sideR | l <- padded]
            ++ [bottomRow | edgeBottom edges]

      ma = mempty {attrBg = mbg}
      boxW = bl + padW + br
      outerW = ml + boxW + mr
      marginCells = fillCells ma (T.singleton mch)
      margined =
        replicate mt (marginCells outerW)
          ++ [marginCells ml ++ l ++ marginCells mr | l <- bordered]
          ++ replicate mb (marginCells outerW)

   in cropToProps props margined

-- Getters ---------------------------------------------------------------------

-- | The text attributes a modifier sets (on any thing).
getAttrs :: Mod f -> Attrs
getAttrs = propAttrs . propsOf

-- | The 'maxWidth' a modifier sets (on any thing).
getMaxWidth :: Mod f -> Maybe Int
getMaxWidth = propMaxWidth . propsOf

-- | The 'maxHeight' a modifier sets (on any thing).
getMaxHeight :: Mod f -> Maybe Int
getMaxHeight = propMaxHeight . propsOf

field :: (Style -> a) -> Mod Style -> a
field f = f . fst . resolveStyle

-- | The padding on each side.
getPadding :: Mod Style -> Sides Int
getPadding = field (boxPadding . styleBox)

-- | The margins on each side.
getMargins :: Mod Style -> Sides Int
getMargins = field (boxMargin . styleBox)

-- | The 'marginBg', if one is set.
getMarginBg :: Mod Style -> Maybe Color
getMarginBg = field (boxMarginBg . styleBox)

-- | The border characters, if a border is drawn.
getBorder :: Mod Style -> Maybe Border
getBorder m =
  let bp = field styleBorder m
      (chars, Edges t r b l) = borderEdges bp
   in if or [t, r, b, l] then Just chars else Nothing

-- | Which sides of the border are drawn.
getBorderSides :: Mod Style -> Sides Bool
getBorderSides m = let Edges t r b l = snd (borderEdges (field styleBorder m)) in Sides t r b l

-- | The 'width', if one is set.
getWidth :: Mod Style -> Maybe Int
getWidth = field (sizeWidth . styleSize)

-- | The 'height', if one is set.
getHeight :: Mod Style -> Maybe Int
getHeight = field (sizeHeight . styleSize)

-- | The horizontal alignment ('left' by default).
getAlign :: Mod Style -> Position
getAlign = field (alignH . styleAlign)

-- | The vertical alignment ('top' by default).
getVAlign :: Mod Style -> Position
getVAlign = field (alignV . styleAlign)

-- | How many spaces a tab becomes.
getTabWidth :: Mod Style -> Int
getTabWidth = field styleTabWidth

-- | Whether the style is 'inline'.
getInline :: Mod Style -> Bool
getInline = field ((== Just True) . styleInline)

-- | Columns of padding: left plus right.
horizontalPadding :: Mod Style -> Int
horizontalPadding m = let Sides _ r _ l = getPadding m in l + r

-- | Rows of padding: top plus bottom.
verticalPadding :: Mod Style -> Int
verticalPadding m = let Sides t _ b _ = getPadding m in t + b

-- | Columns of margins: left plus right.
horizontalMargins :: Mod Style -> Int
horizontalMargins m = let Sides _ r _ l = getMargins m in l + r

-- | Rows of margins: top plus bottom.
verticalMargins :: Mod Style -> Int
verticalMargins m = let Sides t _ b _ = getMargins m in t + b

-- | Columns taken by the left and right sides of the border, where they
-- are drawn.
horizontalBorderSize :: Mod Style -> Int
horizontalBorderSize m = let (l, r) = borderColumns (field styleBorder m) in l + r

-- | Rows taken by the top and bottom sides of the border, where they are
-- drawn.
verticalBorderSize :: Mod Style -> Int
verticalBorderSize m = let Sides t _ b _ = getBorderSides m in fromEnum t + fromEnum b

-- | Columns taken by padding, border and margins: what is left for content
-- is the available width minus this.
horizontalFrameSize :: Mod Style -> Int
horizontalFrameSize m = horizontalPadding m + horizontalBorderSize m + horizontalMargins m

-- | Rows taken by padding, border and margins.
verticalFrameSize :: Mod Style -> Int
verticalFrameSize m = verticalPadding m + verticalBorderSize m + verticalMargins m

-- | @(horizontalFrameSize, verticalFrameSize)@
frameSize :: Mod Style -> (Int, Int)
frameSize m = (horizontalFrameSize m, verticalFrameSize m)