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

nano-ui

An immediate-mode GUI toolkit for Haskell.

In an immediate-mode GUI, your UI code runs again on every frame. Drawing a button and checking whether it was clicked is one function call, and the application keeps no tree of widget objects to update. In Haskell this makes the interface a function of state, written with ordinary functions, do notation, and if/case/for_, with no callbacks to register.

You describe the whole window as one NanoUI () action, and the backend runs that action again on every frame. The rest of this book calls that action the view. Widgets return values directly (button returns Bool), input widgets take a mutable Var holding their state, and layout is a composable modifier argument.

counter :: NanoUI ()
counter = row $ do
  n <- useVar (0 :: Int)
  whenM (button "-") (modifyVar n (subtract 1))
  label . T.pack . show =<< readVar n
  whenM (button "+") (modifyVar n (+ 1))

Code samples assume import NanoUI and OverloadedStrings. The runnable source for each tutorial is in packages/nano-ui-demo/examples.

Packages in the monorepo

PackageWhat it isRead
packages/nano-uiCore toolkit: widgets, layout, input, theming, draw list, text arena.State, Layout, Widget Gallery
packages/nano-ui-sdlSDL3 backend: TrueType fonts via HarfBuzz, system font search, native non-blocking file dialogs, custom title bars.Ecosystem Packages, Desktop Windows
packages/nano-ui-rgfwRGFW + OpenGL 3.2 backend with a bundled bitmap font and no system dependencies.Ecosystem Packages
packages/nano-ui-emitElm-style typed messages and update reducers (NanoUIE msg a).Elm-Style State
packages/nano-ui-diagramsLine, bar, scatter, and area charts (NanoUI.Plot) with diagrams-lib integration.Ecosystem Packages
packages/nano-ui-formValidated applicative forms built on ditto.Ecosystem Packages
packages/nano-ui-markdownMarkdown rendering, incrementally appendable for streamed replies.Ecosystem Packages
packages/nano-ui-svgHeadless DrawOp-to-SVG export and visual regression testing.Ecosystem Packages
packages/nano-ui-demoRunnable example applications and single-topic tutorials.Tutorials

Styling workbenches

The styling chapters embed live specimens. Toggle modifiers, themes, and interaction states:

Quick Start

Set up a project and open a window. The tutorials continue from here.

Prerequisites

  • GHC 9.10 through 9.14 (development uses 9.14)
  • Cabal 3.12+
  • SDL3 and SDL3_ttf 3.2+ for the SDL backend
  • pkg-config

Nix (Linux / macOS)

In the nano-ui repository, nix develop provides GHC, Cabal, SDL3, SDL3_ttf, and pkg-config.

Windows

Install SDL3 and SDL3_ttf through MSYS2 UCRT64:

pacman -S mingw-w64-ucrt-x86_64-sdl3 mingw-w64-ucrt-x86_64-sdl3-ttf mingw-w64-ucrt-x86_64-pkg-config

Then point PKG_CONFIG_PATH at MSYS2’s pkgconfig directory:

$env:PKG_CONFIG_PATH = "C:\msys64\ucrt64\lib\pkgconfig"

Add nano-ui to a project

nano-ui is on Hackage, but this book follows the repository, which may be ahead of the release. Pin the source in cabal.project:

source-repository-package
  type: git
  location: https://github.com/goolord/nano-ui
  subdir:
    packages/nano-ui
    packages/nano-ui-sdl

package nano-ui-sdl
  flags: +sdl

Add the other packages you need (nano-ui-emit, nano-ui-diagrams, and so on) to the subdir list and to your .cabal dependencies.

In my-app.cabal:

executable my-app
  main-is: Main.hs
  default-language: GHC2024
  default-extensions: OverloadedStrings
  ghc-options: -threaded -Wall
  build-depends:
      base >= 4.20 && < 5
    , nano-ui
    , nano-ui-sdl
    , text

Haddocks and Hoogle

Hackage has Haddocks for the released version. For the current source, from a checkout:

cabal haddock-project --output=haddocks --hoogle

Open haddocks/index.html. Building every package is memory-heavy; cabal haddock nano-ui --haddock-hoogle builds one. See Development Setup for Hoogle indexing.

Important

Compile executables with -threaded. Between frames the backend blocks in SDL’s event wait, a foreign call. Under the non-threaded runtime that call stops every Haskell thread, so Task and Stream workers make no progress while the window is idle.

Your first application

Create Main.hs:

module Main (main) where

import Data.Text qualified as T
import NanoUI
import NanoUI.Backend.Sdl

main :: IO ()
main =
  runSdlApp
    defaultSdlOptions
      { sdlWindowSettings =
          defaultWindowSettings {wsTitle = "Hello nano-ui"}
      }
    appView

appView :: NanoUI ()
appView = columnWith (gap 12 . padAll 20 . alignCenter) $ do
  heading "Counter Demo"

  -- State at this call site; allocated on the first frame only.
  n <- useVar (0 :: Int)

  rowWith (gap 8 . alignMid) $ do
    whenM (button "-") (modifyVar n (subtract 1))

    value <- readVar n
    labelWith (fontMono . fontSize 18) (T.pack (show value))

    whenM (button "+") (modifyVar n (+ 1))

  whenM (button "Reset") (writeVar n 0)

Run it:

cabal run -j1 my-app

-j1 keeps memory down; a normal project can use cabal’s defaults.

How it works

  1. runSdlApp initializes SDL3, creates a window with TrueType font rendering, and starts the frame loop.
  2. columnWith and rowWith are layout containers. Their argument composes layout modifiers (gap 12 . padAll 20); the leftmost modifier wins when two set the same property.
  3. useVar (0 :: Int) allocates a Var on the first frame and returns the same Var on later frames, matched by its call position in the view. The initial value is used only on the first frame.
  4. button "+" draws an interactive button and returns True only on the frame it is activated.
  5. modifyVar n (+ 1) updates the value. Reading n below the buttons means the label shows the new value in the same pass; reading it above them would cost a second pass.

The view runs every frame, so two rules follow:

  • Guard side effects with an event: whenM (button "Save") save. button "Save" >> save saves every frame.
  • Allocate long-lived resources (newVar, tasks, caches) in IO before runSdlApp, never inside the view.

Next: Tutorial 1: Build a Todo List.

Tutorials

Three applications, built one feature at a time. The full source of each is in packages/nano-ui-demo/examples/.

TutorialYou buildYou learn
1. Build a Todo ListA filtered, reorderable todo list with per-row editing.Var, keys, local state, scope, derived values, keyboard submission.
2. Build a NotepadA menu-bar text editor with file dialogs and unsaved-change prompts.Menus and popups, shortcuts, TextDocument, modals, window close requests.
3. Live Data & Background WorkA background search, streamed progress, and a live chart.Task, Stream, spinners and progress bars, charts, cancellation rules.

Do the Quick Start first. From a nano-ui checkout, run the examples directly:

cabal run nano-ui-example-todo-list
cabal run nano-ui-sdl-notepad
cabal run nano-ui-example-tasks
cabal run nano-ui-example-live-chart

Tutorial 1: Build a Todo List

A todo list that adds items with Enter, edits them in place, filters by all/active/done, reorders rows with their local state, and clears completed items. The complete program is packages/nano-ui-demo/examples/TodoList.hs. Run it with:

cabal run nano-ui-example-todo-list

1. The data model

A todo item needs an identity that outlives its position. Positions change when an item is added, removed, filtered, or reordered; the id does not. Every widget or Var that belongs to a row is keyed by that id later.

data Todo = Todo
  { todoId :: !Int
  , todoText :: !Text
  , todoDone :: !Bool
  }
  deriving (Eq, Generic)

-- | The items in order, and the id the next one gets.
data Todos = Todos
  { todoItems :: ![Todo]
  , todoNext :: !Int
  }
  deriving (Eq, Generic)

newtype App = App {appTodos :: Var Todos}

The list lives in a Var Todos. That is not a type useVar can allocate, so it is created once during setup with newVar:

newApp :: IO App
newApp =
  App
    <$> newVar
      ( Todos
          [ Todo 0 "Read the header" True
          , Todo 1 "Water the plants" False
          , Todo 2 "Edit me, then delete the first row" False
          ]
          3
      )

Allocated in main and passed into the view, it survives between frames. A Var allocated inside the view would be fresh on every pass.

Both records derive Generic; with OverloadedRecordDot, app.appTodos.todoItems is a Var [Todo] projecting just that field. Editing the list does not repaint a widget that reads only todoNext.

2. The application shell

main :: IO ()
main = do
  app <- newApp
  runSdlApp
    defaultSdlOptions
      { sdlAppShouldQuit = pressedOnceIn KeyEscape
      , sdlWindowSettings =
          defaultWindowSettings {wsTitle = "Todo list", wsSize = Size 720 560}
      }
    (todoApp app)

runSdlApp calls the view every frame, and the view describes the whole window each time.

todoApp :: App -> NanoUI ()
todoApp app = do
  let
    items = app.appTodos.todoItems
  draft <- useVar ""
  filt <- useEnumVar All
  columnWith (padAll 20 . gap 12 . fillW . fillH) $ do
    heading "Todos"
    newItemRow app.appTodos draft
    filterRow items filt
    todoList items filt

The draft text and the filter are view-local, allocated with useVar and useEnumVar. Both are identified by their call site, so they need no setup and no record field.

3. Adding an item

The input row draws the field and the button, and adds an item on Enter or a click.

newItemRow :: Var Todos -> Var Text -> NanoUI ()
newItemRow todosVar draft = rowWith (tight . gap 8 . alignMid . fillW) $ do
  inputR <-
    textInputConfigured'
      defaultTextInputConfig {ticPlaceholder = "What needs doing?"}
      draft
  clicked <- button "Add"
  text <- T.strip <$> readVar draft
  when ((clicked || respSubmitted inputR) && not (T.null text)) $ do
    modifyVar todosVar (\(Todos xs n) -> Todos (xs <> [Todo n text False]) (n + 1))
    writeVar draft ""
    requestFocus (respId inputR)
  • textInputConfigured', the primed form, returns a Response holding the widget’s identity, rectangle, hover and click state, and whether Enter was submitted (respSubmitted). button already returns the one Bool this row needs.
  • readVar draft sits below the field that edits it, so the field’s write happens first and the read returns the new text in the same pass. Reading above an editing widget forces a second pass.
  • modifyVar applies its function to the latest value, so two changes in one frame both apply.
  • The code clears the field and focuses it again, so the next item can be typed immediately. requestFocus takes effect at the end of the frame.

Note

The when guard around the mutation is required. The view runs every frame, so an unguarded modifyVar would add an item on every pass. Event-driven work goes inside whenM (button ...) or a condition like the one above.

4. Filtering and a derived count

The filter is a local value with three states. useEnumVar accepts any Enum/Bounded type and keeps the chosen value across frames:

data Filter = All | Active | Done
  deriving (Eq, Show, Enum, Bounded)

matches :: Filter -> Todo -> Bool
matches All _ = True
matches Active t = not (todoDone t)
matches Done t = todoDone t

filterRow :: Var [Todo] -> Var Filter -> NanoUI ()
filterRow items filt = rowWith (tight . gap 12 . alignMid . fillW) $ do
  -- A header for every filter, as a segmented control.
  boundedTabBarConfigured
    defaultTabsConfig {tabsStyle = TabSegmented}
    (T.pack . show)
    filt
  flex
  todos <- readVar items
  let
    left = length (filter (not . todoDone) todos)
  labelWith
    (tight . alignMid . fontMuted)
    (T.pack (show left) <> if left == 1 then " item left" else " items left")
  disabledWhen (left == length todos) $
    whenM (button "Clear completed") (modifyVar items (filter (not . todoDone)))

boundedTabBarConfigured draws a segmented control over every constructor of Filter; the label function is show. The count is derived: it is computed from the list every frame, never stored in a second Var that could drift. disabledWhen dims the clear button and blocks its events without removing it from the layout.

5. Rows own their state

Each row needs local state (whether it is being edited and the draft of its text), but the list can be filtered and reordered underneath it. Per-row state must therefore be keyed by the item’s id, which stays fixed while the row’s screen position changes.

todoList :: Var [Todo] -> Var Filter -> NanoUI ()
todoList items filt = do
  shownFilter <- readVar filt
  scrollWith (grow . fillW) $
    columnWith (tight . gap 6 . fillW) $
      withElemVars todoId items $ \todos at -> do
        let
          shown = filter (matches shownFilter) todos
        -- Each row gets its visible neighbours, so Up and Down skip rows
        -- the filter hides.
        forM_
          (zip3 shown (Nothing : map Just shown) (map Just (drop 1 shown) <> [Nothing])) $ \(todo, above, below) ->
          withKey (todoId todo) (todoRow items (at todo) above below)
  • withElemVars todoId items gives the current list and a function at :: Todo -> Var Todo. at todo is a Var addressed by the item’s id, so it survives a reorder or a filter change.
  • withKey (todoId todo) does the same for the row’s widget state and its positional useVar slots. Without the key, deleting the first row would move every later row’s caret, edit flag, and half-typed draft one position up.
  • matches filters the rows. The neighbours (above, below) are passed in so the Up/Down buttons can skip rows that the filter hides.

The row, trimmed to its structure:

todoRow :: Var [Todo] -> Var Todo -> Maybe Todo -> Maybe Todo -> NanoUI ()
todoRow items todo above below = do
  -- Local state, kept under this row's key.
  editing <- useVar False
  focusPending <- useVar False
  draft <- useVar ""
  t <- readVar todo
  isEditing <- readVar editing
  let
    tid = todoId t
    save = do
      writeVar todo.todoText =<< readVar draft
      writeVar editing False
  when isEditing (whenM takeEscape (writeVar editing False))
  rowWith (tight . gap 8 . alignMid . fillW) $ do
    checkboxWith alignMid "" todo.todoDone
    -- The label and the field swap places, so they share a scope.
    scope $
      if isEditing
        then do
          fieldR <- textInput' draft
          -- Focus can only move to a widget declared in the pass that asks,
          -- and the field first appears in the pass after Edit was clicked,
          -- so the request waits in a Var.
          whenM
            (readVar focusPending)
            (requestFocus (respId fieldR) >> writeVar focusPending False)
          when (respSubmitted fieldR) save
        else
          labelWith
            (fillW . alignMid . (if todoDone t then fontStrike . fontMuted else id))
            (todoText t)
    whenM (button (if isEditing then "Save" else "Edit")) $
      if isEditing
        then save
        else
          writeVar draft (todoText t)
            >> writeVar editing True
            >> writeVar focusPending True
    disabledWhen (null above) $
      whenM (button "Up") (forM_ above (modifyVar items . swapIds tid . todoId))
    disabledWhen (null below) $
      whenM (button "Down") (forM_ below (modifyVar items . swapIds tid . todoId))
    whenM
      (styled destructive (button "Delete"))
      (modifyVar items (filter ((/= tid) . todoId)))

Three details make this work:

  1. scope wraps the label/field swap. Only one of the two branches runs on a given frame. Without scope, the widgets after the branch would shift ids whenever the row enters or leaves edit mode. scope reserves one slot either way. (The useVar slots inside the row are stable because the whole row is under withKey.)
  2. The focus request is delayed. requestFocus can only target a widget declared in the same pass. The field first appears in the pass after Edit was clicked, so the request is stored in focusPending and issued on the pass that declares the field.
  3. Editing writes back into the item’s Var. todo.todoText projects one field; todo.todoDone does too, which is why the checkbox can edit it in place. Saving writes the draft and clears the edit flag.

6. Reordering by id

Up and Down need to swap two items wherever they are in the full list. Swapping by id keeps the operation correct even though the visible list is filtered:

-- | Swap the places of two items, by id.
swapIds :: Int -> Int -> [Todo] -> [Todo]
swapIds a b xs = map pick xs
 where
  byId i t = fromMaybe t (find ((== i) . todoId) xs)
  pick t
    | todoId t == a = byId b t
    | todoId t == b = byId a t
    | otherwise = t

The row’s local state follows the id, so the edit flag and draft travel with the item.

7. Keyboard behaviour

Enter in the new-item field adds an item because respSubmitted reports it. Escape cancels an edit:

  when isEditing (whenM takeEscape (writeVar editing False))

takeEscape claims the press for this handler, so later Escape handlers in the view (an open popup or a second takeEscape) do not also act on it. The window’s quit policy is separate: the example sets sdlAppShouldQuit = pressedOnceIn KeyEscape, so Escape still quits the application unless the application chooses another policy (or handles Escape itself in the view and leaves the predicate off).

For the full shortcut model (shortcut, shortcutOnce, menu bindings, and how focused widgets claim keys), see Keyboard, Focus & IME.

8. Try it

  • Replace the Up/Down buttons with useReorder for drag-and-drop reordering (see Drag & Drop).
  • Remember the filter across runs in a Settings record allocated with newVar.
  • Show an empty state when shown is empty, for example muted "Nothing here yet."
  • Validate the draft length with the techniques in Ecosystem Packages.

Next: Tutorial 2: Build a Notepad adds menus, dialogs, and a real text document.

Tutorial 2: Build a Notepad

A menu bar, file dialogs, a text document with undo, a status bar, and a prompt before losing unsaved work.

The complete program is packages/nano-ui-demo/examples/Notepad.hs. Run it with:

cabal run nano-ui-sdl-notepad

1. State that belongs to the session

A text document is not a type useVar can hold, and the editor’s identity must outlive any single frame, so both live in a record allocated once:

data NotepadState = NotepadState
  { doc :: !(Var TextDocument)
  , editorId :: !(Var WidgetId)
  , openDlg :: !(Var (Maybe FileDialogId))
  , saveDlg :: !(Var (Maybe FileDialogId))
  }

-- | A notepad's private document and dialog state. Allocate one per
-- instance before starting the SDL session, and draw it with 'notepadUi'.
newNotepad :: IO NotepadState
newNotepad = do
  doc <- newVar emptyDocument
  editorId <- newVar (WidgetId 0)
  openDlg <- newVar Nothing
  saveDlg <- newVar Nothing
  pure NotepadState {..}

The menu state, path, dirty flag, zoom, and transient messages are simpler and can be useVars inside the view. Allocate in IO what other code needs a handle to (a document, a task, a dialog), and use useVar for view-local state.

2. Close confirmation

By default the window closes as soon as the close button is pressed. To prompt about unsaved changes first, the notepad creates the window with wsExitOnCloseRequest = False:

main :: IO ()
main = do
  state <- newNotepad
  runSdlApp
    defaultSdlOptions
      { -- A close request goes to the view, which may ask about unsaved changes.
        sdlWindowSettings =
          defaultWindowSettings
            { wsTitle = "nano-ui Notepad"
            , wsSize = Size 1000 720
            , wsExitOnCloseRequest = False
            }
      , sdlAppTheme = Just tomorrowNightMinDarkTheme
      }
    (notepadUi state)

With exit-on-close off, winCloseRequested <$> askWindow reports the request inside the view, where it can open a confirmation modal instead of quitting:

  item (winCloseRequested <$> askWindow) exitApp

exitApp quits immediately when the document is clean, and opens a modal when it is not. The full wiring is in section 7.

3. A menu bar from primitives

There is no menuBar widget. A menu bar is a row of menuButton's, each paired with a popup' anchored to the button’s rectangle, so a menu’s contents are ordinary NanoUI ().

openMenu holds the label of the open menu (empty when all are closed):

menuBar :: Var Text -> [(Text, NanoUI ())] -> NanoUI ()
menuBar openMenu entries = do
  current <- readVar openMenu
  rowWith (tight . fillW . fixedH 28) $ do
    for_ entries $ \(menuLabel, body) -> do
      let
        isOpen = current == menuLabel
      -- Each title covers the bar's height, its text in the middle.
      btn <- menuButtonWith' fillH menuLabel isOpen
      when (respClicked btn) (writeVar openMenu (if isOpen then "" else menuLabel))
      when
        (not isOpen && not (T.null current) && respHovered btn)
        (writeVar openMenu menuLabel)
      let
        cfg =
          (defaultPopupConfig (AnchorRect (respRect btn)))
            { cfgPlacement = PlacementBelow
            , cfgOffset = 0
            }
      -- The open flag is derived from the open menu's name, so the popup
      -- gets a constant and closing it is written here.
      (popupResp, _) <- popup' (constVar isOpen) cfg (columnWith (tight . gap 0) body)
      when (respClicked popupResp) (writeVar openMenu "")

