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:
- The
NanoUI ()action runs top-to-bottom, appending layout nodes, reading input, and reading and writingVars. - If a
Varwas written after it was read this pass, the action runs again with one-shot input events cleared. - The layout pass computes a rectangle for every node from flex weights, gaps, padding, and constraints.
- Pointer coordinates, hover, press, shortcuts, and Tab order are resolved against the new geometry.
- The new geometry and draw ops are diffed against the previous frame’s to find the damaged region.
- 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
Varread 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. readVarplacement is a performance decision. Reading below the input that edits a value keeps a change to one pass; reading above costs a second pass. AVarbound to a widget with noreadVarrepaints only that widget; areadVarpromotes the change to a full-window repaint.- Geometry lags one frame.
respRectis 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.
scopeandwithKeykeep 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 commands | A: IORef + askWake |
| Is a large set of interactive panes and tools | B: record of Vars |
| Has a model that predates the UI or is shared with a library | C: app-owned IORef + requestFrame |
| Is a state machine with explicit transitions | Elm-style (nano-ui-emit) |
| Is a small tool | Whatever 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
- Read state once at the top, or immediately before the subtree that uses it.
- Build top-to-bottom. Layout is solved after the view.
- Read after the parts that may write.
- Run keyboard and focus queries last.
- Guard effects with events. The view can run more than once per frame.
- Keep derived values derived. Cache expensive work by revision, not by copying fields.