packages feed

cloudy-0.1.0.0: src/Cloudy/Table.hs

module Cloudy.Table where

import Control.Monad (when)
import Data.List.NonEmpty (NonEmpty ((:|)), cons, transpose)
import qualified Data.List.NonEmpty as NonEmpty
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.IO as Text
import Control.Exception (assert)

data Align = LeftJustified | Centered | RightJustified

data Table = Table
  { tableHeaders :: NonEmpty (Align, Text)
  , tableBodyRows :: NonEmpty (NonEmpty Text)
  }

printTable :: Table -> IO ()
printTable Table{tableHeaders, tableBodyRows} = do
  assertRowsSameLengths tableHeaders tableBodyRows
  let rawTable = renderTable tableHeaders tableBodyRows
  Text.putStrLn rawTable

assertRowsSameLengths :: NonEmpty a -> NonEmpty (NonEmpty b) -> IO ()
assertRowsSameLengths headers bodyRows =
  when (any (\row -> length headers /= length row) bodyRows) $
    error "Body row does not contain same number of elements as header row"

renderTable :: NonEmpty (Align, Text) -> NonEmpty (NonEmpty Text) -> Text
renderTable headers body =
  let headerTexts = fmap snd headers
      maxWidths = getMaxWidths $ cons headerTexts body
      maxAlignWidths = NonEmpty.zip (fmap fst headers) maxWidths
      rawHeaders = renderHeaders maxWidths headerTexts
      rawBody = renderBody maxAlignWidths body
      fatDiv = renderDiv True maxWidths "="
      skinnyDiv = renderDiv False maxWidths "-"
  in
  Text.intercalate
    "\n"
    [ skinnyDiv
    , rawHeaders
    , fatDiv
    , rawBody
    , skinnyDiv
    ]

renderDiv :: Bool -> NonEmpty Int -> Text -> Text
renderDiv shouldUseHorizontalDivs maxWidths c =
  let divider = if shouldUseHorizontalDivs then c <> "|" <> c else c <> c <> c
      rowMiddle = Text.intercalate divider . NonEmpty.toList $ fmap (\width -> Text.replicate width c) maxWidths
  in "|" <> c <> rowMiddle <> c <> "|"

renderHeaders :: NonEmpty Int -> NonEmpty Text -> Text
renderHeaders maxWidths headers =
  renderRow (zipThreeNE (Centered :| repeat Centered) maxWidths headers)

zipThreeNE :: NonEmpty a -> NonEmpty b -> NonEmpty c -> NonEmpty (a, b, c)
zipThreeNE (a :| as) (b :| bs) (c :| cs) = (a,b,c) :| zipThree as bs cs

zipThree :: [a] -> [b] -> [c] -> [(a,b,c)]
zipThree = \cases
  [] _ _ -> []
  _ [] _ -> []
  _ _ [] -> []
  (a : as) (b : bs) (c : cs) -> (a,b,c) : zipThree as bs cs

renderBody :: NonEmpty (Align, Int) -> NonEmpty (NonEmpty Text) -> Text
renderBody maxAlignWidths body =
  Text.intercalate "\n" $ NonEmpty.toList $ fmap (renderRow . zipOneMore maxAlignWidths) body

zipOneMore :: NonEmpty (a, b) -> NonEmpty c -> NonEmpty (a, b, c)
zipOneMore = NonEmpty.zipWith (\(a, b) c -> (a, b, c))

renderRow :: NonEmpty (Align, Int, Text) -> Text
renderRow columnInfo =
  let rowMiddle = Text.intercalate " | " . NonEmpty.toList $ fmap renderColumn columnInfo
  in "| " <> rowMiddle <> " |"

renderColumn :: (Align, Int, Text) -> Text
renderColumn (align, maxWidth, t) =
  let textWidth = Text.length t
      remainingLength = maxWidth - textWidth
      paddedText =
        case align of
          LeftJustified -> t <> Text.replicate remainingLength " "
          Centered ->
            let (surroundingLength, extra) = quotRem remainingLength 2
                leftSpace = Text.replicate surroundingLength " "
                rightSpace = Text.replicate (surroundingLength + extra) " "
            in leftSpace <> t <> rightSpace
          RightJustified -> Text.replicate remainingLength " " <> t
  in assert (remainingLength >= 0) paddedText

getMaxWidths :: NonEmpty (NonEmpty Text) -> NonEmpty Int
getMaxWidths = fmap (maximum . fmap Text.length) . transpose