popup' needs a Var Bool for whether it is open, but the state is derived from openMenu (“is this label the open one?”). constVar isOpen supplies a read-only Var; writes to it are no-ops. When the popup reports a click, the code writes the real state (openMenu) instead.

Important

Call overlays unconditionally. popup' is called for every title on every frame, even when closed; it draws nothing while closed. Wrapping it in when isOpen would change the widget ids of later siblings each time a menu opens.

4. Commands, menus, and shortcuts

Each menu row is a menuItem / menuItemShortcut. The row reports activation, and the action runs in the same frame:

    item menuRow action = whenM menuRow (writeVar openMenu "" >> action)

    fileMenu = do
      commandItem newCmd
      commandItem openCmd
      commandItem saveCmd
      commandItem saveAsCmd
      menuSeparator
      commandItem exitCmd

(newDocument, openDocument, saveDocument, and exitApp are ordinary NanoUI () actions defined in the full source; openDocument starts a non-blocking askOpenFileDialog.)

menuItemShortcut shows the chord label and binds it while the menu is open. To make the command work with the menu closed, bind the same chord with shortcut outside the menu:

  for_ [newCmd, openCmd, saveCmd, saveAsCmd, exitCmd] $
    \(_, chord, action) -> item (shortcut chord) action

Only the first binding of a chord in a frame fires, so the menu row and the view binding never both run it. The menu row is declared first, so while the menu is open its row wins.

  • menuItemDisabled "Undo" draws a dimmed row that cannot be clicked. The Edit menu draws a real item or a disabled one depending on textCanUndo / textCanRedo.
  • An action that opens a file dialog writes the new FileDialogId into a Var; dialogs are non-blocking, so the view is not interrupted.

5. Opening and saving files

The SDL backend’s file dialogs are asynchronous. useFileDialog polls a Var (Maybe FileDialogId), consumes the finished result once, and restores focus afterwards:

  useFileDialog openDlg $ \chosenPaths ->
    for_ (listToMaybe chosenPaths) $ \filePath -> do
      loaded <-
        liftIO (try (BS.readFile filePath) :: IO (Either SomeException BS.ByteString))
      case loaded of
        Left _ -> writeVar statusMsg ("Could not open " <> T.pack filePath)
        Right raw -> do
          -- Lenient decode: malformed bytes become U+FFFD instead of throwing.
          writeVar doc (textDocument (TE.decodeUtf8With (\_ _ -> Just '\xFFFD') raw))
          -- A new editor, without the old document's caret and undo history.
          modifyVar docGen (+ 1)
          writeVar docPath (T.pack filePath)
          writeVar docDirty False
          writeVar statusMsg ("Opened " <> T.pack filePath)
  useFileDialog saveDlg $ \chosenPaths ->
    for_ (listToMaybe chosenPaths) $ \filePath -> do
      saved <- writeDocument filePath =<< readVar doc
      if saved
        then do
          writeVar docPath (T.pack filePath)
          writeVar docDirty False
          writeVar statusMsg ("Saved " <> T.pack filePath)
        else writeVar statusMsg ("Could not save " <> T.pack filePath)

The Open command starts one and keeps its id:

    openCmd =
      ( "Open..."
      , cmdOrCtrl <> key 'o'
      , writeVar openDlg . Just =<< askOpenFileDialog defaultFileDialogOptions
      )

writeDocument is ordinary Haskell around liftIO, because file I/O is an event-driven side effect:

writeDocument :: FilePath -> TextDocument -> NanoUI Bool
writeDocument filePath doc = do
  result <-
    liftIO
      ( try (BS.writeFile filePath (TE.encodeUtf8 (documentText doc))) ::
          IO (Either SomeException ())
      )
  pure (isRight result)

Only documentText converts the document to a Text; editing works on the document directly and never rebuilds the whole string per keystroke.

6. The editor

textAreaDocument edits a Var TextDocument with line-sharing and an undo history. The primed variant supplies the Response the application needs:

    editorResp <-
      withKey gen $
        textAreaDocumentWith'
          (grow . minW 240 . minH 160 . fontSizeScale zoomVal)
          doc
    -- respChanged reports an edit, not a caret move.
    when (respChanged editorResp) (writeVar docDirty True)
    writeVar editorId (respId editorResp)
  • withKey gen changes the editor’s key when the document is replaced, so a new document gets a fresh caret, selection, and undo stack. Editing does not change gen.
  • respChanged is True after an edit and False after a caret move alone. Marking dirty on caret movement would make a read-only inspection look like a modification.
  • respId is saved for menu commands, which call runTextCommand editor Undo and the other TextCommands.

fontSizeScale zoomVal zooms the text without touching the document. The zoom commands are ordinary modifyVar calls clamped to a sane range.

7. Dirty state, title, and the close prompt

The title is derived every frame from the path and the dirty flag, and set declaratively:

    path <- readVar docPath
    dirty <- readVar docDirty
    let
      name = (if T.null path then "Untitled" else path) <> (if dirty then " *" else "")

-- ...after the layout, which returns the name as docName:
  setWindowTitleUi (docName <> " - nano-ui Notepad")

setWindowTitleUi does nothing when the title has not changed, so it is safe to call unconditionally. The imperative window actions (resizeWindowUi, maximizeWindowUi) are different: they run on every call and belong behind events.

The close request and the modal fit together like this:

    exitApp = do
      dirty <- readVar docDirty
      if dirty then writeVar aboutOpen False >> writeVar confirmExit True else quitUi

-- ...inside the view, called unconditionally:
  modal confirmExit "Unsaved changes" $ do
    label "Discard the changes to this document and exit?"
    rowWith fillW $ do
      flex
      whenM (button "Discard") quitUi
      whenM (button "Cancel") (writeVar confirmExit False)

There is one modal at a time: opening the exit prompt closes the About modal first. Overlay state is a Var Bool, and the modal call itself is unconditional.

8. Undo, redo, and the rest of the editing commands

The document keeps one undo history shared by typing, context-menu actions, and programmatic commands. The Edit menu reads the history state and runs commands by widget id:

    editMenu = do
      editor <- readVar editorId
      canUndo <- textCanUndo editor
      canRedo <- textCanRedo editor
      if canUndo
        then editItem "Undo" (cmdOrCtrl <> key 'z') Undo
        else menuItemDisabled "Undo"
      if canRedo
        then editItem "Redo" (cmdOrCtrl <> shift <> key 'z') Redo
        else menuItemDisabled "Redo"
      menuSeparator
      editItem "Cut" (cmdOrCtrl <> key 'x') Cut
      editItem "Copy" (cmdOrCtrl <> key 'c') Copy
      editItem "Paste" (cmdOrCtrl <> key 'v') Paste
      menuSeparator
      editItem "Select All" (cmdOrCtrl <> key 'a') SelectAll

TextCommand covers InsertText, Delete, Move, Select, Replace, ReplaceAll, Undo, Redo, Cut, Copy, Paste, and SelectAll. Using commands rather than writing the Var keeps undo intact; assigning a freshly computed TextDocument to doc clears the history.

Tip

A focused text editor claims its editing chords (Ctrl+Z, Ctrl+X, and so on), and shortcut skips claimed keys. The menu items still work because they run explicit commands, and the top-level bindings fire when focus is elsewhere.

9. The status bar

The status bar is a row of muted labels. Line count comes from the document without materializing its text; word and character counts are computed on demand from documentText:

statusBar :: Text -> Var TextDocument -> Var Text -> Float -> NanoUI ()
statusBar docName docVar statusMsg zoomVal = do
  doc <- readVar docVar
  message <- readVar statusMsg
  rowWith (tight . gap 12 . fillW . padXY 8 4) $ do
    labelWith (tight . fontMuted) message
    flex
    labelWith (tight . fontMuted) docName
    labelWith
      (tight . fontMuted)
      ("Lines: " <> T.pack (show (documentLineCount doc)))
    labelWith
      (tight . fontMuted)
      ("Zoom: " <> T.pack (show (round (zoomVal * 100) :: Int)) <> "%")

Reading doc here records a dependency: every edit repaints the status bar. That is intentional, because the line count changes. If the label’s text length changes often, pin its width with fixedW so the rest of the bar does not shift (see Recurring Bugs).

10. Try it

  • Add a recent-files list persisted between runs.
  • Replace the About modal with a floating window that can be moved and resized.
  • Add word wrap using the editor’s layout options.
  • Build a custom title bar with captionButtons and dragSpans (see Desktop Windows).

Next: Tutorial 3: Live Data & Background Work moves slow work off the frame and streams results back.

Tutorial 3: Live Data & Background Work

A frame must finish quickly. Searching a database, downloading a file, or waiting for a sensor cannot happen inside the view, or the window stops responding while the work runs. nano-ui moves that work to worker threads behind two handles, Task and Stream, and wakes the frame loop when there is something new to draw.

This tutorial builds three small panes from packages/nano-ui-demo/examples/Tasks.hs plus the live chart from LiveChart.hs. Run them with:

cabal run nano-ui-example-tasks
cabal run nano-ui-example-live-chart

Important

Compile with -threaded. The frame loop sleeps while a job runs, and the job runs on a Haskell thread while the loop is blocked.

1. Allocate handles once

A Task runs one typed job; a Stream carries one typed push channel. Both are created during setup, like a Var:

-- | One handle per job. The types fix each job's key and result.
data App = App
  { appSearch :: !(Task Text [Text])
  , appFlaky :: !(Task Int Text)
  , appProgress :: !(Stream () Int)
  }

newApp :: IO App
newApp = App <$> newTask <*> newTask <*> newStream

The handle fixes the key and result types, so a task cannot be reused for a different job.

The query field is debounced: searchInput' reports respChanged once typing pauses, not on every keystroke. The committed query becomes the task’s key:

searchCard :: App -> NanoUI ()
searchCard app = do
  query <- useVar ""
  committed <- useVar ""
  resp <- searchInput' "Find a word" query
  -- searchInput' reports a change once typing pauses, so a fast typist
  -- starts one job, not one per keystroke. Keying by the query itself would
  -- also work; each new key would just kill the job before it.
  when (respChanged resp) (writeVar committed =<< readVar query)
  key <- readVar committed
  status <- useTaskStatus (appSearch app) key (slowSearch key)
  -- While a new query runs, TaskRunning carries the previous results, so
  -- the list stays put instead of flashing empty.
  let
    (hits, running) = case status of
      TaskRunning previous -> (fromMaybe [] previous, True)
      TaskDone found -> (found, False)
      TaskFailed _ previous -> (fromMaybe [] previous, False)
  rowWith (tight . gap 8 . alignMid) $ do
    -- The spinner comes and goes, so it sits in a scope: the label after it
    -- keeps its widget id either way.
    scope (when running spinner)
    muted
      ( T.pack (show (length hits))
          <> " matches"
          <> if running then " (searching...)" else ""
      )
  rowWith (wrap . gap 6 . fillW) $
    forM_ (take 24 hits) $
      \w -> withKey w (label w)

slowSearch is ordinary blocking IO:

slowSearch :: Text -> IO [Text]
slowSearch q = do
  threadDelay 500000
  let
    found = filter (T.isInfixOf (T.toLower q)) wordList
  length found `seq` pure found

What the loop does with it:

  • The first frame calls useTaskStatus with a new key, which starts a worker thread.
  • Later frames return immediately. TaskRunning previous carries the last completed result, so the list does not flash empty when a new query starts.
  • The worker finishes and wakes the loop for a frame. The next call reports TaskDone.
  • Changing the key (typing a new committed query) cancels the old worker with an asynchronous exception and starts the new one.

When only the latest result matters, use useTask:

fileView :: App -> Text -> NanoUI ()
fileView app path = do
  contents <- useTask (appFile app) path (T.readFile (T.unpack path))
  label (fromMaybe "Loading..." contents)

3. Retrying a failed job

A job that throws becomes TaskFailed err previous. The exception is shown and the key includes an attempt counter, so pressing Retry reruns the same input:

flakyCard :: App -> NanoUI ()
flakyCard app = do
  attemptVar <- useVar (0 :: Int)
  attempt <- readVar attemptVar
  -- Same input every time, so the attempt number is the key: bumping it is
  -- what reruns the job.
  status <- useTaskStatus (appFlaky app) attempt (flakyRequest attempt)
  -- Each branch declares different widgets, so the whole case is one scope.
  scope $ case status of
    TaskRunning _ -> rowWith (tight . gap 8 . alignMid) $ do
      spinner
      label "Contacting the server..."
    TaskDone reply -> rowWith (tight . gap 8 . alignMid) $ do
      label reply
      whenM (button "Run again") (modifyVar attemptVar (+ 1))
    TaskFailed err _ -> rowWith (tight . gap 8 . alignMid) $ do
      danger (T.pack (displayException err))
      whenM (button "Retry") (modifyVar attemptVar (+ 1))

Two rules are visible here:

  • The key must change to rerun. Keying by the input alone would never restart an identical request, so the attempt number is part of the key.
  • Each branch is a whole scope. The branches declare different widgets, and the scope keeps the widget ids after the card stable as the status changes.

Cancellation is an asynchronous exception, so acquire resources inside the job with bracket:

job :: Text -> IO [Text]
job q = bracket (connect info) disconnect $ \conn -> runQuery conn q

4. Streams: many updates, one frame

A Stream is for work that pushes many times per job: progress ticks, sensor readings, tokens from a model. Its producer receives update and runs until it returns or is cancelled:

progressCard :: App -> NanoUI ()
progressCard app = do
  running <- useVar False
  checkbox "Run" running
  isRunning <- readVar running
  -- When "Run" is unticked 'useStream' is not called, and that alone kills
  -- the producer: there is no stop button to wire up. Ticking it again
  -- starts a fresh producer from the initial value. The scope keeps
  -- anything declared after it on stable ids.
  scope $ when isRunning $ do
    pct <- useStream (appProgress app) () (0 :: Int) $ \update ->
      forM_ [1 .. 100] $ \i -> do
        threadDelay 50000
        -- Each update wakes the loop for one frame; updates that arrive
        -- between frames share it.
        update (const i)
    progressBar (fromIntegral pct / 100)
    muted
      ( if pct >= 100
          then "Done. Untick and tick Run to start over."
          else T.pack (show pct) <> "%"
      )
  • To stop the stream, stop calling the hook. When isRunning is false, useStream is not called, and the producer is cancelled.
  • Updates coalesce. If the producer pushes five values between two frames, one frame runs and draws the latest value.
  • The stream’s state starts from the initial value. Restarting after a pause begins at 0 here, which is why the label explains how to start over. To resume from the last state, save it outside and pass it as the initial value (the live chart below does this).
  • Keep the hook outside tabs when the stream must keep running. Like a task, a stream is released when its hook is skipped for a whole frame.

A paused progress pane costs no frames.

5. A live chart

A Stream feeds the chart through a PlotCache. The cache is allocated once and passed to plot, which compares the chart with the one stored in the cache and rebuilds the diagram only when the data or theme changes:

data App = App
  { appFeed :: !(Stream () Feed)
  , appSaved :: !(Var Feed)
  , appPlots :: !PlotCache
  }

newApp :: IO App
newApp = App <$> newStream <*> newVar emptyFeed <*> newPlotCache

The view pauses by not calling the stream, and resumes from the saved feed:

view :: App -> NanoUI ()
view app = columnWith (padAll 16 . gap 12 . grow) $ do
  running <- useVar True
  isRunning <- readVar running
  saved <- readVar (appSaved app)
  -- While running, the stream is the source of truth; 'saved' only seeds a
  -- restarted producer and is ignored after its first frame. While paused,
  -- 'useStream' is not called and the saved samples are shown instead.
  feed <-
    scope $
      if isRunning
        then useStream (appFeed app) () saved $ \update -> forever $ do
          threadDelay 100000
          update advance
        else pure saved

  rowWith (tight . gap 8 . alignMid . fillW) $ do
    whenM (button (if isRunning then "Pause" else "Resume")) $ do
      when isRunning (writeVar (appSaved app) feed)
      writeVar running (not isRunning)
    muted
      (if isRunning then "Live: a sample every 100 ms" else "Paused: the loop is idle")

  let
    points = feedPoints feed
  -- A crosshair for reading values off the plot; the response reports the
  -- sample nearest the pointer.
  resp <-
    withCursorShape
      UiCursorCrosshair
      (plot (appPlots app) (fillW . minH 300) (walkChart points))

  rowWith (tight . gap 16 . alignMid . fillW) $ do
    let
      ys = fmap snd points
    -- Both parts come and go, so each sits in a scope.
    scope $
      if Seq.null ys
        then muted "Waiting for the first sample..."
        else do
          kv "min" (fmt (minimum ys))
          kv "max" (fmt (maximum ys))
          kv "last" (fmt (feedLevel feed))
    scope . for_ (plotHover resp) $ \hover ->
      kv
        "cursor"
        ( "#"
            <> T.pack (show (round (hoverDataX hover) :: Int))
            <> " = "
            <> fmt (hoverDataY hover)
        )

Feed, advance, and fmt are in LiveChart.hs; walkChart builds the chart value:

walkChart :: Seq (Double, Double) -> Chart
walkChart points =
  withGrid GridBoth
    . withLegend LegendNone
    . withXAxis "sample"
    . withYAxis "value" $
    chart [line "walk" points]

This is the chart after its first 200 samples, when the window is full:

A line chart of a 200-sample random walk with a grid, the x axis titled sample and the y axis titled value.

The chart API is small:

  • line, scatter, bar, area, and step build series from points (or category/value pairs for bars).
  • chart takes the list of series; withGrid, withLegend, withXAxis, withYAxis, and withTitle decorate it.
  • plot cache layout theChart draws it and returns a PlotResponse whose plotHover is the nearest data point under the pointer.

Building the Chart value every frame is cheap: it is a plain value, and comparing a few hundred points costs little. Rebuilding the diagram is expensive, and the PlotCache skips it when the chart and theme are unchanged.

6. Try it

  • Add a search that runs on every keystroke (key by the query) and compare the cancellation behaviour with searchInput'.
  • Stream markdown tokens into NanoUI.Markdown and append them incrementally (see Ecosystem Packages).
  • Virtualize a 100,000-row log list using getScrollMetricsUi (see Layout & Scrolling and the Logs.hs example).
  • Replace the streamed chart with a Task that loads a whole dataset once.

The full lifecycle reference is Async Work & Animation.

State & Var

The view runs every frame, and nothing survives between frames except state you keep. Most state lives in a Var, a typed mutable reference. Input widgets take the Var they display and mutate it directly, with no callback.

counter :: NanoUI ()
counter = rowWith (gap 8 . alignMid) $ do
  n <- useVar (0 :: Int)
  whenM (button "-") (modifyVar n (subtract 1))
  label . T.pack . show =<< readVar n
  whenM (button "+") (modifyVar n (+ 1))

Keep state, derive values

A value that changes over time and cannot be recomputed belongs in a Var. A value that can be recomputed belongs in a local binding, computed fresh every frame:

appView :: App -> NanoUI ()
appView app = do
  -- Keep: the inputs to the next frame.
  draft <- useVar ""
  settings <- readVar app.appSettings
  textInput draft

  -- Derive: computed from what was kept.
  len <- T.length <$> readVar draft
  label ("Signed in as " <> settings.nickname)
  label ("Draft: " <> T.pack (show len) <> " characters")

A derived value copied into a second Var drifts as soon as one writer forgets to update it.

Allocating state

There are two ways to create a Var, plus one for dynamic collections:

useVarnewVar
WhereInside the viewIn IO, during setup
TypesInt, Float, Double, Bool, Text, SortCol (useEnumVar for any Bounded/Enum type)Any type
IdentityThe call site, counted in call orderThe reference itself
Best forView-local state: a draft, an open flag, a filterApplication state: a document, a list, a settings record, a task

useVar needs no setup. Its initial value is used only when the slot is first created; call writeVar to reset it later. Its slots are counted separately from widget ids, but a conditional useVar still needs scope so later slots keep their positions:

advanced :: NanoUI ()
advanced = do
  showAdvanced <- useVar False
  checkbox "Advanced" showAdvanced
  scope . whenM (readVar showAdvanced) $ do
    level <- useVar (3 :: Float)
    slider 0 10 level

newVar can hold any type. Allocate it once in IO and pass it into the view:

data App = App
  { appSettings :: Var Settings
  , appTodos :: Var [Todo]
  }
  deriving (Generic)

newApp :: IO App
newApp = App <$> newVar (Settings 40 False "Ada") <*> newVar []

main :: IO ()
main = do
  app <- newApp
  runSdlApp defaultSdlOptions (appView app)

