packages feed

table-layout-1.0.0.0: src/Text/Layout/Table.hs

-- | This module provides tools to layout text as grid or table. Besides basic
-- things like specifying column positioning, alignment on the same character
-- and length restriction it also provides advanced features like justifying
-- text and fancy tables with styling support.
--
{-# LANGUAGE RecordWildCards     #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections #-}
module Text.Layout.Table
    ( -- * Column Layout
      -- | Specify how a column is rendered with the combinators in this
      -- section. Sensible default values are provided with 'def'.

      module Data.Default.Class

      -- ** Columns
    , ColSpec
    , column
    , numCol
    , fixedCol
    , fixedLeftCol
    , defColSpec
      -- ** Length of Columns
    , LenSpec
    , expand
    , fixed
    , expandUntil
    , fixedUntil
    , expandBetween
      -- ** Positional Alignment
    , Position
    , H
    , left
    , right
    , center
    , beginning
      -- ** Alignment of Cells at Characters
    , AlignSpec
    , noAlign
    , charAlign
    , predAlign
    , dotAlign
      -- ** Cut Marks
    , CutMark
    , noCutMark
    , singleCutMark
    , doubleCutMark
    , ellipsisCutMark

      -- * Grids
      -- ** Rendering
    , Row
    , grid
    , gridB
    , gridBWithCMIs
    , gridLines
    , gridLinesB
    , gridString
    , gridStringB

      -- ** Concatenating
    , concatRow
    , concatLines
    , concatGrid

      -- ** Modification Functions
    , altLines
    , checkeredCells

      -- * Tables
      -- ** Grouping Rows
      -- | Rows in character-based tables are separated by separator lines.
      -- This section provides the tools to decide when this separation is
      -- happening.  Thus, several text rows may be in the same row of the
      -- table.
    , RowGroup
    , rowsG
    , rowG

    -- *** Columns as Row Groups
    -- | [Text justification](#text) may be used to turn text into
    -- length-limited columns. Such columns may be turned into a 'RowGroup'
    -- with 'colsG' or 'colsAllG'.
    , colsG
    , colsAllG

      -- ** Specifying Tables
      -- | The most basic `TableSpec` may be constructed by using `simpleTableS`.
    , module Text.Layout.Table.Spec.TableSpec

      -- ** Rendering
      -- | Render a 'TableSpec'.
    , tableLines
    , tableLinesB
    , tableLinesBWithCMIs
    , tableString
    , tableStringB

      -- ** Headers
    , HeaderColSpec
    , headerColumn
    , HeaderSpec
    , noneSepH
    , noneH
    , fullSepH
    , fullH
    , titlesH
    , groupH
    , headerH
    , defHeaderColSpec

      -- ** Styles
    , module Text.Layout.Table.Style
    , module Text.Layout.Table.LineStyle


      -- * Multi-Row Cell Rendering
      -- ** Text Justification
      -- | #text# Split text and turn it into a column.  Such columns may be
      -- combined with other columns.
    , justify
    , justifyText

      -- ** Vertical Column Positioning
      -- | Turn rows of columns into a grid by aligning the columns.
    , V
    , top
    , bottom
    , Col
    , colsAsRowsAll
    , colsAsRows

      -- * Custom Layout Generation
      -- ** Column Modification Functions
    , pad
    , trim
    , trimOrPad
    , trimOrPadBetween
    , align
    , alignFixed
    , buildCellMod
    , adjustCell

      -- ** Column Modifaction Primitives
      -- | These functions are provided to be reused. For example if someone
      -- wants to render their own kind of tables.
    , ColModInfo
    , widthCMI
    , unalignedCMI
    , ensureWidthCMI
    , ensureWidthOfCMI
    , columnModifier
    , AlignInfo
    , widthAI
    , deriveColModInfosFromGrid
    , deriveColModInfosFromColumns
    , deriveAlignInfo
    , OccSpec

      -- ** Table Headers
    , zipHeader
    , flattenHeader
    , headerContents
    ) where

-- TODO AlignSpec:   multiple alignment points - useful?
-- TODO RowGroup:    optional: provide extra layout for a RowGroup
-- TODO ColSpec:     add some kind of combinator to construct ColSpec values (e.g. via Monoid, see optparse-applicative)

import           Data.Bifunctor
import           Data.Default.Class
import           Data.List
import           Data.Maybe
import           Data.Semigroup

import           Text.Layout.Table.Cell
import           Text.Layout.Table.Justify
import           Text.Layout.Table.LineStyle
import           Text.Layout.Table.Primitives.AlignInfo
import           Text.Layout.Table.Primitives.ColumnModifier
import           Text.Layout.Table.Primitives.Header
import           Text.Layout.Table.Primitives.Table
import           Text.Layout.Table.Spec.AlignSpec
import           Text.Layout.Table.Spec.ColSpec
import           Text.Layout.Table.Spec.CutMark
import           Text.Layout.Table.Spec.HeaderColSpec
import           Text.Layout.Table.Spec.HeaderSpec
import           Text.Layout.Table.Spec.LenSpec
import           Text.Layout.Table.Spec.OccSpec
import           Text.Layout.Table.Spec.Position
import           Text.Layout.Table.Spec.RowGroup
import           Text.Layout.Table.Spec.TableSpec
import           Text.Layout.Table.Spec.Util
import           Text.Layout.Table.StringBuilder
import           Text.Layout.Table.Style
import           Text.Layout.Table.Vertical

-------------------------------------------------------------------------------
-- Layout types and combinators
-------------------------------------------------------------------------------

-- | Align all text at the first dot from the left. This is most useful for
-- floating point numbers.
dotAlign :: AlignSpec
dotAlign = charAlign '.'

-- | Numbers are positioned on the right and aligned on the floating point dot.
numCol :: ColSpec
numCol = column expand right dotAlign ellipsisCutMark

-- | Fixes the column length and positions according to the given 'Position'.
fixedCol :: Int -> Position H -> ColSpec
fixedCol l pS = column (fixed l) pS noAlign ellipsisCutMark

-- | Fixes the column length and positions on the left.
fixedLeftCol :: Int -> ColSpec
fixedLeftCol i = fixedCol i left

-------------------------------------------------------------------------------
-- Basic layout
-------------------------------------------------------------------------------

-- | Modifies cells according to the column specification.
gridB :: (Cell a, StringBuilder b) => [ColSpec] -> [Row a] -> [Row b]
gridB specs = fst . gridBWithCMIs specs

-- | Modifies cells according to the column specification, also returning the
-- 'ColModInfo' used to generate the grid.
gridBWithCMIs :: (Cell a, StringBuilder b) => [ColSpec] -> [Row a] -> ([Row b], [ColModInfo])
gridBWithCMIs specs tab = (zipWith4 columnModifier positions cms cMIs <$> tab, cMIs)
  where
    cMIs = deriveColModInfosFromGrid specs tab
    positions = map position specs
    cms = map cutMark specs

-- | A version of 'gridB' specialized to 'String'.
grid :: Cell a => [ColSpec] -> [Row a] -> [Row String]
grid = gridB

-- | A version of 'gridB' that joins the cells of a row with one space.
gridLinesB :: (Cell a, StringBuilder b) => [ColSpec] -> [Row a] -> [b]
gridLinesB specs = fmap (concatRow 1). gridB specs

-- | A version of 'gridLinesB' specialized to 'String'.
gridLines :: Cell a => [ColSpec] -> [Row a] -> [String]
gridLines = gridLinesB

-- | A version of 'gridLinesB' that also concatenates the lines.
gridStringB :: (Cell a, StringBuilder b) => [ColSpec] -> [Row a] -> b
gridStringB specs = concatLines . gridLinesB specs

-- | A version of 'gridStringB' specialized to 'String'.
gridString :: Cell a => [ColSpec] -> [Row a] -> String
gridString = gridStringB

concatLines :: StringBuilder b => [b] -> b
concatLines = mconcat . intersperse (charB '\n')

-- | Concatenates a row with a given amount of spaces.
concatRow
    :: StringBuilder b
    => Int
    -> Row b
    -> b
concatRow n bs = mconcat $ intersperse (replicateCharB n ' ') bs

-- | Concatenates a whole grid with the given amount of horizontal spaces
-- between columns.
concatGrid :: StringBuilder b => Int -> [Row b] -> b
concatGrid n = concatLines . fmap (concatRow n)

-------------------------------------------------------------------------------
-- Grid modification functions
-------------------------------------------------------------------------------

-- | Applies functions to given lines in a alternating fashion. This makes it
-- easy to color lines to improve readability in a row.
altLines :: [a -> b] -> [a] -> [b]
altLines = zipWith ($) . cycle

-- | Applies functions to cells in a alternating fashion for every line, every
-- other line gets shifted by one. This is useful for distinguishability of
-- single cells in a grid arrangement.
checkeredCells  :: (a -> b) -> (a -> b) -> [[a]] -> [[b]]
checkeredCells f g = zipWith altLines $ cycle [[f, g], [g, f]]

-------------------------------------------------------------------------------
-- Advanced layout
-------------------------------------------------------------------------------

-- | Create a 'RowGroup' by aligning the columns vertically. The position is
-- specified for each column.
colsG :: [Position V] -> [Col a] -> RowGroup a
colsG ps = nullableRowsG . colsAsRows ps

-- | Create a 'RowGroup' by aligning the columns vertically. Each column uses
-- the same position.
colsAllG :: Position V -> [Col a] -> RowGroup a
colsAllG p = nullableRowsG . colsAsRowsAll p

-- | Renders a table as 'StringBuilder' lines. Note that providing fewer layout
-- specifications than columns or vice versa will result in not showing the
-- redundant ones.
tableLinesB :: (Cell a, Cell r, Cell c, StringBuilder b)
            => TableSpec rowSep colSep r c a
            -> [b]
tableLinesB = fst . tableLinesBWithCMIs

-- | Renders a table as 'StringBuilder' lines, providing the 'ColModInfo' for
-- each column. Note that providing fewer layout specifications than columns or
-- vice versa will result in not showing the redundant ones.
tableLinesBWithCMIs :: forall rowSep r colSep c a b.
                       (Cell a, Cell r, Cell c, StringBuilder b)
                    => TableSpec rowSep colSep r c a
                    -> ([b], [ColModInfo])
tableLinesBWithCMIs TableSpec { tableStyle = TableStyle { .. }, ..  } =
    ( maybe id (:) optTopLine . addColHeader $ maybe id (\b -> (++[b])) optBottomLine rowGroupLines
    , cMIs
    )
  where
    -- Helpers for horizontal lines that will put layout characters around and
    -- in between a row of the pre-formatted grid.

    -- | Generate columns filled with 'sym', or blank spaces if 'sym' is of width 0.
    -- If there is a rowHeader, keep that separate.
    fakeColumns :: String -> String -> (Maybe b, Row b)
    fakeColumns headerSym groupSym =
        (replicateSym headerSym . widthCMI <$> rowHeaderCMI, map (replicateSym groupSym) colWidths)
      where
        replicateSym sym w = stimesMonoid q (stringB sym') <> stringB (take r sym')
          where
            (q, r) = w `quotRem` l
            (sym', l) = let l' = length sym in if l' == 0 then (" ", 1) else (sym, l')

    -- | Replace the content of a 'HeaderSpec' with the content of the rows or columns to be rendered,
    -- and flatten to a list of content interspersed with column/row separators. If given 'NoneHS', first
    -- replace it with the shape of the data.
    flattenWithContent (NoneHS sep) contentShape r = flattenHeader . fmap fst . zipHeader mempty r $ fullSepH sep (repeat defHeaderColSpec) contentShape
    flattenWithContent h            _            r = flattenHeader . fmap fst $ zipHeader mempty r h

    -- | Intersperse a row with its rendered separators.
    withRowSeparators :: (rowSep -> Maybe b) -> [Row b] -> [Either (Maybe b) (Row b)]
    withRowSeparators renderDelimiter = map (first renderDelimiter) . flattenWithContent rowHeader rowGroups

    -- | Intersperse a column with its rendered separators, including an optional row header.
    withColSeparators :: (colSep -> String) -> (Maybe b, Row b) -> (Maybe b, Row (Either String b))
    withColSeparators renderDelimiter = second renderRow
      where
        renderRow = map (first renderIfDrawn) . flattenWithContent colHeader columns
        columns = maybe [] rowGroupShape $ listToMaybe rowGroups
        -- Render the delimiters of a column if it is drawn, otherwise return an empty string.
        renderIfDrawn x
            -- If no delimiters are drawn in this column, return the empty string
            | null headerV && null groupV = ""
            -- If this delimiter is not drawn, but others in the column are, pad with spaces
            | null separator              = replicate (max (visibleLength headerV) (visibleLength groupV)) ' '
            -- Otherwise, just render the delimiter
            | otherwise                   = separator
          where
            separator = renderDelimiter x
            headerV   = headerC x
            groupV    = groupC  x

    -- Draw a line using the specified delimiters, but only if the horizontal string is non-null
    optDrawLine horizontal rowSep leftD rightD headerSepD colSepD = if null horizontal && null rowSep
        then Nothing
        else Just . horizontalDetailLine horizontal rowSep leftD rightD headerSepD . withColSeparators colSepD $ fakeColumns rowSep horizontal
    -- Horizontal separator lines that occur in a table.
    optTopLine        = optDrawLine realTopH realRowHeaderT realTopL realTopR bothHeadersTR realTopC
    optBottomLine     = optDrawLine groupBottomH rowHeaderB realGroupBottomL groupBottomR rowHeaderSepBC groupBottomC
    optGroupSepLine s = optDrawLine (groupSepH s) (rowHeaderC s) (realGroupSepLC s) (groupSepRC s) (rowHeaderSepC s s) (groupSepC s)
    optHeaderSepLine  = optDrawLine headerSepH bothHeadersB realHeaderSepLC headerSepRC bothHeadersBR (\x -> headerSepC x x)

    -- Vertical content lines
    rowGroupLines = concatRowGroups $ withRowSeparators optGroupSepLine linesPerRowGroup
    concatRowGroups = concatMap (either (maybe [] pure) id)
    linesPerRowGroup = map rowGroupToLines $ addRowHeader rowGroups
    rowGroupToLines :: (Maybe (HeaderColSpec, r), RowGroup a) -> [b]
    rowGroupToLines = map (horizontalContentLine realLeftV groupR rowHeaderSepV . withColSeparators groupC) . applyRowMods

    -- Optional values for the row header
    (addRowHeader, rowHeaderCMI, realLeftV, realHeaderTopL, realHeaderSepLC, realGroupSepLC, realGroupBottomL)
                  = case rowHeader of
        NoneHS _ ->
            ( map (Nothing,)
            , Nothing
            , groupL
            , headerTopL
            , headerSepLC
            , groupSepLC
            , groupBottomL
            )
        _ ->
            let attachRowHeader grps = map (\(hSpec, (grp, r)) -> (Just (hSpec, r), grp))
                                     . headerContents $ zipHeader (rowG []) grps rowHeader
                singleColCMI = Just . deriveColModInfoFromColumnLA (expand, noAlign)
            in
            ( attachRowHeader
            , singleColCMI . map snd $ headerContents rowHeader
            , rowHeaderLeftV
            , bothHeadersTL
            , bothHeadersBL
            , rowHeaderLeftC
            , rowHeaderLeftB
            )

    -- Optional values for the column header
    (addColHeader, fitHeaderIntoCMIs, realTopH, realTopL, realTopC, realTopR, realRowHeaderT)
                  = case colHeader of
        NoneHS _ ->
            ( id
            , id
            , groupTopH
            , groupTopL
            , groupTopC
            , groupTopR
            , rowHeaderT
            )
        _ ->
            let headerLine = horizontalContentLine headerL headerR bothHeadersR $ withColSeparators headerC
                               (emptyFromCMI <$> rowHeaderCMI, headerRowMods hTitles)
                headerRowMods = zipWith4 headerCellModifier
                                         headerColSpecs
                                         cMSs
                                         cMIs
                (headerColSpecs, hTitles) = unzip $ headerContents colHeader
            in
            ( (headerLine :) . maybe id (:) optHeaderSepLine
            , fitTitlesCMI hTitles posSpecs
            , headerTopH
            , realHeaderTopL
            , headerTopC
            , headerTopR
            , bothHeadersT
            )

    emptyFromCMI = spacesB . widthCMI

    cMSs      = map cutMark colSpecs
    posSpecs  = map position colSpecs
    cMIs      = fitHeaderIntoCMIs $ deriveColModInfosFromColumns colSpecs $ transposeRowGroups rowGroups
    rowMods   = zipWith3 (\p cm cmi -> (emptyFromCMI cmi, columnModifier p cm cmi)) posSpecs cMSs cMIs

    rowBody :: RowGroup a -> [[b]]
    rowBody   = mapRowGroupColumns rowMods
    colWidths = map widthCMI cMIs

    -- Apply modifiers to rows, adding row headers to the first row in the group if needed
    applyRowMods  :: (Maybe (HeaderColSpec, r), RowGroup a) -> [(Maybe b, [b])]
    applyRowMods (Just (hSpec, r), grp) | Just rCMI <- rowHeaderCMI
        = zip (header rCMI) (rowBody grp)
      where
        header cMI = fmap Just $ headerCellModifier hSpec noCutMark cMI r : repeat (emptyFromCMI cMI)
    applyRowMods (_, grp) = map (Nothing,) $ rowBody grp

-- | A version of 'tableLinesB' specialized to 'String'.
tableLines :: (Cell a, Cell r, Cell c)
           => TableSpec rowSep colSep r c a
           -> [String]
tableLines = tableLinesB

-- | A version of 'tableLinesB' that also concatenates the lines.
tableStringB :: (Cell a, Cell r, Cell c, StringBuilder b)
             => TableSpec rowSep colSep r c a
             -> b
tableStringB = concatLines . tableLinesB

-- | A version of 'tableStringB' specialized to 'String'.
tableString :: (Cell a, Cell r, Cell c)
            => TableSpec rowSep colSep r c a
            -> String
tableString = tableStringB