Skip to content

Button-only boards: focus & navigation

On a board with a touchscreen, navigation just works: tap a button, tap a list row, the framework dispatches the click. On a board with only hardware buttons and no touch — like the Pico Enviro Mon — there is no pointer at all. The entire touch HAL is compiled out, so apps receive no pointer events. Navigation is keypad-only: the user moves a focus highlight between widgets with the physical buttons.

That changes two things for you as a Java developer:

  • Widgets only receive key events and focus if you make them focusable (View.setFocusable(true)).
  • The board maintains a focus group — an ordered ring of focusable widgets that the up/down buttons traverse. Each Activity gets its own group.

Everything else is the same Android API you already know. List rows are focusable for free, and OnItemClickListener fires identically whether the row was activated by a tap or by a button press.

The button map

The Pico Enviro Mon wires the four Pimoroni Enviro+ buttons (A/B/X/Y on GP12–GP15) in its board.toml:

# Pimoroni Enviro+ Pack hardware buttons (A/B/X/Y → GP12-15).
# Semantic mapping: PREV→UP, NEXT→DOWN, ENTER→CENTER, ESC→BACK.
[[button]]
pin = 12
lv_key = "PREV"
keycode = 19
[[button]]
pin = 13
lv_key = "NEXT"
keycode = 20
[[button]]
pin = 14
lv_key = "ENTER"
keycode = 23
[[button]]
pin = 15
lv_key = "ESC"
keycode = 4

Each entry binds a GPIO pin to an LVGL keypad key (lv_key) and an Android KeyEvent keycode. That produces the standard 4-button convention:

ButtonPinlv_keykeycodeKeyEventRole
A12PREV19KEYCODE_DPAD_UPup / previous focusable
B13NEXT20KEYCODE_DPAD_DOWNdown / next focusable
X14ENTER23KEYCODE_DPAD_CENTERopen / activate focused
Y15ESC4KEYCODE_BACKback

So A = up, B = down, X = open (ENTER), Y = back (ESC). A single [[button]] entry is enough for the board to count as button-capable; the keypad focus-group machinery turns on automatically.

A button can also be a system key that stays out of the focus ring. lv_key = "NONE" queues the keycode for Java and injects nothing into the LVGL keypad, which is what pico_touch_kit — a board with a touchscreen and two buttons — uses for HOME:

[[button]]
pin = 14
lv_key = "NONE"
keycode = 3 # KEYCODE_HOME

HOME goes straight to the launcher on a multi-app board, from any app at any depth, and never reaches an app: not a focused view’s OnKeyListener, not a showing dialog, not onBackPressed. On a board with no launcher it is swallowed. A board with no HOME key gets it by holding BACK for a second (home_hold_ms, default 1000): the app sees its ordinary long-press at 400 ms, then a release flagged FLAG_CANCELED (so onBackPressed does not run), and the launcher comes up. An app cannot keep the user by swallowing BACK.

A touchscreen board with no button at all (soft_nav = true; the testbenches) gets the same two gestures from a small round control the framework draws in the bottom-left corner of the window: a tap is BACK, a hold is HOME. It hides while the system keyboard is up and rides above a dialog’s scrim. See Apps on every board for the input profiles every board meets.

An app that wants to show a hint such as “A: up” only where there is an A asks KeyCharacterMap.deviceHasKey(KeyEvent.KEYCODE_DPAD_UP); BACK and HOME answer true on every board, the control and the hold included. getResources().getConfiguration() says the same thing at the profile level — navigation is NAVIGATION_DPAD where the four keys exist, touchscreen is TOUCHSCREEN_FINGER where a panel does — and gives the window’s size in dp, which is what to branch on for a two-column layout. Handle the keys regardless: an app that handles KEYCODE_DPAD_UP works wherever the key exists, and the hint is the only thing that should depend on the answer.

For the full board.toml schema — required keys, valid lv_key values, how [[button]] differs from [touch] — see the porting guide. The board files live in platforms/rp/boards.

