Keyboard and gamepad
Your widgets work from the keyboard and a gamepad with no extra code; here is how focus moves between them, and how to follow it.
How focus moves
| Press | What it does |
|---|---|
| Tab, Shift+Tab | moves to the next or previous widget in order |
| An arrow key, the D-pad or the left stick | moves to the nearest widget that way, preferring one in the same row (Left, Right) or column (Up, Down) |
| Enter, gamepad A | presses the focused widget |
| Escape, gamepad B | asks to go back (Handle Back) |
| PageUp, PageDown, Home, End, LT, RT | page through what scrolls |
| The right stick | scrolls |
| The Menu key, Shift+F10, gamepad X | ask for the focused element's actions (Drag and context menus) |
The focus ring shows once someone uses keys or a gamepad, and not after a click or a tap.
Where focus starts
With nothing focused, the first arrow key, D-pad or stick press only shows where focus starts. It lands on the first of these that shows:
- the widget you gave
default_focus = true; - a text field made with
autofocus = true; - the widget at the top left.
Tab with nothing focused starts at the default_focus widget too, else
at the first widget in order. An autofocus text field also takes focus
as soon as it is made.
local ui = datumhue.ui
local token = datumhue.theme.token
local form = ui.root:panel({ width = 420, gap = 12 })
form:label({ text = "New player", font_size = "1.5rem" })
local name = form:text_input({ placeholder = "Name" })
local volume = form:slider({ value = 0.8 })
form:checkbox({ label = "Show hints" })
local row = form:panel({ flex_direction = "row", gap = 8, padding = 0 })
row:button({ text = "Cancel" })
local start = row:button({ text = "Start" })
start.default_focus = true
local hint = form:label({ color = token("text_secondary") })
datumhue.events.on("focus", function(e)
if e.element == nil then
hint.text = "Press an arrow key, Tab or the D-pad"
elseif e.element == name then
hint.text = "Type a name; Down moves on"
elseif e.element == volume then
hint.text = "Left and Right change the volume"
else
hint.text = "Enter or gamepad A presses"
end
end)
In this example: datumhue.ui datumhue.theme.token datumhue.ui.root UiElement:panel UiElement:label UiElement:text_input UiElement:slider UiElement:checkbox UiElement:button UiElement.default_focus datumhue.events.on UiElement.text
- The first arrow key press lands on Start, its
default_focuswidget. - The
focusevent reports each change ofui.focused, so the hint follows focus without checking every frame. It also fires once right away, with nothing focused. - In the name field, Down moves on; on the slider, Left and Right change the volume.
Widgets that keep keys
A widget keeps the keys it uses itself, and the D-pad works some widgets the same way:
| Widget | Arrow keys | D-pad |
|---|---|---|
| Slider | Left and Right change it; Up and Down move focus | the same |
| One-line text field | Left and Right move the caret; Up and Down move focus | moves focus |
| Multi-line text field | move the caret | moves the caret |
| List, radio group | move the selection, except the way that would wrap it | the same |
| A date picker's calendar | move the day | the same |
| Any other widget | move focus | moves focus |
The left stick always moves focus, so it leaves every widget.
A multi-line text field made with tab_spaces keeps Tab and Shift+Tab
too, as a code editor does: Tab inserts that many spaces at the caret,
and Shift+Tab takes up to that many off the start of the line. Escape
leaves the field.
Tab and the arrows pass over a widget that is hidden or disabled, one a popover or menu covers, and one cut off by a panel that does not scroll.
A screen reader announces a radio group by its accessible_label when
focus reaches it, as it announces a button by its text. A label beside
the group does not name it, so give every group a name. Assigning
UiElement.accessible_label renames a group in place: focus and the
selection stay where they are.
local ui = datumhue.ui
local row = ui.root:panel({ flex_direction = "row", gap = 16 })
row:label({ text = "Motion" })
local motion = row:radio_group({
options = { "On", "Calm" },
selected = 1,
accessible_label = "Motion",
})
print(motion.accessible_label)
In this example: datumhue.ui datumhue.ui.root UiElement:panel UiElement:label UiElement:radio_group UiElement.accessible_label
Text fields
Ctrl+Z undoes the last edit in a focused text field, and Ctrl+Shift+Z or
Ctrl+Y redoes it; on a Mac, iPhone or iPad, Cmd takes the place of Ctrl.
A run of typing undoes as one step. Setting UiElement.text from your
code clears the field's undo history. The field then shows the new text
from its start, with the caret at its end.
A multi-line text field scrolls its text under the mouse wheel and a touch drag, and passes the scroll on to the panel holding it once the text reaches its end. A mouse drag in it still selects text.
For code, make a multi-line field with syntax = "lua",
line_numbers = true and tab_spaces. The text takes the theme's
syntax_* colors as it is typed, a column at the field's start numbers
its lines, and Enter starts each new line indented as the line above it.
Tab inserts spaces at the caret. With a whole line or several lines
selected, Tab indents each of them and Shift+Tab moves them back out;
empty lines stay empty, and the selection stays on the same text.
Ctrl+Z undoes an indent in one step. Escape leaves the field, so the
keyboard can reach the rest of your app.
A single-line field takes syntax too, for a short piece of code such
as the missing part of a line. It colors what is typed in the same way,
while Enter still calls on_submit and Tab still moves to the next
widget.
To point at lines, such as the line an error is on, set the field's
line_marks. Each mark colors its line's rows and shows a sign beside
the line's number, and it stays on its line as the user types above it.
To bring that line into view, call reveal_line with its number: the
text scrolls only when the line is out of sight, and the panels holding
the field follow, as they do when Tab reaches it. The caret and focus
stay where they are.
A screen reader announces a text field by its accessible_label, as it
announces a button by its text. A placeholder is not announced in its
place, and a label beside the field does not name it, so give every
field a name. Assigning UiElement.accessible_label renames a field in
place: focus, the caret and the selection stay where they are.
local ui = datumhue.ui
local form = ui.root:panel({ width = 320, gap = 8 })
form:label({ text = "Find" })
local find = form:text_input({
placeholder = "A word or a phrase",
accessible_label = "Find in notes",
})
print(find.accessible_label)
In this example: datumhue.ui datumhue.ui.root UiElement:panel UiElement:label UiElement:text_input UiElement.accessible_label
Pressing a button takes focus from a text field, and on a phone the
on-screen keyboard closes with it. A button made with
keep_input_focus = true leaves your app's focused field focused
instead: the caret, the selection and the keyboard stay, so a code
editor's Run button runs the code while the person goes on typing.
While anything else has focus, pressing such a button moves focus as
pressing any button does.
local ui = datumhue.ui
local input = datumhue.debug.input
local box = ui.root:panel({ width = 360, gap = 8 })
local editor = box:text_input({
value = 'print("Hello")',
multiline = true,
syntax = "lua",
height = 120,
accessible_label = "main.lua, your code",
})
local run = box:button({
text = "Run",
keep_input_focus = true,
on_click = function()
print("editor focused: " .. tostring(ui.focused == editor))
end,
})
editor:focus()
datumhue.time.after(0.5, function()
local center = run.bounds.center
input.cursor(center.x, center.y)
input.click()
end)
In this example: datumhue.ui datumhue.debug.input datumhue.ui.root UiElement:panel UiElement:text_input UiElement:button datumhue.ui.focused UiElement:focus datumhue.time.after UiElement.bounds datumhue.debug.input.cursor datumhue.debug.input.click
- Half a second in,
datumhue.debug.inputpresses Run, as a test would. The editor still has focus, so Run printseditor focused: true. - Without
keep_input_focus, the press moves focus to Run, and it printseditor focused: false.
Popovers and menus
While focus is in an open popover or menu of your app, the arrows, the
D-pad and the stick stay inside it. A popover made with
navigation = "modal" holds them, and Tab too, for as long as it shows,
whether focus is inside it or not:
- the first press lands inside it;
- a widget left focused behind it gets no keys;
- PageUp, PageDown, Home, End and the triggers page what scrolls inside it.
One made with navigation = "none", such as a toast, holds nothing.
In an app running inside an element of another app's layout, Tab past a modal popover's last widget, or an arrow key that finds nothing more in it, still leaves the app, as it does past the app's own widgets.
The gamepad's buttons
- A presses the focused widget, as Enter does, but never submits a text field: focusing the field already readies it for the platform's keyboard.
- B asks to go back, and never reaches your widgets as a key.
- When the D-pad, the left stick, A or a trigger acts as a key (an
arrow key, Enter, PageUp or PageDown), that key reaches your widgets
only:
input.on_keynever hears it.input.on_buttonand the gamepad reads see the gamepad's own buttons.
Follow focus
| To | Use |
|---|---|
| ask whether an element holds focus | UiElement.focused |
| name the element that holds it | ui.focused, nil while none of yours does |
| hear each change | the focus event of datumhue.events.on |
| hear focus come into or leave part of your UI | UiElement:on_focus, UiElement:on_blur |
| move focus | UiElement:focus |
UiElement:on_focus and UiElement:on_blur fire for focus anywhere
inside the element, and never for a move within it, so a card, a row or
a toast can react to focus anywhere in it.
When focus is on a link inside a text element, ui.focused names the
text element. When it is on a part of a widget, such as a number
input's steppers, a picker's popover or a menu's button, ui.focused
names the widget you made. That element's own focused stays false,
since the link or the part holds focus, and a move between parts reports
no change.
Focus and other apps
Each app keeps its focused widget while another app is in front, and
gets it back when it is focused again. When the focused widget goes
away while someone uses a keyboard or a gamepad, focus moves to its
nearest neighbor. When one of your callbacks takes it away, that happens
as the callback ends, so the callback can focus the widget it chooses
first. UiElement:focus called while another app is in
front, or while a dialog of the workstation's own is open, moves no
keys: it picks the widget your app gets back.
Games
A game that reads the arrow keys or the D-pad itself is unaffected while
it shows no widget that can take focus. One that mixes play with widgets
turns focus navigation off with ui.focus_navigation = false. Its
widgets then keep only their own keys, and Escape and B make no request
to go back, except Escape in a focused text field.
See also
- Handle Back: where Escape, B and Back go.
- Read keys, mouse, touch and gamepads: read input yourself, for a game.
- Input, focus and Back: which app hears the keys.
ui.focus_navigation: every rule focus navigation follows.
Reference used on this page: datumhue.ui datumhue.theme.token datumhue.ui.root UiElement:panel UiElement:label UiElement:text_input UiElement:slider UiElement:checkbox UiElement:button UiElement.default_focus datumhue.events.on UiElement.text UiElement:radio_group UiElement.accessible_label datumhue.debug.input datumhue.ui.focused UiElement:focus datumhue.time.after UiElement.bounds datumhue.debug.input.cursor datumhue.debug.input.click UiElement.focused UiElement:on_focus UiElement:on_blur datumhue.input.on_key datumhue.input.on_button datumhue.ui.focus_navigation