Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Ecosystem Packages

Each package adds a backend or one capability to nano-ui. Pin them from source as in the Quick Start.

PackageWhat it adds
nano-ui-sdlSDL3 window backend, TrueType fonts, native file dialogs, custom chrome
nano-ui-rgfwRGFW + OpenGL 3.2 backend with a bundled bitmap font
nano-ui-diagramsLine/bar/scatter/area charts and diagrams-lib content
nano-ui-formValidated applicative forms built on ditto
nano-ui-markdownCommonMark rendering, appendable for streamed replies
nano-ui-svgDrawOp-to-SVG and whole-frame SVG export

Backends

nano-ui-sdl is the primary desktop backend:

sdlMain :: IO ()
sdlMain = Sdl.runSdlApp
  Sdl.defaultSdlOptions
    { Sdl.sdlWindowSettings = defaultWindowSettings {wsTitle = "My App", wsSize = Size 1024 768}
    , Sdl.sdlAppFont = Sdl.FontSearch ["Segoe UI", "SF Pro Text", "Inter"]
    , Sdl.sdlAppFontSize = 14
    , Sdl.sdlAppTheme = Just defaultTheme
    }
  appView

It adds HarfBuzz shaping, installed-font lookup, high-DPI handling, non-blocking native file dialogs (useFileDialog), custom title bars (windowCaption), and screenshots (saveScreenshot, captureScreenshot). See Desktop Windows for the window side.

nano-ui-rgfw is the alternative with no system dependencies:

rgfwMain :: IO ()
rgfwMain = Rgfw.runRgfwApp Rgfw.defaultRgfwOptions appView

It links RGFW statically and bundles a bitmap font, so binaries are small and standalone. Use it where SDL3 is not available.

Charts and diagrams

nano-ui-diagrams provides NanoUI.Plot and the diagrams-lib widget. import NanoUI.Diagrams brings in both. A chart is a plain Chart value; plot draws it, sized by a layout modifier:

cpuChart :: PlotCache -> [(Double, Double)] -> NanoUI ()
cpuChart cache samples =
  void $ plot cache (fillW . fixedH 220) $
    withGrid GridBoth
      . withXAxis "time (s)"
      . withYAxis "CPU %"
      . withLegend LegendNone
      $ chart [line "cpu" samples]
A framed line chart of CPU load over sixty seconds, with a grid, numbered axes, and the titles time (s) and CPU %.

Allocate the PlotCache once in IO with newPlotCache and keep it in the application record. It rebuilds a chart’s drawing only when the chart or the theme changes, so building the Chart value every frame is cheap.

A chart keeps its drawing’s proportions. With fillW the frame fills the width and the chart is centred in it; with only fixedH the widget takes the width the proportions give. A chart whose labels would overlap at the requested size is drawn larger, up to twice the size.

The examples below are compiled from ChartExamples.hs, which nano-ui-svg-glossary also renders to the images. They share this data:

wave :: [(Double, Double)]
wave = [(x, 3 + sin x + 0.5 * sin (2.3 * x)) | x <- [0, 0.3 .. 2 * pi]]

counts :: [(Double, Double)]
counts = zip [0 ..] [2, 4, 3, 6, 5, 7, 4, 5]

quarters :: [(Text, Double)]
quarters = [("Q1", 3), ("Q2", 5), ("Q3", 4), ("Q4", 7)]

Series

Series constructors take a name and the points: (x, y) pairs, or (label, value) pairs for bar. Each cell below plots withLegend LegendNone (chart [s]).

Five small charts: a line through a wave, the same wave as dots, the wave filled down to zero, a staircase through eight counts, and four bars labelled Q1 to Q4.
seriesKinds :: [(Text, Series)]
seriesKinds =
  [ ("line", line "line" wave)
  , ("scatter", scatter "scatter" wave)
  , ("area", area "area" wave)
  , ("step", step "step" counts)
  , ("bar", bar "bar" quarters)
  ]

Lines and steps join the points in the order given, so sort them first if they are not already in x order. An area fills down to zero. Bars sit in category order, each taking 72% of its slot. lineChart, barChart, scatterChart, and areaChart are one-series shortcuts that plot a list with a grid and no legend.

Series without a colour take the theme’s series colours in order. The modifiers override the colour and size:

Four charts: a teal line, a thick line, a line with a dot on every point, and an area filled up and down to the line y equals 3.
seriesStyles :: [(Text, Series)]
seriesStyles =
  [ ("withColor", withColor (colorRGB 20 184 166) (line "s" wave))
  , ("withStrokeWidth 4", withStrokeWidth 4 (line "s" wave))
  , ("withMarker MarkCircle", withMarker MarkCircle (line "s" counts))
  , ("withBaseline 3", withBaseline 3 (area "s" wave))
  ]

withStrokeWidth means the line width for a line or step, the marker size for a scatter, and the slot fraction for a bar. withBaseline sets where an area fills to, in data units, so values below it fill downward. A line’s markers are twice its stroke width; a scatter’s markers are its stroke width, 3 by default:

Five scatter charts of the same counts drawn with circles, squares, diamonds, triangles, and crosses.
markers :: [(Text, Series)]
markers =
  [ (name, withStrokeWidth 6 (withMarker shape (scatter name counts)))
  | (name, shape) <-
      [ ("MarkCircle", MarkCircle)
      , ("MarkSquare", MarkSquare)
      , ("MarkDiamond", MarkDiamond)
      , ("MarkTriangle", MarkTriangle)
      , ("MarkCross", MarkCross)
      ]
  ]

Grid, titles, and legend

withGrid picks the grid lines drawn through the data box. Ticks and their labels stay either way.

The same wave four times: with horizontal and vertical grid lines, horizontal only, vertical only, and no grid.
grids :: [(Text, Chart)]
grids =
  [ (name, withGrid mode . withLegend LegendNone $ chart [line "s" wave])
  | (name, mode) <-
      [("GridBoth", GridBoth), ("GridHorizontal", GridHorizontal), ("GridVertical", GridVertical), ("GridNone", GridNone)]
  ]

withTitle, withXAxis, and withYAxis add titles. withLegend places the legend, which is on the right by default. At this height the bottom and inside legends would overlap other labels, so those two charts are drawn larger.

Five charts titled Latency with lines p50 and p99: the legend on the right, along the top, absent, along the bottom of a larger chart, and inside the top left corner of a larger chart.
latency :: Chart
latency =
  withTitle "Latency" $
    chart [line "p50" [(x, 20 + 4 * sin x) | x <- [0, 0.5 .. 6]], line "p99" [(x, 42 + 9 * sin (x + 1)) | x <- [0, 0.5 .. 6]]]

legends :: [(Text, Chart)]
legends =
  [ (name, withLegend pos latency)
  | (name, pos) <-
      [ ("LegendRight", LegendRight)
      , ("LegendTop", LegendTop)
      , ("LegendNone", LegendNone)
      , ("LegendBottom", LegendBottom)
      , ("LegendInside", LegendInside)
      ]
  ]

Hover

plot returns a PlotResponse. Its plotHover is the point nearest the pointer, in data units, while the pointer is inside the data box:

requests :: [(Double, Double)]
requests = zip [0 ..] [120, 180, 150, 260, 210, 300, 240, 280]

hoverReadout :: PlotCache -> NanoUI ()
hoverReadout cache = columnWith (gap 8) $ do
  resp <- plot cache (fixedWH 240 200) (withLegend LegendNone (chart [line "requests" requests]))
  case plotHover resp of
    Nothing -> muted "Hover a point"
    Just h -> kv "value" (T.pack (show (round (hoverDataY h) :: Int)))
A line chart of eight request counts with the pointer by the seventh point; below it the readout value 240.

The pointer rests by the seventh point, so the readout shows its 240. hoverSeriesIdx and hoverPointIdx hold the index of its series and point. Wrap the chart in withCursorShape UiCursorCrosshair to give the pointer a crosshair.

Diagrams

diagram places any diagrams-lib Diagram B in the layout, framed like a chart and scaled to fit the layout modifier with its proportions kept. Colour it from the theme with colourOf, or take the chart colours from uiPlotStyle:

-- | Three labelled boxes joined by arrows, coloured from the theme.
stages :: Theme -> Diagram B
stages t = (mconcat (zipWith stage [0 ..] ["view", "layout", "paint"]) <> arrows) # frame 0.25
  where
    ink = colourOf (styleFg (themePanel t))
    stage i name =
      (text name # fontSizeL 0.3 # fc ink <> roundedRect 1.6 0.8 0.15 # fc (colourOf (themeAccent t)) # lw none)
        # translate (D.r2 (2.4 * i, 0))
    arrows =
      mconcat
        [ arrowBetween' (with & headLength .~ local 0.2) (p2 (2.4 * i + 0.9, 0)) (p2 (2.4 * i + 1.5, 0)) # lc ink # lwO 1.5
        | i <- [0, 1]
        ]

pipelineDiagram :: NanoUI ()
pipelineDiagram = void $ diagram (fixedWH 360 72) . stages =<< uiTheme
Three blue rounded boxes labelled view, layout, and paint, joined left to right by white arrows.

Text in a diagram is measured with the current font so labels fit. The cache key is the theme and the size only, so for a diagram whose content changes, use diagramWithKeyAndEnvelope with a key that changes with it. The live chart tutorial feeds a chart from a Stream: Live Data & Background Work.

Validated forms

nano-ui-form builds applicative forms on ditto. A Form Text a has Text errors and returns a:

data Signup = Signup Text Text

signup :: Form Text Signup
signup =
  Signup
    <$> withFieldErrors (inputText "Name" "" `prove` notEmpty "Name is required")
    <*> withFieldErrors (inputText "Email" "" `prove` validEmail (const "Not an email address"))

Allocate owner <- newFormState once, then run it with a stable prefix:

signupView :: FormState -> NanoUI ()
signupView owner = do -- owner <- newFormState in setup
  submitted <- nanoFormSubmit owner "signup" "Sign up" signup
  case submitted of
    Just (Signup name _) -> label ("Welcome, " <> name)
    Nothing              -> pure ()

nanoFormLive returns Just a whenever the form is valid; nanoFormEx takes a FormConfig; runNanoForm returns the view and result separately. Inputs include inputText, inputPassword, inputTextArea, inputCheckbox, inputSlider, inputSelect, inputRadio, and inputColor. Validation includes notEmpty, minLength, maxLength, inRange, validEmail, matches, and customProof. inputWidget adapts any Var a -> NanoUI Response widget, with a publication policy (OnChange, OnChangeOr, OnlyWhen).

Markdown

nano-ui-markdown parses with commonmark and draws with rich text:

doc :: MarkdownDoc
doc = parseMarkdown "# Hello\n\nSome *emphasis* and `code`."

view :: MarkdownCache -> NanoUI ()
view cache = do -- cache <- newMarkdownCache in setup
  clicked <- markdown cache doc
  for_ clicked $ \url -> liftIO (void (Sdl.openUrl url))

Allocate cache <- newMarkdownCache once. markdown returns the link clicked this frame. For streaming, append without reparsing the whole document:

reply :: Stream Int MarkdownDoc -> MarkdownCache -> Int -> NanoUI ()
reply stream cache replyId = do
  answer <- useStream stream replyId emptyMarkdown $ \update ->
    onToken (\token -> update (appendMarkdown token))
  void (markdown cache answer)

When appending on the UI thread, append a frame’s tokens in one call. The parser reparses only the tail block, with a few documented exceptions (a long unsplittable final block, or a link-reference definition). MarkdownConfig.mdBlock overrides block drawing, for example to syntax-highlight code.

SVG export

nano-ui-svg has two levels:

  • NanoUI.Draw.Svg exports DrawOp sequences: drawOpsToSvg bounds ops or renderDrawOpsSvg config.
  • NanoUI.Draw.FrameSvg re-renders a whole frame: frameToSvg config ctx input drawData.
exportViewToSvg :: NanoUI () -> IO Text
exportViewToSvg v = do
  ctx <- newPixelContext
  let inp = withInputOff 1280 800
  enableSvgExport ctx
  (_, drawData, _) <- runFrame ctx inp v
  frameToSvg defaultFrameSvgConfig ctx inp drawData

enableSvgExport records the shapes paint draws and the text spans the exporter reads; frameToSvg fails on a context without it. Ids in the document take a prefix from its hash, so several SVGs can share a page. A newPixelContext measures text on a 16 px monospace grid; run the frames inside withSdl to measure in the real font. The specimens in this book are made this way.