Files
Joe MattielloandClaude Opus 5.5 b0dc50f871 feat(app): controller navigation of the desktop library, with per-family and custom button profiles (#1021) (#1030)
* docs(app): controller navigation of the desktop library, design (#1021)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(app): controller navigation of the desktop library, plan (#1021)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): NavInput per-pad sources, supplied intents and long-press Accept (#1021)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): controller-family menu-button profiles with a per-model custom layer (#1021)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): a menu-button family change clears the model's overrides; ASCII minus label (#1021)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): DesktopNav focus model for the desktop library (#1021)

Pure focus over the library's regions (Favorites strip, Recents strip, grid,
or the States list), reusing tv::LinearFocus/GridFocus/FocusMemory/jumpInitial.
Maps pad intents to Activate/OpenMenu/ToggleFavorite/TabPrev/TabNext/
FocusSearch/Back commands, follows the focused item by key when lists change,
remembers focus per tab and region, and raises a one-shot scroll flag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): TV mode reads each pad's menu-button profile (#1021)

The shell asks the host what a pad's button or trigger means (the pad's controller-family profile plus the model's custom
layer) instead of the fixed Xbox table; hints name the profile's binding. Search and AcceptLong are the desktop
library's, so a button mapped to one is neither fed to the TV screens nor consumed. A removed pad drops only its own holds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): route pads to the desktop library when it owns the input (#1021)

DesktopPad (tv::NavInput with long-press Accept + DesktopNav + pending commands)
and deskPadActive/HandleEvent/Frame per design section 2: TV off, no running game
(or a paused one with the library window open), home library or window showing, no
capture or other desktop window.  Pad buttons and triggers go through the pad's
NavProfile, the left stick through padAxis, the right stick Y is kept for scrolling;
a mapped press is consumed.  Start, unmapped buttons and the keyboard fall through.
Every edge of active / library window / tab / pad popup resets the nav and the queue.
Script: expect desk-pad and desk-pending.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): zero the desktop nav's right-stick scroll on ownership edges and focus loss (#1021)

A right stick let go while the library did not own the pad left scrollY set, so the
grid would scroll on once it did.  Zero it on every edge of active / library window /
tab / pad popup and on window focus loss.  Script: new 'axis NAME VALUE' verb and
'expect desk-scroll', plus a Settings open/close line for desk-pad (Ctrl+,).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): pad focus, ring and actions in the desktop library (#1021)

The library publishes its Favorites / Recently played strips, grid and State shots to the desktop nav each frame, draws a
focus ring (theme text colour, soft shadow) on the focused tile while the pad drives, scrolls it into view only when it is
not fully visible, scrolls with the right stick, and executes the queued commands: launch (arming the release guard),
the tile menu and BIOS popup anchored at the tile, favorite, tab switch, search focus and Back. A pad press in the
search box leaves it. The status line shows the last-used pad's button hints.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): pad scroll only for the pad, stick scroll hands focus on, shared button labels (#1021)

Bring-into-view scrolling acts only while the pad drives (the mouse's view no longer snaps on a scan, search or tab click). When the right stick scrolls the focused grid tile out of view, focus moves to the nearest fully visible tile of its column (pure deskNearestVisibleIndex, DesktopNav::focusIndex without a scroll flag). The face-label logic moves into padButtonLabel (pad_profile) and is used by TV hints and the desktop hint line. The hint rides the status line (no line comes and goes) and says only what works on the States tab. A stale queued tile command is dropped, the redundant second command pass goes, and the design doc's search-box rule says any pad press leaves it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): drive library popups with the pad through ImGui's gamepad nav (#1021)

Pad-opened popups (tile menu, BIOS popup, delete modal) get ImGui's own gamepad
navigation fed with our intents (directions, Accept -> GamepadFaceDown, Back ->
GamepadFaceRight), so the Switch confirm/back swap holds. The SDL3 backend is in
Manual gamepad mode; HasGamepad is set between its NewFrame and ImGui's, only
while the popup is open. Accept acts on press inside popups. tv_ui_smoke guards
that gamepad nav does not make ImGui want the keyboard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): edit a controller model's menu buttons in the controls screen (#1021)

