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

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.