packages feed

brick-1.6: src/Brick/Widgets/Table.hs

{-# LANGUAGE MultiWayIf #-}
-- | This module provides a table widget that can draw other widgets
-- in a table layout, draw borders between rows and columns, and allow
-- configuration of row and column alignment. To get started, see
-- 'table'.
module Brick.Widgets.Table
  (
  -- * Types
    Table
  , ColumnAlignment(..)
  , RowAlignment(..)
  , TableException(..)

  -- * Construction
  , table

  -- * Configuration
  , alignLeft
  , alignRight
  , alignCenter
  , alignTop
  , alignMiddle
  , alignBottom
  , setColAlignment
  , setRowAlignment
  , setDefaultColAlignment
  , setDefaultRowAlignment
  , surroundingBorder
  , rowBorders
  , columnBorders

  -- * Rendering
  , renderTable

  -- * Low-level API
  , RenderedTableCells(..)
  , BorderConfiguration(..)
  , tableCellLayout
  , addBorders
  , alignColumns
  )
where

import Control.Monad (forM)
import qualified Control.Exception as E
import Data.List (transpose, intersperse, nub)
import qualified Data.Map as M
#if !(MIN_VERSION_base(4,11,0))
import Data.Monoid ((<>))
#endif
import Graphics.Vty (imageHeight, imageWidth, charFill)
import Lens.Micro ((^.))

import Brick.Types
import Brick.Widgets.Core
import Brick.Widgets.Center
import Brick.Widgets.Border

-- | Column alignment modes. Use these modes with the alignment
-- functions in this module to configure column alignment behavior.
data ColumnAlignment =
    AlignLeft
    -- ^ Align all cells to the left.
    | AlignCenter
    -- ^ Center the content horizontally in all cells in the column.
    | AlignRight
    -- ^ Align all cells to the right.
    deriving (Eq, Show, Read)

-- | Row alignment modes. Use these modes with the alignment functions
-- in this module to configure row alignment behavior.
data RowAlignment =
    AlignTop
    -- ^ Align all cells to the top.
    | AlignMiddle
    -- ^ Center the content vertically in all cells in the row.
    | AlignBottom
    -- ^ Align all cells to the bottom.
    deriving (Eq, Show, Read)

-- | A table creation exception.
data TableException =
    TEUnequalRowSizes
    -- ^ Rows did not all have the same number of cells.
    | TEInvalidCellSizePolicy
    -- ^ Some cells in the table did not use the 'Fixed' size policy for
    -- both horizontal and vertical sizing.
    deriving (Eq, Show, Read)

instance E.Exception TableException where

-- | A table data structure for widgets of type 'Widget' @n@. Create a
-- table with 'table'.
data Table n =
    Table { columnAlignments :: M.Map Int ColumnAlignment
          , rowAlignments :: M.Map Int RowAlignment
          , tableRows :: [[Widget n]]
          , defaultColumnAlignment :: ColumnAlignment
          , defaultRowAlignment :: RowAlignment
          , tableBorderConfiguration :: BorderConfiguration
          }

-- | A border configuration for a table.
data BorderConfiguration =
    BorderConfiguration { drawSurroundingBorder :: Bool
                        , drawRowBorders :: Bool
                        , drawColumnBorders :: Bool
                        }

-- | Construct a new table.
--
-- The argument is the list of rows with the topmost row first, with
-- each element of the argument list being the contents of the cells in
-- in each column of the respective row, with the leftmost cell first.
--
-- Each row's height is determined by the height of the tallest cell
-- in that row, and each column's width is determined by the width of
-- the widest cell in that column. This means that control over row
-- and column dimensions is a matter of controlling the size of the
-- individual cells, such as by wrapping cell contents in padding,
-- 'fill' and 'hLimit' or 'vLimit', etc. This also means that it is not
-- necessary to explicitly set the width of most table cells because
-- the table will determine the per-row and per-column dimensions by
-- looking at the largest cell contents. In particular, this means
-- that the table's alignment logic only has an effect when a given
-- cell's contents are smaller than the maximum for its row and column,
-- thus giving the table some way to pad the contents to result in the
-- desired alignment.
--
-- By default:
--
-- * All columns are left-aligned. Use the alignment functions in this
-- module to change that behavior.
-- * All rows are top-aligned. Use the alignment functions in this
-- module to change that behavior.
-- * The table will draw borders between columns, between rows, and
-- around the outside of the table. Border-drawing behavior can be
-- configured with the API in this module. Note that tables always draw
-- with 'joinBorders' enabled. If a cell's contents has smart borders
-- but you don't want those borders to connect to the surrounding table
-- borders, wrap the cell's contents with 'freezeBorders'.
--
-- All cells of all rows MUST use the 'Fixed' growth policy for both
-- horizontal and vertical growth. If the argument list contains any
-- cells that use the 'Greedy' policy, this function will raise a
-- 'TableException'.
--
-- All rows MUST have the same number of cells. If not, this function
-- will raise a 'TableException'.
table :: [[Widget n]] -> Table n
table rows =
    if | not allFixed      -> E.throw TEInvalidCellSizePolicy
       | not allSameLength -> E.throw TEUnequalRowSizes
       | otherwise         -> t
    where
        allSameLength = length (nub (length <$> rows)) <= 1
        allFixed = all fixedRow rows
        fixedRow = all fixedCell
        fixedCell w = hSize w == Fixed && vSize w == Fixed
        t = Table { columnAlignments = mempty
                  , rowAlignments = mempty
                  , tableRows = rows
                  , defaultColumnAlignment = AlignLeft
                  , defaultRowAlignment = AlignTop
                  , tableBorderConfiguration =
                      BorderConfiguration { drawSurroundingBorder = True
                                          , drawRowBorders = True
                                          , drawColumnBorders = True
                                          }
                  }

-- | Configure whether the table draws a border on its exterior.
surroundingBorder :: Bool -> Table n -> Table n
surroundingBorder b t =
    t { tableBorderConfiguration = (tableBorderConfiguration t) { drawSurroundingBorder = b } }

-- | Configure whether the table draws borders between its rows.
rowBorders :: Bool -> Table n -> Table n
rowBorders b t =
    t { tableBorderConfiguration = (tableBorderConfiguration t) { drawRowBorders = b } }

-- | Configure whether the table draws borders between its columns.
columnBorders :: Bool -> Table n -> Table n
columnBorders b t =
    t { tableBorderConfiguration = (tableBorderConfiguration t) { drawColumnBorders = b } }

-- | Align the specified column to the right. The argument is the column
-- index, starting with zero. Silently does nothing if the index is out
-- of range.
alignRight :: Int -> Table n -> Table n
alignRight = setColAlignment AlignRight

-- | Align the specified column to the left. The argument is the column
-- index, starting with zero. Silently does nothing if the index is out
-- of range.
alignLeft :: Int -> Table n -> Table n
alignLeft = setColAlignment AlignLeft

-- | Align the specified column to center. The argument is the column
-- index, starting with zero. Silently does nothing if the index is out
-- of range.
alignCenter :: Int -> Table n -> Table n
alignCenter = setColAlignment AlignCenter

-- | Align the specified row to the top. The argument is the row index,
-- starting with zero. Silently does nothing if the index is out of
-- range.
alignTop :: Int -> Table n -> Table n
alignTop = setRowAlignment AlignTop

-- | Align the specified row to the middle. The argument is the row
-- index, starting with zero. Silently does nothing if the index is out
-- of range.
alignMiddle :: Int -> Table n -> Table n
alignMiddle = setRowAlignment AlignMiddle

-- | Align the specified row to bottom. The argument is the row index,
-- starting with zero. Silently does nothing if the index is out of
-- range.
alignBottom :: Int -> Table n -> Table n
alignBottom = setRowAlignment AlignBottom

-- | Set the alignment for the specified column index (starting at
-- zero). Silently does nothing if the index is out of range.
setColAlignment :: ColumnAlignment -> Int -> Table n -> Table n
setColAlignment a col t =
    t { columnAlignments = M.insert col a (columnAlignments t) }

-- | Set the alignment for the specified row index (starting at
-- zero). Silently does nothing if the index is out of range.
setRowAlignment :: RowAlignment -> Int -> Table n -> Table n
setRowAlignment a row t =
    t { rowAlignments = M.insert row a (rowAlignments t) }

-- | Set the default column alignment for columns with no explicitly
-- configured alignment.
setDefaultColAlignment :: ColumnAlignment -> Table n -> Table n
setDefaultColAlignment a t =
    t { defaultColumnAlignment = a }

-- | Set the default row alignment for rows with no explicitly
-- configured alignment.
setDefaultRowAlignment :: RowAlignment -> Table n -> Table n
setDefaultRowAlignment a t =
    t { defaultRowAlignment = a }

-- | Render the table.
renderTable :: Table n -> Widget n
renderTable t =
    joinBorders $
    Widget Fixed Fixed $ do
        tableCellLayout t >>= addBorders >>= render

-- | The result of performing table cell intermediate rendering and
-- layout.
data RenderedTableCells n =
    RenderedTableCells { renderedTableRows :: [[Widget n]]
                       -- ^ The table's cells in row-major order.
                       , renderedTableColumnWidths :: [Int]
                       -- ^ The widths of the table's columns.
                       , renderedTableRowHeights :: [Int]
                       -- ^ The heights of the table's rows.
                       , borderConfiguration :: BorderConfiguration
                       -- ^ The border configuration to use.
                       }

-- | Augment rendered table cells with borders according to the
-- border configuration accompanying the cells.
addBorders :: RenderedTableCells n -> RenderM n (Widget n)
addBorders r = do
    let cfg = borderConfiguration r
        rows = renderedTableRows r
        rowHeights = renderedTableRowHeights r
        colWidths = renderedTableColumnWidths r

        contentWidth = sum colWidths
        contentHeight = sum rowHeights

        hBorderLength = contentWidth + if drawColumnBorders cfg
                                       then max (length colWidths - 1) 0
                                       else 0
        vBorderHeight = contentHeight + if drawRowBorders cfg
                                        then max (length rowHeights - 1) 0
                                        else 0
        horizBorder = hLimit hBorderLength hBorder
        vertBorder = vLimit vBorderHeight vBorder

        leftBorder =
            vBox [topLeftCorner, vertBorder, bottomLeftCorner]
        rightBorder =
            vBox [topRightCorner, vertBorder, bottomRightCorner]

        maybeWrap check f =
            if check cfg then f else id
        addSurroundingBorder b =
            leftBorder <+> (horizBorder <=> b <=> horizBorder) <+> rightBorder
        addRowBorders =
            intersperse horizBorder

        rowsWithColumnBorders = (\(h, row) -> hBox $ maybeColumnBorders h row) <$> zip rowHeights rows
        maybeColumnBorders height = maybeIntersperse cfg drawColumnBorders (vLimit height vBorder)
        body = vBox $
               maybeWrap drawRowBorders addRowBorders rowsWithColumnBorders

    return $ maybeWrap drawSurroundingBorder addSurroundingBorder body

tableCellLayout :: Table n -> RenderM n (RenderedTableCells n)
tableCellLayout t = do
    ctx <- getContext
    cellResults <- forM (tableRows t) $ mapM render

    let rowHeights = rowHeight <$> cellResults
        colWidths = colWidth <$> transpose cellResults
        numRows = length rowHeights
        numCols = if length cellResults >= 1
                     then length (cellResults !! 0)
                     else 0
        allRowAligns = (\i -> M.findWithDefault (defaultRowAlignment t) i (rowAlignments t)) <$>
                       [0..numRows - 1]
        allColAligns = (\i -> M.findWithDefault (defaultColumnAlignment t) i (columnAlignments t)) <$>
                       [0..numCols - 1]
        rowHeight = maximum . fmap (imageHeight . image)
        colWidth = maximum . fmap (imageWidth . image)

        toW = Widget Fixed Fixed . return
        fillEmptyCell w h result =
            if imageWidth (image result) == 0 && imageHeight (image result) == 0
            then result { image = charFill (ctx^.attrL) ' ' w h }
            else result
        mkRow (vAlign, height, rowCells) =
            let paddedCells = flip map (zip3 allColAligns colWidths rowCells) $ \(hAlign, width, cell) ->
                    applyColAlignment width hAlign $
                    applyRowAlignment height vAlign $
                    toW $
                    fillEmptyCell width height cell
            in paddedCells

    let rows = mkRow <$> zip3 allRowAligns rowHeights cellResults

    return $ RenderedTableCells { renderedTableRows = rows
                                , renderedTableColumnWidths = colWidths
                                , renderedTableRowHeights = rowHeights
                                , borderConfiguration = tableBorderConfiguration t
                                }

maybeIntersperse :: BorderConfiguration -> (BorderConfiguration -> Bool) -> Widget n -> [Widget n] -> [Widget n]
maybeIntersperse cfg f v | f cfg = intersperse v
                         | otherwise = id

topLeftCorner :: Widget n
topLeftCorner = joinableBorder $ Edges False True False True

topRightCorner :: Widget n
topRightCorner = joinableBorder $ Edges False True True False

bottomLeftCorner :: Widget n
bottomLeftCorner = joinableBorder $ Edges True False False True

bottomRightCorner :: Widget n
bottomRightCorner = joinableBorder $ Edges True False True False

-- | Given a "table row" of widgets, align each one according to the
-- list of specified column alignments in columns of the specified
-- widths.
alignColumns :: [ColumnAlignment]
             -- ^ The column alignments to use for each widget,
             -- respectively.
             -> [Int]
             -- ^ The width of each column in terminal columns,
             -- respectively.
             -> [Widget n]
             -- ^ The column cells to align.
             -> [Widget n]
alignColumns as widths cells =
    (\(w, a, c) -> applyColAlignment w a c) <$> zip3 widths as cells

applyColAlignment :: Int -> ColumnAlignment -> Widget n -> Widget n
applyColAlignment width align w =
    hLimit width $ case align of
        AlignLeft   -> padRight Max w
        AlignCenter -> hCenter w
        AlignRight  -> padLeft Max w

applyRowAlignment :: Int -> RowAlignment -> Widget n -> Widget n
applyRowAlignment rHeight align w =
    vLimit rHeight $ case align of
        AlignTop    -> padBottom Max w
        AlignMiddle -> vCenter w
        AlignBottom -> padTop Max w