layoutz-0.1.0.0: Layoutz.hs
{-# LANGUAGE OverloadedStrings, ExistentialQuantification, FlexibleInstances #-}
{- |
Module : Layoutz
Description : Friendly, expressive print-layout DSL for Haskell
Copyright : (c) 2025 Matthieu Court
License : Apache-2.0
A simple Haskell port of the layoutz library for creating structured terminal layouts.
-}
module Layoutz
( -- * Core Types
Element(..)
, Border(..)
, L
, Tree(..)
-- * Basic Elements
, layout
, text
, br
-- * Layout Functions
, center, center'
, row
, underline, underline'
-- * Containers
, box, box'
, statusCard, statusCard'
-- * Widgets
, ul
, inlineBar
, table, table'
, section, section', section''
, kv
, tree, leaf, branch
-- * Visual Elements
, margin, marginError, marginWarn, marginSuccess, marginInfo
, hr, hr', hr''
, pad
, chart
-- * Rendering
, render
) where
import Data.List (intercalate, transpose)
import Text.Printf (printf)
-- Core Element typeclass
class Element a where
renderElement :: a -> String
-- Calculate element width (longest line)
width :: a -> Int
width element =
let rendered = renderElement element
renderedLines = lines rendered
in if null renderedLines then 0
else maximum $ 0 : map length renderedLines
-- Calculate element height (number of lines)
height :: a -> Int
height element =
let rendered = renderElement element
in if null rendered then 1
else length (lines rendered)
render :: Element a => a -> String
render = renderElement
-- | L is the universal layout element type - a type-erased wrapper for the DSL.
--
-- This allows mixing different element types in layouts while providing a common interface.
-- Uses existential quantification to store any Element type inside L.
--
-- Constructors:
-- * L a - Wraps any Element (Text, Box, Table, etc.)
-- * UL [L] - Special case for unordered lists (allows nesting)
-- * AutoCenter L - Smart centering that adapts to layout context width
--
-- Example usage:
-- layout [text "title", box "content" [...], center (text "footer")]
-- All different types unified as L, so they can be composed together.
data L = forall a. Element a => L a | UL [L] | AutoCenter L
instance Element L where
renderElement (L x) = render x
renderElement (UL items) = render (UnorderedList items)
renderElement (AutoCenter element) = render element -- Will be handled by Layout
width (L x) = width x
width (UL items) = width (UnorderedList items)
width (AutoCenter element) = width element
height (L x) = height x
height (UL items) = height (UnorderedList items)
height (AutoCenter element) = height element
instance Show L where
show = render
-- Border styles
data Border = NormalBorder | DoubleBorder | ThickBorder | RoundBorder
deriving (Show, Eq)
borderChars :: Border -> (String, String, String, String, String, String, String, String, String)
borderChars NormalBorder = ("┌", "┐", "└", "┘", "─", "│", "├", "┤", "┼")
borderChars DoubleBorder = ("╔", "╗", "╚", "╝", "═", "║", "╠", "╣", "╬")
borderChars ThickBorder = ("┏", "┓", "┗", "┛", "━", "┃", "┣", "┫", "╋")
borderChars RoundBorder = ("╭", "╮", "╰", "╯", "─", "│", "├", "┤", "┼")
-- Elements
newtype Text = Text String
instance Element Text where renderElement (Text s) = s
newtype LineBreak = LineBreak ()
instance Element LineBreak where renderElement _ = ""
data Layout = Layout [L]
instance Element Layout where
renderElement (Layout elements) =
let -- Calculate max width of all non-AutoCenter elements
nonAutoCenterElements = [e | e <- elements, not (isAutoCenter e)]
maxWidth = if null nonAutoCenterElements
then 80 -- fallback
else maximum (0 : map width nonAutoCenterElements)
-- Render elements, providing context width to AutoCenter elements
renderedElements = map (renderWithContext maxWidth) elements
in intercalate "\n" renderedElements
where
isAutoCenter (AutoCenter _) = True
isAutoCenter _ = False
renderWithContext contextWidth (AutoCenter element) =
render (Centered (render element) contextWidth)
renderWithContext _ element = render element
-- | Centered element with custom width
data Centered = Centered String Int -- content, target_width
instance Element Centered where
renderElement (Centered content targetWidth) =
let contentLines = lines content
in intercalate "\n" $ map centerLine contentLines
where
centerLine line =
let lineLength = length line
in if lineLength >= targetWidth
then line
else let totalPadding = targetWidth - lineLength
leftPadding = totalPadding `div` 2
rightPadding = totalPadding - leftPadding
in replicate leftPadding ' ' ++ line ++ replicate rightPadding ' '
-- | Underlined element with custom character
data Underlined = Underlined String String -- content, underline_char
instance Element Underlined where
renderElement (Underlined content underlineChar) =
let contentLines = lines content
maxWidth = if null contentLines then 0
else maximum (map length contentLines)
underlinePattern = underlineChar
underline = if length underlinePattern >= maxWidth
then take maxWidth underlinePattern
else let repeats = maxWidth `div` length underlinePattern
remainder = maxWidth `mod` length underlinePattern
in concat (replicate repeats underlinePattern) ++ take remainder underlinePattern
in content ++ "\n" ++ underline
data Row = Row [L]
instance Element Row where
renderElement (Row elements) =
if null elements then ""
else let elementStrings = map render elements
elementLines = map lines elementStrings
maxHeight = maximum (map length elementLines)
elementWidths = map (maximum . map length) elementLines
-- Pad each element to maxHeight and proper width, then align at top
paddedElements = zipWith (padElementToSize maxHeight) elementWidths elementLines
-- Transpose to get rows, then join each row with spaces
in intercalate "\n" $ map (intercalate " ") (transpose paddedElements)
where
padElementToSize maxH width linesList =
let currentLines = linesList ++ replicate (maxH - length linesList) ""
paddedLines = map (\line -> line ++ replicate (max 0 (width - length line)) ' ') currentLines
in paddedLines
data Box = Box String [L] Border
instance Element Box where
renderElement (Box title elements border) =
let elementStrings = map render elements
content = intercalate "\n" elementStrings
contentLines = if null content then [""] else lines content
contentWidth = if null contentLines then 0 else maximum (map length contentLines)
titleWidth = if null title then 0 else length title + 2
innerWidth = max contentWidth titleWidth
totalWidth = innerWidth + 4
(topLeft, topRight, bottomLeft, bottomRight, horizontal, vertical, _, _, _) = borderChars border
topBorder = if null title
then topLeft ++ replicate (totalWidth - 2) (head horizontal) ++ topRight
else let titlePadding = totalWidth - length title - 2
leftPad = titlePadding `div` 2
rightPad = titlePadding - leftPad
in topLeft ++ replicate leftPad (head horizontal) ++ title ++ replicate rightPad (head horizontal) ++ topRight
bottomBorder = bottomLeft ++ replicate (totalWidth - 2) (head horizontal) ++ bottomRight
paddedContent = map (\line ->
vertical ++ " " ++ line ++ replicate (innerWidth - length line) ' ' ++ " " ++ vertical) contentLines
in intercalate "\n" (topBorder : paddedContent ++ [bottomBorder])
data StatusCard = StatusCard String String Border
instance Element StatusCard where
renderElement (StatusCard label content border) =
let labelLines = lines label
contentLines = lines content
allLines = labelLines ++ contentLines
maxWidth = if null allLines then 0 else maximum (map length allLines)
contentWidth = maxWidth + 2
(topLeft, topRight, bottomLeft, bottomRight, horizontal, vertical, _, _, _) = borderChars border
topBorder = topLeft ++ replicate (contentWidth + 2) (head horizontal) ++ topRight
bottomBorder = bottomLeft ++ replicate (contentWidth + 2) (head horizontal) ++ bottomRight
createCardLines ls = map (\line ->
vertical ++ " " ++ line ++ replicate (contentWidth - length line) ' ' ++ " " ++ vertical) ls
labelCardLines = createCardLines labelLines
contentCardLines = createCardLines contentLines
in intercalate "\n" ([topBorder] ++ labelCardLines ++ contentCardLines ++ [bottomBorder])
-- | Margin element that adds prefix to each line
data Margin = Margin String [L] -- prefix, elements
instance Element Margin where
renderElement (Margin prefix elements) =
let content = if length elements == 1
then render (head elements)
else render (Layout elements)
contentLines = lines content
in intercalate "\n" $ map (\line -> prefix ++ " " ++ line) contentLines
-- | Horizontal rule with custom character and width
data HorizontalRule = HorizontalRule String Int -- char, width
instance Element HorizontalRule where
renderElement (HorizontalRule char width) = concat (replicate width char)
-- | Padded element with padding around all sides
data Padded = Padded String Int -- content, padding
instance Element Padded where
renderElement (Padded content padding) =
let contentLines = lines content
maxWidth = if null contentLines then 0 else maximum (0 : map length contentLines)
horizontalPad = replicate padding ' '
verticalPad = replicate (maxWidth + padding * 2) ' '
paddedLines = map (\line -> horizontalPad ++ line ++ replicate (maxWidth - length line) ' ' ++ horizontalPad) contentLines
verticalLines = replicate padding verticalPad
in intercalate "\n" (verticalLines ++ paddedLines ++ verticalLines)
-- | Chart for data visualization
data Chart = Chart [(String, Double)] -- (label, value) pairs
instance Element Chart where
renderElement (Chart dataPoints) =
if null dataPoints then "No data"
else let maxValue = maximum (0 : map snd dataPoints)
maxLabelWidth = minimum [15, maximum (0 : map (length . fst) dataPoints)]
chartWidth = 40
in intercalate "\n" $ map (renderBar maxValue maxLabelWidth chartWidth) dataPoints
where
renderBar maxVal labelWidth barWidth (label, value) =
let truncatedLabel = if length label > labelWidth
then take (labelWidth - 3) label ++ "..."
else label
paddedLabel = truncatedLabel ++ replicate (labelWidth - length truncatedLabel) ' '
percentage = value / maxVal
barLength = floor (percentage * fromIntegral barWidth)
bar = replicate barLength '█'
emptyBar = replicate (barWidth - barLength) '─'
valueStr = if value == fromInteger (round value)
then show (round value)
else printf "%.1f" value
in paddedLabel ++ " │" ++ bar ++ emptyBar ++ "│ " ++ valueStr
-- | Table with headers and borders (fixed alignment)
data Table = Table [String] [[L]] Border -- headers, rows, border
instance Element Table where
renderElement (Table headers rows border) =
let normalizedRows = map (normalizeRow (length headers)) rows
columnWidths = calculateColumnWidths headers normalizedRows
(topLeft, topRight, bottomLeft, bottomRight, horizontal, vertical, leftTee, rightTee, cross) = borderChars border
-- Calculate actual table width based on content and separators
totalContentWidth = sum columnWidths
totalSeparatorWidth = (length columnWidths - 1) * 3 -- " | " between columns
totalWidth = totalContentWidth + totalSeparatorWidth + 4 -- 4 for outer borders and padding
hChar = head horizontal
-- Fixed border construction with proper connectors
topConnector = case border of
RoundBorder -> "┬"
NormalBorder -> "┬"
DoubleBorder -> "╦"
ThickBorder -> "┳"
topParts = map (\w -> replicate w hChar) columnWidths
topBorder = topLeft ++ [hChar] ++ intercalate ([hChar] ++ topConnector ++ [hChar]) topParts ++ [hChar] ++ topRight
-- Create proper separator with tee connectors
separatorParts = map (\w -> replicate w hChar) columnWidths
separatorBorder = leftTee ++ [hChar] ++ intercalate ([hChar] ++ cross ++ [hChar]) separatorParts ++ [hChar] ++ rightTee
-- Create proper bottom border with bottom connectors
bottomConnector = case border of
RoundBorder -> "┴" -- Special case for round borders
NormalBorder -> "┴"
DoubleBorder -> "╩"
ThickBorder -> "┻"
bottomParts = map (\w -> replicate w hChar) columnWidths
bottomBorder = bottomLeft ++ [hChar] ++ intercalate ([hChar] ++ bottomConnector ++ [hChar]) bottomParts ++ [hChar] ++ bottomRight
-- Create header row
headerCells = zipWith padToWidth columnWidths headers
headerRow = vertical ++ " " ++ intercalate (" " ++ vertical ++ " ") headerCells ++ " " ++ vertical
-- Create data rows
dataRows = concatMap (renderTableRow columnWidths vertical) normalizedRows
in intercalate "\n" ([topBorder, headerRow, separatorBorder] ++ dataRows ++ [bottomBorder])
where
normalizeRow expectedLen row =
let currentLen = length row
in if currentLen >= expectedLen
then take expectedLen row
else row ++ replicate (expectedLen - currentLen) (text "")
calculateColumnWidths hdrs rws =
let headerWidths = map length hdrs
rowWidths = map (map (safeMaxWidth . lines . render)) rws
allWidths = headerWidths : rowWidths
in map (maximum . (0:)) (transpose allWidths)
where
safeMaxWidth [] = 0
safeMaxWidth linesList = maximum (0 : map length linesList)
padToWidth width str = str ++ replicate (max 0 (width - length str)) ' '
renderTableRow widths vChars row =
let cellContents = map render row
cellLines = map lines cellContents
maxCellHeight = if null cellLines then 1 else maximum (1 : map length cellLines)
paddedCells = zipWith (padCellToSize maxCellHeight) widths cellLines
tableRows = [[paddedCells !! j !! i | j <- [0..length paddedCells - 1]] | i <- [0..maxCellHeight - 1]]
in map (\rowCells -> vChars ++ " " ++ intercalate (" " ++ vChars ++ " ") rowCells ++ " " ++ vChars) tableRows
padCellToSize height width cellLines =
let paddedLines = cellLines ++ replicate (height - length cellLines) ""
in map (\line -> line ++ replicate (max 0 (width - length line)) ' ') paddedLines
-- | Section with decorative header
data Section = Section String [L] String Int -- title, content, glyph, flanking_chars
instance Element Section where
renderElement (Section title content glyph flankingChars) =
let header = replicate flankingChars (head glyph) ++ " " ++ title ++ " " ++ replicate flankingChars (head glyph)
body = render (Layout content)
in header ++ "\n" ++ body
-- | Key-value pairs with alignment
data KeyValue = KeyValue [(String, String)]
instance Element KeyValue where
renderElement (KeyValue pairs) =
if null pairs then ""
else let maxKeyLength = maximum (map (length . fst) pairs)
alignmentPosition = maxKeyLength + 2
in intercalate "\n" $ map (renderPair alignmentPosition) pairs
where
renderPair alignPos (key, value) =
let keyWithColon = key ++ ":"
spacesNeeded = alignPos - length keyWithColon
padding = replicate (max 1 spacesNeeded) ' '
in keyWithColon ++ padding ++ value
-- | Tree structure for hierarchical data
data Tree = Tree String [Tree]
instance Element Tree where
renderElement tree = renderTree tree "" True []
where
renderTree (Tree name children) prefix isLast parentPrefixes =
let nodeLine = if null parentPrefixes
then name
else prefix ++ (if isLast then "└── " else "├── ") ++ name
childPrefix = if null parentPrefixes
then ""
else prefix ++ (if isLast then " " else "│ ")
childLines = zipWith (\child idx ->
renderTree child childPrefix (idx == length children - 1) (parentPrefixes ++ [not isLast])
) children [0..]
in if null children
then nodeLine
else nodeLine ++ "\n" ++ intercalate "\n" childLines
isUnorderedList :: L -> Bool
isUnorderedList (UL _) = True
isUnorderedList _ = False
getUnorderedListItems :: L -> [L]
getUnorderedListItems (UL items) = items
getUnorderedListItems _ = []
newtype UnorderedList = UnorderedList [L]
instance Element UnorderedList where
renderElement (UnorderedList items) = renderAtLevel 0 items
where
bulletStyles = ["•", "◦", "▪"]
renderAtLevel level itemList =
let currentBullet = bulletStyles !! (level `mod` length bulletStyles)
indent = replicate (level * 2) ' '
in intercalate "\n" $ map (renderItem level indent currentBullet) itemList
renderItem level indent bullet item =
if isUnorderedList item
then renderAtLevel (level + 1) (getUnorderedListItems item)
else
let content = render item
contentLines = lines content
in case contentLines of
[singleLine] -> indent ++ bullet ++ " " ++ singleLine
(firstLine:restLines) ->
let firstOutput = indent ++ bullet ++ " " ++ firstLine
restIndent = replicate (length indent + length bullet + 1) ' '
restOutput = map (restIndent ++) restLines
in intercalate "\n" (firstOutput : restOutput)
[] -> indent ++ bullet ++ " "
data InlineBar = InlineBar String Double
instance Element InlineBar where
renderElement (InlineBar label progress) =
let clampedProgress = max 0.0 (min 1.0 progress)
barWidth = 20
filledSegments = floor (clampedProgress * fromIntegral barWidth)
emptySegments = barWidth - filledSegments
bar = replicate filledSegments '█' ++ replicate emptySegments '─'
percentage = floor (clampedProgress * 100) :: Int
in printf "%s [%s] %d%%" label bar percentage
-- Smart constructors and automatic conversions
text :: String -> L
text s = L (Text s)
br :: L
br = L (LineBreak ())
center :: Element a => a -> L
center element = AutoCenter (L element)
-- | Center element within specified width
center' :: Element a => Int -> a -> L
center' width element = L (Centered (render element) width)
underline :: Element a => a -> L
underline element = L (Underlined (render element) "─")
-- | Add underline with custom character
underline' :: Element a => String -> a -> L
underline' char element = L (Underlined (render element) char)
ul :: [L] -> L
ul items = UL items
inlineBar :: String -> Double -> L
inlineBar label progress = L (InlineBar label progress)
statusCard :: String -> String -> L
statusCard label content = L (StatusCard label content NormalBorder)
-- | Status card with custom border
statusCard' :: Border -> String -> String -> L
statusCard' border label content = L (StatusCard label content border)
layout :: [L] -> L
layout elements = L (Layout elements)
row :: [L] -> L
row elements = L (Row elements)
box :: String -> [L] -> L
box title elements = L (Box title elements NormalBorder)
-- | Box with custom border
box' :: Border -> String -> [L] -> L
box' border title elements = L (Box title elements border)
-- | Create margin with custom prefix
margin :: String -> [L] -> L
margin prefix elements = L (Margin prefix elements)
-- | Predefined status margins
marginError, marginWarn, marginSuccess, marginInfo :: [L] -> L
marginError elements = L (Margin "[error]" elements)
marginWarn elements = L (Margin "[warn]" elements)
marginSuccess elements = L (Margin "[success]" elements)
marginInfo elements = L (Margin "[info]" elements)
-- | Horizontal rule with default character and width
hr :: L
hr = L (HorizontalRule "─" 50)
-- | Horizontal rule with custom character
hr' :: String -> L
hr' char = L (HorizontalRule char 50)
-- | Horizontal rule with custom character and width
hr'' :: String -> Int -> L
hr'' char width = L (HorizontalRule char width)
-- | Add padding around element
pad :: Element a => Int -> a -> L
pad padding element = L (Padded (render element) padding)
-- | Create horizontal bar chart
chart :: [(String, Double)] -> L
chart dataPoints = L (Chart dataPoints)
-- | Create table with headers and rows
table :: [String] -> [[L]] -> L
table headers rows = L (Table headers rows NormalBorder)
-- | Create table with custom border
table' :: Border -> [String] -> [[L]] -> L
table' border headers rows = L (Table headers rows border)
-- | Create section with title and content
section :: String -> [L] -> L
section title content = L (Section title content "=" 3)
-- | Create section with custom glyph
section' :: String -> String -> [L] -> L
section' glyph title content = L (Section title content glyph 3)
-- | Create section with custom glyph and flanking chars
section'' :: String -> String -> Int -> [L] -> L
section'' glyph title flanking content = L (Section title content glyph flanking)
-- | Create key-value pairs
kv :: [(String, String)] -> L
kv pairs = L (KeyValue pairs)
-- | Create tree structure
tree :: String -> [Tree] -> L
tree name children = L (Tree name children)
-- | Create leaf tree node (no children)
leaf :: String -> Tree
leaf name = Tree name []
-- | Create branch tree node with children
branch :: String -> [Tree] -> Tree
branch name children = Tree name children