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

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.