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 aResponseholding the widget’s identity, rectangle, hover and click state, and whether Enter was submitted (respSubmitted).buttonalready returns the oneBoolthis row needs.readVar draftsits 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.modifyVarapplies 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.
requestFocustakes effect at the end of the frame.
Note
The
whenguard around the mutation is required. The view runs every frame, so an unguardedmodifyVarwould add an item on every pass. Event-driven work goes insidewhenM (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 itemsgives the current list and a functionat :: Todo -> Var Todo.at todois aVaraddressed 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 positionaluseVarslots. Without the key, deleting the first row would move every later row’s caret, edit flag, and half-typed draft one position up.matchesfilters 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:
scopewraps the label/field swap. Only one of the two branches runs on a given frame. Withoutscope, the widgets after the branch would shift ids whenever the row enters or leaves edit mode.scopereserves one slot either way. (TheuseVarslots inside the row are stable because the whole row is underwithKey.)- The focus request is delayed.
requestFocuscan 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 infocusPendingand issued on the pass that declares the field. - Editing writes back into the item’s
Var.todo.todoTextprojects one field;todo.todoDonedoes 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
useReorderfor drag-and-drop reordering (see Drag & Drop). - Remember the filter across runs in a
Settingsrecord allocated withnewVar. - Show an empty state when
shownis empty, for examplemuted "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.