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

Keyboard, Focus & IME

The view reads keyboard input during the frame; there are no key handlers to register. A focused widget claims the keys it uses, and shortcuts and key queries skip claimed keys.

Shortcuts

import NanoUI.Shortcut

saveChord :: Shortcut
saveChord = cmdOrCtrl <> key 's'

saveCommand :: NanoUI ()
saveCommand = whenM (shortcut saveChord) saveDocument

<> unions modifiers and keeps the right-hand key. key accepts a Key or a Char; letters are case-insensitive, and space maps to KeySpace:

chords :: [Shortcut]
chords =
  [ ctrl <> key 's'                 -- Ctrl+S
  , cmdOrCtrl <> shift <> key 'p'   -- Cmd+Shift+P on macOS, Ctrl+Shift+P elsewhere
  , alt <> key KeyEnter             -- Alt+Enter
  , key (KeyF 5)                    -- F5
  ]

Use cmdOrCtrl for application commands. ctrl, shift, alt, and super are exact: ctrl <> key 's' does not match Ctrl+Shift+S.

  • shortcut fires on the frame the chord goes down, including auto-repeat (holding Ctrl+Z repeats undo).
  • shortcutOnce ignores auto-repeat. Use it for toggles and one-shot commands.

Only the first matching shortcut call in a frame fires. It consumes that key’s presses for the frame, so two bindings of the same chord never both run. Declaration order is priority order.

What suppresses a shortcut

A chord does not fire:

  • behind an open modal;
  • inside disabledWhen True;
  • when the focused widget claims the key: a text field keeps typing, cursor, and editing chords such as Ctrl+A and Ctrl+Z; buttons keep Enter and Space; sliders, selects, and lists keep arrows, Home, End, Page Up, and Page Down;
  • for Escape when takeEscape fails, or when an open overlay takes it first.

Focused widgets claim only their own unmodified or Shift-modified keys, so modified chords such as Ctrl+Enter, Alt+Left, or Ctrl+S still reach the application while a field is focused. The matching low-level functions are filtered the same way: keyPressed, keyPressedOnce, keyReleased, and keyHeld. Use raw pressedIn only when implementing a custom widget.

menuItemShortcut shows a chord beside a menu row and binds it while the menu is open. Bind the same chord outside the menu for when it is closed:

saveBindings :: NanoUI ()
saveBindings = do
  whenM (menuItemShortcut "Save" saveChord) saveDocument
  whenM (shortcut saveChord) saveDocument

The open menu’s row is declared first, so it wins.

Escape

Text fields do not claim Escape, so Escape can still close a popup, clear a selection, or leave a mode while the caret is in a field. That also means every Escape handler needs its own condition. Do not share the “a field is editing” guard used for Ctrl+A or Delete.

takeEscape claims a press for the current handler:

escapes :: Bool -> Var Bool -> NanoUI ()
escapes isEditing editing = do
  when isEditing (whenM takeEscape (writeVar editing False))
  whenM takeEscape clearFocus

The first call that succeeds wins. An open popup, dropdown, or text-edit menu reserves Escape before the view’s own handlers run.

User-configurable chords

parseShortcut reads a chord from text, for a settings file or keymap editor:

keymapEntry :: Text -> NanoUI ()
keymapEntry txt =
  case parseShortcut txt of -- "C-S-s"
    Right chord -> label (shortcutLabel chord)
    Left err    -> showError err

The syntax is modifier prefixes followed by a key: C- Ctrl, S- Shift, A- Alt/Option, s- Super, M- Command on macOS and Ctrl elsewhere. A key is one character as typed without Shift, or a named key in angle brackets: C-s, M-S-p, A-<Enter>, <F5>, C--, C-S-=. Name a symbol by its unshifted key, so Ctrl++ on a US layout is C-S-=.

shortcutLabel renders a chord for menus and tooltips.

Focus

  • Clicking a text field, select, slider, or focusable custom widget focuses it. Clicking elsewhere unfocuses the current field.
  • Tab moves to the next focusable widget; Shift+Tab moves to the previous one.
  • requestFocus (respId resp) focuses a widget. focusNext and focusPrevious move focus like Tab and Shift+Tab. clearFocus drops focus at the end of the frame.
  • holdFocus wid claims focus every frame for a view that handles input itself; it also consumes Tab so an editor can indent with it.

Focus requests are applied at the end of the frame, against that frame’s layout. A focus move is visible on the next frame:

findField :: Var Text -> NanoUI ()
findField query = do
  resp <- searchInput' "Find" query
  whenM (shortcut (cmdOrCtrl <> key 'f')) (requestFocus (respId resp))
  -- next frame: the focus ring is drawn and isFocused answers True

A request can name a widget declared later in the same pass (for example with currentId just before it), but not one that is not declared on this pass at all. A widget that appears after a state change needs a delayed request:

editableName :: Var Text -> NanoUI ()
editableName draft = do
  editing <- useVar False
  focusPending <- useVar False

  showField <- readVar editing
  scope $
    if showField
      then do
        fieldR <- textInput' draft
        whenM (readVar focusPending) $ do
          requestFocus (respId fieldR)
          writeVar focusPending False
      else label "Not editing"

  whenM (button "Edit") $ do
    writeVar editing True
    writeVar focusPending True

Warning

Query focus at the end of the view. The node arena is reset before the view runs, so isFocused at the top always returns False. Keyboard shortcuts that depend on focus belong after the widgets have run.

A focus request is ignored for a disabled widget, a widget behind an open modal, one not declared this pass, or a group response that is not itself a Tab stop. A widget in an inactive tab is not declared, so it cannot keep focus; request focus again when the tab is shown.

disabledWhen dims a subtree and blocks interaction without changing layout or state. The focus ring is themed by themeFocusRing.

IME

Input method editors compose text before committing it. Built-in text fields handle the whole cycle, including caret rendering and suppressing shortcuts mid-composition.

A custom widget that reads committed characters from inputChars must opt in every frame it accepts text:

-- Every frame the widget accepts text:
composition :: WidgetId -> Rect -> NanoUI ()
composition wid caretRect = do
  preedit <- useInputMethod wid InputNormal caretRect
  for_ preedit drawCompositionAtCaret

useInputMethod :: WidgetId -> InputPurpose -> Rect -> NanoUI (Maybe Composition) places the candidate window at the caret and returns the in-progress composition to draw. Pass the caret rectangle, not the whole widget. It checks focus itself, so a separate isFocused guard is unnecessary.

Raw input fields: inputChars (committed text), inputKeys (pressed, including repeats), inputKeysNew (initial presses), inputKeysHeld, inputKeysReleased, and inputComposition. The terminal demo in packages/nano-ui-demo/examples/Terminal.hs is a complete custom text widget.

Testing

NanoUI.Testing.Sim drives a view headlessly the way a user would. runSim settles the view, then the script clicks text, types, and presses keys, each action running the frames a real event would:

renameTest :: IO ()
renameTest = do
  ctx <- newContext
  failed <- newIORef 0
  runSim ctx failed (withInput 400 300) editorView $ do
    Sim.click "Rename"
    typeText "notes.txt"
    pressKey KeyEnter
    expect "notes.txt"

A string literal aims at the visible text with that label. At p, Within rect, On response and OnWidget wid aim elsewhere. check and checkEq count failures on failed.

For lower-level tests, runFrame ctx input view runs one frame. Focus changes apply on the next frame, and geometry lags one frame, so warm up with warmup2 before targeting a widget by respRect. keyInp / chordInp from NanoUI.Testing.Harness build key input.