Apache-2.0 licensed by Matthieu Court
Maintained by [email protected]
This version can be pinned in stack with:layoutz-0.4.0.0@sha256:fb701aaec8fa57a20e943e33826de05e12e0b9e1cc47957ac7e639592ac0e187,2442

Module documentation for 0.4.0.0

Used by 1 package in nightly-2026-07-13(full list with versions):

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.

Also in Scala, OCaml

Features

  • Pure Haskell, zero-dependencies (use Layoutz.hs like 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 Element to 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

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 printf and 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 layout arranges 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 a parameters (like underline', center', pad), use text explicitly:

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

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 exactly colsร—rows cells, 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 *Styled variants

[0.3.4.0] - 2026-03-31

Added

  • renderText :: Element a => a -> Text for rendering directly to Data.Text.Text.
  • Unit tests for renderText.

Changed

  • Added text as an explicit dep on boot (stil technically zero dep by GHC standards)
  • But easy to comment out renderText for porting to MicroHs

[0.3.3.0] - 2026-03-14

Added

  • runAppFinal: like runApp but returns the final application state.
  • runAppWithFinal: like runAppWith but returns the final application state.
  • runInline: renders app inline without alt-screen, for embedded spinners/progress bars.
  • New InlineLoadingDemo example.

[0.3.2.0] - 2026-03-11

Added

  • CmdExit command for graceful app shutdown from within appUpdate.
  • cmdIsExit helper to check if a command tree contains an exit.

Fixed

  • Haddock markup errors.
  • SpinnerDemo.hs example.

[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).
  • AppOptions and AppAlignment for 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 Semigroup combining (StyleBold <> StyleItalic).
  • tightRow for gapless horizontal layouts.
  • wrap for 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, withStyle combinators.
  • ANSI-aware width calculation (charWidth, visibleLength).
  • hr, vr, pad, margin, chart, section, alignLeft/alignRight/alignCenter/justify.
  • OverloadedStrings support for L.