Passing the same Var to two widgets shares their state. Allocating a Var inside the view creates a fresh one on every pass.

For dynamic collections, VarMap gives one Var per key:

data RowState = RowState {editing :: Bool, rowDraft :: Text}
  deriving (Eq, Generic)

newRows :: IO (VarMap Int RowState)
newRows = newVarMap

todoRows :: VarMap Int RowState -> [Int] -> NanoUI ()
todoRows rows liveIds = do
  for_ liveIds $ \todoId -> withKey todoId $ do
    rowState <- varAt rows todoId (RowState False "")
    checkbox "Editing" rowState.editing
  retainVars (`elem` liveIds) rows

varAt returns the existing Var for a key or creates it. dropVar removes one; retainVars keep prunes everything whose key fails the predicate.

Projections and read-only views

A record in a Var gives a Var for each field. With Generic and OverloadedRecordDot:

data Settings = Settings
  { volume :: !Float
  , silent :: !Bool
  , nickname :: !Text
  }
  deriving (Eq, Generic)

settingsView :: Var Settings -> NanoUI ()
settingsView settings = do
  slider 0 100 settings.volume
  checkbox "Silent" settings.silent
  textInput settings.nickname

settings.volume is a Var Float projecting one field. Editing it does not repaint what reads only settings.nickname.

  • constVar x is read-only; writes are no-ops. Use it for a derived value shown in an input widget.
  • withValue val widget runs a Var widget against a temporary Var and returns the result and final value. nano-ui-emit uses it for emitChanged.
  • zoomVar getter setter projects a computed part of a larger Var; zoomVarL does the same from a lens.
  • forEachVar key list body gives per-element Vars that follow their keys across reorders. withElemVars is the variant for filtering or sorting yourself.

What a write costs

readVar records a dependency for the current pass. When the value changes, nano-ui classifies the write:

WriteWhenCost
ForwardWritten before any read this passOne pass; later reads see the new value
BackwardWritten after being read earlier this passA second view pass, with one-shot input events cleared
UnchangedNew value == old valueNo-op: no pass, no repaint

Two habits follow:

  • Place readVar below the widget that edits the value when the order is free. Typing then resolves in one pass; reading above it costs a second pass per change.
  • Do not readVar a value only to pass it back into the same widget. Passing the Var is enough.

A freshly computed NaN is never unchanged (NaN /= NaN), so writing one every pass redraws forever. Guard the write.

Tip

When an input widget is bound to a Var and nothing in the view calls readVar on it, only that widget repaints on change. A readVar promotes the write to a full-window repaint.

peekVar reads without recording a dependency. readVarIO, writeVarIO, and modifyVarIO are the thread-safe versions; after a background write, call the callback from askWake to schedule a frame. Never touch the UI Context from a worker thread.

Lifetime and resources

Tasks, streams, image handles, drag handles, plot caches, and file-dialog ids are allocated in IO during setup and stored in the application record, exactly like a newVar:

data Editor = Editor
  { editorDocument :: Var TextDocument
  , editorSearch :: Task Text [Text]
  }

newEditor :: IO Editor
newEditor = Editor <$> newVar emptyDocument <*> newTask

A handle allocated inside the view is a per-frame leak. If the view needs a new handle when a key changes, allocate it in setup and key its use, or use a hook that owns its lifetime (useTask, useStream, useImageRgba).

Where should this value live?

The value…Put it in
Is a draft, open flag, selection, or filter used by one viewuseVar / useEnumVar
Is a list, document, settings record, or handlenewVar in IO, stored in the app record
Belongs to one row of a dynamic listA keyed Var (forEachVar, withElemVars, VarMap)
Already lives outside the UIAn IORef or app record, woken with askWake
Is a pure state machine with a clear msg -> model -> modelThe Elm-style reducer

Next: Widget Identity & Keys for keyed lists and conditional UI, or Elm-Style State for the reducer alternative.

Widget Identity & Keys

Widget state (caret position, text selection, an open drop-down, a scroll offset, per-row local Vars) is stored under the widget’s id. Ids come from the call order among siblings. If the order changes, the state jumps to a different widget.

How ids are assigned

Each container assigns ids to its children counting from zero. A column with three labels has children 0, 1, and 2; a nested row starts its own count.

Two separate id spaces exist:

  • Widget ids, for layout nodes, focus, input state, and scroll state.
  • useVar slots, counted in call order, with their own counter and key space.

A conditional widget does not shift useVar slots, and a conditional useVar does not shift widget ids. Both still shift their own kind; scope prevents that.

Rule 1: keep the call order stable

The structure of the calls should be the same on every frame. Straight-line code and ordinary if/case over widget values are fine; conditionally calling a widget changes the count for everything after it.

Rule 2: wrap conditional subtrees in scope

scope reserves one slot whether or not its body runs, so later siblings keep their ids:

-- BAD: when showDetails is False, every later sibling shifts down one id.
detailsBad showDetails detailsVar =
  when showDetails $ do
    label "Details..."
    textInput detailsVar

-- GOOD: one stable slot, whether or not the body runs.
detailsGood showDetails detailsVar =
  scope $
    when showDetails $ do
      label "Details..."
      textInput detailsVar

The same applies to a branch that swaps two widgets:

todoLabel :: Bool -> Var Text -> Todo -> NanoUI ()
todoLabel isEditing draft todo =
  scope $
    if isEditing
      then textInput draft
      else label (todoText todo)

scope is cheap. Use it around anything that may not run every frame: a conditional block, a case whose branches declare different widgets, a loading placeholder. Overlays are the exception: call them unconditionally (Rule 4).

Rule 3: key dynamic lists with withKey

Never key a list item by its index. Wrap each item in withKey with a stable, sibling-unique key: an item id, a UUID, a file path.

todoList :: [Todo] -> NanoUI ()
todoList items =
  column $ for_ items $ \todo ->
    withKey (todoId todo) $
      row $ do
        label (todoText todo)
        whenM (button "Delete") (deleteItem (todoId todo))

Without the key, removing the first row makes every later row inherit the previous row’s caret, selection, and local Vars. With it, the row’s state travels with the item through filters, sorts, and reorders.

withKey is also how per-row useVar calls stay attached to their row:

expandable :: [Todo] -> NanoUI ()
expandable items =
  for_ items $ \todo ->
    withKey (todoId todo) $ do
      expanded <- useVar False -- follows todoId, not the row index
      checkbox (todoText todo) expanded

The full todo-list tutorial builds this up step by step: Build a Todo List.

Lists that use Vars per element

For a list stored in a Var [a], forEachVar key list body gives each element a Var a and puts the body under the element’s key automatically. withElemVars does the same but passes the elements and an at accessor, for when you filter or sort the list yourself:

visibleTodos :: Var [Todo] -> NanoUI ()
visibleTodos items =
  withElemVars todoId items $ \todos at ->
    forM_ (filter visible todos) $ \todo ->
      withKey (todoId todo) (todoRow (at todo))

Rule 4: call overlays unconditionally

modal, window, and popup take a Var Bool for their open state and draw nothing while it is false. Call them every frame:

deleteDialog :: NanoUI ()
deleteDialog = do
  confirm <- useVar False
  whenM (button "Delete...") (writeVar confirm True)

  modal confirm "Delete the file?" $
    whenM (button "Delete") (deleteFile >> writeVar confirm False)

Wrapping the call in when shifts every later sibling’s id each time the dialog opens or closes. For derived open state, pass constVar and use the primed variants.

Rule 5: retain state for dynamic collections

Widget and Var state under a key survives the key not being drawn for a while: hide a filtered row and show it again, and its draft is still there. That state is not freed automatically. For an unbounded collection (a chat log, a set of tabs, a streaming table), keep the per-row state in a VarMap and prune it periodically:

pruneRows :: VarMap Int RowState -> [Int] -> NanoUI ()
pruneRows rowVars liveIds = retainVars (`elem` liveIds) rowVars

Prune too little and state for deleted rows accumulates; prune too eagerly and a filtered-out row loses its draft. Choose per collection: a todo row can keep its draft while hidden, a row from a 100,000-row log cannot.

Symptoms and their fixes

SymptomLikely cause
Typing in one field moves the caret in anotherA conditional widget before it changed the id count; wrap the branch in scope.
Deleting a row resets the next row’s edit stateThe list is keyed by index; use withKey item.id.
A modal’s fields do not respond after opening another modalThe modal is called conditionally; call it unconditionally with a Var Bool.
State appears in the wrong row after sortingPer-row useVars are outside withKey, or the key is the index.
Memory grows with every streamed entryPer-row state is in an unbounded VarMap; retainVars.

For allocation, projections, and write costs, read State & Var. For the lifecycle that makes ids reset, read Architecture & Internals.

Elm-Style State

nano-ui-emit is the reducer-based alternative to Var: the view emits typed messages, and a pure reducer folds them into a model.

data Model = Model {counter :: Int, name :: Text}
  deriving Eq

data Msg = Increment | Decrement | SetName Text

view :: Model -> NanoUIE Msg ()
view m = column $ do
  heading "Profile"
  row $ do
    emitWhen (button "-") Decrement
    label (T.pack (show m.counter))
    emitWhen (button "+") Increment
  emitChanged textInput m.name SetName

update :: Msg -> Model -> Model
update Increment m = m {counter = m.counter + 1}
update Decrement m = m {counter = m.counter - 1}
update (SetName n) m = m {name = n}

main :: IO ()
main = runSdlAppReduce defaultSdlOptions update (Model 0 "Ada") view

Widgets, layout, focus, and keys are unchanged. Only the state model differs.

Emitting

NanoUIE msg a is a UI action that can emit only msg. There is no Typeable constraint and no runtime type test: emitting the wrong type is a compile error.

A NanoUIE view is an ordinary NanoUI action with the view’s message sink in scope (an implicit parameter, ?emit). Every core widget runs in it unchanged, so NanoUI.Emit re-exports NanoUI and adds the functions below. An emit inside a container, an overlay body, a tab or a menu callback goes to the sink of the view around it.

FunctionEmits when
emit msgImmediately
emitWhen widget msgThe widget returns True (button click, submitted field)
emitChanged widget old toMsgA Var a -> NanoUI r widget’s final value differs from old
emitEdited widget old toMsgThe widget’s value changed and respChanged is true
settingsForm :: Float -> NanoUIE Msg ()
settingsForm volume =
  column $ do
    label "Settings"
    withKey ("form" :: Text) $ do
      emitChanged (slider 0 100) volume SetVolume
      emitWhen (button "Save") Save

emitChanged runs the widget against a temporary Var seeded with the model value and emits the new value only if it differs. emitEdited is its stricter form for text fields, where programmatic writes should not look like edits.

Give each view a signature in its own message type, view :: Model -> NanoUIE Msg (). The runners take a view of that shape, and a local helper defined outside a view needs the signature too, or GHC finds no sink for its emit.

Caution

Emit only inside an event branch. emit Save at the top level of a view emits on every pass, and the reducer runs every message. The adapters above already guard on the widget’s event.

Messages are collected across every pass of a frame, including local-Var rebuilds, and reduced at frame end in emission order.

Components

mapMessages embeds a child view whose message type is smaller than its parent’s:

data ChildMsg = ChildIncrement | ChildReset

data ParentMsg = Child ChildMsg | SaveAll

childView :: Int -> NanoUIE ChildMsg ()
childView n = row $ do
  emitWhen (button "+") ChildIncrement
  label (T.pack (show n))
  emitWhen (button "Reset") ChildReset

parentView :: Int -> NanoUIE ParentMsg ()
parentView n = column $ do
  mapMessages Child (childView n)
  emitWhen (button "Save All") SaveAll

Messages go straight to the parent’s sink in traversal order, with no intermediate queue. mapMessages changes only the message type and leaves widget ids unchanged, so a list still needs withKey around each component, exactly as a Var-based row does.

For local UI state, keep using Var inside the component; the durable model stays in NanoUIE.

Running and testing

runSdlAppReduce and runRgfwAppReduce take an update function, an initial model, and a view:

main :: IO ()
main = runSdlAppReduce defaultSdlOptions update initialModel view

The view runs with the current model, messages are reduced at frame end, and the drawing shown for that frame is from before the reduction. If the model changed, another frame is requested to draw it.

For tests and embedded uses:

FunctionReturns
runNanoUIE viewNanoUI (result, [msg])
runFrameE ctx input viewIO (result, [msg], DrawData, Bool)
runFrameReduce update ctx input model viewIO (result, model, [msg], DrawData, Bool)
runClickReduce update ctx input model view posIO (model, [msg], Bool)

runFrameReduce requests a follow-up frame when the reduced model differs. reduceMessages and reduceUpdates fold message lists.

When to use it

Var-firstElm-style
State shapeDistributed through the view and app recordOne model value
TransitionsDirect mutationExplicit Msg and one update
TestingWidget-level with NanoUI.TestingPure update tests plus frame tests
Local widget stateNaturalStill use Var for drafts and open flags
Best forTools and editors with external stateState machines, wizards, undo/history, small apps with a clear model

The styles mix. An emitting view can keep Vars for transient UI state; a Var-based view can run a small component with runNanoUIE and act on the returned messages.

Keep update pure: file and network I/O belong in the view’s event branch, or in an effect queue run outside the reducer. Do not mirror model state in Vars to read it a frame earlier; keep durable state in the model only.

Layout & Scrolling

Every container takes its layout as an argument: row and column take a layout, and rowWith/columnWith take a Layout -> Layout function first. Modifiers compose with (.), and when two set the same property, the leftmost wins.

settingsPanel :: NanoUI ()
settingsPanel =
  columnWith (gap 8 . padAll 12 . fillW) $ do
    label "Settings"
    whenM (buttonWith (fixedW 120) "Apply") apply

Logical pixels, origin top-left, y downward. Backends handle DPI scaling.

Each example below expands to its code and the frame it renders. The frames add padAll 14 and fill the window so the spacing shows.

Containers

A container wraps a NanoUI block and arranges the widgets it draws. The plain form (row) uses the inherited layout; the ...With form takes a modifier function.

row / rowWith: children left to right
rowExample =
  rowWith (gap 8) $
    mapM_ (void . button) ["One", "Two", "Three"]
Three buttons in a horizontal row.
column / columnWith: children top to bottom
columnExample =
  columnWith (gap 8) $
    mapM_ (void . buttonWith fillW) ["One", "Two", "Three"]
Three full-width buttons stacked vertically.
grid / gridWith: rows of equal columns
gridExample =
  gridWith 3 (gap 8 . fillW) $
    mapM_
      (\i -> void (buttonWith fillW ("Cell " <> T.pack (show (i :: Int)))))
      [1 .. 6]
Six buttons in a three-column grid.
panel / panelWith: a styled surface
panelExample =
  panelWith (fixedW 300 . padAll 12 . gap 6) $ do
    heading "Deployments"
    muted "3 services running"
A panel with a heading and a muted line of text.
card: a full-width panel with a minimum width
cardExample =
  card $ do
    heading "Build #482"
    muted "Passed in 1m 42s"
A full-width card showing a build result.
callout: a panel tinted by a semantic colour
calloutExample = do
  warnCol <- themeWarning <$> uiTheme
  callout warnCol $ do
    heading "Certificate expiring"
    muted "TLS certificate expires in 3 days."
An amber callout warning about an expiring certificate.
layers: children stacked in one box, later on top
layersExample =
  layers $ do
    let badgeRed = colorRGB 204 64 64
    void (buttonWith (fixedWH 170 40) "Inbox")
    styled (panelStyle (background badgeRed . borderColor badgeRed)) $
      panelWith (alignEnd . alignTop . padXY 6 1) $
        labelWith (fontColor colorWhite) "3"
A button with a small red count badge stacked at its top right.
center: content centred on both axes
centerExample =
  panelWith (fillW . fillH) $
    center (label "Centred on both axes")
A label centred in a large panel.
responsive / responsiveRowCol: one layout when wide, another when narrow

responsive breakpoint wide narrow body picks a container by window width; responsiveRowCol is the common row/column pair.

responsiveExample =
  responsiveRowCol 640 (tight . gap 8 . fillW) $ do
    panelWith (fillW . padAll 12) (label "Sidebar")
    panelWith (fillW . padAll 12) (label "Content")

A 420 px window:

Sidebar and Content stacked vertically at 420 pixels wide.

The same view at 760 px:

Sidebar and Content side by side at 760 pixels wide.
mouseArea: hover and clicks for a whole region
mouseAreaExample = do
  (_, area) <-
    panelWith fillW $
      mouseArea (fillW . padAll 10 . gap 4) $ do
        label "build.log"
        muted "Click anywhere in this card"
  when (respClicked area) (muted "opening build.log")
A card containing two lines, wrapped in a mouse area.
separator: a one-pixel rule
separatorExample =
  columnWith (fillW . gap 8) $ do
    label "Above the rule"
    separator
    label "Below the rule"
A horizontal rule between two labels.
spacer: flexible space between siblings
spacerExample =
  rowWith (fillW . alignMid) $ do
    label "Left"
    spacer (Grow 1) Fit
    label "Right"
Left and Right labels pushed to opposite ends of a row.

Modifiers

Modifiers are Layout -> Layout functions, so they chain with (.). The leftmost wins when two set the same property, so tight . padAll 14 has no padding while padAll 14 . tight keeps it.

Sizing

fillW: take all remaining width
fillWExample =
  rowWith (fillW . gap 8) $ do
    void (buttonWith (fixedW 110) "fixedW 110")
    void (buttonWith fillW "fillW")
A fixed-width button beside a button filling the rest of the row.
fillH: take all remaining height
fillHExample =
  rowWith (fillW . gap 8 . fixedH 96) $ do
    panelWith (fixedW 120 . fillH) (label "fillH")
    panelWith (fixedW 120) (label "natural")
One panel filling a fixed-height row and one panel at its natural height.
grow: fill both axes (fillW . fillH)
growExample =
  rowWith (grow . gap 8) $ do
    panelWith (fixedW 120) (label "fixedW 120")
    panelWith grow (label "grow")
A small fixed panel beside a panel growing to fill the remaining space.
fixedW: an exact width
fixedWExample =
  rowWith (gap 8) $ do
    void (buttonWith (fixedW 60) "60")
    void (buttonWith (fixedW 110) "110")
    void (buttonWith (fixedW 170) "170")
Three buttons at 60, 110, and 170 pixels wide.
fixedH: an exact height
fixedHExample =
  rowWith (gap 8 . fillW) $ do
    void (buttonWith (fixedH 30) "fixedH 30")
    void (buttonWith (fixedH 56) "fixedH 56")
    void (buttonWith (fixedH 82) "fixedH 82")
Three buttons at 30, 56, and 82 pixels tall.
fixedWH: an exact width and height
fixedWHExample =
  rowWith (gap 8) $ do
    void (buttonWith (fixedWH 120 36) "120 x 36")
    void (buttonWith (fixedWH 200 72) "200 x 72")
A small button and a larger button with explicit sizes.
minW: a lower width bound
minWExample =
  rowWith (fillW . gap 8) $ do
    void (buttonWith (minW 200) "minW 200")
    void (button "natural")
A wide minimum-width button beside a natural-width button.
maxW: an upper width bound
maxWExample =
  rowWith (fillW . gap 8) $ do
    void (buttonWith (fillW . maxW 160) "maxW 160")
    void (buttonWith fillW "fillW")
A capped button beside an uncapped fill-width button.
minH: a lower height bound
minHExample =
  rowWith (fillW . gap 8) $ do
    panelWith (minH 80 . fillW) (label "minH 80")
    panelWith fillW (label "natural")
A panel forced to 80 pixels tall beside one at its natural height.
maxH: an upper height bound
maxHExample =
  rowWith (fillW . gap 8 . fixedH 100) $ do
    panelWith (maxH 50 . fillW) (label "maxH 50")
    panelWith (fillW . fillH) (label "fillH")
A panel capped at 50 pixels beside one filling a 100-pixel row.
percent: a percentage of the parent's width
percentExample =
  rowWith (fillW . gap 8) $ do
    panelWith (percent 30) (label "percent 30")
    panelWith (percent 60) (label "percent 60")
Two panels sized to 30 and 60 percent of their row.
aspect: keep a width / height ratio
aspectExample =
  panelWith (fillW . aspect (16 / 9)) $
    center (label "16 : 9")
A wide panel with a 16 by 9 aspect ratio.

Spacing

padAll: equal padding on all four edges
padAllExample =
  panelWith (fixedWH 260 120 . padAll 20) $
    panelWith (fillW . fillH) (label "padAll 20")
