layoutz
Simple, beautiful CLI output
https://github.com/mattlianje/layoutz
| Stackage Nightly 2026-07-13: | 0.4.0.0 |
| Latest on Hackage: | 0.4.0.0 |
layoutz-0.4.0.0@sha256:fb701aaec8fa57a20e943e33826de05e12e0b9e1cc47957ac7e639592ac0e187,2442Module documentation for 0.4.0.0
layoutz
Simple, beautiful CLI output ๐ชถ
A lightweight, zero-dep lib to build compositional ANSI strings, terminal plots, and interactive Elm-style TUIโs in pure Haskell.
Features
- Pure Haskell, zero-dependencies (use
Layoutz.hslike a header file) - Elm-style TUIs
- Layout primitives, tables, trees, lists, CJK-aware
- Colors, ANSI styles, rich formatting
- Terminal charts and plots
- Widgets: text input, spinners, progress bars
- Inline raster images via the kitty graphics protocol
- One-shot interactive prompts (
askInput,askChoose,askFilter, โฆ) - Drop-in progress
loaders for build scripts and batch jobs - Implement
Elementto add your own primitives - Easy porting to MicroHs
Layoutz also lets you easily drop animations into build scripts or any processes that use Stdout:
Table of Contents
- Installation
- Quickstart
- Why layoutz?
- Core Concepts
- Elements
- Border Styles
- Charts & Plots
- Colors & Styles
- Inline Images
- Custom Elements
- Collections
- Interactive Apps
- Prompts (Ask)
- Progress (loader)
- Examples
- Contributing
Installation
Add Layoutz on Hackage to your projectโs .cabal file:
build-depends: layoutz
All you need:
import Layoutz
Quickstart
(1/3) Static rendering: pretty, composable strings.
import Layoutz
demo = layout
[ center $ row
[ withStyle StyleBold $ text "Layoutz"
, withColor ColorCyan $ underline' "ห" $ text "DEMO"
]
, br
, row
[ statusCard "Users" "1.2K"
, withBorder BorderDouble $ statusCard "API" "UP"
, withColor ColorRed $ withBorder BorderThick $ statusCard "CPU" "23%"
, withStyle StyleReverse $ withBorder BorderRound $ table ["Name", "Role", "Skills"]
[ ["Gegard", "Pugilist", ul ["Armenian", ul ["bad", ul["man"]]]]
, ["Eve", "QA", "Testing"]
]
]
]
putStrLn $ render demo
(2/3) Interactive apps - Build Elm-style TUIโs:
import Layoutz
data Msg = Inc | Dec
counterApp :: LayoutzApp Int Msg
counterApp = LayoutzApp
{ appInit = (0, CmdNone)
, appUpdate = \msg count -> case msg of
Inc -> (count + 1, CmdNone)
Dec -> (count - 1, CmdNone)
, appSubscriptions = \_ -> subKeyPress $ \key -> case key of
KeyChar '+' -> Just Inc
KeyChar '-' -> Just Dec
_ -> Nothing
, appView = \count -> layout
[ section "Counter" [text $ "Count: " <> show count]
, ul ["Press '+' or '-'", "ESC to quit"]
]
}
main = runApp counterApp
(3/3) Prompts (Ask)
One-shot CLI prompts (inputs, choosers, filters, file pickers, spinners) that collapse to a single line as you answer them.
import Layoutz
import Control.Concurrent (threadDelay)
main :: IO ()
main = do
name <- askInput "Name โบ " "anonymous" ""
realm <- askChoose "Choose a realm" ["The Shire", "Rivendell", "Mirkwood"] id
packs <- askChooseMany "Pack provisions" ["lembas", "pipe-weed", "rope"] 3 id
member <- askFilter "Search a companion โบ " ["Bilbo", "Gandalf", "Thorin"] 8 id
riddle <- askWrite "Pose a riddle" "This thing all things devoursโฆ" "" "Ctrl-D to save"
quest <- askConfirm "Venture on the quest?" True "Yes" "No"
smaug <- askSpin "Awaking Smaugโฆ" SpinnerDots (threadDelay 1500000 >> pure "ready")
pure ()
Why layoutz?
- We have
printfand full-blown TUI libraries - but thereโs a gap in-between - layoutz is a tiny, declarative DSL for structured CLI output
- On the side, it has a little Elm-style runtime + keyhandling DSL to animate your elements, much like a flipbookโฆ
- But you can just use Layoutz without any of the TUI stuff
Core concepts
- Every piece of content is an
Element - Elements are immutable and composable - build complex layouts by combining simple elements
- A
layoutarranges elements vertically:
layout [elem1, elem2, elem3] -- Joins with "\n"
Call render on any element to get a string
The power comes from uniform composition - since everything has the Element typeclass, everything can be combined.
String Literals
With OverloadedStrings enabled, you can use string literals directly:
layout ["Hello", "World"] -- Instead of layout [text "Hello", text "World"]
[!NOTE] When passing to functions that take polymorphic
Element aparameters (likeunderline',center',pad), usetextexplicitly:
underline' "=" $ text "Title" -- Correct
underline' "=" "Title" -- Ambiguous type error
Border Styles
Applied via withBorder to any element with the HasBorder typeclass (box, statusCard, table):
withBorder BorderRound $ box "Info" ["content"]
withBorder BorderDouble $ statusCard "API" "UP"
withBorder BorderThick $ table ["Name"] [["Alice"]]
Write generic code over bordered elements:
makeThick :: HasBorder a => a -> a
makeThick = setBorder BorderThick
BorderNormal -- โโโ (default)
BorderDouble -- โโโ
BorderThick -- โโโ
BorderRound -- โญโโฎ
BorderAscii -- +-+
BorderBlock -- โโโ
BorderDashed -- โโโ
BorderDotted -- โโโ
BorderInnerHalfBlock -- โโโ
BorderOuterHalfBlock -- โโโ
BorderMarkdown -- |-|
BorderCustom "+" "=" "|" -- Custom border
BorderNone -- No borders
Elements
Layout
Stacking & rows
layout ["First", "Second", "Third"] -- vertical
-- First
-- Second
-- Third
row ["Left", "Middle", "Right"] -- horizontal
-- Left Middle Right
columns [layout ["A", "B"], layout ["C", "D"]]
-- A C
-- B D
tightRow [text "A", text "B", text "C"] -- no spacing
-- ABC
Spacing & rules
layout [text "Line 1", br, text "Line 2"] -- br is a blank line
hr -- default โโโโโโโโโโ...
hr' "~" -- custom char
hr'' "=" 20 -- ====================
vr -- vertical rule, 10 high with โ
vr' "โ" -- custom char
vr'' "|" 5 -- custom char + height
Text transforms
center $ text "Auto-centered" -- width taken from siblings
center' 20 $ text "TITLE" -- โ TITLE โ
alignLeft 20 "Left" -- โLeft โ
alignRight 20 "Right" -- โ Rightโ
justify 30 "Spread this out"
-- โSpread this outโ
wrap 20 "Long text here that should wrap"
-- Long text here that
-- should wrap
truncate' 15 (text "Very long text that will be cut off") -- Very long te...
truncate'' 20 "โฆ" (text "Custom ellipsis example text here") -- Custom ellipsis exaโฆ
pad 2 $ text "content" -- 2 cells of padding all around
underline $ text "Title" -- Title
-- โโโโโ
underline' "=" $ text "Custom" -- Custom
-- โโโโโโ
underlineColored "~" ColorCyan $ text "Fancy"
margin "[error]"
[ text "Ooops!"
, row [text "val result: Int = ", underline' "^" (text "getString()")]
, text "Expected Int, found String"
]
-- [error] Ooops!
-- [error] val result: Int = getString()
-- [error] ^^^^^^^^^^^
-- [error] Expected Int, found String
Content
Basics
text "hello"
"hello" - with OverloadedStrings
section "Status" [text "All systems operational"]
-- === Status ===
-- All systems operational
section' "-" "Status" [text "ok"] -- custom glyph
section'' "#" "Report" 5 [text "42"] -- custom glyph + width
kv [("Name", "Alice"), ("Age", "30"), ("City", "NYC")]
-- Name: Alice
-- Age: 30
-- City: NYC
Boxes, cards & banners
box "Status" [text "All systems go"]
-- โโโStatusโโโโโโโโโโโ
-- โ All systems go โ
-- โโโโโโโโโโโโโโโโโโโโ
withBorder BorderDouble $ box "Fancy" [text "Double border"]
-- โโโFancyโโโโโโโโโโโโ
-- โ Double border โ
-- โโโโโโโโโโโโโโโโโโโโ
withBorder BorderRound $ box "Smooth" [text "Rounded corners"]
-- โญโโSmoothโโโโโโโโโโโโโฎ
-- โ Rounded corners โ
-- โฐโโโโโโโโโโโโโโโโโโโโโฏ
row [ withColor ColorGreen $ statusCard "CPU" "45%"
, withColor ColorCyan $ statusCard "MEM" "2.1G"
]
-- โโโโโโโโ โโโโโโโโโ
-- โ CPU โ โ MEM โ
-- โ 45% โ โ 2.1G โ
-- โโโโโโโโ โโโโโโโโโ
banner ["System Dashboard"]
-- โโโโโโโโโโโโโโโโโโโโ
-- โ System Dashboard โ
-- โโโโโโโโโโโโโโโโโโโโ
Pipe any HasBorder element through withBorder:
withBorder BorderRound $ box "Info" ["content"]
withBorder BorderDouble $ statusCard "API" "UP"
withBorder BorderThick $ table ["Name"] [["Alice"]]
Lists & trees
ul ["Backend", ul ["API", ul ["REST", "GraphQL"], "DB"], "Frontend"]
-- โข Backend
-- โฆ API
-- โช REST
-- โช GraphQL
-- โฆ DB
-- โข Frontend
ol ["Setup", ol ["Install deps", ol ["npm", "pip"], "Configure"], "Deploy"]
-- 1. Setup
-- a. Install deps
-- i. npm
-- ii. pip
-- b. Configure
-- 2. Deploy
tree "Project"
[ branch "src" [leaf "main.hs", leaf "test.hs"]
, branch "docs" [leaf "README.md"]
]
-- Project
-- โโโ src
-- โ โโโ main.hs
-- โ โโโ test.hs
-- โโโ docs
-- โโโ README.md
Table
table ["Name", "Age", "City"]
[ ["Alice", "30", "New York"]
, ["Bob", "25", ""]
, ["Charlie", "35", "London"]
]
โโโโโโโโโโโฌโโโโโโฌโโโโโโโโโโโ
โ Name โ Age โ City โ
โโโโโโโโโโโผโโโโโโผโโโโโโโโโโโค
โ Alice โ 30 โ New York โ
โ Bob โ 25 โ โ
โ Charlie โ 35 โ London โ
โโโโโโโโโโโดโโโโโโดโโโโโโโโโโโ
Progress & spinners
inlineBar "Download" 0.75
-- Download [โโโโโโโโโโโโโโโโโโโโ] 75%
chart [("Web", 10), ("Mobile", 20), ("API", 15)]
-- Web โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ 10
-- Mobile โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ 20
-- API โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ 15
spinner "Loading" frame SpinnerDots -- โ โ โ น โ ธ โ ผ โ ด โ ฆ โ ง โ โ
spinner "Loading" frame SpinnerLine -- | / - \
spinner "Loading" frame SpinnerClock -- ๐ ๐ ๐ ๐ ๐ ๐ ๐ ๐ ๐ ๐ ๐ ๐
spinner "Loading" frame SpinnerBounce -- โ โ โ โ
spinner "Loading" frame SpinnerEarth -- ๐ ๐ ๐
spinner "Loading" frame SpinnerMoon -- ๐ ๐ ๐ ๐ ๐ ๐ ๐ ๐
spinner "Loading" frame SpinnerGrow -- โ โ โ โ โ โ โ โ โ โ โ โ โ โ
spinner "Loading" frame SpinnerArrow -- โ โ โ โ โ โ โ โ
Inline Images
Inline raster images via the kitty graphics protocol.
A KittyImage measures exactly colsรrows cells, so it composes with every other
element: drop it into boxes, rows, tables, center, etc.
img <- kittyImageFile "pix/sakuraba.png" 35 14 -- PNG sized to 35ร14 cells
putStrLn $ render $ row
[ withBorder BorderRound $ box "the gracie hunter" [img]
, box "stats" [kv [("flying", "yes"), ("opponent", "grounded"), ("rules", "PRIDE")]]
]
Build straight from bytes or raw pixels:
kittyImage bytes 35 14 -- PNG bytes -> cols rows
kittyRGB pixels w h 18 9 -- raw RGB pxW pxH -> cols rows
kittyRGBA pixels w h 18 9 -- raw RGBA pxW pxH -> cols rows
let (w, h) = (96, 96)
pixels = [ channel i | i <- [0 .. w * h * 4 - 1] ]
channel i =
let p = i `div` 4; x = p `mod` w; y = p `div` w
in fromIntegral $ case i `mod` 4 of
0 -> x * 255 `div` w
1 -> y * 255 `div` h
2 -> 255 - (x * 255 `div` w)
_ -> 255
putStrLn $ render $ box "gradient" [kittyRGBA pixels w h 18 9]
Needs a kitty-graphics-capable terminal (kitty, WezTerm, Ghostty). Inside a
LayoutzApp, each distinct image is transmitted once and re-placed cheaply on
every frame.
Charts & Plots
See also Granite for terminal plots in Haskell.
Line Plot
let sinePoints = [(x, sin x) | x <- [0, 0.1 .. 10.0]]
plotLine 40 10 [Series sinePoints "sine" ColorBrightCyan]
Multiple series:
let sinPts = [(x, sin (x * 0.15) * 5) | x <- [0..50]]
cosPts = [(x, cos (x * 0.15) * 5) | x <- [0..50]]
plotLine 50 12
[ Series sinPts "sin(x)" ColorBrightCyan
, Series cosPts "cos(x)" ColorBrightMagenta
]
Pie Chart
plotPie 20 10
[ Slice 50 "A" ColorBrightCyan
, Slice 30 "B" ColorBrightMagenta
, Slice 20 "C" ColorBrightYellow
]
Bar Chart
plotBar 40 10
[ BarItem 85 "Mon" ColorBrightCyan
, BarItem 120 "Tue" ColorBrightGreen
, BarItem 95 "Wed" ColorBrightMagenta
]
plotBar 40 10
[ BarItem 100 "Sales" ColorBrightMagenta
, BarItem 80 "Costs" ColorBrightRed
, BarItem 20 "Profit" ColorBrightCyan
]
Stacked Bar Chart
plotStackedBar 40 10
[ StackedBarGroup [BarItem 30 "Q1" ColorDefault, BarItem 20 "Q2" ColorDefault, BarItem 25 "Q3" ColorDefault] "2022"
, StackedBarGroup [BarItem 35 "Q1" ColorDefault, BarItem 25 "Q2" ColorDefault, BarItem 30 "Q3" ColorDefault] "2023"
, StackedBarGroup [BarItem 40 "Q1" ColorDefault, BarItem 30 "Q2" ColorDefault, BarItem 35 "Q3" ColorDefault] "2024"
]
Sparkline
plotSparkline [1, 4, 2, 8, 5, 7, 3, 6]
Heatmap
plotHeatmap $ HeatmapData
[ [12, 15, 22, 28, 30, 25, 18]
, [14, 18, 25, 32, 35, 28, 20]
, [10, 13, 20, 26, 28, 22, 15]
]
["Mon", "Tue", "Wed"]
["6am", "9am", "12pm", "3pm", "6pm", "9pm", "12am"]
Colors
Add ANSI colors to any element:
layout
[ withColor ColorRed $ text "The quick brown fox..."
, withColor ColorBrightCyan $ text "The quick brown fox..."
, underlineColored "~" ColorRed $ text "The quick brown fox..."
, margin "[INFO]" [withColor ColorCyan $ text "The quick brown fox..."]
]
ColorBlack
ColorRed
ColorGreen
ColorYellow
ColorBlue
ColorMagenta
ColorCyan
ColorWhite
ColorBrightBlack -- Bright 8
ColorBrightRed
ColorBrightGreen
ColorBrightYellow
ColorBrightBlue
ColorBrightMagenta
ColorBrightCyan
ColorBrightWhite
ColorFull 196 -- 256-color palette (0-255)
ColorTrue 255 128 0 -- 24-bit RGB
ColorDefault -- Conditional no-op
Color Gradients
Create gradients with extended colors:
let palette = tightRow $ map (\i -> withColor (ColorFull i) $ text "โ") [16, 19..205]
redToBlue = tightRow $ map (\i -> withColor (ColorTrue i 100 (255 - i)) $ text "โ") [0, 4..255]
greenFade = tightRow $ map (\i -> withColor (ColorTrue 0 (255 - i) i) $ text "โ") [0, 4..255]
rainbow = tightRow $ map colorBlock [0, 4..255]
where
colorBlock i =
let r = if i < 128 then i * 2 else 255
g = if i < 128 then 255 else (255 - i) * 2
b = if i > 128 then (i - 128) * 2 else 0
in withColor (ColorTrue r g b) $ text "โ"
putStrLn $ render $ layout [palette, redToBlue, greenFade, rainbow]
Styles
Add ANSI styles to any element:
layout
[ withStyle StyleBold $ text "The quick brown fox..."
, withColor ColorRed $ withStyle StyleBold $ text "The quick brown fox..."
, withStyle StyleReverse $ withStyle StyleItalic $ text "The quick brown fox..."
]
StyleBold
StyleDim
StyleItalic
StyleUnderline
StyleBlink
StyleReverse
StyleHidden
StyleStrikethrough
StyleDefault -- Conditional no-op
StyleBold <> StyleItalic -- Combine with <>
Combining Styles:
Use <> to combine multiple styles at once:
layout
[ withStyle (StyleBold <> StyleItalic <> StyleUnderline) $ text "The quick brown fox..."
, withStyle (StyleBold <> StyleReverse) $ text "The quick brown fox..."
]
You can also combine colors and styles:
withColor ColorBrightYellow $ withStyle (StyleBold <> StyleItalic) $ text "The quick brown fox..."
Custom Elements
Create your own components by implementing the Element typeclass
data Square = Square Int
instance Element Square where
renderElement (Square size)
| size < 2 = ""
| otherwise = intercalate "\n" (top : middle ++ [bottom])
where
w = size * 2 - 2
top = "โ" ++ replicate w 'โ' ++ "โ"
middle = replicate (size - 2) ("โ" ++ replicate w ' ' ++ "โ")
bottom = "โ" ++ replicate w 'โ' ++ "โ"
-- Helper to avoid wrapping with L
square :: Int -> L
square n = L (Square n)
-- Use it like any other element
putStrLn $ render $ row
[ square 3
, square 5
, square 7
]
โโโโโโ โโโโโโโโโโ โโโโโโโโโโโโโโ
โ โ โ โ โ โ
โโโโโโ โ โ โ โ
โ โ โ โ
โโโโโโโโโโ โ โ
โ โ
โโโโโโโโโโโโโโ
Collections
import Data.List (groupBy, sortOn)
import Data.Function (on)
data User = User { userName :: String, userRole :: String }
users :: [User]
users =
[ User "Alice" "Admin"
, User "Bob" "User"
, User "Tom" "User"
]
putStrLn $ render $ section "Users by Role"
[ layout
[ box (userRole (head grp)) [ul [text (userName u) | u <- grp]]
| grp <- groupBy ((==) `on` userRole) (sortOn userRole users)
]
]
=== Users by Role ===
โโโAdminโโโ
โ โข Alice โ
โโโโโโโโโโโ
โโโUserโโโ
โ โข Bob โ
โ โข Tom โ
โโโโโโโโโโ
REPL
Drop into GHCi to experiment:
cabal repl
ฮป> :set -XOverloadedStrings
ฮป> import Layoutz
ฮป> putStrLn $ render $ center $ box "Hello" ["World!"]
โโโHelloโโโ
โ World! โ
โโโโโโโโโโโ
ฮป> putStrLn $ render $ table ["A", "B"] [["1", "2"]]
โโโโโฌโโโโ
โ A โ B โ
โโโโโผโโโโค
โ 1 โ 2 โ
โโโโโดโโโโ
Interactive Apps
LayoutzApp uses the Elm Architecture where your
view is a layoutz Element.
data LayoutzApp state msg = LayoutzApp
{ appInit :: (state, Cmd msg) -- Initial state + startup command
, appUpdate :: msg -> state -> (state, Cmd msg) -- Pure state transitions
, appSubscriptions :: state -> Sub msg -- Event sources
, appView :: state -> L -- Render to UI
}
Three daemon threads coordinate rendering (~30fps), tick/timers, and input capture. State updates flow through appUpdate synchronously.
Press ESC to exit.
App Options
Customise how your app runs with runAppWith and the AppOptions record. Override only the fields you need:
runApp app -- Default options
runAppWith defaultAppOptions { optAlignment = AppAlignCenter } app -- Centered in terminal
runInline app -- Animate in-place, no alt screen
runInline renders the app below existing terminal output without clearing the screen. Useful for embedding progress bars or spinners in build scriptsโฆ use CmdExit to quit programmatically.
Terminal width is detected once at startup via ANSI cursor position report (zero dependencies).
Subscriptions
subKeyPress (\key -> ...) -- Keyboard input
subEveryMs 100 msg -- Periodic ticks (interval in ms)
subBatch [sub1, sub2, ...] -- Combine subscriptions
Commands
CmdNone -- No effect
CmdExit -- Quit the app gracefully
cmdFire (writeFile "log.txt" "entry") -- Fire and forget IO
cmdTask (readFile "data.txt") -- IO that returns a message
cmdAfterMs 500 msg -- Fire a message after delay (ms)
CmdBatch [cmd1, cmd2, ...] -- Combine multiple commands
Example: Logger with file I/O
import Layoutz
data Msg = Log | Saved
data State = State { count :: Int, status :: String }
loggerApp :: LayoutzApp State Msg
loggerApp = LayoutzApp
{ appInit = (State 0 "Ready", CmdNone)
, appUpdate = \msg s -> case msg of
Log -> (s { count = count s + 1 },
cmdFire $ appendFile "log.txt" ("Entry " <> show (count s) <> "\n"))
Saved -> (s { status = "Saved!" }, CmdNone)
, appSubscriptions = \_ -> subKeyPress $ \key -> case key of
KeyChar 'l' -> Just Log
_ -> Nothing
, appView = \s -> layout
[ section "Logger" [text $ "Entries: " <> show (count s)]
, text (status s)
, ul ["'l' to log", "ESC to quit"]
]
}
main = runApp loggerApp
Key Types
-- Printable
KeyChar Char -- 'a', '1', ' '
-- Editing
KeyEnter
KeyBackspace
KeyTab
KeyEscape
KeyDelete
-- Navigation
KeyUp
KeyDown
KeyLeft
KeyRight
KeyPageUp
KeyPageDown
KeyHome
KeyEnd
-- Modifiers
KeyCtrl Char -- Ctrl+'C', Ctrl+'Q', etc.
KeySpecial String -- Other unrecognized sequences
Prompts (Ask)
You often just want a one-shot CLI prompt (a spinner, a filter, a file picker) without dropping into โElm-territoryโ and thinking about a whole flipbook each time.
layoutz offers one-shot interactive actions with the ask* family. Each takes
over the tty briefly, returns a value, and leaves a single committed line behind.
Nothing means the user cancelled (Esc / Ctrl-C / Ctrl-D).
import Layoutz
import Control.Concurrent (threadDelay)
main :: IO ()
main = do
name <- askInput "Name โบ " "anonymous" ""
ok <- askConfirm "Venture on the quest?" True "Yes" "No"
realm <- askChoose "Choose a realm" ["The Shire", "Rivendell", "Mirkwood", "Lake-town", "Erebor"] id
packs <- askChooseMany "Pack provisions" ["lembas", "pipe-weed", "waybread", "miruvor", "rope"] 3 id
riddle <- askWrite "Pose a multi-line riddle" "This thing all things devoursโฆ" "" "Ctrl-D to save"
member <- askFilter "Search > " ["Bilbo", "Balin", "Dwalin", "Thorin", "Gandalf"] 8 id
path <- askFile "." 12
askPager longString 0 True
answer <- askSpin "Awaking Smaugโฆ" SpinnerDots (do threadDelay 1500000; pure (42 :: Int))
pure ()
Each ask* returns in IOโฆ and a Maybe if the user can cancel midway:
askInput prompt placeholder initial -- IO (Maybe String)
askConfirm question default yes no -- IO Bool
askChoose prompt items render -- IO (Maybe a)
askChooseMany prompt items limit render -- IO (Maybe [a])
askWrite prompt placeholder initial hint -- IO (Maybe String)
askFilter prompt items height render -- IO (Maybe a)
askFile start height -- IO (Maybe String)
askPager content height lineNumbers -- IO ()
askSpin label style task -- IO a
Progress (loader)
Wrap any list with loader to get a live progress bar while you process it: map
an IO action over each item. Unbounded work gets loaderStream, a spinner
with a running count.
_ <- loader "Resizing" imageFiles resize
_ <- loader "Inserting" rows insertRow
_ <- loaderStream "Tailing" logLines indexLine
Pick a LoaderStyle with loaderStyled / loaderStreamStyled
styleBlocks -- (default)
styleBar
styleAscii
styleDots
styleLine
stylePipes
LoaderStyle { .. } -- (custom)
_ <- loaderStyled styleAscii "Reindexing" docIds reindex
_ <- loaderStyled stylePipes "Crawling" urls fetch
Every built-in style, then an unbounded stream:
import Layoutz
import Control.Concurrent (threadDelay)
main :: IO ()
main = do
_ <- loaderStyled styleBlocks "Blocks " [1..60] (const (threadDelay 16000))
_ <- loaderStyled styleDots "Dots " [1..60] (const (threadDelay 16000))
_ <- loaderStyled styleLine "Line " [1..60] (const (threadDelay 16000))
_ <- loaderStyled stylePipes "Pipes " [1..60] (const (threadDelay 16000))
_ <- loaderStyled styleBar "Bar " [1..60] (const (threadDelay 16000))
_ <- loaderStyled styleAscii "Ascii " [1..60] (const (threadDelay 16000))
_ <- loaderStream "Streaming" [1..90] (const (threadDelay 45000))
pure ()
Examples
Small, copy-pasteable snippets for everyday CLI chores.
import Layoutz
main :: IO ()
main = putStrLn $ render $ section "Deploy"
[ row
[ withColor ColorGreen $ statusCard "build" "OK"
, withColor ColorGreen $ statusCard "tests" "142 โ"
, withColor ColorYellow $ statusCard "deploy" "staging"
]
, kv [("commit", "a1b2c3d"), ("branch", "master"), ("by", "matt")]
]
import Layoutz
main :: IO ()
main = putStrLn $ render $ margin "error:"
[ text "type mismatch"
, row [text "let x: Int = ", underline' "^" (text "getString()")]
, text " expected Int, found String"
]
-- error: type mismatch
-- error: let x: Int = getString()
-- ^^^^^^^^^^^^
-- error: expected Int, found String
import Layoutz
data Svc = Svc { svcName :: String, svcStatus :: String, svcCpu :: String }
svcs :: [Svc]
svcs =
[ Svc "api" "up" "23%"
, Svc "db" "up" "61%"
, Svc "cache" "down" "0%"
]
main :: IO ()
main = putStrLn $ render $ table ["Service", "Status", "CPU"]
[ [text (svcName s), text (svcStatus s), text (svcCpu s)] | s <- svcs ]
import Layoutz
import Control.Concurrent (threadDelay)
main :: IO ()
main = do
let files = ["a.png", "b.png", "c.png"]
_ <- loader "Resizing" files $ \f -> threadDelay 200000 -- your IO per item
putStrLn "done"
import Layoutz
main :: IO ()
main = do
ok <- askConfirm "Drop production table?" False "Yes" "No"
if ok then putStrLn "droppingโฆ" else putStrLn "cancelled"
import Layoutz
import Control.Concurrent (threadDelay)
main :: IO ()
main = do
rows <- askSpin "Queryingโฆ" SpinnerDots runQuery
putStrLn $ render $ section "Result" [text $ show rows <> " rows"]
where
runQuery = do
threadDelay 1500000 -- stand-in for your slow IO
pure (1234 :: Int)
Contributing
You need GHC (8.10+) and Cabal.
make build # build library
make test # run tests
make repl # GHCi with layoutz loaded
make clean # clean build artifacts
Fork, make your change, make test, open a PR. Keep it zero-dep.
Inspiration
- Original Scala layoutz
Changes
Changelog
All notable changes to layoutz-hs will be documented in this file.
The format is based on Keep a Changelog.
[0.4.0.0] - 2026-07-12
Added
- Inline raster images via the kitty graphics protocol:
kittyImage,kittyImageFile,kittyRGB,kittyRGBA. Images measure exactlycolsรrowscells, so they compose with boxes, rows, tables, etc. - One-shot
ask*prompts that collapse to a single line once answered - Progress
loaders for wrapping work in build scripts and batch jobs:loader,loaderStream, and their*Styledvariants
[0.3.4.0] - 2026-03-31
Added
renderText :: Element a => a -> Textfor rendering directly toData.Text.Text.- Unit tests for
renderText.
Changed
- Added
textas an explicit dep on boot (stil technically zero dep by GHC standards) - But easy to comment out
renderTextfor porting to MicroHs
[0.3.3.0] - 2026-03-14
Added
runAppFinal: likerunAppbut returns the final application state.runAppWithFinal: likerunAppWithbut returns the final application state.runInline: renders app inline without alt-screen, for embedded spinners/progress bars.- New
InlineLoadingDemoexample.
[0.3.2.0] - 2026-03-11
Added
CmdExitcommand for graceful app shutdown from withinappUpdate.cmdIsExithelper to check if a command tree contains an exit.
Fixed
- Haddock markup errors.
SpinnerDemo.hsexample.
[0.3.1.0] - 2026-03-01
Changed
- Documentation tweaks and Haddock polish.
[0.3.0.0] - 2026-03-01
Added
- TUI runtime (
LayoutzApp, Elm Architecture style event loop). - Keyboard input handling (
Key,readKey). - Commands (
Cmd,cmdFire,cmdTask,cmdAfterMs). - Subscriptions (
Sub,subKeyPress,subEveryMs). AppOptionsandAppAlignmentfor layout customization.- Spinner animations (
SpinnerDots,SpinnerLine,SpinnerClock,SpinnerBounce). - Visualization primitives:
plotSparkline,plotLine,plotPie,plotBar,plotStackedBar,plotHeatmap. - Braille-based line and pie chart rendering.
- 256-color and RGB true-color support (
ColorFull,ColorTrue). - Text styles with
Semigroupcombining (StyleBold <> StyleItalic). tightRowfor gapless horizontal layouts.wrapfor word-boundary text wrapping.- Ordered lists (
ol) with nested numbering (arabic, alpha, roman).
[0.1.0.0] - 2026-02-27
Added
- Initial Haskell port of layoutz.
- Core DSL:
text,layout,box,row,center,ul,table,kv,tree. - Border styles: normal, double, thick, round, ASCII, block, dashed, dotted, half-block, markdown, custom.
withBorder,withColor,withStylecombinators.- ANSI-aware width calculation (
charWidth,visibleLength). hr,vr,pad,margin,chart,section,alignLeft/alignRight/alignCenter/justify.OverloadedStringssupport forL.