lipstick-0.1.0.0: internal/Lipstick/Internal/Layout.hs
-- | Laying out blocks at a width, hidden from other packages: the public
-- parts are re-exported by "Lipstick.Block".
--
-- A 'Block' is first given the text settings it inherits ('TextStyle'),
-- which can change what it measures, and becomes a 'Measured'. A tree of
-- nodes is laid out in one pass that goes down the tree and back up.
-- Going up, every node reports a 'Request': its smallest and natural
-- width, which depend only on its content, and, from 'blockLayout', its
-- natural height at the width it was given. Going down, a parent shares
-- its width among its children with 'allocateWithin' and tells children
-- that stretch the height of their row, which it knows only once the
-- same calls have reported their natural heights. Laziness resolves the
-- apparent circle (see jaspervdj's
-- [Lazy Layout](https://jaspervdj.be/posts/2023-07-22-lazy-layout.html)),
-- as long as
--
-- * a 'Request' never depends on the 'Assign'ment, and
-- * the natural height never depends on 'assignHeight'.
--
-- So the fields of 'Request' and 'Assign', and the result pair, must stay
-- lazy.
module Lipstick.Internal.Layout
( -- * Blocks
Block (..)
, Measured (..)
, measureIn
, measure
, TextStyle (..)
, defTextStyle
, Request (..)
, Assign (..)
, layout
, layoutIn
, fromGrid
, emptyBlock
, textBlock
, cropLayoutWidth
, takenWidth
, addLimits
, maxLimit
, usableWidth
-- * Sharing out width
, allocate
, allocateWithin
) where
import Data.String (IsString (..))
import Data.Text (Text)
import Data.Text qualified as T
import Lipstick.Internal.Grid
import Lipstick.Internal.Types (Position, left)
-- | Styled content that can be laid out at any width, once it knows the
-- text settings it inherits.
newtype Block = Block (TextStyle -> Measured)
-- | A block with its inherited text settings applied.
data Measured = Measured
{ blockRequest :: Request
, blockLayout :: Assign -> (Grid, Int)
-- ^ the grid, exactly 'assignWidth' wide, and the natural height at
-- that width (the grid is taller only when the block stretches)
}
-- | Text settings that nested text inherits, as in CSS: set on a style,
-- they apply to all the text inside what it frames, unless something
-- nearer sets them again. They are known before anything is measured,
-- since some of them change widths.
data TextStyle = TextStyle
{ textAlign :: !Position
, textWrap :: !Bool
-- ^ wrap lines, or cut them short with an ellipsis
, textBreaks :: ![Char]
-- ^ characters to also break lines after
, textTabWidth :: !Int
, textTransform :: !(Maybe (Text -> Text))
, textInline :: !Bool
-- ^ newlines removed, never wrapped
}
-- | Left-aligned, wrapping text, with each tab replaced by four spaces.
defTextStyle :: TextStyle
defTextStyle = TextStyle left True [] 4 Nothing False
-- | A block in inherited text settings.
measureIn :: TextStyle -> Block -> Measured
measureIn ctx (Block f) = f ctx
-- | A block in the default text settings.
measure :: Block -> Measured
measure = measureIn defTextStyle
-- | What a block asks of its parent. Lazy, and independent of the
-- assignment.
data Request = Request
{ reqMin :: Int
-- ^ the narrowest width that breaks no words
, reqMax :: Int
-- ^ the natural width: nothing wrapped
, reqGrow :: Int
-- ^ the weight for spare width; 0 for none
, reqLimit :: Maybe Int
-- ^ the most it can use when it grows; 'Nothing' for no limit
, reqStretch :: Bool
-- ^ whether the block grows to the height of its row
, reqFill :: Bool
-- ^ whether a box framing it gives it the whole inner width, so it
-- aligns its own lines (text), rather than only the width it takes, the
-- box aligning it as a whole (boxes, rows)
}
-- | What a parent gives a block. Lazy: 'assignHeight' can depend on the
-- natural heights the same layout reports.
data Assign = Assign
{ assignWidth :: Int
, assignHeight :: Int
-- ^ the height of the row, for blocks that stretch
}
-- | Unstyled text, word-wrapped to the width it is given.
instance IsString Block where
fromString = textBlock . plain . T.pack
-- | Lay a block out in an available width: at its natural width, or, if
-- it grows, at as much of the width as it can use (up to its
-- 'Lipstick.Mod.maxWidth', say), but never wider than is available.
-- With 'Nothing', at its natural width.
layout :: Maybe Int -> Block -> Grid
layout = layoutIn defTextStyle
-- | Like 'layout', in inherited text settings.
layoutIn :: TextStyle -> Maybe Int -> Block -> Grid
layoutIn ctx avail b = fst (blockLayout n (Assign w 0))
where
n = measureIn ctx b
r = blockRequest n
w = maybe (reqMax r) (takenWidth r) avail
-- | The width a block takes on its own in an available width: its natural
-- width, or, if it grows, as much as its limit allows (but at least its
-- natural width); never more than is available.
takenWidth :: Request -> Int -> Int
takenWidth r avail
| reqGrow r > 0 = maybe a (min a . max (reqMax r)) (reqLimit r)
| otherwise = min a (reqMax r)
where
a = max 0 avail
-- | The most a row of blocks can use: the sum of what each can.
addLimits :: [Request] -> Maybe Int
addLimits rs = sum <$> traverse usableWidth rs
-- | The most a column of blocks can use: what the widest can.
maxLimit :: [Request] -> Maybe Int
maxLimit rs = maximum . (0 :) <$> traverse usableWidth rs
-- | The most a block can use: its natural width if it does not grow,
-- otherwise its limit but at least its natural width; 'Nothing' for no
-- limit.
usableWidth :: Request -> Maybe Int
usableWidth r
| reqGrow r > 0 = max (reqMax r) <$> reqLimit r
| otherwise = Just (reqMax r)
-- | A grid as a block: cropped or padded to the width it is given. It
-- inherits nothing.
fromGrid :: Grid -> Block
fromGrid g =
Block . const $
Measured
(Request (gridWidth g) (gridWidth g) 0 Nothing False False)
(\(Assign w _) -> (fitWidth w g, gridHeight g))
-- | The block with no lines.
emptyBlock :: Block
emptyBlock = fromGrid emptyGrid
-- | Inline text, laid out in the text settings it inherits: word-wrapped
-- to the width it is given (or cut short with an ellipsis), each line
-- aligned in it.
textBlock :: Inline -> Block
textBlock inl = Block $ \ctx ->
let ls0 = case textTransform ctx of
-- Shared by every layout in the common case, so text is segmented
-- once however often the block is laid out.
Nothing | textTabWidth ctx == textTabWidth defTextStyle -> defaultLines
Nothing -> inlineLines (textTabWidth ctx) inl
Just f -> let Inline segs = inl in inlineLines (textTabWidth ctx) (Inline [(a, f t) | (a, t) <- segs])
ls = if textInline ctx then [concat ls0] else ls0
hi = maximum (0 : map cellsWidth ls)
lo
| textWrap ctx && not (textInline ctx) = maximum (0 : concatMap (wordWidths (textBreaks ctx)) ls)
| otherwise = hi
run (Assign w _) =
let fitted
| textInline ctx = map (cropCells w) ls
-- With no room at all, text is cut rather than wrapped one
-- cluster a line, so that it does not grow tall for nothing.
| textWrap ctx && w > 0 = concatMap (wrapCellsWith (textBreaks ctx) w) ls
| otherwise = map (truncateCells w) ls
in (Grid w (length fitted) [cellsLine (cropCells w (alignText (textAlign ctx) w l)) | l <- fitted], length fitted)
in Measured (Request lo hi 0 Nothing False True) run
where
defaultLines = inlineLines (textTabWidth defTextStyle) inl
-- | The width to lay out something with a 'Lipstick.Mod.maxWidth' crop
-- at, before cropping, given the crop, its weight for spare width, its
-- natural width and the width @w@ it is given. When the crop, not @w@, is
-- the binding limit, something that grows is laid out at the crop width,
-- so it fills up to it, and anything else at least at its natural width,
-- so the crop cuts lines instead of making text wrap. Otherwise it is
-- laid out at @w@.
cropLayoutWidth :: Maybe Int -> Int -> Int -> Int -> Int
cropLayoutWidth crop weight natural w = case crop of
Just c
| w >= max 0 c -> if weight > 0 then max 0 c else max w natural
_ -> w
-- | Share out the columns of a row among its children, given each child's
-- smallest and natural width and its weight for spare space.
--
-- * When every child fits at its natural width, each gets it, and the
-- rest goes to the children with a weight, in proportion (left ones
-- first for what does not divide evenly). Without weights the row is
-- narrower than the space.
--
-- * Otherwise the widest children shrink first, towards their smallest
-- width: every child ends up at its natural width or at a common level,
-- whichever is less.
--
-- * When even the smallest widths do not fit, the widest children shrink
-- further, down to one column (zero for children whose smallest width
-- is zero), and the result is wider than the space only if that is
-- still too much.
--
-- The widths add up to the space whenever the bounds allow it.
allocate :: Int -> [(Int, Int, Int)] -> [Int]
allocate space children = allocateWithin space [(lo, hi, k, Nothing) | (lo, hi, k) <- children]
-- | Like 'allocate', with the most each child can use when it grows
-- ('Nothing' for no limit): what a child cannot use goes to the others.
allocateWithin :: Int -> [(Int, Int, Int, Maybe Int)] -> [Int]
allocateWithin space children
| sum his <= w = growWithin (w - sum his) [(hi, max 0 k, l) | (hi, (_, _, k, l)) <- zip his children]
| sum los <= w = level los his
| otherwise = level floors los
where
w = max 0 space
los = [max 0 lo | (lo, _, _, _) <- children]
his = [max lo hi | (lo, (_, hi, _, _)) <- zip los children]
floors = map (min 1) los
-- Share out spare columns by weight. Children that reach their limit
-- keep it and drop out, and the rest is shared again.
growWithin extra cs
| extra <= 0 || total == 0 = [x | (x, _, _) <- cs]
| or full = growWithin (extra - sum used) [if f then (x + u, 0, l) else c | (c@(x, _, l), f, u) <- zip3 cs full used]
| otherwise = zipWith3 (\(x, _, _) sh b -> x + sh + b) cs shares (ones (extra - sum shares) (map open cs))
where
open (x, k, l) = k > 0 && maybe True (> x) l
total = sum [k | c@(_, k, _) <- cs, open c]
shares = [if open c then extra * k `div` total else 0 | c@(_, k, _) <- cs]
room (x, _, l) = maybe maxBound (subtract x) l
full = [open c && sh >= room c | (c, sh) <- zip cs shares]
used = [if f then room c else 0 | (c, f) <- zip cs full]
-- Every child at @clamp lo hi t@ for the highest level @t@ that fits;
-- the columns left over go to the children cut at the level, right
-- ones first: that is where shrinking the widest child one column at
-- a time, leftmost on ties, would leave them, and the tests check
-- that the two agree.
level lows highs
| sum lows >= w = lows
| otherwise =
let t = search 0 (maximum (0 : highs))
ws = [clamp lo hi t | (lo, hi) <- zip lows highs]
spare = w - sum ws
cut = [hi > t && lo <= t | (lo, hi) <- zip lows highs]
in zipWith (+) ws (reverse (ones spare (reverse cut)))
where
total t = sum [clamp lo hi t | (lo, hi) <- zip lows highs]
-- The highest t in [lo, hi] with total t <= w; total lo <= w.
search lo hi
| lo >= hi = lo
| otherwise =
let mid = (lo + hi + 1) `div` 2
in if total mid <= w then search mid hi else search lo (mid - 1)
clamp lo hi = max lo . min hi
-- 1 at the first n positions that are True, 0 elsewhere.
ones :: Int -> [Bool] -> [Int]
ones n bs = case bs of
b : rest
| b && n > 0 -> 1 : ones (n - 1) rest
| otherwise -> 0 : ones n rest
[] -> []