A panel inset by 20 pixels on every side inside another panel.
padXY: horizontal, then vertical, padding
padXYExample =
  panelWith (fixedWH 280 100 . padXY 32 10) $
    panelWith (fillW . fillH) (label "padXY 32 10")
A panel with wide horizontal padding and shallow vertical padding.
padLeft: left padding
padLeftExample =
  panelWith (fixedWH 260 100 . padLeft 48) $
    panelWith (fillW . fillH) (label "padLeft 48")
A panel inset 48 pixels from the left.
padRight: right padding
padRightExample =
  panelWith (fixedWH 260 100 . padRight 48) $
    panelWith (fillW . fillH) (label "padRight 48")
A panel inset 48 pixels from the right.
padTop: top padding
padTopExample =
  panelWith (fixedWH 260 120 . padTop 40) $
    panelWith (fillW . fillH) (label "padTop 40")
A panel inset 40 pixels from the top.
padBottom: bottom padding
padBottomExample =
  panelWith (fixedWH 260 120 . padBottom 40) $
    panelWith (fillW . fillH) (label "padBottom 40")
A panel inset 40 pixels from the bottom.
padLRTB: left, right, top, bottom padding
padLRTBExample =
  panelWith (fixedWH 280 120 . padLRTB 40 8 30 12) $
    panelWith (fillW . fillH) (label "padLRTB 40 8 30 12")
A panel with different padding on each side.
gap: space between children
gapExample =
  columnWith (fillW . gap 12) $ do
    rowWith (gap 4) $ mapM_ (void . button) ["gap 4", "B", "C"]
    rowWith (gap 24) $ mapM_ (void . button) ["gap 24", "B", "C"]
Two rows with tight and wide spacing between their buttons.
tight: remove container padding, keep the gap
tightExample =
  rowWith (gap 16 . fillW) $ do
    panelWith (padAll 14 . gap 6 . fillW) $ do
      muted "padAll 14"
      label "default padding"
    panelWith (tight . padAll 14 . gap 6 . fillW) $ do
      muted "tight"
      label "no padding"
Two panels comparing normal padding with tight.

Alignment and wrapping

alignStart, alignCenter, and alignEnd place a child across a column. alignTop, alignMid, and alignBottom place it down a row. An alignment modifier positions the node it is applied to inside its parent. alignMid on a row moves the row; to centre the row’s children, put alignMid on each child.

alignStart: the left edge
alignStartExample =
  panelWith (fixedWH 300 110 . padAll 10) $
    void (buttonWith (alignStart . fixedW 140) "alignStart")
A button aligned to the left edge of a panel.
alignCenter: the horizontal centre
alignCenterExample =
  panelWith (fixedWH 300 110 . padAll 10) $
    void (buttonWith (alignCenter . fixedW 140) "alignCenter")
A button centred horizontally in a panel.
alignEnd: the right edge
alignEndExample =
  panelWith (fixedWH 300 110 . padAll 10) $
    void (buttonWith (alignEnd . fixedW 140) "alignEnd")
A button aligned to the right edge of a panel.
alignTop: the top edge
alignTopExample =
  panelWith (fixedWH 300 110 . padAll 10) $
    rowWith (fillW . fillH . gap 8) $
      void (buttonWith (alignTop . fixedW 140) "alignTop")
A button aligned to the top of a tall row.
alignMid: the vertical centre
alignMidExample =
  panelWith (fixedWH 300 110 . padAll 10) $
    rowWith (fillW . fillH . gap 8) $
      void (buttonWith (alignMid . fixedW 140) "alignMid")
A button centred vertically in a tall row.
alignBottom: the bottom edge
alignBottomExample =
  panelWith (fixedWH 300 110 . padAll 10) $
    rowWith (fillW . fillH . gap 8) $
      void (buttonWith (alignBottom . fixedW 140) "alignBottom")
A button aligned to the bottom of a tall row.
alignBaseline: share the first baseline
alignBaselineExample =
  rowWith (gap 12 . fixedW 340) $ do
    labelWith (fontSize 24 . alignBaseline) "Title"
    labelWith alignBaseline "baseline text"
A large and a small label lined up on their baselines.
wrap: start a new line when a child does not fit
wrapExample =
  rowWith (wrap . gap 6 . fixedW 280) $
    mapM_ (void . button) ["haskell", "immediate", "mode", "layout", "sdl", "widgets"]
Buttons wrapped onto two lines.
lineGap: space between wrapped lines
lineGapExample =
  columnWith (fillW . gap 16) $ do
    let labels = ["haskell", "immediate", "mode", "layout", "sdl", "widgets"]
    muted "lineGap 2"
    rowWith (wrap . lineGap 2 . gap 6 . fixedW 280) $ mapM_ (void . button) labels
    muted "lineGap 18"
    rowWith (wrap . lineGap 18 . gap 6 . fixedW 280) $ mapM_ (void . button) labels
Two wrapped rows comparing a small and a large line gap.
lineAlign: align wrapped lines in the container
lineAlignExample =
  rowWith (wrap . lineAlign LinesCenter . gap 6 . fixedW 210) $
    mapM_ (void . button) ["alpha", "beta", "gamma", "delta", "epsilon"]
Wrapped buttons with the final line centred.

Positioning and pointer behavior

pinAt takes a child out of the flow. pointer sets whether an overlapping region consumes pointer events (PointerBlock) or passes them to what is underneath (PointerPass). The pointer examples park the pointer over the overlay.

pinAt: position absolutely inside the parent
pinAtExample =
  panelWith (fixedWH 280 110 . padAll 12) $ do
    heading "Inbox"
    muted "The badge is pinned, not stacked."
    let badgeCol = colorRGB 204 64 64
    styled (panelStyle (background badgeCol . borderColor badgeCol)) $
      panelWith (pinAt 6 (-6) . alignEnd . alignTop . padXY 6 1) $
        labelWith (fontColor colorWhite) "12"
A count badge pinned to the top-right corner of a panel.
pointer PointerBlock: consume pointer events over a region
pointerBlockExample =
  layers $ do
    void (buttonWith (fixedWH 200 44) "Open logs")
    styled (panelStyle (background (colorRGBA 0 0 0 150) . borderWidth 0)) $
      panelWith (pinAt 0 0 . grow . pointer PointerBlock . padAll 6) $
        muted "scrim"
A dimming scrim over a button labelled Open logs.
pointer PointerPass: let pointer events through
pointerPassExample =
  layers $ do
    void (buttonWith (fixedWH 200 44) "Open logs")
    labelWith (pinAt 8 (-6) . pointer PointerPass) "decorative label"
A decorative label over a button that still receives hover.

Scrolling

A scroll container needs a bounded viewport on its scrolling axis:

itemList :: [Text] -> NanoUI ()
itemList items =
  scrollWith (fixedH 300 . fillW) $
    column $ for_ items label

Without a bounded ancestor, grow can leave a scroller with nothing to size against.

  • scrollWith returns the body’s result.
  • scrollArea also returns the scroller’s WidgetId, for programmatic control.
  • scroll2DWith / scrollArea2D scroll both axes.
  • A scroller that fills both axes (grow) drops its well and paints the page background (pageBackground, themeWindow by default). Scope a view in styled panelPage for a page on the panel surface.

Virtualization

For 10,000+ rows, build only the visible slice and use spacers for the rest. Capture the scroller id and read the previous layout with getScrollMetricsUi:

virtualizedList :: [Item] -> NanoUI ()
virtualizedList items = do
  sid <- currentId
  scrollWith (fixedH 500 . fillW) $ do
    metrics <- getScrollMetricsUi sid
    let rowH = 28
        total = length items
        (offsetY, viewH) = case metrics of
          Nothing -> (0, 0)
          Just m  -> (v2Y (scrollOffset m), rectH (scrollViewport m))
        firstVisible = max 0 (floor (offsetY / rowH))
        lastVisible  = min total (firstVisible + ceiling (viewH / rowH) + 2)
        topH    = fromIntegral firstVisible * rowH
        bottomH = fromIntegral (total - lastVisible) * rowH
        visibleRows = take (lastVisible - firstVisible) (drop firstVisible items)
    columnWith (tight . fillW) $ do
      when (topH > 0) (spacer Fit (Fixed topH))
      for_ visibleRows $ \item ->
        withKey (itemId item) $
          labelWith (fixedH rowH . fillW) (itemTitle item)
      when (bottomH > 0) (spacer Fit (Fixed bottomH))

scrollViewport excludes padding and the scrollbar lane; use it instead of the container rectangle for the visible height. scrollOffset is the current offset. The metrics read registers a layout dependency, so a resize triggers another frame.

See packages/nano-ui-demo/examples/LongList.hs (100,000 rows) and Logs.hs (sticky-to-bottom).

Programmatic scrolling and wheel tuning

reveal :: WidgetId -> Float -> Int -> Bool -> NanoUI ()
reveal sid rowH index shouldReveal =
  when shouldReveal $
    scrollRectIntoViewUi sid (Rect 0 (fromIntegral index * rowH) 1 rowH) ScrollNearest ScrollInstant

From a view: scrollToUi, scrollByUi, scrollPagesUi, scrollRectIntoViewUi, setScrollOffsetUi, setScrollStepUi. The Context variants (scrollTo, getScrollOffset2D, …) are for IO.

One wheel notch scrolls 60 logical pixels by default. For fixed-height rows, set the step so a notch is one row (askContext is in NanoUI.Monad):

oneRowPerNotch :: Float -> NanoUI ()
oneRowPerNotch rowHeight = do
  ctx <- askContext
  liftIO $ setScrollTuning ctx defaultScrollTuning
    {scrollWheelStep = rowHeight, scrollSmoothTime = 0}

setScrollStepUi sid px overrides one scroller; 0 restores the context step.

Nesting and chrome

scrollArea, panel, and card each draw a background, border, and radius. Nesting them inside a popup or dialog draws a box in a box. Draw the chrome at one level only: flatten the inner surface with styled (inputStyle (background colorTransparent . borderWidth 0)), or give the outer dialog padAll 0 and pad the inner body.

See Styling Gotchas and Troubleshooting for the recurring cases.

Widget Gallery

Every widget is an ordinary function exported from NanoUI.

Buttons, a checkbox, text field, select, slider, switch, knob, spinner, progress bar, and sparkline in one nano-ui row.
Buttons, inputs, and canvas controls in one frame. Rendered with nano-ui-svg.

Find a widget by task

You need…Read
Buttons, menus, checkboxes, toggles, radios, selects, combo boxes, sliders, numeric fields, text fields, color pickersButtons, Menus & Inputs
Multi-line text, documents, undo history, IME-aware editingText Editing
Tabs, sortable tables, treesTabs, Tables & Trees
Modals, floating windows, popups, tooltips, context menusOverlays & Windows
Drag-and-drop, tab reordering, dockable pane gridsDrag & Drop
Knobs, switches, progress bars, spinners, sparklines, canvas drawingCustom Drawing & Controls
Colors, themes, interaction states, fontsStyling & Theming
Live examples of every styling modifierInteractive Styling Lab
Non-obvious styling behaviorStyling Gotchas

Containers (row, column, grid, panel, card, layers, …) and sizing are covered once in Layout & Scrolling.

How widget names work

The same four forms appear across the library:

FormMeaning
button "Save"Base form. Returns Bool (activated) or ()
button'Primed form. Returns a Response
buttonWith (fixedW 120)Takes a Layout -> Layout modifier first
buttonConfigured cfgTakes a config record; every config has a default...Config

A Response carries identity and interaction state:

Field / helperMeaning
respIdThe widget’s WidgetId (focus, menus, scroll targets)
respRectThe widget’s rectangle from the previous frame
respHoveredPointer is over the widget
respPressedHeld down, not yet released
respClickedActivated: left click, Enter, or Space
respChangedThe value was edited
respSubmittedCommitted, for example Enter in a text field
respHeldWith / respClickedWithWhich MouseButton is held / clicked
respRightPressed / respRightClickedSecondary-button variants

Anything with a Response can be passed to tooltip, contextMenu, and the focus helpers.

Buttons, Menus & Inputs

Buttons

button   :: Text -> NanoUI Bool
button'  :: Text -> NanoUI Response
buttonWith :: (Layout -> Layout) -> Text -> NanoUI Bool
buttonContent :: NanoUI () -> NanoUI Bool
iconButton :: Svg -> Text -> NanoUI Bool

button returns True only on the frame it is activated, by left click or by Enter/Space while focused:

saveButton = whenM (button "Save Document") (liftIO saveFile)

Use the primed form when you need the response: hover state, a rectangle for an anchored popup, or a tooltip.

refreshButton = do
  resp <- button' "Refresh"
  tooltip resp "Poll the remote server for updates"
  when (respClicked resp) refresh

buttonContent puts any view inside a button. The content is for display; a click anywhere on the button activates it:

iconSaveButton =
  whenM (buttonContent $ rowWith (gap 6 . alignMid) $ do
            svgIcon 16 saveIcon
            label "Save") $
    saveDocument

iconButton puts an icon before the label; an empty label gives an icon-only square button.

menuItem draws a row and returns True when clicked. menuItemShortcut also displays a chord and binds it while the menu is open. menuSeparator and menuItemDisabled complete the row set.

A menu bar is a menuButton' plus a popup' anchored to its rectangle:

fileMenu = do
  fileOpen <- useVar False
  isOpen <- readVar fileOpen
  btn <- menuButton' "File" isOpen
  when (respClicked btn) (writeVar fileOpen (not isOpen))

  let cfg = (defaultPopupConfig (AnchorRect (respRect btn)))
              {cfgPlacement = PlacementBelow, cfgOffset = 0}
  (popupResp, _) <- popup' (constVar isOpen) cfg $
    columnWith (tight . gap 0) $ do
      whenM (menuItemShortcut "New" (cmdOrCtrl <> key 'n')) newDocument
      whenM (menuItem "Open...") openDocument
      menuSeparator
      whenM (menuItem "Exit") (writeVar fileOpen False >> quitUi)
  when (respClicked popupResp) (writeVar fileOpen False)

constVar supplies a read-only open flag derived from the menu state, and the code writes a click on the popup back to the real Var.

Important

Call overlays unconditionally. popup' draws nothing while closed, but wrapping it in when isOpen changes later widget ids each time the menu opens.

Bind a command chord outside the menu as well as on its row, so it works with the menu closed:

saveBindings :: NanoUI ()
saveBindings = do
  whenM (menuItemShortcut "Save" saveChord) saveDocument
  whenM (shortcut saveChord) saveDocument

Only the first binding of a chord in a frame fires, so the open menu’s row wins. A complete menu bar is built in Build a Notepad.

contextMenu attaches a right-click menu to any widget with a Response:

rowMenu = do
  (_, rowResp) <- mouseArea (fillW . gap 6) (label "Right-click for actions")
  void $ contextMenu rowResp $ do
    whenM (menuItem "Inspect") inspectRow
    whenM (menuItem "Copy") copyRow

Input widgets

Input widgets read and write the Var they display, and store caret and selection state under their widget id. To reset a field, write the Var; to react to an edit, use the primed form’s Response.

WidgetSignatureDescription
checkboxText -> Var Bool -> NanoUI ()Two-state box with a label
toggleButtonTone -> Text -> Var Bool -> NanoUI ()Two-state button, filled in the tone while on
radioFoldable f => f Text -> Var Int -> NanoUI ()Radio group over indexed options
boundedRadio(Bounded a, Enum a) => (a -> Text) -> Var a -> NanoUI ()Radio group over every enum value
selectFoldable f => f Text -> Var Int -> NanoUI ()Drop-down select by index
boundedSelect(Bounded a, Enum a) => (a -> Text) -> Var a -> NanoUI ()Drop-down over every enum value
comboBoxFoldable f => Text -> f Text -> Var Text -> NanoUI ()Placeholder, suggestions, free-text value
sliderFloat -> Float -> Var Float -> NanoUI ()Min, max, value
textInputVar Text -> NanoUI ()Single-line editable text
searchInputText -> Var Text -> NanoUI ()Placeholder + search field with a clear button
numericInputVar Double -> NanoUI ()Numeric field with steppers
colorPickerVar Color -> NanoUI ()Saturation/value field and hue bar

