packages feed

table-layout-1.0.0.2: src/Text/Layout/Table/Cell.hs

{-# LANGUAGE DeriveFunctor     #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE RecordWildCards   #-}

module Text.Layout.Table.Cell where

import Control.Monad (join)
import qualified Data.Text as T

import Text.Layout.Table.Primitives.AlignInfo
import Text.Layout.Table.Primitives.CellMod
import Text.Layout.Table.Spec.CutMark
import Text.Layout.Table.Spec.OccSpec
import Text.Layout.Table.Spec.Position
import Text.Layout.Table.StringBuilder

-- | Ensure a value is not negative.
truncateNegative :: Int -> Int
truncateNegative = max 0

-- | An object along with the amount that its length should be adjusted on both the left and right.
-- Positive numbers are padding and negative numbers are trimming.
data CellView a =
    CellView
    { baseCell :: a
    , leftAdjustment :: Int
    , rightAdjustment :: Int
    } deriving (Eq, Ord, Show, Functor)

-- | Add an adjustment to the left and right of a 'Cell'.
-- Positive numbers are padding and negative numbers are trimming.
adjustCell :: Int -> Int -> a -> CellView a
adjustCell l r a = CellView a l r

-- | Drop a number of characters from the left side. Treats negative numbers
-- as zero.
dropLeft :: Int -> a -> CellView a
dropLeft n = dropBoth n 0

-- | Drop a number of characters from the right side. Treats negative
-- numbers as zero.
dropRight :: Int -> a -> CellView a
dropRight = dropBoth 0

-- | Drop characters from both sides. Treats negative numbers as zero.
dropBoth :: Int -> Int -> a -> CellView a
dropBoth l r = adjustCell (negate $ truncateNegative l) (negate $ truncateNegative r)

instance Applicative CellView where
  pure x = CellView x 0 0
  (CellView f l r) <*> (CellView x l' r') = CellView (f x) (l + l') (r + r')

instance Monad CellView where
  (CellView x l r) >>= f = let CellView y l' r' = f x in CellView y (l + l') (r + r')

-- | The total amount of adjustment in 'CellView'.
totalAdjustment :: CellView a -> Int
totalAdjustment a = leftAdjustment a + rightAdjustment a

-- | Redistribute padding or trimming using a given ratio.
redistributeAdjustment :: Int -> Int -> CellView a -> CellView a
redistributeAdjustment l r a = CellView (baseCell a) lAdjustment rAdjustment
  where
    lAdjustment = (totalAdjustment a * l) `div` (l + r)
    rAdjustment = totalAdjustment a - lAdjustment

-- | Types that can be measured for visible characters, define a sub-string
-- operation and turned into a 'StringBuilder'.
class Cell a where
    -- | Returns the length of the visible characters as displayed on the
    -- output medium.
    visibleLength :: a -> Int

    -- | Measure the preceding and following characters for a position where
    -- the predicate matches.
    measureAlignment :: (Char -> Bool) -> a -> AlignInfo

    -- | Insert the contents into a 'StringBuilder'.
    buildCell :: StringBuilder b => a -> b
    buildCell = buildCellView . pure

    -- | Insert the contents into a 'StringBuilder', padding or trimming as
    -- necessary.
    --
    -- The 'Cell' instance of 'CellView a' means that this can usually be
    -- substituted with 'buildCell', and is only needed for defining the
    -- instance.
    buildCellView :: StringBuilder b => CellView a -> b

    {-# MINIMAL visibleLength, measureAlignment, buildCellView #-}

instance Cell a => Cell (CellView a) where
    visibleLength (CellView a l r) = visibleLength a + l + r
    measureAlignment f (CellView a l r) = case mMatchRemaining of
        -- No match
        Nothing -> AlignInfo (truncateNegative $ matchAt + l + r) Nothing
        -- There is a match, but it is cut off from the left or right
        Just matchRemaining | matchAt < -l || matchRemaining < -r -> AlignInfo (truncateNegative $ matchAt + matchRemaining + 1 + l + r) Nothing
        -- There is a match, and it is not cut off
        Just matchRemaining -> AlignInfo (matchAt + l) (Just $ matchRemaining + r)
      where
        AlignInfo matchAt mMatchRemaining = measureAlignment f a
    buildCell = buildCellView
    buildCellView = buildCellView . join

instance Cell a => Cell (Maybe a) where
    visibleLength = maybe 0 visibleLength
    measureAlignment p = maybe mempty (measureAlignment p)
    buildCell = maybe mempty buildCell
    buildCellView (CellView a l r) = maybe (spacesB $ l + r) (buildCellView . adjustCell l r) a

instance (Cell a, Cell b) => Cell (Either a b) where
    visibleLength = either visibleLength visibleLength
    measureAlignment p = either (measureAlignment p) (measureAlignment p)
    buildCell = either buildCell buildCell
    buildCellView (CellView a l r) = either go go a
      where
        go x = buildCellView $ CellView x l r

instance Cell String where
    visibleLength = length
    measureAlignment p xs = case break p xs of
        (ls, rs) -> AlignInfo (length ls) $ case rs of
            []      -> Nothing
            _ : rs' -> Just $ length rs'

    buildCell = stringB
    buildCellView = buildCellViewLRHelper stringB drop (\n s -> zipWith const s $ drop n s)

instance Cell T.Text where
    visibleLength = T.length
    measureAlignment p xs = case T.break p xs of
        (ls, rs) -> AlignInfo (T.length ls) $ if T.null rs
            then Nothing
            else Just $ T.length rs - 1

    buildCell = textB
    buildCellView = buildCellViewLRHelper textB T.drop T.dropEnd

-- | Construct 'buildCellView' from a builder function, a function for
-- trimming from the left, and a function for trimming from the right.
--
-- Used to define instances of 'Cell'.
buildCellViewLRHelper :: StringBuilder b
                      => (a -> b)  -- ^ Builder function for 'a'.
                      -> (Int -> a -> a)  -- ^ Function for trimming on the left.
                      -> (Int -> a -> a)  -- ^ Function for trimming on the right.
                      -> CellView a
                      -> b
buildCellViewLRHelper build trimL trimR =
    buildCellViewHelper build (\i -> build . trimL i) (\i -> build . trimR i) (\l r -> build . trimL l . trimR r)

-- | Construct 'buildCellView' from a builder function, and a function for
-- trimming from the left and right simultaneously.
--
-- Used to define instanced of 'Cell'.
buildCellViewBothHelper
    :: StringBuilder b
    => (a -> b)  -- ^ Builder function for 'a'.
    -> (Int -> Int -> a -> a)  -- ^ Function for trimming on the left and right simultaneously.
    -> CellView a
    -> b
buildCellViewBothHelper build trimBoth =
    buildCellViewHelper build (\i -> build . trimBoth i 0) (\i -> build . trimBoth 0 i) (\l r -> build . trimBoth l r)

-- | Construct 'buildCellView' from builder functions and trimming functions.
--
-- Used to define instances of 'Cell'.
buildCellViewHelper
    :: StringBuilder b
    => (a -> b)  -- ^ Builder function for 'a'.
    -> (Int -> a -> b)  -- ^ Function for trimming on the left.
    -> (Int -> a -> b)  -- ^ Function for trimming on the right.
    -> (Int -> Int -> a -> b)  -- ^ Function for trimming on the left and right simultaneously.
    -> CellView a
    -> b
buildCellViewHelper build trimL trimR trimBoth (CellView a l r) =
    case (compare l 0, compare r 0) of
        (GT, GT) -> spacesB l <> build a <> spacesB r
        (GT, LT) -> spacesB l <> trimR (negate r) a
        (GT, EQ) -> spacesB l <> build a
        (LT, GT) -> trimL (negate l) a <> spacesB r
        (LT, LT) -> trimBoth (negate l) (negate r) a
        (LT, EQ) -> trimL (negate l) a
        (EQ, GT) -> build a <> spacesB r
        (EQ, LT) -> trimR (negate r) a
        (EQ, EQ) -> build a

-- | Creates a 'StringBuilder' with the amount of missing spaces.
remSpacesB
    :: (Cell a, StringBuilder b)
    => Int -- ^ The expected length.
    -> a -- ^ A cell.
    -> b
remSpacesB n c = remSpacesB' n $ visibleLength c

-- | Fill the right side with spaces if necessary.
fillRight :: Cell a => Int -> a -> CellMod a
fillRight n c = fillRight' n (visibleLength c) c

-- | Fill the right side with spaces if necessary. Preconditions that are
-- required to be met (otherwise the function will produce garbage):
--
-- prop> visibleLength c == k
fillRight' :: Cell a => Int -> Int -> a -> CellMod a
fillRight' n k = padCellRight (truncateNegative $ n - k)

-- | Fill both sides with spaces if necessary.
fillCenter :: Cell a => Int -> a -> CellMod a
fillCenter n c = fillCenter' n (visibleLength c) c

-- | Fill both sides with spaces if necessary. Preconditions that are
-- required to be met (otherwise the function will produce garbage):
--
-- prop> visibleLength c == k
fillCenter' :: Cell a => Int -> Int -> a -> CellMod a
fillCenter' n k = padCell q (q + r)
  where
    missing = n - k
    (q, r)  = missing `divMod` 2

-- | Fill the left side with spaces if necessary.
fillLeft :: Cell a => Int -> a -> CellMod a
fillLeft n c = fillLeft' n (visibleLength c) c

-- | Fill the left side with spaces if necessary. Preconditions that are
-- required to be met (otherwise the function will produce garbage):
--
-- prop> visibleLength c == k
fillLeft' :: Cell a => Int -> Int -> a -> CellMod a
fillLeft' n k = padCellLeft (truncateNegative $ n - k)

-- | Pads the given cell accordingly using the position specification.
--
-- >>> buildCellMod noCutMark $ pad left 10 "foo" :: String
-- "foo       "
pad :: Cell a => Position o -> Int -> a -> CellMod a
pad p n c = pad' p n (visibleLength c) c

-- | Pads the given cell accordingly using the position specification.
-- Preconditions that are required to be met (otherwise the function will
-- produce garbage):
--
-- prop> visibleLength c == k
pad' :: Cell a => Position o -> Int -> Int -> a -> CellMod a
pad' p n k = case p of
    Start  -> fillRight' n k
    Center -> fillCenter' n k
    End    -> fillLeft' n k

-- | If the given text is too long, the 'String' will be shortened according to
-- the position specification. Adds cut marks to indicate that the column has
-- been trimmed in length, otherwise it behaves like 'pad'.
--
-- >>> let cm = singleCutMark ".."
-- >>> buildCellMod cm $ trimOrPad left cm 10 "A longer text." :: String
-- "A longer.."
--
trimOrPad :: Cell a => Position o -> CutMark -> Int -> a -> CellMod a
trimOrPad p cutMark n c = case compare k n of
    LT -> pad' p n k c
    EQ -> keepCell c
    GT -> trim' p cutMark n k c
  where
    k = visibleLength c

-- | If the given text is too long, it will be trimmed to length `upper`
-- according to the position specification, and cut marks will be added to
-- indicate that the column has been trimmed in length. Otherwise, if
-- the given text is too short, it will be padded to length `lower`.
--
-- >>> let cm = singleCutMark ".."
-- >>> buildCellMod cm $ trimOrPadBetween left cm 7 10 "A longer text." :: String
-- "A longer.."
-- >>> buildCellMod cm $ trimOrPadBetween left cm 7 10 "Short" :: String
-- "Short  "
-- >>> buildCellMod cm $ trimOrPadBetween left cm 7 10 "A medium" :: String
-- "A medium"
--
-- Preconditions that are required to be met (otherwise the output will be
-- counterintuitive):
--
-- prop> lower <= upper
trimOrPadBetween
    :: Cell a
    => Position o
    -> CutMark
    -> Int  -- ^ The length `lower` to pad to if too short
    -> Int  -- ^ The length `upper` to trim to if too long
    -> a
    -> CellMod a
trimOrPadBetween p cutMark lower upper c
    | k > lower = trim' p cutMark upper k c
    | k < upper = pad' p lower k c
    | otherwise = keepCell c
  where
    k = visibleLength c

-- | Trim a cell based on the position. Cut marks may be trimmed if necessary.
trim :: Cell a => Position o -> CutMark -> Int -> a -> CellMod a
trim p cutMark n c = if k <= n then keepCell c else trim' p cutMark n k c
  where
    k = visibleLength c

-- | Trim a cell based on the position. Cut marks may be trimmed if necessary.
--
-- Preconditions that are required to be met (otherwise the function will produce garbage):
--
-- prop> visibleLength c > n
-- prop> visibleLength c == k
trim' :: Cell a => Position o -> CutMark -> Int -> Int -> a -> CellMod a
trim' p cutMark n k = case p of
    Start  -> trimCellRight (cutLen + rightLen) (min n rightLen)
    Center -> case cutLen `divMod` 2 of
        (0, 1) -> trimCellLeft (1 + leftLen) n
        (q, r) -> if n >= leftLen + rightLen
                  -- Both cutmarks fit.
                  then trimCell (leftLen + q + r) (rightLen + q) leftLen rightLen
                  else case n `divMod` 2 of
                    (qn, rn) -> trimCell k 0 qn (qn + rn)
    End    -> trimCellLeft (leftLen + cutLen) (min n leftLen)
  where
    leftLen = length $ leftMark cutMark
    rightLen = length $ rightMark cutMark

    cutLen = k - n

-- | Align a cell by first locating the position to align with and then padding
-- on both sides. If no such position is found, it will align it such that it
-- gets aligned before that position.
--
-- >>> let { os = predOccSpec (== '.') ; ai = deriveAlignInfo os "iiii.fff" }
-- >>> in buildCellMod noCutMark . align os ai <$> ["1.5", "30", ".25"] :: [String]
-- ["   1.5  ","  30    ","    .25 "]
--
-- This function assumes that the given 'String' fits the 'AlignInfo'. Thus:
--
-- prop> ai <> deriveAlignInfo s = ai
--
align :: Cell a => OccSpec -> AlignInfo -> a -> CellMod a
align oS (AlignInfo ln optRN) c = case measureAlignment (predicate oS) c of
    AlignInfo lk optRK -> padCell (truncateNegative $ ln - lk) (truncateNegative $ maybe 0 succ optRN - maybe 0 succ optRK) c

data CutAction
    = FillCA Int
    | CutCA Int
    | NoneCA
    deriving (Eq, Show)

surplusSpace :: CutAction -> Int
surplusSpace ca = case ca of
    CutCA n  -> negate n
    FillCA n -> n
    _        -> 0

determineCutAction :: Int -> Int -> CutAction
determineCutAction requiredW actualW = case compare requiredW actualW of
    LT -> CutCA $ actualW - requiredW
    EQ -> NoneCA
    GT -> FillCA $ requiredW - actualW

data CutInfo
    -- | Apply a cut action to each side.
    = SidesCI CutAction CutAction
    -- | Apply a mark to a whitespace string pointing to the left.
    | MarkLeftCI
    -- | Apply a mark to a whitespace string pointing to the right.
    | MarkRightCI
    deriving (Eq, Show)

-- | Compares the view range, that represents the visible part, with the cell
-- range, which is the position of the cell relative to the alignment, and
-- determines the actions that should be performed.
determineCuts :: Int -> Int -> Int -> Int -> CutInfo
determineCuts vl vr cl cr
    | vr <= cl  = MarkRightCI
    | cr <= vl  = MarkLeftCI
    | otherwise = SidesCI (determineCutAction cl vl) (determineCutAction vr cr)

-- | If the amount to be cut is bigger than the cell length then any missing
-- amount is taken away from any remaining padding.
numSpacesAfterCut :: CutAction -> Int -> Int -> Int
numSpacesAfterCut ca cellLen cutAmount = s + min r 0
  where
    s = surplusSpace ca
    r = cellLen - cutAmount

applyCutInfo
    :: Cell a
    => CutInfo
    -> CutMark
    -> Int
    -> Int
    -> a
    -> CellMod a
applyCutInfo ci cutMark availSpace cellLen = case ci of
    -- The cuts might interfere with each other. Properly distribute available
    -- length between both cut marks.
    SidesCI (CutCA lCut) (CutCA rCut) ->
        let (q, r) = availSpace `divMod` 2
        in modifyCellWithCutMarkLen (negate $ lCut + leftLen)
                                    (negate $ rCut + rightLen)
                                    q
                                    (q + r)
    -- The left cut might need some of the right padding.
    SidesCI (CutCA lCut) rCA          ->
        modifyCellWithCutMarkLen (negate $ lCut + leftLen)
                                 (numSpacesAfterCut rCA cellLen $ lCut + leftLen)
                                 availSpace
                                 0
    -- The right cut might need some of the left padding.
    SidesCI lCA (CutCA rCut)          ->
        modifyCellWithCutMarkLen (numSpacesAfterCut lCA cellLen $ rCut + rightLen)
                                 (negate $ rCut + rightLen)
                                 0
                                 availSpace
    -- Filtered out all cuts at this point.
    SidesCI lCA rCA                   ->
        padCell (surplusSpace lCA) (surplusSpace rCA)
    MarkRightCI                       ->
        modifyCellWithCutMarkLen (truncateNegative $ availSpace - rightLen)
                                 (negate cellLen)
                                 0
                                 (min availSpace rightLen)
    MarkLeftCI                        ->
        modifyCellWithCutMarkLen (negate cellLen)
                                 (truncateNegative $ availSpace - leftLen)
                                 (min availSpace leftLen)
                                 0
  where
    leftLen = length $ leftMark cutMark
    rightLen = length $ rightMark cutMark

-- | Given a position, the available width, and the length of an alignment
-- (left and right side, separator is implied) compute a range for the view.
-- The lower bound is inclusive and the upper bound exclusive.
viewRange :: Position o -> Int -> Int -> Int -> (Int, Int)
viewRange p availSpace l r = case p of
    Start  -> (0, availSpace)
    Center -> let (cq, cr) = (l + r + 1 - availSpace) `divMod` 2
                  start    = cq + cr
              in (start, start + availSpace)
    End    -> let end = l + r + 1
              in (end - availSpace, end)

-- | Given the maximum left alignment and the alignment of the cell create a
-- range that describes the position of the cell. The lower bound is inclusive
-- and the upper bound exclusive.
cellRange :: Int -> AlignInfo -> (Int, Int)
cellRange lMax cellAlignInfo@(AlignInfo l _) = (cl, cl + widthAI cellAlignInfo)
  where
    cl = lMax - l

-- | Aligns a cell using a fixed width, fitting it to the width by either
-- filling or cutting while respecting the alignment.
alignFixed
    :: Cell a
    => Position o
    -> CutMark
    -> Int
    -> OccSpec
    -> AlignInfo
    -> a
    -> CellMod a
alignFixed p cutMark n oS (AlignInfo lMax optRMax) c = case optRMax of
    Nothing   -> trimOrPad p cutMark n c
    Just rMax -> let (vl, vr)            = viewRange p n lMax rMax
                     (cl, cr)            = cellRange lMax $ measureAlignment (predicate oS) c
                     cutInfo             = determineCuts vl vr cl cr
                     cellLen             = cr - cl
                 in applyCutInfo cutInfo cutMark n cellLen c

-- | Interpret 'CellMod' to create a builder.
buildCellMod
    :: (Cell c, StringBuilder s)
    => CutMark
    -> CellMod c
    -> s
buildCellMod cutMark CellMod {..} =
    -- 'buildCellView' takes care of padding and trimming.
    applyMarkOrEmpty applyLeftMark leftCutMarkLenCM
    <> buildCellView (CellView baseCellCM leftAdjustmentCM rightAdjustmentCM)
    <> applyMarkOrEmpty applyRightMark rightCutMarkLenCM
  where
    applyMarkOrEmpty applyMark k = if k > 0 then applyMark k else mempty

    applyLeftMark k  = stringB $ take k $ leftMark cutMark
    applyRightMark k = stringB . reverse . take k . reverse $ rightMark cutMark