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 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.