packages feed

lipstick-0.1.0.0: src/Lipstick/Block.hs

-- | Blocks of styled text and how to combine them.
--
-- A 'Block' does not have a width until it is laid out: text in it wraps,
-- and things that 'Lipstick.Mod.grow' take spare columns, to fit the width
-- it gets. Output functions lay blocks out in the output's width (see
-- 'Lipstick.Ansi.envWidth'); 'layout' does it explicitly.
--
-- == Examples
--
-- A block of text has a natural width, the narrowest it can be without
-- breaking words, and a height at its natural width:
--
-- >>> let para = text "A block takes the width it is given."
-- >>> (blockWidth para, blockMinWidth para, blockHeight para)
-- (36,6,1)
--
-- Laid out in less width, the text wraps:
--
-- >>> putBlock (fromGrid (layout (Just 16) para))
-- A block takes
-- the width it is
-- given.
--
-- Blocks side by side, aligned by a 'Position':
--
-- >>> let tall = render (border normalBorder) "one\ntwo\nthree"
-- >>> let short = render (border normalBorder) "four"
-- >>> putBlock (joinHorizontal top [tall, short])
-- ┌─────┐┌────┐
-- │one  ││four│
-- │two  │└────┘
-- │three│
-- └─────┘
-- >>> putBlock (joinHorizontal center [tall, short])
-- ┌─────┐
-- │one  │┌────┐
-- │two  ││four│
-- │three│└────┘
-- └─────┘
--
-- Stacked:
--
-- >>> putBlock (joinVertical right [tall, short])
-- ┌─────┐
-- │one  │
-- │two  │
-- │three│
-- └─────┘
--  ┌────┐
--  │four│
--  └────┘
--
-- A block that 'Lipstick.Mod.fillWidth's takes the spare columns of its
-- row, and one that 'Lipstick.Mod.stretchHeight's is as tall as the row:
--
-- >>> let stretchy = render (border normalBorder <> fillWidth <> stretchHeight) "fills"
-- >>> putBlock (fromGrid (layout (Just 30) (hcat [tall, stretchy])))
-- ┌─────┐┌─────────────────────┐
-- │one  ││fills                │
-- │two  ││                     │
-- │three││                     │
-- └─────┘└─────────────────────┘
--
-- Text with several styles is 'Inline', built with 'styled' and '<>'
-- (the output is shown without its attributes):
--
-- >>> putBlock (render (width 20) (styled bold "Error: " <> "the file " <> styled italic "notes.txt" <> " was not found"))
-- Error: the file
-- notes.txt was not
-- found
module Lipstick.Block
  ( -- * Blocks
    Block
  , blockWidth
  , blockHeight
  , blockMinWidth
  , blockGrows
  , emptyBlock
  , text
  , fromInline

    -- * Laying out
  , layout
  , Grid
  , gridWidth
  , gridHeight
  , fromGrid

    -- * Inline text
  , Inline
  , plain
  , styled
  , styleRanges
  , styleIndices

    -- * Joining
  , joinHorizontal
  , joinVertical
  , hcat
  , vcat
  , Position
  , position
  , left
  , center
  , right
  , top
  , bottom
  ) where

import Data.IntSet qualified as IntSet
import Data.Text (Text)
import Data.Text qualified as T
import Lipstick.Grid (Grid, gridHeight, gridWidth)
import Lipstick.Internal.Grid hiding (Grid (..))
import Lipstick.Internal.Layout
import Lipstick.Internal.Types
import Lipstick.Internal.Mod (Mod, Props (..), propsOf, runMod)

-- $setup
-- >>> import Lipstick

-- | The natural width of a block: the 'Lipstick.Mod.width' of a box that
-- has one, or else as wide as it is with nothing wrapped.
blockWidth :: Block -> Int
blockWidth = reqMax . blockRequest . measure

-- | The height of a block at its natural width.
blockHeight :: Block -> Int
blockHeight b = snd (blockLayout (measure b) (Assign (blockWidth b) 0))

-- | The least width a block gets when width is shared out: the
-- 'Lipstick.Mod.width' of a box that has one, or else the narrowest it can
-- be without breaking a word. Given less, it still shrinks.
blockMinWidth :: Block -> Int
blockMinWidth = reqMin . blockRequest . measure

-- | Whether a block takes spare width (see 'Lipstick.Mod.grow'), as an
-- adapter needs to know to size it.
blockGrows :: Block -> Bool
blockGrows = (> 0) . reqGrow . blockRequest . measure

-- | Unstyled text; lines are split on newlines, and wrap to the width the
-- block is given.
text :: Text -> Block
text = fromInline . plain

-- | Inline text as a block, without any box around it. Lines wrap to the
-- width the block is given.
fromInline :: Inline -> Block
fromInline = textBlock

-- | Text with attributes, for building 'Inline' text with '<>'. Only the
-- text attributes of the modifier apply.
--
-- >>> putBlock (fromInline (styled bold "Error: " <> "file not found"))
-- Error: file not found
styled :: Mod Inline -> Text -> Inline
styled m t = Inline [(propAttrs (snd (runMod m mempty)), t)]

