* 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]>
24 KiB
Controller navigation of the desktop library: implementation plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: a pad browses the desktop library (TV mode off): focus ring, play, tile menu, favorite, tabs, letter jump, search focus, with per-family button profiles and a per-controller-model custom profile that TV mode shares.
Architecture: three new pure units (tv::NavInput extensions, NavProfile, DesktopNav) carry all logic and are
unit-tested. Thin ImGui/SDL glue (desktop_nav_app.cpp) routes pad events, drains intents per frame and executes
commands; library_ui.cpp publishes its display lists to DesktopNav and draws the ring. Popups use ImGui's own
gamepad nav, fed with our intents. The controls screen gets a "Menu buttons" editor.
Tech Stack: C++17, SDL 3.4.16, Dear ImGui 1.92.8-docking, CMake/CTest.
Spec: docs/app-pad-nav-design.md (sections cited as §N).
Global Constraints
- Branch
feat/1021-pad-library-navofflibretro/develop; worktree~/Workspace/Provenance/vj-wt/pad-nav. Rebase onlibretro/developbefore the PR (#1020'sfeat/1020-tv-toggleand #967 phase 2 touchremap_ui.cpp,app.cpp,input.cpp). - Build:
env -u DEVELOPER_DIR CC=/usr/bin/clang CXX=/usr/bin/clang++ cmake -S app -B build -DCMAKE_BUILD_TYPE=Release,cmake --build build -j8,ctest --test-dir build --output-on-failure -j4. Every existing test stays green. - Never open app windows on the developer's Mac (only
--headlessand test binaries). Windowed runs and screenshots on the Mac Studio runner (vj-eval/remote/vjr.sh,ssh studio-runner). - Hooks in existing files stay a few lines each; logic lives in new files. Match the surrounding comment style.
- Mouse and keyboard behaviour of the desktop UI unchanged (§1 anti-goals). Keyboard is never fed to
DesktopNav. - Xbox / generic pads map exactly as
tv::intentForButtondoes today (§7). - Shell aliases
rm/cp/mvare interactive:trashorcommand rm -f. Commits: conventional, 1Password-signed (retry on "failed to fill whole buffer"), endingCo-Authored-By: Claude Opus 5.5 <[email protected]>.
Review Focus
- A pad hot-plugged or unplugged mid-hold (Confirm held, then
SDL_EVENT_GAMEPAD_REMOVED): no AcceptLong, no stuck repeat — Task 1 testunplug_mid_hold. - The library list changes under focus (scan finishes, search typed, a game launched moves Recents): focus follows the
same game by key, else clamps — Task 3 test
list_changes_follow_key. - Two pads at once with different families (Switch + Xbox): each confirms with its own button — Task 1 test
two_pads_same_buttonand Task 4 smokeswitch_pad_accepts_east. - An empty tab / "Nothing matches" (zero items in every region): every intent is a no-op, no crash — Task 3 test
empty_lists. - The custom profile edited for a pad that is then unplugged / a GUID never seen: config keys are inert, defaults apply
— Task 2 test
unknown_guid_defaults.
Task 1: NavInput — per-pad sources, supplied intents, long Accept, Search
Files:
- Modify:
app/src/tv/tv_nav.h,app/src/tv/tv_nav.cpp - Test:
app/tests/tv_nav_test.cpp
Interfaces:
-
Produces:
enum class Intent { …existing…, PageNext, Search, AcceptLong };(appended; nothing reorders)void NavInput::padIntent(uint32_t pad, int button, Intent in, bool down, uint64_t ms);source = (pad, button)void NavInput::padTrigger(uint32_t pad, int axis, Intent in, int16_t v, uint64_t ms);edge only,kTriggerOn/kTriggerOffhysteresis per (pad, axis), no repeat;in == Noneignores the axisvoid NavInput::setLongAccept(bool on);andstatic constexpr uint64_t kLongMs = 500;void NavInput::forgetPad(uint32_t pad);drops that pad's held sources, emits nothing- Existing
padButton(b, down, ms)==padIntent(0, b, intentForButton(b), down, ms);padAxisunchanged.
-
Step 1: Write failing tests in
tv_nav_test.cpp(style of the file):long_accept_tap:setLongAccept(true);padIntent(7, SOUTH, Accept, true, 0);take(100)empty;padIntent(7, SOUTH, Accept, false, 200);take(200) == {Accept}.long_accept_hold: down at 0;take(499)empty;take(500) == {AcceptLong};take(600)empty; up at 900;take(900)empty.long_accept_unseen_release: down at 0;reset(); up at 100;take(100)empty.long_accept_off_unchanged: withoutsetLongAccept, down at 0 →take(0) == {Accept}, up → nothing.two_pads_same_button:setLongAccept(true); pad 1 SOUTH Accept down at 0; pad 2 SOUTH Back down at 10 (take(10) == {Back}); pad 2 up at 20; pad 1 up at 100 →take(100) == {Accept}.unplug_mid_hold: long accept on; pad 3 SOUTH down at 0, pad 3 DPAD_DOWN Down down at 0;forgetPad(3);take(1000)contains neither AcceptLong nor a Down repeat (only the initial Down edge from before).pad_trigger_edges:padTrigger(1, RIGHT_TRIGGER, PageNext, 9000, 0)→{PageNext}; 5000 → nothing; 3000 then 9000 →{PageNext}again; pad 2's trigger independent of pad 1's.
-
Step 2:
cmake --build build -j8 --target vj_tv_nav_test && ctest --test-dir build -R tv_nav_unit→ FAIL (does not compile). -
Step 3: Implement. Source key
int src = (int)((pad & 0x7FFF) << 16) | (kSrcButton + button); triggers keyed the same way withkSrcAxis. Long Accept: an Accept press recordsdownAtin a held-accept list instead of pushing;take(ms)emitsAcceptLongonce whenms - downAt >= kLongMsand marks it swallowed;releaseof a source still in the list and not swallowed pushesAccept; a release whose source is not indown_emits nothing.reset()clears the list. -
Step 4: run
tv_nav_unit,tv_ui_smoke,tv_menu_headless→ PASS. -
Step 5: Commit
feat(app): NavInput per-pad sources, supplied intents and long-press Accept (#1021).
Task 2: NavProfile — families, defaults, custom per-model layer
Files:
- Create:
app/src/pad_profile.h,app/src/pad_profile.cpp - Test:
app/tests/pad_profile_test.cpp; registervj_pad_profile_test/pad_profile_unitinapp/cmake/VJTvTests.cmakelikevj_hold_arbiter_test(linkssrc/remap.cpp src/config.cpp src/paths.cpp, SDL) - Modify:
app/src/settings_reset.cpp(theinput.padguid.*.nav.*keys reset with the controls, same class as the otherinput.keys)
Interfaces:
-
Consumes:
tv::Intent(Task 1),Binding(remap.h),Config(config.h). -
Produces (namespace
vj):enum class PadFamily { Xbox, PlayStation, Switch, Generic };PadFamily familyForType(int sdlGamepadType);— XBOX360/XBOXONE→Xbox; PS3/PS4/PS5→PlayStation; NINTENDO_SWITCH_PRO/JOYCON_LEFT/JOYCON_RIGHT/JOYCON_PAIR/GAMECUBE→Switch; STANDARD/UNKNOWN/other→Genericconst char* familyKey(PadFamily)("xbox" "playstation" "switch" "generic"),bool familyFromKey(const std::string&, PadFamily&),const char* familyName(PadFamily)("Xbox", "PlayStation", "Nintendo Switch", "MFi / generic")constexpr tv::Intent kNavIntents[10] = {Accept, Back, Alt, Aux, Menu, SectionPrev, SectionNext, PagePrev, PageNext, Search};withconst char* navIntentKey(tv::Intent)("confirm" "back" "menu" "favorite" "start" "tabprev" "tabnext" "pageprev" "pagenext" "search") andconst char* navIntentLabel(tv::Intent)("Confirm", "Back", "Tile menu", "Favorite", "Start", "Previous tab", "Next tab", "Previous letter", "Next letter", "Search")struct NavProfile { PadFamily family; bool familyOverridden = false; std::array<Binding, 10> binds; tv::Intent intentForButton(int sdlButton) const; tv::Intent intentForTrigger(int sdlAxis) const; Binding binding(tv::Intent) const; };— D-pad buttons always map to directions; a trigger maps only through aPadAxisbinding withdir == +1on LEFT/RIGHT_TRIGGER.NavProfile familyNavProfile(PadFamily);Xbox/PlayStation/Generic: Accept SOUTH, Back EAST, Alt WEST, Aux NORTH, Menu START, SectionPrev LEFT_SHOULDER, SectionNext RIGHT_SHOULDER, PagePrev axis(LEFT_TRIGGER,+1), PageNext axis(RIGHT_TRIGGER,+1), Search BACK. Switch: same with Accept EAST, Back SOUTH.std::string navKeyPrefix(const std::string& guid)="input.padguid." + guid + ".nav."NavProfile resolveNavProfile(const Config&, const std::string& guid, int sdlGamepadType);— family from<prefix>familywhen set, elsefamilyForType; then each<prefix><intentKey>that parses as one Binding overrides. Empty guid → family default.enum class NavSet { Ok, Swapped, Reserved };NavSet setNavBinding(Config&, const std::string& guid, int sdlGamepadType, tv::Intent, const Binding&, const std::vector<Binding>& reserved);— refuses D-pad buttons, LEFTX/LEFTY axes and anything inreserved(the pad's C_TVMENU bindings, hold flag ignored); if another intent holds the binding the two swap; a key equal to the family default is erased, not written.void setNavFamily(Config&, const std::string& guid, const std::string& keyOrAuto);("auto" erases the key)void clearNavProfile(Config&, const std::string& guid);erases every key under the prefixstd::string navButtonName(PadFamily, const Binding&);face buttons by family (Xbox A/B/X/Y; PlayStation Cross/Circle/Square/Triangle; Switch SOUTH=B EAST=A WEST=Y NORTH=X), shoulders LB/RB · L1/R1 · L/R, triggers LT/RT · L2/R2 · ZL/ZR, START Menu/Options/+, BACK View/Create/−; elseBinding::display().
-
Step 1: Write failing tests (
pad_profile_test.cpp, FAIL-regex style ofhold_arbiter_test.cpp):family_for_every_type(all 12SDL_GamepadTypevalues incl. COUNT-1),xbox_matches_intentForButton(for everySDL_GamepadButton,familyNavProfile(Xbox).intentForButton(b) == tv::intentForButton(b)except BACK→Search),switch_swaps_confirm_back(EAST→Accept, SOUTH→Back, WEST→Alt),triggers_page,resolve_family_override(<prefix>family=switchon an Xbox type → Switch map),set_swaps(set Accept=NORTH on Xbox → Accept NORTH, Aux SOUTH, both keys written),set_default_erases,set_reserved_refused(DPAD_UP; LEFTX; MISC1 when passed as reserved),trigger_to_button_and_back,clear_removes_all,unknown_guid_defaults(keys for guid A, resolve guid B → family default),round_trip(save/load a temp Config file),button_names(Switch EAST "A", PlayStation SOUTH "Cross", Switch LEFT_TRIGGER axis "ZL"). -
Step 2: build
vj_pad_profile_test, runctest -R pad_profile_unit→ FAIL. -
Step 3: implement
pad_profile.cpp. -
Step 4:
ctest -R "pad_profile_unit|settings_reset"→ PASS. -
Step 5: Commit
feat(app): controller-family menu-button profiles with a per-model custom layer (#1021).
Task 3: DesktopNav — pure focus over the library's regions
Files:
- Create:
app/src/desktop_nav.h,app/src/desktop_nav.cpp - Test:
app/tests/desktop_nav_test.cpp; targetvj_desktop_nav_test/ testdesktop_nav_unitinapp/cmake/VJTvTests.cmake(linkssrc/desktop_nav.cpp src/tv/tv_focus.cpp)
Interfaces:
-
Consumes:
tv::Intent,tv::LinearFocus,tv::GridFocus,tv::FocusMemory,tv::jumpInitial(tv_focus.h). -
Produces (namespace
vj):struct DeskItem { std::string key, title; };(key = the entry's path; a state shot =path + "#" + slot)enum class DeskRegion { FavStrip, RecentStrip, Grid, States };struct DeskLists { std::vector<DeskItem> fav, recent, grid, states; int gridCols = 1; };struct DeskCmd { enum Kind { Activate, OpenMenu, ToggleFavorite, TabPrev, TabNext, FocusSearch, Back } kind; std::string key; };class DesktopNav { public: void setLists(const std::string& tab, const DeskLists&); std::vector<DeskCmd> apply(const std::vector<tv::Intent>&); DeskRegion region() const; int index() const; std::string focusedKey() const; bool focused(DeskRegion, int index) const; bool takeScroll(); void setVisible(bool); bool visible() const; void reset(); };
-
Rules (§6, §3): regions in order FavStrip, RecentStrip, Grid (empty ones skipped) or States alone. Left/Right move in a strip; the grid moves with
GridFocus; Up from grid row 0 → the region above at its remembered index; Down from a strip → the next region (grid index = min(strip index, cols−1)). States: Up/Left −1, Down/Right +1. PagePrev/PageNext jump initials in the grid (jumpInitialon grid titles). Accept → Activate(key); AcceptLong/Alt → OpenMenu(key); Aux → ToggleFavorite(key, not in States); SectionPrev/Next → TabPrev/TabNext (and the new tab's remembered focus applies on the nextsetLists); Search → FocusSearch; Back → Back; Menu, None → nothing.setListskeeps the focused key if it is still listed (FocusMemory item), else clamps the index; memory is pertab + region. Every move sets the scroll flagtakeScroll()returns once.applywith no items anywhere returns only tab/search/back commands. -
Step 1: Write failing tests:
grid_moves_and_edges,strip_to_grid_and_back(remembered strip index),states_linear,page_initials,commands_from_intents(each intent → expectedDeskCmd),list_changes_follow_key(focused "b" stays focused after "a" is inserted ahead; removed → clamps),per_tab_memory,empty_lists(every intent on empty lists: no crash, no Activate),scroll_flag_once. -
Step 2: run
desktop_nav_unit→ FAIL. -
Step 3: implement.
-
Step 4: run → PASS.
-
Step 5: Commit
feat(app): DesktopNav focus model for the desktop library (#1021).
Task 4: Pads translate through their profile in TV mode too
Files:
-
Modify:
app/src/input.h,app/src/input.cpp(Pad getsNavProfile nav; resolved wherep.profileis resolved, withSDL_GetGamepadType(p.handle);const NavProfile& navProfile(uint32_t joystickId) const— a static Xbox default for an unknown id;PadFamily lastPadFamily() const) -
Modify:
app/src/tv/tv_host.h(virtual Intent padIntent(uint32_t pad, int button) const { return intentForButton(button); },virtual Intent padTriggerIntent(uint32_t pad, int axis) constdefaulting to LEFT_TRIGGER→PagePrev, RIGHT_TRIGGER→PageNext),app/src/tv/tv_app_host.cpp(override both fromapp.input.navProfile;glyph(Accept/Back/Alt/Aux/Section*/Page*)labels the profile's binding: the SDL face label for a face button as now,navButtonNameotherwise) -
Modify:
app/src/tv/tv_shell.cpphandleEvent: buttons vianav_.padIntent(e.gbutton.which, b, host_.padIntent(which, b), …); triggers vianav_.padTrigger;SearchandAcceptLongare not TV's (not fed, not consumed);SDL_EVENT_GAMEPAD_REMOVEDcallsnav_.forgetPad. -
Test:
app/tests/tv_ui_smoke.cpp(FakeHost gets aswitchPadflag) -
Modify:
app/CMakeLists.txt/app/cmake/VJTvTests.cmakesource lists forpad_profile.cppwhereinput.cppandtv_app_host.cppare built. -
Step 1: Write failing smoke case
switch_pad_accepts_east: FakeHost withswitchPad = truemaps EAST→Accept, SOUTH→Back; on Home, tapping EAST launches the focused game (FakeHost recordslaunch), tapping SOUTH at the root does not. -
Step 2: run
tv_ui_smoke→ FAIL. -
Step 3: implement the hooks above.
-
Step 4: full
ctest→ PASS (tv_menu_headlessunchanged: its virtual pad is a generic type → Xbox map). -
Step 5: Commit
feat(app): TV mode reads each pad's menu-button profile (#1021).
Task 5: Routing — when the desktop library owns the pad
Files:
- Create:
app/src/desktop_nav_app.h,app/src/desktop_nav_app.cpp(struct DesktopPad { tv::NavInput in; DesktopNav nav; bool active = false; bool padPopup = false; float scrollY = 0; };,bool deskPadActive(const App&),bool deskPadHandleEvent(App&, const SDL_Event&, uint64_t ms),void deskPadFrame(App&, uint64_t ms)) - Modify:
app/src/app.h(one memberDesktopPad deskPad;),app/src/app.cpphandleEvent(one line after the TV shell's:if (deskPadHandleEvent(*this, e, SDL_GetTicks())) return;with a comment that phase 4's Auto controller trigger goes before it, §2),app/src/devices_ui.cpptvInputFrame(calldeskPadFrameaftertvShell().frame) - Modify:
app/src/tv_script.cpp,app/tests/tv_menu_headless.txt.in(new expectdesk-pad=0|1)
Interfaces:
-
Consumes: Tasks 1–4.
-
Produces:
deskPadActive(app)exactly per §2:!tvMode.active() && !tvShell().menuOpen() && !input.capturing() && (homeVisible() || library.show) && (!host.isLoaded() || (paused && library.show)) && !(showSettings || showStates || showCheats || showAbout || input.overlay.open). Events: pad buttons/triggers throughinput.navProfile(which)intodeskPad.in(setLongAccept(true)), left stick viapadAxis, right stick Y stored inscrollY; a mapped press is consumed (returns true on down, false on up, as the TV shell does); unmapped buttons (Guide, Share, paddles, Start is mapped butDesktopNavignores Menu) fall through. Pads never blocked byWantTextInput. Mouse motion →nav.setVisible(false); a pad press →true.deskPadFramecallsin.reset()on every edge ofactive,padPopup,library.showand tab change, then drainsin.take(ms)intonav.apply(or into ImGui, Task 7).DeskCmd::Activateon a game callsinput.armReleaseGuard()beforesvc.launch(§5); a launch of a state too. -
Step 1: Write failing script lines in
tv_menu_headless.txt.in: no game, TV off →expect desk-pad=1; F4 (TV on) →desk-pad=0; F4 →desk-pad=1;loada game →desk-pad=0;capturestart →desk-pad=0. -
Step 2: build, run
tv_menu_headless→ FAIL (unknown expect). -
Step 3: implement routing and the expect verb.
-
Step 4: full
ctest→ PASS. -
Step 5: Commit
feat(app): route pads to the desktop library when it owns the input (#1021).
Task 6: library_ui — lists, focus ring, scroll, commands, hint line
Files:
-
Modify:
app/src/library_ui.cpp(publishDeskListsfromdrawGrid/drawStrips/drawStatesTabeach frame viaapp.deskPad.nav.setLists(tabKey, lists);drawCelltakesbool focusand draws the ring after the hover outline:AddRectinui::pal().text, rounding 4, thickness2.5f * dpiScale, plus a 3-layer soft shadow liketv_widgets.cpp, only whennav.visible(); whentakeScroll()the focused grid row's y is computed fromrow * (artH + textH + gap)andImGui::SetScrollYbrings it into view (strips:SetScrollXin their child); executeDeskCmds: Activate = the tile's click action (extract a helperactivateEntry(app, svc, e)used by the click and the pad; BIOS tiles open##bios), OpenMenu =ImGui::OpenPopup("##cellmenu")inside that tile's ID scope, anchored withSetNextWindowPosat the tile's bottom-left, anddeskPad.padPopup = true; ToggleFavorite =svc.setFavorite; TabPrev/TabNext overlib::visibleTabs→svc.section = t; svc.saveSettings(); FocusSearch =svc.focusSearch = true; Back = close the Game library window (svc.show = false) unless on the home library; while the search box is active a pad Back/Down/Accept clears it (ImGui::ClearActiveID()/SetWindowFocus(nullptr)) and returns focus to the grid; right stickscrollYscrolls the grid child atfs * 40 px/sper full deflection. -
Hint line in
drawStatuswhilenav.visible(): " Play · hold Menu · Favorite · / Tabs" withnavButtonName(lastPadFamily(), binding)(SDL face labels where a handle is known). -
Step 1: build; full
ctest→ PASS (no behaviour change for the mouse; the ring only draws when pad-driven). -
Step 2: Windowed script on the Studio runner (
tv_scriptwindowed:wait library,pad tap dpad_right,pad tap dpad_down,shot grid.png,pad down south600 mspad up south,shot menu.png,pad tap east,pad tap rightshoulder,shot tab.png,quit), at 1280×720; check by eye: ring on the focused tile, menu at the tile, tab switched, mouse hover unchanged. -
Step 3: Commit
feat(app): pad focus, ring and actions in the desktop library (#1021).
Task 7: Popups — ImGui gamepad nav fed with our intents
Files:
-
Modify:
app/src/app.cppinitImGui(ImGui_ImplSDL3_SetGamepadMode(ImGui_ImplSDL3_GamepadMode_Manual, nullptr, 0)— the backend then submits no pad state; nav was already off) and the twoImGui_ImplSDL3_NewFrame(); … ImGui::NewFrame();sites (one calldeskPadBeforeNewFrame(*this)between them) -
Modify:
app/src/desktop_nav_app.cpp(void deskPadBeforeNewFrame(App&): whiledeskPad.padPopupand an ImGui popup is open, setImGuiConfigFlags_NavEnableGamepadandio.BackendFlags |= ImGuiBackendFlags_HasGamepad; otherwise clear both andpadPopup = false;deskPadFramein popup mode turns intents intoio.AddKeyEventdown+up pairs: Up/Down/Left/Right →ImGuiKey_GamepadDpad*, Accept (on press:setLongAccept(false)in popup mode) →ImGuiKey_GamepadFaceDown, Back →ImGuiKey_GamepadFaceRight; the Confirm still held from the long press is ignored until released (the reset on thepadPopupedge does it)) -
Modify:
app/src/library_ui.cpp(the##biospopup and the "Delete save state?" modal setdeskPad.padPopupwhen opened from the pad) -
Modify:
app/tests/tv_ui_smoke.cppor a new case in it: withNavEnableGamepad+HasGamepadand only gamepad nav,io.WantCaptureKeyboardstays false over an open popup (§4). -
Step 1: Write the failing smoke assertion above.
-
Step 2: run → FAIL / PASS as written (the assertion documents the ImGui guarantee; if it passes immediately, keep it as the guard).
-
Step 3: implement; full
ctest→ PASS. -
Step 4: Windowed script on the Studio: long-press opens the tile menu,
pad tap dpad_down×2,shot,pad tap southtoggles "Add to Favorites"; a Switch-type pad is not available there, so the swap inside popups is covered by reading the code path (intents, not raw buttons, reach ImGui). -
Step 5: Commit
feat(app): drive library popups with the pad through ImGui's gamepad nav (#1021).
Task 8: The "Menu buttons" editor in the controls screen
Files:
-
Modify:
app/src/remap_ui.cpp(drawControlsTab: whendevice >= 2(one controller model) a collapsing section "Menu buttons": Family combo — "Auto ()", then the four families — viasetNavFamily; a table row perkNavIntentsentry: label,navButtonNameof the current binding, Set (capture), Default (erase that key); a "Reset menu buttons" button →clearNavProfile; a refused or swapped Set shows its note under the table; withdevice == 1the family table of the last-used pad is shown read-only. After any changeapp.cfg.save(); in.reresolve();) -
Modify:
app/src/input.h/.cpp(void beginNavCapture(const std::string& guid, tv::Intent): the existingBindCaptureforDeviceClass::Gamepad; on capture callsetNavBindingwith the pad's C_TVMENU bindings asreserved, store theNavSetresult for the UI (NavSet lastNavSet() const), save, reresolve) -
Test: extend
tv_menu_headless.txt.inif the script'scaptureverb can target a nav intent (addcapture nav:confirm→ presspad tap north→expect cfg=input.padguid.<virtual guid>.nav.confirm:bnorth), else rely on Task 2's unit tests and note it in the PR. -
Step 1: write the script lines (or record why not).
-
Step 2: implement; full
ctest→ PASS. -
Step 3: Windowed screenshot on the Studio of Settings > Controls with the virtual pad selected.
-
Step 4: Commit
feat(app): edit a controller model's menu buttons in the controls screen (#1021).
Task 9: Docs, rebase, PR
- Update
app/README.md(controls section: the desktop library with a pad, the families, the editor), the cheat sheet if it lists library controls, anddocs/app-tv-mode-design.md§3.1's first row ("pads do nothing except…" → "pads browse the library, see docs/app-pad-nav-design.md"). git fetch libretro develop && git rebase libretro/develop; rebuild; fullctest.- Push
feat/1021-pad-library-navtolibretro, open the PR intodevelopwithCloses #1021, the screenshots from Tasks 6–8, and the list of design calls fromdocs/app-pad-nav-design.md§10.