D-pad-only operation (no touchscreen required)¶
Vela is fully drivable with a 5-key D-pad (↑ ↓ ← → + OK) plus BACK. Touch is a bonus, not a requirement. This document is the authoritative record of the design, the findings that shaped it, every change made, and the rules for keeping the work easy to merge with upstream.
Why this is mostly free in Compose - and where it isn't¶
Finding 1: Compose gives D-pad focus traversal for free on anything clickable.
Every Modifier.clickable, Material button, chip, switch, checkbox, dialog button and
dropdown item is already a focus target: arrows move focus spatially, OK activates.
Roughly 90% of Vela's UI (search rows, place-sheet actions, settings, dialogs, route
picker, nav controls) needed no operability work - only focus visibility work.
Finding 2: five things were genuinely touch-only. These were the real gaps:
- The map itself - pan/zoom/tap/long-press are all gestures on the MapLibre view.
- The search overlay's focus semantics - two traps (below).
- Sheet handles - the place sheet's expand/collapse handle was a
detectTapGestures - drag detector (no focus target, no key activation). The results list and the
directions panel already had button/
clickablealternatives. - The nav maneuver banner - stepping through upcoming turns was swipe-only.
- The full-screen photo viewer - paging was swipe-only.
Finding 3: focus visibility is the other half. Material's default focus indication (a faint ripple state layer) is too subtle on Vela's fixed-gray sheets. Without an obvious ring you can't tell where you are, which makes the app technically operable but practically unusable.
Design¶
Core helpers - app/ui/DpadFocus.kt (new file)¶
rememberDpadFirstDevice()- the D-pad is a PRIMARY input, so default to a D-pad-first UI (affordances shown persistently, initial focus/engage placed for the user). Detection is deliberately conservative (rewritten 2026-07-08 after it broke touch phones):FEATURE_TOUCHSCREEN- genuinely touchless (Android TV / a real no-touch keypad) is the one unambiguous signal. Feature phones lie about it (the MTK panel reportstouchscreen=finger), so it only ever produces a true-negative, never a false-positive.- A PHYSICAL
InputDevicewithSOURCE_DPAD- a game controller, remote, or a keypad whose own hardware exposes the D-pad. - Do NOT count the framework's Virtual aggregate device (id −1). It reports
KEYBOARD | DPADon essentially EVERY Android phone (confirmed on a Pixel 9 viadumpsys input). An earlier version enumerated it (the note here used to insist on including virtual devices), which classified ordinary phones as keypad devices and broke the search bar - a plain tap stopped opening the field / raising the keyboard, and the+/−zoom buttons appeared on a touch phone (repo-owner + tester reports 2026-07-08). KeyCharacterMap.deviceHasKey(DPAD_CENTER)is dropped - true on a Pixel too (the virtual keymap carries the key) and already false on the MTK phone, so it only added false positives.- A fake-touchscreen keypad phone therefore isn't detected as D-pad-first; it still gets
full D-pad operation REACTIVELY the moment a key is pressed (via
rememberDpadMode), just without pre-placed focus on the very first frame. That is the price of never breaking touch, since no startup signal separates such a phone from a Pixel. rememberDpadMode()-dpadFirst || inputMode == Keyboard. True on a D-pad-first device, and on any other device the instant Compose sees a non-touch key event (affordances appear on the first key press, melt away on the next tap). This is what carries hybrid/keypad phones now thatdpadFirstno longer trusts the virtual device.Modifier.dpadHighlight(shape)- a 3 dp focus ring (fixed orange since the 2026-07-23 Settings redesign, see the last section; it was the teal primary before), drawn only while the element (or a descendant - Material buttons host their own focus node, andonFocusEvent.hasFocuscovers both) holds focus and the UI is key-driven (honorsdpadFirstdirectly, since a D-pad-first phone may still readinputMode == Touchuntil the first key event). Never appears under touch.rememberDpadAutoFocus(vararg keys)- D-pad-FIRST initial focus. Returns aFocusRequester; attach it to a screen's primary element viaModifier.focusRequester(...)and focus is placed there the moment the screen/overlay appears - so the user never has to press a key just to wake up focus (see "Initial focus" below). Retries 20 × 50 ms because a freshly-composed focus node usually isn't attached on frame 1 (the firstrequestFocus()throws and nothing ends up focused - the exact reason the map-target acquisition retries). Only requests on a D-pad-first device, so touch UX is byte-identical. Its siblingModifier.dpadAutoFocus()does the same as a Modifier but keeps re-requesting untilonFocusEventconfirms focus actually landed (not just "requestFocus didn't throw") - use it when the target may be off-screen; see Known limitations.-
Modifier.dpadFieldEscape()- makes a text field escapable by D-pad: UP/DOWN move focus to the previous/next form control instead of being swallowed by the field's own cursor handling. A single- or multi-lineTextField/BasicTextFieldotherwise eats the vertical arrows, trapping focus on the field so nothing below it is reachable (measured on-device: the Settings "Try the voice" / variant-jump / routing-filter / voice-search fields, and the reviews search field - DOWN sat blinking in the field, the controls under it never got focus). Fires viaonPreviewKeyEvent(root→leaf) so it wins before the field consumes the key, and swallows the matching key-up so the field never sees a half event. Returns false at a list edge where focus can't move, so the field still behaves normally there. Inert under touch (gated onrememberDpadMode). Apply to any text field sitting in a vertical list of focusable controls. (The search bar's field solves the same trap inline - see Trap C below - because it also needs the BACK/close handling in the same key handler.) -
UP at the top of a
TopAppBarscreen (Settings): the back button lives in theTopAppBar, a separate container from the scrollColumn, so Compose's directional UP can't bridge them and an UP from the top row clears focus (nothing focused, no way back via arrows). Settings tracks when its top row holds focus and routes that UP to the back button withrequestFocus-moveFocus(Up)can't be used here because it itself clears focus at the top edge before it returns.
The MapLibre MapView steals D-pad keys - the load-bearing fix¶
Finding 0 (the reason "nothing happened"): MapLibre's MapView calls requestFocus()
on itself and overrides onKeyDown to handle hardware keys (DPAD_CENTER = zoom in,
arrows = scroll). On a keypad phone it therefore grabs focus and swallows every D-pad key
before Compose focus ever sees it - arrows did nothing visible, OK zoomed the map, and no
Compose focus ring ever appeared (proven on-device: OK zoomed 2000→1000 mi). Fix
(VelaMapView.kt): make the MapView and its descendants non-focusable
(isFocusable = false, descendantFocusability = FOCUS_BLOCK_DESCENDANTS), unconditionally
and re-asserted in the AndroidView update block (MapLibre re-enables it on surface
recreation). Touch gestures don't need view focus, so nothing is lost; keys now flow to the
Compose focus system and MapDpadController drives the map instead.
The map - MapDpadController (new file) + a focusable center target¶
app/ui/map/MapDpadController.kt is the key→camera seam. VelaMapView wires it up in
getMapAsync alongside the touch listeners; MapScreen owns the key handling. The
controller deliberately reuses the exact same code paths as touch:
selectAtCenter()calls the same tap-resolution lambda the click listener uses (search-result pins → ambient POI dots → alternate-route lines → basemap POIs → unnamed-POI reverse geocode). The touch listener body was only named (handleTap)- not moved or reindented - so upstream edits to it merge cleanly.
longPressAtCenter()→ the sameonMapLongPresspath (drop pin / set a Choose-on-map point).panBy()sets the samegestureMoveflag a drag sets (so "Search this area" and camera-idle viewport updates fire) and detaches the nav follow-camera exactly like a finger pan.zoomBy()during nav adopts the manual nav-zoom override exactly like a pinch (navUserZoom); in browse it counts as a user gesture.
MapScreen's map target: a 140 dp focusable box at the screen center, with a two-stage model:
- Focused (pill: "OK: move the map"): a normal focus stop. Arrows keep traversing the chrome - search bar, chips, zoom buttons, FABs, sheets all stay reachable. OK engages map control.
- Engaged (crosshair + screen-edge ring):
| Key | Action |
|---|---|
| ↑ ↓ ← → | pan by 22% of the view (auto-repeats when held) |
| OK (short) | "tap" whatever is under the crosshair; in Choose-on-map mode, confirm the pick |
| OK (held ≥ 500 ms) | long-press at the crosshair (drop pin / set pick directly) |
| +/− or zoom keys | zoom (bonus for devices that have them) |
| BACK | disengage - focus stays on the target, arrows traverse the chrome again |
History (v1 trap, fixed same day): v1 had a single stage - the map consumed all four arrows whenever it merely held focus, and BACK cleared focus to nowhere, after which Compose's focus restoration put the next key press right back on the map. Result: "no way to grab the search bar or switch focus to anything but the map". The two-stage model is the fix: panning is an explicit OK-entered mode, and leaving it never clears focus. Don't regress this - a focusable that consumes arrows must always be a mode you enter, not a place focus merely lands.
Long-press detection uses the native key event's repeatCount/eventTime − downTime
(≥ 500 ms while held), so the pin drops while holding, like touch. On a D-pad-first
device the app starts focused + engaged, so the first key press already pans the
map; one BACK hands the arrows to the chrome.
Panel-aware (proven fix): the map target is shown/focusable ONLY when the map is the
primary surface. With a list/sheet/panel/search open (mapTargetHidden), it unmounts so
the panel owns focus - a center crosshair + focus stop floating over the results list stole
DOWN traversal into the rows (measured: DOWN from the results header jumped to the zoom +
button, never reaching a result). Returning to the bare map re-acquires + re-engages it via
LaunchedEffect(dpadFirst, mapTargetHidden) (retries because the focus node may not be
attached on the first frame). Nav keeps the map primary (the banner is an overlay), so you
can still pan to look around during nav.
Choose-on-map is the one exception (sweep fix, 2026-07-07). "Choose on map" (setting a
route origin/stop by placing a pin) opens with directionsOpen still true underneath, which
mapTargetHidden would normally treat as a panel and unmount the map target - but this mode
requires the map be pannable so the user can position the pin. Without the exception the
arrows only moved focus to the cancel ✕ and the pin couldn't be moved (measured). So
mapTargetHidden now has a leading state.pickOnMap == null && guard: while a pick is in
progress the map target stays mounted and auto-engaged, so arrows pan immediately and OK
confirms the pick (hold-OK sets it directly). Two matching cosmetic tweaks: the "OK: move the
map" focused pill is suppressed during a pick (mapFocused && state.pickOnMap == null) -
there the arrows already pan and OK confirms rather than enters map control, so that pill
would be a lie - and the crosshair/pin + "Move the map to set…" banner come from the existing
ChooseOnMapOverlay instead.
Zoom buttons: pinch has no 5-key equivalent, so a D-pad-mode +/− pair shows on the
map. Shown only while browsing the bare map (not during search / results / place sheet /
directions / nav) - in their first spot, mid-right, they sat in the vertical focus path of those
panels and intercepted DOWN into their rows (measured). Behind a panel the map is covered
anyway; zoom it via the engaged crosshair after closing the panel. Since 2026-09-14 (issue #393)
the pair is one pill in the bottom-right stack, above the parking button, and touch phones get
it too when Settings > Navigation > "Prefer buttons over swipes" is on
(dpadMode || PreferButtons.on in MapScreen).
Initial focus on every screen (D-pad-first, hard rule - sweep 2026-07-07)¶
Rule: no screen or view may open with nothing focused. On a D-pad-first device the app is driven entirely by keys, so a screen that appears un-focused wastes the user's first keypress just establishing focus instead of acting - that press should already be doing something. Every screen/overlay must land already focused on a sensible element.
Compose does not give this for free: it only auto-focuses in a few cases, and when a
focused element is removed (e.g. a results row → the place sheet), focus recovery is
nondeterministic - measured on-device it landed sometimes on a photo, sometimes on the
search bar behind the sheet, sometimes nowhere. The fix is rememberDpadAutoFocus() (Core
helpers) attached to each surface's primary element:
| Screen / overlay | Auto-focus target | Was broken? |
|---|---|---|
| Bare map | nothing on open - the map neither auto-focuses nor auto-engages; the user's first arrow lands on the search bar (the first focusable). See note below. | changed 2026-07-08 |
| Settings | back button (top of screen) | nothing focused - confirmed + fixed |
| Welcome | Get-started button | fixed |
| Place sheet | drag handle | focus leaked to the search bar behind it - fixed |
| Directions panel | first travel-mode tab (Drive) | fixed (classic panel; the Google-style chooser, the default since 2026-09-18, has no auto-focus target yet, see ROADMAP "D-pad hardware pass") |
| Route steps sheet | first step row (preview); the current step row while navigating | fixed |
| Reviews WebView (full-screen) | back arrow (until the WebView loads + grabs focus) | fixed |
| Photo gallery | (pre-existing galleryFocus) |
already OK |
| Search overlay | armed search field | already OK (arming focuses it) |
| Nav | map stays primary (the banner is an overlay) | already OK |
Proven on-device (focus dumped immediately on open, no keypress): Settings → back button, place sheet → handle, directions panel → Drive tab all land focused. The three share the one helper, so the sheet/reviews/steps that attach it identically follow. Results after a search keep focus on the search field (Google-style - you can refine or press DOWN into the list); that's "already focused", so it's left as-is.
Reproducible verification: ../dpad_test_suite/. The manual adb
focus-dump checks used throughout this doc are scripted there. Run all three after any change that
touches focus (audit_static.sh needs no device):
run_all.sh- per-surface assertions (bare map → search bar, Settings back button, Welcome/dialog auto-focus, place-sheet handle +VelaMenu, Choose-on-map engages, Directions pill reachable).audit_static.sh- EXHAUSTIVE source scan: everyclickable/toggleable/selectablehas adpadHighlightring, every gesture (detect*Gestures/draggable/…) has a key path, no bareDropdownMenu/AlertDialog, noisSystemInDarkTheme, plus triage notes for bare.focusable()/ raw windows / text fields. Fails the build on any real violation.audit_dynamic.sh- EXHAUSTIVE on-device tour: every surface opens focused, focus is never lost across a full DOWN-traversal (a null sample = a dead-end trap), and BACK exits.
Focus rings on the place sheet (2026-07-08). The place sheet's custom clickable content rows
(the Directions/Call/Street-View action pills, route-alternate rows, From/Add-stop/To editors,
phone/website rows, "also at this location", "people also search for", photo thumbnails, hours/
transit/popular-times expanders) originally had no dpadHighlight, so they were focus-reachable but
invisibly (Material's default state-layer is too faint on the fixed-gray sheet). They all carry a
ring now - audit_static.sh enforces it going forward. Same for the Settings voice-group headers.
Choose-on-map auto-engages (2026-07-08). The bare map opening un-focused (above) removed the
global auto-engage that Choose-on-map depended on, so pick mode opened dead (nothing focused, map
not engaged, crosshair/pill suppressed in pick mode). Restored scoped to pick mode: a
LaunchedEffect(pickingOnMap) in MapScreen requestFocuses + engages the center target the moment
state.pickOnMap goes non-null, so arrows pan immediately to place the pin and OK confirms. This
works (unlike the cold-open bare map) because pick mode is entered mid-session, so focus already
exists and requestFocus lands.
Settings horizontal-key focus trap (2026-07-08, found by audit_dynamic.sh's multi-axis walk).
Settings is a Column(verticalScroll). A LEFT/RIGHT press on a plain row (a SelectableRow, a
switch row - no horizontal neighbor) made Compose's focus search CLEAR focus outright, with no way
back via arrows (only BACK escaped). Root cause: moveFocus clears on a no-target directional move
in a scrolling column; focusGroup() doesn't help - only swallowing the key keeps focus. Fix: the
reusable Modifier.dpadSwallowHorizontal() (DpadFocus.kt) on the Settings root Column AND on
the top-bar back button (it lives outside the Column, so it needed it too - the auditor's 1/44
residual). It fires AFTER a focused child's own handler. The ONE horizontal row, the vibrate FilterChips,
drives its OWN LEFT/RIGHT via per-chip FocusRequesters (requestFocus never clears at the ends, and
consuming the key stops it reaching the root swallow), so it still walks Driving↔Walking↔Cycling↔
Transit. SelectableRow's RadioButton is also onClick = null (display-only) so the row is a single
focus stop. Any new vertical-list screen with a lone horizontal row wants the same shape.
Switch rows follow the same single-focus-stop pattern (fixed 2026-07-08, user report). The
Settings toggles used to be a bare Row + focusable Switch: traversal stopped on the Switch,
whose only focus indication is Material's faint state-layer halo, invisible against the dark
theme. From the Map section to the updater (nine sections, 15 toggles) a D-pad user lost the
cursor completely and the screen read as "not D-pad compatible". Fix: ToggleRow
(now in ui/settings/SettingsComponents.kt) mirrors SelectableRow - dpadHighlight ring + clickable on the ROW,
Switch(onCheckedChange = null) display-only. Bonus for touch: the whole row is now tappable,
like every platform settings app. Don't add a bare focusable Switch/Checkbox anywhere; wrap
it in a ringed row.
The bare map is the ONE intentional exception (2026-07-08). It used to auto-focus AND
auto-engage the center map target on open, so arrows immediately panned and you had to press
BACK before you could reach the search bar (user report). Now the map neither auto-focuses nor
auto-engages: nothing is focused on open, and the user's first arrow lands on the search bar
(Compose's real-first-key initial focus picks the first focusable, which is the search bar). Why
not just pre-focus the search bar? Compose won't let us on the opening screen - verified ~13
ways: requestFocus no-ops while nothing is focused yet; moveFocus only ever lands on the
center map target (even after removing its FocusRequester); moveFocus(Up)/Enter and
synthetic KeyEvents don't take. So "nothing focused, first key → search bar" is the closest
reachable behavior - and it doesn't violate the spirit of the always-focused rule (the map is
ambient; the first key isn't wasted, it goes straight to search). From the search bar, DOWN walks
to the category chips and the map target; OK on the map target engages it to pan.
The search overlay - the "can't get out of search" trap (MapScreen + SearchBar)¶
Trap A: focusing the field opened the overlay. searchOpen keys off field focus, so
merely walking focus across the search bar flipped the whole screen into the search page.
Fix (SearchBar.kt): in dpadMode the field is unfocusable (focusProperties { canFocus })
until armed - OK on the search text region arms + focuses the field. Un-focusing
disarms. Touch phones: dpadMode false ⇒ byte-identical.
The arm
clickablegoes on the text region, NOT the whole Card. A first version put it on the Card, which made the entire bar one focus stop and swallowed the Settings gear inside it - the gear became unreachable by D-pad (measured: RIGHT stayed on the bar, never reaching the gear). With it on the text region, the gear / clear / back IconButtons stay independently focusable (Settings proven reachable + navigable on-device).
Trap B: no way back out (the reported bug, root-caused on-device). Two compounding causes, both fixed:
- A derived focus-latch (
searchHold && searchTreeFocus) keptsearchOpentrue afterclearFocus()because focus never fully left the overlay tree - so BACK could never close it. Replaced with an explicitsearchExpandedboolean: opened on field focus, closed on touch-blur / BACK / once a search runs or a place is picked (LaunchedEffect(results, selected, picking…)). Deterministic; no latch to get stuck. - A shown soft IME holds an active InputConnection that swallows the BACK key, and
BasicTextField's built-in "BACK clears focus" ate theKeyDownbefore aKeyUphandler could run - so it took THREE presses to escape (measured: IME-hide, blur, close). Fix: indpadModedon't raise the IME (the keypad types on hardware keys straight into the focused field), and catch BACK on the field viaonPreviewKeyEvent+KeyDown(fires before the field's own handling). Now it's the platform-standard two presses (IME window eats the first to hide itself, the next closes) - deterministic, proven returning to the map.
Trap C: DOWN couldn't leave the field (sweep fix, 2026-07-07). With the field armed and
focused, the entry rows below it (Home / Work / saved places / recent searches / live
suggestions) were unreachable - a single-line BasicTextField swallows DOWN as a
cursor move, so focus never left the field. Fix: the field's existing onPreviewKeyEvent
handler gained a DirectionDown case that calls focusManager.moveFocus(Down) and consumes
the key, handing focus into the rows. (It lives inline in SearchBar rather than reusing the
generic dpadFieldEscape because it shares the handler with the BACK/close logic above; the
effect is identical.) Now: OK arms → type → DOWN walks into the suggestions → OK opens one.
Touch phones are unaffected: all of the above is gated on dpadMode.
Gesture alternatives¶
- Nav maneuver banner (
NavOverlays.kt): the banner is focusable; ←/→ walk the upcoming steps (the key mirror of the swipe), OK resumes live guidance while previewing (via the preview-modeclickable, which OK activates). The ←/→ key handler sits before that clickable in the modifier chain (key events bubble leaf→root, i.e. from the focused node up to its ancestors); a standalonefocusable()exists only when the clickable doesn't, so the banner is always exactly one focus stop. Proven on-device: RIGHT → next step, RIGHT → further, LEFT → back, OK → resume. - Place sheet handle (
PlaceSheet.kt): the tap-onlydetectTapGesturesbecame a realclickable(focusable, OK toggles peek/expanded; identical under touch). The drag detector is untouched. Collapse-to-dismiss stays on BACK (already handled by MapScreen's peelBackHandler). - Directions panel (
PlaceSheet.kt): the handle isclickableand gained the focus ring. Sweep fix (2026-07-07): with up to 4 route alternates the mode tabs + route list + depart options pushed the Start button off the bottom of the screen with no way to reach it - a real layout bug that affects touch too, not just D-pad. TheAnimatedVisibilitybody is now averticalScrollColumn capped at ~58% of screen height (heightIn(max = screenHeightDp * 0.58)), so focusing (or tapping) Start scrolls it into view. (Since 2026-07-13 the From/To rows live inRouteTopCardat the top of the screen, and since 2026-09-13 Start is a footer under the route list that stays put while the list scrolls.) - Photo viewer (
PlaceSheet.ktPhotoGallery): grabs focus on open (it's aDialog, its own focus scope); ←/→ page through photos; BACK dismisses (Dialog default). Pinch-zoom has no key equivalent (accepted - see limitations). - Text fields (Settings + reviews search):
Modifier.dpadFieldEscape()so UP/DOWN leave the field instead of being trapped in it (see Core helpers, and Trap C for the search bar's inline equivalent). - Results list / sheets: expand/collapse already had focusable buttons; they gained
rings. Scroll happens implicitly as focus walks the rows (
LazyColumn/verticalScrollbring-into-view). The results list became a BOTTOM sheet on 2026-07-08: OK on its handle opens it from the minimized bar and toggles peek and expanded, and BACK steps it down (expanded, peek, the minimized bar, then cleared), so no drag is ever required. There is no "Hide results" button any more. - Nav bottom bar (
NavOverlays.kt, 2026-09-04): dragging the bar up opens the step list, and that gesture is touch-only on purpose. The key path is the chevron handle above the figures, a focusableclickablewith a ring that opens the same sheet, plus a list button in the bar whenever "Prefer buttons over swipes" is on or the device is D-pad-first. - Pause and mute (
NavHoldControls, used when Settings > Navigation "Pause button on the navigation bar" is off): a long press mutes, which a D-pad cannot do, so the first OK on the button slides mute out beside it for a few seconds as its own target, and a second OK on the same button pauses.
Focus visibility pass¶
dpadHighlight applied to: search bar card, suggestion/shortcut/saved rows, search
result rows, the results sheet's handle, category chips, both re-center FABs, the
zoom buttons, steps-sheet rows, both sheet handles, the maneuver banner. Since then
dpad_test_suite/audit_static.sh fails on any clickable/toggleable/selectable
without a ring, so the list is complete by construction rather than by this paragraph. Material
buttons/switches/dialogs keep their built-in focus indication (adequate on those
components; extend the pass if a spot proves hard to see).
The search bar's text region insets its content 10 dp inside the ring (2026-07-09): the ring is drawn on the Box whose edge is exactly where the first typed letter starts, so with no inset the 2 dp stroke cut straight through that letter on every keypad-phone search.
Everything checked, file by file¶
| Surface | Verdict |
|---|---|
WelcomeScreen |
made scrollable - its fixed weight(1f)-spacer layout pushed the Get-started button off the bottom of a small (480×640) screen with no way to scroll to it, so a D-pad user couldn't SEE it (focusable-when-clipped, but invisible). Now verticalScroll + heightIn(min = screen); button reveals + activates on-device. |
Onboarding prompts (VelaRoot) |
dialogs + buttons - natively focusable (proven: "Offline maps" dismissed via D-pad; that prompt was cut from onboarding on 2026-07-10, and every dialog is a VelaDialog now) |
SearchBar |
armed-field design + BACK-out + DOWN-escape into the entry rows (Traps A/B/C above) |
| Search entry page (shortcut/saved/recent rows, menus) | clickable rows + DropdownMenus - operable natively; rings added |
| Search results list | rows/chips operable; the sheet's drag has key equivalents (handle tap, BACK); rings added. It was a top sheet when this pass ran and is a bottom sheet since 2026-07-08 |
| Map | MapDpadController + center target (above) |
| Place sheet | handle fixed; action buttons/tabs/rows are Material or clickable - operable; Reviews tab search field got dpadFieldEscape (proven UP-escapes + dismisses the IME) |
Live reviews WebView (ReviewsPanel, "Read all reviews") |
reachable (OK on the button) + exitable (BACK) proven; ↑/↓ now page-scroll the WebView (sweep fix); visual scroll not confirmable on the test network - see limitations |
| Directions panel | handle ring; rows/chips/buttons (incl. stop reorder) are buttons - operable; body scroll-capped so Start is reachable with 4 alternates (sweep fix, helps touch too) |
| Route steps sheet | rows clickable; rings added |
| Nav (banner, controls, faster-route card, arrival card) | banner keys added; the rest are Material buttons |
| Choose-on-map | map stays pannable to position the pin (the pickOnMap exception, sweep fix); OK confirms; hold-OK sets directly; Set/Cancel buttons focusable |
| Settings | rows/switches/±steppers focusable; text fields got dpadFieldEscape so UP/DOWN escape them (sweep fix); no gesture-only widgets (no sliders) |
| Trip replay pill, notices, info cards | buttons - operable |
Proven on-device (MTK "M5" keypad phone, Android 13, no touch used)¶
Every flow below was driven with adb input keyevent D-pad keys only and verified by
screenshot. Device facts that shaped the design were captured by on-device logging, not
assumed: touchscreen=finger (lies), SOURCE_DPAD only on the Virtual device,
deviceHasKey=false, keypad center = KEYCODE_DPAD_CENTER (scancode 232), BACK = 158.
- Launch: crosshair + zoom buttons + engaged edge-ring appear immediately (dpad-first). Superseded on 2026-07-08: the bare map now opens with nothing focused and the first arrow lands on the search bar (see "The bare map is the ONE intentional exception").
- Map: ← ↑ → ↓ pan (crosshair fixed, map slides under it); BACK disengages to the "OK: move the map" pill; arrows then traverse the chrome.
- Traversal + rings: map → category chip → search bar → results rows → place-sheet rows, every stop showing a visible focus ring.
- Search: OK arms the field, hardware typing enters text, live suggestions load, and two BACK presses return to the map (the reported trap - fixed).
- Category search → results → place: OK on a chip returned 20 live results; OK on a row opened the place sheet (photos, actions, hours all focus-reachable).
- Directions → nav: OK on Directions computed a live route; OK on Start began turn-by-turn.
- Nav banner: RIGHT/RIGHT walked steps ("Turn left onto Avenue K" → "…Nostrand Ave" → "Arrive"), LEFT went back, OK resumed live guidance.
- End: focus reached the End button; OK returned to the route preview.
Full-function sweep (2026-07-07). Beyond the flows above, every surface of the app was
driven end-to-end by D-pad - map browse, search entry, results filters, the whole place
sheet, the directions panel, navigation controls, and every Settings section - specifically
to find anything still touch-only. That exhaustive pass surfaced the five refinements marked
"sweep fix" here: (1) the search field's DOWN-escape (Trap C), (2) dpadFieldEscape on the
Settings + reviews text fields, (3) Choose-on-map staying pannable to place the pin, (4) its
cosmetic pill suppression, and (5) the directions-panel scroll cap so Start is reachable
with 4 alternates. Nav was re-verified through Start → banner ←/→ step preview → voice-mute
toggle → End. The one remaining touch-only surface - the full-screen "Read all reviews"
WebView - was also swept: it's now D-pad-scrollable (↑/↓ → pageUp/pageDown), and its reach
(OK opens) + exit (BACK closes) were proven on-device; only its loaded-page visual scroll is
unconfirmable on the test network (see limitations). Nothing gesture-only remains outside the
documented limitations below.
(GPS was mocked via a gps test provider to give routing a valid origin; the network's
content filter otherwise leaves routing without a usable fix on this device.)
Known limitations / follow-ups (also on the ROADMAP)¶
- Menus & dialogs - SOLVED (
VelaMenu/VelaDialog), was the last framework wall. A Compose MaterialDropdownMenu(Popup) andAlertDialog(Dialog) open with their window focused but no content Compose-focused - Compose sets that focus only on the first key event, so they'd open un-highlighted. Nothing in-app pre-places focus onto their content: ~10 approaches verified failing on-device (2026-07-07) -requestFocus()on the item / a custom focusable Row / aTextButton/ a directly-.clickableText, a retry-until-onFocusEvent-confirms loop, an outer-scope delayed request,FocusManager.moveFocus(Down), and a syntheticDPAD_DOWNKeyEventdispatched to the popup's ComposeView, then itsrootView, then again with a real DPAD input source. The one seam that works is a hand-built rawDialogwith an explicit.focusable()element (Vela's photo gallery proves it), so both were rebuilt on it: VelaDialog(ui/VelaDialog.kt) - drop-in two-buttonAlertDialogreplacement (rawDialog+ Material-matched Surface) that auto-focuses the dismiss/safe button on open; buttons are a directly-.focusable()Text (the only noderequestFocuslands on in a Dialog) with OK via.onKeyEventand touch viapointerInput(not.clickable, which adds a 2nd focus target). Every dialog in the app uses it (there is no bareAlertDialogleft, andaudit_static.shfails on a new one); looks identical under touch.-
VelaMenu(ui/VelaMenu.kt) - drop-inDropdownMenureplacement. Under touch it renders the ordinary anchoredDropdownMenubyte-identical; under D-pad a raw-Dialogchooser whose first item is focused on open.VelaMenu(expanded, onDismissRequest){ item("A"){…}; item("B"){…} }. Every menu in the app uses it; a bareDropdownMenufailsaudit_static.sh.Proven on-device: onboarding dialogs auto-focus "Not now"; the place-sheet ⋮ auto-focuses "Set as Home"; the share menu auto-focuses "Google Maps link"; OK selects, DOWN/UP walk, arrows move between dialog buttons, BACK dismisses. No secondary window opens un-focused any more.
-
Off-screen initial-focus targets (small screens). Compose won't move focus to an element it can't bring into view, so a primary control that starts below the fold can't be auto-focused on open (measured: the Welcome screen's Get-started button on a 480×640 keypad screen).
Modifier.dpadAutoFocus()(the retry-until-onFocusEvent-confirms variant) lands it when it's on-screen (normal phones); when it's off-screen the D-pad user presses DOWN to reveal + focus it. Force-scrolling it into view on open was tried and reverted - it hides the welcome intro on a once-seen screen, a worse tradeoff.rememberDpadAutoFocus()(the simpler requester) is fine for on-screen targets; usedpadAutoFocus()when a target may be off-screen. - Time and date pickers (fixed 2026-07-11). "Depart at / Arrive by" used to open the
platform
android.app.TimePickerDialog, outside Vela's Compose focus system. It is now the Material 3 time and date picker insidePickerDialog(PlaceSheet.kt), a rawDialogwith theVelaDialogbutton grammar whose confirm pill auto-focuses on open. Confirmations were alreadyVelaDialogs (above). - Text entry relies on hardware keys. In
dpadModeVela focuses the field but does NOT raise the soft IME - the keypad's physical keys type straight into the focused field (verified). A device with neither a hardware keyboard nor a D-pad-navigable IME would have no way to type; that's out of scope (such a device isn't "D-pad operable" to begin with). - Live reviews panel (
ReviewsPanel, the full-screen "Read all reviews" WebView) - now D-pad-scrollable (sweep fix, 2026-07-07). A raw WebView's default D-pad handling hops focus between the page's links instead of scrolling, which is useless for reading. InfullScreenmode the WebView now maps ↑/↓ topageUp/pageDown(stable WebView APIs) via anOnKeyListener, andrequestFocus()es on page-finish so it receives the keys - so it scrolls deterministically regardless of the page's focusables. The handler only fires on hardware D-pad keys, so it's completely inert under touch, and only infullScreen(the inline scroll-sync path is untouched). Proven on-device: the panel is REACHABLE (OK on the "All N reviews" button opens it) and EXITABLE (hardware BACK closes it back to the sheet, via theDialog'sBackHandler, which fires even while the WebView holds focus - so it can never trap you). Not visually confirmable on the test device: its network content filter throttles the reviews carve, and the panel keeps the WebView atalpha=0until the carve is "ready", so the page never becomes visible here - it always falls back to the fully D-pad-operable native reviews list. The page-scroll wiring is therefore verified-by- construction (documented APIs, focus requested, inert-safe) rather than pixel-verified here. - Photo pinch-zoom has no key equivalent (view-only; ←/→ paging works).
- Map tilt (two-finger drag) has no key equivalent; browse/nav don't need it.
- Tuning still worth a pass: pan step (0.22 of view), OK-hold threshold (500 ms), focus-ring visibility in both themes. Values felt right on the test device.
- On a touch phone in
dpadMode(external keyboard), the first tap on the search field can be eaten while the input mode flips back to Touch (tap again). Cosmetic; D-pad-first devices unaffected.
Merge-with-upstream policy (how this stays rebasable)¶
The whole feature follows these rules - keep following them when extending it:
- New behavior lives in new files:
DpadFocus.kt,MapDpadController.kt,docs/dpad.md. Upstream can't conflict with files it doesn't have. - Edits to shared files are additive and anchored, never restructuring: one
contiguous, commented import block per file ("docs/dpad.md" marker); new modifiers
inserted into existing chains; new state vars added next to related ones. The one
near-refactor (naming the map click listener
handleTap) changed only the lambda header and threereturn@labels - the 50-line body is untouched, so upstream diffs to it apply cleanly. - Touch paths are never forked - D-pad code calls the same lambdas/flags/state the gesture handlers use. Upstream fixes to tap resolution, reroute-on-pan, nav-zoom overrides etc. automatically apply to D-pad input.
- Everything is gated on
dpadMode/noTouchwhere it could change touch behavior; with a touchscreen and no key input, the UI is byte-identical.
If a pull does conflict, the conflicts will be in the small anchored insertions - re-apply them around the upstream change and re-read this file's design section to confirm the invariants still hold.
Settings is hub-and-spoke (2026-07-23 redesign, ported from the vela-dpad fork)¶
Settings was a single extremely long page; it is now a short hub of category rows
(ui/settings/SettingsHub.kt), each opening its own small sub-screen
(ui/settings/sections/*.kt). Every page - the hub and each spoke - renders through
SettingsScaffold (ui/settings/SettingsScaffold.kt), which carries, once, all of the
device-found Settings focus plumbing: Back auto-focus, the DOWN-from-Back and UP-from-top-row
bridges, and the horizontal swallow. Its content lambda hands the page a topRow modifier -
attach it to the page's FIRST focusable control or the Back bridges dead-end. BACK peels
spoke -> hub -> map; the hub explicitly re-focuses the row a spoke returned from (Compose's
recovery is nondeterministic when the focused tree unmounts). When you add a Settings page,
build it on SettingsScaffold - do not re-inline any of this. The shared row vocabulary is
SettingsComponents.kt: SettingsGroup, ToggleRow, SelectableRow, GroupDivider,
PageIntro, Hint, CollapsibleSectionTitle.
Two focus findings from the fork's verification (both live in SettingsScaffold):
- The Back auto-focus needs a SETTLE WINDOW, not a land-once retry. Window-focus churn on
open can steal freshly-landed focus off Back;
dpadAutoFocusstops at its first landing, so the steal left the page unfocused. The scaffold re-requests while focus is neither on Back nor anywhere in the content; any key press ends the window at once, so it can never fight the user. - A second instance of the cold-open focus WALL. When Settings is the session's first
focus-touching surface,
requestFocusandmoveFocusboth no-op (same wall as the cold-open bare map). Accepted: the first key press establishes focus on Back, costing exactly one press.
Settings search on the hub is label -> OPEN-THE-SPOKE (SEARCH_INDEX in SettingsHub.kt):
the old page scrolled to the matched row's measured Y, which died with the long page. When a
section gains a row, add its label to the index.
The redesign's focus-ring color is the fork's fixed orange (0xFFFF6D00, DpadFocus.kt) - the
teal primary ring read as "green on green" against the teal ON-switch. Two new reusable
helpers came with it: Modifier.dpadContainVertical() (keeps a no-target UP/DOWN at a scroll
container's edge from clearing focus irrecoverably - the vertical twin of
dpadSwallowHorizontal; the scaffold applies it) and Modifier.dpadClickable() (a clickable
that drops Material's gray focus layer while input is key-driven, so the ring is the one focus
signal; pair it with dpadHighlight on the same row - dpad_test_suite/audit_static.sh
exempts only DpadFocus.kt itself from the ring rule).