-- | Place blocks side by side. Spare columns go to blocks that
-- 'Lipstick.Mod.grow'; when there are too few columns, the widest blocks
-- shrink first, and what does not fit even at one column each is cut off
-- on the right. The position aligns shorter blocks vertically ('top',
-- 'center', 'bottom'); blocks that 'Lipstick.Mod.stretchHeight' take the
-- row's height instead.
joinHorizontal :: Position -> [Block] -> Block
joinHorizontal p bs = Block $ \ctx ->
  let ns = map (measureIn ctx) bs
      rs = map blockRequest ns
   in rowNode p ns rs

rowNode :: Position -> [Measured] -> [Request] -> Measured
rowNode p ns rs = Measured req run
  where
    req =
      Request
        { reqMin = sum (map reqMin rs)
        , reqMax = sum (map reqMax rs)
        , reqGrow = sum (map reqGrow rs)
        , reqLimit = addLimits rs
        , reqStretch = any reqStretch rs
        , reqFill = False
        }
    run (Assign w h) = (fitWidth w (joinGridsH p (map fst results)), natural)
      where
        ws = allocateWithin w [(reqMin r, reqMax r, reqGrow r, reqLimit r) | r <- rs]
        -- The row height goes into the calls that give the natural heights
        -- it is computed from: a block's natural height never depends on
        -- the height it is asked to stretch to.
        results = [blockLayout n (Assign bw rowH) | (n, bw) <- zip ns ws]
        natural = maximum (0 : map snd results)
        rowH = if reqStretch req then max h natural else natural

-- | Stack blocks. Each takes as much of the column's width as it can use
-- (all of it if it grows without a limit), and the position aligns them
-- horizontally ('left', 'center', 'right').
joinVertical :: Position -> [Block] -> Block
joinVertical p bs = Block $ \ctx ->
  let ns = map (measureIn ctx) bs
      rs = map blockRequest ns
   in columnNode p ns rs

columnNode :: Position -> [Measured] -> [Request] -> Measured
columnNode p ns rs = Measured req run
  where
    req =
      Request
        { reqMin = maximum (0 : map reqMin rs)
        , reqMax = maximum (0 : map reqMax rs)
        , reqGrow = maximum (0 : map reqGrow rs)
        , reqLimit = maxLimit rs
        , reqStretch = False
        , reqFill = False
        }
    run (Assign w _) = (joinGridsV p w (map fst results), sum (map snd results))
      where
        results = [blockLayout n (Assign (takenWidth r w) 0) | (n, r) <- zip ns rs]

-- | Side by side, aligned to the top.
hcat :: [Block] -> Block
hcat = joinHorizontal top

-- | Stacked, aligned to the left.
vcat :: [Block] -> Block
vcat = joinVertical left

-- | Add attributes to ranges of display columns @[start, end)@, counted
-- from the start of the text and continuing across lines (a newline takes
-- no columns); a cluster is styled when its first column is in a range.
-- A tab counts as four columns, the default 'Lipstick.Style.tabWidth':
-- where the text ends up, and so its tab width, is not known yet. The
-- range attributes go on top of the text's own.
--
-- >>> toAnsi defaultRenderEnv (fromInline (styleRanges [(0, 3, bold <> fg red)] "foobar"))
-- "\ESC[0;1;31mfoo\ESC[0mbar"
styleRanges :: [(Int, Int, Mod Inline)] -> Inline -> Inline
styleRanges ranges = restyle $ \col _ ->
  mconcat [propAttrs (propsOf m) | (s, e, m) <- ranges, col >= s, col < e]

-- | Add attributes to the characters (grapheme clusters, not counting
-- newlines) at the given indices, and other attributes to the rest. Handy
-- for highlighting fuzzy-search matches.
--
-- >>> toAnsi defaultRenderEnv (fromInline (styleIndices [0, 3] underline mempty "foobar"))
-- "\ESC[0;4mf\ESC[0moo\ESC[0;4mb\ESC[0mar"
styleIndices :: [Int] -> Mod Inline -> Mod Inline -> Inline -> Inline
styleIndices indices matched unmatched = restyle $ \_ i ->
  if IntSet.member i chosen then on else off
  where
    chosen = IntSet.fromList indices
    on = propAttrs (propsOf matched)
    off = propAttrs (propsOf unmatched)

-- | Layer extra attributes over each cluster, given its starting column and
-- its index.
restyle :: (Int -> Int -> Attrs) -> Inline -> Inline
restyle extra (Inline segs) = Inline (merge (go 0 0 (styledClusters segs)))
  where
    go _ _ [] = []
    go col i ((a, c, cw) : rest)
      | T.any (== '\n') c = (a, c) : go col i rest
      | c == T.singleton '\t' = (a <> extra col i, c) : go (col + textTabWidth defTextStyle) (i + 1) rest
      | otherwise = (a <> extra col i, c) : go (col + cw) (i + 1) rest
    -- Runs of clusters with the same attributes, each joined once.
    merge ((a, t) : rest) =
      let (same, rest') = span ((== a) . fst) rest
       in (a, T.concat (t : map snd same)) : merge rest'
    merge [] = []