Making widgets reachable

A plain View is not focusable (the Android default). On a button board that means it never receives key events and the up/down buttons skip over it. A view with a click listener is focusable on a board whose keys move the focus — setOnClickListener makes it so, as Android’s focusable="auto" does — so a screen written with click listeners alone is reachable by the four keys. For anything else, make it focusable and, if it should start selected, request focus:

import picodroid.widget.Button;
Button save = new Button(this);
save.setText("Save");
save.setFocusable(true); // joins this Activity's keypad focus group
save.requestFocus(); // make it the initially-focused widget

The signatures and contract come straight from Android (sdk/java/picodroid/view/View.java):

  • public void setFocusable(boolean focusable) — “Set whether this view can take input focus. On a hardware-button device, only a focusable view receives key events — call this (and requestFocus()) on the view that owns the OnKeyListener. Focusability is independent of setOnKeyListener, exactly as in Android.” A focusable view also scrolls into view when it takes focus, so a column of focusable rows taller than the screen (the launcher’s app list) can be walked with A and B.
  • public boolean requestFocus() — returns false (without effect) if the view is not focusable, otherwise true if it became the focused view.
  • public boolean isFocusable() / public boolean isFocused() — isFocused() is true iff this view is the active group’s focused widget.
  • public boolean hasFocus() — Picodroid focuses leaf widgets directly, so this is equivalent to isFocused().

You can also observe focus changes, which is how you’d react to the highlight moving:

edit.setOnFocusChangeListener((v, hasFocus) -> {
if (hasFocus) showHint();
});

The OnFocusChangeListener “fires when this view gains or loses input focus (on a hardware-button device, as PREV/NEXT move the keypad focus highlight between focusable views).” The view must be focusable (or an adapter row) to ever fire it.

Focus groups are per-Activity

The board keeps one focus group per Activity, mirroring Android’s per-Window focus scope:

  • Focusable widgets join the group in the order they’re added. PREV (A) and NEXT (B) traverse them in that order, wrapping around at the ends.
  • A backgrounded Activity’s focus is retained untouched and restored when it returns to the top — you don’t manage this.
  • One Activity can never traverse into another’s focus.

The practical takeaway: add your focusable widgets to the layout in the order you want A/B to walk them, and call requestFocus() on the one that should be selected first.

Handling keys

Most apps never write an OnKeyListener — the focus group handles A/B navigation and X activation for you, and Y runs the back chain. Reach for setOnKeyListener only when a focused widget needs custom key behavior:

import picodroid.view.KeyEvent;
view.setOnKeyListener((v, event) -> {
if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_CENTER
&& event.getAction() == KeyEvent.ACTION_UP) {
activate();
return true; // consume — don't fall through to onBackPressed
}
return false;
});

OnKeyListener is the Android single-method interface boolean onKey(View v, KeyEvent event); returning true consumes the event. Remember: only a focusable view receives key events, and focusability is independent of whether you’ve set a key listener. A screen with nothing to focus overrides the Activity’s onKeyDown / onKeyLongPress / onKeyUp instead, and a held key auto-repeats (getRepeatCount()) after 400 ms — so four buttons can carry eight actions, a press and a hold each. See Key events.

The KeyEvent constants match Android exactly (sdk/java/picodroid/view/KeyEvent.java):

ACTION_DOWN = 0;
ACTION_UP = 1;
KEYCODE_HOME = 3; // handled by the framework, never delivered
KEYCODE_BACK = 4;
KEYCODE_DPAD_UP = 19;
KEYCODE_DPAD_DOWN = 20;
KEYCODE_DPAD_LEFT = 21;
KEYCODE_DPAD_RIGHT = 22;
KEYCODE_DPAD_CENTER = 23;

BACK (Y) routing order