Every widget has the usual variants: primed (...') returns a Response, ...With takes a layout modifier, and ...Configured takes a config record. For example:

byteField = do
  byte <- useVar (0 :: Double)
  numericInputConfigured defaultNumericInputConfig
    {nicMin = 0, nicMax = 255, nicHex = True} byte

Text and search fields

queryField = do
  query <- useVar ""
  resp <- textInput' query
  when (respSubmitted resp) (search =<< readVar query)
  when (respChanged resp) (preview =<< readVar query)

respChanged fires on every edit; respSubmitted fires on Enter. For expensive work use searchInput', whose respChanged is debounced (300 ms by default) and fires immediately when the field is cleared:

packageSearch = do
  query <- useVar ""
  resp <- searchInput' "Find a package" query
  when (respChanged resp) (refreshResults =<< readVar query)

Choices

data Pace = Walk | Jog | Sprint
  deriving (Eq, Show, Enum, Bounded)

choices = do
  enabled <- useVar True
  checkbox "Enable notifications" enabled

  isBold <- useVar False
  toggleButton Accent "B" isBold

  pace <- useEnumVar Jog
  enumRadio pace
  enumSelect pace

select is index-based for data-driven options:

sizeSelect = do
  choice <- useVar 0
  select ["Small", "Medium", "Large"] choice

comboBox takes free text plus suggestions; its respChanged fires on commit; a model write alone does not set it.

Sliders, numbers, color

values :: Var Color -> NanoUI ()
values accent = do
  volume <- useVar (40 :: Float)
  slider 0 100 volume

  count <- useVar (0 :: Double)
  numericInput count

  colorPicker accent -- accent <- newVar (colorHex "#38bdf8") in setup

numericInput accepts digits and a leading minus when the range allows it; arrow keys step, and Shift steps ten times as far. nicDecimals controls display and accepted precision. colorPickerRGBA adds an alpha bar.

Text Editing

  • textArea edits a Var Text. Use it for short text: notes, descriptions, commit messages.
  • textAreaDocument edits a Var TextDocument, which shares unchanged lines between edits. Use it for files and code buffers.

textArea for short text

notes = do
  draft <- useVar ""
  textArea draft

Every edit builds a new Text, which is cheap for a few kilobytes.

textAreaDocument for documents

editorView :: Var WidgetId -> Var TextDocument -> NanoUI ()
editorView editorId docVar = do
  resp <- textAreaDocumentWith' (grow . minW 320 . minH 200) docVar
  when (respChanged resp) markDirty
  writeVar editorId (respId resp)

Only the edited lines are copied; unchanged lines are shared. documentText materializes the whole text; call it when saving instead of on every edit.

save :: FilePath -> TextDocument -> NanoUI ()
save path doc = liftIO (BS.writeFile path (TE.encodeUtf8 (documentText doc)))

documentLineCount is cheap enough for a status bar.

One undo history

Keyboard edits, mouse selection, the right-click menu, and programmatic commands all dispatch TextCommands, so they share one undo history:

-- | Editing, navigation, selection, history, and clipboard operations on a
-- text field. Mutating commands record an undo step and replace any active
-- selection.
data TextCommand
  = -- | Replace the selection with text (typing, a snippet).
    InsertText !Text
  | -- | Delete the selection, or from the cursor to where the motion lands:
    -- @Delete CharLeft@ is Backspace, @Delete WordRight@ Ctrl+Delete.
    Delete !TextMotion
  | -- | Move the cursor, extending the selection when the flag is set.
    -- Without it, 'CharLeft' and 'CharRight' over a selection go to its start
    -- and its end.
    Move !TextMotion !Bool
  | SelectAll
  | -- | Select from the first position (the anchor) to the second (the
    -- cursor), clamped into the document.
    Select !Cursor !Cursor
  | -- | Replace the text between two positions, leaving the cursor after it.
    Replace !Cursor !Cursor !Text
  | -- | Replace the whole document as one undoable edit.
    ReplaceAll !Text
  | Undo
  | Redo
  | Cut
  | Copy
  | Paste
  deriving (Eq, Show)

Run a command against a field by its WidgetId:

editMenu :: WidgetId -> Text -> NanoUI ()
editMenu editor today = do
  whenM (menuItem "Undo") (runTextCommand editor Undo)
  whenM (menuItem "Insert date") (runTextCommand editor (InsertText today))

Query history state for menu enablement:

undoState :: Var WidgetId -> NanoUI (Bool, Bool)
undoState editorId = do
  editor <- readVar editorId
  canUndo <- textCanUndo editor
  canRedo <- textCanRedo editor
  pure (canUndo, canRedo)

A complete Edit menu built this way is in Build a Notepad.

Tip

Directly overwriting the Var Text or Var TextDocument from outside resets the undo stack. To make a programmatic edit undoable, issue a TextCommand (InsertText, ReplaceAll, and so on) instead of writing the Var.

In the primed variants’ Response, respChanged is True after an edit and False after a caret move alone, so use it to mark a document dirty. respId names the editor for commands and focus.

Tabs, Tables & Trees

A nano-ui frame with a tab strip, a sortable service table, and a file tree.
A tab strip, sortable table, and tree in one frame. Rendered with nano-ui-svg.

Tabs

A tab strip binds the active key to a Var; only the selected tab’s body runs:

data Page = Overview | Logs | Settings
  deriving (Eq, Show, Enum, Bounded)

pages = do
  page <- useEnumVar Overview
  boundedTabs (T.pack . show) page $ \case
    Overview -> overviewView
    Logs     -> logsView
    Settings -> settingsView

tab creates a plain header; closableTab adds a close button and reports the close through the primed response:

closableTabs = do
  active <- useVar ("overview" :: Text)
  resp <- tabs' active
    [ closableTab "overview" "Overview" overviewView
    , closableTab "logs" "Logs" logsView
    ]
  for_ (tabClosed resp) closeTab

tabs' reports tabClosed and leaves the tab in place; the caller removes it from its list. A key no tab has selects the first tab and writes that key back to the Var.

tabBar / tabBar' draw headers only and leave the body to the caller. tabsConfigured / tabBarConfigured take a TabsConfig:

segmented :: Var Text -> [Tab Text (NanoUI ())] -> NanoUI ()
segmented active docTabs =
  let cfg = defaultTabsConfig
        {tabsStyle = TabSegmented, tabsOrientation = TabTop, tabsTrailing = newTabButton}
   in tabsConfigured cfg active docTabs

The four TabStyles, with a different tab selected in each:

Four tab strips: underline, pill, segmented, and contained, each with a different selected tab.
The four `TabStyle` values, rendered with nano-ui-svg.
StyleHeader treatment
TabUnderlineHeaders on a rule; the selected header has an accent bar
TabPillThe selected header is filled with the accent color
TabSegmentedHeaders sit in a rounded track; the selected one is raised
TabContainedFolder tabs; the selected header opens onto a bordered body

Placements: TabTop, TabBottom, TabLeft, TabRight. boundedTabs / boundedTabBar generate a tab per enum value.

Tables

table renders Colonnade columns. Header clicks sort through a Var SortCol:

data Process = Process
  { pid :: Int
  , name :: Text
  , mem :: Int
  }

processTable :: [Process] -> Var SortCol -> NanoUI ()
processTable procs sortVar =
  table "procs" cols procs sortVar
  where
    cols = mconcat
      [ headed "PID" (T.pack . show . pid)
      , headed "Name" name
      , headed "Memory (MB)" (T.pack . show . mem)
      ]

SortCol holds the active column and direction. simpleTable headers rows renders a table of text rows. table' returns a TableResponse with the updated sort, column order, and hidden columns; tableConfigured adds frozen rows/columns, fixed column widths, and hidden columns through TableConfig. Rows are virtualized internally.

Cell helpers: textCell, cellWidget, widgetCell, and Cell with an explicit sort key (cellKey). A cell with a widget view still needs cellText if its natural width should be measured.

Trees

items :: [TreeItem]
items =
  [ TreeItem "src" [TreeItem "Main.hs" [], TreeItem "Widgets" []]
  , TreeItem "README.md" []
  ]

fileTree :: [TreeItem] -> Var Int -> NanoUI ()
fileTree = tree "file-tree"

tree takes the library’s TreeItem type (treeItemLabel, treeItemChildren). The Var Int holds the selected item’s pre-order index. Build the index in the same pre-order when you need to map a selection back to an item. Collapsed nodes keep their state; rows are drawn as focusable buttons and support arrow-key navigation.

For large trees, keep the expansion set in a VarMap keyed by a stable path, and rebuild the visible items before calling tree.

To reorder tabs by dragging, see Drag & Drop.

Overlays & Windows

Modals, floating windows, and popups are ordinary NanoUI actions that take a Var Bool for their open state.

Call overlays unconditionally

modal  :: Var Bool -> Text -> NanoUI () -> NanoUI ()
window :: Var Bool -> Text -> NanoUI () -> NanoUI ()
popup  :: Var Bool -> PopupConfig -> NanoUI () -> NanoUI ()

Caution

Always call an overlay on every frame. Wrapping it in when isShown (modal ...) shifts widget ids for all subsequent siblings when it opens or closes, which moves focus, carets, and per-row state. Overlays draw nothing while their Var Bool is False.

confirmDelete = do
  showConfirm <- useVar False

  whenM (button "Delete File...") (writeVar showConfirm True)

  modal showConfirm "Confirm Deletion" $ do
    label "Are you sure you want to permanently delete this file?"
    row $ do
      whenM (button "Cancel") (writeVar showConfirm False)
      whenM (styled destructive (button "Delete")) $ do
        deleteFile
        writeVar showConfirm False

Escape, a click outside, and the close button write False to the Var, so the dialog closes in the same frame. A modal blocks interaction with the content behind it and is drawn above it.

Modals

modal renders a titled panel with a close button, centered in the window and limited to the window’s bounds. The body is not scrolled automatically; wrap it in scrollWith for long content, and give the panel a size with modalWith when the body needs to fill it:

settingsDialog = do
  open <- useVar False
  modalWith (fixedWH 600 400) open "Settings" $
    columnWith (fillW . fillH) settingsBody

modal' returns (Response, Maybe a) and reports a close request as a click. When the open flag is derived from other state, pass constVar and write the real flag from the response:

notice = do
  open <- useVar False
  isOpen <- readVar open
  (resp, _) <- modal' (constVar isOpen) "Important" $ do
    label "This dialog is shown while `open` is True."
    whenM (button "OK") (writeVar open False)
  when (respClicked resp) (writeVar open False)

For a modal without a title bar or close button, use modalPanel / modalPanel'; the panel surface and backdrop remain, and Escape or an outside click still writes False.

Floating windows

window draws a draggable, resizable in-app window with a scrolling body. Other windows and the page remain interactive outside its bounds:

inspector = do
  inspectorOpen <- useVar False
  window inspectorOpen "Inspector" $ do
    inspectorBody

window' returns the body result and a response. Use window for panels that coexist with the main content, modal when the user must answer first.

Popups

popup is a floating panel placed by a PopupConfig:

filterPopup = do
  filterOpen <- useVar False
  showFruit <- useVar True
  showVeg <- useVar True
  filterR <- button' "Filter"
  when (respClicked filterR) (modifyVar filterOpen not)

  popup filterOpen (defaultPopupConfig (AnchorRect (respRect filterR))) $ do
    checkbox "Fruit" showFruit
    checkbox "Vegetables" showVeg

A press on the anchor counts as inside the popup, so the button that toggles the popup does not immediately reopen it.

Popup placement:

  • AnchorRect (respRect r) anchors to a widget; AnchorPoint pos anchors to a coordinate.
  • cfgPlacement chooses PlacementAuto, PlacementBelow, PlacementAbove, PlacementAtCursor, and the other placements.
  • cfgOffset is the gap from the anchor.
  • cfgDismissable = False disables Escape/outside-click closing; act on the response from popup' instead.

An anchored popup positions against the previous frame’s respRect, so warm a frame before relying on it in tests. For long popup bodies, cap the height with popupWith (maxH ...) and scroll the body.

Tooltips

Tooltips are stateless and attach to any widget with a Response:

tooltips = do
  save <- button' "Save"
  tooltip save "Write the file to disk"

  more <- button' "More"
  tooltipConfigured defaultTooltipConfig
    {tooltipPlacement = PlacementAtCursor} more "Follows the pointer"

The default delay is half a second; TooltipConfig sets the delay and placement. withTooltip attaches a rich tooltip widget instead of text.

Rules

  • Modals stack. Escape closes the innermost first; side-by-side modals close in declaration order.
  • Allocate the open flag in the view that opens and closes the overlay, outside any child that may be skipped.
  • Read focus after the overlay call.
  • A scrollArea, panel, or card inside a dialog body draws a box in a box; strip its chrome (see Double borders).

Build a Notepad wires a modal to the window’s close request and builds a menu bar from popup'.

Drag & Drop

There are two kinds of drag: a pointer drag inside the window (reordering a list, moving tabs, docking panes), and files or text dropped in from another application. The hooks only report what happened. Applying a drop to your data is up to you.

Reordering a list

useReorder takes the current order of item ids and each item’s response, in drawn order:

chips :: Var [Int] -> NanoUI ()
chips orderVar = rowWith (gap 6) $ do
  order <- readVar orderVar -- orderVar <- newVar [0 .. 4] in setup
  drawn <- forM order $ \i -> (i,) <$> chip i
  r <- useReorder order drawn
  writeVar orderVar (reorderOrder r)

reorderOrder changes on the frame the item is dropped. Draw from reorderPreview instead to move the other items aside during the drag. reorderDragging is the held item, and reorderMoved is set once the press has moved past the drag threshold, so a click can be ignored.

Gestures with useDrag

For anything else, useDrag tracks a thresholded drag across keyed sources. Allocate the handle once with newDrag in setup. Here it reorders tabs, with insertionIndex turning the pointer into a slot in the strip (Nothing outside it):

tabStrip :: DragHandle Text -> Var Text -> [Tab Text ()] -> NanoUI ()
tabStrip dragHandle active documents = do
  headers <- tabBar' active documents
  gesture <- useDrag dragHandle (tabHeaders headers)
  for_ gesture $ \d -> when (dragPhase d == DragReleased) $ do
    let rest = filter ((/= dragPayload d) . fst) (tabHeaders headers)
    for_ (insertionIndex DragAxisX (tabStripRect headers) (map (respRect . snd) rest) (dragAt d)) $
      moveTab (dragPayload d)

dragPhase is DragStarted, Dragging, DragReleased, or DragCancelled (Escape, a lost hold, or the source disappearing). The last two last one frame. Commit only on DragReleased.

Files and text from other applications

The SDL backend delivers the operating system’s drag and drop; the RGFW backend does not. dropZone draws a panel and reports the files and text dropped on it:

fileDrop :: NanoUI ()
fileDrop = do
  (_, _, target) <- dropZone (fillW . padAll 12) (label "Drop files here")
  when (dropReceived target) (mapM_ openFile (dropFiles target))

dropHovered is true while a drag is over the zone. useDrop rect does the same for any rectangle. Only the topmost layer receives the drop, so a zone behind a modal gets nothing.

Pane grids

paneGrid lays out dockable, splittable panes. Its response’s pgrDropTarget is the destination under the pointer, which pairs with a useDrag gesture:

dockPanes :: PaneGridConfig -> Maybe (Drag Word64) -> NanoUI ()
dockPanes cfg gesture = do
  panes <- paneGrid cfg
  case (gesture, pgrDropTarget panes) of
    (Just d, Just target) | dragPhase d == DragReleased -> void (commitPaneDrop target)
    _ -> pure ()

pgdLocation is BesidePane paneId edge or OutsideGrid edge; pgdRect and pgdPane help validate the drop. Save pgrTree when pgrCommitted is set.

Custom Drawing & Controls

When the built-in widgets do not fit, draw the control with the canvas API. The six controls in NanoUI.Widgets.Custom are built on it, and their source is the example to copy.

Reference custom controls

NanoUI exports them from NanoUI.Widgets.Custom.

ControlSignatureDefault sizeNotes
knobFloat -> Float -> Var Float -> NanoUI ()36×36Min, max, value; vertical drag, wheel, arrows
toggleSwitchVar Bool -> NanoUI ()44×24Click, Space, or Enter toggles
circularProgressFloat -> NanoUI ()32×32Fraction in [0, 1]
progressBarFloat -> NanoUI ()fills width, 12 highFraction in [0, 1]
spinnerNanoUI ()18×18Indeterminate; requests animation frames while declared
sparkline[Float] -> NanoUI ()80×24Scales to the values’ range

Each has a primed form returning Response (knob', toggleSwitch', …) and a ...With form that takes a layout modifier and size parameters:

controls :: Var Float -> Float -> [Float] -> NanoUI ()
controls volume progress samples = do
  knobWith alignMid 48 0 100 volume
  progressBarWith fillW 6 progress
  sparklineWith (fixedWH 120 32) 120 32 samples

sparkline maps the smallest value to the bottom of its plot area and the largest to the top, so two series of different magnitude can draw the same line. At its default size and with sparklineWith:

sparklines :: [Float] -> NanoUI ()
sparklines samples = rowWith (gap 16 . alignMid) $ do
  sparkline samples
  sparklineWith (fixedWH 120 32) 120 32 samples
Two small boxed blue line graphs of the same wavy series, the second wider and taller.

For a chart with axes, a legend, and hover, use plot from nano-ui-diagrams (see Ecosystem Packages).

A settings row:

settingsRow :: Var Bool -> Var Float -> NanoUI ()
settingsRow enabled volume = rowWith (gap 12 . alignMid) $ do
  toggleSwitch enabled
  labelWith (alignMid . fixedW 64) "Enabled"
  knob 0 100 volume
  v <- readVar volume
  labelWith (alignMid . fontMono . fixedW 48) (T.pack (show (round v :: Int)))

The fixedW on the changing number keeps the controls from shifting. Read the value below the knob so a drag resolves in one pass.

spinner calls keepAnimating internally while it is drawn. Do not add a global keepAnimating; that would keep the loop running when the spinner is not visible.

Implementing custom controls

Each control in NanoUI.Widgets.Custom follows the same steps:

  1. Take an id with freshWidget.
  2. Size and draw through a CustomWidgetSpec and runCanvasFor.
  3. Turn input into a change of value with useDrag2DOn, useWheelDeltaOn, and useKeyNav.
  4. Write the result to the caller’s Var with bindVar or finishInput.

Canvas drawing

canvas sizes a rectangle with a layout modifier and runs a CanvasM drawing in it. The rectangle is the widget’s place in the window, so draw relative to it:

import NanoUI.Path qualified as P

badge :: NanoUI ()
badge = void $ canvas (fixedWH 120 40) $ \r@(Rect x y _ h) -> do
  drawRoundedRect r 8 blue
  drawStrokePath (P.circle (V2 (x + 20) (y + h / 2)) 8) 2 colorWhite
  drawText (V2 (x + 68) (y + h / 2)) AlignCenter AlignMiddle "Ready" colorWhite
A blue rounded badge with a white ring on the left and the word Ready.

canvas returns the widget’s Response; discard it with void when the drawing needs no hover or clicks. canvasConfigured adds a content key, a cursor, and pointer tracking. For full control, use customWidget with a CustomWidgetSpec:

dot :: Float -> Float -> NanoUI Response
dot angle volume = do
  (resp, ()) <- customWidget defaultCustomWidgetSpec
    { widgetLayout = fixedWH 48 48 defaultLayout
    , widgetContent = contentKey [angle, volume]
    , widgetDraw = \cdc (Rect x y w h) -> runCanvasFor cdc $ do
        drawCircle (V2 (x + w / 2) (y + h / 2)) 16 (themeAccent (cdcTheme cdc))
    , widgetCursor = Just (\_ _ _ -> UiCursorPointer)
    }
  pure resp

runCanvasFor adapts tessellation to the display scale inside widgetDraw. drawContext / cdcTheme supply the active theme, font, hover, and press state.

Reading the examples

The rest of this section is compiled from CanvasExamples.hs, and nano-ui-svg-glossary renders the same definitions to the images. Each list pairs a caption with a drawing, and the drawing gets one cell’s rectangle. They share a palette and a helper:

ink, blue, teal, amber, rose :: Color
ink = colorRGB 226 232 240
blue = colorRGB 59 130 246
teal = colorRGB 20 184 166
amber = colorRGB 245 158 11
rose = colorRGB 244 63 94

centre :: Rect -> V2
centre (Rect x y w h) = V2 (x + w / 2) (y + h / 2)

Thin grey lines, small dots, dashed outlines, and the red crosshair in the images are annotations drawn on top. They are not part of the example code.

Rectangles, circles, and gradients

The basic ops take a rectangle or a centre and radius. They need no NanoUI.Path import.

Eight cells: a blue rectangle, a teal rounded rectangle, an amber disc, a rose ring, a diagonal line, a horizontal blue-to-teal gradient, a vertical amber-to-rose gradient, and a four-colour corner gradient.
primitives :: [(Text, Rect -> CanvasM ())]
primitives =
  [ ("drawRect", \r -> drawRect r blue)
  , ("drawRoundedRect", \r -> drawRoundedRect r 14 teal)
  , ("drawCircle", \r -> drawCircle (centre r) 28 amber)
  , ("drawStrokeCircle", \r -> drawStrokeCircle (centre r) 28 4 rose)
  , ("drawStrokeAA", \(Rect x y w h) -> drawStrokeAA (V2 x (y + h)) (V2 (x + w) y) 4 ink)
  , ("drawLinearGradientH", \r -> drawLinearGradientH r blue teal)
  , ("drawLinearGradientV", \r -> drawLinearGradientV r amber rose)
  , ("drawQuadGradient", \r -> drawQuadGradient r blue teal amber rose)
  ]

drawStrokeCircle keeps its border inside the radius. drawStrokeAA is an anti-aliased line with square ends; drawStroke is the same line without anti-aliasing, for axis-aligned rules. drawQuadGradient takes its colours clockwise from the top left.

Paths

A Path is a list of steps joined with <>. moveTo starts a subpath. lineTo, quadTo, cubicTo, and arc extend it from the current point, and close draws a line back to where it started. Each path below is stroked with drawStrokePath (path r) 3 blue. Filled dots are points on the path; hollow dots are control points and arc centres.

Five cells: a two-segment peak, a quadratic curve with its control point above, an S-shaped cubic curve with two control points, a three-quarter arc, and the same arc closed through its centre into a pie slice.
segments :: [(Text, Rect -> P.Path)]
segments =
  [ ("lineTo", \(Rect x y w h) ->
      P.moveTo (V2 x (y + h)) <> P.lineTo (V2 (x + w / 2) y) <> P.lineTo (V2 (x + w) (y + h)))
  , ("quadTo", \(Rect x y w h) ->
      P.moveTo (V2 x (y + h)) <> P.quadTo (V2 (x + w / 2) (y - 12)) (V2 (x + w) (y + h)))
  , ("cubicTo", \(Rect x y w h) ->
      P.moveTo (V2 x (y + h)) <> P.cubicTo (V2 x y) (V2 (x + w) (y + h)) (V2 (x + w) y))
  , ("arc", \r -> P.arc (centre r) 28 0 (3 * pi / 2))
  , ("moveTo, arc, close", \r -> P.moveTo (centre r) <> P.arc (centre r) 28 0 (3 * pi / 2) <> P.close)
  ]

Angles are in radians. Because y points down, a positive sweep turns clockwise on screen: the arc starts at 3 o’clock and runs three quarters of the way round. An arc after moveTo joins the current point to its start with a line, which is how the last cell draws a pie slice.

Shape helpers build whole subpaths. They never join onto what comes before them, so <> combines them into one path with several subpaths.

Seven cells, each a translucent blue fill with a blue outline: a rectangle, a rounded rectangle, a rectangle with two large and two small corner radii, a circle, a wide ellipse, a triangle, and an open zigzag line.
shapes :: [(Text, Rect -> CanvasM ())]
shapes =
  [ ("rect", \r -> shape (P.rect r))
  , ("roundedRect", \r -> shape (P.roundedRect r 12))
  , ("roundedRectCorners", \r -> shape (P.roundedRectCorners r 24 4 24 4))
  , ("circle", \r -> shape (P.circle (centre r) 28))
  , ("ellipse", \r -> shape (P.ellipse (centre r) (V2 48 22)))
  , ("polygon", \(Rect x y w h) -> shape (P.polygon [V2 (x + w / 2) y, V2 (x + w) (y + h), V2 x (y + h)]))
  , ("polyline", \(Rect x y w h) ->
      drawStrokePath (P.polyline [V2 x (y + h), V2 (x + w / 3) y, V2 (x + 2 * w / 3) (y + h), V2 (x + w) y]) 2 blue)
  ]
  where
    shape p = drawPath p (withAlpha blue 0.35) >> drawStrokePath p 2 blue

roundedRectCorners takes its radii in CSS border-radius order: top left, top right, bottom right, bottom left. polygon closes its outline and polyline leaves it open, so the polyline is only stroked here. A fill closes every subpath.

Fill rules

drawPath fills with P.NonZero. drawPathWith takes the rule and a paint. All three cells fill three concentric circles:

Three cells of teal concentric circles: NonZero fills a solid disc; NonZero with the middle circle reversed and EvenOdd both show a ring around a dot.
fillRules :: [(Text, Rect -> CanvasM ())]
fillRules =
  [ ("NonZero", \r -> drawPathWith P.NonZero (rings r) (P.Solid teal))
  , ("NonZero, middle reversed", \r -> drawPathWith P.NonZero (reversedMiddle r) (P.Solid teal))
  , ("EvenOdd", \r -> drawPathWith P.EvenOdd (rings r) (P.Solid teal))
  ]
  where
    rings r = P.circle (centre r) 30 <> P.circle (centre r) 20 <> P.circle (centre r) 9
    -- A negative sweep runs the middle circle anticlockwise.
    reversedMiddle r@(Rect x y w h) =
      P.circle (centre r) 30
        <> P.moveTo (V2 (x + w / 2 + 20) (y + h / 2))
        <> P.arc (centre r) 20 0 (-2 * pi)
        <> P.close
        <> P.circle (centre r) 9

P.circle always runs clockwise, so under NonZero the inner circles add to the outer one and the disc is solid. Running the middle circle the other way cancels it out, cutting a hole. EvenOdd ignores direction: each nested subpath toggles between filled and empty. Crossing subpaths are not resolved against each other, so keep holes inside the shape they cut.

Paints

P.Solid is one colour. P.Linear from to stops is a gradient along the line from from (offset 0) to to (offset 1).

Four cells: a solid blue rounded rectangle, a blue-to-teal gradient across the full width, the same gradient squeezed into the middle third with solid ends, and a disc shading amber to rose to blue along its diagonal.
paints :: [(Text, Rect -> CanvasM ())]
paints =
  [ ("Solid", \r -> drawPathWith P.NonZero (P.roundedRect r 10) (P.Solid blue))
  , ("Linear, two stops", \r@(Rect x y w _) ->
      drawPathWith P.NonZero (P.roundedRect r 10) (P.Linear (V2 x y) (V2 (x + w) y) [(0, blue), (1, teal)]))
  , ("Linear, short span", \r@(Rect x y w _) ->
      drawPathWith P.NonZero (P.roundedRect r 10) (P.Linear (V2 (x + w / 3) y) (V2 (x + 2 * w / 3) y) [(0, blue), (1, teal)]))
  , ("Linear, three stops", \r@(Rect x y w h) ->
      drawPathWith P.NonZero (P.circle (centre r) 30) (P.Linear (V2 x y) (V2 (x + w) (y + h)) [(0, amber), (0.5, rose), (1, blue)]))
  ]

Past the first and last stop the gradient keeps their colours, which the short span shows. Strokes take a Paint too, so a gradient can run along an outline.

Strokes

drawStrokePathWith takes a P.Stroke. Start from P.stroke width (butt caps, miter joins, solid) and override fields with record update. Strokes are centred on the path.

Caps finish the ends of open subpaths and of every dash. The thin line is the path; butt caps stop at its end points, round and square caps extend past them by half the width.

Three thick blue lines over a thin guide line: ButtCap ends flush with the end points, RoundCap ends in semicircles past them, SquareCap ends in squares past them.
capLine :: Rect -> P.Path
capLine (Rect x y w h) = P.moveTo (V2 (x + 16) (y + h / 2)) <> P.lineTo (V2 (x + w - 16) (y + h / 2))

caps :: [(Text, Rect -> CanvasM ())]
caps =
  [ (name, \r -> drawStrokePathWith (P.stroke 18) {P.strokeCap = cap} (capLine r) (P.Solid blue))
  | (name, cap) <- [("ButtCap", P.ButtCap), ("RoundCap", P.RoundCap), ("SquareCap", P.SquareCap)]
  ]

Joins shape the corners between segments. A miter longer than strokeMiterLimit times the width falls back to a bevel, which the last cell forces with a limit of 1.5.

Four thick teal zigzags over a thin guide: a pointed miter corner, a rounded corner, a cut-off bevel corner, and a miter beveled by a low miter limit.
zigzag :: Rect -> P.Path
zigzag (Rect x y w h) = P.polyline [V2 (x + w / 4) (y + h - 4), V2 (x + w / 2) (y + 10), V2 (x + 3 * w / 4) (y + h - 4)]

joins :: [(Text, Rect -> CanvasM ())]
joins =
  [ ("MiterJoin", withJoin (P.stroke 14) {P.strokeJoin = P.MiterJoin})
  , ("RoundJoin", withJoin (P.stroke 14) {P.strokeJoin = P.RoundJoin})
  , ("BevelJoin", withJoin (P.stroke 14) {P.strokeJoin = P.BevelJoin})
  , ("strokeMiterLimit 1.5", withJoin (P.stroke 14) {P.strokeJoin = P.MiterJoin, P.strokeMiterLimit = 1.5})
  ]
  where
    withJoin st r = drawStrokePathWith st (zigzag r) (P.Solid teal)

strokeDash lists alternating dash and gap lengths, repeated along each subpath. strokeDashOffset starts the pattern part of the way in. A zero-length dash with round caps draws a dot.

Four amber dashed rounded rectangles: even dashes, the same dashes shifted along, a long-short dash-dot pattern, and a row of round dots.
dashes :: [(Text, Rect -> CanvasM ())]
dashes =
  [ ("[12, 6]", dashed (P.stroke 4) {P.strokeDash = [12, 6]})
  , ("[12, 6], offset 9", dashed (P.stroke 4) {P.strokeDash = [12, 6], P.strokeDashOffset = 9})
  , ("[18, 5, 3, 5]", dashed (P.stroke 4) {P.strokeDash = [18, 5, 3, 5]})
  , ("[0, 10], RoundCap", dashed (P.stroke 6) {P.strokeDash = [0, 10], P.strokeCap = P.RoundCap})
  ]
  where
    dashed st r = drawStrokePathWith st (P.roundedRect r 12) (P.Solid amber)

Transforms

withTransform draws a block through a P.Transform. Transforms compose with <>, and the right-hand one applies first. The dashed outline is the arrow before the transform.

Four cells with a teal arrow and a dashed outline of its starting position: moved right and down, rotated 45 degrees about its centre, scaled 1.5 times about its centre with a thicker outline, and mirrored to point left.
arrowPath :: Rect -> P.Path
arrowPath r =
  let V2 cx cy = centre r
   in P.polygon [V2 (cx - 24) (cy - 6), V2 cx (cy - 6), V2 cx (cy - 16), V2 (cx + 24) cy, V2 cx (cy + 16), V2 cx (cy + 6), V2 (cx - 24) (cy + 6)]

arrow :: Rect -> CanvasM ()
arrow r = drawPath (arrowPath r) teal >> drawStrokePath (arrowPath r) 2 ink

-- | Scale about a point: move it to the origin, scale, move it back.
scaleAround :: V2 -> Float -> Float -> P.Transform
scaleAround (V2 cx cy) sx sy = P.translate cx cy <> P.scale sx sy <> P.translate (-cx) (-cy)

transforms :: [(Text, Rect -> CanvasM ())]
transforms =
  [ ("translate 20 10", \r -> withTransform (P.translate 20 10) (arrow r))
  , ("rotateAround c (pi / 4)", \r -> withTransform (P.rotateAround (centre r) (pi / 4)) (arrow r))
  , ("scaleAround c 1.5 1.5", \r -> withTransform (scaleAround (centre r) 1.5 1.5) (arrow r))
  , ("scaleAround c (-1) 1", \r -> withTransform (scaleAround (centre r) (-1) 1) (arrow r))
  ]

P.rotate and P.scale work about the window origin (0, 0). Move the pivot to the origin first: P.rotateAround does that for rotation, and scaleAround above does it for scale. Stroke widths and dashes scale with the transform, so the enlarged arrow’s outline is thicker. Text moves its anchor and scales, but glyphs do not rotate.

Clipping

withClip rect clips a block to rect, intersected with the current clip. The current clip is at most the widget’s rectangle, so a canvas never draws outside its own bounds.

Two cells with a rose disc and the text 'text past the edge': without a clip both spill past a dashed box; with withClip they are cut at the box.
clipped :: Rect -> CanvasM ()
clipped r@(Rect x y _ h) = do
  drawCircle (centre r) 34 rose
  drawText (V2 (x - 6) (y + h / 2)) AlignStart AlignMiddle "text past the edge" ink

clipBox :: Rect -> Rect
clipBox (Rect x y w h) = Rect (x + 10) (y + 8) (w - 20) (h - 16)

clips :: [(Text, Rect -> CanvasM ())]
clips =
  [ ("no clip", clipped)
  , ("withClip box", \r -> withClip (clipBox r) (clipped r))
  ]

Text

drawText pos alignX alignY txt colour anchors text to a point. The alignment selects which part of the text sits on the point (the red dot): AlignStart puts the point at the left edge, AlignTop at the top of the line, AlignBaseline on the baseline. drawTextWith takes a TextFont for size, variant, weight, and slant.

Eight cells showing the word Anchor placed against a red dot: left, centre and right aligned; then top, middle, baseline and bottom aligned; and a large bold amber 42.
textAnchors :: [(Text, Rect -> CanvasM ())]
textAnchors =
  [ (name, \r -> drawText (centre r) ax AlignMiddle "Anchor" ink)
  | (name, ax) <- [("AlignStart", AlignStart), ("AlignCenter", AlignCenter), ("AlignEnd", AlignEnd)]
  ]
    <> [ (name, \r -> drawText (centre r) AlignCenter ay "Anchor" ink)
       | (name, ay) <- [("AlignTop", AlignTop), ("AlignMiddle", AlignMiddle), ("AlignBaseline", AlignBaseline), ("AlignBottom", AlignBottom)]
       ]
    <> [ ("drawTextWith", \r ->
          drawTextWith defaultTextFont {textFontSize = 24, textFontWeight = WeightBold} (centre r) AlignCenter AlignMiddle "42" amber)
       ]

Measure text with lineWidth and uiFontMetrics. Measure a whole run with lineWidth rather than adding per-glyph advances, because a shaping backend lays runs out by unrounded advances.

Borders: inset or centred

drawStrokeRoundedRect keeps its border inside the rectangle. A path stroke is centred on the path, so half its width falls outside. The thin outline is the rectangle both cells were given.

Two blue rounded borders over a thin guide rectangle: drawStrokeRoundedRect sits entirely inside it, drawStrokePath straddles it.
borders :: [(Text, Rect -> CanvasM ())]
borders =
  [ ("drawStrokeRoundedRect", \r -> drawStrokeRoundedRect r 12 8 (withAlpha blue 0.8))
  , ("drawStrokePath", \r -> drawStrokePath (P.roundedRect r 12) 8 (withAlpha blue 0.8))
  ]

Use the inset form for a widget’s own border, so it never paints past the widget’s rectangle.

Content keys and caching

Without widgetContent, a custom widget’s draw ops are rebuilt and diffed every frame. With a key, unchanged frames skip rebuild and repaint, but the key must cover everything the drawing reads:

gauge :: Float -> Float -> Float -> NanoUI Response
gauge angle volume scrollY = do
  (resp, ()) <- customWidget defaultCustomWidgetSpec
    { widgetLayout = fixedWH 48 48 defaultLayout
    , widgetContent = contentKey [angle, volume, fromIntegral (round scrollY :: Int)]
    , widgetDraw = \cdc rect -> runCanvasFor cdc (drawGauge rect)
    }
  pure resp

A key that misses an input shows stale pixels. See Styling Gotchas.

A drawing whose layout rectangle never changes (a drag ghost pinned under the pointer with pinAt) gets no layout change when it moves. Give it an explicit version that includes what moves it:

-- ghostOps :: Rect -> SmallArray DrawOp
dragGhost :: Float -> Float -> NanoUI Response
dragGhost mx my = drawingVersioned (contentKey [mx, my]) (fixedWH 32 32) ghostOps

Otherwise it keeps the ops of the frame it first appeared in.

SVG

parseSvg reads SVG text and loadSvg reads a file. An icon that strokes or fills with currentColor takes the text colour, so fontColor and the semantic styles tint it; other colours are kept.

icons :: Svg -> Color -> NanoUI ()
icons star accent = do
  svgIcon 24 star
  svgIconWith (fixedWH 32 32 . fontColor accent) star
  whenM (iconButton star "Star") starItem

svgIconConfigured takes an ImageConfig for CSS-style fitting (FitContain, FitCover, FitFill).

Inside a canvas, rasterize the SVG with rasterizeSvg from NanoUI.Svg, lease the pixels with useImageRgba, and draw the image. Key the lease on everything the pixels depend on, here the tint:

starBadge :: ImageHandle Color -> Svg -> Color -> NanoUI Response
starBadge owner star tint = do
  iid <- useImageRgba owner tint 32 32 (rasterizeSvg 32 32 tint star)
  canvas (fixedWH 140 44) $ \(Rect x y _ _) ->
    for_ iid $ \i -> drawImage (Rect (x + 8) (y + 6) 32 32) i colorWhite

nano-ui-svg goes the other way and exports draw ops as SVG; see SVG export.

Styling & Theming

Styling in nano-ui is scoped: a Theme holds a Style per surface kind, and styled/themed modify that theme for a subtree. There is no per-widget CSS and no global mutable stylesheet.

For a live tour of the modifiers, see the Interactive Styling Lab. For non-obvious behavior, see Styling Gotchas.

The theme’s five surfaces

FieldUsed by
themeButtonButtons, toggles, dropdown triggers
themeInputText fields, search boxes, numeric inputs, sliders
themePanelCards, containers, sidebars, toolbars
themePopupDropdowns, context menus, select popouts
themeFloatingWindowModals, floating windows, inspectors

A Style sets bg, fg, border, borderWidth, cornerRadius, hoverBg, and activeBg. A Theme also defines semantic colors: themeAccent / themeOnAccent, themeMuted, themeSelection, themeFocusRing, themeWindow, themeSeparator, and the success/warning/danger tones.

The page behind the view is pageBackground: themeWindow, unless the theme sets themePage. pageColor sets one. panelPage puts a view that draws no panel of its own on the panel surface. The page sits over the window background, which shows in the gaps between panels:

-- An app with no panel of its own: the panel surface is the page.
panelPageOptions :: SdlOptions
panelPageOptions = defaultSdlOptions {sdlAppTheme = Just (panelPage defaultTheme)}

-- Or scope it to a page with no panels.
body :: NanoUI ()
body = styled panelPage (columnWith (padAll 12) (label "Body"))

A full-window scroller (scrollWith grow) fills the page colour.

Built-in themes: defaultTheme (dark), defaultLightTheme, the Tomorrow variants, and themeFromBase16 / themeFromBase16Dark / themeFromBase16Light. Set one at startup with sdlAppTheme = Just appTheme, switch per frame with setUiTheme, or follow the OS with followSystemTheme / lightDark.

Scoped styling

themedViews = do
  -- Replace the base theme for a subtree.
  themed defaultLightTheme mainContent

  -- Modify the active theme for a subtree.
  whenM (styled (primary . rounded 999) (button "Continue")) continue

Modifiers compose with (.); nested scopes inherit the outer modification. Theme scopes affect painting only, never layout metrics.

  • background, foreground, borderColor, borderWidth, borderSides, cornerRadius, hoverBackground, pressBackground, and fillColor set Style fields.
  • buttonStyle, inputStyle, panelStyle, everyStyle, accentColor, textColor, rounded, subtle, tinted, and readableOn modify the Theme.
  • primary, destructive, success, warning, tone Danger, and toneColor Warning apply semantic colours.

A quiet button:

quiet :: Theme -> Theme
quiet t = (subtle . buttonStyle (foreground (themeMuted t))) t

quietSkip = whenM (styled quiet (button "Skip")) skip

For a fully custom look, define a palette record, build a Theme from it, and derive scoped helpers. Keep three surface levels (well, shelf, controls) and reserve color for meaning.

Interaction states

A widget is drawn in one of four states:

StateDrawn with
DefaultstyleBg, styleFg, styleBorder
HoverstyleHoverBg
PressedstyleActiveBg, while the primary button is held inside the widget
DisabledDimmed colours; pointer and keyboard input are ignored

disabledWhen cond subtree disables interaction and dims rendering while preserving layout and state:

savingButtons = do
  isSaving <- useVar False
  saving <- readVar isSaving
  disabledWhen saving $ row $ do
    whenM (button "Cancel") cancel
    whenM (button "Confirm") confirm

Shortcuts and pointer events inside a disabled subtree are suppressed automatically.

All four states, light and dark:

Typography and fonts

Font modifiers apply to any text-capable widget (labelWith, headingWith, textInputWith, buttonWith, …):

  • fontSize px, fontMono, fontBold (fontWeight), fontItalic, fontUnderline, fontStrike.
  • fontTone Warning, fontColor color.
warningLabel = labelWith (fontMono . fontSize 15 . fontTone Warning) "warning: low disk"

The SDL backend bundles Inter and finds installed fonts by family name:

fontOptions :: SdlOptions
fontOptions =
  defaultSdlOptions
    { sdlAppFont = FontSearch ["Segoe UI", "SF Pro Text", "Helvetica Neue"]
    , sdlAppMonoFont = FontSearch ["Cascadia Mono", "JetBrains Mono", "Consolas"]
    , sdlAppFontSize = 14
    }

setSdlUiFont changes fonts mid-session. sdlAppUiScale = 0 follows the desktop scale on Windows. Measure text with lineWidth, fmLineHeight, and fmSnapScale, not by summing per-glyph advances.

The font modifiers side by side:

Interactive Styling Lab

Toggle styling modifiers and themes on each widget family. Toggle Compare Baseline shows the unstyled widget beside it, and the generated Haskell can be copied. The concepts are in Styling & Theming and Styling Gotchas.

Buttons

Toggle buttons

Checkboxes

Three surfaces: the box well (themeInput), the check (themeAccent), and the label.

Radio buttons

Text inputs

Search and numeric inputs

Select and combo box

select draws its trigger with themeButton; comboBox uses themeInput.

Text areas

Sliders

Custom controls

Switches, knobs, and spinners from NanoUI.Widgets.Custom.

Panels, cards, and callouts

Scroll areas

Tabs

Tables and trees

Overlays

Floating windows, popup menus, and tooltips.

Styling Gotchas

Styling results that surprise people. Each specimen shows the trap, why it happens, and the fix.

checkbox and radio span three surfaces

The unchecked well reads themeInput, the outline and label read themeButton, and the checked state reads themeAccent.

select and comboBox use different surfaces

They look alike when closed, but select reads themeButton and comboBox reads themeInput.

scrollWith changes surface with its sizing

A bounded scroller draws a well. A grow scroller drops it and fills the page background.

callout tints nested panels

The tinted panelStyle a callout sets reaches every panel inside it.

toggleButton ignores buttonStyle when on

While on, it fills with its tone colour (themeAccent for Accent) regardless of buttonStyle.

borderWidth does not add layout space

Borders are drawn inset and never grow a widget’s bounds.

Modifier order in (.) and styled

The leftmost layout modifier wins; nested styled scopes apply outside in, so the inner one wins.

tabs read themePanel, not themeButton

Tab headers are buttons, but their colours come from themePanel and themeAccent.

Keyboard, Focus & IME

The view reads keyboard input during the frame; there are no key handlers to register. A focused widget claims the keys it uses, and shortcuts and key queries skip claimed keys.

Shortcuts

import NanoUI.Shortcut

saveChord :: Shortcut
saveChord = cmdOrCtrl <> key 's'

saveCommand :: NanoUI ()
saveCommand = whenM (shortcut saveChord) saveDocument

<> unions modifiers and keeps the right-hand key. key accepts a Key or a Char; letters are case-insensitive, and space maps to KeySpace:

chords :: [Shortcut]
chords =
  [ ctrl <> key 's'                 -- Ctrl+S
  , cmdOrCtrl <> shift <> key 'p'   -- Cmd+Shift+P on macOS, Ctrl+Shift+P elsewhere
  , alt <> key KeyEnter             -- Alt+Enter
  , key (KeyF 5)                    -- F5
  ]

Use cmdOrCtrl for application commands. ctrl, shift, alt, and super are exact: ctrl <> key 's' does not match Ctrl+Shift+S.

  • shortcut fires on the frame the chord goes down, including auto-repeat (holding Ctrl+Z repeats undo).
  • shortcutOnce ignores auto-repeat. Use it for toggles and one-shot commands.

Only the first matching shortcut call in a frame fires. It consumes that key’s presses for the frame, so two bindings of the same chord never both run. Declaration order is priority order.

What suppresses a shortcut

A chord does not fire:

  • behind an open modal;
  • inside disabledWhen True;
  • when the focused widget claims the key: a text field keeps typing, cursor, and editing chords such as Ctrl+A and Ctrl+Z; buttons keep Enter and Space; sliders, selects, and lists keep arrows, Home, End, Page Up, and Page Down;
  • for Escape when takeEscape fails, or when an open overlay takes it first.

Focused widgets claim only their own unmodified or Shift-modified keys, so modified chords such as Ctrl+Enter, Alt+Left, or Ctrl+S still reach the application while a field is focused. The matching low-level functions are filtered the same way: keyPressed, keyPressedOnce, keyReleased, and keyHeld. Use raw pressedIn only when implementing a custom widget.

menuItemShortcut shows a chord beside a menu row and binds it while the menu is open. Bind the same chord outside the menu for when it is closed:

saveBindings :: NanoUI ()
saveBindings = do
  whenM (menuItemShortcut "Save" saveChord) saveDocument
  whenM (shortcut saveChord) saveDocument

The open menu’s row is declared first, so it wins.

Escape

Text fields do not claim Escape, so Escape can still close a popup, clear a selection, or leave a mode while the caret is in a field. That also means every Escape handler needs its own condition. Do not share the “a field is editing” guard used for Ctrl+A or Delete.

takeEscape claims a press for the current handler:

escapes :: Bool -> Var Bool -> NanoUI ()
escapes isEditing editing = do
  when isEditing (whenM takeEscape (writeVar editing False))
  whenM takeEscape clearFocus

The first call that succeeds wins. An open popup, dropdown, or text-edit menu reserves Escape before the view’s own handlers run.

User-configurable chords

parseShortcut reads a chord from text, for a settings file or keymap editor:

keymapEntry :: Text -> NanoUI ()
keymapEntry txt =
  case parseShortcut txt of -- "C-S-s"
    Right chord -> label (shortcutLabel chord)
    Left err    -> showError err

The syntax is modifier prefixes followed by a key: C- Ctrl, S- Shift, A- Alt/Option, s- Super, M- Command on macOS and Ctrl elsewhere. A key is one character as typed without Shift, or a named key in angle brackets: C-s, M-S-p, A-<Enter>, <F5>, C--, C-S-=. Name a symbol by its unshifted key, so Ctrl++ on a US layout is C-S-=.

shortcutLabel renders a chord for menus and tooltips.

Focus

  • Clicking a text field, select, slider, or focusable custom widget focuses it. Clicking elsewhere unfocuses the current field.
  • Tab moves to the next focusable widget; Shift+Tab moves to the previous one.
  • requestFocus (respId resp) focuses a widget. focusNext and focusPrevious move focus like Tab and Shift+Tab. clearFocus drops focus at the end of the frame.
  • holdFocus wid claims focus every frame for a view that handles input itself; it also consumes Tab so an editor can indent with it.

Focus requests are applied at the end of the frame, against that frame’s layout. A focus move is visible on the next frame:

findField :: Var Text -> NanoUI ()
findField query = do
  resp <- searchInput' "Find" query
  whenM (shortcut (cmdOrCtrl <> key 'f')) (requestFocus (respId resp))
  -- next frame: the focus ring is drawn and isFocused answers True

A request can name a widget declared later in the same pass (for example with currentId just before it), but not one that is not declared on this pass at all. A widget that appears after a state change needs a delayed request:

editableName :: Var Text -> NanoUI ()
editableName draft = do
  editing <- useVar False
  focusPending <- useVar False

  showField <- readVar editing
  scope $
    if showField
      then do
        fieldR <- textInput' draft
        whenM (readVar focusPending) $ do
          requestFocus (respId fieldR)
          writeVar focusPending False
      else label "Not editing"

  whenM (button "Edit") $ do
    writeVar editing True
    writeVar focusPending True

Warning

Query focus at the end of the view. The node arena is reset before the view runs, so isFocused at the top always returns False. Keyboard shortcuts that depend on focus belong after the widgets have run.

A focus request is ignored for a disabled widget, a widget behind an open modal, one not declared this pass, or a group response that is not itself a Tab stop. A widget in an inactive tab is not declared, so it cannot keep focus; request focus again when the tab is shown.

disabledWhen dims a subtree and blocks interaction without changing layout or state. The focus ring is themed by themeFocusRing.

IME

Input method editors compose text before committing it. Built-in text fields handle the whole cycle, including caret rendering and suppressing shortcuts mid-composition.

A custom widget that reads committed characters from inputChars must opt in every frame it accepts text:

-- Every frame the widget accepts text:
composition :: WidgetId -> Rect -> NanoUI ()
composition wid caretRect = do
  preedit <- useInputMethod wid InputNormal caretRect
  for_ preedit drawCompositionAtCaret

useInputMethod :: WidgetId -> InputPurpose -> Rect -> NanoUI (Maybe Composition) places the candidate window at the caret and returns the in-progress composition to draw. Pass the caret rectangle, not the whole widget. It checks focus itself, so a separate isFocused guard is unnecessary.

Raw input fields: inputChars (committed text), inputKeys (pressed, including repeats), inputKeysNew (initial presses), inputKeysHeld, inputKeysReleased, and inputComposition. The terminal demo in packages/nano-ui-demo/examples/Terminal.hs is a complete custom text widget.

Testing

NanoUI.Testing.Sim drives a view headlessly the way a user would. runSim settles the view, then the script clicks text, types, and presses keys, each action running the frames a real event would:

renameTest :: IO ()
renameTest = do
  ctx <- newContext
  failed <- newIORef 0
  runSim ctx failed (withInput 400 300) editorView $ do
    Sim.click "Rename"
    typeText "notes.txt"
    pressKey KeyEnter
    expect "notes.txt"

A string literal aims at the visible text with that label. At p, Within rect, On response and OnWidget wid aim elsewhere. check and checkEq count failures on failed.

For lower-level tests, runFrame ctx input view runs one frame. Focus changes apply on the next frame, and geometry lags one frame, so warm up with warmup2 before targeting a widget by respRect. keyInp / chordInp from NanoUI.Testing.Harness build key input.

Async Work & Animation

The view must never block, and nothing should redraw while nothing moves. Slow work runs on a worker, timed work on a wake, and the frame loop sleeps in between. Compile with -threaded.

Task: one job per key

Allocate the handles once in IO:

data App = App
  { appSearch :: Task Text [Text]
  , appFile :: Task Text Text
  , appProgress :: Stream () Int
  }

newApp :: IO App
newApp = App <$> newTask <*> newTask <*> newStream

In the view:

results :: App -> Text -> NanoUI ()
results app queryKey = do
  status <- useTaskStatus (appSearch app) queryKey (fetchSearchResults queryKey)

  case status of
    TaskRunning previous -> showPreviousOrSpinner previous
    TaskDone found       -> renderResults found
    TaskFailed err _     -> danger (T.pack (displayException err))

When only the latest result matters, use useTask:

fileView :: App -> Text -> NanoUI ()
fileView app path = do
  contents <- useTask (appFile app) path (T.readFile (T.unpack path))
  label (fromMaybe "Loading..." contents)
  • The key identifies the job. A new key starts a worker and cancels the old job with an asynchronous exception.
  • A skipped hook cancels the job. If the view does not call useTask for a full frame, because the pane is in an inactive tab or inside a branch, the work is released. Keep the hook outside tabs when work must continue while hidden.
  • To retrigger the same input, put a counter in the key. A Retry button must change the key or the job never reruns.
  • Cancellation is an async exception. Use bracket or withFile inside the job so resources are released when the user changes the query.
  • The result is forced on the worker. Return a value in weak head normal form; force a list’s spine with length found \seq` pure found`.
  • TaskRunning previous carries the last result, so a list does not flash empty while the next query runs.

Stream: repeated pushes

A Stream is for sensors, downloads, token streams, and anything else that pushes repeatedly:

progress :: App -> NanoUI ()
progress app = do
  pct <- useStream (appProgress app) () (0 :: Int) $ \update ->
    forM_ [1 .. 100] $ \i -> do
      threadDelay 50000
      update (const i)
  progressBar (fromIntegral pct / 100)

The producer receives update :: (s -> s) -> IO () and returns when done, cancelled, or throwing. Each update wakes the loop; updates between frames coalesce into one frame.

  • Stop by not calling the hook. when isRunning (useStream ...) releases the producer.
  • Stopping discards the accumulated state. Save it outside and pass it as the next initial value to resume.
  • Producers must not touch the UI. They may call update from their thread or threads they start; never touch the UI Context.

Threads outside those handles

refreshInBackground :: Var [Text] -> NanoUI ()
refreshInBackground queryResult = do
  wake <- askWake -- in the view, once

  void . liftIO . forkIO $ do
    result <- expensiveQuery
    writeVarIO queryResult result
    wake -- schedule one frame

readVarIO, writeVarIO, and modifyVarIO are the thread-safe Var operations. The wake repaints the whole window, because it carries no record of which widget changed. Use a Stream for frequent updates.

Animation

animateTo moves a value toward a target:

sidebar :: Float -> NanoUI ()
sidebar targetWidth = do
  currentWidth <- animateTo (Tween EaseOutQuad 0.25 0) targetWidth
  panelWith (fixedW currentWidth . fillH) (label "Sidebar")

A Transition is either:

  • Tween ease duration delay, in seconds. Curves include EaseOutQuad, EaseInOutCubic, EaseOutBack, and EaseCubicBezier.
  • Spring params, a damped spring. presetSmooth, presetBouncy, and presetStiff are the presets.
follow :: Float -> Float -> NanoUI V2
follow targetX targetY = animateToA (Spring presetSmooth) (V2 targetX targetY)

Springs retarget mid-flight from their current position and velocity. animateToA animates any Animatable value (Float, Double, V2, or Color) channel by channel.

Continuous animation

pulse returns a value oscillating in [0, 1] with the given period in seconds:

fading :: NanoUI Float
fading = pulse 1.2

Clock-driven values do not, by themselves, keep the loop awake. A widget that draws from them must call keepAnimating with its own response, every frame it is on screen:

breathingDot :: NanoUI ()
breathingDot = do
  t <- pulse 1.2
  (resp, ()) <- customWidget defaultCustomWidgetSpec
    { widgetLayout = fixedWH 24 24 defaultLayout
    , widgetContent = contentKey [t * 100]
    , widgetDraw = \cdc (Rect x y w h) -> runCanvasFor cdc $
        drawCircle (V2 (x + w / 2) (y + h / 2)) (6 + 3 * t) (themeAccent (cdcTheme cdc))
    }
  keepAnimating resp

The loop goes back to sleep on the first frame that leaves it out.

Caution

Never force redraws by dirtying the context every frame. That is an unpaced busy loop.

Timers

wakeAfter requests one frame after a delay in seconds:

clock :: NanoUI ()
clock = do
  label =<< currentTimeText
  wakeAfter 1

Call it on every frame that still needs the wake; pending wakes reset each frame.

Choosing a tool

The work…Use
Returns one result for an inputTask + useTask / useTaskStatus
Pushes many updatesStream + useStream
Is already running in a thread you ownVar IO operations + askWake
Must keep running while its pane is hiddenKeep the hook outside the tab or branch
Changes on a schedulewakeAfter
Animates continuouslyanimateTo / pulse + keepAnimating

Runnable versions: Live Data & Background Work, Tasks.hs, LiveChart.hs, Animation.hs.

Desktop Windows

A window program

main :: IO ()
main = do
  docName <- newVar ("untitled.txt" :: Text)
  runSdlApp opts (editor docName)

opts :: SdlOptions
opts =
  defaultSdlOptions
    { sdlWindowSettings =
        defaultWindowSettings
          { wsTitle       = "Main Window"
          , wsSize        = Size 1280 800
          , wsMinSize     = Just (Size 640 480)
          , wsMode        = Windowed      -- Windowed | Fullscreen | Hidden
          , wsTransparent = False
          , wsResizable   = True
          }
    }

editor :: Var Text -> NanoUI ()
editor docName = column $ do
  textInput docName
  name <- readVar docName
  setWindowTitleUi (name <> " - My App")         -- setter: every frame
  whenM (button "Maximize") toggleMaximizedUi    -- command: on click only
  whenM (button "Quit") quitUi

runSdlApp :: SdlOptions -> NanoUI () -> IO () creates the window from opts, then runs frames until the window closes. A frame evaluates the view (editor) on the display thread, the OS thread that runs SDL; lays out the widgets the view declared; compares the resulting draw commands with the previous frame’s; and repaints the rectangles that differ. The view is evaluated a second time in the same frame when it writes a Var it already read.

A frame runs when SDL delivers an input or window event, or a timer or task wakes the loop. An idle window runs none. button :: Text -> NanoUI Bool reads the input events SDL delivered since the previous frame and returns True on the frame that delivers its click. whenM :: Monad m => m Bool -> m () -> m () runs the second action on that frame only.

NanoUI is a newtype over an effectful Eff stack that carries the frame’s input, layout tree and window. Only a backend runner or NanoUI.Testing can run it, so state that must outlive a frame lives outside it: main allocates docName :: Var Text with newVar in IO and passes it to editor.

Sizes are in layout units. Physical pixels are layout units times winScale, which the backend reads from the display. With the default wsPosition (WindowPositionDefault) the OS places the window. wsOpacity (SDL only) sets the opacity of the whole window.

Changing the window from a view

The OS window lasts for the whole run, and the view is evaluated again on every frame, so window calls differ in what a repeated call does. editor above uses one of each kind.

Setters store the value they last applied and call SDL only when the new value differs. A frame that skips a setter leaves the last value in place. editor calls setWindowTitleUi every frame with a title computed from docName, so the title changes on the same frame as each edit (textInput writes docName before readVar reads it): setWindowTitleUi, setWindowModeUi, setWindowMinSizeUi, setWindowMaxSizeUi, setWindowOpacityUi, setWindowIconUi.

A repeated setter call costs one Eq comparison. For setWindowIconUi, build the RgbaPixels once in setup and pass the same value each frame: ByteString equality returns at once for the same buffer, and compares every byte of a freshly decoded one.

Commands call SDL every time they are evaluated, because the user can also move, resize or maximize the window and there is no last value to compare against. Guard them with a widget result that is True on one frame only, such as button: moveWindowUi, centerWindowUi, resizeWindowUi, minimizeWindowUi, maximizeWindowUi, restoreWindowUi, toggleMaximizedUi.

SDL applies a command during the view, and the new window state comes back as an SDL event. askWindow in the same frame still returns the old size; a later frame returns the new one. moveWindowUi takes desktop coordinates on SDL and pixels on RGFW.

quitUi closes the window after the current frame is painted, and runSdlApp returns. Inside a nativeWindow body it closes only that window.

Window calls read the window from the NanoUI context, which exists only while the backend evaluates the view. To change the window from a worker thread, write a Var that the view passes to a setter with writeVarIO, then run the action askWake returns so a frame runs (State & Var).

askWindow :: NanoUI WindowState returns the record the backend fills before the view runs: size, scale, focus, maximized and fullscreen flags, and winCloseRequested. The first call marks the window as read. From then on, every change to the record (focus, a move, maximizing) runs a frame that repaints the whole window instead of the differing rectangles. A view that never calls askWindow pays nothing.

Confirming before close

Set wsExitOnCloseRequest = False. On a close request the backend leaves the window open, sets winCloseRequested, runs one frame, and clears the flag after it. The confirmation has to outlive that frame, so the view copies the flag into a Var:

confirmMain :: IO ()
confirmMain =
  runSdlApp
    defaultSdlOptions
      { sdlWindowSettings = defaultWindowSettings {wsExitOnCloseRequest = False} }
    appView

appView :: NanoUI ()
appView = do
  confirming <- useVar False
  mainContent
  -- True on one frame only: keep it in a Var so the modal stays open.
  whenM (winCloseRequested <$> askWindow) (writeVar confirming True)
  modal confirming "Quit?" $ do
    label "Unsaved changes will be lost."
    whenM (button "Quit") quitUi
    whenM (button "Cancel") (writeVar confirming False)

useVar False allocates the Var on the first frame and returns the same Var on later frames, matched by its call position in the view. modal confirming title body evaluates body as a modal dialog on every frame where confirming holds True.

Build a Notepad asks only when the document has unsaved changes.

More windows

nativeWindow :: Var Bool -> WindowSettings -> NanoUI () -> NanoUI () shows a second window while the Var holds True. body is an unevaluated NanoUI () action; the backend evaluates it as the view of the second window’s frames. Call nativeWindow every frame, like modal: the backend opens an OS window on the first frame that calls it with the Var set, and destroys it on the first frame that does not. The close button writes False to the Var.

inspectorMain :: IO ()
inspectorMain = newVar False >>= runSdlApp defaultSdlOptions . editorView

editorView :: Var Bool -> NanoUI ()
editorView showInspector = do
  checkbox "Inspector" showInspector
  nativeWindow showInspector
    defaultWindowSettings {wsTitle = "Inspector", wsSize = Size 360 480}
    inspectorView
  • Each window has its own keyboard focus and widget state (text cursors, scroll offsets, hover). useVar calls in body are numbered from the start of body, so they do not shift ids in the parent view.
  • nativeWindow reads settings once, when it opens the window. To change the title later, call setWindowTitleUi inside body.
  • body is a closure over the parent view’s bindings. Each open window evaluates it again after every frame of the parent view, so captured values are those of the parent’s latest frame.
  • Child cost scales with the parent’s frame rate: a parent animating at 60 fps evaluates and lays out every open body 60 times a second. A child repaints only when its draw commands differ from its previous frame’s.
  • body can open more windows.
  • Windows share state through Vars. A Var written in one window shows in the others on their next frame.
  • With NanoUIE (Elm-Style State), body emits to the same reducer. To keep the open flag in your model, pass constVar model.inspectorOpen, set wsExitOnCloseRequest = False, and emit a close message when winCloseRequested is set.
  • The SDL and RGFW backends open the window. Under NanoUI.Testing no window opens and body does not run.
  • On Windows, other windows stop redrawing while one is being resized, because Win32 blocks the event loop during a resize drag.

nano-ui-example-windows has three windows sharing state.

Custom title bars

sdlWindowDecorations removes the OS title bar. windowCaption :: [Rect] -> NanoUI Bool (from nano-ui-sdl) draws minimize, maximize, and close buttons and returns True on the frame close is clicked. Minimize and maximize act inside windowCaption.

framelessOpts :: SdlOptions
framelessOpts = defaultSdlOptions
  { sdlWindowDecorations = DecorationsNone
  , sdlWindowSettings = defaultWindowSettings {wsSize = Size 1000 720}
  }

framelessView :: NanoUI ()
framelessView = columnWith (fillW . fillH) $ do
  titleBar
  mainContent

titleBar :: NanoUI ()
titleBar = rowWith (fixedH 32 . fillW . alignMid) $ do
  menuResp <- button' "File"          -- interactive: exclude from dragging
  label "My Application"
  spacer (Grow 1) Fit
  closeClicked <- windowCaption [respRect menuResp]
  when closeClicked quitUi

The rest of the bar drags the window. windowCaption takes the row at the caption buttons’ height across the full window width, removes the rects you pass, the buttons and any floating panels (an open menu, a modal), and stores the remaining spans. SDL calls a hit-test callback with the pointer position on each press; the callback returns “drag” inside a stored span, and the OS then moves the window without delivering the press to the view. Pass the rect of every clickable widget in the bar. button' returns a Response instead of a Bool, and respRect takes its rectangle.

The rects are in layout units with the origin at the window’s top-left corner. windowCaption multiplies them by the window’s scale before storing them. The callback runs during SDL’s event handling, outside any frame, against the spans the last frame stored. respRect returns the previous frame’s rectangle, so the exclusions trail a moving widget by one frame. A frame that does not call windowCaption removes the spans.

DecorationsFrame removes the title bar but keeps the OS resize border. DecorationsNone removes both. On a resizable window, the outer edges resize instead of drag. windowCaptionWith customizes the buttons and resize border.

windowFrame wraps the view in a panel that fills the window, draws a rounded border frameWidth thick, and insets the view by frameWidth:

framedView :: NanoUI ()
framedView = windowFrame (defaultWindowFrame (colorRGBA 70 70 80 255)) $ do
  mainContent

Screenshots

requestScreenshot :: (Maybe Screenshot -> IO ()) -> NanoUI () queues a callback for the frame the view is building. After the backend presents that frame to the screen, it reads the pixels back and calls the callback, with Nothing if the backend can’t capture. The callback is IO, since it runs after the view has finished:

screenshotButton :: NanoUI ()
screenshotButton =
  whenM (button "Screenshot") $
    requestScreenshot $ \maybeScreenshot -> for_ maybeScreenshot saveShot

A capture copies the whole frame from the GPU at 4 bytes per physical pixel, about 16 MB for a 1280 by 800 window at scale 2. The callback runs on the display thread before the next frame starts, and that frame waits for it. Keep it short: write an IORef or forkIO the encoding.

askScreenshot :: NanoUI (IO (Maybe Screenshot)) reads the current window in the view and returns an IO action bound to it. Run that action on a background thread: it wakes the loop and blocks until the next frame is presented. Running it on the display thread deadlocks.

useScreenshot :: Eq k => Task k (Maybe Screenshot) -> k -> NanoUI (Maybe Screenshot) takes one screenshot per key and returns it to the view. The Task holds the result across frames, so newApp allocates it in IO. On a frame with a new key useScreenshot starts a capture and returns the previous key’s result until the capture arrives a frame or two later, as useTask does (see Async Work & Animation):

data App = App
  { appShots :: Task Int (Maybe Screenshot)
  , appCount :: Var Int
  }

screenshotMain :: IO ()
screenshotMain = newApp >>= runSdlApp defaultSdlOptions . view

newApp :: IO App
newApp = App <$> newTask <*> newVar 0

view :: App -> NanoUI ()
view app = do
  whenM (button "Screenshot") (modifyVar (appCount app) (+ 1))
  shotKey <- readVar (appCount app)
  shot <- if shotKey == 0 then pure Nothing else useScreenshot (appShots app) shotKey
  for_ shot showShot

nano-ui-sdl also has saveScreenshot env path (BMP) and captureScreenshot env (pixels), which read the last presented frame. env is the SdlEnv that askSdlEnv returns. Call both on the display thread. For PNG, encode with JuicyPixels on another thread.

Testing

Window actions do nothing when no backend window is attached, so views that use them run under NanoUI.Testing. Screenshots and custom title bars need a real SDL window, as in nano-ui-demo’s hidden-window tests.

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.

Recurring Bugs & Fixes

Bugs and fixes from the git histories of nano-ui applications (ned, stronghead, and winget-gui). Each entry starts with the symptom.

Symptom index

SymptomSection
Buttons and labels shift every frameLayout jitter
A list scrolls sideways when one row is longLayout jitter
A dropdown clips its longest optionLayout jitter
A box inside a box inside a dialogDouble borders
Text shimmers while scrolling at 125% scalingText metrics
A custom drawing does not updateStale cached drawings
A drag ghost freezes where it first appearedStale cached drawings
Content runs under the scrollbarScroll details
Ctrl+A fires while typingFocus and input ordering
Escape does not close anything while a field is focusedFocus and input ordering
Typed keys never reach a custom widgetFocus and input ordering
A drag selects one character leftwardFocus and input ordering
SVG clip paths collideSVG export

1. Layout shifting & jitter

A label whose text changes length moves its siblings every frame

A counter going 9 -> 10 -> 100, a timer, or a byte total changes the label’s natural width, which re-flows the row on every tick.

Fix: pin the label to the width of its widest expected value:

count :: Int -> NanoUI ()
count n = labelWith (fixedW 48 . alignEnd) (T.pack (show n))

stronghead made this fix more than once (“fix markers count to fixed width”).

A long value inside a scrolling list widens the whole list

One long row increases the column’s natural width, the list grows a horizontal scrollbar, and the bar appears and disappears as wide rows enter and leave.

Fix: make the cell fill its column instead of contributing natural width, and ellipsize the text:

-- In a row, a label too narrow for its text cuts it off rather than wrapping.
nameLine :: (Layout -> Layout) -> Text -> NanoUI ()
nameLine f = rowWith (fillW . tight) . labelWith (fillW . f)

A drop-down clips its longest option

A fixed width guessed for a select breaks when an option is long ("Unknown disk · 113 apps · 9.24 GB").

Fix: measure the options with the font and add the chrome:

diskSelect :: [Text] -> Var Int -> NanoUI ()
diskSelect options selectedVar = do
  fm <- uiFontMetrics
  let w = maximum (0 : map (lineWidth fm) options) + 56
  selectWith (fixedW w) options selectedVar

Content-derived geometry recomputes and jitters

Tree column widths, row heights, and pane splits that are derived from the current content every frame move when the content changes.

Fix: lock the geometry. Use a constant row height, and store a pane split the user chose in saved layout state (ned’s “grid size locking”).

flex around the wrong child

Make the one growable child fillW instead. stronghead replaced fixedW 90 + flex around a filter box with fillW, which also removed dead space.

2. Double borders & box in a box

nano-ui fills padded containers with the panel color, and scrollArea, panel, and card each draw their own background, border, and radius. Nesting them inside popups, dialogs, and panes draws a box inside a box.

Symptoms:

  • A scrollArea in a popup shows a bordered well inside the dialog.
  • A dialog has two concentric borders.
  • A pane with a panel inside a card looks recessed twice.

Fix: one level supplies the chrome.

  • Give the outer dialog padAll 0 and move the padding to the inner columnWith (fillW . padAll 12 ...).

  • Or strip the inner surface:

flatList :: NanoUI () -> NanoUI () flatList = styled (inputStyle (background colorTransparent . borderWidth 0))


- Set `themeFloatingWindow` and `themePopup` to the panel surface, so a padded body does not show a panel box inside the dialog.
- Between two chrome regions, draw one `separator` at the seam instead of a border on the bottom of one and the top of the other.

`stronghead` ("Remove duplicate borders from workspace popovers and notifications") and `winget-gui`'s dialogs both made this fix.

## 3. Text metrics & the pixel grid

### Glyphs drift over a long run

`fmAdvance` per glyph multiplied by the character count is not the shaped width of a run. A shaping backend lays a run out by unrounded advances (8 vs 8.25 px at size 15), so per-glyph arithmetic drifts a cell every ~32 characters and long runs overdraw what follows.

**Fix:** measure the whole run:

```haskell
runWidth :: FontMetrics -> Text -> Float
runWidth fm run = lineWidth fm run

(ned: “text stays on the cell grid along a long run”.)

Text shimmers at fractional DPI scales

At 125% scaling, rows of 22.4 px stack as 22, 23, 22, 23 and text shimmers while scrolling. Positions and row heights must be whole device pixels.

Fix: snap with the font’s snap scale:

snap :: FontMetrics -> Float -> Float
snap f v = let k = max 1 (fmSnapScale f) in fromIntegral (round (v * k) :: Int) / k

winget-gui’s rowButton does this.

Box-centred is not optically centred

A large label over a small one centres by line boxes, which leave more room under the smaller line, so the block reads about 4 px high. Nudge it with padTop, or centre on the ink: measure the ink span and the cap/x-height midpoints from FontMetrics glyphs.

alignMid on a row positions the row, not its contents

Centring the row’s contents takes alignMid on each child. This was stronghead’s “header dead space” fix.

4. Stale cached drawings

Without a content key, a custom widget’s ops are rebuilt and diffed every frame. With a key, unchanged frames skip both, so a key that misses an input shows stale pixels.

The drawing freezes on an input the key missed

winget-gui’s sort arrow stayed on the old column until the key covered everything the drawing read.

Fix: key on every input: values, colours, text, theme, scroll viewport width, animation state. contentKey hashes numbers; fold colours and text in with mix64 from NanoUI.Backend.

sortArrow :: Int -> Bool -> Float -> NanoUI Response
sortArrow col ascending scrollY =
  drawingVersioned (contentKey [fromIntegral col, if ascending then 1 else 0, scrollY]) (fixedWH 12 12) arrowOps

A pinned overlay keeps its first frame’s ops

A drag ghost pinned under the pointer never changes its layout rectangle, so an unversioned drawing keeps the frame it appeared in (ned’s “tab ghost”).

Fix: version it by what moves it:

-- ghostOps :: Rect -> SmallArray DrawOp
dragGhost :: Float -> Float -> NanoUI Response
dragGhost mx my = drawingVersioned (contentKey [mx, my]) (fixedWH 32 32) ghostOps

Widths from the scroll viewport belong in the key

ned’s finder rows clamp to rectW (scrollViewport metrics) and key on it, so the drawing follows the viewport when the scrollbar appears.

5. Scroll details

Content runs under the scrollbar

The scrollbar keeps a lane out of the viewport. A wide row drawn to the widget’s full width runs beneath the bar. Clamp content and hit targets to the viewport width, but keep the backdrop filling the widget or its edge shows as a seam down the lane (ned’s “hold the finder’s rows out of its scrollbar’s lane”).

The wheel step does not match the row height

The default notch (60 px) jumps about one row of 54 px or three smaller rows. Set the step so a notch is one row:

oneRowPerNotch :: Float -> NanoUI ()
oneRowPerNotch rowHeight = do
  ctx <- askContext
  liftIO $ setScrollTuning ctx defaultScrollTuning
    {scrollWheelStep = rowHeight, scrollSmoothTime = 0}

A scroller can also set its own step with setScrollStepUi; 0 returns it to the context step.

6. Focus and input ordering

Focus queries at the top of the view always return False

The node arena is reset before the view runs, so isFocused (or any check of editing state) at the top of appView returns False every frame.

Symptom: Ctrl+A selects every item while the search box is being typed into; Delete opens the uninstall dialog.

Fix: read focus and place shortcuts at the end of the view, after the widgets have run:

appView :: Var Text -> NanoUI ()
appView query = do
  searchResp <- searchInput' "Search" query
  -- ... all widgets ...
  searching <- isFocused (respId searchResp)
  unless searching $ whenM (shortcut (key KeyDelete)) deleteSelected

Escape behind the same guard as Ctrl+A

Text inputs do not claim Escape. If Escape shares the “a field is editing” guard with Ctrl+A and Delete, Escape can never clear a selection once a field has focus.

Fix: give Escape its own condition.

Typed keys never reach a custom widget

A focused widget that reads inputChars must call useInputMethod every frame it accepts text, even when it is not a text field. ned’s file tree takes vim’s j/k as typed text; only the editor called useInputMethod, so the keys never reached the tree.

Fix:

-- Every frame the widget accepts text:
composition :: WidgetId -> Rect -> NanoUI ()
composition wid caretRect = do
  preedit <- useInputMethod wid InputNormal caretRect
  for_ preedit drawCompositionAtCaret

A slow leftward drag selects one character

A one-character selection’s anchor sits on its left, so re-deriving the anchor from the selection each frame carries it along with a leftward drag (ned’s “slow leftward drags”).

Fix: keep the anchor from the press.

A widget that appears next frame cannot be focused this frame

requestFocus resolves after layout, but only for widgets declared on that pass. If a field appears because of a state change, request focus on the pass that declares it (store a focusPending flag). See Keyboard, Focus & IME.

Conditional widgets shift ids

A branch that declares different widgets than its alternative changes the id count for everything after it; widgets appear to swap caret state. Wrap the branch in scope, and keep overlays unconditional. See Widget Identity & Keys.

7. SVG export

  • Several canvas drawings in one frame emit colliding clip_/grad_ element ids. Scope them per drawing (prefix each <g><defs> block with an index), as stronghead does.
  • Right-aligned numerals lose their anchor: text-anchor/textLength may need post-processing, and tabular numbers help readouts (font-variant-numeric: tabular-nums at the root).

Quick checklist

  • liftIO (newVar ...) inside a view: allocates every pass. Move it to setup.
  • Unguarded side effects: the view re-runs. Wrap them in whenM (button ...).
  • Conditional widgets or useVars without scope; keyed lists using indices.
  • Overlays called conditionally instead of unconditionally with a Var Bool.
  • useTask inside a tab or branch: the task is cancelled when hidden.
  • keepAnimating at an always-run top level: a busy loop. Call it in the animating widget; use wakeAfter for timers.
  • Writing a freshly computed NaN to a Var Float: NaN /= NaN, so it redraws forever. Guard the write.
  • A custom widget’s content key missing an input: stale pixels or a frozen drawing.
  • A changing label without fixedW: its siblings shift every frame.
  • A wide cell in a scrolling list without fillW: the list scrolls sideways.
  • A scrollArea / panel / card nested in a popup: a box inside a box.
  • Focus queries at the top of the view: the arena was reset; read them at the end.
  • Escape behind the same guard as Ctrl+A/Delete: text inputs do not claim Escape.
  • A focused widget reading inputChars without useInputMethod: typed text never arrives.
  • Per-glyph advance × count for a long run: use lineWidth.
  • Parsing or rebuilding documents per frame: parse once and keep the result outside the view.
  • readVar above the widget that edits the Var: an extra pass per change.
  • Missing -threaded.
  • Touching Context from a worker thread: wake with askWake instead.

Development Setup

Working on nano-ui itself. To use it in an application, see the Quick Start.

Setup

This repository’s nix develop shell provides GHC 9.14, Cabal, SDL3, SDL3_ttf, and pkg-config. Outside it, install the same tools listed under Quick Start prerequisites.

Building and testing

cabal build -j1 all
cabal test -j1 all

Warning

Build with -j1. Parallel optimizing GHC processes can exhaust memory. For a hard cap, prefix with GHCRTS=-M4G.

Test suites

SuiteCovers
nano-ui-testWidgets, layout, input, focus, damage, and draw lists, frame by frame and headless
text-buffer-specText buffer and document edits: lines, graphemes, carets, undoable edits
nano-ui-inspectionHot paths (quad writers, store and focus access, canvas) still optimize as written
nano-ui-emit-testEmitting views, adapters, and reducers
nano-ui-svg-testDrawOp-to-SVG: clips, gradients, escaping, rasterization
nano-ui-diagrams-testChart and diagrams-lib rendering
nano-ui-form-testForm validation, reset, and submission
nano-ui-markdown-testParsing, incremental append against a full parse (QuickCheck), layout
nano-ui-rgfw-test, nano-ui-rgfw-bindings-testRGFW input translation, bitmap font, frame delivery
nano-ui-font-search-test, nano-ui-font-effects-testSDL font discovery, shaping, caches, handle lifetimes
nano-ui-render-testNative SDL readback: partial damage, clipping, screenshots
nano-ui-demo-test, nano-ui-terminal-testThe demos and the terminal in hidden SDL windows
nano-ui-book-testThis book: no inline Haskell, #include anchors, specimen tags, and an mdbook build

One suite, with failure details:

cabal test nano-ui-test --test-show-details=failures

Haddocks and Hoogle

Released packages have Haddock pages on Hackage. To build documentation for the current checkout:

cabal haddock-project --output=haddocks --hoogle

The index is haddocks/index.html; --hoogle also writes Hoogle input files there. One package, with less memory:

cabal haddock nano-ui --haddock-hoogle

Its output is under dist-newstyle/.../doc/html/nano-ui/.

To search locally, point hoogle generate --local=... at the directory holding the generated .txt files. See Installing Hoogle locally.

Book code

The book’s snippets are anchored regions of compiled modules: packages/nano-ui-demo/book/Book/*.hs, or packages/nano-ui-svg/app/*Examples.hs for code drawn to a picture. Include them with an anchored #include; nano-ui-book-test rejects Haskell written inline.

Module conventions

The companion packages are listed in Ecosystem Packages. In packages/nano-ui/lib, the public API is NanoUI and NanoUI.Widgets.*; packages/nano-ui/lib/NanoUI.hs is the full export list. NanoUI.Internal.* is exposed for backends and tests and may change between minor releases.

Architecture & Internals

How a frame runs, and how applications are structured around it. None of this is needed to write a view; it explains what readVar, focus, and damage cost.

The frame lifecycle

Each frame:

  1. The NanoUI () action runs top-to-bottom, appending layout nodes, reading input, and reading and writing Vars.
  2. If a Var was written after it was read this pass, the action runs again with one-shot input events cleared.
  3. The layout pass computes a rectangle for every node from flex weights, gaps, padding, and constraints.
  4. Pointer coordinates, hover, press, shortcuts, and Tab order are resolved against the new geometry.
  5. The new geometry and draw ops are diffed against the previous frame’s to find the damaged region.
  6. The backend rasterizes and presents only the damaged region.

The view runs before layout, so respRect is the previous frame’s rectangle. Focus is resolved after the view, so a focus request takes effect next frame.

Consequences

  • The view can run more than once per frame. A write to a Var read earlier in the pass triggers a second pass with one-shot events cleared. Guard side effects with an event (whenM (button ...)), and never allocate frame-lived resources inside the view.
  • readVar placement is a performance decision. Reading below the input that edits a value keeps a change to one pass; reading above costs a second pass. A Var bound to a widget with no readVar repaints only that widget; a readVar promotes the change to a full-window repaint.
  • Geometry lags one frame. respRect is empty on a widget’s first appearance. Tests warm up before targeting rectangles.
  • Widget ids are positional. Conditional widgets, reordered lists, and conditional overlays change the count and therefore later ids. scope and withKey keep the counts stable; see Widget Identity & Keys.
  • An idle window costs nothing. With no animation, timed wake, task, or input, the loop blocks on the OS event queue. Do not dirty the context every frame to force redraws.

Application patterns

Three applications (ned, stronghead, winget-gui) share one rule, set up once in IO, then run, and differ in where durable state lives.

main :: IO ()
main = do
  env <- newEnv
  runSdlApp defaultSdlOptions (appView env)

Pattern A: one domain state and a wake channel

winget-gui keeps package-manager results in one IORef and snapshots it at frame start:

data Env = Env
  { envState :: IORef AppState
  , envWake  :: IORef (IO ())
  }

appView :: Env -> NanoUI ()
appView env = do
  wake <- askWake
  liftIO $ writeIORef env.envWake wake
  state <- liftIO $ readIORef env.envState
  mainLayout state

-- On a worker thread:
publish :: Env -> [Text] -> IO ()
publish env results = do
  atomicModifyIORef' env.envState (\s -> (s {packages = results}, ()))
  wake <- readIORef env.envWake
  wake

Gather every read into one Frame record before building the window, memoize derived collections in IORef (Maybe (key, value)) caches keyed by a revision counter, and read shortcuts after the widgets. Use this when the domain state is shared with workers, a database, or a protocol.

Pattern B: an app record of Vars

stronghead has many independent panels, each with its own view state:

data App = App
  { filterText   :: Var Text
  , activeTab    :: Var TabId
  , zoomLevel    :: Var Float
  , isInspecting :: Var Bool
  }

newApp :: IO App
newApp = App <$> newVar "" <*> newVar TabOverview <*> newVar 1.0 <*> newVar False

Panel modules take the slice they own. Use this when the application is mostly interactive UI state with little external I/O.

Pattern C: application-owned state

ned keeps a plain IORef App that nano-ui holds no reference to. The view projects slices and re-reads after anything that may change the model:

editorView :: Editor -> NanoUI ()
editorView (Editor ref) = do
  app <- liftIO (readIORef ref)
  renderEditor app
  app' <- liftIO (readIORef ref)
  when (viewChanged app app') requestFrame

requestFrame schedules one more frame; call it when what was drawn no longer matches the state. Use this for editors, terminals, games, and models that already exist as a library.

Choosing

The application…Pattern
Has one domain state, uses worker threads, runs commandsA: IORef + askWake
Is a large set of interactive panes and toolsB: record of Vars
Has a model that predates the UI or is shared with a libraryC: app-owned IORef + requestFrame
Is a state machine with explicit transitionsElm-style (nano-ui-emit)
Is a small toolWhatever is simplest; a handful of useVars is fine

Patterns mix. An IORef domain state with Var view state is normal; an Elm reducer can sit inside one pane of a Var-based application.

The shared view discipline

  1. Read state once at the top, or immediately before the subtree that uses it.
  2. Build top-to-bottom. Layout is solved after the view.
  3. Read after the parts that may write.
  4. Run keyboard and focus queries last.
  5. Guard effects with events. The view can run more than once per frame.
  6. Keep derived values derived. Cache expensive work by revision, not by copying fields.