Settings > Controls gets a Menu buttons section for one connected controller
model: a layout combo (Auto shows the detected family), a Set / Default row per
menu intent and Reset menu buttons. With every gamepad selected it shows the
last-used pad's family table read-only. InputManager::beginNavCapture reuses the
bind capture, writes no game binding, refuses the pad's TV-menu buttons, D-pad
and sticks, and reports Swapped / Reserved. The headless tv script gains
capture nav:INTENT and a {padguid} placeholder in expect cfg.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): menu-button Default swaps the partner back; swap note ignores hold flag (#1021)

Default on a menu intent now goes through setNavBinding with the family's
button, so a swapped partner gets its old button back and no two intents share
one. The Swapped note compares kind/code/dir like setNavBinding. The combo is
labelled Family. Script: navdefault INTENT.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): pad nav review fixes: visible popup cursor, Play via the pad path, no pad under foreign popups (#1021)

- A popup the pad opened shows its focused first row at once (ConfigNavCursorVisibleAlways while its nav is on), since Accept acts on press.
- The tile menu's Play row goes through activateEntry, so a pad user gets no mouse-only resume prompt and the confirm press is guarded.
- The library does not own the pad while an ImGui popup the pad did not open is up (right-click or menu-bar menu, Lists popup, combo, modal).
- Remove the unreachable pad hook on the delete-state modal.
- Menu-button capture also refuses the pad's controls-overlay (C_HELP) button; resetNavBinding bounds-checks the intent; picking the family a pad already has no longer wipes its menu-button changes (items that do say so).
- DeskLists are built and published only while the pad drives the library, and moved into the nav; Pad::nav defaults to the Xbox map; tv_host.h comment says SDL_gamepad.h is for enums only.
- Docs: README and TV-mode design updates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 17:23:35 -04:00

14 KiB
Raw Permalink Blame History

Controller navigation of the desktop library: design

Status: design for review (2026-10-17). Issue #1021, milestone v4.0.0. Scope: app/ only. Related: TV mode spec docs/app-tv-mode-design.md (#991), the TV-mode shortcut #1020.

1. Intent

A player holding a pad at the desktop library (TV mode off) should be able to do the basics without reaching for the mouse: browse the games, start one, open a tile's menu, switch tabs, and switch to TV mode. TV mode stays the full couch experience; this is "the desktop UI works with a pad as well as it allows". Mouse and keyboard behaviour do not change.

Button names and confirm/back placement follow the controller family (Xbox, PlayStation, Switch, MFi/generic), and a player can override the buttons for one controller model.

Goals

  • Pad focus over the desktop library's cover grid, the Favorites / Recently played strips, the BIOS tab and the States tab, with a visible focus ring.
  • The button map in section 3, from built-in family profiles detected through SDL.
  • A user-editable custom profile per controller model (SDL GUID).
  • The same profile drives TV mode's navigation, so a Switch pad confirms with its A in both UIs.

Non-goals

  • Pad navigation of the rest of the desktop UI (menu bar, toolbar widgets, Settings window, BIOS settings, cheats). Start opens Settings only when phase 3's pad-driven "More settings" exists; until then Start is unbound in the desktop library (section 3).
  • Keyboard navigation of the desktop library (arrows stay unbound there; "keyboard unchanged").
  • An on-screen keyboard. Back/View focuses the search box for a physical keyboard; nothing more.

Anti-goals (a failure even if it works)

  • A pad press reaching a game that starts or resumes (the A that launched it must not also press A).
  • Any change to mouse or keyboard behaviour in the desktop UI, or to the game's own bindings.
  • A second navigation system: the focus model and intents are TV mode's (app/src/tv/tv_focus.h, tv_nav.h), not ImGui's spatial navigation.
  • A Switch player who learnt "A on the right confirms" in TV mode finding it backwards on the desktop, or the other way round.

2. When the desktop library owns the pad

Desktop pad navigation is active when all of these hold:

  • TV mode is off and the TV in-game menu is not open;
  • no game is loaded, or a game is loaded but paused and the Game library window (Ctrl+L) is open (my call: a running game always owns its pad);
  • the home library is showing (App::homeVisible()) or the Game library window is open;
  • no bind capture is in progress, and no other desktop window (Settings, States, Cheats, About, the controls overlay) is open. A popup the pad opened itself (the tile menu, the BIOS popup) does not count: it is driven as section 4 says. Any other open popup (a right-click menu, a menu-bar menu, a combo, the delete-state modal) takes the pad away from the library until it closes.

TV mode's Auto trigger wins (user decision). With the TV setting Auto and its "when a controller is used" trigger on (phase 4, not built yet), a pad press in the desktop library turns TV mode on, as docs/app-tv-mode-design.md §8 says. Desktop pad navigation serves Off, Manual, and Auto with that trigger unticked. Today nothing calls TvMode::onPadPress, so this PR only has to leave the press available to it: the desktop consumer must not swallow a press before phase 4's hook would see it. Write it as: the auto check runs first, and desktop nav sees the press only when it did not switch.

The existing app actions keep their meaning: the TV-menu action (Share hold, Guide hold, paddles, F4) toggles TV mode with no game; C_HELP (touchpad) is untouched.

3. Button map

Positions are named Xbox-style here; the family profile maps physical buttons to these intents.

Input Intent Desktop library
D-pad / left stick Up/Down/Left/Right Move focus. TV mode's repeat: 350 ms, then every 80 ms
Confirm, tap Accept Play the focused game; on a BIOS tile open its popup; on a state shot load it; inside a popup activate the row
Confirm, held 500 ms AcceptLong The focused tile's menu (the right-click menu)
X (west) Alt The tile's menu at once
Back Back Close a popup; else close the Game library window; on the home library nothing
Y (north) Aux Toggle favorite on the focused game
LB / RB SectionPrev/Next Previous / next visible library tab (Cartridges, CD, BIOS, Favorites, Recents, States)
LT / RT PagePrev/Next Jump to the previous / next initial letter in the grid (tv::jumpInitial)
Right stick (scroll) Scroll the grid; moves focus to the nearest visible tile when it scrolls out
Back / View (select) Search Focus the search box (svc.focusSearch)
Start Menu Unbound in the desktop library for now (see non-goals)
Share / Guide hold, paddles C_TVMENU Unchanged: toggles TV mode with no game

Confirm with a long press means Accept fires on release (under 500 ms) in the desktop library, not on press. That delay is only in the desktop library; TV mode keeps Accept on press (it has X for details). The release guard (section 5) covers the button that launched a game either way.

Families (detected with SDL_GetGamepadType, SDL 3.4.16 types):

Family SDL types Confirm / Back Labels
Xbox XBOX360, XBOXONE South / East A B X Y, LB RB, LT RT, View, Menu
PlayStation PS3, PS4, PS5 South / East Cross Circle Square Triangle, L1 R1, L2 R2, Share/Create, Options
Switch NINTENDO_SWITCH_PRO, JOYCON_LEFT/RIGHT/PAIR, GAMECUBE East / South (A/B swapped) A B X Y by SDL label, L R, ZL ZR, −, +
MFi / generic STANDARD, UNKNOWN South / East A B X Y, LB RB, LT RT

The fallback for anything unrecognised is the Xbox mapping. West and North stay positional in every family (menu on the left face button, favorite on the top). Face-button glyphs come from SDL_GetGamepadButtonLabel as TV mode already does (tv_app_host.cpp glyph), so an 8BitDo pad in Switch mode that reports a Switch type and Switch labels gets both. A pad whose type and labels disagree follows the type; the custom profile is the escape hatch.

4. Popups

The tile menu and the BIOS popup are ImGui popups (the delete-state modal stays the mouse's). Inside them the pad drives ImGui's own navigation, which is fine for a vertical list (the TV spec's objections were to spatial nav across the grid): while a popup opened from pad focus is open, the app sets ImGuiConfigFlags_NavEnableGamepad, puts the SDL3 backend in ImGui_ImplSDL3_GamepadMode_Manual with no pads (so it submits no raw pad state), and feeds our intents as ImGui gamepad keys: directions to ImGuiKey_GamepadDpad*, Accept to GamepadFaceDown, Back to GamepadFaceRight. That keeps the Switch swap inside popups. The flag goes off when the popup closes. Gamepad nav alone does not make WantCaptureKeyboard true (imgui.cpp 1.92.8, only NavEnableKeyboard does), so F4 and the #1020 shortcut keep working.

5. Ownership hand-off

Starting a game from the desktop library with the pad, or loading a state, calls the existing InputManager::armReleaseGuard() before the game owns input, exactly as TV mode's launch does, so the confirm press is masked from the game until it is released. Opening the library window over a paused game and closing it again arms it too.

6. Focus and its look

  • Regions: the Favorites strip and the Recently played strip (when shown on the tab, each a tv::LinearFocus), then the grid (tv::GridFocus, cols set from drawGrid's per-frame column count). Up from the grid's first row goes to the strip above; Down from a strip goes to the strip or grid below; Up from the top region does nothing. The States tab is one LinearFocus over its shots in display order; the BIOS tab is the grid.
  • Memory: tv::FocusMemory per tab and region, keyed by the game's path as TV mode does, so a list that reorders (a launch moves a game to the front of Recents) keeps the same game focused.
  • Scrolling: the focused tile is scrolled into view (the grid uses an ImGuiListClipper, so the scroll target is computed from the row, not from a drawn item).
  • Look: the same ring as TV mode: the theme's text colour, 2–3 px at desktop scale, round corners, plus the soft shadow, drawn in drawCell where the hover outline is drawn now. Not colour alone. (The approved description said "the theme's accent"; the palette has no accent colour and TV mode uses text on purpose, so this follows TV mode.)
  • Visibility: the ring shows after a pad input and hides on mouse movement, like TV mode's padDriven. The mouse hover outline is unchanged.
  • Hint line: while pad-driven, the library's status line shows the common buttons with the pad's own labels, e.g. "A Play · hold A Menu · Y Favorite · LB/RB Tabs" ("Cross Play …" on a PlayStation pad, "A" on the right-hand button on a Switch pad).

7. Profiles and the custom editor

  • A pure PadProfile maps SDL gamepad buttons (and the trigger axes) to tv::Intents. Built-in profiles per family; familyFor(SDL_GamepadType).
  • The TV shell and the desktop consumer both translate a pad event through the profile of the pad it came from (by joystick id), instead of the hard-coded tv::intentForButton. Xbox / generic pads map exactly as intentForButton does today, so existing TV tests stay valid.
  • Custom profile per controller model: stored in the config next to the per-model game bindings, input.padguid.<GUID>.nav.family (auto | xbox | playstation | switch | generic) and input.padguid.<GUID>.nav.<intent> = one serialised button binding per intent that differs from the base family. Absent keys mean "the family default". Not new Ctl ids (they would eat the 64-control headroom and reach the game bindings' code paths). Covered by settings_reset.
  • Editor: in the controls screen, when the device picker is set to one connected controller model, a "Menu buttons" section: a Family combo (Auto shows the detected family) and one row per intent (Confirm, Back, Menu/Alt, Favorite/Aux, Tabs prev/next, Page prev/next, Search) with Set and Default, reusing InputManager's bind capture for a pad button. "Reset menu buttons" clears the model's nav keys. With "Gamepads (every controller)" selected the section shows the family table read-only. Directions are not rebindable (D-pad and left stick always move).

8. Constraints

  • C++17, SDL 3.4.16, Dear ImGui 1.92.8-docking. New logic lives in new files (the profile and the desktop consumer); hooks in app.cpp, input.cpp, devices_ui.cpp, library_ui.cpp, tv_shell.cpp and remap_ui.cpp stay small. The emulation-thread work (#967 phase 2) is editing app.cpp / input in parallel.
  • initImGui keeps nav off by default; only section 4 turns gamepad nav on, and only while a pad-opened popup is open.
  • Never open app windows on the developer's Mac. Windowed checks and screenshots run on the Mac Studio runner.

9. Testing

  • Unit (pure): PadProfile family detection for every SDL_GamepadType, the Switch swap, custom overrides and their config round trip; NavInput's long-press Accept (tap fires on release under 500 ms, a hold fires AcceptLong once); the desktop focus regions (grid ↔ strips, edges, memory by game key, the States list).
  • Existing tv_nav_test, tv_focus_test, tv_ui_smoke, tv_menu_headless stay green unchanged for Xbox/generic pads.
  • Headless script (tv_menu_headless style): with no game, a pad tap moves the desktop focus and A launches the focused game; a press while TV mode's Auto controller trigger would act is left for it. The virtual pad is a generic type, so family behaviour is covered by the unit tests, not the script.
  • Screenshots of the focused grid, a strip, the tile menu and the hint line on the Mac Studio runner, attached to the PR.

10. Input rules settled after review (my calls)

  • Search box. Pads never type, so pad intents are not blocked by a focused text field. While the search box has focus, any pad press leaves it (a direction also moves the grid focus as usual; a Back / Accept / menu / favorite command of that press is dropped and its release forgotten, so it neither acts nor closes the window; tab switches still run). The library clears the box's active id; the keyboard keeps typing as before.
  • A release only counts if its press was seen here. NavInput keeps a per-source "pressed here" latch: a release whose press it did not see, or that went down before the last reset(), produces nothing. Every ownership or state change (a popup opens or closes, the library window opens or closes, a tab switch, the Auto-TV check taking the press, a capture) calls reset(). So an A that activated a popup row and is released over the grid launches nothing.
  • AcceptLong fires at the 500 ms threshold while the button is still held, once; its release is swallowed. The tile menu it opens takes no input from that button until it is released.
  • Inside popups Accept acts on press (fed straight to ImGui as GamepadFaceDown); only the grid and strips delay Accept to release.
  • One shared NavInput, sources keyed by (joystick id, button). Each event is translated through the profile of the pad it came from. The existing padButton(int, bool, ms) stays as an overload (joystick id 0, Xbox mapping) so the TV tests keep their signatures.
  • Custom profile rules. Global only, not per game. One button per intent and one intent per button: Set on a button another intent uses swaps the two, so every intent stays bound. Reserved and refused with a note: the D-pad, the left stick, and any button that carries a C_TVMENU binding on that pad. Overrides are stored as a serialised Binding (button or axis), so Page prev/next can move from the triggers to buttons and back; Default restores the family's binding.
  • Pad-opened popups open anchored at the focused tile (the right-click menu uses the mouse position); cell launch / open logic in drawCell moves into a helper shared by the click and the pad.
  • AppHost::glyph and the hint line take Confirm/Back from the profile, not hard-coded South/East.

11. Left to the builder

The exact hint-line wording and layout, the ring thickness at desktop scale, how the right stick scroll converts to pixels, the file names, and how the desktop consumer is split between pure state and the ImGui-facing part.