When Y is released, the framework tries each of these in order and stops at the first that consumes it:

  1. Dismiss the soft keyboard if it’s visible (the only way to close it without touch).
  2. Dismiss a showing AlertDialog — the only way to dismiss a dialog on a keypad-only board.
  3. The focused view’s OnKeyListener (if it returns true).
  4. The top Activity’s onKeyDown / onKeyUp. The defaults carry Android’s BACK contract: onKeyDown tracks the press, and onKeyUp runs onBackPressed() for a release that was tracked and not cancelled by a long-press.
  5. Activity.onBackPressed(), whose default pops the Fragment back stack if there is one to pop and otherwise calls finish().

Override onBackPressed() without calling super to intercept Back (a confirm dialog, for example), or consume KEYCODE_BACK in onKeyDown without calling super; on the root Activity leave both alone so Y returns to the launcher (see the hub pattern below).

While an AlertDialog shows it holds the keypad: A and B cycle the dialog’s own buttons and cannot walk onto the rows behind it.

Value widgets by key

Every stock widget works with the four keys (app portability K7). The ones that hold a value are edited: with the widget focused, X (SELECT) enters edit mode — the widget gets LVGL’s edited outline — A/B change the value instead of moving the focus, and X commits or Y (BACK) leaves, neither reaching the app’s onBackPressed. What A/B do is the widget’s own:

WidgetSELECT, then A / BLeave with
NumberPicker+1 / −1 (OnValueChangeListener)X or Y
SeekBar+1 / −1 (onProgressChanged)X or Y
TimePicker (each roller)previous / next value (onTimeChanged)X or Y
DatePickerthe day before / after; X selects it (onDateChanged)Y
SpinnerX opens the list, A/B move the highlight, X picks (onItemSelected), Y closes—
Switch, CheckBox, RadioButton, ToggleButton, Button, a clickable rowX activates, no edit mode—
EditTextX opens the system keyboard (below)Y
AlertDialog with itemsthe list has the focus: A/B move between rows, X picks, Y dismissesY

A screen with nothing focusable — an About page, a ScrollView of plain text — still answers A/B: they page its first ScrollView, as Android’s arrowScroll scrolls when no view takes the arrow. ViewPager2 has no key paging of its own; on a key board call setCurrentItem from onKeyDown, as the claudeusage chrome does.

Lists and menus

ListView rows are focusable automatically — you don’t call setFocusable on them. A/B move the row highlight, X (ENTER) activates the highlighted row. The activation path is unified: a row reached by ENTER on a button board and a row tapped in the touch sim both fire the same onItemClick.

ListView menu = new ListView();
menu.setSize(224, 188);
menu.setAdapter(new ArrayAdapter<String>(LABELS)); // LABELS = {"Live", "History", "Settings"}
menu.setOnItemClickListener(
(parent, view, position, id) -> startActivity(new Intent(DESTINATIONS[position])));
root.addView(menu);

This is the Pico Enviro Mon HomeActivity hub pattern: a selectable menu of destinations. A/B move the highlight (with wrap-around — the focus group cycles, so down past the last row returns to the first), X opens the highlighted screen. Two details worth copying:

  • Nothing to hold. The list is a local: setContentView makes it the Activity’s content view, which keeps it — and its item-click listener — alive for as long as the Activity lives, as on Android.
  • Let Back exit from the root hub. The home screen does not override onBackPressed(): Y runs the inherited finish() on the last Activity, which ends the app and returns to the launcher — the Android home-screen behaviour, and on a button board the only way back to the launcher. The hub’s legend says so (A:Up B:Down X:Open Y:Exit). Override onBackPressed() only on a screen that must confirm before leaving, and call finish() yourself from the confirmation.

For a worked multi-screen example using this hub + startActivity pattern, see the multi-screen app tutorial.

The options menu

The write-once action surface. Declare the Activity’s actions once and the framework finds a way to open them on every board — a MENU key where a board has one, holding SELECT on a four-key board (when the focused view has no OnLongClickListener of its own; a tap stays its click), and a round menu control the framework draws bottom-right on a touch board while the resumed Activity has a menu. The menu shows as a list dialog, walked with UP/DOWN and picked with SELECT or a tap, and the pick reaches onOptionsItemSelected:

@Override
public boolean onCreateOptionsMenu(Menu menu) {
menu.add(Menu.NONE, ID_REFRESH, Menu.NONE, "Refresh");
menu.add(Menu.NONE, ID_UNITS, Menu.NONE, "Toggle units");
MenuItem about = menu.add(Menu.NONE, ID_ABOUT, Menu.NONE, "About");
about.setOnMenuItemClickListener(item -> { showAbout(); return true; }); // took the pick
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case ID_REFRESH: refresh(); return true;
case ID_UNITS: toggleUnits(); return true;
default: return super.onOptionsItemSelected(item);
}
}

onCreateOptionsMenu runs once after the first onResume; invalidateOptionsMenu() runs it again, onPrepareOptionsMenu runs before each show (return false to keep the menu closed), and openOptionsMenu() / closeOptionsMenu() do what they say. An empty menu is offered nowhere: no control is drawn and a held SELECT does nothing. onOptionsMenuClosed follows a pick, closeOptionsMenu or a BACK that dismissed the list. A key you map by hand in onKeyDown still works — the menu is for the actions that would otherwise need a button the board does not have. examples/menudemo drives all three openers from the simulator.

Focus styling

The keypad sets two state bits on the focused widget, and you must style both:

  • LV_STATE_FOCUSED — focused (set for both pointer and keypad focus).
  • LV_STATE_FOCUS_KEY — additionally set when focus arrives via a keypad rather than a pointer.

ListView rows handle this for you: the framework fills the focused row with the accent color for both states, so the highlight is unmistakable on dark backgrounds. Every other view made focusable gets a 2 px light border while it has the focus, in both states and drawn inside its bounds — the theme’s own focus outline is drawn outside the object and is clipped away for a full-width row. But if you theme focus yourself on a custom widget and cover only LV_STATE_FOCUSED, keypad focus will show nothing useful — the default theme repaints the keypad-focused widget blue after the first move (it renders your color on first paint, then the theme’s blue once LV_STATE_FOCUS_KEY kicks in). Whenever you override the focus highlight, cover the *_FOCUS_KEY state too. (This was the QA #7 lesson: a row styled only LV_STATE_FOCUSED showed teal on first render and blue after.)

There is no dedicated Java “focus highlight color” setter; list rows get the framework default automatically, and View.setBackground(Drawable) dispatches virtually for custom cases. See theming for palette fields.

A practical tip on a touchless device: tell users what each button does. The Pico Enviro Mon shows an always-visible ButtonHintBar legend on every screen, e.g. "A:Up B:Down X:Info Y:Back" on the History screen and "A:Up B:Down X:Open Y:Exit" on the home hub. The legend bar is 224 px wide — keep hint strings short enough to fit 224 px or they clip (the QA #6 lesson).

Keyboard on button boards

EditText is single-line on these boards, and that’s load-bearing. On a keypad board the X (ENTER) press that opens the soft keyboard would otherwise also insert a newline — the cursor jumps to an empty second line and the field looks cleared (its text becomes e.g. "30\n"). The framework sets the textarea one-line so ENTER no longer inserts and the value stays put (QA #5).

The real input API is the Android one:

import picodroid.text.InputType;
EditText field = new EditText(this); // focusable by default, as on Android
field.setInputType(InputType.TYPE_CLASS_NUMBER); // shows the digit keypad

Fields flagged TYPE_CLASS_NUMBER get the numeric keypad layout; everything else gets the default text layout, and a password variation masks what is typed. With the field focused, X (ENTER) opens the keyboard and the keyboard takes the keypad focus: A and B walk its keys (hold to repeat), X types the highlighted key, and its OK key fires the field’s OnEditorActionListener. Dismiss the soft keyboard with Y/BACK — on a keypad-only board it’s the only way to close it (the keyboard is consumed first in the BACK routing order above, so the user stays on the same screen). See UI components for the full EditText / keyboard surface.

See also