From 7e2f5967ee37b4bb0dfdc97e03bd79bbb27f6fe6 Mon Sep 17 00:00:00 2001 From: Joe Mattiello Date: Thu, 22 Oct 2026 22:11:32 -0400 Subject: [PATCH] feat(app): TV mode auto-switching, first-use hint and build default (TV mode phase 4) (#1067) * docs(app): revise TV mode plan Phase 4 against develop (#991) Phase 4 was written before Phases 1-3 and desktop pad navigation (#1030) landed. Re-point the tasks at the current code: the controller trigger gates on deskPadActive and consumes the switching press, package defaults are set in app-package.yml (not release.yml), the first-use hint text comes from the pad's actual TV-menu binding. Adds Phase 4 approvals A-G and a placeholder for the user's layout fixes. Refs #991 Co-Authored-By: Claude Opus 5.5 * feat(app): TV mode switches on in fullscreen and on a controller press (#991) Wires the Auto setting. Until now Auto acted as Manual (effectiveSetting), so the fullscreen and controller triggers TvMode already implements were never fed. Why each piece is shaped as it is: - App tells TvMode about fullscreen edges only (tvFullscreenEdge against tvNoteFsSeen_): TvMode re-applies a repeated onFullscreen(true), and macOS sends enter events on space and display changes, which would turn TV mode back on after a manual off. - A mapped pad button (a menu intent: D-pad, face buttons, shoulders, Start) pressed while the desktop library owns the pad and no game is loaded switches TV mode on and is consumed, so it neither moves desktop focus nor acts on the TV screen it reveals. Guide/Share/touchpad/paddles/sticks are not triggers: they carry app actions or drift. The call sits before the TV shell so presses the shell consumes still give a controller-started TV mode a fresh mouse budget (otherwise mouse jitter would sum up and drop TV mode while the pad is in use). - Mouse travel and a printable key the TV UI did not use hand a controller-started TV mode back; F-keys, Esc and Ctrl/Cmd/Alt shortcuts never count (F4 and Ctrl+Shift+T are toggles themselves). - effectiveSetting() is gone; a config with no ui.tv_mode takes defaultSetting(VJ_TV_DEFAULT_SETTING) (Auto), never written back, so the build default can move. setTvSetting/setTvAuto are the one way to change the settings; the script gains `fullscreen on|off` and `expect tv-setting`. Co-Authored-By: Claude Opus 5.5 * feat(app): TV mode in Settings > Interface, the Auto rows in TV Settings, VJ_TV_DEFAULT (#991) Settings > Interface gets a TV mode section (Off / Manual / Auto, plus the two Auto checkboxes while Auto is chosen) and TV Settings gets the matching Auto: in fullscreen / Auto: when a controller is used rows, so the Phase 4 triggers can be switched without editing the config. Both routes go through App::setTvSetting / setTvAuto and save afterwards; the setters themselves never save. The default a fresh config starts from now comes from CMake: VJ_TV_DEFAULT (auto | manual | off, validated) defines VJ_TV_DEFAULT_SETTING for the virtualjaguar target, so a nightly can start on Auto while a release package starts on Manual. A stored ui.tv_mode still wins, and is only written when the user changes it. The headless script also pins approval C at App level (Esc, Enter and a Ctrl shortcut never end a controller-started TV mode) and walks the new rows, shifting the script's later lines to make room. Co-Authored-By: Claude Opus 5.5 * ci(app): release packages default TV mode to Manual, nightlies to Auto (#991) The TV mode default now comes from CMake's VJ_TV_DEFAULT. app-package.yml sets it per build from release_tag: vX.Y.Z tags and prerelease start a fresh config on Manual, nightly, prNNN and dev builds stay on Auto. The three native configures pass it as -DVJ_TV_DEFAULT, and the Flatpak, which is built straight from its manifest, gets the same value through one sed on config-opts before flatpak-builder runs (the committed manifest keeps CMake's default). Co-Authored-By: Claude Opus 5.5 * test(app): make the TV Settings Auto-rows walk independent of earlier tests (#991) The headless block that walks to "Auto: in fullscreen" relied on the shell still remembering Settings > Interface with focus on More settings..., left by a test far above it. It now presses Back twice (Home from either level of the Settings screen), opens a fresh Settings visit and clamps its way to the row: Up x4 / Down x3 on the groups, Up x8 / Down x4 in the list. It also checks that the Auto checkbox change, which saves the config, still leaves ui.tv_mode absent, and the CMake comment now says app-package.yml always passes VJ_TV_DEFAULT. Co-Authored-By: Claude Opus 5.5 * ci(app): fail the Flatpak build when the TV default sed did not apply (#991) Check the manifest for the exact config-opts line after the sed instead of printing it, so a changed manifest cannot silently ship the wrong default. Co-Authored-By: Claude Opus 5.5 * test(app): anchor the Auto-rows walk with expect tv-focus (#991) The previous walk assumed a fresh Settings visit starts on the groups. It does not: the shell's memory is never cleared, so the Base layer resumes the level (list or groups) and row an earlier test left, and TV mode comes back on at Home, so the Back presses before the first visit did nothing. The stray Right after the Up/Down presses only passed because the later Up x8 re-anchored it. Add `expect tv-focus=LABEL` (Screen::focusLabel(), Shell::focusLabel(); Settings names the group on the groups and the row in its list) and rewrite the walk so every step is asserted before the next one acts: open Settings, one Back, LB x3 to Home, RB x3 back (now on the groups either way), Up x4 reads Picture, Down x3 Interface, Right enters the list, Up x8 reads Show FPS, Down x4 reads Auto: in fullscreen, and only then Left / Right change a value. Also fix the Settings screen's comment: the Base layer resumes the remembered level, only the menu slot always starts on the groups. Co-Authored-By: Claude Opus 5.5 * feat(app): one-time TV mode hint on the first controller press in the library (#991) Under Manual nothing tells a new player how to reach TV mode, and the button differs per pad (Share hold, Guide hold, a paddle) and per OS. The first pad press the desktop library takes now shows a 6 s toast naming this pad's own TV-menu button and the keyboard's (tv::firstUseHint; the fixed Ctrl/Cmd+Shift+T stands in until two are known). The key ui.tv_hint_shown is saved before the toast and a per-run flag backs it, so it cannot show twice; Auto (whose switching press returns before the hint site) and Off never show it. A Settings reset of Interface clears the key, so it may show once more. The pad text uses the short names (Share, Guide) rather than Binding::display()'s "Share / Capture". The TV script gains expect toast=TEXT, toast-count=N and hintreset, and the Settings > Interface hint now says nightly and local builds start on Auto. Co-Authored-By: Claude Opus 5.5 * docs(app): TV mode section in the README (#991) Document what TV mode is, the three settings and where they live, the Auto rules, the build default (-DVJ_TV_DEFAULT: Auto for local and nightly builds, Manual for releases), the first-use hint, --tv, the 500 ms hold and the Guide / keypad-chord limitation. The existing key-table rows link to it. Co-Authored-By: Claude Opus 5.5 * fix(app): hint names the pad's own button; mouse verb sends real motion; Home copy for a library with games (#991) Review of Task 18 and the Studio windowed check found four things. The first-use hint named "Share" on every pad, but SDL's MISC1 is the DualSense mic and the Switch Pro Capture button, and Guide is PS / Home on those. tvMenuButtonName(family, binding) names it per family (the virtual pad keeps "Share (hold)"). Settings > Reset group on Interface now also clears the per-run flag, so the hint may show again as the README says. The windowed mouse check left TV mode on because SDL reports a warped pointer with xrel = yrel = 0, so the 32 px budget never accumulated (the product path was fine: the events reached TvMode::onMouseMove with zero travel). `mouse` now also pushes a MOUSE_MOTION carrying the travel, in headless runs too, and a headless block pins the handleEvent wiring. Under Auto a new player with 20 games landed on Home saying the library was empty. With games but no shelves Home now says "Nothing to continue yet. Press RB for your Library."; the old text stays for a truly empty one. The Off "no hint" check now proves the press reached the desktop nav. Co-Authored-By: Claude Opus 5.5 * fix(app): Home empty state without a per-frame library copy; no doubled "(hold)"; README Off wording (#991) TV Home with no shelves is the landing state of a new Auto user, and it asked the host for List::All every frame to find out whether any games exist, which builds a GameRef (several strings) per library entry. A Host::hasGames() answers it with no copy and an early exit. tvMenuButtonName appended " (hold)" to navButtonName(), whose fallback Binding::display() already adds it, so a rebound hold on a paddle read "Paddle P1 (hold) (hold)". It now names a non-hold copy and suffixes once. README: Off never turns TV mode on, but with a game running the TV-menu button still opens the in-game menu (spec 8). Co-Authored-By: Claude Opus 5.5 * docs(app): TV mode plan Task 16a closed, no layout fixes needed (#991) The owner tested phases 1-3 on a TV and reported no layout fixes, so the last open task of the plan is done and phase 4 completes #991. Co-Authored-By: Claude Opus 5.5 * fix(app): Settings opens the TV Settings screen while TV mode is on (#991) Every Settings entry (Ctrl/Cmd+Comma, the menu bar, the native macOS menu / tray and Cmd::Settings) set showSettings, the desktop window, even in TV mode, so a player in a game could not reach settings without the plain UI. They now go through App::openSettings(): TV mode on with no game switches the Base layer to the Settings section; with a game it opens the in-game menu (same pause / hardcore gate as the TV-menu action) with the Settings screen in its slot, so B returns to the menu root. TV mode off is unchanged. Ctrl/Cmd+Comma still closes an open desktop window (More settings...); in TV mode a second press leaves the TV screen as it is. The BIOS-tab entries stay desktop (the TV Settings has no BIOS tab). Co-Authored-By: Claude Opus 5.5 * fix(app): the TV menu opens without pausing when hardcore refuses; BIOS entries use the TV hand-off (#991) Settings must stay reachable mid-game, so when Achievements::allowPause refuses the in-game menu (the F4 action and the Settings route alike) opens anyway, like the netplay rule: the menu owns the pads, the game runs on, and the menu shows a 'Hardcore: the game keeps running' banner. menuResume restores the pad ownership as after a netplay menu. The refusal toast is dropped from this path (nothing refuses any more). The BIOS-tab entries (menu bar, native Cmd::BiosSettings) now go through App::openBiosSettings: with TV mode on it is the pad-driven More-settings hand-off (the menu first with a game), else the desktop window as before. Test seams: the 'command NAME' and 'hardcore-pause deny|allow' script verbs and the menu-owns-input expect. Co-Authored-By: Claude Opus 5.5 * test(app): TV Settings routes through the native commands, the Save page, hardcore and BIOS (#991) Native-menu path via the new 'command' verb (a synthesized ctrl+, is ignored in a real window), the menu root and Save page routes, the hardcore-refused menu (runs on, owns the pads, hands them back), command bios in TV mode, and smoke tests for Shell::openSettings and the hardcore banner. Co-Authored-By: Claude Opus 5.5 * docs(app): TV mode plan Phase 5, every setting and the Library in TV mode (#991) The owner found TV mode stops at the game: Settings and the Library open the desktop windows mid-game, and TV Settings lists a handful of rows with the core options hidden until a game has run. Phase 5 reads the core's option definitions at startup so every core option and every choice-like app setting has a TV row, and routes the Library through TV screens too (an in-game Library row; a launch asks first). Co-Authored-By: Claude Opus 5.5 * feat(app): core options readable at startup; TV Settings catalogue of every core option and app choice (#991) The owner could not see the game core's settings in TV mode: the option table only reached the app through CoreHost::startSession, so until a game had run the TV Settings hid the three curated core rows and said "Available once a game has run". CoreHost::probeOptions() runs retro_set_environment once at startup, before the emulation thread exists, answering only the option commands (everything else is refused), then the core's own display pass, so defs, categories and visibility are filled exactly as a load fills them. The core is then taken back to a fresh process's state with retro_deinit, the call every reload makes before its retro_set_environment: the display pass leaves its show_* latches at "hidden", and without the reset the first game's own display pass finds no change and every gated row (netlink host, voice chat, mouse and rotary tuning, CD-only keys...) stays visible. core_options_probe checks that the first load after a probe has the same visibility as the second. The TV Settings model is now a catalogue: SettingRow::group is a string key plus a label, coreOptionRows lists every visible core option by the core's categories (overridden = read-only, Host-kind typed keys read-only, "(custom)" for a value off the list, a restart note), sliderRow turns the desktop sliders into stepped lists with a "(custom)" entry for a finer value, and AppHost lists every choice, toggle and slider of the desktop tabs (Picture, Sound, Interface, Library, Achievements, Discord, Neuro) before the core's groups. The curated core rows, the speed note and the fixed four-group enum are gone. tv_settings.cpp only follows the new group type; the rail's scroll and the long-list focus are the next task. tv_menu_headless walks the longer lists with Down x20 (clamping) and a new test-only `settings-group KEY` verb that sets the shell's rail memory, so no count of rail items depends on the build. Co-Authored-By: Claude Opus 5.5 * fix(app): a "(custom)" setting value sits between its neighbours, so Left and Right step the right way (#991) A slider value off the list (the audio buffer's own default of 64 ms, an off-grid volume, dead zone, tile size) was appended last, so Left jumped to the end of the list and Right did nothing. sliderRow now inserts " (custom)" at its sorted position and stepValue maps indices past it back to the grid: 64 ms sits between 60 and 70. A typed numeric core option whose presets are in numeric order (a clock scale, a port) gets the same through coreCustomPos, which setSetting uses to map the stepped index back to the option's values; text, addresses and unordered presets keep the entry last. Also says in a comment why probeOptions drops the display callback: gating options do not show or hide dependent rows until the first game loads. Co-Authored-By: Claude Opus 5.5 * feat(app): TV Settings scrolls its rail, pages its list, and shows every group (#991) With the core's categories joining the app's tabs there are ~17 groups, and the rail and the in-game groups level drew every one at a fixed pitch, so the tail ran off the screen and the focus could land there. Both now scroll a whole item at a time (LinearFocus::first eased by the Phase 3 tween, snapped on the first draw), keep the focused item in view, show "more" arrows with room reserved for them, and draw no item that is wholly outside the window. The list scrolls the same way, so no row is cut mid-text at rest. LT / RT (PagePrev / PageNext) jump a screenful on the list, the rail and the in-menu groups level, and stop at the ends; a "Page" hint appears when the list scrolls. A held direction already repeats on the nav's timer, so Shell::repeatIntent() (NavInput::lastWasRepeat) lets the screen tell a repeat from a press where it matters: a held Left stops at a row's first choice instead of leaving for the rail, and a Right held from the rail enters the list without then stepping its first row. Test support: expect tv-group=KEY (Screen::focusGroup), an opt-in text position probe in the widgets, and a headless walk that steps the core's internal resolution with no game loaded, checks opt.* is saved and that the next game reads it. Co-Authored-By: Claude Opus 5.5 * feat(app): Library is a TV screen while TV mode is on; in-game Library row with a launch confirm (#991) While TV mode is on every Library entry point (Ctrl/Cmd+L, File > Game library, the native Cmd::Library) goes through App::openLibrary(): the Library section with no game, the in-game menu with the Library in its slot with one (the hardcore pause gate and its toast apply, as for Settings). The menu gains a "Library" row after Load state; the slot screen is wide() so the rail and cover grid draw over the dimmed game. Typing reaches the slot; Details and the letter picker (Base stack only) are not offered there. Launching from the slot asks first (Page::ConfirmLaunch, "Close and start ?", No / Yes, focus on No); Home, Library and Details launch through Shell::requestLaunch, which starts at once when no game runs. Yes goes through the existing library launch (the app auto-saves and closes the menu), then launched() arms the release guard. Script expects: menu-page=..., and tv-focus reads the in-game menu while it is open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(app): TV Library review fixes: no desktop library over TV mode at startup or on its on-edge; launch confirm rows match Reset/Close (#991) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(app): TV Settings and placeholder tiles wrap long text instead of cutting it Studio shots showed long setting labels cut with "..." (two Per-Title Enhancement rows read the same; notes lost their ends) and Library placeholder titles split mid-word or running off the tile. - Settings in the in-game menu's slot takes the whole panel (wide(), as the Library does) and names itself in a caption line; the label gets the row width less a value column sized by the widest choice, and wraps at words to two lines (a note too); every row of a group is as tall as the tallest so whole-row scrolling still holds. - widgets::wrapLines / textLines: word wrap with a last-line "..." cut at a word; the text probe still sees the whole text once. - Placeholder tiles wrap at words, drop to the 24 u floor before splitting a word, and sit at the foot with the "..." only on the last line that fits; the menu's Library uses tighter columns so "Cybermorph" fits whole, and waits to draw a row until it fits above the panel's foot. - Scroll arrows sit clear of the focus ring (arrow room 26 u); the groups level has its caption so its up arrow stays inside the list area. - The rail and the groups level list "Page" for LT / RT, joining only the glyphs that exist. - Core options already prefer desc_categorized (coreRow); the Per-Title rows have none in libretro_core_options.h, so wrapping is what tells them apart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(app): F8 does not pause the game behind the hardcore run-on menu While the menu runs on over a hardcore game (its banner says the game keeps running) a pause-decay countdown can end and F8 then paused the game the player cannot see, to be resumed as the menu closes. menuRunsOn_ is treated like menuHeldPause_: F8 is refused with the same toast. tv_menu_headless covers it (allow the pause while the menu is open, then F8) and `command bios` with no game (the window opens alone, B closes it). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(app): the launch confirm lists Yes above No Phase 5 item 8 said "rows No / Yes", and the test description said Down for Yes; the rows are Yes, No with the focus on No, so Yes is Up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- .github/workflows/app-package.yml | 17 +- app/CMakeLists.txt | 11 +- app/README.md | 42 +- app/cmake/VJTvTests.cmake | 17 + app/src/app.cpp | 135 ++- app/src/app.h | 23 +- app/src/core_host.cpp | 30 +- app/src/core_host.h | 7 + app/src/desktop.cpp | 14 +- app/src/desktop_nav_app.cpp | 17 + app/src/devices_ui.cpp | 4 + app/src/input.cpp | 18 + app/src/input.h | 5 + app/src/pad_profile.cpp | 13 + app/src/pad_profile.h | 5 + app/src/settings_reset_ui.cpp | 5 +- app/src/tv/tv_app_host.cpp | 291 ++++- app/src/tv/tv_app_host.h | 2 + app/src/tv/tv_details.cpp | 10 +- app/src/tv/tv_home.cpp | 23 +- app/src/tv/tv_host.h | 11 +- app/src/tv/tv_ingame.cpp | 23 +- app/src/tv/tv_library.cpp | 48 +- app/src/tv/tv_mode.cpp | 17 +- app/src/tv/tv_mode.h | 11 +- app/src/tv/tv_nav.cpp | 3 + app/src/tv/tv_nav.h | 4 + app/src/tv/tv_screen.h | 9 + app/src/tv/tv_settings.cpp | 318 ++++-- app/src/tv/tv_settings_model.cpp | 202 +++- app/src/tv/tv_settings_model.h | 66 +- app/src/tv/tv_shell.cpp | 110 +- app/src/tv/tv_shell.h | 37 +- app/src/tv/tv_widgets.cpp | 113 +- app/src/tv/tv_widgets.h | 17 + app/src/tv_script.cpp | 69 +- app/src/ui.cpp | 52 +- app/tests/core_options_probe_test.cpp | 166 +++ app/tests/pad_profile_test.cpp | 15 + app/tests/tv_menu_headless.txt.in | 1466 +++++++++++++++++-------- app/tests/tv_mode_test.cpp | 98 +- app/tests/tv_nav_test.cpp | 15 + app/tests/tv_settings_model_test.cpp | 250 ++++- app/tests/tv_ui_smoke.cpp | 1050 ++++++++++++++++-- docs/app-tv-mode-plan.md | 836 ++++++++++++-- 45 files changed, 4727 insertions(+), 968 deletions(-) create mode 100644 app/tests/core_options_probe_test.cpp diff --git a/.github/workflows/app-package.yml b/.github/workflows/app-package.yml index 33a8f353..addbed73 100644 --- a/.github/workflows/app-package.yml +++ b/.github/workflows/app-package.yml @@ -72,6 +72,9 @@ env: # DISCORD_APP_ID, when set, overrides it (each configure below passes it only when non-empty, so an unset # variable never turns the feature off). DISCORD_APP_ID: ${{ vars.DISCORD_APP_ID }} + # TV mode (#991): the setting a fresh config starts from. Releases (vX.Y.Z, prerelease) start on Manual; nightly, prNNN and dev + # builds on Auto. Each configure below passes it as VJ_TV_DEFAULT, the Flatpak through its manifest. + TV_DEFAULT: ${{ (startsWith(inputs.release_tag, 'v') || inputs.release_tag == 'prerelease') && 'manual' || 'auto' }} jobs: # ------------------------------------------------------------------------------------------ @@ -199,7 +202,7 @@ jobs: cmake -S app -B "build-$arch" -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_OSX_ARCHITECTURES="$arch" -DCMAKE_OSX_DEPLOYMENT_TARGET="$MIN_MACOS" \ -DVJ_APP_FETCH_LIBRASHADER=OFF -DVJ_LIBRASHADER_LIB="$PWD/librashader/librashader-$triple/librashader.dylib" \ - -DVJ_PACKAGE_VERSION="$SHORT_VERSION" \ + -DVJ_PACKAGE_VERSION="$SHORT_VERSION" -DVJ_TV_DEFAULT="$TV_DEFAULT" \ ${DISCORD_APP_ID:+-DVJ_DISCORD_APP_ID="$DISCORD_APP_ID"} cmake --build "build-$arch" --config Release -j4 cmake --install "build-$arch" --prefix "$PWD/stage-$arch" @@ -374,7 +377,7 @@ jobs: run: | set -eu cmake -S app -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \ - -DVJ_APP_WIN32_GUI=ON -DVJ_APP_FETCH_LIBRASHADER=ON -DVJ_PACKAGE_VERSION="$SHORT_VERSION" \ + -DVJ_APP_WIN32_GUI=ON -DVJ_APP_FETCH_LIBRASHADER=ON -DVJ_PACKAGE_VERSION="$SHORT_VERSION" -DVJ_TV_DEFAULT="$TV_DEFAULT" \ ${DISCORD_APP_ID:+-DVJ_DISCORD_APP_ID="$DISCORD_APP_ID"} \ ${{ matrix.cmake_extra }} grep -E 'librashader|CMAKE_SYSTEM_PROCESSOR|CMAKE_C_COMPILER:' build/CMakeCache.txt | head @@ -551,6 +554,14 @@ jobs: steps: - uses: actions/checkout@v7 + - name: Set the TV mode default in the manifest + # The manifest itself stays on CMake's default (Auto); a release package gets Manual here, like the other packages. + run: | + set -eu + sed -i "s|- -DVJ_APP_BUILD_TESTS=OFF|&\n - -DVJ_TV_DEFAULT=$TV_DEFAULT|" app/packaging/flatpak/io.github.libretro.VirtualJaguar.yml + # Fail here, not silently, if the sed did not match (the manifest's config-opts changed). + grep -qx " - -DVJ_TV_DEFAULT=$TV_DEFAULT" app/packaging/flatpak/io.github.libretro.VirtualJaguar.yml + - name: Build the bundle # flatpak/flatpak-github-actions v6.8 (pinned by commit). upload-artifact is OFF: its # own artifact would be named app-<arch>.flatpak and not app-package-*, and the release @@ -641,7 +652,7 @@ jobs: run: | set -eu cat librashader/BUILD-INFO.txt - cmake -S app -B build -DCMAKE_BUILD_TYPE=Release -DVJ_PACKAGE_VERSION="$SHORT_VERSION" \ + cmake -S app -B build -DCMAKE_BUILD_TYPE=Release -DVJ_PACKAGE_VERSION="$SHORT_VERSION" -DVJ_TV_DEFAULT="$TV_DEFAULT" \ -DVJ_LIBRASHADER_LIB="$PWD/librashader/librashader.so" \ ${DISCORD_APP_ID:+-DVJ_DISCORD_APP_ID="$DISCORD_APP_ID"} cmake --build build -j"$(nproc)" diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index 0a7d4059..60c3ab4c 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -62,6 +62,13 @@ set(VJ_DISCORD_APP_ID "1559739062682787870" CACHE STRING "Discord application ID if(NOT VJ_DISCORD_APP_ID STREQUAL "" AND NOT VJ_DISCORD_APP_ID MATCHES "^[0-9]+$") message(FATAL_ERROR "VJ_DISCORD_APP_ID must be the numeric application ID from the Discord developer portal (got '${VJ_DISCORD_APP_ID}')") endif() +# TV mode (#991): the setting a fresh config starts from (a stored ui.tv_mode wins; the key is written only when the user +# changes it, so this default can move between builds). app-package.yml always passes it: manual for release tags and +# prerelease, auto for nightlies, PR builds and dev; a plain configure gets the default below. +set(VJ_TV_DEFAULT "auto" CACHE STRING "TV mode setting for a fresh config: auto (nightlies, PR builds) | manual (releases) | off") +if(NOT VJ_TV_DEFAULT MATCHES "^(auto|manual|off)$") + message(FATAL_ERROR "VJ_TV_DEFAULT must be auto, manual or off (got '${VJ_TV_DEFAULT}')") +endif() option(VJ_APP_WERROR "Treat warnings in app/ sources as errors" OFF) option(VJ_APP_HTTP "Box art and database downloads (libcurl; WinHTTP on Windows). Off or missing: the library works from files and cached art" ON) option(VJ_APP_FREETYPE "Rasterise UI fonts with FreeType (hinting + anti-aliasing choices; fetched, pinned, built static). Off: Dear ImGui's built-in stb_truetype" ON) @@ -465,6 +472,7 @@ if(VJ_APP_NEURO) target_compile_definitions(virtualjaguar PRIVATE VJ_HAVE_NEURO=1) endif() message(STATUS "Discord Rich Presence application ID: ${VJ_DISCORD_APP_ID}") +message(STATUS "TV mode default for a fresh config: ${VJ_TV_DEFAULT}") if(WIN32) target_link_libraries(virtualjaguar PRIVATE iphlpapi ws2_32) # netplay_addr.cpp: this machine's addresses target_link_libraries(virtualjaguar PRIVATE shell32) # platform_windows.cpp: SHAddToRecentDocs, ShellExecuteW @@ -474,7 +482,8 @@ if(APPLE) endif() target_compile_definitions(virtualjaguar PRIVATE VJ_APP_VERSION="${PROJECT_VERSION}" - VJ_DISCORD_APP_ID="${VJ_DISCORD_APP_ID}") + VJ_DISCORD_APP_ID="${VJ_DISCORD_APP_ID}" + VJ_TV_DEFAULT_SETTING="${VJ_TV_DEFAULT}") if(MSVC) target_compile_options(virtualjaguar PRIVATE /W3 /utf-8) target_compile_definitions(virtualjaguar PRIVATE _CRT_SECURE_NO_WARNINGS) diff --git a/app/README.md b/app/README.md index 6778ca73..85dd1cea 100644 --- a/app/README.md +++ b/app/README.md @@ -30,6 +30,9 @@ ctest --test-dir app/build -C Release --output-on-failure # headless smoke tes The binary is `app/build/virtualjaguar` (`app/build/Release/virtualjaguar.exe` with the Visual Studio generator). +`-DVJ_TV_DEFAULT=auto|manual|off` (default `auto`) is the TV mode setting a fresh config starts on, so a local build starts on Auto +while release packages are built with `manual` (see [TV mode](#tv-mode)). + FreeType 2.14.1 is the default font rasteriser: it is fetched (pinned, SHA-256 checked), built static with every optional library off, so it needs no system libraries. `-DVJ_APP_FREETYPE=OFF` uses Dear ImGui's built-in stb_truetype instead (no hinting or anti-aliasing choices); offline builds use `-DFETCHCONTENT_SOURCE_DIR_FREETYPE=<dir>`. @@ -110,7 +113,7 @@ Loading another game while one is running does a full unload, `retro_deinit`, | number row / numpad, `-` `=` | Jaguar keypad 0-9, `*`, `#` | | A S D, Q E | Jaguar Pro Controller X Y Z and left / right shoulder (keypad 9 8 7, 4 6) | | F2 | keypad overlay | -| Ctrl/Cmd+Shift+T | toggle TV mode (View > TV mode), with or without a game; the game keeps running. Fixed, not rebindable | +| Ctrl/Cmd+Shift+T | toggle TV mode (View > TV mode), with or without a game; the game keeps running. Fixed, not rebindable (see [TV mode](#tv-mode)) | | F4, or a Share hold | the "TV menu / TV mode" action (rebindable): with a game it opens the in-game TV menu, without one it toggles TV mode | | F3 | controls overlay: a cheat sheet of your current bindings (also View menu) | | Esc / F10 | release the captured mouse; F10 or a click on the picture captures it again (see below) | @@ -174,13 +177,48 @@ and the BIOS and States tabs. Mouse and keyboard work as before. | LT / RT | previous / next initial letter | | Right stick | scroll the grid | | View / Back | focus the search box | -| Share or Guide, held | toggle TV mode | +| Share or Guide, held | toggle TV mode (see [TV mode](#tv-mode)) | The buttons follow the controller family, detected automatically: Xbox, PlayStation, Nintendo Switch (A on the right confirms, B on the bottom goes back) and MFi / generic (Xbox layout). A custom layout is under **Settings > Input**: pick one controller in the device list, then **Menu buttons** (a Family choice, and **Set** / **Default** per button). **Reset menu buttons** clears that controller's overrides. +### TV mode + +TV mode is a 10-foot look for a pad and a TV: large tiles and text, one focus ring, and a pad-driven Home, +Library, Achievements and Settings, plus an in-game menu (see the rows in the key table above). With a game running it +only changes which screen stands behind the game; the game keeps running. + +The **TV mode** setting has three values, under **Settings > Interface** (and **TV Settings > Interface** in TV mode itself): + +- **Off:** TV mode never turns on: F4, the Share hold, Ctrl/Cmd+Shift+T and `--tv` do nothing without a game. With a game running + the TV-menu button still opens the in-game menu. +- **Manual:** only when you ask: View > TV mode, Ctrl/Cmd+Shift+T, the TV-menu button (F4, or a hold of the pad's Share / Capture / Mic button, or of Guide on a pad + without one, with no game running) or `--tv`. The on/off state is remembered for the next start (`--tv` is not). +- **Auto:** also on its own, with two checkboxes you can untick, **Turn on in fullscreen** and **Turn on when a controller is used**. + +Auto in one paragraph: entering fullscreen turns TV mode on and leaving it turns it off; a controller press on a navigation +button (D-pad, face buttons, LB / RB, Start) in the library, with no game running, turns it on, and that press does nothing +else. A TV mode the controller started is handed back to the desktop by 32 px of mouse movement or by typing (a printable key +without Ctrl, Cmd or Alt; F-keys, Esc, Enter and shortcuts never count), unless fullscreen is holding it. Turning it on or off by +hand wins until the next fullscreen change or controller press. + +**Build default.** A fresh config starts on the build's default, which is Auto for a local build (the CMake default) and for +nightly builds and the other CI packages, and Manual for release packages; `-DVJ_TV_DEFAULT=auto|manual|off` sets it when +configuring. Nothing is stored until you change the setting in the UI, so a build can move its default under people who never +chose one; **Reset group** at the top of Settings > Interface returns to the build default. + +**First-use hint.** Under Manual, the first controller press in the desktop library shows a 6-second toast naming the buttons +for this pad, as the pad names the button ("Press Share (hold) or F4 for TV mode" on an Xbox pad; "Mic (hold)" on a DualSense, +"Capture (hold)" on a Switch Pro Controller, "Guide (hold)" on a pad without Share). It shows once per install +(`ui.tv_hint_shown`), never under Auto or Off, and **Reset group** on Settings > Interface shows it once more. + +**Starting and the hold.** `--tv` starts with TV mode on (when the setting is not Off). The TV-menu button on a pad is a hold +of 500 ms, so a short Share tap keeps opening the keypad overlay. Limit: Guide held opens the TV menu on a pad with no Share +button, and Guide is also the keypad's `#`, so a keypad chord that holds `#` (such as `*` + `#`) needs the keypad overlay, a pad +with Share, or that binding removed (the controls screen says so next to the binding). + ### Devices, players and the mouse **Settings > Input > Devices & players** (everywhere, or this game only; stored as diff --git a/app/cmake/VJTvTests.cmake b/app/cmake/VJTvTests.cmake index 86262b23..085e4800 100644 --- a/app/cmake/VJTvTests.cmake +++ b/app/cmake/VJTvTests.cmake @@ -27,6 +27,23 @@ endif() add_test(NAME tv_settings_model_unit COMMAND vj_tv_settings_model_test) set_tests_properties(tv_settings_model_unit PROPERTIES TIMEOUT 60 FAIL_REGULAR_EXPRESSION "FAIL ") +# The core's option table without a game (tests/core_options_probe_test.cpp, #991): CoreHost::probeOptions() fills it before any +# load, the display rules have run, and the first game after a probe sees the core as it would without one. Links the real core. +add_executable(vj_core_options_probe_test tests/core_options_probe_test.cpp src/core_host.cpp src/emu_thread.cpp src/paths.cpp) +target_include_directories(vj_core_options_probe_test PRIVATE src) +target_link_libraries(vj_core_options_probe_test PRIVATE vj_core ${VJ_SDL3_TARGET} Threads::Threads) +if(WIN32) + target_link_libraries(vj_core_options_probe_test PRIVATE ws2_32 iphlpapi) +endif() +if(MSVC) + target_compile_options(vj_core_options_probe_test PRIVATE /W3 /utf-8) + target_compile_definitions(vj_core_options_probe_test PRIVATE _CRT_SECURE_NO_WARNINGS) +else() + target_compile_options(vj_core_options_probe_test PRIVATE -Wall -Wextra -Wno-unused-parameter) +endif() +add_test(NAME core_options_probe COMMAND vj_core_options_probe_test "${VJ_ROMS}/yarc.j64") +set_tests_properties(core_options_probe PROPERTIES TIMEOUT 120 FAIL_REGULAR_EXPRESSION "FAIL ") + # Controller navigation (#1021): the pad-family menu-button profiles and the per-controller-model custom layer # (tests/pad_profile_test.cpp). Pure; tv_nav.cpp is linked for the parity check against tv::intentForButton. add_executable(vj_pad_profile_test tests/pad_profile_test.cpp src/pad_profile.cpp src/tv/tv_nav.cpp src/remap.cpp src/config.cpp src/paths.cpp) diff --git a/app/src/app.cpp b/app/src/app.cpp index 5bdbe55d..d39f3289 100644 --- a/app/src/app.cpp +++ b/app/src/app.cpp @@ -23,6 +23,12 @@ #include <filesystem> #include <thread> +// app/CMakeLists.txt's VJ_TV_DEFAULT defines this for the virtualjaguar target (the single source of the default). The fallback +// is for a build that compiles app.cpp outside CMake only; keep it equal to the option's default. +#ifndef VJ_TV_DEFAULT_SETTING +#define VJ_TV_DEFAULT_SETTING "auto" +#endif + namespace vj { namespace { @@ -211,18 +217,60 @@ void App::applySettingsFromConfig() { resumeMode = lib::clampResumeMode(cfg.getInt("saves.resume", lib::kResumeDefault)); input.stickAsDpad = cfg.getBool("input.stick_dpad", true); emuThreadPref = cfg.has("ui.emu_thread") ? (cfg.getBool("ui.emu_thread", false) ? 1 : 0) : -1; - tvSetting = tv::TvSetting::Manual; - tv::settingFromKey(cfg.get("ui.tv_mode", "manual"), tvSetting); // an unknown value stays Manual + // No stored ui.tv_mode: the build's default (Auto unless the package says otherwise). An unknown value does the same. + tvSetting = tv::defaultSetting(VJ_TV_DEFAULT_SETTING); + if (cfg.has("ui.tv_mode")) tv::settingFromKey(cfg.get("ui.tv_mode"), tvSetting); configureTvMode(); - if (tv::effectiveSetting(tvSetting) == tv::TvSetting::Manual && cfg.getBool("ui.tv_manual_on", false)) tvMode.setOn(true); + if (tvSetting == tv::TvSetting::Manual && cfg.getBool("ui.tv_manual_on", false)) tvMode.setOn(true); } void App::configureTvMode() { tv::TvConfig c; - c.setting = tv::effectiveSetting(tvSetting); // Auto acts as Manual until its triggers are wired up + c.setting = tvSetting; c.autoFullscreen = cfg.getBool("ui.tv_auto_fullscreen", true); c.autoController = cfg.getBool("ui.tv_auto_controller", true); tvMode.configure(c, fullscreen); + tvNoteFsSeen_ = fullscreen; // what TvMode now knows: tvFullscreenEdge() feeds it changes only +} + +void App::setTvSetting(tv::TvSetting s) { + const bool wasOn = tvMode.active(); + if (s != tvSetting) cfg.set("ui.tv_mode", tv::settingKey(s)); // on change only: a default that was never chosen stays free to move + tvSetting = s; + if (s == tv::TvSetting::Off) cfg.setBool("ui.tv_manual_on", false); // or a later Manual would switch TV mode on at once + configureTvMode(); // starts from off ... + if (wasOn) tvMode.setOn(true); // ... so a change of setting stays in TV mode; Off stays off (setOn is a no-op then) +} + +void App::setTvAuto(bool fullscreenTrigger, bool on) { + const bool wasOn = tvMode.active(); + cfg.setBool(fullscreenTrigger ? "ui.tv_auto_fullscreen" : "ui.tv_auto_controller", on); + configureTvMode(); + if (wasOn) tvMode.setOn(true); +} + +// The Auto controller trigger (spec 8, approvals A and B). Only a button with a menu intent counts: Guide, Share, the +// touchpad and the paddles carry app actions, and a stick or trigger drifts. While TV mode is on the press is the TV UI's +// (and a fresh mouse budget for a controller-started TV mode); while it is off and the desktop library owns the pad with no +// game loaded, it turns TV mode on and is consumed, so it neither moves the desktop focus nor acts on the TV screen it +// reveals. TvMode itself checks Auto and the "when a controller is used" box. +bool App::tvAutoPadPress(const SDL_Event& e) { + if (input.navProfile(e.gbutton.which).intentForButton(e.gbutton.button) == tv::Intent::None) return false; + if (tvMode.active()) { + tvMode.onPadPress(true); + return false; + } + const bool typing = imguiUp_ && ImGui::GetCurrentContext() && ImGui::GetIO().WantTextInput; + if (deskPadActive(*this) && !host.isLoaded() && !typing) tvMode.onPadPress(true); // a paused game under the library window stays the desktop's + return tvMode.active(); +} + +// TvMode re-applies a repeated onFullscreen(true) (it turns TV mode back on after a manual off), and macOS sends enter events +// for space and display changes: it is told of changes only. setFullscreen() and the two SDL events all land here. +void App::tvFullscreenEdge() { + if (fullscreen == tvNoteFsSeen_) return; + tvNoteFsSeen_ = fullscreen; + tvMode.onFullscreen(fullscreen); } void App::saveConfig() { @@ -240,8 +288,8 @@ void App::saveConfig() { if (!themeFromCli_) cfg.set("ui.theme", themePreviewing_ ? themeBeforePreview_ : theme::choiceKey()); cfg.setBool("ui.home_library", homeLibrary); // ui.tv_mode is never written here: a stored value would pin a default that is meant to move (Auto on nightlies, #991 - // Task 16) for everyone who ran this build. Only the TV Settings row writes it, on change (AppHost::setSetting). - if (tv::effectiveSetting(tvSetting) == tv::TvSetting::Manual && !tvFromCli_) cfg.setBool("ui.tv_manual_on", tvMode.active()); + // Task 16) for everyone who ran this build. Only setTvSetting() writes it, on change. + if (tvSetting == tv::TvSetting::Manual && !tvFromCli_) cfg.setBool("ui.tv_manual_on", tvMode.active()); cfg.setBool("saves.autosave", autosaveSram); cfg.setBool("ui.tray", trayIcon); cfg.setBool("ui.keep_awake", keepAwake); @@ -271,6 +319,7 @@ void App::addRecent(const std::string& path) { } void App::toast(const std::string& text, double seconds, int level) { + toastCount_++; Osd o; o.text = text; o.born = now(); @@ -1101,6 +1150,7 @@ void App::setFullscreen(bool fs) { fullscreen = fs; fullscreenMenu.update(fullscreen, fullscreenAutoHideMenu, menuPinned); if (window_) SDL_SetWindowFullscreen(window_, fs); + tvFullscreenEdge(); } // --------------------------------------------------------------------------------- @@ -1331,14 +1381,16 @@ void App::reset() { } void App::togglePause() { - if (menuHeldPause_) { // the TV menu holds the pause: a toggle here would be overwritten when it closes + if (menuHeldPause_ || menuRunsOn_) { // the TV menu holds the pause: a toggle here would be overwritten when it closes + // (Hardcore's run-on menu holds none, but its banner promises the game keeps running: a pause behind it would stall the game + // the player cannot see, and the menu would resume it as it closes.) toast("Close the menu to resume", 1.5); return; } if (!paused) { // Hardcore rations pausing (rc_client_can_pause); softcore never refuses. std::string why; - if (!ach.allowPause(why)) { + if (!pauseAllowed(why)) { toast(why, 2.5, 2); return; } @@ -1351,15 +1403,29 @@ void App::togglePause() { // The TV in-game menu's pause: the same gate and the same pause as togglePause(), without its toast, and it keeps // what was true before so that a game the player had paused stays paused when the menu closes. A netplay session -// keeps running (a pause would stall the other side), so there the menu only lays over the picture. +// keeps running (a pause would stall the other side), so there the menu only lays over the picture; hardcore's refusal to pause +// (rc_client_can_pause) opens the menu the same way, so Settings stays reachable mid-game: the menu owns the pads, the game runs on, and +// the menu's banner says so. `why` (the pause-decay countdown) is left unset: nothing toasts it. +bool App::pauseAllowed(std::string& why) { + if (testDenyPause) { + why = "Hardcore: pausing is available again in 3 s"; + return false; + } + return ach.allowPause(why); +} + bool App::menuPause(std::string& why) { - if (menuHeldPause_) return true; // already open + if (menuHeldPause_ || menuRunsOn_) return true; // already open if (netplay.configured()) { input.setMenuOwnsInput(true); // running on, but not for the pads: the menu has them return true; } if (!paused) { - if (!ach.allowPause(why)) return false; // hardcore rations pausing (rc_client_can_pause) + if (!pauseAllowed(why)) { // hardcore rations pausing (rc_client_can_pause): the menu opens anyway, over the running game + menuRunsOn_ = true; + input.setMenuOwnsInput(true); + return true; + } paused = true; audio.setPaused(true); pausedBeforeMenu_ = false; @@ -1373,6 +1439,7 @@ bool App::menuPause(std::string& why) { void App::menuResume() { input.setMenuOwnsInput(false); // before the caller arms the release guard + menuRunsOn_ = false; if (!menuHeldPause_) return; menuHeldPause_ = false; if (paused != pausedBeforeMenu_) { @@ -1383,6 +1450,26 @@ void App::menuResume() { tv::Shell& App::tvShell() { return *tvShell_; } +void App::openLibrary() { + if (tvMode.active()) tvShell().openLibrary(); // (as Settings: the in-game menu opens even when hardcore will not pause; a refused menu toasts and nothing opens) + else toggleLibrary(); +} + +void App::openSettings() { + if (tvMode.active()) tvShell().openSettings(); // (the in-game menu opens even when hardcore will not pause: the game runs on under it) + else showSettings = true; +} + +void App::openBiosSettings() { + devSelectTab = "BIOS"; + if (tvMode.active()) { + if (host.isLoaded() && !tvShell().menuOpen() && !tvShell().openMenu()) return; // the pads are the window's only over the menu + tvMoreSettingsOpen(*this); + } else { + showSettings = true; + } +} + void App::showOpenDialog() { if (!window_) return; g_filterPattern = joinExtensions(CoreHost::validExtensions()) + ";m3u"; // multi-disc playlists: the app reads them (m3u.h) @@ -1735,10 +1822,13 @@ void App::handleHotkey(const SDL_KeyboardEvent& k) { if (mod) reset(); break; case SDLK_COMMA: - if (mod) showSettings = !showSettings; + if (mod) { // a desktop window open (incl. "More settings...") closes; else Settings opens (TV mode: the TV screen, which a second press leaves be) + if (showSettings) showSettings = false; + else openSettings(); + } break; case SDLK_L: - if (mod) toggleLibrary(); + if (mod) openLibrary(); break; case SDLK_N: if (mod) newWindow(); @@ -1778,9 +1868,17 @@ void App::handleEvent(const SDL_Event& e, bool headless) { } input.feedHoldEdges(e); // before anything can consume the event, so a release is never lost tvHost_->noteInput(e); // which device the hints should name (the shell consumes events InputManager never sees) + if (e.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && tvAutoPadPress(e)) return; // Auto: this press turned TV mode on, and is only that if (tvMoreSettingsHandleEvent(*this, e, SDL_GetTicks())) return; // "More settings" is up: its window has the pads and keys, even over the in-game menu (tv_more_settings.h) if (tvShell().ownsInput() && !input.capturing() && tvShell().handleEvent(e, SDL_GetTicks())) return; // the TV UI has the pads and keys (not while a binding is being captured) - // (phase 4's Auto controller trigger, TvMode::onPadPress, goes before this call: desktop nav sees a press only when it did not switch TV on) + // The Auto triggers (#991): the mouse and the keyboard hand a controller-started TV mode back (the pad's half, tvAutoPadPress, runs + // before the shell: its presses are the shell's). Desktop nav below sees a press only when it did not switch TV mode on. + if (e.type == SDL_EVENT_MOUSE_MOTION && !input.mouseCaptured()) tvMode.onMouseMove(e.motion.xrel, e.motion.yrel); + if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat && !(e.key.mod & (SDL_KMOD_CTRL | SDL_KMOD_ALT | SDL_KMOD_GUI)) && + !(imguiUp_ && ImGui::GetCurrentContext() && ImGui::GetIO().WantCaptureKeyboard)) { + const SDL_Keycode k = SDL_GetKeyFromScancode(e.key.scancode, SDL_KMOD_NONE, false); + if (k >= 0x20 && k <= 0x7E) tvMode.onNonNavKey(); // a printable key the TV UI did not use (F-keys, Esc and shortcuts never count) + } if (deskPadHandleEvent(*this, e, SDL_GetTicks())) return; // the desktop library has the pad (desktop_nav_app.h) if (input.handleEvent(e)) return; switch (e.type) { @@ -1816,10 +1914,12 @@ void App::handleEvent(const SDL_Event& e, bool headless) { case SDL_EVENT_WINDOW_ENTER_FULLSCREEN: fullscreen = true; // also the green button / Ctrl+Cmd+F on macOS, which never go through setFullscreen() fullscreenMenu.update(fullscreen, fullscreenAutoHideMenu, menuPinned); + tvFullscreenEdge(); break; case SDL_EVENT_WINDOW_LEAVE_FULLSCREEN: fullscreen = false; fullscreenMenu.update(fullscreen, fullscreenAutoHideMenu, menuPinned); + tvFullscreenEdge(); break; case SDL_EVENT_USER: { platform::Cmd ncmd = platform::Cmd::None; @@ -2052,6 +2152,11 @@ int App::run(const CliArgs& a) { neuroInit(*this, a); #endif + // The core's option table, before any game and before the emulation thread exists (it publishes the table at once): + // the TV Settings and the desktop Core tab show every option from the first frame (#991). After loadUserOptions, + // wireHost (the lock) and the --option overrides, so the display rules read what the first game will. + host.probeOptions(); + int rc = a.headless ? (a.tvTestScript.empty() ? runHeadless(a) : runTvScript(a)) : runInteractive(a); saveConfig(); @@ -2123,7 +2228,7 @@ int App::runInteractive(const CliArgs& a) { ach.images.createTexture = &VideoOut::createTextureRGBA; // RetroAchievements avatar / icons / badges ach.images.destroyTexture = &VideoOut::destroyTexture; // With the home-screen library the boot screen already is the library; the floating window is for in-game use. - library.start(paths, cfg, !host.isLoaded() && !homeLibrary); + library.start(paths, cfg, !host.isLoaded() && !homeLibrary && !tvMode.active()); // (TV mode's Library is the TV screen, never this window) if (a.showWindow == "library") library.show = true; // dev: drive the TV menu from a script (a screenshot of it). A script that cannot start ends the run through the normal teardown. diff --git a/app/src/app.h b/app/src/app.h index 4a0bfa87..87883381 100644 --- a/app/src/app.h +++ b/app/src/app.h @@ -242,6 +242,8 @@ public: float uiScale_ = 1.0f; // the scale the style was set up at (the display's content scale times ui.scale) std::string themeBeforePreview_; // the choice to keep/restore while the theme editor previews an unsaved theme bool themePreviewing_ = false; + bool tvHintShownThisRun_ = false; // the one-time TV mode hint (ui.tv_hint_shown) was shown, or tried, in this run + unsigned toastCount_ = 0; // toast() calls since start (the TV script counts them: osd_ is capped) bool tvFromCli_ = false; // --tv was given: this run's TV state is not remembered (ui.tv_manual_on) bool themeFromCli_ = false; // --theme was given: this run's choice is not read from / written to config.ini (ui.theme) bool autosaveSram = true; @@ -276,21 +278,34 @@ public: bool homeVisible() const { return homeLibrary && !host.isLoaded() && !tvMode.active(); } // is the desktop library standing in for the game? (TV mode's screens stand in instead) bool desktopWindowOpen() const; // Settings, States, Cheats, About, the library window, the keypad overlay or any popup / menu is open (devices_ui.cpp) void newWindow(); // File > New Window (Ctrl/Cmd+N): another copy of the app (instance.h) - void toggleLibrary(); // Ctrl/Cmd+L: the library window, or focus the home screen's search box + void toggleLibrary(); // the desktop library window, or focus the home screen's search box (TV mode off) + void openLibrary(); // Ctrl/Cmd+L, File > Game library, Cmd::Library: the TV Library while TV mode is on (over the paused game, from the in-game menu, with one), else toggleLibrary() (#991) void reset(); void togglePause(); // The TV in-game menu's pause (tv/tv_app_host.cpp): like togglePause() but silent, and it remembers whether the // game was already paused, so closing the menu gives back exactly that. False = refused (hardcore rations pausing; // `why` says so). Under netplay it returns true without pausing: the link keeps running. - bool menuPause(std::string& why); + bool menuPause(std::string& why); // always true: hardcore's refusal (or netplay) opens the menu over a game that keeps running + bool menuRunsOn() const { return menuRunsOn_; } // ... because hardcore would not let the menu pause it (the banner says so) + bool testDenyPause = false; // tv_script `hardcore-pause deny`: allowPause() refuses, as hardcore does between pauses void menuResume(); tv::Shell& tvShell(); tv::TvMode tvMode; // TV mode (#991): when it is on bool tvMoreSettings_ = false; // the Settings window was opened by "More settings..." in TV mode (tv_more_settings.h) DesktopPad deskPad; // pad navigation of the desktop library (#1021, desktop_nav_app.h) - tv::TvSetting tvSetting = tv::TvSetting::Manual; // ui.tv_mode as stored; effectiveSetting() is what TvMode acts on + tv::TvSetting tvSetting = tv::TvSetting::Auto; // ui.tv_mode as stored, else the build default (tv::defaultSetting); TvMode acts on it as is + void openBiosSettings(); // the desktop Settings on the BIOS tab; with TV mode on, the pad-driven "More settings" hand-off (the menu first with a game) + void openSettings(); // Settings: the TV Settings screen while TV mode is on (the in-game menu with a game), else the desktop window (#991) void toggleTvMode() { tvMode.toggle(); } // View > TV Mode, F4 with no game; a no-op when the setting is Off void configureTvMode(); // tvMode.configure() from tvSetting + the config (also when Settings changes it) + // The one way to change the TV-mode settings (Settings > Interface, the TV Settings rows): each writes its key, reconfigures, + // and keeps TV mode on if it was on. Neither saves the config; the caller does. + void setTvSetting(tv::TvSetting s); // ui.tv_mode, written on change only (saveConfig never writes it); Off clears ui.tv_manual_on + void setTvAuto(bool fullscreenTrigger, bool on); // ui.tv_auto_fullscreen (true) / ui.tv_auto_controller (false) + bool tvAutoPadPress(const SDL_Event& e); // the Auto controller trigger: true = this press turned TV mode on and is consumed + void tvFullscreenEdge(); // tells TvMode `fullscreen` when it differs from tvNoteFsSeen_ (edges only) + bool tvSeenOn_ = false; // tvMode.active() as tvInputFrame last saw it (the on edge closes the desktop library window) + bool tvNoteFsSeen_ = false; // the fullscreen state TvMode was last told void setFullscreen(bool fs); void applyVsync(); void syncSwapInterval(); @@ -429,6 +444,8 @@ private: bool waitEventUntil(SDL_Event& e, uint64_t dueNs, Sint32 ms); void latchLog(); void latchUpdatePeriod(); + bool menuRunsOn_ = false; // the menu is open over a running game because allowPause() refused (netplay is its own case) + bool pauseAllowed(std::string& why); // ach.allowPause(), or the test seam bool menuHeldPause_ = false; // menuPause() took the game's pause (menuResume gives it back) bool pausedBeforeMenu_ = false; // ... and the game was already paused by the user std::unique_ptr<tv::AppHost> tvHost_; // the TV module's view of this App diff --git a/app/src/core_host.cpp b/app/src/core_host.cpp index c7daf68b..8d2457c0 100644 --- a/app/src/core_host.cpp +++ b/app/src/core_host.cpp @@ -485,7 +485,7 @@ void CoreHost::pushMessage(const std::string& text, uint32_t ms, int level) { // ---- environment --------------------------------------------------------------- // --verbose: say once per change when the core is handed a non-default value. void CoreHost::traceOption(const std::string& key, const char* value, const char* why) { - if (!logVerbose_) return; + if (!logVerbose_ || probing_) return; // a probe is not a load: the first real read says it std::string& last = optionTrace_[key]; if (last == value) return; last = value; @@ -493,6 +493,17 @@ void CoreHost::traceOption(const std::string& key, const char* value, const char } bool CoreHost::env(unsigned cmd, void* data) { + if (probing_) { // probeOptions(): the option commands only, everything else is refused (the core must not learn anything yet) + switch (cmd) { + case RETRO_ENVIRONMENT_GET_CORE_OPTIONS_VERSION: + case RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2: + case RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2_INTL: + case RETRO_ENVIRONMENT_SET_CORE_OPTIONS_UPDATE_DISPLAY_CALLBACK: + case RETRO_ENVIRONMENT_SET_CORE_OPTIONS_DISPLAY: + case RETRO_ENVIRONMENT_GET_VARIABLE: break; + default: return false; + } + } // Experimental flag bit is part of some command ids; compare masked. switch (cmd) { case RETRO_ENVIRONMENT_GET_LOG_INTERFACE: @@ -766,6 +777,23 @@ bool CoreHost::env(unsigned cmd, void* data) { } // ---- content ------------------------------------------------------------------- +// The environment half of a session, alone: retro_set_environment makes the core push its option table and install its +// display callback, whose one call hides the rows its gates hide (libretro allows both before retro_init). The core +// is then taken back to the state of a fresh process -- retro_deinit, the call every reload makes before its +// retro_set_environment -- because the display pass leaves its show_* latches at "hidden", and a game's own load would +// see no change and never hide those rows again. The first startSession then clears and re-pushes everything. +void CoreHost::probeOptions() { + if (noteOffOwner("probeOptions") || initialized_ || loaded_ || !defs_.empty()) return; + probing_ = true; + retro_set_environment(envCb); + if (updateDisplayCb_) updateDisplayCb_(); + probing_ = false; + retro_deinit(); + // Dropped as in stopSession. Until the first game loads a gating option (Network Link mode, voice chat...) therefore does not + // show or hide its dependent rows: keeping the callback would re-flip the latches retro_deinit just reset. + updateDisplayCb_ = nullptr; +} + bool CoreHost::startSession(std::string& err) { g_logVerbose = logVerbose_; // Tear down the previous game completely (the iOS path: statics are reset in diff --git a/app/src/core_host.h b/app/src/core_host.h index e321de1f..2deb7808 100644 --- a/app/src/core_host.h +++ b/app/src/core_host.h @@ -227,6 +227,12 @@ public: // `sessionOnly` = the --option overrides; false = the saved config.ini ones. std::vector<std::string> checkOverrides(bool sessionOnly) const; unsigned visibleOptionCount() const; + // Reads the core's option definitions without a game (#991): the table the settings screens show from the first + // frame. UI thread, once, before the emulation thread exists (App::run, after loadUserOptions and wireHost); + // a no-op once the options are known or while a session runs. Only the option commands are answered (everything else is + // refused), the core's display pass runs once, and the core is then reset to a fresh process's state (retro_deinit), so the + // first load re-pushes everything (startSession) and sees exactly what it would have without a probe. + void probeOptions(); // ---- memory (achievements) -------------------------------------------- // The map the core published with SET_MEMORY_MAPS (valid while content is @@ -333,6 +339,7 @@ private: bool inFrame_ = false; // inside retro_run() uint64_t eventsSeen_ = 0; bool initialized_ = false; // retro_init has run (and retro_deinit has not) + bool probing_ = false; // probeOptions() is inside retro_set_environment: env() answers the option commands only bool loaded_ = false; std::string contentPath_, contentName_; // the disc in the tray after a swap std::string loadedPath_, loadedName_; // what was opened diff --git a/app/src/desktop.cpp b/app/src/desktop.cpp index 58923375..660b112b 100644 --- a/app/src/desktop.cpp +++ b/app/src/desktop.cpp @@ -6,6 +6,7 @@ #include "platform/platform.h" #include "platform/viewports.h" #include "theme.h" +#include "tv/tv_shell.h" #include "ui_util.h" namespace vj { @@ -31,8 +32,8 @@ platform::MenuState App::menuState() { s.vsync = vsync; s.homeLibrary = homeLibrary; s.tvMode = tvMode.active(); - s.tvAvailable = tv::effectiveSetting(tvSetting) != tv::TvSetting::Off; - s.libraryOpen = homeVisible() || library.show; + s.tvAvailable = tvSetting != tv::TvSetting::Off; + s.libraryOpen = homeVisible() || library.show || (tvMode.active() && tvShell().showingLibrary()); if (s.gameLoaded) { DiscInfo d = host.discInfo(); s.disc.available = d.available; @@ -80,12 +81,9 @@ void App::runNativeCommand(platform::Cmd cmd, int arg) { } void bootBiosCd() override { a.bootBios(bios::Cd); } void bootBiosCart() override { a.bootBios(bios::CartBoot); } - void showBiosSettings() override { - a.showSettings = true; - a.devSelectTab = "BIOS"; - } + void showBiosSettings() override { a.openBiosSettings(); } void newWindow() override { a.newWindow(); } - void toggleLibrary() override { a.toggleLibrary(); } + void toggleLibrary() override { a.openLibrary(); } // (the TV Library while TV mode is on) void closeGame() override { a.closeContent(); } void revealGame() override { platform::revealFile(a.host.contentPath()); } void quit() override { a.quit = true; } @@ -124,7 +122,7 @@ void App::runNativeCommand(platform::Cmd cmd, int arg) { } void toggleHomeLibrary() override { a.homeLibrary = !a.homeLibrary; } void toggleTvMode() override { a.toggleTvMode(); } - void showSettings() override { a.showSettings = true; } + void showSettings() override { a.openSettings(); } void toggleMute() override { a.mute = !a.mute; a.applyAudioSettings(); diff --git a/app/src/desktop_nav_app.cpp b/app/src/desktop_nav_app.cpp index 161e1bba..8dcd4d4b 100644 --- a/app/src/desktop_nav_app.cpp +++ b/app/src/desktop_nav_app.cpp @@ -24,6 +24,22 @@ bool deskPadActive(App& app) { return !deskForeignPopupOpen(app.deskPad.padPopup); } +// The one-time TV mode hint (approval E): the first pad press the desktop library takes under Manual says how to reach TV mode, +// in this pad's own words. Stored before it shows, and flagged for the run, so it can never show twice; Auto and Off never see it +// (Auto's switching press returned before getting here). A Settings > Reset of Interface clears the key and it may show once more. +static void tvMaybeShowHint(App& app, SDL_JoystickID pad) { + if (app.tvSetting != tv::TvSetting::Manual || app.tvHintShownThisRun_ || app.cfg.getBool("ui.tv_hint_shown", false)) return; + app.tvHintShownThisRun_ = true; + app.cfg.setBool("ui.tv_hint_shown", true); + app.saveConfig(); +#ifdef __APPLE__ + const bool macOS = true; +#else + const bool macOS = false; +#endif + app.toast(tv::firstUseHint(app.input.tvMenuBindingText(pad), app.input.tvMenuKeyText(), macOS), 6.0); +} + bool deskPadHandleEvent(App& app, const SDL_Event& e, uint64_t ms) { DesktopPad& d = app.deskPad; switch (e.type) { @@ -59,6 +75,7 @@ bool deskPadHandleEvent(App& app, const SDL_Event& e, uint64_t ms) { d.seq++; } d.in.padIntent(e.gbutton.which, e.gbutton.button, in, down, ms); + if (down) tvMaybeShowHint(app, e.gbutton.which); return down; // a release is fed (it ends a repeat, or fires a tap) but still reaches the app } case SDL_EVENT_GAMEPAD_AXIS_MOTION: diff --git a/app/src/devices_ui.cpp b/app/src/devices_ui.cpp index d4f4b71c..89987065 100644 --- a/app/src/devices_ui.cpp +++ b/app/src/devices_ui.cpp @@ -66,6 +66,10 @@ void tvInputFrame(App& app, uint64_t ms) { if (app.host.isLoaded()) app.tvShell().toggleMenu(); else app.toggleTvMode(); } + // TV mode just came on (any trigger: the toggle, Auto's fullscreen / pad press, --tv, the remembered Manual state): its Library is the + // TV screen, so the desktop library window does not stay open over it. + if (app.tvMode.active() && !app.tvSeenOn_) app.library.show = false; + app.tvSeenOn_ = app.tvMode.active(); if (app.tvShell().menuOpen() && !app.host.isLoaded()) app.tvShell().closeMenu(); // the game went away under it app.tvShell().noteCapture(in.capturing()); // a capture start / end forgets what the nav thinks is held app.tvShell().frame(ms); // the nav intents gathered since the last frame diff --git a/app/src/input.cpp b/app/src/input.cpp index 4cbe0b57..eedd3750 100644 --- a/app/src/input.cpp +++ b/app/src/input.cpp @@ -283,6 +283,24 @@ void InputManager::feedHoldEdges(const SDL_Event& e) { } } +std::string InputManager::tvMenuBindingText(SDL_JoystickID id) const { + for (const Pad& p : pads_) { + if (p.id != id) continue; + const std::vector<Binding>& v = p.profile.binds[C_TVMENU]; + for (int pass = 0; pass < 2; pass++) // hold bindings first: a hold is the one the default pad uses + for (const Binding& b : v) + if (b.kind == Binding::PadButton && b.hold == (pass == 0)) return tvMenuButtonName(navProfile(id).family, b); + return ""; + } + return ""; +} + +std::string InputManager::tvMenuKeyText() const { + for (const Binding& b : kbProfile_.binds[C_TVMENU]) + if (b.kind == Binding::Key && !b.hold) return b.display(); + return ""; +} + void InputManager::tickHolds(uint64_t ms, bool live) { holds_.setLive(live); if (holds_.takeFires(ms) > 0) tvMenu_ = true; diff --git a/app/src/input.h b/app/src/input.h index 5564b9b9..b0be1ec0 100644 --- a/app/src/input.h +++ b/app/src/input.h @@ -100,6 +100,11 @@ public: bool takeHelpToggle() { bool h = helpToggle_; helpToggle_ = false; return h; } // The TV-menu button was pressed (plain binding) or held (a hold binding reached kHoldMs) since the last call. bool takeTvMenuToggle() { bool t = tvMenu_; tvMenu_ = false; return t; } + // What to press for the TV menu, as a person reads it (the first-use hint): that pad's first C_TVMENU binding, hold bindings + // first ("Share (hold)", "Guide (hold)", "Paddle P1"), and the keyboard profile's first plain C_TVMENU key ("F4"). "" = none + // bound, or (the pad) an id that is not open. + std::string tvMenuBindingText(SDL_JoystickID id) const; + std::string tvMenuKeyText() const; // ---- dual-use buttons (hold_arbiter.h) ----------------------------------------------------- // Every UI frame, also while paused: the holds that reached the threshold fire the TV-menu toggle. `live` = a diff --git a/app/src/pad_profile.cpp b/app/src/pad_profile.cpp index 2a433833..2a8fec8a 100644 --- a/app/src/pad_profile.cpp +++ b/app/src/pad_profile.cpp @@ -250,6 +250,19 @@ std::string navButtonName(PadFamily f, const Binding& b) { return b.display(); } +std::string tvMenuButtonName(PadFamily f, const Binding& b) { + const bool ps = f == PadFamily::PlayStation, sw = f == PadFamily::Switch; + std::string s; + if (b.kind == Binding::PadButton && b.code == SDL_GAMEPAD_BUTTON_MISC1) s = sw ? "Capture" : ps ? "Mic" : "Share"; + else if (b.kind == Binding::PadButton && b.code == SDL_GAMEPAD_BUTTON_GUIDE) s = sw ? "Home" : ps ? "PS" : "Guide"; + else { + Binding plain = b; // (Binding::display(), navButtonName's fallback, appends " (hold)" itself: name it plain, suffix once) + plain.hold = false; + s = navButtonName(f, plain); + } + return b.hold ? s + " (hold)" : s; +} + std::string padButtonLabel(SDL_Gamepad* g, const NavProfile& p, tv::Intent in) { const Binding bd = p.binding(in); if (bd.kind == Binding::None) return std::string(); diff --git a/app/src/pad_profile.h b/app/src/pad_profile.h index 2f865fa0..eddef517 100644 --- a/app/src/pad_profile.h +++ b/app/src/pad_profile.h @@ -75,6 +75,11 @@ void clearNavProfile(Config& cfg, const std::string& guid); // erases every ke // label), shoulders, triggers, START and BACK. Anything else is Binding::display(). std::string navButtonName(PadFamily f, const Binding& b); +// A TV-menu binding as the family's pad names the button, " (hold)" when it is a long press (the first-use hint): SDL's MISC1 is the +// Xbox Share button, the DualSense microphone button and the Switch Pro Capture button; Guide is PS / Home on those two. +// Anything else is navButtonName(). +std::string tvMenuButtonName(PadFamily f, const Binding& b); + // The caption for the button `p` gives `in`: a face button as SDL labels it on the open pad `g` (an 8BitDo in Switch mode says // A/B/X/Y, a DualSense Cross/Circle/...), else navButtonName() for the family; "" when the profile leaves the intent unbound. // `g` may be null (the pad is gone, or no handle is known). diff --git a/app/src/settings_reset_ui.cpp b/app/src/settings_reset_ui.cpp index 9737b787..d9a5a542 100644 --- a/app/src/settings_reset_ui.cpp +++ b/app/src/settings_reset_ui.cpp @@ -28,7 +28,10 @@ void syncToConfig(App& app) { app.saveConfig(); } void applyAfterErase(App& app, const std::vector<KeyClass>& touched) { app.applySettingsFromConfig(); - if (has(touched, KeyClass::Interface)) app.library.resetDisplaySettings(); + if (has(touched, KeyClass::Interface)) { + app.library.resetDisplaySettings(); + app.tvHintShownThisRun_ = false; // ui.tv_hint_shown was erased with the group: the one-time TV mode hint may show again + } if (has(touched, KeyClass::Shader)) { app.video.shader.clearPreset(); // the shader switch and preset came back from the config; drop the loaded chain app.video.shader.retry(); diff --git a/app/src/tv/tv_app_host.cpp b/app/src/tv/tv_app_host.cpp index 090ea2ec..4efb30bb 100644 --- a/app/src/tv/tv_app_host.cpp +++ b/app/src/tv/tv_app_host.cpp @@ -8,6 +8,10 @@ #include "app.h" #include "aspect_choices.h" +#include "devices.h" +#include "devices_ui.h" +#include "platform/platform.h" +#include "typed_options.h" #include "ra_dash.h" #include "shaders_ui.h" #include "theme.h" @@ -22,6 +26,7 @@ std::string AppHost::gameTitle() const { return app_.host.contentName(); } bool AppHost::menuPause(std::string& why) { return app_.menuPause(why); } void AppHost::menuResume() { app_.menuResume(); } bool AppHost::netplayActive() const { return app_.netplay.configured(); } +bool AppHost::menuRunsOn() const { return app_.menuRunsOn(); } // With a game running: what the achievements policy says. With none, hardcoreActive() is always false (no game is being // looked up), so ask whether hardcore would apply to the next one: a state load started from Home is refused then. bool AppHost::canLoadState() const { return app_.host.isLoaded() ? app_.ach.allowStateLoad() : !app_.ach.hardcoreNextGame(); } @@ -68,7 +73,7 @@ void AppHost::clearHolds() { app_.input.clearHolds(); } void AppHost::toast(const std::string& text) { app_.toast(text, 2.5, 2); } bool AppHost::tvActive() const { return app_.tvMode.active(); } -bool AppHost::tvAvailable() const { return effectiveSetting(app_.tvSetting) != TvSetting::Off; } +bool AppHost::tvAvailable() const { return app_.tvSetting != TvSetting::Off; } void AppHost::setTvActive(bool on) { if (on != app_.tvMode.active()) app_.toggleTvMode(); // a no-op when the setting is Off @@ -152,6 +157,12 @@ std::vector<GameRef> AppHost::games(List l) { return out; } +bool AppHost::hasGames() const { + for (const lib::Entry& e : app_.library.entries()) + if (e.section != lib::Section::Bios) return true; + return false; +} + Art AppHost::art(const GameRef& g) { const lib::Entry* e = entryFor(g); return e ? artOf(app_.library.art(*e)) : Art(); @@ -196,23 +207,28 @@ void AppHost::setFavorite(const GameRef& g, bool on) { namespace { -// The three core options on the TV list: the row id, the option key, the label, the group. -struct CoreRow { - const char* id; - const char* key; - const char* label; - int group; -}; -const CoreRow kResolution = {"resolution", "virtualjaguar_internal_resolution", "Internal resolution", 0}; -const CoreRow kRiscClock = {"risc_clock", "virtualjaguar_risc_clock_scale", "RISC overclock", 1}; -const CoreRow kIdleSkip = {"idle_skip", "virtualjaguar_risc_idle_skip", "Idle-loop skip", 1}; - -const OptionDef* defFor(const CoreHost& host, const char* key) { +const OptionDef* defFor(const CoreHost& host, const std::string& key) { for (const OptionDef& d : host.optionDefs()) if (d.key == key) return &d; return nullptr; } +constexpr float kDeadZoneLo = 0, kDeadZoneHi = 40, kDeadZoneStep = 5; +constexpr float kBufferLo = 20, kBufferHi = 200, kBufferStep = 10; +constexpr float kPauseLo = 0, kPauseHi = 10, kPauseStep = 0.5f; +constexpr float kTileStep = 0.1f; + +// Where a device setting is written: the game's own scope when it already overrides the key (the desktop's "This game only"), +// else everywhere -- so the row's value is the one the change takes effect in. +std::string deviceScope(App& app, const char* key) { + const std::string game = app.input.content(); + return !game.empty() && DeviceSettings::defined(app.cfg, game, key) ? game : std::string(); +} + +SettingRow pictureRow(const char* id, const char* label, bool on) { return onOffRow(id, label, "picture", "Picture", on); } +SettingRow interfaceRow(const char* id, const char* label, bool on) { return onOffRow(id, label, "interface", "Interface", on); } +SettingRow libraryRow(const char* id, const char* label, bool on) { return onOffRow(id, label, "library", "Library", on); } + } // namespace // The custom themes the folder holds; read again once a second (Settings > Appearance does the same: a theme can be added by @@ -226,42 +242,125 @@ const std::vector<std::string>& AppHost::themeNames() { return themeNames_; } -// Only the rows that exist: no shader runtime, or no core option definitions yet (no game has run this session), or an -// option the app locks, means no row -- never a greyed one. The one exception is the Speed group before a game has run: its -// only row is the read-only "Available once a game has run" line, so the group is there and says why. +// Every setting that is a choice, a toggle or a slider on the desktop (the plan's catalogue), grouped as the rail shows them: +// Picture, Sound, Interface, Library, Achievements, Discord, Neuro, then the core's own categories. Only the rows that exist: +// no shader runtime, no RetroAchievements client or Discord application means no row -- never a greyed one. A core option the +// core hides (or the app locks) is no row either. Settings that need text (folders, BIOS files, sign-in, addresses) are the +// "Desktop settings..." row's. std::vector<SettingRow> AppHost::settingRows() { CoreHost& host = app_.host; ShaderChain& sc = app_.video.shader; std::vector<SettingRow> rows; - auto core = [&](const CoreRow& cr) { - const OptionDef* def = defFor(host, cr.key); - bool hidden = false; - SettingRow r = coreOptionRow(cr.id, cr.label, cr.group, def, def ? host.optionValue(*def) : std::string(), host.optionVisible(cr.key), - host.optionLocked(cr.key), host.optionOverridden(cr.key), &hidden); - if (!hidden) rows.push_back(std::move(r)); - }; + auto add = [&](SettingRow r) { rows.push_back(std::move(r)); }; + + // ---- Picture sc.probeLibrary(); // cached after the first call (the desktop Shader section probes the same way) - if (ShaderChain::builtWithHeaders() && sc.libraryLoaded()) rows.push_back(shaderRow(sc.preset, sc.enabled, shaderPresetPaths(app_))); - rows.push_back(onOffRow("integer", "Integer scaling", 0, app_.present.integerScale)); - rows.push_back(aspectRow(app_.present.aspectOverride, kAspectChoices, kAspectChoiceCount)); - core(kResolution); - core(kRiscClock); - core(kIdleSkip); - if (!defFor(host, kRiscClock.key) && !defFor(host, kIdleSkip.key)) rows.push_back(speedNoteRow()); // no game has run: say why the group is empty - rows.push_back(volumeRow(app_.volume)); - rows.push_back(onOffRow("fps", "Show FPS", 3, app_.showFps)); - rows.push_back(onOffRow("vsync", "VSync", 3, app_.vsync)); - rows.push_back(themeRow(theme::choiceKey(), themeNames())); - rows.push_back(tvModeRow(app_.tvSetting)); - rows.push_back(moreRow()); + if (ShaderChain::builtWithHeaders() && sc.libraryLoaded()) add(shaderRow(sc.preset, sc.enabled, shaderPresetPaths(app_))); + add(pictureRow("integer", "Integer scaling", app_.present.integerScale)); + add(pictureRow("smooth", "Smooth (bilinear) filter", app_.present.linearFilter)); + add(pictureRow("stretch", "Stretch to window (ignore aspect ratio)", app_.present.stretch)); + add(aspectRow(app_.present.aspectOverride, kAspectChoices, kAspectChoiceCount)); + add(pictureRow("vsync", "VSync", app_.vsync)); + add(pictureRow("fullscreen", "Fullscreen", app_.fullscreen)); + + // ---- Sound + add(volumeRow(app_.volume)); + add(onOffRow("mute", "Mute", "sound", "Sound", app_.mute)); + add(sliderRow("audio_buffer", "Audio buffer", "sound", "Sound", app_.audioTargetMs, kBufferLo, kBufferHi, kBufferStep, "%.0f ms")); + + // ---- Interface + add(interfaceRow("fps", "Show FPS", app_.showFps)); + add(interfaceRow("controls_at_start", "Show the controls overlay when a game starts", app_.cfg.getBool("ui.controls.autoshow", false))); + add(themeRow(theme::choiceKey(), themeNames())); + add(tvModeRow(app_.tvSetting)); + for (SettingRow& r : tvAutoRows(app_.tvSetting, app_.cfg.getBool("ui.tv_auto_fullscreen", true), + app_.cfg.getBool("ui.tv_auto_controller", true))) + add(std::move(r)); + if (platform::nativeMenuActive()) add(interfaceRow("menu_in_window", "Show the menu bar inside the window", app_.showInWindowMenu)); + add(interfaceRow("menu_autohide", "Auto-hide the menu bar in fullscreen", app_.fullscreenAutoHideMenu)); + { + SettingRow r = interfaceRow("windows_outside", "Let windows move outside the main window", app_.windowsOutside); + r.note = app_.viewportsNote(); // why it is ignored right now, or "" + add(std::move(r)); + } + add(interfaceRow("keep_awake", "Keep the display awake while a game is running", app_.keepAwake)); + add(interfaceRow("tray", "Show a tray icon", app_.trayIcon)); + { + SettingRow r = interfaceRow("emu_thread", "Emulation thread", app_.emuThreadWanted()); + if (app_.emuThreadWanted() != app_.emuThreadThisRun()) r.note = "Restart the app to apply"; + add(std::move(r)); + } + { + const DeviceSettings ds = DeviceSettings::load(app_.cfg, app_.input.content()); + add(sliderRow("dead_zone", "Stick dead zone", "interface", "Interface", (float)ds.deadzonePct, kDeadZoneLo, kDeadZoneHi, kDeadZoneStep, "%.0f %%")); + add(choiceRow("mouse_port", "Mouse drives", "interface", "Interface", {"Automatic", "Jaguar port 1", "Jaguar port 2"}, + std::clamp(ds.mousePort + 1, 0, 2))); + add(choiceRow("gun_source", "Light gun aims with", "interface", "Interface", {"Automatic", "Mouse only", "Stick / keys only"}, + std::clamp(ds.gunSource, 0, 2))); + } + add(interfaceRow("flush_saves", "Flush saves every few seconds", app_.autosaveSram)); + add(moreRow()); + + // ---- Library + { + std::vector<std::string> modes, shows; + for (int m = 0; m < lib::kResumeModeCount; m++) modes.push_back(lib::resumeModeLabel(m)); + for (int m = 0; m < lib::kShowAsCount; m++) shows.push_back(lib::showAsLabel(m)); + add(choiceRow("resume", "Resume where I left off", "library", "Library", modes, std::clamp(app_.resumeMode, 0, lib::kResumeModeCount - 1))); + add(libraryRow("home_library", "Show the game library on the home screen", app_.homeLibrary)); + add(choiceRow("show_as", "Favorites show as", "library", "Library", shows, std::clamp(app_.library.favoritesShow, 0, lib::kShowAsCount - 1))); + add(choiceRow("show_as_recent", "Recently played shows as", "library", "Library", shows, + std::clamp(app_.library.recentsShow, 0, lib::kShowAsCount - 1))); + add(sliderRow("tile_size", "Tile size", "library", "Library", app_.library.cellScale, lib::kTileScaleMin, lib::kTileScaleMax, kTileStep, "%.1fx")); + add(libraryRow("download_art", "Download box art", app_.library.downloadArt.load())); + add(libraryRow("close_on_launch", "Close the library on launch", app_.library.hideOnLaunch)); + } + + // ---- Achievements (a build without the client has nothing to set) + if (app_.ach.available()) add(onOffRow("ra_images", "Download pictures", "achievements", "Achievements", app_.ach.imagesWanted())); + + // ---- Discord (no application ID, or a second copy of the app, publishes nothing) + if (app_.discordPresence.available()) { + add(onOffRow("discord_enabled", "Show what I'm playing on Discord", "discord", "Discord", app_.cfg.getBool("discord.enabled", true))); + add(onOffRow("discord_art", "Show game artwork", "discord", "Discord", app_.cfg.getBool("discord.art", true))); + add(onOffRow("discord_buttons", "Show buttons", "discord", "Discord", app_.cfg.getBool("discord.buttons", true))); + } + +#ifdef VJ_HAVE_NEURO + add(onOffRow("neuro_enabled", "Connect to Neuro", "neuro", "Neuro", app_.cfg.getBool("neuro.enabled", false))); + add(onOffRow("neuro_auto_force", "Ask for the next move when the last one is done", "neuro", "Neuro", app_.cfg.getBool("neuro.auto_force", true))); + add(sliderRow("neuro_interval", "Pause between moves", "neuro", "Neuro", app_.cfg.getFloat("neuro.force_interval", 1.0f), kPauseLo, kPauseHi, kPauseStep, "%.1f s")); +#endif + + // ---- the core's own groups (read at startup: CoreHost::probeOptions) + for (SettingRow& r : coreOptionRows( + host.optionCategories(), host.optionDefs(), [&](const OptionDef& d) { return host.optionValue(d); }, + [&](const std::string& k) { return host.optionVisible(k); }, [&](const std::string& k) { return host.optionOverridden(k); }, + [](const std::string& k) { return typedOptionFor(k); })) + rows.push_back(std::move(r)); return rows; } // What the desktop control does for each row, then the config is saved. An index the row cannot have (the "(custom)" entry -// of a typed option, a theme whose file is gone) changes nothing. +// of a typed option or a slider, a theme whose file is gone) changes nothing. void AppHost::setSetting(const std::string& id, int index) { CoreHost& host = app_.host; - if (id == "shader") { + const bool on = index != 0; + auto deviceChanged = [&] { // devices_ui.cpp's deviceChanged (the config is saved below) + app_.input.reresolve(); + applyPortDevices(app_); + }; + if (id.rfind("core:", 0) == 0) { + const std::string key = id.substr(5); + const OptionDef* def = defFor(host, key); + // The call drawOption makes: the app's own hardcore handling (Achievements::checkSettings) sees it as any other change. + if (def && host.optionVisible(key) && !host.optionOverridden(key) && index >= 0) { + // The row may carry a "(custom)" entry among the values (coreCustomPos): that index changes nothing, the ones after it shift. + const int custom = coreCustomPos(*def, host.optionValue(*def), typedOptionFor(key)); + const int at = custom >= 0 && index > custom ? index - 1 : index; + if (index != custom && at < (int)def->values.size()) host.setOptionValue(key, def->values[(size_t)at].value); + } + } else if (id == "shader") { ShaderChain& sc = app_.video.shader; const std::vector<std::string> paths = shaderPresetPaths(app_); if (index == 0) { @@ -271,35 +370,115 @@ void AppHost::setSetting(const std::string& id, int index) { applyShaderPreset(app_, paths[(size_t)index - 1]); } } else if (id == "integer") { - app_.present.integerScale = index != 0; + app_.present.integerScale = on; + } else if (id == "smooth") { + app_.present.linearFilter = on; + } else if (id == "stretch") { + app_.present.stretch = on; } else if (id == "aspect") { if (index >= 0 && index < kAspectChoiceCount) app_.present.aspectOverride = kAspectChoices[index].value; - } else if (id == "resolution" || id == "risc_clock" || id == "idle_skip") { - const CoreRow& cr = id == "resolution" ? kResolution : id == "risc_clock" ? kRiscClock : kIdleSkip; - const OptionDef* def = defFor(host, cr.key); - // The call drawOption makes: the app's own hardcore handling (Achievements::checkSettings) sees it as any other change. - if (def && !host.optionOverridden(cr.key) && index >= 0 && index < (int)def->values.size()) - host.setOptionValue(cr.key, def->values[(size_t)index].value); + } else if (id == "vsync") { + app_.vsync = on; + app_.applyVsync(); + } else if (id == "fullscreen") { + app_.setFullscreen(on); } else if (id == "volume") { - app_.volume = (float)std::clamp(index, 0, 10) / 10.0f; + app_.volume = stepValue(volumeRow(app_.volume), index, 0.0f, 10.0f, app_.volume * 100.0f) / 100.0f; + app_.applyAudioSettings(); + } else if (id == "mute") { + app_.mute = on; + app_.applyAudioSettings(); + } else if (id == "audio_buffer") { + const SettingRow r = sliderRow(id, "", "sound", "Sound", app_.audioTargetMs, kBufferLo, kBufferHi, kBufferStep, "%.0f"); + app_.audioTargetMs = stepValue(r, index, kBufferLo, kBufferStep, app_.audioTargetMs); app_.applyAudioSettings(); } else if (id == "fps") { - app_.showFps = index != 0; - } else if (id == "vsync") { - app_.vsync = index != 0; - app_.applyVsync(); + app_.showFps = on; + } else if (id == "controls_at_start") { + app_.cfg.setBool("ui.controls.autoshow", on); } else if (id == "theme") { const std::string key = themeKeyAt(index, themeNames()); if (!key.empty()) app_.setThemeChoice(key); } else if (id == "tv_mode") { if (index >= 0 && index <= (int)TvSetting::Auto) { - const bool wasOn = app_.tvMode.active(); - app_.tvSetting = (TvSetting)index; - app_.cfg.set("ui.tv_mode", settingKey(app_.tvSetting)); // written on change only: saveConfig() leaves the default free to move - if (app_.tvSetting == TvSetting::Off) app_.cfg.setBool("ui.tv_manual_on", false); // or a later Manual would switch TV on at once - app_.configureTvMode(); // starts from off ... - if (wasOn) app_.tvMode.setOn(true); // ... so a Manual <-> Auto change stays in TV mode; Off stays off (setOn is a no-op then) + app_.setTvSetting((TvSetting)index); // writes ui.tv_mode on change only, stays in TV mode across Manual <-> Auto } + } else if (id == "tv_auto_fs") { + app_.setTvAuto(true, index == 1); // ui.tv_auto_fullscreen; a trigger-started TV mode stays on, as a Manual one + } else if (id == "tv_auto_pad") { + app_.setTvAuto(false, index == 1); // ui.tv_auto_controller + } else if (id == "menu_in_window") { + app_.showInWindowMenu = on; + } else if (id == "menu_autohide") { + app_.fullscreenAutoHideMenu = on; + } else if (id == "windows_outside") { + app_.windowsOutside = on; + } else if (id == "keep_awake") { + app_.keepAwake = on; + } else if (id == "tray") { + app_.trayIcon = on; + if (!app_.applyTray()) { // as the desktop checkbox: a desktop with no tray puts it back + app_.trayIcon = false; + app_.toast("No system tray is available on this desktop", 4.0, 2); + } + } else if (id == "emu_thread") { + app_.emuThreadPref = on ? 1 : 0; + } else if (id == "dead_zone") { + const float cur = (float)DeviceSettings::load(app_.cfg, app_.input.content()).deadzonePct; + const SettingRow r = sliderRow(id, "", "interface", "Interface", cur, kDeadZoneLo, kDeadZoneHi, kDeadZoneStep, "%.0f"); + const float pct = stepValue(r, index, kDeadZoneLo, kDeadZoneStep, cur); + if (pct != cur) { + DeviceSettings::set(app_.cfg, deviceScope(app_, "deadzone"), "deadzone", std::to_string((int)pct)); + deviceChanged(); + } + } else if (id == "mouse_port") { + if (index >= 0 && index <= 2) { + DeviceSettings::set(app_.cfg, deviceScope(app_, "mouse_port"), "mouse_port", std::to_string(index - 1)); + deviceChanged(); + } + } else if (id == "gun_source") { + if (index >= 0 && index <= 2) { + DeviceSettings::set(app_.cfg, deviceScope(app_, "gun_source"), "gun_source", std::to_string(index)); + deviceChanged(); + } + } else if (id == "flush_saves") { + app_.autosaveSram = on; + } else if (id == "resume") { + if (index >= 0 && index < lib::kResumeModeCount) app_.resumeMode = index; + } else if (id == "home_library") { + app_.homeLibrary = on; + } else if (id == "show_as" || id == "show_as_recent") { + if (index >= 0 && index < lib::kShowAsCount) { + (id == "show_as" ? app_.library.favoritesShow : app_.library.recentsShow) = index; + app_.library.saveSettings(); + } + } else if (id == "tile_size") { + const SettingRow r = sliderRow(id, "", "library", "Library", app_.library.cellScale, lib::kTileScaleMin, lib::kTileScaleMax, kTileStep, "%.1f"); + app_.library.cellScale = lib::clampTileScale(stepValue(r, index, lib::kTileScaleMin, kTileStep, app_.library.cellScale)); + app_.library.saveSettings(); + } else if (id == "download_art") { + app_.library.setDownloadArt(on); + } else if (id == "close_on_launch") { + app_.library.hideOnLaunch = on; + app_.library.saveSettings(); + } else if (id == "ra_images") { + app_.ach.setImagesWanted(on); + } else if (id == "discord_enabled") { + app_.cfg.setBool("discord.enabled", on); + } else if (id == "discord_art") { + app_.cfg.setBool("discord.art", on); + } else if (id == "discord_buttons") { + app_.cfg.setBool("discord.buttons", on); +#ifdef VJ_HAVE_NEURO + } else if (id == "neuro_enabled") { + app_.cfg.setBool("neuro.enabled", on); + } else if (id == "neuro_auto_force") { + app_.cfg.setBool("neuro.auto_force", on); + } else if (id == "neuro_interval") { + const float cur = app_.cfg.getFloat("neuro.force_interval", 1.0f); + const SettingRow r = sliderRow(id, "", "neuro", "Neuro", cur, kPauseLo, kPauseHi, kPauseStep, "%.1f"); + app_.cfg.setFloat("neuro.force_interval", stepValue(r, index, kPauseLo, kPauseStep, cur)); +#endif } app_.saveConfig(); } diff --git a/app/src/tv/tv_app_host.h b/app/src/tv/tv_app_host.h index 42b1a9b8..f936fc3f 100644 --- a/app/src/tv/tv_app_host.h +++ b/app/src/tv/tv_app_host.h @@ -22,6 +22,7 @@ public: bool menuPause(std::string& why) override; void menuResume() override; bool netplayActive() const override; + bool menuRunsOn() const override; bool canLoadState() const override; std::vector<SlotInfo> slots() override; bool saveState(int slot) override; @@ -43,6 +44,7 @@ public: bool desktopWindowOpen() const override; bool libraryScanning() const override; std::vector<GameRef> games(List l) override; + bool hasGames() const override; Art art(const GameRef& g) override; std::vector<StateRef> states(const GameRef& g) override; Art stateThumb(const GameRef& g, const StateRef& state) override; diff --git a/app/src/tv/tv_details.cpp b/app/src/tv/tv_details.cpp index d5a16775..4f1d5ae2 100644 --- a/app/src/tv/tv_details.cpp +++ b/app/src/tv/tv_details.cpp @@ -68,13 +68,13 @@ void DetailsScreen::accept() { const Item it = items_[(size_t)focus_.index]; switch (it.kind) { case Item::Play: - host_.launch(g_); - shell_.launched(); + shell_.requestLaunch(g_, nullptr); break; - case Item::Resume: - host_.launchState(g_, {it.slot, it.time, it.disc}); - shell_.launched(); + case Item::Resume: { + const StateRef state{it.slot, it.time, it.disc}; + shell_.requestLaunch(g_, &state); break; + } case Item::Favorite: host_.setFavorite(g_, !host_.isFavorite(g_)); break; case Item::Achievements: // replaces the stack, this screen with it: nothing of ours is touched after the call shell_.openSection(Section::Achievements, makeAchievementsScreen(host_, shell_, achId_)); diff --git a/app/src/tv/tv_home.cpp b/app/src/tv/tv_home.cpp index 63fe6ca6..1cea4ddd 100644 --- a/app/src/tv/tv_home.cpp +++ b/app/src/tv/tv_home.cpp @@ -242,8 +242,7 @@ private: void play() { const GameRef* g = focused(); if (!g) return; - host_.launch(*g); - shell_.launched(); + shell_.requestLaunch(*g, nullptr); } // Start / a right-click: Game details of the focused game. Back from there lands on this tile again. @@ -259,8 +258,8 @@ private: if (!g || !host_.canLoadState()) return; const std::vector<StateRef> st = host_.states(*g); if (st.empty()) return; - host_.launchState(*g, st[0]); - shell_.launched(); + const StateRef newest = st[0]; + shell_.requestLaunch(*g, &newest); } widgets::TileArgs tileArgs(const GameRef& g) { @@ -277,10 +276,18 @@ private: void drawEmpty(const DrawCtx& c) { const Metrics& m = c.m; const float u = m.u, w = std::min(1100 * u, c.size.x - 2 * m.safeX), pad = 48 * u; - // The first scan of a new install (or of an empty cache) is still running: the library is not empty yet. - const std::string msg = host_.libraryScanning() - ? "Scanning your library..." - : "Your library is empty. Add game folders in desktop mode: View > TV mode turns TV mode off."; + // Games but no shelves (a new player: nothing played, no favorites, no discs) is not an empty library: the Library + // section lists them. Otherwise the first scan of a new install (or of an empty cache) may still be running. + std::string msg; + if (host_.hasGames()) { + std::string next = host_.glyph(Intent::SectionNext); // the button the section bar shows + if (next.empty()) next = "RB"; + msg = "Nothing to continue yet. Press " + next + " for your Library."; + } else { + msg = host_.libraryScanning() + ? "Scanning your library..." + : "Your library is empty. Add game folders in desktop mode: View > TV mode turns TV mode off."; + } const ImVec2 ts = widgets::textSize(m.body, msg, w - 2 * pad); const ImVec2 size(w, ts.y + 2 * pad); const ImVec2 pos(c.pos.x + (c.size.x - size.x) * 0.5f, c.pos.y + (c.size.y - size.y) * 0.5f); diff --git a/app/src/tv/tv_host.h b/app/src/tv/tv_host.h index 9ee2fb86..bb2974d9 100644 --- a/app/src/tv/tv_host.h +++ b/app/src/tv/tv_host.h @@ -52,12 +52,13 @@ enum class List { All, Favorites, Recent, Disc }; // One row of the TV Settings screen, by value (AppHost fills them from the app's settings and the core's options, so the // screen never sees either). `choices` are what Left / Right / Accept step through; "more" has none. struct SettingRow { - std::string id, label; // ids: shader integer aspect resolution risc_clock idle_skip speed_note volume fps vsync theme tv_mode more + std::string id, label; // app rows: shader integer smooth stretch aspect vsync fullscreen volume mute audio_buffer fps ... more; a core option: "core:" + its key std::vector<std::string> choices; // empty for "more" int index = 0; bool readOnly = false; - std::string note; // shown under the value: why it is read-only - int group = 0; // 0 Picture, 1 Speed, 2 Sound, 3 Interface + std::string note; // shown under the value: why it is read-only, or what to know about it + std::string group; // the rail item's key: "picture" "sound" "interface" "library" "achievements" "discord" "neuro", "core:" + a category key ("core:" = no category) + std::string groupLabel; // ... and what the rail shows for it }; // One achievement of the loaded game, or one active leaderboard tracker (kind Tracker: read-only, no status). AppHost fills them @@ -102,9 +103,10 @@ public: virtual double now() const = 0; virtual bool gameLoaded() const = 0; virtual std::string gameTitle() const = 0; - virtual bool menuPause(std::string& why) = 0; // false = refused (hardcore rationing); netplay: true, not paused + virtual bool menuPause(std::string& why) = 0; // App: always true -- netplay and hardcore's refusal open the menu over a game that keeps running virtual void menuResume() = 0; // restores the pause state from before the menu virtual bool netplayActive() const = 0; // netplay.configured() + virtual bool menuRunsOn() const { return false; } // the menu is over a running game because hardcore would not pause it (banner) virtual bool canLoadState() const = 0; // ach.allowStateLoad() virtual std::vector<SlotInfo> slots() = 0; // 0..9, thumbnails from App::slotThumb virtual bool saveState(int slot) = 0; @@ -140,6 +142,7 @@ public: // All / Disc / Favorites: by title, case-insensitive; Recent: newest first. Never BIOS. Fetched fresh every frame. virtual bool libraryScanning() const = 0; // a scan is running (an empty library may still fill up) virtual std::vector<GameRef> games(List l) = 0; + virtual bool hasGames() const = 0; // the library lists at least one game (never BIOS); no copy, cheap enough for a frame virtual Art art(const GameRef& g) = 0; // starts the load on first use; tex 0 until it is ready virtual std::vector<StateRef> states(const GameRef& g) = 0; virtual Art stateThumb(const GameRef& g, const StateRef& state) = 0; diff --git a/app/src/tv/tv_ingame.cpp b/app/src/tv/tv_ingame.cpp index 324ef32a..86236ff5 100644 --- a/app/src/tv/tv_ingame.cpp +++ b/app/src/tv/tv_ingame.cpp @@ -1,6 +1,7 @@ // TV mode (#991): the in-game menu drawn over the dimmed game -- Shell's row model (tv_shell.h) as a rail and a list. // The rail names the game and the page; the list is the rows (a slot page shows each slot's picture) -- or, while a screen -// sits in the menu's slot (Achievements, Overlays, Settings), that screen drawn in the list area; a hint bar at the +// sits in the menu's slot (Achievements, Overlays, Settings), that screen drawn in the list area (the Library takes the whole panel, no +// rail: Screen::wide()); a hint bar at the // bottom lists only what the focused row (or the screen) can do. Hover and click are rectangle tests (no ImGui item), so this window // never holds ImGui's active item and never makes io.WantCaptureKeyboard true. #include <SDL3/SDL.h> @@ -76,20 +77,24 @@ void Shell::drawMenu(ImVec2 pos, ImVec2 size) { // ---- the rail: the page, and the game ------------------------------------------------------------------------- const bool screen = menuScreen_ != nullptr; // a screen in the slot: its name is the page + const bool wide = screen && menuScreen_->wide(); // ... that takes the whole panel (the Library): no rail const bool root = page_ == Page::Root && !screen; const float railX = pp.x + inset; - float y = pp.y + inset; - const ImVec2 t1 = widgets::text(dl, m.titleSmall, ImVec2(railX, y), widgets::col(pal.text), - screen ? capitalised(menuScreen_->id()) : title_, 0, railW); - y += t1.y + 12 * m.u; - widgets::text(dl, m.caption, ImVec2(railX, y), widgets::col(pal.textMuted), - root ? std::string(host_.netplayActive() ? "Menu" : "Paused") : host_.gameTitle(), railW); + if (!wide) { + float y = pp.y + inset; + const ImVec2 t1 = widgets::text(dl, m.titleSmall, ImVec2(railX, y), widgets::col(pal.text), + screen ? capitalised(menuScreen_->id()) : title_, 0, railW); + y += t1.y + 12 * m.u; + widgets::text(dl, m.caption, ImVec2(railX, y), widgets::col(pal.textMuted), + root ? std::string(host_.netplayActive() || host_.menuRunsOn() ? "Menu" : "Paused") : host_.gameTitle(), railW); + } // ---- the list ------------------------------------------------------------------------------------------------- - const float listX = railX + railW + inset; + const float listX = wide ? railX : railX + railW + inset; const float listW = pp.x + ps.x - inset - listX; float listY = pp.y + inset; if (host_.netplayActive()) listY += widgets::banner(dl, m, ImVec2(listX, listY), listW, "Netplay: the game keeps running") + 20 * m.u; + else if (host_.menuRunsOn()) listY += widgets::banner(dl, m, ImVec2(listX, listY), listW, "Hardcore: the game keeps running") + 20 * m.u; const float rowH = (slotPage ? 124 : 88) * m.u, gap = 14 * m.u; const float listH = hintY - 24 * m.u - listY; const int visible = std::max(1, (int)((listH + gap) / (rowH + gap))); @@ -160,7 +165,7 @@ void Shell::drawMenu(ImVec2 pos, ImVec2 size) { const char* verb = "Select"; if (page_ == Page::SaveSlots) verb = "Save"; else if (page_ == Page::LoadSlots) verb = "Load"; - else if (page_ == Page::ConfirmReset || page_ == Page::ConfirmClose) verb = "Choose"; + else if (page_ == Page::ConfirmReset || page_ == Page::ConfirmClose || page_ == Page::ConfirmLaunch) verb = "Choose"; hints.push_back({host_.glyph(Intent::Accept), verb}); } hints.push_back({host_.glyph(Intent::Back), root ? "Resume" : "Back"}); diff --git a/app/src/tv/tv_library.cpp b/app/src/tv/tv_library.cpp index ebe5f3a1..f23fa762 100644 --- a/app/src/tv/tv_library.cpp +++ b/app/src/tv/tv_library.cpp @@ -27,7 +27,7 @@ constexpr int kCols = 6; constexpr float kRailW = 344, kRailGap = 64, kItemH = 54, kItemGap = 8; // design units constexpr float kGridTop = 196 - 150; // from the top of the content area (y = 150) constexpr float kRowExtra = 72; // under each tile: room for the focused tile's caption -constexpr float kColGap = 32; +constexpr float kColGap = 32, kColGapMenu = 12; constexpr size_t kMaxFilter = 32; const char* const kLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; // the picker's cells, row by row constexpr int kLetterCount = 36; @@ -63,6 +63,7 @@ public: } const char* id() const override { return "library"; } + bool wide() const override { return embedded(); } // in the in-game menu's slot the rail + grid take the whole panel void intent(Intent in) override { refresh(); @@ -115,7 +116,10 @@ public: const Metrics& m = c.m; const theme::Palette& pal = theme::pal(); const float u = m.u; - const float x0 = c.pos.x + m.safeX, right = c.pos.x + c.size.x - m.safeX; + // In the in-game menu's slot the area is the menu's panel inside its inset: no safe margin, only the room the focused tile and its + // ring grow into. + const float edge = embedded() ? 12 * u : m.safeX; + const float x0 = c.pos.x + edge, right = c.pos.x + c.size.x - edge; if (!mouseSeen_) { // a pointer already resting on a tile when the screen appears has not chosen it lastMouse_ = c.mouse; @@ -124,7 +128,7 @@ public: const bool moved = c.mouse.x != lastMouse_.x || c.mouse.y != lastMouse_.y; lastMouse_ = c.mouse; // The letter picker is drawn over this screen with the same pointer: it is the one that gets it. - const bool live = c.hovered && shell_.screenId() == id(); + const bool live = c.hovered && (embedded() || shell_.screenId() == id()); // (the slot's screen is not on the Base stack) const bool pick = live && (moved || c.clicked || c.rightClicked); const bool clicked = live && c.clicked, rightClicked = live && c.rightClicked; @@ -147,7 +151,10 @@ public: // ---- the grid ------------------------------------------------------------------------------------------------ const float gx = x0 + (kRailW + kRailGap) * u, gridW = right - gx; - const float tileW = std::max(1.0f, (gridW - (kCols - 1) * kColGap * u) / (float)kCols), tileH = tileW * 7 / 5; + // In the menu's slot the grid is narrower (the panel, not the screen): tighter columns keep a placeholder tile wide enough for a + // whole word of a title ("Cybermorph") at the 24 u floor. + const float colGap = (embedded() ? kColGapMenu : kColGap) * u; + const float tileW = std::max(1.0f, (gridW - (kCols - 1) * colGap) / (float)kCols), tileH = tileW * 7 / 5; const float pitch = tileH + kRowExtra * u; const float top = c.pos.y + kGridTop * u, bottom = c.pos.y + c.size.y; const int visibleRows = std::max(1, (int)((bottom - top + kRowExtra * u) / pitch)); @@ -192,10 +199,13 @@ public: for (int r = 0; r < grid_.rows(); r++) { const float y = top + (float)r * pitch - scroll; if (y + pitch <= clipTop || y >= bottom) continue; + // In the menu's slot a row the panel's foot would cut through (its title lies at the tile's foot) waits until it fits whole + // (while the rows scroll it may enter by the edge). + if (embedded() && scroll == scroll_.to && y + tileH > bottom + 0.5f) continue; for (int col = 0; col < kCols; col++) { const int j = r * kCols + col; if (j >= n) break; - const float tx = gx + (float)col * (tileW + kColGap * u); + const float tx = gx + (float)col * (tileW + colGap); widgets::TileArgs a = tileArgs(games_[(size_t)j]); a.focus = !onRail_ && j == grid_.index ? tweens_.at(j, c.now) : 0.0f; const widgets::Hit hit = widgets::tile(c.dl, m, ImVec2(tx, y), ImVec2(tileW, tileH), a); @@ -232,15 +242,18 @@ public: } std::vector<widgets::Hint> hints() const override { - if (games_.empty()) return {{host_.glyph(Intent::Back), "Home"}}; // an empty grid: no game to open, play or jump in - if (onRail_) return {{host_.glyph(Intent::Accept), "Open"}, {host_.glyph(Intent::Back), "Home"}}; + // In the menu's slot Back leaves for the menu, and Details / the letter picker (which push onto the Base stack, not shown under a + // game) are not offered. + const char* way = embedded() ? "Menu" : "Home"; + if (games_.empty()) return {{host_.glyph(Intent::Back), way}}; // an empty grid: no game to open, play or jump in + if (onRail_) return {{host_.glyph(Intent::Accept), "Open"}, {host_.glyph(Intent::Back), way}}; std::vector<widgets::Hint> h = {{host_.glyph(Intent::Accept), "Play"}}; if (resumeOk_) h.push_back({host_.glyph(Intent::Alt), "Resume state"}); - h.push_back({host_.glyph(Intent::Menu), "Details"}); - h.push_back({host_.glyph(Intent::Aux), "Jump to letter"}); + if (!embedded()) h.push_back({host_.glyph(Intent::Menu), "Details"}); + if (!embedded()) h.push_back({host_.glyph(Intent::Aux), "Jump to letter"}); const std::string lt = host_.glyph(Intent::PagePrev), rt = host_.glyph(Intent::PageNext); h.push_back({lt.empty() && rt.empty() ? std::string() : lt + " " + rt, "A\xE2\x80\x93Z"}); - h.push_back({host_.glyph(Intent::Back), "Home"}); + h.push_back({host_.glyph(Intent::Back), way}); return h; } @@ -322,10 +335,14 @@ private: case Intent::Down: grid_.move(0, 1); break; case Intent::Accept: play(); break; case Intent::Alt: resume(); break; - case Intent::Aux: openLetters(); break; + case Intent::Aux: + if (!embedded()) openLetters(); // (a screen pushed on the Base stack does not show under a game) + break; case Intent::PagePrev: grid_.index = jumpInitial(titles_, grid_.index, -1); break; case Intent::PageNext: grid_.index = jumpInitial(titles_, grid_.index, 1); break; - case Intent::Menu: details(); break; + case Intent::Menu: + if (!embedded()) details(); + break; default: break; } } @@ -339,8 +356,7 @@ private: void play() { const GameRef* g = focused(); if (!g) return; - host_.launch(*g); - shell_.launched(); + shell_.requestLaunch(*g, nullptr); // asks first when a game is running (the menu's slot) } // Start / a right-click: Game details of the focused game. Back from there lands on this tile again. @@ -356,8 +372,8 @@ private: if (!g || !host_.canLoadState()) return; const std::vector<StateRef> st = host_.states(*g); if (st.empty()) return; - host_.launchState(*g, st[0]); - shell_.launched(); + const StateRef newest = st[0]; + shell_.requestLaunch(*g, &newest); } widgets::TileArgs tileArgs(const GameRef& g) { diff --git a/app/src/tv/tv_mode.cpp b/app/src/tv/tv_mode.cpp index 590901c4..dd195146 100644 --- a/app/src/tv/tv_mode.cpp +++ b/app/src/tv/tv_mode.cpp @@ -1,6 +1,7 @@ #include "tv/tv_mode.h" #include <cmath> +#include <vector> namespace vj::tv { @@ -21,8 +22,20 @@ bool settingFromKey(const std::string& k, TvSetting& out) { return true; } -TvSetting effectiveSetting(TvSetting stored) { - return stored == TvSetting::Auto ? TvSetting::Manual : stored; +TvSetting defaultSetting(const char* buildDefault) { + TvSetting s = TvSetting::Auto; + if (buildDefault) settingFromKey(buildDefault, s); // an unknown value leaves Auto + return s; +} + +std::string firstUseHint(const std::string& padText, const std::string& keyText, bool macOS) { + std::vector<std::string> ways; + if (!padText.empty()) ways.push_back(padText); + if (!keyText.empty()) ways.push_back(keyText); + if (ways.size() < 2) ways.push_back(macOS ? "Cmd+Shift+T" : "Ctrl+Shift+T"); + std::string s = "Press " + ways[0]; + for (size_t i = 1; i < ways.size(); i++) s += " or " + ways[i]; + return s + " for TV mode"; } void TvMode::turnOn(Cause c) { diff --git a/app/src/tv/tv_mode.h b/app/src/tv/tv_mode.h index f69c2636..61ab7916 100644 --- a/app/src/tv/tv_mode.h +++ b/app/src/tv/tv_mode.h @@ -3,8 +3,7 @@ // whether TV mode is active. // // Three stored settings: Off (never), Manual (only the toggle), Auto (also on fullscreen and on a controller press). -// TvMode itself acts on the setting it is given; mapping the stored setting to the effective one is -// effectiveSetting()'s job (Auto is Manual until the Auto triggers are wired up). +// TvMode itself acts on the setting it is given; defaultSetting() is what a config with no stored setting gets. #pragma once #include <string> @@ -15,7 +14,13 @@ enum class TvSetting { Off, Manual, Auto }; const char* settingKey(TvSetting s); // "off" | "manual" | "auto" bool settingFromKey(const std::string& k, TvSetting& out); // false (out untouched) on an unknown key -TvSetting effectiveSetting(TvSetting stored); // Auto -> Manual until the Auto triggers exist +// The setting a config with no ui.tv_mode starts from: the build's (VJ_TV_DEFAULT_SETTING: "off" | "manual" | "auto"). +// Null or anything else is Auto. Never written back, so a build can move its default under people who never chose. +TvSetting defaultSetting(const char* buildDefault); + +// The one-time first-use hint (decision 8): "Press <the pad's TV-menu button> or <the keyboard's> for TV mode". A missing half is +// dropped, and the fixed shortcut (Ctrl+Shift+T, Cmd+Shift+T on macOS) stands in until there are two, so it is never empty. +std::string firstUseHint(const std::string& padText, const std::string& keyText, bool macOS); struct TvConfig { TvSetting setting = TvSetting::Manual; diff --git a/app/src/tv/tv_nav.cpp b/app/src/tv/tv_nav.cpp index 356d957f..f262496a 100644 --- a/app/src/tv/tv_nav.cpp +++ b/app/src/tv/tv_nav.cpp @@ -171,6 +171,7 @@ void NavInput::padAxis(int axis, int16_t v, uint64_t ms) { std::vector<Intent> NavInput::take(uint64_t ms) { std::vector<Intent> out; out.swap(pending_); + repeated_ = false; for (HeldAccept& a : acceptHeld_) { if (a.swallowed || ms < a.downAt || ms - a.downAt < kLongMs) continue; a.swallowed = true; // fires once; its release is swallowed @@ -182,6 +183,7 @@ std::vector<Intent> NavInput::take(uint64_t ms) { } if (!held_.empty() && ms >= nextRepeat_) { out.push_back(held_.back().dir); + repeated_ = true; // always the last of the call // One repeat per call however late the call is; the clock catches up so a stall does not burst. nextRepeat_ += kRepeatMs * ((ms - nextRepeat_) / kRepeatMs + 1); } @@ -195,6 +197,7 @@ void NavInput::reset() { acceptHeld_.clear(); trigOn_.clear(); rearm_ = false; + repeated_ = false; nextRepeat_ = 0; for (int& s : stickState_) s = 0; trig_[0] = trig_[1] = false; diff --git a/app/src/tv/tv_nav.h b/app/src/tv/tv_nav.h index 4122e826..4f62db1c 100644 --- a/app/src/tv/tv_nav.h +++ b/app/src/tv/tv_nav.h @@ -46,6 +46,9 @@ public: void forgetPad(uint32_t pad); // pad unplugged: drop what it held; emits nothing, edges already queued stay void key(int scancode, bool down, bool shift, uint64_t ms); std::vector<Intent> take(uint64_t ms); // edges since the last call, plus repeats (directions only) + // The last intent take() returned was a repeat of a held direction (not a press). A screen that does something once per press + // (leave a level on Left at its first choice) asks Shell::repeatIntent(). + bool lastWasRepeat() const { return repeated_; } void reset(); // forget everything held and pending private: @@ -64,6 +67,7 @@ private: bool longAccept_ = false; std::vector<HeldAccept> acceptHeld_; // pad Accepts down, waiting to become a tap or AcceptLong std::vector<int> trigOn_; // padTrigger latches that are on, by (pad, axis) source + bool repeated_ = false; // take()'s last intent was a repeat bool rearm_ = false; // forgetPad handed the repeat to an older hold: restart its delay at the next take }; diff --git a/app/src/tv/tv_screen.h b/app/src/tv/tv_screen.h index f38901b8..abe1a95e 100644 --- a/app/src/tv/tv_screen.h +++ b/app/src/tv/tv_screen.h @@ -45,6 +45,12 @@ public: virtual const char* id() const = 0; // "home" | "library" | "letters" | "details" | "achievements" | "settings" | "overlays": FocusMemory keys, tests virtual void intent(Intent in) = 0; virtual bool typed(char c) { return false; } // 'a'..'z', '0'..'9', '\b'; true = used + // The label of what has the focus (Settings: the group on the rail, or the row in its list), "" when the screen has none to name. + // For tests and the headless script (`expect tv-focus=LABEL`); not drawn. Not const: a screen may refresh its model first. + virtual std::string focusLabel() { return {}; } + // Settings: the key of the group on the rail ("picture", "core:video"); "" for any other screen. Tests and the headless script + // (`expect tv-group=KEY`). + virtual std::string focusGroup() { return {}; } virtual bool back() { return false; } // Back: true = the screen stepped up inside itself; false = the shell's (pop / Home) virtual void draw(const DrawCtx& c) = 0; // draws, and does hover-focus / click / wheel from c virtual std::vector<widgets::Hint> hints() const = 0; // last = the way out @@ -54,6 +60,9 @@ public: // a screen draws no rail of its own and no safe margin, and its way out is "Menu" (Back always leaves for the menu, after the // screen's own back()). void setEmbedded(bool on) { embedded_ = on; } + // An embedded screen that wants the whole panel (the Library: its own rail and grid) instead of the list area beside the menu's + // rail. The menu then draws no rail, and DrawCtx::pos / size are the panel inside its inset. + virtual bool wide() const { return false; } protected: bool embedded() const { return embedded_; } diff --git a/app/src/tv/tv_settings.cpp b/app/src/tv/tv_settings.cpp index e93c97c5..7ca09d16 100644 --- a/app/src/tv/tv_settings.cpp +++ b/app/src/tv/tv_settings.cpp @@ -1,4 +1,4 @@ -// TV mode (#991): the Settings screen -- a rail of groups (Picture, Speed, Sound, Interface) beside the focused group's +// TV mode (#991): the Settings screen -- a rail of groups (the app's tabs, then the core's categories) beside the focused group's // rows, each a label on the left and its value on the right between two chevrons. Left / Right step a row, Accept steps it // forward and wraps (so a one-handed player can still change everything), and the last row of Interface opens the desktop // Settings window. Geometry is the Library's rail and the plan's "Screen geometry", in design units (u = viewport @@ -7,11 +7,21 @@ // The rows come from Host::settingRows() on every draw and every intent: a row the build or the session does not have // is simply absent, and one that changed under the player is followed by its id. Where focus was lives in // Shell::memory(): the group under "settings/rail", each group's row under "settings/<group>" (index and row id), and -// whether the list had the focus under "settings/list", so coming back lands there. +// whether the list had the focus under "settings/list", so coming back lands there. A group is its key (SettingRow::group), so +// the memory survives groups that come and go. // -// In the in-game menu's slot (Screen::setEmbedded) the screen has no rail of its own -- the menu names the page -- so the groups -// are a first list level in the menu's list area: Accept on a group opens its rows, Back returns to the groups, and Back on the -// groups is the menu's. Every visit starts on the groups. +// Both the rail and the list are long (the core's categories join the app's tabs; a category can hold a dozen rows), so each scrolls +// a whole item at a time (Scroll: LinearFocus::first eased by the Phase 3 tween), keeps the focus in view, and shows an arrow where +// there is more. LT / RT (PagePrev / PageNext) jump a screenful, stopping at the ends. A held direction repeats on the nav's own +// timer; the screen only has to tell a repeat from a press (Shell::repeatIntent) where a press means more than the repeat does. +// +// In the in-game menu's slot (Screen::setEmbedded) the screen has no rail of its own, so the groups are a first list level: Accept on +// a group opens its rows, Back returns to the groups, and Back on the groups is the menu's. Every visit starts on the groups. It takes +// the whole panel (wide(), as the Library does: the menu's own rail is not drawn) so the labels have the room, and names itself in the +// caption line above its list. +// +// A label that does not fit beside its value wraps at words to a second line (and a note under the value the same) instead of being cut; +// every row of a group then has the height of the tallest, so the window still scrolls a whole row at a time. #include <algorithm> #include <cfloat> #include <cstdlib> @@ -29,18 +39,75 @@ constexpr float kRailW = 344, kRailGap = 64, kItemH = 54, kItemGap = 8; // the constexpr float kListTop = 196 - 150; // from the top of the content area (y = 150) constexpr float kRowH = 72, kRowGap = 14; constexpr float kRowsMargin = 12; // room above the first row for a focused row's ring and shadow +constexpr float kRailPitch = kItemH + kItemGap; +constexpr float kArrowRoom = 26; // under a list that scrolls: the "more" arrow's place +constexpr float kArrowGap = 19; // the arrow's centre, from a row list's first / last row (clear of its ring) +constexpr float kRailArrowGap = 16; // ... from the rail's first / last item (its ring does not grow) +constexpr float kRowVPad = 7; // above and below a row's text (72 u holds a label with a note line) constexpr const char* kMoreId = "more"; -std::string memoryKey(int group) { return "settings/" + std::to_string(group); } +std::string memoryKey(const std::string& group) { return "settings/" + group; } + +// How many whole items of `pitch` fit under `viewH` (the last one has no gap after it). A run that does not fit whole keeps +// `arrowRoom` free under its last item for the "more" arrow. +int itemsThatFit(float viewH, float pitch, float gap, int count, float arrowRoom) { + const int all = std::max(1, (int)((viewH + gap) / pitch)); + if (all >= count) return all; + return std::max(1, (int)((viewH - arrowRoom + gap) / pitch)); +} + +// A run of equal items scrolled a whole item at a time: LinearFocus::first is the first one showing and the pixel offset eases +// to it, so at rest no item is cut by the edge of its view. `restart`: another run took the place (start from the top, no easing). +struct Scroll { + Tween tween; + bool init = false; + float lastU = 0, lastPitch = 0; + float update(LinearFocus& f, int visible, float pitch, float u, bool restart, double now) { + if (restart) f.first = 0; + f.first = std::clamp(f.first, 0, std::max(0, f.count - visible)); + f.scrollToShow(visible); + const float target = (float)f.first * pitch; + if (!init || lastU != u || lastPitch != pitch || restart) { + tween.from = tween.to = target; + tween.start = now; + init = true; + lastU = u; + lastPitch = pitch; + } else if (target != tween.to) { + tween.retarget(target, now); + } + return tween.at(now); + } +}; + +// LT / RT: the focus and the window both move a screenful, and stop at the ends. +void pageBy(LinearFocus& f, int visible, int dir) { + const int step = std::max(1, visible); + f.index = std::clamp(f.index + dir * step, 0, std::max(0, f.count - 1)); + f.first = std::clamp(f.first + dir * step, 0, std::max(0, f.count - visible)); +} class SettingsScreen : public Screen { public: SettingsScreen(Host& h, Shell& s) : host_(h), shell_(s) {} const char* id() const override { return "settings"; } + bool wide() const override { return embedded(); } // in the in-game menu's slot the labels get the whole panel (the menu draws no rail) + + std::string focusLabel() override { + refresh(); + if (group_.empty() || rows_.empty()) return {}; + return onRail_ ? groupLabel() : rows_[(size_t)list_.index].label; + } + + std::string focusGroup() override { + refresh(); + return group_; + } void intent(Intent in) override { refresh(); + if (!shell_.repeatIntent()) enteredByRight_ = false; // a fresh press: whatever the last hold started is over if (onRail_) railIntent(in); else listIntent(in); remember(); @@ -73,16 +140,31 @@ public: lastMouse_ = c.mouse; const bool pick = c.hovered && (moved || c.clicked); - // ---- the rail -------------------------------------------------------------------------------------------------- + // ---- the rail: the groups, a window of whole items that follows the focus --------------------------------------- int hoverItem = -1; - for (int i = 0; !emb && i < (int)groups_.size(); i++) { - const ImVec2 p0(x0, c.pos.y + (float)i * (kItemH + kItemGap) * u), p1(x0 + kRailW * u, p0.y + kItemH * u); - const bool sel = i == rail_.index; - if (sel) c.dl->AddRectFilled(p0, p1, widgets::col(pal.cardBgHover), 10 * u); - if (sel && onRail_) c.dl->AddRect(p0, p1, widgets::col(pal.text), 10 * u, 0, m.ring); - widgets::text(c.dl, m.body, ImVec2(p0.x + 20 * u, p0.y + 12 * u), widgets::col(sel ? pal.text : pal.textMuted), - settingGroupLabel(groups_[(size_t)i]), (kRailW - 40) * u); - if (c.hovered && c.mouse.x >= p0.x && c.mouse.x < p1.x && c.mouse.y >= p0.y && c.mouse.y < p1.y) hoverItem = i; + if (!emb) { + const float pitch = kRailPitch * u, itemH = kItemH * u; + railVisible_ = itemsThatFit(c.size.y, pitch, kItemGap * u, (int)groups_.size(), kArrowRoom * u); + const float scroll = railScroll_.update(rail_, railVisible_, pitch, u, false, c.now); + const float winBottom = c.pos.y + (float)railVisible_ * pitch - kItemGap * u; // the window's lower edge + const float ringRoom = m.ring; // the ring is drawn a little outside its item + c.dl->PushClipRect(ImVec2(c.pos.x, c.pos.y - ringRoom), ImVec2(c.pos.x + c.size.x, winBottom + ringRoom), true); + for (int i = 0; i < (int)groups_.size(); i++) { + const float y = c.pos.y + (float)i * pitch - scroll; + if (y + itemH <= c.pos.y - ringRoom || y >= winBottom) continue; // wholly outside: not drawn at all + const ImVec2 p0(x0, y), p1(x0 + kRailW * u, y + itemH); + const bool sel = i == rail_.index; + if (sel) c.dl->AddRectFilled(p0, p1, widgets::col(pal.cardBgHover), 10 * u); + if (sel && onRail_) c.dl->AddRect(p0, p1, widgets::col(pal.text), 10 * u, 0, m.ring); + widgets::text(c.dl, m.body, ImVec2(p0.x + 20 * u, p0.y + 12 * u), widgets::col(sel ? pal.text : pal.textMuted), + groups_[(size_t)i].label, (kRailW - 40) * u); + if (c.hovered && c.mouse.x >= p0.x && c.mouse.x < p1.x && c.mouse.y >= std::max(p0.y, c.pos.y) && + c.mouse.y < std::min(p1.y, winBottom)) + hoverItem = i; + } + c.dl->PopClipRect(); + drawMoreArrows(c, x0 + kRailW * u * 0.5f, c.pos.y - kRailArrowGap * u, winBottom + kRailArrowGap * u, rail_.first > 0, + rail_.first + railVisible_ < (int)groups_.size()); } if (hoverItem >= 0 && pick) { // hovering a group selects it: its rows show at once selectGroup(hoverItem); @@ -97,39 +179,26 @@ public: return; } - // ---- the rows: scrolled so the focused one shows --------------------------------------------------------------- + // ---- the rows: a window of whole rows that follows the focus ---------------------------------------------------- const float lx = emb ? x0 : x0 + (kRailW + kRailGap) * u, lw = std::max(1.0f, right - lx); const std::string count = std::to_string(rows_.size()) + (rows_.size() == 1 ? " setting" : " settings"); widgets::text(c.dl, m.caption, ImVec2(lx, c.pos.y), widgets::col(pal.textMuted), - std::string(settingGroupLabel(group_)) + " \xC2\xB7 " + count, lw); + groupLabel() + " \xC2\xB7 " + count, lw); - const float rowsTop = c.pos.y + kListTop * u, viewBottom = c.pos.y + c.size.y, viewH = viewBottom - rowsTop; - const float pitch = (kRowH + kRowGap) * u, rowH = kRowH * u; - const float total = (float)rows_.size() * pitch - kRowGap * u; - const float focusTop = (float)list_.index * pitch; - float target = scrollTo_; - if (group_ != lastGroup_) target = 0; - if (focusTop < target) target = focusTop; - else if (focusTop + rowH > target + viewH) target = focusTop + rowH - viewH; - target = std::clamp(target, 0.0f, std::max(0.0f, total - viewH)); - scrollTo_ = target; - if (!scrollInit_ || lastU_ != u || group_ != lastGroup_) { - scroll_.from = scroll_.to = target; - scroll_.start = c.now; - scrollInit_ = true; - lastU_ = u; - lastGroup_ = group_; - } else if (target != scroll_.to) { - scroll_.retarget(target, c.now); - } - const float scroll = scroll_.at(c.now); - tweens_.update((int)rows_.size(), list_.index, group_, c.now); + layoutRows(m, lw); + const float rowsTop = c.pos.y + kListTop * u, viewBottom = c.pos.y + c.size.y, viewH = std::max(1.0f, viewBottom - rowsTop); + const float pitch = rowH_ + kRowGap * u, rowH = rowH_; + listVisible_ = itemsThatFit(viewH, pitch, kRowGap * u, (int)rows_.size(), kArrowRoom * u); + const float scroll = listScroll_.update(list_, listVisible_, pitch, u, group_ != lastGroup_, c.now); + lastGroup_ = group_; + tweens_.update((int)rows_.size(), list_.index, groupSerial_, c.now); + const float winBottom = rowsTop + (float)listVisible_ * pitch - kRowGap * u; // the window's lower edge int hoverRow = -1; - c.dl->PushClipRect(ImVec2(c.pos.x, rowsTop - kRowsMargin * u), ImVec2(c.pos.x + c.size.x, viewBottom), true); + c.dl->PushClipRect(ImVec2(c.pos.x, rowsTop - kRowsMargin * u), ImVec2(c.pos.x + c.size.x, winBottom + 8 * u), true); for (int i = 0; i < (int)rows_.size(); i++) { const float y = rowsTop + (float)i * pitch - scroll; - if (y + rowH < rowsTop - kRowsMargin * u || y > viewBottom) continue; + if (y + rowH <= rowsTop - kRowsMargin * u || y >= winBottom) continue; // wholly outside: not drawn at all const float f = !onRail_ && i == list_.index ? tweens_.at(i, c.now) : 0.0f; widgets::RowArgs a; // the card and the focus ring; the label and the value are drawn here a.focus = f; @@ -138,12 +207,12 @@ public: // The unscaled card, widened by what a focused row grows: its right chevron lies beyond the unscaled edge. const float slack = (m.focusScale - 1.0f) * 0.5f * lw; if (c.hovered && c.mouse.x >= lx - slack && c.mouse.x < lx + lw + slack && c.mouse.y >= y && c.mouse.y < y + rowH && - c.mouse.y >= rowsTop && c.mouse.y < viewBottom) // not the part the clip hides + c.mouse.y >= rowsTop && c.mouse.y < winBottom) // not the part the clip hides hoverRow = i; } c.dl->PopClipRect(); - if (scroll > 0.5f) widgets::moreArrow(c.dl, m, ImVec2(lx + lw * 0.5f, rowsTop - 4 * u), true); - if (scroll + viewH < total - 0.5f) widgets::moreArrow(c.dl, m, ImVec2(lx + lw * 0.5f, viewBottom - 10 * u), false); + drawMoreArrows(c, lx + lw * 0.5f, rowsTop - kArrowGap * u, winBottom + kArrowGap * u, list_.first > 0, + list_.first + listVisible_ < (int)rows_.size()); // ---- the pointer: hovering a row focuses it, a click on the value's left / right half steps that way, a click // elsewhere on the row is Accept, a notch is a row -------------------------------------------------------------- @@ -168,6 +237,7 @@ public: std::vector<widgets::Hint> h; if (onRail_ || rows_.empty()) { if (!rows_.empty()) h.push_back({host_.glyph(Intent::Accept), "Open"}); + pageHint(h, (int)groups_.size() > railVisible_); // a rail (or groups level) that scrolls h.push_back({host_.glyph(Intent::Back), embedded() ? "Menu" : "Home"}); return h; } @@ -179,33 +249,69 @@ public: h.push_back({l.empty() ? rt : rt.empty() || l == rt ? l : l + " " + rt, "Change"}); // "D-pad" once, "Left Right" on a keyboard h.push_back({host_.glyph(Intent::Accept), "Next"}); } + pageHint(h, (int)rows_.size() > listVisible_); // a list that scrolls h.push_back({host_.glyph(Intent::Back), "Groups"}); return h; } private: - // Fetches the rows, keeps the groups that have any, and settles the focus: the rail follows the group (not its - // position: Speed appears once the core's options are known) and the list follows the row's id. + // "LT RT Page" on a list that scrolls: one glyph if the other has none (or both are the same key), nothing if neither has. + void pageHint(std::vector<widgets::Hint>& h, bool scrolls) const { + if (!scrolls) return; + const std::string lt = host_.glyph(Intent::PagePrev), rt = host_.glyph(Intent::PageNext); + const std::string glyph = lt.empty() ? rt : rt.empty() || lt == rt ? lt : lt + " " + rt; + if (!glyph.empty()) h.push_back({glyph, "Page"}); + } + + // How each row of the focused group is laid out at rest (the label and the note broken into lines) and how tall its rows are: the + // tallest row's, never under kRowH. The label gets the row's width less the value column, which is sized by the widest choice + // (not the one showing) so that stepping a value never rewraps the label; a note under the value is right-aligned in half a row. + void layoutRows(const Metrics& m, float lw) { + const float u = m.u, pad = 24 * u, inner = std::max(1.0f, lw - 2 * pad), half = inner * 0.5f; + const float chevW = 9 * u, gap = 24 * u; + lay_.assign(rows_.size(), RowLay()); + rowH_ = kRowH * u; + for (size_t i = 0; i < rows_.size(); i++) { + const SettingRow& r = rows_[i]; + RowLay& L = lay_[i]; + float avail = inner; // a note row ("Available once a game has run"): the whole row is its text + if (!r.choices.empty()) { + float widest = 0; + for (const std::string& ch : r.choices) widest = std::max(widest, widgets::textWidth(m.body, ch)); + avail = inner - (std::min(widest, half - chevW - gap) + 2 * (chevW + gap)) - gap; + } else if (!r.readOnly) { + avail = inner - chevW - gap; // "more": a chevron + } + L.label = widgets::wrapLines(m.body, r.label, std::max(avail, 0.4f * inner), 2); + if (!r.choices.empty() && !r.note.empty()) L.note = widgets::wrapLines(m.minText, r.note, half, 2); + const float n = (float)std::max<size_t>(1, L.label.size()); + const float labelH = n * m.body + (n - 1) * 4 * u; + const float noteH = L.note.empty() ? 0.0f : 4 * u + (float)L.note.size() * m.minText + (float)(L.note.size() - 1) * 2 * u; + rowH_ = std::max(rowH_, std::max(labelH, m.body + noteH) + 2 * kRowVPad * u); + } + } + + // Fetches the rows, takes their groups in first-seen order (the host lists them in the rail's order), and settles the focus: + // the rail follows the group's key (not its position: groups come and go with the session) and the list follows the row's id. void refresh() { const std::vector<SettingRow> all = host_.settingRows(); - int count[kSettingGroups] = {}; + groups_ = groupsOf(all); + groupCount_.assign(groups_.size(), 0); for (const SettingRow& r : all) - if (r.group >= 0 && r.group < kSettingGroups) count[r.group]++; - groups_.clear(); - for (int g = 0; g < kSettingGroups; g++) { - groupCount_[g] = count[g]; - if (count[g] > 0) groups_.push_back(g); - } + for (size_t g = 0; g < groups_.size(); g++) + if (groups_[g].key == r.group) groupCount_[g]++; if (!built_) { built_ = true; - onRail_ = embedded() || shell_.memory().get("settings/list") == 0; // a fresh visit starts on the rail (the menu: the groups) + onRail_ = embedded() || shell_.memory().get("settings/list") == 0; // the Base layer resumes the level it was left on (list or rail); the menu's slot always starts on the groups } rail_.count = (int)groups_.size(); rail_.index = 0; - const int wantGroup = shell_.memory().get("settings/rail", groups_.empty() ? 0 : groups_[0]); + const std::string wantGroup = shell_.memory().getItem("settings/rail"); for (int i = 0; i < (int)groups_.size(); i++) - if (groups_[(size_t)i] == wantGroup) rail_.index = i; - group_ = groups_.empty() ? -1 : groups_[(size_t)rail_.index]; + if (groups_[(size_t)i].key == wantGroup) rail_.index = i; + const std::string now = groups_.empty() ? std::string() : groups_[(size_t)rail_.index].key; + if (now != group_) groupSerial_++; + group_ = now; rows_.clear(); for (const SettingRow& r : all) if (r.group == group_) rows_.push_back(r); @@ -221,8 +327,8 @@ private: void remember() { if (!embedded()) shell_.memory().put("settings/list", onRail_ ? 0 : 1); // the menu's visits always start on the groups - if (group_ < 0 || rows_.empty()) return; - shell_.memory().put("settings/rail", group_); + if (group_.empty() || rows_.empty()) return; + shell_.memory().putItem("settings/rail", group_); shell_.memory().put(memoryKey(group_), list_.index); shell_.memory().putItem(memoryKey(group_), rows_[(size_t)list_.index].id); } @@ -231,7 +337,7 @@ private: void selectGroup(int k) { if (k < 0 || k >= (int)groups_.size() || k == rail_.index) return; remember(); - shell_.memory().put("settings/rail", groups_[(size_t)k]); + shell_.memory().putItem("settings/rail", groups_[(size_t)k].key); refresh(); } @@ -244,9 +350,19 @@ private: case Intent::Down: if (f.move(1)) selectGroup(f.index); break; + case Intent::PagePrev: + case Intent::PageNext: { + pageBy(f, railVisible_, in == Intent::PageNext ? 1 : -1); + rail_.first = f.first; + selectGroup(f.index); + break; + } case Intent::Right: case Intent::Accept: - if (!rows_.empty()) onRail_ = false; // to the list, at the remembered row + if (!rows_.empty()) { // to the list, at the remembered row + onRail_ = false; + enteredByRight_ = in == Intent::Right; // a Right still held repeats into the list: those repeats are not steps + } break; default: break; } @@ -257,17 +373,22 @@ private: switch (in) { case Intent::Up: list_.move(-1); break; case Intent::Down: list_.move(1); break; + case Intent::PagePrev: pageBy(list_, listVisible_, -1); break; + case Intent::PageNext: pageBy(list_, listVisible_, 1); break; case Intent::Left: if (canStep(focused(), -1)) step(focused(), -1, false); - else onRail_ = true; // the left edge of the row: the groups + else if (!shell_.repeatIntent()) onRail_ = true; // the left edge of the row: the groups (a held Left stops there) + break; + case Intent::Right: + if (!(shell_.repeatIntent() && enteredByRight_)) step(focused(), 1, false); break; - case Intent::Right: step(focused(), 1, false); break; case Intent::Accept: accept(); break; default: break; } } const SettingRow& focused() const { return rows_[(size_t)list_.index]; } + std::string groupLabel() const { return rail_.index >= 0 && rail_.index < (int)groups_.size() ? groups_[(size_t)rail_.index].label : std::string(); } static bool canStep(const SettingRow& r, int delta) { return !r.readOnly && !r.choices.empty() && r.index + delta >= 0 && r.index + delta < (int)r.choices.size(); @@ -296,21 +417,33 @@ private: else step(r, mouseX < valueMid_[(size_t)list_.index] ? -1 : 1, false); } - // The in-menu first level: one row per group with how many settings it holds. Hovering one focuses it, a click opens it, a - // notch is a group. + // The in-menu first level: one row per group with how many settings it holds, a window of whole rows that follows the focus. + // Hovering one focuses it, a click opens it, a notch is a group. void drawGroups(const DrawCtx& c, float lx, float lw, bool pick) { const Metrics& m = c.m; - const float u = m.u, top = c.pos.y + kRowsMargin * u, pitch = (kRowH + kRowGap) * u; + const float u = m.u, top = c.pos.y + kListTop * u, pitch = (kRowH + kRowGap) * u, rowH = kRowH * u; + widgets::text(c.dl, m.caption, ImVec2(lx, c.pos.y), widgets::col(theme::pal().textMuted), // this level's name: the menu draws no rail + "Settings \xC2\xB7 " + std::to_string(groups_.size()) + (groups_.size() == 1 ? " group" : " groups"), lw); + railVisible_ = itemsThatFit(std::max(1.0f, c.pos.y + c.size.y - top), pitch, kRowGap * u, (int)groups_.size(), kArrowRoom * u); + const float scroll = railScroll_.update(rail_, railVisible_, pitch, u, false, c.now); + const float winBottom = top + (float)railVisible_ * pitch - kRowGap * u; groupTweens_.update((int)groups_.size(), rail_.index, 0, c.now); int hover = -1; + c.dl->PushClipRect(ImVec2(c.pos.x, top - kRowsMargin * u), ImVec2(c.pos.x + c.size.x, winBottom + 8 * u), true); for (int i = 0; i < (int)groups_.size(); i++) { - const int n = groupCount_[groups_[(size_t)i]]; + const float y = top + (float)i * pitch - scroll; + if (y + rowH <= top - kRowsMargin * u || y >= winBottom) continue; // wholly outside: not drawn at all + const int n = groupCount_[(size_t)i]; widgets::RowArgs a; - a.label = settingGroupLabel(groups_[(size_t)i]); + a.label = groups_[(size_t)i].label; a.value = std::to_string(n) + (n == 1 ? " setting" : " settings"); a.focus = i == rail_.index ? groupTweens_.at(i, c.now) : 0.0f; - if (widgets::row(c.dl, m, ImVec2(lx, top + (float)i * pitch), ImVec2(lw, kRowH * u), a).hovered) hover = i; + const bool h = widgets::row(c.dl, m, ImVec2(lx, y), ImVec2(lw, rowH), a).hovered; + if (h && c.mouse.y >= top && c.mouse.y < winBottom) hover = i; // not the part the clip hides } + c.dl->PopClipRect(); + drawMoreArrows(c, lx + lw * 0.5f, top - kArrowGap * u, winBottom + kArrowGap * u, rail_.first > 0, + rail_.first + railVisible_ < (int)groups_.size()); if (c.hovered && c.mouse.x >= lx && c.mouse.x < lx + lw && c.mouse.y >= c.pos.y && c.mouse.y < c.pos.y + c.size.y) wheel_ += c.wheel; const int steps = (int)wheel_; wheel_ -= (float)steps; @@ -320,24 +453,33 @@ private: if (hover >= 0 && c.clicked) intent(Intent::Accept); // opens the group } - // The label, the value between its chevrons, and a read-only row's note under the value. + // The two arrows of a scrolling run, centred on x: above its first item (y = `upY`) and below its last (`downY`). + static void drawMoreArrows(const DrawCtx& c, float x, float upY, float downY, bool up, bool down) { + if (up) widgets::moreArrow(c.dl, c.m, ImVec2(x, upY), true); + if (down) widgets::moreArrow(c.dl, c.m, ImVec2(x, downY), false); + } + + // The label (its lines from layoutRows), the value between its chevrons, and a note under the value. void drawRow(const DrawCtx& c, int index, ImVec2 pos, ImVec2 size, float f) { const SettingRow& r = rows_[(size_t)index]; + const RowLay& L = lay_[(size_t)index]; const Metrics& m = c.m; const theme::Palette& pal = theme::pal(); const float u = m.u, k = 1.0f + (m.focusScale - 1.0f) * f; // the row grows about its centre (widgets::row) const ImVec2 mid(pos.x + size.x * 0.5f, pos.y + size.y * 0.5f); const ImVec2 p0(mid.x - size.x * k * 0.5f, mid.y - size.y * k * 0.5f), p1(mid.x + size.x * k * 0.5f, mid.y + size.y * k * 0.5f); const float pad = 24 * u * (0.6f + 0.4f * k), half = (p1.x - p0.x - 2 * pad) * 0.5f; - const float px = m.body * (1.0f + 0.08f * f); + const float px = m.body * (1.0f + 0.08f * f), notePx = m.minText * (1.0f + 0.08f * f); const ImU32 tc = widgets::col(r.readOnly ? pal.textMuted : pal.text), mc = widgets::col(pal.textMuted); - const bool hasNote = !r.note.empty(); + const float hgt = p1.y - p0.y; valueMid_.resize(rows_.size(), p1.x); - const float total = hasNote ? px + m.minText + 4 * u : px; - const float y = p0.y + (p1.y - p0.y - total) * 0.5f; - const bool line = r.choices.empty() && r.readOnly; // a note ("Available once a game has run"): the whole row is its text - widgets::text(c.dl, px, ImVec2(p0.x + pad, p0.y + (p1.y - p0.y - px) * 0.5f), tc, r.label, line ? 2 * half : half); + const float nL = (float)std::max<size_t>(1, L.label.size()); + const float labelH = nL * px + (nL - 1) * 4 * u; + widgets::textLines(c.dl, px, ImVec2(p0.x + pad, p0.y + (hgt - labelH) * 0.5f), tc, L.label, 4 * u); + // The value's side: the value (and, under it, the note's lines), as one block centred in the row. + const float noteH = L.note.empty() ? 0.0f : 4 * u + (float)L.note.size() * notePx + (float)(L.note.size() - 1) * 2 * u; + const float y = p0.y + (hgt - (px + noteH)) * 0.5f; const float chevW = 9 * u * k, chevH = 18 * u * k, gap = 24 * u * k, thick = 3 * u; const float cy = y + px * 0.5f; auto chevron = [&](float x, bool toRight, ImU32 col) { // an open "<" or ">" (a font may lack the glyphs) @@ -358,28 +500,30 @@ private: valueMid_[(size_t)index] = valueRight - vw * 0.5f; if (canStep(r, -1)) chevron(valueRight - vw - gap - chevW, false, mc); if (canStep(r, 1)) chevron(inner - chevW, true, mc); - if (hasNote) { - const float nw = std::min(widgets::textWidth(m.minText, r.note), half); - widgets::text(c.dl, m.minText, ImVec2(inner - nw, y + px + 4 * u), mc, r.note, nw); - } + if (!L.note.empty()) widgets::textLines(c.dl, notePx, ImVec2(inner, y + px + 4 * u), mc, L.note, 2 * u, true); } Host& host_; Shell& shell_; - std::vector<int> groups_; // the groups that have rows, in order; the rail's items - int groupCount_[kSettingGroups] = {}; // how many rows each group has, from the last refresh() + std::vector<SettingGroup> groups_; // the groups that have rows, in order; the rail's items + std::vector<int> groupCount_; // how many rows each of them has, from the last refresh() widgets::FocusTweens groupTweens_; // the menu's group rows std::vector<SettingRow> rows_; // the focused group's rows, from the last refresh() + struct RowLay { + std::vector<std::string> label, note; // the lines each is broken into (layoutRows) + }; + std::vector<RowLay> lay_; // per row of rows_, from the last draw + float rowH_ = 0; // the rows' height at the last draw, pixels LinearFocus rail_, list_; std::vector<float> valueMid_; // per row of the last draw: x of the middle of its value (where a click turns from back to forward) - int group_ = -1; // the focused group (settingGroupLabel); -1 when there are no rows - int lastGroup_ = -1; + std::string group_; // the focused group's key; "" when there are no rows + std::string lastGroup_; + int groupSerial_ = 0; // moves with group_: what the row tweens restart on bool built_ = false; bool onRail_ = true; - Tween scroll_; // vertical scroll of the rows, in pixels - float scrollTo_ = 0; // ... and where it is heading - bool scrollInit_ = false; - float lastU_ = 0; + Scroll railScroll_, listScroll_; // the rail's window and the rows' (whole items; first is LinearFocus::first) + int railVisible_ = 8, listVisible_ = 8; // how many items each window showed at the last draw (LT / RT page by them) + bool enteredByRight_ = false; // the last move from the rail to the list was a Right: its repeats are not steps widgets::FocusTweens tweens_; ImVec2 lastMouse_{-FLT_MAX, -FLT_MAX}; bool mouseSeen_ = false; diff --git a/app/src/tv/tv_settings_model.cpp b/app/src/tv/tv_settings_model.cpp index 44da6ed4..b86eb8c0 100644 --- a/app/src/tv/tv_settings_model.cpp +++ b/app/src/tv/tv_settings_model.cpp @@ -1,23 +1,48 @@ #include "tv/tv_settings_model.h" #include <algorithm> +#include <cctype> #include <cmath> +#include <cstdio> +#include <string> #include <utility> namespace vj::tv { namespace { -SettingRow rowOf(const std::string& id, const std::string& label, int group, std::vector<std::string> choices, int index) { +SettingRow rowOf(const std::string& id, const std::string& label, const std::string& group, const std::string& groupLabel, + std::vector<std::string> choices, int index) { SettingRow r; r.id = id; r.label = label; r.group = group; + r.groupLabel = groupLabel; r.choices = std::move(choices); r.index = index; return r; } +constexpr const char* kCustomSuffix = " (custom)"; + +bool endsWith(const std::string& s, const char* suffix) { + const size_t n = std::char_traits<char>::length(suffix); + return s.size() >= n && s.compare(s.size() - n, n, suffix) == 0; +} + +bool mentionsRestart(const std::string& text) { + std::string t; + for (char c : text) t.push_back((char)std::tolower((unsigned char)c)); + return t.find("restart") != std::string::npos; +} + +// "%g": 85 -> "85", 0.55 -> "0.55" (the value of a slider position that is not on the list's grid). +std::string plainNumber(float v) { + char b[32]; + std::snprintf(b, sizeof b, "%g", (double)v); + return b; +} + // The file name without its folders (either separator) and its extension. std::string stemOf(const std::string& path) { const size_t slash = path.find_last_of("/\\"); @@ -29,48 +54,151 @@ std::string stemOf(const std::string& path) { } // namespace -const char* settingGroupLabel(int group) { - switch (group) { - case 0: return "Picture"; - case 1: return "Speed"; - case 2: return "Sound"; - case 3: return "Interface"; - } - return ""; +namespace { + +// The number a typed option's value stands for ("200%", "8000"); false for a Host key or text that is not one. +bool typedNumber(const TypedOption& t, const std::string& v, long& out) { + if (t.kind == OptKind::Percent) return parseTypedPercent(v, out); + if (t.kind == OptKind::Int) return parseTypedInt(v, out); + return false; } -SettingRow coreOptionRow(const std::string& id, const std::string& label, int group, const OptionDef* def, const std::string& current, - bool visible, bool locked, bool overridden, bool* hidden) { - SettingRow r = rowOf(id, label, group, {}, 0); - const bool hide = !def || def->values.empty() || !visible || locked; - if (hidden) *hidden = hide; - if (hide) return r; +// One option as a row of `group`; empty (`ok` false) when it has no row. +SettingRow coreRow(const OptionDef& d, const std::string& group, const std::string& groupLabel, const std::string& current, + bool overridden, const TypedOption* typed, bool* ok) { + SettingRow r = rowOf("core:" + d.key, !d.descCategorized.empty() ? d.descCategorized : d.desc, group, groupLabel, {}, 0); + *ok = !d.values.empty(); + if (!*ok) return r; int found = -1; - for (size_t i = 0; i < def->values.size(); i++) { - const OptionValue& v = def->values[i]; + for (size_t i = 0; i < d.values.size(); i++) { + const OptionValue& v = d.values[i]; r.choices.push_back(v.label.empty() ? v.value : v.label); if (v.value == current) found = (int)i; } - if (found < 0 && !current.empty()) { // a typed value the core's list does not have - r.choices.push_back(current + " (custom)"); - found = (int)r.choices.size() - 1; + const int custom = coreCustomPos(d, current, typed); + if (custom >= 0) { // a value the core's list does not have (a typed RISC clock), where stepping from it goes both ways + r.choices.insert(r.choices.begin() + custom, current + kCustomSuffix); + found = custom; } r.index = std::max(found, 0); if (overridden) { // the desktop row is disabled for the same reason (drawOption) r.readOnly = true; - r.note = "Set for this session by Netplay or --option"; + r.note = "Set by Netplay or --option for this session"; + } else if (typed && typed->kind == OptKind::Host) { // only ever typed: the desktop window has the box + r.readOnly = true; + r.note = "Type it in Desktop settings"; + } else if (mentionsRestart(d.info) || mentionsRestart(d.infoCategorized)) { + r.note = "Applies when the game next starts"; } return r; } -SettingRow onOffRow(const std::string& id, const std::string& label, int group, bool on) { - return rowOf(id, label, group, {"Off", "On"}, on ? 1 : 0); +} // namespace + +int coreCustomPos(const OptionDef& d, const std::string& current, const TypedOption* typed) { + if (current.empty()) return -1; + for (const OptionValue& v : d.values) + if (v.value == current) return -1; + const int last = (int)d.values.size(); // text, a Host key, or presets that are not in numeric order: the entry goes last + long cur = 0; + if (!typed || !typedNumber(*typed, current, cur)) return last; + long prev = 0; + int pos = last; + for (int i = 0; i < last; i++) { + long v = 0; + if (!typedNumber(*typed, d.values[(size_t)i].value, v) || (i > 0 && v < prev)) return last; + prev = v; + if (pos == last && v > cur) pos = i; + } + return pos; +} + +std::vector<SettingRow> coreOptionRows(const std::vector<OptionCategory>& cats, const std::vector<OptionDef>& defs, + const std::function<std::string(const OptionDef&)>& current, + const std::function<bool(const std::string&)>& visible, + const std::function<bool(const std::string&)>& overridden, + const std::function<const TypedOption*(const std::string&)>& typed) { + std::vector<SettingRow> rows; + auto add = [&](const OptionDef& d, const std::string& group, const std::string& label) { + if (visible && !visible(d.key)) return; + bool ok = false; + SettingRow r = coreRow(d, group, label, current ? current(d) : d.defaultValue, overridden && overridden(d.key), + typed ? typed(d.key) : nullptr, &ok); + if (ok) rows.push_back(std::move(r)); + }; + auto known = [&](const std::string& key) { + for (const OptionCategory& c : cats) + if (c.key == key) return true; + return false; + }; + for (const OptionDef& d : defs) // no category, or one the core never defined: the desktop lists those under General too + if (d.categoryKey.empty() || !known(d.categoryKey)) add(d, "core:", "General"); + for (const OptionCategory& c : cats) + for (const OptionDef& d : defs) + if (d.categoryKey == c.key) add(d, "core:" + c.key, c.desc); + return rows; +} + +SettingRow choiceRow(const std::string& id, const std::string& label, const std::string& group, const std::string& groupLabel, + std::vector<std::string> choices, int index) { + return rowOf(id, label, group, groupLabel, std::move(choices), index); +} + +SettingRow sliderRow(const std::string& id, const std::string& label, const std::string& group, const std::string& groupLabel, + float current, float lo, float hi, float step, const char* fmt) { + std::vector<std::string> labels; + const int n = step > 0 && hi >= lo ? (int)std::lround((hi - lo) / step) + 1 : 1; + char b[64]; + for (int i = 0; i < n; i++) { + std::snprintf(b, sizeof b, fmt, (double)(lo + (float)i * step)); + labels.push_back(b); + } + const int at = step > 0 ? (int)std::lround((current - lo) / step) : 0; + int index = -1; + if (at >= 0 && at < n && std::fabs(current - (lo + (float)at * step)) <= step * 1e-3f) index = at; + if (index < 0) { // off the grid (the desktop slider is finer): shown as it is, between its neighbours, selected, kept until the player steps + int pos = n; + for (int i = 0; i < n; i++) + if (lo + (float)i * step > current) { + pos = i; + break; + } + labels.insert(labels.begin() + pos, plainNumber(current) + kCustomSuffix); + index = pos; + } + return rowOf(id, label, group, groupLabel, std::move(labels), index); +} + +float stepValue(const SettingRow& r, int index, float lo, float step, float current) { + int custom = -1; // where sliderRow put the "(custom)" entry, if it did + for (size_t i = 0; i < r.choices.size(); i++) + if (endsWith(r.choices[i], kCustomSuffix)) custom = (int)i; + const int grid = (int)r.choices.size() - (custom >= 0 ? 1 : 0); + if (index < 0 || index >= (int)r.choices.size() || index == custom) return current; // the "(custom)" entry: nothing to change + const int g = custom >= 0 && index > custom ? index - 1 : index; + return g < grid ? lo + (float)g * step : current; +} + +std::vector<SettingGroup> groupsOf(const std::vector<SettingRow>& rows) { + std::vector<SettingGroup> out; + for (const SettingRow& r : rows) { + bool seen = false; + for (const SettingGroup& g : out) + if (g.key == r.group) { + seen = true; + break; + } + if (!seen) out.push_back({r.group, r.groupLabel}); + } + return out; +} + +SettingRow onOffRow(const std::string& id, const std::string& label, const std::string& group, const std::string& groupLabel, bool on) { + return rowOf(id, label, group, groupLabel, {"Off", "On"}, on ? 1 : 0); } SettingRow volumeRow(float volume) { - std::vector<std::string> pct; - for (int i = 0; i <= 10; i++) pct.push_back(std::to_string(i * 10) + " %"); - return rowOf("volume", "Volume", 2, std::move(pct), std::clamp((int)std::lround(volume * 10.0f), 0, 10)); + return sliderRow("volume", "Volume", "sound", "Sound", std::round(std::clamp(volume, 0.0f, 1.0f) * 1000.0f) / 10.0f, 0.0f, 100.0f, 10.0f, "%.0f %%"); } SettingRow aspectRow(float aspectOverride, const AspectChoice* table, int n) { @@ -80,11 +208,11 @@ SettingRow aspectRow(float aspectOverride, const AspectChoice* table, int n) { labels.push_back(table[i].label); if (std::abs(aspectOverride - table[i].value) < 1e-3f) sel = i; } - return rowOf("aspect", "Aspect ratio", 0, std::move(labels), sel); + return rowOf("aspect", "Aspect ratio", "picture", "Picture", std::move(labels), sel); } SettingRow themeRow(const std::string& choiceKey, const std::vector<std::string>& customNames) { - SettingRow r = rowOf("theme", "Theme", 3, {"System", "Dark", "Light"}, 0); + SettingRow r = rowOf("theme", "Theme", "interface", "Interface", {"System", "Dark", "Light"}, 0); for (const std::string& n : customNames) r.choices.push_back("Custom: " + n); if (choiceKey == "dark") r.index = 1; else if (choiceKey == "light") r.index = 2; @@ -106,11 +234,17 @@ std::string themeKeyAt(int index, const std::vector<std::string>& customNames) { } SettingRow tvModeRow(TvSetting stored) { - return rowOf("tv_mode", "TV mode", 3, {"Off", "Manual", "Auto"}, (int)stored); + return rowOf("tv_mode", "TV mode", "interface", "Interface", {"Off", "Manual", "Auto"}, (int)stored); +} + +std::vector<SettingRow> tvAutoRows(TvSetting stored, bool autoFullscreen, bool autoController) { + if (stored != TvSetting::Auto) return {}; + return {onOffRow("tv_auto_fs", "Auto: in fullscreen", "interface", "Interface", autoFullscreen), + onOffRow("tv_auto_pad", "Auto: when a controller is used", "interface", "Interface", autoController)}; } SettingRow shaderRow(const std::string& currentPreset, bool enabled, const std::vector<std::string>& presetPaths) { - SettingRow r = rowOf("shader", "Shader", 0, {"Off"}, 0); + SettingRow r = rowOf("shader", "Shader", "picture", "Picture", {"Off"}, 0); int sel = 0; for (size_t i = 0; i < presetPaths.size(); i++) { r.choices.push_back(stemOf(presetPaths[i])); @@ -124,13 +258,7 @@ SettingRow shaderRow(const std::string& currentPreset, bool enabled, const std:: return r; } -SettingRow speedNoteRow() { - SettingRow r = rowOf("speed_note", "Available once a game has run", 1, {}, 0); - r.readOnly = true; - return r; -} - -SettingRow moreRow() { return rowOf("more", "More settings\xE2\x80\xA6", 3, {}, 0); } +SettingRow moreRow() { return rowOf("more", "Desktop settings\xE2\x80\xA6", "interface", "Interface", {}, 0); } int stepIndex(const SettingRow& r, int delta, bool wrap) { const int n = (int)r.choices.size(); diff --git a/app/src/tv/tv_settings_model.h b/app/src/tv/tv_settings_model.h index 7978cda4..17508d66 100644 --- a/app/src/tv/tv_settings_model.h +++ b/app/src/tv/tv_settings_model.h @@ -3,39 +3,69 @@ // core's list is kept and shown as custom) are unit-tested on their own; AppHost only gathers the inputs. #pragma once +#include <functional> #include <string> #include <vector> #include "aspect_choices.h" -#include "emu_snapshot.h" // OptionDef +#include "emu_snapshot.h" // OptionDef, OptionCategory +#include "typed_options.h" #include "tv/tv_host.h" #include "tv/tv_mode.h" namespace vj::tv { -constexpr int kSettingGroups = 4; -const char* settingGroupLabel(int group); // "Picture" "Speed" "Sound" "Interface"; "" outside 0..3 +// A rail item: its key (SettingRow::group) and what the rail shows (SettingRow::groupLabel). +struct SettingGroup { + std::string key, label; +}; -// One core option as a row. hidden: no definition (no game has run yet), not visible, locked by the app, or nothing to -// choose from. readOnly + note when a session override (Netplay, --option) decides what the core reads. A current value -// outside the list (a typed RISC clock) is appended as "<value> (custom)" and selected. `hidden` may be null. -SettingRow coreOptionRow(const std::string& id, const std::string& label, int group, const OptionDef* def, const std::string& current, - bool visible, bool locked, bool overridden, bool* hidden); -SettingRow onOffRow(const std::string& id, const std::string& label, int group, bool on); -SettingRow volumeRow(float volume); // "0 %" .. "100 %", index = round(volume * 10) -SettingRow aspectRow(float aspectOverride, const AspectChoice* table, int n); +// Every visible core option as a row, in category order: the options with no category first under {"core:", "General"} +// (an option naming a category the core never defined counts as none), then each category in definition order under +// {"core:" + cat.key, cat.desc}; a category with nothing to show makes no group. id = "core:" + key, label = the +// categorized title (else the flat one), value = the choice's label (else its value). The rules of decision 4: +// * not visible (the core's own gates and the app's locks both end in `visible`), or nothing to choose from: no row; +// * `overridden` (Netplay, --option): read-only, with the note that says so; +// * a value that is not one of the core's presets (a typed RISC clock): "<value> (custom)" is selected, placed by +// coreCustomPos (between its numeric neighbours for an ordered typed number, else last); +// * a Host-kind typed key (the Network Link address) is read-only: it can only be typed, in the desktop window; +// * an option whose help says it needs a restart carries the note "Applies when the game next starts". +// `current`, `visible`, `overridden` and `typed` are per option, so the model never sees CoreHost. +std::vector<SettingRow> coreOptionRows(const std::vector<OptionCategory>& cats, const std::vector<OptionDef>& defs, + const std::function<std::string(const OptionDef&)>& current, + const std::function<bool(const std::string&)>& visible, + const std::function<bool(const std::string&)>& overridden, + const std::function<const TypedOption*(const std::string&)>& typed); +// Where the "(custom)" entry of an option row sits in its choices (and the row's index), or -1 when `current` is one of the +// core's values or empty. A typed numeric option whose presets run in numeric order gets it between its neighbours, so Left and +// Right step to the nearest presets; any other (a text, an address, presets out of order) gets it last. AppHost maps a stepped +// index back to `def.values` with it. +int coreCustomPos(const OptionDef& d, const std::string& current, const TypedOption* typed); +SettingRow choiceRow(const std::string& id, const std::string& label, const std::string& group, const std::string& groupLabel, + std::vector<std::string> choices, int index); +// A slider as a stepped list: "lo", "lo+step", ... "hi" printed with fmt ("%.0f ms"); a value off the grid is inserted between +// its grid neighbours (last when beyond "hi") as "<value> (custom)" and selected, so Left / Right step to the nearest grid values. +// stepValue() maps an index back (the "(custom)" index gives `current` unchanged). +SettingRow sliderRow(const std::string& id, const std::string& label, const std::string& group, const std::string& groupLabel, + float current, float lo, float hi, float step, const char* fmt); +float stepValue(const SettingRow& r, int index, float lo, float step, float current); +std::vector<SettingGroup> groupsOf(const std::vector<SettingRow>& rows); // first-seen order, keys unique + +SettingRow onOffRow(const std::string& id, const std::string& label, const std::string& group, const std::string& groupLabel, bool on); +SettingRow volumeRow(float volume); // Sound: "0 %" .. "100 %" in tens (a volume between two is shown as custom) +SettingRow aspectRow(float aspectOverride, const AspectChoice* table, int n); // Picture // keys: system dark light custom:<name>. A custom theme that is no longer in the list is still the one in force: it is // appended and selected (and themeKeyAt() gives "" for it, so picking it changes nothing). -SettingRow themeRow(const std::string& choiceKey, const std::vector<std::string>& customNames); +SettingRow themeRow(const std::string& choiceKey, const std::vector<std::string>& customNames); // Interface std::string themeKeyAt(int index, const std::vector<std::string>& customNames); // "" when out of range -SettingRow tvModeRow(TvSetting stored); // Off / Manual / Auto +SettingRow tvModeRow(TvSetting stored); // Interface: Off / Manual / Auto +// The two "Auto: ..." On / Off rows (tv_auto_fs, tv_auto_pad; Interface) that follow the TV mode row: offered only while the +// stored setting is Auto, since they decide nothing under Off or Manual. Empty otherwise. +std::vector<SettingRow> tvAutoRows(TvSetting stored, bool autoFullscreen, bool autoController); // "Off" then each preset's file name without its extension. The preset in force when it is in neither list (a file picked -// in the desktop window) is appended as "<stem> (custom)". +// in the desktop window) is appended as "<stem> (custom)". Picture. SettingRow shaderRow(const std::string& currentPreset, bool enabled, const std::vector<std::string>& presetPaths); -// The Speed group's only row while no game has run this session (its core options are not known yet): a read-only line, never -// steppable, saying why. -SettingRow speedNoteRow(); -SettingRow moreRow(); // "More settings..." (opens the desktop window): no choices +SettingRow moreRow(); // Interface: "Desktop settings..." (opens the desktop window): no choices // Left / Right (wrap = false: stops at the ends) and Accept (wrap = true). A row with no choices stays where it is. int stepIndex(const SettingRow& r, int delta, bool wrap); diff --git a/app/src/tv/tv_shell.cpp b/app/src/tv/tv_shell.cpp index 5c8f9d68..b93337eb 100644 --- a/app/src/tv/tv_shell.cpp +++ b/app/src/tv/tv_shell.cpp @@ -58,12 +58,63 @@ void Shell::closeMenu() { if (!open_) return; open_ = false; retire(std::move(menuScreen_)); // the slot's screen may be the caller (Overlays closes the menu from its own Accept) + retire(std::move(keptScreen_)); // ... and so may the one ConfirmLaunch was keeping (the Library's Yes) nav_.reset(); host_.menuResume(); host_.armReleaseGuard(); // whatever is down now (the A that picked Resume) stays masked from the game host_.clearHolds(); // ... and no hold or tap pulse in flight survives the hand-off } +bool Shell::openSettings() { + if (!host_.tvActive()) return false; + if (!host_.gameLoaded()) { // the Base layer + if (section_ != Section::Settings || stack_.empty()) setSection(Section::Settings); + return true; + } + if (!openMenu()) return false; // already open: true. A sub-screen pushed on the slot's screen (or a deeper level of it) is left as is. + if (menuScreenId() != "settings") menuPushScreen(makeSettingsScreen(host_, *this), Row::Settings); + return true; +} + +bool Shell::openLibrary() { + if (!host_.tvActive()) return false; + if (!host_.gameLoaded()) { // the Base layer + if (section_ != Section::Library || stack_.empty()) setSection(Section::Library); + return true; + } + if (!openMenu()) return false; // as openSettings(): the hardcore gate and its toast + if (menuScreenId() != "library") menuPushScreen(makeLibraryScreen(host_, *this), Row::Library); + return true; +} + +bool Shell::showingLibrary() const { + if (open_) return menuScreenId() == "library"; // (with a game the Base stack is kept but not shown) + return baseActive() && section_ == Section::Library; +} + +void Shell::requestLaunch(const GameRef& g, const StateRef* state) { + if (!host_.gameLoaded()) { // nothing to lose (the Base layer): start it now + if (state) host_.launchState(g, *state); + else host_.launch(g); + launched(); + return; + } + if (!openMenu()) return; // a game is running and the question has no menu to sit in (not reachable from a screen today): never launch over it unasked + pending_.game = g; + pending_.hasState = state != nullptr; + if (state) pending_.state = *state; + std::unique_ptr<Screen> kept = std::move(menuScreen_); // enter() would retire the slot's screen: No wants it back + enter(Page::ConfirmLaunch, Row::No); + keptScreen_ = std::move(kept); +} + +// ConfirmLaunch's No / Back: the root with the Library back in its slot (the screen kept aside, with its focus). +void Shell::declineLaunch() { + std::unique_ptr<Screen> kept = std::move(keptScreen_); + enter(Page::Root, Row::Library); + if (kept) menuPushScreen(std::move(kept), Row::Library); +} + void Shell::toggleMenu() { if (open_) closeMenu(); else openMenu(); @@ -107,6 +158,11 @@ bool Shell::handleEvent(const SDL_Event& e, uint64_t ms) { // Alt+Enter and the like are hotkeys (a press only: a release always reaches the nav, or a repeat could stick). if (down && (e.key.mod & (SDL_KMOD_CTRL | SDL_KMOD_ALT | SDL_KMOD_GUI))) return false; const bool shift = (e.key.mod & SDL_KMOD_SHIFT) != 0; + // Typing goes to the slot's screen under the menu too (the Library's filter); a text field of a desktop window has the keyboard first. + if (!base && down && menuScreen_ && !host_.desktopWindowOpen() && !(ImGui::GetCurrentContext() && ImGui::GetIO().WantTextInput)) { + const char c = typedChar(e.key); + if (c && menuScreen_->typed(c)) return true; + } if (base && down) { // A text field of a desktop window has the keyboard: every key is the field's (Enter, Tab, Backspace, the // arrows, Esc too). Key-ups still reach the nav below, so a repeat that began earlier can stop. @@ -161,16 +217,24 @@ void Shell::frame(uint64_t ms) { if (!baseActive()) return; if (stack_.empty()) setSection(Section::Home); if (!live) return; // a desktop window has the pads and keys: the screens wait - for (Intent in : nav_.take(ms)) baseIntent(in); + const std::vector<Intent> in = nav_.take(ms); + for (size_t i = 0; i < in.size(); i++) { + repeat_ = nav_.lastWasRepeat() && i + 1 == in.size(); // a repeat is the last intent of its call + baseIntent(in[i]); + } + repeat_ = false; return; } // (Not under a slot screen: enter() would clear it. Leaving the screen rebuilds the root anyway.) if (!menuScreen_ && page_ == Page::Root && (host_.canLoadState() != rootHasLoad_ || tvRowState() != rootTvRow_)) enter(Page::Root, rows_[(size_t)focus_.index].kind); - for (Intent in : nav_.take(ms)) { - intent(in); + const std::vector<Intent> in = nav_.take(ms); + for (size_t i = 0; i < in.size(); i++) { + repeat_ = nav_.lastWasRepeat() && i + 1 == in.size(); + intent(in[i]); if (!open_) break; } + repeat_ = false; } void Shell::draw(Layer layer, ImVec2 pos, ImVec2 size) { @@ -220,6 +284,20 @@ void Shell::retire(std::unique_ptr<Screen> s) { if (s) retired_.push_back(std::move(s)); } +// With the in-game menu open it is the menu that has the focus (the Base stack is kept under the game, not shown): the slot's screen, or the row. +std::string Shell::focusLabel() { + if (open_) { + if (menuScreen_) return menuScreen_->focusLabel(); + return focus_.index >= 0 && focus_.index < (int)rows_.size() ? rows_[(size_t)focus_.index].label : std::string(); + } + return stack_.empty() ? std::string() : stack_.back()->focusLabel(); +} + +std::string Shell::focusGroup() { + if (open_) return menuScreen_ ? menuScreen_->focusGroup() : std::string(); + return stack_.empty() ? std::string() : stack_.back()->focusGroup(); +} + std::string Shell::screenId() const { return stack_.empty() ? std::string() : std::string(stack_.back()->id()); } // While a binding is being captured App::handleEvent keeps every event from the shell: a direction that was down when the @@ -269,6 +347,7 @@ void Shell::buildRoot() { rows_.push_back({Row::Save, "Save state"}); rootHasLoad_ = host_.canLoadState(); if (rootHasLoad_) rows_.push_back({Row::Load, "Load state"}); // hardcore refuses loads: no row at all + rows_.push_back({Row::Library, "Library"}); // the TV Library over the paused game (a launch from it asks first) rows_.push_back({Row::Achievements, "Achievements"}); // spec 4's order; the three open a screen in the slot rows_.push_back({Row::Overlays, "Overlays"}); rows_.push_back({Row::Settings, "Settings"}); @@ -281,6 +360,7 @@ void Shell::buildRoot() { void Shell::enter(Page p, Row::Kind focusKind) { retire(std::move(menuScreen_)); // a page replaces whatever the slot held + retire(std::move(keptScreen_)); // (declineLaunch() and requestLaunch() move theirs out first) page_ = p; switch (p) { case Page::Root: buildRoot(); break; @@ -306,6 +386,12 @@ void Shell::enter(Page p, Row::Kind focusKind) { rows_.push_back({Row::Yes, "Yes"}); rows_.push_back({Row::No, "No"}); // focus starts on the safe answer (enter() is given Row::No) break; + case Page::ConfirmLaunch: // as Reset and Close: Yes, No, the focus on No (every confirm is answered the same way) + title_ = "Close " + host_.gameTitle() + " and start " + pending_.game.title + "?"; + rows_.clear(); + rows_.push_back({Row::Yes, "Yes"}); + rows_.push_back({Row::No, "No"}); + break; } focus_ = LinearFocus(); focus_.count = (int)rows_.size(); @@ -320,6 +406,7 @@ void Shell::menuPushScreen(std::unique_ptr<Screen> s, Row::Kind from) { if (!open_ || !s) return; s->setEmbedded(true); retire(std::move(menuScreen_)); + retire(std::move(keptScreen_)); // (a Settings / Library opened over a pending launch) menuScreen_ = std::move(s); menuFrom_ = from; } @@ -360,6 +447,7 @@ void Shell::accept() { case Row::Achievements: menuPushScreen(makeAchievementsScreen(host_, *this), Row::Achievements); break; case Row::Overlays: menuPushScreen(makeOverlaysScreen(host_, *this), Row::Overlays); break; case Row::Settings: menuPushScreen(makeSettingsScreen(host_, *this), Row::Settings); break; + case Row::Library: menuPushScreen(makeLibraryScreen(host_, *this), Row::Library); break; case Row::Controls: closeMenu(); // the sheet is drawn over the game, so the game has to be showing host_.showControlsOverlay(); @@ -377,9 +465,20 @@ void Shell::accept() { if (host_.loadState(r.slot)) closeMenu(); } break; - case Row::No: enter(Page::Root, page_ == Page::ConfirmReset ? Row::Reset : Row::Close); break; + case Row::No: + if (page_ == Page::ConfirmLaunch) declineLaunch(); + else enter(Page::Root, page_ == Page::ConfirmReset ? Row::Reset : Row::Close); + break; case Row::Yes: - if (page_ == Page::ConfirmReset) { + if (page_ == Page::ConfirmLaunch) { + // The app closes the running game's menu and auto-saves it on the way (App::openContent, a queued event); until it does the + // Library is what the menu shows again, so a refused launch (the debugger has the game halted) leaves the player somewhere. + const Pending p = pending_; + if (p.hasState) host_.launchState(p.game, p.state); + else host_.launch(p.game); + launched(); + if (open_) declineLaunch(); // (a host that closed the menu on the way has nothing to come back to) + } else if (page_ == Page::ConfirmReset) { host_.reset(); closeMenu(); } else { @@ -400,6 +499,7 @@ void Shell::back() { case Page::LoadSlots: enter(Page::Root, Row::Load); break; case Page::ConfirmReset: enter(Page::Root, Row::Reset); break; case Page::ConfirmClose: enter(Page::Root, Row::Close); break; + case Page::ConfirmLaunch: declineLaunch(); break; } } diff --git a/app/src/tv/tv_shell.h b/app/src/tv/tv_shell.h index 89771f6e..34b923a7 100644 --- a/app/src/tv/tv_shell.h +++ b/app/src/tv/tv_shell.h @@ -3,8 +3,8 @@ // pads and keyboard belong to while either is up (spec 3.1). Everything it needs of the app goes through tv::Host. // // The in-game menu is plain data here: pages of rows, a focus index, and what Accept / Back do on each. Drawing it -// (tv_ingame.cpp) only reads that model. Beside the pages sits one slot for a tv::Screen (Achievements, Overlays, Settings): while -// it is set the panel's list area is that screen's, it gets every intent but Back, and Back clears it onto the row it came from. The Base layer is a stack of tv::Screen (tv_screen.h) with a section tab +// (tv_ingame.cpp) only reads that model. Beside the pages sits one slot for a tv::Screen (Achievements, Overlays, Settings, Library): while +// it is set the panel's list area is that screen's (the whole panel for a wide() one), it gets every intent but Back and the typing, and Back clears it onto the row it came from. The Base layer is a stack of tv::Screen (tv_screen.h) with a section tab // per root screen; the chrome around it is tv_chrome.cpp. // // Input: while the shell owns the input handleEvent() feeds NavInput with the buttons / keys / axes that mean @@ -40,9 +40,9 @@ enum class Layer { Base, Menu }; // Base: TV screens, under desktop windows; M class Shell { public: // ---- the in-game menu model (read by tv_ingame.cpp) --------------------------------------------------------- - enum class Page { Root, SaveSlots, LoadSlots, ConfirmReset, ConfirmClose }; + enum class Page { Root, SaveSlots, LoadSlots, ConfirmReset, ConfirmClose, ConfirmLaunch }; struct Row { - enum Kind { Resume, Save, Load, Achievements, Overlays, Settings, Controls, TvMode, Reset, Close, Slot, No, Yes }; + enum Kind { Resume, Save, Load, Library, Achievements, Overlays, Settings, Controls, TvMode, Reset, Close, Slot, No, Yes }; Kind kind; std::string label; int slot = -1; // Slot rows: the state slot @@ -52,9 +52,24 @@ public: explicit Shell(Host& h); bool menuOpen() const { return open_; } - bool openMenu(); // menuPause(); refused -> toast(why), false + bool openMenu(); // menuPause(); a host may still refuse (toast(why), false) void closeMenu(); // menuResume(), armReleaseGuard(), clearHolds() void toggleMenu(); + // Settings, wherever the shell is showing: TV mode on with no game -> the Settings section; with a game -> the in-game menu (opened + // the way C_TVMENU opens it, so the same pause / hardcore gate) with the Settings screen in its slot. Idempotent (already there: + // nothing changes). False = TV mode is off (or a host refused the menu). + bool openSettings(); + // The Library, wherever the shell is showing: as openSettings(), with the Library in the slot (a game) or as the Library section. + // Idempotent; False = TV mode is off (or a host refused the menu). + bool openLibrary(); + // The Library is what the shell is showing now: the Library section on the Base layer, or the Library in the menu's slot (the + // native File menu's checkmark). + bool showingLibrary() const; + // A screen wants to start `g` (with `state` loaded when given; null = fresh). No game running: it starts at once (host.launch / + // launchState, then launched()). A game running (the screen is the menu's slot): the menu asks first -- Page::ConfirmLaunch, + // "Close <running> and start <g>?", focus on No. Yes starts it (the app closes the menu on its way); No and Back return to the slot. + // Rows Yes, No with the focus on No, as the Reset and Close confirms. + void requestLaunch(const GameRef& g, const StateRef* state); bool ownsInput() const; // §3.1: inputOwner() == TvUi: the menu is open, or TV is active with no game running bool handleEvent(const SDL_Event& e, uint64_t ms); // nav input while ownsInput(); true = consumed void frame(uint64_t ms); // NavInput::take -> the top screen @@ -68,6 +83,10 @@ public: void openSection(Section s, std::unique_ptr<Screen> root); // ... with a root the caller prepared (Game details -> Achievements on a game) void push(std::unique_ptr<Screen> s); void pop(); // never pops the root + std::string focusLabel(); // the top screen's focusLabel(); with the in-game menu open, the menu's (the slot screen's, else the row's); "" when none + std::string focusGroup(); // the top screen's focusGroup() (Settings: the rail's group key), or the slot screen's with the menu open + // The intent being handled is a repeat of a held direction, not a press (NavInput's timer). Only meaningful inside Screen::intent. + bool repeatIntent() const { return repeat_; } std::string screenId() const; // the top screen's id(), "" when the stack is empty FocusMemory& memory() { return memory_; } void launched(); // a game was started from a screen: armReleaseGuard() + clearHolds() @@ -93,6 +112,7 @@ private: int tvRowState() const; // rootTvRow_ as the host says it is now void accept(); void back(); + void declineLaunch(); // ConfirmLaunch's No / Back: the Library comes back in the slot, where it was void clearMenuScreen(); // the slot's Back: retire the screen and return to the root, focused on the row it came from void drawMenu(ImVec2 pos, ImVec2 size); // tv_ingame.cpp: the menu model drawn over the dimmed game void drawBase(ImVec2 pos, ImVec2 size); // tv_chrome.cpp: the Base window -- chrome, then the stack @@ -113,6 +133,12 @@ private: bool focusWindow_ = false; // the menu just opened: its window takes the focus once bool rootHasLoad_ = false; // the Load-state row is in the root page (re-evaluated while it shows) std::unique_ptr<Screen> menuScreen_; // the slot; set only while open_ (closeMenu() and enter() retire it) + std::unique_ptr<Screen> keptScreen_; // the slot's screen while ConfirmLaunch asks (enter() would retire it); No puts it back + struct Pending { + GameRef game; + StateRef state{-1, 0}; + bool hasState = false; + } pending_; // what ConfirmLaunch's Yes starts Row::Kind menuFrom_ = Row::Resume; // the root row that opened it int rootTvRow_ = 0; // the TV-mode row of the root page: 0 = none, 1 = "Enter TV mode", 2 = "Exit TV mode" (re-evaluated while it shows) @@ -123,6 +149,7 @@ private: unsigned stackGen_ = 0; // bumped on every push / pop / section change: drawBase stops when its list went stale FocusMemory memory_; bool capturing_ = false; + bool repeat_ = false; // the intent in hand came from NavInput's repeat timer bool baseWas_ = false; // baseActive() with no desktop window open over it, at the last frame(): a change resets the nav bool padDriven_ = false; // the last input was a pad: the pointer is hidden until the mouse moves }; diff --git a/app/src/tv/tv_widgets.cpp b/app/src/tv/tv_widgets.cpp index cd22c65e..c8571d0b 100644 --- a/app/src/tv/tv_widgets.cpp +++ b/app/src/tv/tv_widgets.cpp @@ -12,6 +12,7 @@ namespace widgets { namespace { std::vector<std::string>* g_probe = nullptr; +std::vector<TextAt>* g_posProbe = nullptr; // The UTF-8 string cut to the longest prefix (plus an ellipsis) that fits in maxW. std::string fit(float px, const std::string& s, float maxW) { @@ -26,6 +27,29 @@ std::string fit(float px, const std::string& s, float maxW) { return dots; } +// The UTF-8 string's longest prefix (at least one character) that fits in maxW at px. +size_t prefixThatFits(float px, const std::string& s, float maxW) { + size_t best = 0, n = 0; + while (n < s.size()) { + n++; + while (n < s.size() && (static_cast<unsigned char>(s[n]) & 0xC0) == 0x80) n++; // on to the next character boundary + if (best > 0 && textWidth(px, s.substr(0, n)) > maxW) break; + best = n; + } + return best; +} + +// Every space-separated word of `s` is no wider than maxW at px. +bool wordsFit(float px, const std::string& s, float maxW) { + for (size_t i = 0; i < s.size();) { + size_t j = s.find(' ', i); + if (j == std::string::npos) j = s.size(); + if (j > i && textWidth(px, s.substr(i, j - i)) > maxW) return false; + i = j + 1; + } + return true; +} + ImVec2 scaledAbout(ImVec2 pos, ImVec2 size, float k, ImVec2* outSize) { const ImVec2 c(pos.x + size.x * 0.5f, pos.y + size.y * 0.5f); *outSize = ImVec2(size.x * k, size.y * k); @@ -35,6 +59,7 @@ ImVec2 scaledAbout(ImVec2 pos, ImVec2 size, float k, ImVec2* outSize) { } // namespace void setTextProbe(std::vector<std::string>* sink) { g_probe = sink; } +void setTextPosProbe(std::vector<TextAt>* sink) { g_posProbe = sink; } void FocusTweens::update(int count, int focusIndex, int pageKey, double now) { if (pageKey != key || (int)t.size() != count) { @@ -89,10 +114,82 @@ std::string formatDate(int64_t secs) { return std::to_string(tm.tm_mday) + " " + kMonths[tm.tm_mon % 12] + " " + std::to_string(tm.tm_year + 1900); } +std::vector<std::string> wrapLines(float px, const std::string& s, float maxW, int maxLines) { + std::vector<std::string> words; + for (size_t i = 0; i < s.size();) { + while (i < s.size() && s[i] == ' ') i++; + size_t j = i; + while (j < s.size() && s[j] != ' ') j++; + if (j > i) words.push_back(s.substr(i, j - i)); + i = j; + } + std::vector<std::string> lines; + if (words.empty()) return lines; + if (maxW <= 0) { + std::string one; + for (const std::string& w : words) one += (one.empty() ? "" : " ") + w; + return {one}; + } + std::string line; + for (const std::string& w : words) { + const std::string joined = line.empty() ? w : line + " " + w; + if (textWidth(px, joined) <= maxW) { + line = joined; + continue; + } + if (!line.empty()) lines.push_back(line); + line = w; + while (textWidth(px, line) > maxW) { // a word wider than the line: split where it must be + const size_t n = prefixThatFits(px, line, maxW); + if (n >= line.size()) break; + lines.push_back(line.substr(0, n)); + line = line.substr(n); + } + } + lines.push_back(line); + if (maxLines > 0 && (int)lines.size() > maxLines) { // the last line that fits ends in "...", cut at a word + lines.resize((size_t)maxLines); + std::string& last = lines.back(); + const std::string dots = "..."; + while (!last.empty() && textWidth(px, last + dots) > maxW) { + const size_t sp = last.rfind(' '); + if (sp != std::string::npos) { + last.resize(sp); + continue; + } + size_t n = last.size() - 1; // one word left: cut it back to a character boundary + while (n > 0 && (static_cast<unsigned char>(last[n]) & 0xC0) == 0x80) n--; + last.resize(n); + } + last += dots; + } + return lines; +} + +ImVec2 textLines(ImDrawList* dl, float px, ImVec2 pos, ImU32 c, const std::vector<std::string>& lines, float lineGap, bool alignRight) { + ImFont* f = ImGui::GetFont(); + if (g_probe) { + std::string all; + for (const std::string& l : lines) all += (all.empty() ? "" : " ") + l; + g_probe->push_back(all); + } + float w = 0, y = pos.y; + for (const std::string& l : lines) { + const float lw = textWidth(px, l); + const ImVec2 at(alignRight ? pos.x - lw : pos.x, y); + if (g_posProbe) g_posProbe->push_back({l, at, px}); + dl->AddText(f, px, at, c, l.c_str(), l.c_str() + l.size()); + w = std::max(w, lw); + y += px + lineGap; + } + return ImVec2(w, lines.empty() ? 0.0f : y - pos.y - lineGap); +} + ImVec2 text(ImDrawList* dl, float px, ImVec2 pos, ImU32 c, const std::string& s, float maxW, float wrapW) { ImFont* f = ImGui::GetFont(); const std::string shown = wrapW > 0 ? s : fit(px, s, maxW); if (g_probe) g_probe->push_back(shown); + if (g_posProbe) g_posProbe->push_back({shown, pos, px}); dl->AddText(f, px, pos, c, shown.c_str(), shown.c_str() + shown.size(), wrapW); return f->CalcTextSizeA(px, FLT_MAX, wrapW, shown.c_str(), shown.c_str() + shown.size()); } @@ -188,10 +285,18 @@ Hit tile(ImDrawList* dl, const Metrics& m, ImVec2 pos, ImVec2 size, const TileAr const ImVec2 i0(p0.x + (sz.x - iw) * 0.5f, p0.y + (sz.y - ih) * 0.5f); dl->AddImageRounded((ImTextureID)(intptr_t)a.tex, i0, ImVec2(i0.x + iw, i0.y + ih), ImVec2(0, 0), ImVec2(1, 1), IM_COL32_WHITE, round); - } else { // no picture (yet): the title, wrapped inside the padding and sitting at the foot - const float pad = 18 * m.u * k, px = m.caption * k, wrap = sz.x - 2 * pad; - const ImVec2 ts = textSize(px, a.title, wrap); - text(dl, px, ImVec2(p0.x + pad, std::max(p0.y + pad, p1.y - pad - ts.y)), col(p.placeholderText), a.title, 0, wrap); + } else { // no picture (yet): the title, wrapped at words inside the padding and sitting at the foot + // The lines are broken at the tile's size at rest and drawn at the focused size: both grow together, so focus never rewraps. + const float pad = 8 * m.u, wrap = size.x - 2 * pad; + float px0 = m.caption; + for (const float tryPx : {m.caption, m.minText}) { // the caption size if the longest word fits the line, else the 24 u floor + px0 = tryPx; + if (wordsFit(tryPx, a.title, wrap)) break; + } + const int maxLines = std::max(1, (int)((size.y - 2 * pad) / px0)); + const std::vector<std::string> lines = wrapLines(px0, a.title, wrap, maxLines); + const float px = px0 * k, h = (float)lines.size() * px; + textLines(dl, px, ImVec2(p0.x + pad * k, std::max(p0.y + pad * k, p1.y - pad * k - h)), col(p.placeholderText), lines); } if (a.disc) { const float px = m.minText * k, padX = 8 * m.u * k, padY = 3 * m.u * k; diff --git a/app/src/tv/tv_widgets.h b/app/src/tv/tv_widgets.h index 4a9bd44b..4571480d 100644 --- a/app/src/tv/tv_widgets.h +++ b/app/src/tv/tv_widgets.h @@ -22,6 +22,14 @@ namespace widgets { // Tests: every string the widgets draw is also appended to `sink` (nullptr = off, the default). void setTextProbe(std::vector<std::string>* sink); +// Tests: ... and where each one was drawn (the pen position, pixels). Separate from the string probe so a test that wants places +// switches it on alone (nullptr = off, the default). +struct TextAt { + std::string s; + ImVec2 pos; + float px; +}; +void setTextPosProbe(std::vector<TextAt>* sink); // One 0..1 "how focused" value per item of a list; moving the focus retargets two of them (the old item eases out, // the new one eases in). A change of `key` (another page) or of the item count starts over with no animation. @@ -50,6 +58,15 @@ ImVec2 text(ImDrawList* dl, float px, ImVec2 pos, ImU32 col, const std::string& float textWidth(float px, const std::string& s); // The size `text` would draw `s` at, wrapped at wrapW (0 = no wrap). ImVec2 textSize(float px, const std::string& s, float wrapW = 0); +// `s` broken into lines no wider than maxW, at spaces (never inside a word). More than maxLines (0 = any number) and the last line +// is cut at a word, plus "..."; a single word wider than maxW cannot be broken at a space, so it is split where it must (the only +// time a word is cut), and the ellipsis does not apply to it. UTF-8 safe. maxW <= 0 = one line. +std::vector<std::string> wrapLines(float px, const std::string& s, float maxW, int maxLines = 0); +// Draws wrapLines' result one line under the other (`lineGap` between them), the left edge at pos.x, or -- `alignRight` -- every line +// ending at pos.x. The text probe gets the lines joined with spaces once (the whole text, as `text` would), the position probe each +// line at its place. Returns the block's size. +ImVec2 textLines(ImDrawList* dl, float px, ImVec2 pos, ImU32 col, const std::vector<std::string>& lines, float lineGap = 0, + bool alignRight = false); // "16 Oct 2026": a date in the local time zone, from seconds since the epoch (English month names, whatever the locale). std::string formatDate(int64_t secs); diff --git a/app/src/tv_script.cpp b/app/src/tv_script.cpp index 7606b629..8346b8a5 100644 --- a/app/src/tv_script.cpp +++ b/app/src/tv_script.cpp @@ -15,6 +15,8 @@ // InputManager through the ordinary SDL_EVENT_GAMEPAD_ADDED hot-plug path, exactly like a real pad. If it does not // open there, the run FAILS: nothing here fakes GAMEPAD_* events for an unopened id. Windowed runs attach it too. // +// `fullscreen on|off` is F11 without the window: App::setFullscreen(), so TvMode hears the edge (the Auto fullscreen trigger). +// // Windowed-only verbs (for screenshots of the TV screens): `wait library` blocks the script until the Library has // scanned (the LibraryService threads run only in a windowed run), `shot PATH` saves the window at the end of the // next UI frame (App::tvScriptFrameEnd, called after the UI is drawn and before the swap), `quit` ends the run through @@ -33,6 +35,7 @@ #include "app.h" #include "devices_ui.h" #include "libretro.h" +#include "platform/commands.h" #include "tv/tv_shell.h" #include "tv_more_settings.h" @@ -104,6 +107,8 @@ struct App::TvScriptRun { uint64_t prevExec = 0, prevMs = 0; unsigned expects = 0; // windowed-only verbs + float mouseX = 0.0f, mouseY = 0.0f; // where the last `mouse` line put the pointer (windowed: seeded from the real one) + bool mouseSeeded = false; bool waitLibrary = false; // `wait library` is blocking the script uint64_t waitStart = 0; std::string shotPath; // `shot PATH`: saved at the end of the next UI frame @@ -258,11 +263,28 @@ int App::tvScriptStep() { tvScriptDrain(); tick(); } else if (verb == "mouse") { - if (!run.windowed || !window_) return fail(l, "mouse is windowed only"); char* endX = nullptr; char* endY = nullptr; const float x = std::strtof(arg1.c_str(), &endX), y = std::strtof(arg2.c_str(), &endY); if (arg1.empty() || arg2.empty() || *endX || *endY) return fail(l, "mouse wants X Y"); - SDL_WarpMouseInWindow(window_, x, y); + if (run.windowed && window_ && !run.mouseSeeded) SDL_GetMouseState(&run.mouseX, &run.mouseY); + run.mouseSeeded = true; + // A warp moves the pointer (hover tests) but SDL reports it with xrel = yrel = 0, which is nothing the TV mode's mouse + // budget can count. So the move is also pushed the way the OS reports a real one: a MOUSE_MOTION with the travel in + // xrel / yrel. (Headless has no pointer: only the event.) + if (run.windowed && window_) SDL_WarpMouseInWindow(window_, x, y); + SDL_Event ev; + SDL_zero(ev); + ev.type = SDL_EVENT_MOUSE_MOTION; + ev.motion.windowID = window_ ? SDL_GetWindowID(window_) : 0; + ev.motion.x = x; + ev.motion.y = y; + ev.motion.xrel = x - run.mouseX; + ev.motion.yrel = y - run.mouseY; + run.mouseX = x; + run.mouseY = y; + SDL_PushEvent(&ev); + tvScriptDrain(); + tick(); } else if (verb == "focus") { SDL_Event ev; SDL_zero(ev); @@ -276,6 +298,27 @@ int App::tvScriptStep() { } else if (verb == "tvsetting") { // what Settings > Interface will do: store the setting, reconfigure TV mode if (!tv::settingFromKey(arg1, tvSetting)) return fail(l, "tvsetting wants off|manual|auto"); configureTvMode(); + } else if (verb == "fullscreen") { // fullscreen on|off -- as F11 does (headless: the flag, and the edge TvMode is told) + if (arg1 != "on" && arg1 != "off") return fail(l, "fullscreen wants on|off"); + setFullscreen(arg1 == "on"); + } else if (verb == "command") { // command settings|bios|library|tvmode -- what the native macOS menu / tray click runs (App::runNativeCommand) + platform::Cmd c = platform::Cmd::None; + if (arg1 == "settings") c = platform::Cmd::Settings; + else if (arg1 == "bios") c = platform::Cmd::BiosSettings; + else if (arg1 == "library") c = platform::Cmd::Library; + else if (arg1 == "tvmode") c = platform::Cmd::TvMode; + else return fail(l, "command wants settings|bios|library|tvmode"); + runNativeCommand(c, 0); + } else if (verb == "hardcore-pause") { // hardcore-pause deny|allow -- allowPause() refuses (rc_client_can_pause between pauses) or not + if (arg1 != "deny" && arg1 != "allow") return fail(l, "hardcore-pause wants deny|allow"); + testDenyPause = arg1 == "deny"; + } else if (verb == "settings-group") { // settings-group KEY -- the TV Settings rail will open on that group (interface, sound, core:video ...): the shell's focus memory, so a walk need not count rail items (the rail's length varies with the build and the session) + if (arg1.empty()) return fail(l, "settings-group wants a group key"); + tvShell().memory().putItem("settings/rail", arg1); + } else if (verb == "hintreset") { // the one-time TV mode hint is due again (a Settings > Reset of Interface); toast-count starts over + cfg.erase("ui.tv_hint_shown"); + tvHintShownThisRun_ = false; + toastCount_ = 0; } else if (verb == "saveconfig") { // App::saveConfig() (a headless run keeps its config in a private temp dir) saveConfig(); } else if (verb == "wait" || verb == "shot" || verb == "quit") { // windowed only @@ -317,16 +360,36 @@ int App::tvScriptStep() { tvScriptDrain(); const size_t eq = arg1.find('='); if (eq == std::string::npos) return fail(l, "expect wants name=value"); - const std::string name = arg1.substr(0, eq), want = arg1.substr(eq + 1); + const std::string name = arg1.substr(0, eq); + std::string want = arg1.substr(eq + 1); + if (name == "tv-focus" || name == "toast") // a label or a sentence with spaces: the rest of the line + for (size_t i = 2; i < l.w.size(); i++) want += " " + l.w[i]; run.expects++; auto flag = [&](bool v) { return std::string(v ? "1" : "0"); }; std::string got; if (name == "fullscreen") got = flag(fullscreen); + else if (name == "tv-setting") got = tv::settingKey(tvSetting); else if (name == "menu-visible") got = flag(inWindowMenuBar()); else if (name == "menu-pinned") got = flag(menuPinned); else if (name == "menu-open") got = flag(tvShell().menuOpen()); else if (name == "tv") got = flag(tvMode.active()); + else if (name == "toast") got = osd_.empty() ? std::string() : osd_.back().text; // the last toast + else if (name == "toast-count") got = std::to_string(toastCount_); else if (name == "tv-screen") got = tvShell().screenId(); + else if (name == "menu-owns-input") got = flag(input.menuOwnsInput()); // the in-game menu has the pads (open, paused or running on) + else if (name == "menu-page") { // the in-game menu's page: root | save | load | confirm-reset | confirm-close | confirm-launch + switch (tvShell().menuPage()) { + case tv::Shell::Page::Root: got = "root"; break; + case tv::Shell::Page::SaveSlots: got = "save"; break; + case tv::Shell::Page::LoadSlots: got = "load"; break; + case tv::Shell::Page::ConfirmReset: got = "confirm-reset"; break; + case tv::Shell::Page::ConfirmClose: got = "confirm-close"; break; + case tv::Shell::Page::ConfirmLaunch: got = "confirm-launch"; break; + } + } + else if (name == "menu-slot") got = tvShell().menuHasScreen() ? tvShell().menuScreenId() : std::string("none"); // the screen in the in-game menu's slot + else if (name == "tv-group") got = tvShell().focusGroup(); // the key of the group on the TV Settings rail (picture, sound, core:video ...) + else if (name == "tv-focus") got = tvShell().focusLabel(); // the focused group / row / tile label of the top TV screen ("" = none) else if (name == "tv-owns-input") got = flag(tvShell().ownsInput()); // 0 while a desktop window covers the TV screens else if (name == "settings-open") got = flag(showSettings); // the desktop Settings window else if (name == "tv-more") got = flag(tvMoreSettingsLive(*this)); // ... opened by "More settings..." in TV mode: its pads are its own diff --git a/app/src/ui.cpp b/app/src/ui.cpp index d5ac32a8..49f2618b 100644 --- a/app/src/ui.cpp +++ b/app/src/ui.cpp @@ -123,13 +123,7 @@ bool matchesFilter(const OptionDef& d, const std::string& f) { void drawCoreOptions(App& app) { CoreHost& host = app.host; - if (!host.isLoaded() && host.optionDefs().empty()) { - ImGui::TextWrapped( - "The core's options appear here once a game is loaded (or the console is booted). " - "The menu is generated from the core's own option definitions."); - return; - } - if (host.isLoaded()) { + if (host.isLoaded()) { // the table is there from startup (CoreHost::probeOptions), game or not if (ImGui::Button("Restart game to apply")) app.reloadContent(); ui::hint("'Restart required' options apply on the next load."); } @@ -245,6 +239,38 @@ void drawStorageTab(App& app) { ui::hint("SRAM, EEPROM and the Memory Track. HLE boot is the default, so no BIOS file is needed (Settings > BIOS)."); } +// TV mode (#991 phase 4): Off / Manual / Auto, and under Auto the two triggers. Both setters reconfigure TvMode, which turns a +// trigger-started TV mode into a Manual one (it stays on; mouse and key hand-back stops), and neither saves: this does. +void drawTvModeSettings(App& app) { + ImGui::SeparatorText("TV mode"); + static const char* const kLabels[] = {"Off", "Manual", "Auto"}; // tv::TvSetting order + int cur = (int)app.tvSetting; + ImGui::SetNextItemWidth(ui::em(10.0f)); + if (ImGui::BeginCombo("##tv_mode", kLabels[cur])) { + for (int i = 0; i < 3; i++) + if (ImGui::Selectable(kLabels[i], cur == i) && i != cur) { + app.setTvSetting((tv::TvSetting)i); + app.saveConfig(); + } + ImGui::EndCombo(); + } + if (app.tvSetting == tv::TvSetting::Auto) { + bool fs = app.cfg.getBool("ui.tv_auto_fullscreen", true); + if (ImGui::Checkbox("Turn on in fullscreen", &fs)) { + app.setTvAuto(true, fs); + app.saveConfig(); + } + bool pad = app.cfg.getBool("ui.tv_auto_controller", true); + if (ImGui::Checkbox("Turn on when a controller is used", &pad)) { + app.setTvAuto(false, pad); + app.saveConfig(); + } + } + ui::hint("A 10-foot look for a pad and a TV. Manual: View > TV mode, Ctrl/Cmd+Shift+T, or F4 / a Share hold with no game. " + "Auto also turns it on in fullscreen and when a controller is used in the library (mouse movement or typing " + "turns it off again). Nightly and local builds start on Auto, releases on Manual."); +} + // Settings > Interface: how the app itself looks and behaves (as opposed to Video's picture and Storage's saves). // The config keys are the ones these controls always had (ui.*, saves.resume, library.*): nothing is renamed. void drawInterfaceTab(App& app) { @@ -252,6 +278,7 @@ void drawInterfaceTab(App& app) { ImGui::SeparatorText("Overlays"); ImGui::Checkbox("Show frame rate", &app.showFps); drawControlsOverlaySettings(app); + drawTvModeSettings(app); drawAppearanceSettings(app); drawFontSettings(app); ImGui::SeparatorText("Performance"); @@ -549,7 +576,7 @@ void drawMenuBar(App& app) { if (ImGui::BeginMenu("File")) { if (ImGui::MenuItem("Open...", "Ctrl+O")) app.showOpenDialog(); if (ImGui::MenuItem("New window", "Ctrl+N")) app.newWindow(); - if (ImGui::MenuItem("Game library...", "Ctrl+L", app.library.show && !app.homeVisible())) app.toggleLibrary(); + if (ImGui::MenuItem("Game library...", "Ctrl+L", (app.library.show && !app.homeVisible()) || (app.tvMode.active() && app.tvShell().showingLibrary()))) app.openLibrary(); if (ImGui::BeginMenu("Open recent", !app.recents.empty())) { std::vector<std::string> list = app.recents; // opening reorders the list for (size_t i = 0; i < list.size(); i++) { @@ -565,10 +592,7 @@ void drawMenuBar(App& app) { if (ImGui::MenuItem("CD BIOS (no disc)")) app.bootBios(bios::Cd); if (ImGui::MenuItem("Cartridge boot ROM (empty slot)")) app.bootBios(bios::CartBoot); ImGui::Separator(); - if (ImGui::MenuItem("BIOS & boot settings...")) { - app.showSettings = true; - app.devSelectTab = "BIOS"; - } + if (ImGui::MenuItem("BIOS & boot settings...")) app.openBiosSettings(); ImGui::EndMenu(); } ImGui::Separator(); @@ -628,14 +652,14 @@ void drawMenuBar(App& app) { overlayEvent(app.input.controls, OverlayEvent::Toggle); } ImGui::MenuItem("Library on home screen", nullptr, &app.homeLibrary); - if (ImGui::MenuItem("TV mode", "Ctrl+Shift+T", app.tvMode.active(), tv::effectiveSetting(app.tvSetting) != tv::TvSetting::Off)) + if (ImGui::MenuItem("TV mode", "Ctrl+Shift+T", app.tvMode.active(), app.tvSetting != tv::TvSetting::Off)) app.toggleTvMode(); if (ImGui::MenuItem("VSync", nullptr, &app.vsync)) app.applyVsync(); ImGui::EndMenu(); } drawToolsMenu(app); if (ImGui::BeginMenu("Settings")) { - if (ImGui::MenuItem("Settings...", "Ctrl+,")) app.showSettings = true; + if (ImGui::MenuItem("Settings...", "Ctrl+,")) app.openSettings(); if (ImGui::MenuItem("Mute", nullptr, &app.mute)) app.applyAudioSettings(); ImGui::EndMenu(); } diff --git a/app/tests/core_options_probe_test.cpp b/app/tests/core_options_probe_test.cpp new file mode 100644 index 00000000..c6af3105 --- /dev/null +++ b/app/tests/core_options_probe_test.cpp @@ -0,0 +1,166 @@ +// The core's option table without a game (#991 Task 19): CoreHost::probeOptions() runs retro_set_environment with only the option +// commands answered, so the settings screens list every option from the first frame, and the game that loads later sees the core +// exactly as if nothing had been probed. +// +// vj_core_options_probe_test [rom.j64] +// +// Part 1 (no game): the table is there (118 options in 10 categories at the time of writing), the display rules ran (a gated row +// is hidden), a write with no game is stored and reported, a second probe pushes nothing, and the core never loaded anything. +// Part 1b (the emulation thread, #967): the thread started after the probe publishes the probed table at once, so the UI reads it from +// its first frame, and a write made from the UI thread reaches both copies. +// Part 2 (with a ROM): the visibility of every option after the FIRST load that follows the probe is the visibility after a second +// load of the same game -- the probe must not leave the core's display latches ("hidden" already) where the load's own display pass +// finds no change and never hides the row again. +#include "core_host.h" +#include "emu_thread.h" + +#include <cstdio> +#include <ctime> +#include <filesystem> +#include <functional> +#include <map> +#include <string> +#include <thread> +#include <utility> +#include <vector> + +using namespace vj; + +static int g_fail = 0, g_checks = 0; +#define CHECK(cond) \ + do { \ + g_checks++; \ + if (!(cond)) { \ + g_fail++; \ + std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); \ + } \ + } while (0) + +// The emulation thread's client: no game, so it only owns the core and idles. +struct IdleClient : EmuThread::Client { + CoreHost& host = CoreHost::instance(); + void threadStarted() override { host.setOwner(std::this_thread::get_id()); } + void threadStopping() override { host.setOwner(std::thread::id()); } + bool canRun() override { return false; } + double framePeriod() override { return 1.0 / 60.0; } + double audioQueuedMs() override { return -1.0; } + void runFrame() override {} + void afterCommands() override { host.publishSnapshot(); } +}; + +static const OptionDef* findDef(CoreHost& h, const std::string& key) { + for (const OptionDef& d : h.optionDefs()) + if (d.key == key) return &d; + return nullptr; +} + +static std::map<std::string, bool> visibility(CoreHost& h) { + std::map<std::string, bool> m; + for (const OptionDef& d : h.optionDefs()) m[d.key] = h.optionVisible(d.key); + return m; +} + +int main(int argc, char** argv) { + namespace fs = std::filesystem; + std::error_code ec; + const fs::path tmp = fs::temp_directory_path(ec) / ("vj-core-options-probe-" + std::to_string((long long)std::time(nullptr))); + fs::create_directories(tmp / "saves", ec); + fs::create_directories(tmp / "system", ec); + CoreHost& h = CoreHost::instance(); + h.setDirectories((tmp / "system").string() + "/", (tmp / "saves").string() + "/"); + h.inputPoll = [] {}; + h.inputState = [](unsigned, unsigned, unsigned, unsigned) -> int16_t { return 0; }; + std::vector<std::pair<std::string, std::string>> changed; + h.onOptionChanged = [&](const std::string& k, const std::string& v, bool erased) { if (!erased) changed.push_back({k, v}); }; + + // ---- part 1: no game --------------------------------------------------------------------------------------------------------- + CHECK(h.optionDefs().empty() && h.optionCategories().empty() && h.optionPushes() == 0); + h.probeOptions(); + CHECK(h.optionDefs().size() >= 100); + CHECK(h.optionCategories().size() == 10); + CHECK(!h.isLoaded()); + const unsigned pushes = h.optionPushes(); + CHECK(pushes >= 1); + h.probeOptions(); // once is all it takes + CHECK(h.optionPushes() == pushes && h.optionDefs().size() >= 100 && !h.isLoaded()); + + const std::string res = "virtualjaguar_internal_resolution"; + const OptionDef* d = findDef(h, res); + CHECK(d != nullptr && d->categoryKey == "video"); + if (d) CHECK(!d->values.empty() && h.optionValue(*d) == d->defaultValue); + + // The display pass ran: libretro.c update_option_visibility() hides the Network Link host and port while the mode is its default + // (auto), the texture-dump preview knob while the dump is off, and the voice chat sub-options while voice chat is off; options + // with no gate stay visible; with no content loaded the CD-only and cartridge-only groups both stay (the type is unknown). + CHECK(!h.optionVisible("virtualjaguar_netlink_host")); + CHECK(!h.optionVisible("virtualjaguar_netlink_port")); + CHECK(!h.optionVisible("virtualjaguar_texdump_16bpp")); + CHECK(!h.optionVisible("virtualjaguar_voice_chat_gate")); + CHECK(h.optionVisible(res) && h.optionVisible("virtualjaguar_netlink")); + CHECK(h.optionVisible("virtualjaguar_cd_bios_type") && h.optionVisible("virtualjaguar_bios")); + + // A write with no game is kept, reported (the app saves it to config.ini there) and read back; the next load reads it. + h.setOptionValue(res, "2x"); + CHECK(h.optionValueFor(res, "") == "2x"); + CHECK(changed.size() == 1 && changed[0].first == res && changed[0].second == "2x"); + if (d) CHECK(h.optionValue(*d) == "2x"); + CHECK(h.optionPushes() == pushes && !h.isLoaded()); + h.resetOptionToDefault(res); + + // ---- part 1b: the emulation thread starts after the probe ------------------------------------------------------------------- + { + EmuThread emu; + IdleClient client; + h.post = [&emu](std::function<void()> fn) { emu.post(std::move(fn)); }; + emu.setPaused(true); + CHECK(emu.start(EmuThread::Mode::Threaded, client)); + CHECK(h.threaded() && !h.onOwner()); + h.pinSnapshot(); + CHECK(h.optionDefs().size() >= 100 && h.optionCategories().size() == 10 && h.optionPushes() == pushes); // the UI's copy, from the first pin + CHECK(!h.optionVisible("virtualjaguar_netlink_host") && h.optionVisible(res)); + CHECK(h.optionValueFor(res, "") == ""); + changed.clear(); + h.setOptionValue(res, "2x"); // off the owner: the UI's copy now, the config write here, the core's copy at the boundary + CHECK(h.optionValueFor(res, "") == "2x" && changed.size() == 1 && changed[0].second == "2x"); + CHECK(emu.call([&] { return h.optionValueFor(res, ""); }) == "2x"); + h.resetOptionToDefault(res); + CHECK(h.optionValueFor(res, "") == "" && emu.call([&] { return h.optionValueFor(res, ""); }) == ""); + CHECK(emu.stop()); + h.post = nullptr; + CHECK(!h.threaded() && h.onOwner() && !h.isLoaded()); + changed.clear(); + } + + // ---- part 2: the first game after a probe sees what the second one does ------------------------------------------------------ + if (argc > 1) { + const std::string rom = argv[1]; + std::string err; + CHECK(h.loadContent(rom, err)); + CHECK(h.isLoaded() && h.optionPushes() > pushes); // startSession re-pushed the table + const std::map<std::string, bool> first = visibility(h); + CHECK(!first.empty()); + // The load's gates: content is a cartridge, so the CD-only group is hidden, and the netlink rows stay hidden. + CHECK(first.count("virtualjaguar_netlink_host") && !first.at("virtualjaguar_netlink_host")); + CHECK(first.count("virtualjaguar_cd_bios_type") && !first.at("virtualjaguar_cd_bios_type")); + h.unloadContent(); + CHECK(h.loadContent(rom, err)); + const std::map<std::string, bool> second = visibility(h); + size_t diff = 0; + for (const auto& kv : first) { + auto it = second.find(kv.first); + if (it == second.end() || it->second != kv.second) { + diff++; + std::fprintf(stderr, " visibility differs: %s first=%d second=%d\n", kv.first.c_str(), kv.second, + it == second.end() ? -1 : (int)it->second); + } + } + CHECK(diff == 0 && first.size() == second.size()); + h.unloadContent(); + } + // A probe after a game is a no-op: the options are known, and a session may be running. + h.probeOptions(); + CHECK(!h.isLoaded()); + + std::fprintf(stderr, "core_options_probe_test: %d checks, %d failed\n", g_checks, g_fail); + return g_fail ? 1 : 0; +} diff --git a/app/tests/pad_profile_test.cpp b/app/tests/pad_profile_test.cpp index 76ddf793..7d25e41a 100644 --- a/app/tests/pad_profile_test.cpp +++ b/app/tests/pad_profile_test.cpp @@ -356,6 +356,20 @@ static void testFamilyChangeClearsOverrides() { CHECK(keyCount(d, OTHER) == 1 && d.has("input.padguid." + GUID + ".A")); } +// The first-use hint names the TV-menu button as that pad's family does: MISC1 is Share / Mic / Capture, Guide is Guide / PS / Home. +static void testTvMenuButtonNames() { + const Binding misc = Binding::button(SDL_GAMEPAD_BUTTON_MISC1).withHold(), guide = Binding::button(SDL_GAMEPAD_BUTTON_GUIDE).withHold(); + CHECK(tvMenuButtonName(PadFamily::Xbox, misc) == "Share (hold)" && tvMenuButtonName(PadFamily::Generic, misc) == "Share (hold)"); + CHECK(tvMenuButtonName(PadFamily::PlayStation, misc) == "Mic (hold)" && tvMenuButtonName(PadFamily::Switch, misc) == "Capture (hold)"); + CHECK(tvMenuButtonName(PadFamily::Xbox, guide) == "Guide (hold)" && tvMenuButtonName(PadFamily::Generic, guide) == "Guide (hold)"); + CHECK(tvMenuButtonName(PadFamily::PlayStation, guide) == "PS (hold)" && tvMenuButtonName(PadFamily::Switch, guide) == "Home (hold)"); + CHECK(tvMenuButtonName(PadFamily::Xbox, Binding::button(SDL_GAMEPAD_BUTTON_MISC1)) == "Share"); // a plain press: no suffix + CHECK(tvMenuButtonName(PadFamily::Xbox, Binding::button(SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1)) == "Paddle P1"); // everything else: navButtonName / display() + CHECK(tvMenuButtonName(PadFamily::PlayStation, Binding::button(SDL_GAMEPAD_BUTTON_SOUTH).withHold()) == "Cross (hold)"); + CHECK(tvMenuButtonName(PadFamily::Xbox, Binding::button(SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1).withHold()) == "Paddle P1 (hold)"); // display() already says (hold): once + CHECK(tvMenuButtonName(PadFamily::Xbox, Binding::button(SDL_GAMEPAD_BUTTON_LEFT_STICK).withHold()) == "Left Stick Click (hold)"); +} + static void testButtonNames() { CHECK(navButtonName(PadFamily::Switch, Binding::button(EAST)) == "A"); CHECK(navButtonName(PadFamily::Switch, Binding::button(SOUTH)) == "B"); @@ -414,6 +428,7 @@ int main() { testRoundTrip(); testFamilyChangeClearsOverrides(); testButtonNames(); + testTvMenuButtonNames(); testButtonLabelNoHandle(); std::printf("pad_profile: %d checks, %d failed\n", g_checks, g_fail); return g_fail ? 1 : 0; diff --git a/app/tests/tv_menu_headless.txt.in b/app/tests/tv_menu_headless.txt.in index 2cb3b7dc..91197119 100644 --- a/app/tests/tv_menu_headless.txt.in +++ b/app/tests/tv_menu_headless.txt.in @@ -7,18 +7,34 @@ # load PATH drop PATH on the window (the normal drop route) # key down|up|tap NAME a key event (SDL scancode name: F4, Escape, F8; "ctrl+L", "ctrl+shift+T" = with those held) # pad down|up|tap BTN a button on the runner's virtual gamepad (SDL button name: a, b, dpdown, misc1) -# mouse X Y windowed only: move the real pointer within the main window (menu hover tests) +# mouse X Y move the pointer to X,Y in the main window: a MOUSE_MOTION with the travel since the last `mouse` line as xrel / yrel +# (windowed: the real pointer is warped there too, for hover tests; headless: only the event) # expect fullscreen=0|1 menu-pinned=0|1 menu-visible=0|1 (menu-visible needs a windowed rendered frame) # focus lost|gained the window loses / gets keyboard focus # typing on|off pretend a text box has focus (input.uiWantsKeyboard) # capture help|tvmenu begin binding that action on the keyboard (the next key becomes its binding) # capture nav:INTENT begin rebinding a menu button of the virtual pad (confirm back menu favorite start tabprev tabnext pageprev # pagenext search): the next pad button becomes it (#1021). expect cfg=...{padguid}... is its GUID. -# tvsetting off|manual|auto set the stored TV-mode setting and reconfigure (as Settings will); off = never, auto acts as manual +# tvsetting off|manual|auto set the stored TV-mode setting and reconfigure (as Settings will); off = never, auto = also in fullscreen and on a pad press +# fullscreen on|off enter / leave fullscreen (headless: the flag and the edge TV mode is told about, no window) +# hintreset forget the one-time TV mode hint (ui.tv_hint_shown and this run's flag) and zero toast-count +# expect toast=TEXT the text of the last toast (the rest of the line, spaces included); expect toast-count=N toasts shown since start +# expect tv-setting=off|manual|auto the stored setting (a fresh config takes the build default: auto on a CTest build) # navdefault INTENT the Default button of that menu intent of the virtual pad (back to the family's button, partner swapped back) +# command settings|bios|library|tvmode what the native macOS menu / tray click runs (App::runNativeCommand); a synthesized ctrl+, is +# ignored in a real window, so this is the Mac path +# settings-group KEY the TV Settings rail opens on that group next (the shell's focus memory "settings/rail": picture sound interface library +# achievements discord neuro, or core:<category key>, e.g. core:video); the rail's length varies with the build and the session +# hardcore-pause deny|allow allowPause() refuses (as hardcore does between pauses) / allows; the in-game menu then opens without pausing +# expect menu-owns-input=0|1 the in-game menu has the pads (open: paused, netplay or hardcore running on) +# expect menu-slot=ID|none the screen in the in-game menu's slot (settings, overlays, achievements, library) +# expect menu-page=root|save|load|confirm-reset|confirm-close|confirm-launch the in-game menu's page # expect menu-open=0|1 paused=0|1 loaded=0|1 capturing=0|1 fast-forward=0|1 core-input=0|1 kb-bind=help|tvmenu:KEY # expect tv=0|1 TV mode is on # expect tv-screen=ID the id of the TV screen on top (home, library, ...) +# expect tv-focus=LABEL the label of what has the focus on the top TV screen (Settings: the group on the rail, or the row in its list; +# the rest of the line, spaces included); Screen::focusLabel(). With the in-game menu open: the slot screen's, else the menu row's +# expect tv-group=KEY the key of the group on the TV Settings rail (picture sound interface library ... core:video); Screen::focusGroup() # expect tv-owns-input=0|1 the TV shell has the pads and keys (0 while a desktop window covers the TV screens) # expect settings-open=0|1 the desktop Settings window is open # expect tv-more=0|1 it was opened by "More settings..." in TV mode: the pads and the arrow keys are its own (tv_more_settings.h) @@ -43,8 +59,9 @@ # (core-input=1: the core would read some Player 1 pad button right now; the expect takes the snapshot itself) # An expect first lets the per-frame input housekeeping run, so a fired hold has been handled by then. -# TV mode with no game (Manual): F4 turns it on and off; Esc at the root is not the shell's; Off ignores F4; Auto acts -# as Manual; a Share hold toggles it too. +# TV mode with no game (Manual): F4 turns it on and off; Esc at the root is not the shell's; Off ignores F4; the toggle works under +# Auto too; a Share hold toggles it too. (A fresh config takes the build default, Auto on a CTest build: start from Manual.) +0 tvsetting manual 0 expect loaded=0 0 expect tv=0 0 key tap F4 @@ -57,7 +74,7 @@ 140 tvsetting off 160 key tap F4 200 expect tv=0 -200 tvsetting auto # acts as Manual in phase 2 +200 tvsetting auto # the toggle works under Auto too 220 key tap F4 260 expect tv=1 260 key tap F4 @@ -69,8 +86,8 @@ 960 key tap F4 1000 expect tv=0 -# Phase 2 never writes ui.tv_mode (a stored value would pin the default that Task 16 moves for nightly users); the -# remembered on/off state (ui.tv_manual_on) is still written. Whatever the setting, the key stays out of the config. +# saveConfig() never writes ui.tv_mode (a stored value would pin a default that moves between builds); only a change in Settings +# does. The remembered on/off state (ui.tv_manual_on) is written under Manual. Whatever the setting, the key stays out of the config. 1000 tvsetting manual 1000 saveconfig 1000 expect cfg=ui.tv_mode:absent @@ -88,9 +105,9 @@ 1080 expect cfg=ui.tv_mode:absent 1080 tvsetting manual -# With TV mode on, Ctrl+L (File > Game library) opens the desktop library WINDOW (the home library is not showing: the TV -# screens stand in for it); with it off the home library is showing, so Ctrl+L only focuses its search box. While the window -# covers the TV screens they take no pad input (RB would step to the Library section), and they get it back when it closes. +# With TV mode on, Ctrl+L (File > Game library) is the TV Library, never the desktop library window (#991): the Library section, again +# changes nothing, B goes Home. With it off the home library is showing, so Ctrl+L only focuses its search box. (The window the key +# used to open is still reachable with `--show-window library`, a dev flag, not a user path.) 1080 expect home-visible=1 1080 key tap ctrl+L 1120 expect library-window=0 @@ -98,14 +115,17 @@ 1160 expect tv=1 1160 expect home-visible=0 1160 expect tv-owns-input=1 +1160 expect tv-screen=home 1160 key tap ctrl+L -1200 expect library-window=1 -1200 expect tv-owns-input=0 -1200 pad tap rightshoulder -1240 expect tv-screen=home -1240 key tap ctrl+L -1280 expect library-window=0 -1280 expect tv-owns-input=1 +1200 expect library-window=0 +1200 expect tv-screen=library +1200 expect tv-owns-input=1 +1200 key tap ctrl+L +1240 expect library-window=0 +1240 expect tv-screen=library +1240 expect tv-owns-input=1 +1240 pad tap b +1280 expect tv-screen=home 1280 pad tap rightshoulder 1320 expect tv-screen=library 1320 pad tap leftshoulder @@ -113,9 +133,10 @@ 1360 key tap F4 1400 expect tv=0 -# "More settings..." from TV mode (#991): A on the last row of Interface opens the desktop Settings window and hands it the pad. -# RB x3 reaches Settings (Home, Library, Achievements, Settings). With no game this session the core rows are hidden and Speed -# holds only its "Available once a game has run" line, so the rail is Picture, Speed, Sound, Interface (Down x3); Right enters the list: Show FPS, VSync, Theme, TV mode, More settings... (Down x4). +# "Desktop settings..." from TV mode (#991): A on the last row of Interface opens the desktop Settings window and hands it the pad. +# RB x3 reaches Settings (Home, Library, Achievements, Settings). The rail is the app's tabs, then the core's categories (read at startup, +# so they are there with no game): Picture, Sound, Interface, ... (Down x2); Right enters the list, whose last row is Desktop settings... +# whatever else the build adds above it (Down x20 clamps there). # While the window is open the shell has stepped aside (tv-owns-input=0) and the pad is the window's: a shoulder button that # would change section does nothing, and B closes it (no popup, no active item), giving the pad back. 1400 key tap F4 @@ -124,523 +145,1040 @@ 1460 pad tap rightshoulder 1480 pad tap rightshoulder 1500 expect tv-screen=settings +# TV Settings, long lists (#991 Task 20), with no game loaded: the rail opens on Picture; Down x3 is Library (Picture, Sound, Interface, Library: +# the app's own groups come first and are in every build). Achievements, Discord and Neuro are gated on the build and the session, so +# the walk to the core's Video category uses the shell's memory (settings-group) instead of counting them. In Video the third row is +# the internal resolution: Right steps it with NO game (the core options are read at startup), the value is the core's, and it is saved +# as opt.<key> for the next game, and STAYS 2x: the first `load` below reads it back, and the in-menu change at the end steps it down. +# Back and the memory put the rail on Picture again, so the walk below is unchanged. +1500 expect tv-group=picture 1500 pad tap dpdown 1510 pad tap dpdown 1520 pad tap dpdown -1540 pad tap dpright -1560 pad tap dpdown +1560 expect tv-group=library +1560 settings-group core:video +1570 expect tv-group=core:video +1570 pad tap dpright # into the list, on its first row 1580 pad tap dpdown -1600 pad tap dpdown -1620 pad tap dpdown -1640 pad tap a -1680 expect settings-open=1 -1680 expect tv-more=1 -1680 expect tv-owns-input=0 -1680 pad tap dpdown # the window's, not the shell's -1700 pad tap leftshoulder # would step back to Achievements -1720 expect tv-screen=settings -1720 pad tap b -1760 expect settings-open=0 -1760 expect tv-more=0 -1760 expect tv-owns-input=1 -# Esc closes it too (the focus is still on More settings...), and so does Ctrl+, -- which also ends the TV hand-off: the next -# Ctrl+, opens the plain desktop window. -1760 pad tap a -1800 expect tv-more=1 -1800 key tap Escape -1840 expect settings-open=0 -1840 expect tv-more=0 -1840 pad tap a -1880 expect tv-more=1 -1880 key tap ctrl+, -1920 expect settings-open=0 -1920 expect tv-more=0 -1920 key tap ctrl+, -1960 expect settings-open=1 -1960 expect tv-more=0 -1960 key tap ctrl+, -2000 expect settings-open=0 -2000 pad tap leftshoulder # back to Home (the shell remembers the section across TV mode off / on) -2010 pad tap leftshoulder -2020 pad tap leftshoulder -2040 expect tv-screen=home -2040 key tap F4 -2080 expect tv=0 +1590 pad tap dpdown +1630 expect tv-focus=Internal Resolution (Restart Required) +1630 pad tap dpright # 1x -> 2x +1670 expect core-option=virtualjaguar_internal_resolution:2x +1670 saveconfig +1670 expect cfg=opt.virtualjaguar_internal_resolution:2x +1710 pad tap dpup # the group remembers its row: back to the first, the later in-menu walk starts there +1720 pad tap dpup +1730 pad tap b # the list -> the rail +1740 settings-group picture +1750 expect tv-group=picture +1760 pad tap dpdown +1770 pad tap dpdown +1790 pad tap dpright +1810 pad tap dpdown +1820 pad tap dpdown +1830 pad tap dpdown +1840 pad tap dpdown +1850 pad tap dpdown +1860 pad tap dpdown +1870 pad tap dpdown +1880 pad tap dpdown +1890 pad tap dpdown +1900 pad tap dpdown +1910 pad tap dpdown +1920 pad tap dpdown +1930 pad tap dpdown +1940 pad tap dpdown +1950 pad tap dpdown +1960 pad tap dpdown +1970 pad tap dpdown +1980 pad tap dpdown +1990 pad tap dpdown +2000 pad tap dpdown +2020 pad tap a +2060 expect settings-open=1 +2060 expect tv-more=1 +2060 expect tv-owns-input=0 +2060 pad tap dpdown # the window's, not the shell's +2080 pad tap leftshoulder # would step back to Achievements +2100 expect tv-screen=settings +2100 pad tap b +2140 expect settings-open=0 +2140 expect tv-more=0 +2140 expect tv-owns-input=1 +# Esc closes it too (the focus is still on More settings...), and so does Ctrl+, -- which also ends the TV hand-off. The next +# Ctrl+, is the TV Settings screen, never the plain desktop window (#991): already there it changes nothing, from another section it +# switches to Settings. +2140 pad tap a +2180 expect tv-more=1 +2180 key tap Escape +2220 expect settings-open=0 +2220 expect tv-more=0 +2220 pad tap a +2260 expect tv-more=1 +2260 key tap ctrl+, +2300 expect settings-open=0 +2300 expect tv-more=0 +2300 key tap ctrl+, +2340 expect settings-open=0 +2340 expect tv-screen=settings +2340 expect tv-more=0 +2340 pad tap leftshoulder +2350 expect tv-screen=achievements +2350 key tap ctrl+, +2380 expect tv-screen=settings +2380 expect settings-open=0 +# The native macOS menu / tray runs Cmd::Settings, not a key (a synthesized Ctrl+, is ignored in a real window): the same. +2380 pad tap leftshoulder +2385 expect tv-screen=achievements +2385 command settings +2390 expect tv-screen=settings +2390 expect settings-open=0 +2390 pad tap leftshoulder # back to Home (the shell remembers the section across TV mode off / on) +2400 pad tap leftshoulder +2410 pad tap leftshoulder +2420 expect tv-screen=home +2420 key tap F4 +2460 expect tv=0 # Desktop pad navigation (#1021): the desktop library owns the pads while TV mode is off, no game is running and the home # library shows. A shoulder button is a mapped intent (SectionNext = next tab) and queues a command; TV mode on (or an edge of # any ownership input) hands the pad to the TV screens and clears what was queued. -2080 expect desk-pad=1 -2080 pad tap rightshoulder -2120 expect desk-pending=1 -2120 pad tap leftshoulder -2160 expect desk-pending=2 -2160 key tap F4 -2200 expect tv=1 -2200 expect desk-pad=0 -2200 expect desk-pending=0 # the edge cleared the queue -2200 pad tap leftshoulder # TV's, not the desktop library's: nothing queues -2240 expect desk-pending=0 -2240 key tap F4 -2280 expect tv=0 -2280 expect desk-pad=1 +2460 expect desk-pad=1 +2460 expect cfg=ui.tv_hint_shown:absent +2460 expect toast-count=0 +2460 pad tap rightshoulder +# The first consumed press under Manual shows the one-time hint (6 s toast, wording from the pad's own TV-menu binding: the virtual +# pad has Share, so its default is the Share hold), stored before it shows; a second press shows nothing more. +2500 expect toast=Press Share (hold) or F4 for TV mode +2500 expect cfg=ui.tv_hint_shown:1 +2500 expect toast-count=1 +2500 expect desk-pending=1 +2500 pad tap leftshoulder +2540 expect toast-count=1 +2540 expect desk-pending=2 +2540 key tap F4 +2580 expect tv=1 +2580 expect desk-pad=0 +2580 expect desk-pending=0 # the edge cleared the queue +2580 pad tap leftshoulder # TV's, not the desktop library's: nothing queues +2620 expect desk-pending=0 +2620 key tap F4 +2660 expect tv=0 +2660 expect desk-pad=1 # A binding capture owns the pad. -2280 capture help -2320 expect capturing=1 -2320 expect desk-pad=0 -2320 key tap Return # ends the capture -2360 expect capturing=0 -2360 expect desk-pad=1 +2660 capture help +2700 expect capturing=1 +2700 expect desk-pad=0 +2700 key tap Return # ends the capture +2740 expect capturing=0 +2740 expect desk-pad=1 # Start (Menu) is not the library's: it stays the app's and queues nothing. -2360 pad tap start -2400 expect desk-pending=0 +2740 pad tap start +2780 expect desk-pending=0 # A right stick pushed while the library listens is a scroll; losing the pad (a desktop window opens, Settings here) or the # window's focus zeroes it, so a stick let go meanwhile cannot scroll on. Settings takes the pad while open. -2440 axis righty 30000 -2480 expect desk-scroll=1 -2480 key tap ctrl+, -2520 expect desk-pad=0 -2520 expect desk-scroll=0 -2520 axis righty 0 -2560 key tap ctrl+, -2600 expect desk-pad=1 -2600 expect desk-scroll=0 -2600 axis righty 30000 -2640 expect desk-scroll=1 -2640 focus lost -2680 expect desk-scroll=0 -2680 axis righty 0 -2720 expect desk-scroll=0 +2820 axis righty 30000 +2860 expect desk-scroll=1 +2860 key tap ctrl+, +2900 expect desk-pad=0 +2900 expect desk-scroll=0 +2900 axis righty 0 +2940 key tap ctrl+, +2980 expect desk-pad=1 +2980 expect desk-scroll=0 +2980 axis righty 30000 +3020 expect desk-scroll=1 +3020 focus lost +3060 expect desk-scroll=0 +3060 axis righty 0 +3100 expect desk-scroll=0 + +# Auto (#991 phase 4): a mapped pad press in the desktop library turns TV mode on and is consumed (nothing queued, nothing +# launched); a printable key turns a controller-started TV mode off; fullscreen holds it; leaving fullscreen ends it. +3140 tvsetting auto +3140 expect tv=0 +3140 expect desk-pad=1 +3140 pad tap a +3180 expect tv=1 +3180 expect tv-screen=home +3180 expect loaded=0 +3180 expect desk-pending=0 +3180 expect toast-count=1 # no second hint (and the switching press takes the return before the hint site) +3180 key tap Escape # approval C: Esc, Enter and shortcuts are not "typing": a controller-started TV mode stays on +3180 expect tv=1 +3180 key tap Return +3180 expect tv=1 +3180 key tap ctrl+H # a printable key with Ctrl held is a shortcut +3180 expect tv=1 +3180 key tap H +3220 expect tv=0 +3220 pad tap dpdown +3260 expect tv=1 +3260 fullscreen on +3280 expect fullscreen=1 +3280 key tap H +3320 expect tv=1 # fullscreen holds it +3320 fullscreen off +3360 expect tv=0 +3360 fullscreen on +3400 expect tv=1 # the fullscreen trigger on its own +3400 key tap ctrl+shift+T +3440 expect tv=0 # manual wins ... +3440 key tap H +3480 expect tv=0 +3480 fullscreen off +3480 fullscreen on +3520 expect tv=1 # ... until the next edge +3520 fullscreen off +3560 expect tv=0 +3560 pad tap touchpad # no nav intent (C_HELP's button): not a trigger. (Not misc1: a Share tap opens the keypad overlay) +3600 expect tv=0 +3600 pad tap start +3640 expect tv=1 # Start is a mapped intent: it switches +3640 key tap H +3680 expect tv=0 +3680 key tap ctrl+, # a desktop window takes the pad before the trigger sees it +3720 pad tap dpdown +3760 expect tv=0 +3760 key tap ctrl+, +# A Share hold toggles TV mode (Manual cause) and is not also the controller trigger (it would switch on at the press, off at the hold). +3760 pad down misc1 +4380 expect tv=1 +4380 pad up misc1 +4420 expect tv=1 +4420 key tap F4 +4460 expect tv=0 +4460 tvsetting manual +4460 saveconfig +4460 expect cfg=ui.tv_mode:absent + +# The Auto rows in TV Settings (#991 phase 4): they exist only under Auto, a change is stored and keeps TV mode on (a reconfigure +# makes it a Manual-started one), and Auto -> Manual removes them. Every step of the walk is anchored by `expect tv-focus=` (the +# group on the rail, the row in the list), because the shell remembers Settings across TV mode off and on and the Base layer resumes +# the level (list or rail) and the row it was left on: nothing here may assume where an earlier test stopped. TV mode comes back on +# at Home. RB x3 opens Settings at whatever level it was left; one Back then goes from the list to the groups, or from the groups +# to Home; LB x3 makes it Home either way, leaving the memory on the groups; RB x3 reopens Settings there. Up x4 must read Picture +# and Down x2 Interface; only then does Right enter the list (on the groups it only enters, it changes nothing). Up x8 clamps to +# Show FPS, Down x4 must read Auto: in fullscreen, and only then do Left / Right change a value. +# With Auto on the list starts Show FPS, Show the controls overlay, Theme, TV mode, Auto: in fullscreen, Auto: when a controller is used, +# ... and ends with Desktop settings... (Down x20 clamps there). +4460 tvsetting auto +4470 key tap F4 +4510 expect tv=1 +4510 expect tv-screen=home +4520 pad tap rightshoulder +4530 pad tap rightshoulder +4540 pad tap rightshoulder +4580 expect tv-screen=settings +4590 pad tap b +4600 pad tap leftshoulder +4610 pad tap leftshoulder +4620 pad tap leftshoulder +4660 expect tv-screen=home +4670 pad tap rightshoulder +4680 pad tap rightshoulder +4690 pad tap rightshoulder +4730 expect tv-screen=settings +4740 pad tap dpup +4750 pad tap dpup +4760 pad tap dpup +4770 pad tap dpup +4810 expect tv-focus=Picture +4820 pad tap dpdown +4830 pad tap dpdown +4880 expect tv-focus=Interface +4890 pad tap dpright +4900 pad tap dpup +4910 pad tap dpup +4920 pad tap dpup +4930 pad tap dpup +4940 pad tap dpup +4950 pad tap dpup +4960 pad tap dpup +4970 pad tap dpup +4980 pad tap dpup +4990 pad tap dpup +5000 pad tap dpup +5010 pad tap dpup +5020 pad tap dpup +5030 pad tap dpup +5040 pad tap dpup +5050 pad tap dpup +5060 pad tap dpup +5070 pad tap dpup +5080 pad tap dpup +5090 pad tap dpup +5130 expect tv-focus=Show FPS +5140 pad tap dpdown +5150 pad tap dpdown +5160 pad tap dpdown +5170 pad tap dpdown +5210 expect tv-focus=Auto: in fullscreen +5210 pad tap dpleft +5250 expect cfg=ui.tv_auto_fullscreen:0 +5250 expect tv=1 # still on (Manual cause after the reconfigure) +5260 pad tap dpright +5300 expect cfg=ui.tv_auto_fullscreen:1 +5300 expect cfg=ui.tv_mode:absent # the checkbox change saved the config, and still did not pin the default +5310 pad tap dpup +5350 expect tv-focus=TV mode +5350 pad tap dpleft # Auto -> Manual +5390 expect cfg=ui.tv_mode:manual +5390 expect tv=1 +5400 pad tap dpdown +5410 pad tap dpdown +5420 pad tap dpdown +5430 pad tap dpdown +5440 pad tap dpdown +5450 pad tap dpdown +5460 pad tap dpdown +5470 pad tap dpdown +5480 pad tap dpdown +5490 pad tap dpdown +5500 pad tap dpdown +5510 pad tap dpdown +5520 pad tap dpdown +5530 pad tap dpdown +5540 pad tap dpdown +5550 pad tap dpdown +5560 pad tap dpdown +5570 pad tap dpdown +5580 pad tap dpdown +5590 pad tap dpdown +5630 expect tv-focus=Desktop settings… # the Auto rows are gone +5630 pad tap a +5670 expect tv-more=1 +5670 pad tap b +5710 expect tv-more=0 +5720 pad tap leftshoulder # back to Home: the shell resumes the screen TV mode was left on, and the lines below start from Home +5730 pad tap leftshoulder +5740 pad tap leftshoulder +5780 expect tv-screen=home +5780 key tap F4 +5820 expect tv=0 + +# The hint never shows under Off or Auto, even with the key unset and the run's flag clear (hintreset: forget both, zero toast-count). +5820 hintreset +5820 tvsetting off +5820 expect desk-pad=1 +5820 expect desk-pending=0 +5830 pad tap rightshoulder +5850 expect desk-pending=1 # the desktop library took the press (so the hint site was reached) ... +5850 expect toast-count=0 # ... and showed nothing +5850 expect cfg=ui.tv_hint_shown:absent +5850 tvsetting auto +5850 mouse 100 100 # (TV mode is off: the pointer's start is not travel) +5860 pad tap a # Auto's switching press: consumed, no hint +5870 expect tv=1 +5870 expect toast-count=0 +5870 expect cfg=ui.tv_hint_shown:absent +# The mouse path through App::handleEvent: 30 px of motion (3 x 10) leave a controller-started TV mode on, the 4th step (40 px) ends it. +# (A warp reports xrel = 0, so `mouse` also pushes the motion event itself; the windowed check on the Studio sends the same.) +5870 mouse 110 100 +5870 mouse 120 100 +5870 mouse 130 100 +5875 expect tv=1 +5875 mouse 140 100 +5880 expect tv=0 # A game to play with. -2760 load @VJ_ROMS@/yarc.j64 -2800 expect loaded=1 +5880 load @VJ_ROMS@/yarc.j64 +5920 expect loaded=1 +5920 expect core-option=virtualjaguar_internal_resolution:2x # the TV Settings change made with no game (first block above) reached this one + +# Auto with a game running (#991 phase 4): a pad press is the game's, never a trigger; fullscreen still turns TV mode on and leaves +# the game running, unpaused, with its input; leaving fullscreen turns it off. +5920 tvsetting auto +5920 pad tap dpdown +5960 expect tv=0 # the game owns the pad +5960 fullscreen on +6000 expect tv=1 +6000 expect menu-open=0 +6000 expect paused=0 +6000 pad down dpright +6040 expect core-input=1 +6040 pad up dpright +6040 fullscreen off +6080 expect tv=0 +6080 tvsetting manual # F4 opens the menu (and pauses the game), F4 again closes it. -2860 key tap F4 -2900 expect menu-open=1 -2900 expect paused=1 -2920 key tap F4 -2960 expect menu-open=0 -2960 expect paused=0 +6120 key tap F4 +6160 expect menu-open=1 +6160 expect paused=1 +6180 key tap F4 +6220 expect menu-open=0 +6220 expect paused=0 # A pause the player made is theirs: the menu does not undo it. -2980 key tap F8 -3000 expect paused=1 -3020 key tap F4 -3060 expect menu-open=1 -3080 key tap F4 -3120 expect menu-open=0 -3120 expect paused=1 -3140 key tap F8 -3160 expect paused=0 +6240 key tap F8 +6260 expect paused=1 +6280 key tap F4 +6320 expect menu-open=1 +6340 key tap F4 +6380 expect menu-open=0 +6380 expect paused=1 +6400 key tap F8 +6420 expect paused=0 # F4 while a text box has the keyboard does nothing. -3180 typing on -3200 key tap F4 -3240 expect menu-open=0 -3240 typing off +6440 typing on +6460 key tap F4 +6500 expect menu-open=0 +6500 typing off # Esc is Back, and Back on the menu's first page is Resume. -3260 key tap F4 -3300 expect menu-open=1 -3320 key tap Escape -3360 expect menu-open=0 -3360 expect paused=0 +6520 key tap F4 +6560 expect menu-open=1 +6580 key tap Escape +6620 expect menu-open=0 +6620 expect paused=0 # A key let go while the menu is open still reaches the app (Tab is fast-forward and has to be let go of). -3380 key down Tab -3400 expect fast-forward=1 -3420 key tap F4 -3460 expect menu-open=1 -3480 key up Tab -3520 expect fast-forward=0 -3540 key tap F4 -3580 expect menu-open=0 +6640 key down Tab +6660 expect fast-forward=1 +6680 key tap F4 +6720 expect menu-open=1 +6740 key up Tab +6780 expect fast-forward=0 +6800 key tap F4 +6840 expect menu-open=0 # A Share tap is not a hold: no menu. # (`pad tap` presses and releases inside one line, so a stalled runner cannot stretch it into a hold.) -3620 pad tap misc1 -3700 expect menu-open=0 +6880 pad tap misc1 +6960 expect menu-open=0 # A Share hold of 600 ms opens the menu; letting go leaves it open. -3720 pad down misc1 -4340 expect menu-open=1 -4340 expect paused=1 -4360 pad up misc1 -4400 expect menu-open=1 +6980 pad down misc1 +7600 expect menu-open=1 +7600 expect paused=1 +7620 pad up misc1 +7660 expect menu-open=1 # A Share hold while it is open closes it (the edge reaches the arbiter before the shell). -4420 pad down misc1 -5060 expect menu-open=0 -5060 expect paused=0 -5080 pad up misc1 -5120 expect menu-open=0 +7680 pad down misc1 +8320 expect menu-open=0 +8320 expect paused=0 +8340 pad up misc1 +8380 expect menu-open=0 # Focus lost 200 ms into a Share hold: the hold is forgotten, so 700 ms later the menu is still closed. -5140 pad down misc1 -5340 focus lost -6040 expect menu-open=0 -6040 pad up misc1 -6080 expect menu-open=0 +8400 pad down misc1 +8600 focus lost +9300 expect menu-open=0 +9300 pad up misc1 +9340 expect menu-open=0 # The menu holds the pause: F8 under it is refused, and closing gives back the state from before the menu. -6100 key tap F4 -6140 expect menu-open=1 -6140 expect paused=1 -6160 key tap F8 -6200 expect paused=1 -6200 expect menu-open=1 -6220 key tap F4 -6260 expect menu-open=0 -6260 expect paused=0 +9360 key tap F4 +9400 expect menu-open=1 +9400 expect paused=1 +9420 key tap F8 +9460 expect paused=1 +9460 expect menu-open=1 +9480 key tap F4 +9520 expect menu-open=0 +9520 expect paused=0 # The menu owns the input: with it open the core reads neutral input (under netplay the menu cannot pause the game, and this is # what keeps it from playing under the menu); a button held across the close stays masked until released; with the menu closed it plays. -6280 key tap F4 -6320 expect menu-open=1 -6340 pad down dpright -6380 expect core-input=0 -6400 pad up dpright -6420 pad down dpright -6440 key tap F4 -6480 expect menu-open=0 -6480 expect core-input=0 -6500 pad up dpright -6520 expect core-input=0 -6540 pad down dpright -6580 expect core-input=1 -6600 pad up dpright -6640 expect core-input=0 +9540 key tap F4 +9580 expect menu-open=1 +9600 pad down dpright +9640 expect core-input=0 +9660 pad up dpright +9680 pad down dpright +9700 key tap F4 +9740 expect menu-open=0 +9740 expect core-input=0 +9760 pad up dpright +9780 expect core-input=0 +9800 pad down dpright +9840 expect core-input=1 +9860 pad up dpright +9900 expect core-input=0 # F4 while a binding is being captured binds F4 and does not open the menu. -6660 capture help -6680 key tap F4 -6720 expect capturing=0 -6720 expect menu-open=0 -6720 expect kb-bind=help:F4 +9920 capture help +9940 key tap F4 +9980 expect capturing=0 +9980 expect menu-open=0 +9980 expect kb-bind=help:F4 -# Walk the menu with the pad (nine rows down: Save state, Load state, Achievements, Overlays, Settings, Controls, the TV-mode row, Reset, Close game): ask, answer No (back to the menu), ask again, Up to Yes (focus starts on No), answer Yes. -6760 key tap F4 -6800 expect menu-open=1 -6820 pad tap dpdown -6830 pad tap dpdown -6840 pad tap dpdown -6850 pad tap dpdown -6860 pad tap dpdown -6870 pad tap dpdown -6880 pad tap dpdown -6900 pad tap dpdown -6910 pad tap dpdown -6920 pad tap a -6940 pad tap a -6980 expect menu-open=1 -6980 expect loaded=1 -7000 pad tap a -7020 pad tap dpup -7040 pad tap a -7080 expect loaded=0 -7080 expect menu-open=0 +# Walk the menu with the pad (ten rows down: Save state, Load state, Library, Achievements, Overlays, Settings, Controls, the TV-mode row, Reset, Close game): ask, answer No (back to the menu), ask again, Up to Yes (focus starts on No), answer Yes. +10020 key tap F4 +10060 expect menu-open=1 +10080 pad tap dpdown +10090 pad tap dpdown +10100 pad tap dpdown +10110 pad tap dpdown +10120 pad tap dpdown +10130 pad tap dpdown +10140 pad tap dpdown +10150 pad tap dpdown # (Library, the row after Load state, makes it ten rows down) +10160 pad tap dpdown +10170 pad tap dpdown +10180 pad tap a +10200 pad tap a +10240 expect menu-open=1 +10240 expect loaded=1 +10260 pad tap a +10280 pad tap dpup +10300 pad tap a +10340 expect loaded=0 +10340 expect menu-open=0 # TV mode survives a game: turn it on, walk to Library, start a game (the TV screen goes away under it), close the game # from the in-game menu -- and the TV screen the player left is back. -7160 key tap F4 -7200 expect tv=1 -7200 expect tv-screen=home -7220 pad tap rightshoulder -7260 expect tv-screen=library -7260 load @VJ_ROMS@/yarc.j64 -7300 expect loaded=1 -7300 expect tv=1 -7320 key tap F4 -7360 expect menu-open=1 -7360 expect tv=1 -7380 pad tap dpdown -7390 pad tap dpdown -7400 pad tap dpdown -7410 pad tap dpdown -7420 pad tap dpdown -7430 pad tap dpdown -7440 pad tap dpdown -7460 pad tap dpdown -7470 pad tap dpdown -7480 pad tap a -7500 pad tap dpup -7520 pad tap a -7560 expect loaded=0 -7560 expect menu-open=0 -7560 expect tv-screen=library -7580 key tap F4 -7620 expect tv=0 +10420 key tap F4 +10460 expect tv=1 +10460 expect tv-screen=home +10480 pad tap rightshoulder +10520 expect tv-screen=library +10520 load @VJ_ROMS@/yarc.j64 +10560 expect loaded=1 +10560 expect tv=1 +10580 key tap F4 +10620 expect menu-open=1 +10620 expect tv=1 +10640 pad tap dpdown +10650 pad tap dpdown +10660 pad tap dpdown +10670 pad tap dpdown +10680 pad tap dpdown +10690 pad tap dpdown +10700 pad tap dpdown +10710 pad tap dpdown +10720 pad tap dpdown +10730 pad tap dpdown +10740 pad tap a +10760 pad tap dpup +10780 pad tap a +10820 expect loaded=0 +10820 expect menu-open=0 +10820 expect tv-screen=library +10840 key tap F4 +10880 expect tv=0 # A binding capture started with TV mode on keeps its input: the shoulder button that would switch sections (the screen # the player left is Library: LB would take it to Home) and the Return key that would be Accept go to the capture instead # of navigating. -7660 key tap F4 -7700 expect tv=1 -7700 expect tv-screen=library -7720 capture help -7740 pad tap leftshoulder -7780 expect tv-screen=library -7800 key tap Return -7840 expect capturing=0 -7840 expect tv-screen=library -7840 expect kb-bind=help:Return -7860 key tap F4 -7900 expect tv=0 +10920 key tap F4 +10960 expect tv=1 +10960 expect tv-screen=library +10980 capture help +11000 pad tap leftshoulder +11040 expect tv-screen=library +11060 key tap Return +11100 expect capturing=0 +11100 expect tv-screen=library +11100 expect kb-bind=help:Return +11120 key tap F4 +11160 expect tv=0 # Ctrl+Shift+T (Cmd+Shift+T on a Mac) toggles TV mode with or without a game, whatever F4 is bound to; a game keeps running # and the in-game menu stays shut. It is ignored while a text box has the keyboard or a binding is being captured, and does # nothing when the TV-mode setting is Off. -7920 key tap ctrl+shift+T -7960 expect tv=1 -7960 expect menu-open=0 -7960 key tap ctrl+shift+T -8000 expect tv=0 -8000 key tap ctrl+T -8040 expect tv=0 # Ctrl alone is not the shortcut -8040 key tap shift+T -8080 expect tv=0 -8080 typing on -8100 key tap ctrl+shift+T -8140 expect tv=0 -8140 typing off -8140 capture help -8160 key tap ctrl+shift+T -8200 expect tv=0 -8200 key tap Return # ends the capture, if the shortcut did not already -8240 expect capturing=0 -8240 tvsetting off -8260 key tap ctrl+shift+T -8300 expect tv=0 -8300 tvsetting manual +11180 key tap ctrl+shift+T +11220 expect tv=1 +11220 expect menu-open=0 +11220 key tap ctrl+shift+T +11260 expect tv=0 +11260 key tap ctrl+T +11300 expect tv=0 # Ctrl alone is not the shortcut +11300 key tap shift+T +11340 expect tv=0 +11340 typing on +11360 key tap ctrl+shift+T +11400 expect tv=0 +11400 typing off +11400 capture help +11420 key tap ctrl+shift+T +11460 expect tv=0 +11460 key tap Return # ends the capture, if the shortcut did not already +11500 expect capturing=0 +11500 tvsetting off +11520 key tap ctrl+shift+T +11560 expect tv=0 +11560 tvsetting manual # With a game loaded: the shortcut turns TV mode on and off, the game stays loaded and running, no menu opens. -8320 load @VJ_ROMS@/yarc.j64 -8360 expect loaded=1 -8360 key tap ctrl+shift+T -8400 expect tv=1 -8400 expect loaded=1 -8400 expect menu-open=0 -8400 expect paused=0 -8400 key tap ctrl+shift+T -8440 expect tv=0 -8440 expect loaded=1 -8440 expect menu-open=0 -8440 expect paused=0 -8440 key tap F4 # F4 with a game is still the menu, not the TV-mode toggle -8480 expect menu-open=1 -8480 expect tv=0 -8480 key tap F4 -8520 expect menu-open=0 +11580 load @VJ_ROMS@/yarc.j64 +11620 expect loaded=1 +11620 key tap ctrl+shift+T +11660 expect tv=1 +11660 expect loaded=1 +11660 expect menu-open=0 +11660 expect paused=0 +11660 key tap ctrl+shift+T +11700 expect tv=0 +11700 expect loaded=1 +11700 expect menu-open=0 +11700 expect paused=0 +11700 key tap F4 # F4 with a game is still the menu, not the TV-mode toggle +11740 expect menu-open=1 +11740 expect tv=0 +11740 key tap F4 +11780 expect menu-open=0 # A running game owns its pad: the desktop library does not, whether or not its window is open. Paused with the window open it # does again. -8520 expect menu-open=0 -8520 expect desk-pad=0 -8520 key tap ctrl+L -8560 expect library-window=1 -8560 expect desk-pad=0 -8560 key tap F8 -8600 expect paused=1 -8600 expect desk-pad=1 -8600 pad tap rightshoulder -8640 expect desk-pending=1 -8640 key tap F8 -8680 expect paused=0 -8680 expect desk-pad=0 -8680 key tap F8 -8720 expect desk-pad=1 -8720 key tap ctrl+L -8760 expect library-window=0 -8760 expect desk-pad=0 -8760 key tap F8 -8800 expect paused=0 +11780 expect menu-open=0 +11780 expect desk-pad=0 +11780 key tap ctrl+L +11820 expect library-window=1 +11820 expect desk-pad=0 +11820 key tap F8 +11860 expect paused=1 +11860 expect desk-pad=1 +11860 pad tap rightshoulder +11900 expect desk-pending=1 +11900 key tap F8 +11940 expect paused=0 +11940 expect desk-pad=0 +11940 key tap F8 +11980 expect desk-pad=1 +11980 key tap ctrl+L +12020 expect library-window=0 +12020 expect desk-pad=0 +12020 key tap F8 +12060 expect paused=0 # Menu buttons (#1021): Set captures the virtual pad's next button for a menu intent, and writes no game binding. North (Y) # becomes Confirm; Y was Favorite's, so the two swap (Favorite takes Confirm's old button, South). -8800 expect capturing=0 -8800 capture nav:confirm -8840 expect capturing=1 -8840 pad tap y -8880 expect capturing=0 -8880 expect cfg=input.padguid.{padguid}.nav.confirm:by -8880 expect cfg=input.padguid.{padguid}.nav.favorite:ba +12060 expect capturing=0 +12060 capture nav:confirm +12100 expect capturing=1 +12100 pad tap y +12140 expect capturing=0 +12140 expect cfg=input.padguid.{padguid}.nav.confirm:by +12140 expect cfg=input.padguid.{padguid}.nav.favorite:ba # Default on one half of the swap puts both back: no two intents on one button, and no stray key. -8880 navdefault confirm -8920 expect cfg=input.padguid.{padguid}.nav.confirm:absent -8920 expect cfg=input.padguid.{padguid}.nav.favorite:absent +12140 navdefault confirm +12180 expect cfg=input.padguid.{padguid}.nav.confirm:absent +12180 expect cfg=input.padguid.{padguid}.nav.favorite:absent # A reserved button is refused and writes nothing: the D-pad moves, so it cannot be a menu button. -8920 capture nav:back -8960 pad tap dpup -9000 expect capturing=0 -9000 expect cfg=input.padguid.{padguid}.nav.back:absent +12180 capture nav:back +12220 pad tap dpup +12260 expect capturing=0 +12260 expect cfg=input.padguid.{padguid}.nav.back:absent # So is the button that opens the controls overlay (the pad's C_HELP, the touchpad button), as the TV-menu button is. -9000 capture nav:back -9040 pad tap touchpad -9080 expect capturing=0 -9080 expect cfg=input.padguid.{padguid}.nav.back:absent +12260 capture nav:back +12300 pad tap touchpad +12340 expect capturing=0 +12340 expect cfg=input.padguid.{padguid}.nav.back:absent # Esc cancels a menu-button capture without writing anything. -9080 capture nav:search -9120 key tap Escape -9160 expect capturing=0 -9160 expect cfg=input.padguid.{padguid}.nav.search:absent +12340 capture nav:search +12380 key tap Escape +12420 expect capturing=0 +12420 expect cfg=input.padguid.{padguid}.nav.search:absent -# A core option changed inside the in-game menu reaches the core once the menu is closed. Down x5 from Resume is Settings (Save state, -# Load state, Achievements, Overlays, Settings), A opens the screen (the groups), Up x4 is Picture whatever group the Settings screen -# was left on earlier in the script (the shell remembers it), A opens Picture (the shader row is absent: the test rules the -# library out), Down x2 is Internal resolution (Integer scaling, Aspect ratio, then it), Right steps it from the core's -# default (1x) to 2x; B x3 is rows -> groups -> the menu -> Resume. The write is a posted command: the value is read after the menu +# A core option changed inside the in-game menu reaches the core once the menu is closed. Down x6 from Resume is Settings (Save state, +# Load state, Library, Achievements, Overlays, Settings), A opens the screen (the groups, on Video: `settings-group` puts the shell's memory +# there, so no count of rail items is needed), A opens Video (the core's category of that name: it is a TV Settings group like the app's), +# Down x2 is Internal resolution (Fast blitter, True color, then it), Left steps it from 2x (the first TV Settings walk set it with no game) to 1x; B x3 is rows -> groups -> the menu -> Resume. The write is a posted command: the value is read after the menu # has closed and the paused core has had its boundary. -9200 load @VJ_ROMS@/yarc.j64 -9240 expect loaded=1 -9240 expect core-option=virtualjaguar_internal_resolution:absent -9240 key tap F4 -9280 expect menu-open=1 -9280 pad tap dpdown -9290 pad tap dpdown -9300 pad tap dpdown -9310 pad tap dpdown -9320 pad tap dpdown -9340 pad tap a -9345 pad tap dpup -9350 pad tap dpup -9355 pad tap dpup -9358 pad tap dpup -9360 pad tap a -9380 pad tap dpdown -9390 pad tap dpdown -9410 pad tap dpright -9430 pad tap b -9450 pad tap b -9470 pad tap b -9510 expect menu-open=0 -9510 expect core-option=virtualjaguar_internal_resolution:2x +12460 load @VJ_ROMS@/yarc.j64 +12500 expect loaded=1 +12500 expect core-option=virtualjaguar_internal_resolution:2x # (set by the first TV Settings walk, with no game) +12500 settings-group core:video +12500 key tap F4 +12540 expect menu-open=1 +12540 pad tap dpdown +12550 pad tap dpdown +12560 pad tap dpdown +12570 pad tap dpdown +12580 pad tap dpdown +12590 pad tap dpdown +12600 pad tap a +12620 pad tap a +12640 pad tap dpdown +12650 pad tap dpdown +12670 pad tap dpleft +12690 pad tap b +12710 pad tap b +12730 pad tap b +12770 expect menu-open=0 +12770 expect core-option=virtualjaguar_internal_resolution:1x -# "More settings..." opened from the in-game menu keeps the menu underneath and hands the pad to the window only. Down x5 is Settings, -# A the groups, Down x4 lands on Interface (it clamps there, so the group the screen was left on does not matter), A its rows, Down x6 -# clamps on More settings..., A opens the window. The window's pad presses never reach the menu: two Ups and a shoulder button go to +# "More settings..." opened from the in-game menu keeps the menu underneath and hands the pad to the window only. Down x6 is Settings, +# A the groups (on Interface: `settings-group`), A its rows, Down x20 clamps on Desktop settings..., A opens the window. The window's pad presses never reach the menu: two Ups and a shoulder button go to # it, B closes it, and the menu is where it was -- the focus is still on More settings... (A opens the window again), and it takes # two more B to leave the Settings screen and one to Resume. Esc closes the window the same way. -9530 key tap F4 -9570 expect menu-open=1 -9570 pad tap dpdown -9580 pad tap dpdown -9590 pad tap dpdown -9600 pad tap dpdown -9610 pad tap dpdown -9630 pad tap a -9650 pad tap dpdown -9660 pad tap dpdown -9670 pad tap dpdown -9680 pad tap dpdown -9700 pad tap a -9720 pad tap dpdown -9730 pad tap dpdown -9740 pad tap dpdown -9750 pad tap dpdown -9760 pad tap dpdown -9770 pad tap dpdown -9790 pad tap a -9830 expect settings-open=1 -9830 expect tv-more=1 -9830 expect menu-open=1 -9830 expect paused=1 -9830 pad tap dpup -9840 pad tap dpup -9850 pad tap leftshoulder -9890 expect menu-open=1 -9890 pad tap b -9930 expect settings-open=0 -9930 expect tv-more=0 -9930 expect menu-open=1 -9930 expect paused=1 -9930 pad tap a # the focus did not move: More settings... again -9970 expect settings-open=1 -9970 expect tv-more=1 -9970 key tap Escape -10010 expect settings-open=0 -10010 expect tv-more=0 -10010 expect menu-open=1 -10010 pad tap b # rows -> groups -10030 pad tap b # groups -> the menu -10070 expect menu-open=1 -10070 pad tap b # Resume -10110 expect menu-open=0 -10110 expect paused=0 +12790 settings-group interface +12790 key tap F4 +12830 expect menu-open=1 +12830 pad tap dpdown +12840 pad tap dpdown +12850 pad tap dpdown +12860 pad tap dpdown +12870 pad tap dpdown +12880 pad tap dpdown +12890 pad tap a +12960 pad tap a +12980 pad tap dpdown +12990 pad tap dpdown +13000 pad tap dpdown +13010 pad tap dpdown +13020 pad tap dpdown +13030 pad tap dpdown +13040 pad tap dpdown +13050 pad tap dpdown +13060 pad tap dpdown +13070 pad tap dpdown +13080 pad tap dpdown +13090 pad tap dpdown +13100 pad tap dpdown +13110 pad tap dpdown +13120 pad tap dpdown +13130 pad tap dpdown +13140 pad tap dpdown +13150 pad tap dpdown +13160 pad tap dpdown +13170 pad tap dpdown +13190 pad tap a +13230 expect settings-open=1 +13230 expect tv-more=1 +13230 expect menu-open=1 +13230 expect paused=1 +13230 pad tap dpup +13240 pad tap dpup +13250 pad tap leftshoulder +13290 expect menu-open=1 +13290 pad tap b +13330 expect settings-open=0 +13330 expect tv-more=0 +13330 expect menu-open=1 +13330 expect paused=1 +13330 pad tap a # the focus did not move: More settings... again +13370 expect settings-open=1 +13370 expect tv-more=1 +13370 key tap Escape +13410 expect settings-open=0 +13410 expect tv-more=0 +13410 expect menu-open=1 +13410 pad tap b # rows -> groups +13430 pad tap b # groups -> the menu +13470 expect menu-open=1 +13470 pad tap b # Resume +13510 expect menu-open=0 +13510 expect paused=0 # A window that is no longer "live" is never left over a running game (its pad player could not close it, and the pads are the # game's): a Share hold closes the menu under "More settings..." (the game unpauses and polls its pad itself), and the window goes # with the hand-off. Same walk as above to open the window, then the hold. -10150 key tap F4 -10190 expect menu-open=1 -10190 pad tap dpdown -10200 pad tap dpdown -10210 pad tap dpdown -10220 pad tap dpdown -10230 pad tap dpdown -10250 pad tap a -10270 pad tap dpdown -10280 pad tap dpdown -10290 pad tap dpdown -10300 pad tap dpdown -10320 pad tap a -10340 pad tap dpdown -10350 pad tap dpdown -10360 pad tap dpdown -10370 pad tap dpdown -10380 pad tap dpdown -10390 pad tap dpdown -10410 pad tap a -10450 expect tv-more=1 -10450 expect menu-open=1 -10450 pad down misc1 -11070 expect menu-open=0 -11070 expect tv-more=0 -11070 expect settings-open=0 -11070 expect paused=0 -11090 pad up misc1 -11130 expect settings-open=0 +13550 settings-group interface +13550 key tap F4 +13590 expect menu-open=1 +13590 pad tap dpdown +13600 pad tap dpdown +13610 pad tap dpdown +13620 pad tap dpdown +13630 pad tap dpdown +13640 pad tap dpdown +13650 pad tap a +13720 pad tap a +13740 pad tap dpdown +13750 pad tap dpdown +13760 pad tap dpdown +13770 pad tap dpdown +13780 pad tap dpdown +13790 pad tap dpdown +13800 pad tap dpdown +13810 pad tap dpdown +13820 pad tap dpdown +13830 pad tap dpdown +13840 pad tap dpdown +13850 pad tap dpdown +13860 pad tap dpdown +13870 pad tap dpdown +13880 pad tap dpdown +13890 pad tap dpdown +13900 pad tap dpdown +13910 pad tap dpdown +13920 pad tap dpdown +13930 pad tap dpdown +13950 pad tap a +13990 expect tv-more=1 +13990 expect menu-open=1 +13990 pad down misc1 +14610 expect menu-open=0 +14610 expect tv-more=0 +14610 expect settings-open=0 +14610 expect paused=0 +14630 pad up misc1 +14670 expect settings-open=0 # A game dropped on the window while it is open from the Base layer: the game starts, the window goes (no game is left behind a TV-sized -# desktop window). Close the game from its menu (Down x9 is Close game, A asks, Up is Yes, A answers), TV mode on, Base layer to -# Settings (LB x4 clamps on Home, RB x3), into the list and Down x6 clamps on More settings... whichever row the screen remembered. -11200 key tap F4 -11240 expect menu-open=1 -11240 pad tap dpdown -11250 pad tap dpdown -11260 pad tap dpdown -11270 pad tap dpdown -11280 pad tap dpdown -11290 pad tap dpdown -11300 pad tap dpdown -11310 pad tap dpdown -11320 pad tap dpdown -11340 pad tap a -11360 pad tap dpup -11380 pad tap a -11420 expect loaded=0 -11420 key tap F4 -11460 expect tv=1 -11460 pad tap leftshoulder -11470 pad tap leftshoulder -11480 pad tap leftshoulder -11490 pad tap leftshoulder -11500 pad tap rightshoulder -11510 pad tap rightshoulder -11520 pad tap rightshoulder -11540 expect tv-screen=settings -11540 pad tap dpright -11550 pad tap dpdown -11560 pad tap dpdown -11570 pad tap dpdown -11580 pad tap dpdown -11590 pad tap dpdown -11600 pad tap dpdown -11620 pad tap a -11660 expect tv-more=1 -11660 load @VJ_ROMS@/yarc.j64 -11720 expect loaded=1 -11720 expect tv-more=0 -11720 expect settings-open=0 +# desktop window). Close the game from its menu (Down x10 is Close game, A asks, Up is Yes, A answers), TV mode on, Base layer to +# Settings (LB x4 clamps on Home, RB x3), into the list and Down x20 clamps on Desktop settings... whichever row the screen remembered. +14740 key tap F4 +14780 expect menu-open=1 +14780 pad tap dpdown +14790 pad tap dpdown +14800 pad tap dpdown +14810 pad tap dpdown +14820 pad tap dpdown +14830 pad tap dpdown +14840 pad tap dpdown +14850 pad tap dpdown +14860 pad tap dpdown +14870 pad tap dpdown +14880 pad tap a +14900 pad tap dpup +14920 pad tap a +14960 expect loaded=0 +14960 key tap F4 +15000 expect tv=1 +15000 settings-group interface +15000 pad tap leftshoulder +15010 pad tap leftshoulder +15020 pad tap leftshoulder +15030 pad tap leftshoulder +15040 pad tap rightshoulder +15050 pad tap rightshoulder +15060 pad tap rightshoulder +15080 expect tv-screen=settings +15080 pad tap dpright +15090 pad tap dpdown +15100 pad tap dpdown +15110 pad tap dpdown +15120 pad tap dpdown +15130 pad tap dpdown +15140 pad tap dpdown +15150 pad tap dpdown +15160 pad tap dpdown +15170 pad tap dpdown +15180 pad tap dpdown +15190 pad tap dpdown +15200 pad tap dpdown +15210 pad tap dpdown +15220 pad tap dpdown +15230 pad tap dpdown +15240 pad tap dpdown +15250 pad tap dpdown +15260 pad tap dpdown +15270 pad tap dpdown +15280 pad tap dpdown +15290 pad tap a +15330 expect tv-more=1 +15330 load @VJ_ROMS@/yarc.j64 +15390 expect loaded=1 +15390 expect tv-more=0 +15390 expect settings-open=0 + +# Settings while TV mode is on (#991): never the desktop window. A game is loaded and TV mode is on: Ctrl+, opens the in-game menu +# (paused, as F4 does) with the Settings screen in its slot; a second press changes nothing; B leaves the slot for the menu root and +# B again resumes. With TV mode off Ctrl+, is the desktop window again (and toggles it). +15430 expect menu-open=0 +15430 expect tv=1 +15430 key tap ctrl+, +15470 expect menu-open=1 +15470 expect paused=1 +15470 expect settings-open=0 +15470 expect menu-slot=settings +15470 key tap ctrl+, +15510 expect menu-open=1 +15510 expect menu-slot=settings +15510 expect settings-open=0 +15510 pad tap b +15550 expect menu-open=1 +15550 expect menu-slot=none +15550 pad tap b +15590 expect menu-open=0 +15590 expect paused=0 +15590 key tap ctrl+shift+T +15630 expect tv=0 +15630 key tap ctrl+, +15670 expect settings-open=1 +15670 expect menu-open=0 +15670 key tap ctrl+, +15710 expect settings-open=0 + +# The native menu path with a game and TV mode on (Cmd::Settings): the same menu with the Settings screen in the slot. B leaves the +# slot; Ctrl+, again from the menu root opens it; from the Save page it replaces the page. Each B returns to the root, B there resumes. +15750 key tap ctrl+shift+T +15790 expect tv=1 +15790 command settings +15830 expect menu-open=1 +15830 expect paused=1 +15830 expect settings-open=0 +15830 expect menu-slot=settings +15830 pad tap b +15870 expect menu-slot=none +15870 key tap ctrl+, +15910 expect menu-slot=settings +15910 pad tap b +15950 expect menu-slot=none +15950 pad tap b +15990 expect menu-open=0 +15990 key tap F4 +16030 expect menu-open=1 +16030 pad tap dpdown # Save state +16040 pad tap a +16050 key tap ctrl+, +16090 expect menu-slot=settings +16090 expect settings-open=0 +16090 pad tap b +16130 expect menu-slot=none +16130 expect menu-open=1 +16130 pad tap b +16170 expect menu-open=0 +16170 expect paused=0 + +# "BIOS & boot settings" (Cmd::BiosSettings) in TV mode is the pad-driven window, over the in-game menu with a game: B closes it and +# the menu is still there. +16210 command bios +16250 expect menu-open=1 +16250 expect settings-open=1 +16250 expect tv-more=1 +16250 pad tap b +16290 expect settings-open=0 +16290 expect tv-more=0 +16290 expect menu-open=1 +16290 pad tap b +16330 expect menu-open=0 + +# Hardcore will not pause (allowPause refuses): the menu opens anyway, the game runs on under it with the menu owning the pads, Settings +# is reachable, and closing hands the pads back. +16370 hardcore-pause deny +16370 key tap F4 +16410 expect menu-open=1 +16410 expect paused=0 +16410 expect menu-owns-input=1 +16410 pad tap b +16450 expect menu-open=0 +16450 expect menu-owns-input=0 +16450 expect paused=0 +16450 command settings +16490 expect menu-open=1 +16490 expect paused=0 +16490 expect menu-owns-input=1 +16490 expect menu-slot=settings +16490 expect settings-open=0 +16490 pad tap b +16530 pad tap b +16570 expect menu-open=0 +16570 expect menu-owns-input=0 +16570 expect paused=0 +16570 hardcore-pause allow + +# Library while TV mode is on with a game (#991 Task 21): never the desktop library window. Ctrl+L opens the in-game menu (paused, as F4 +# does) with the Library in its slot; a second press changes nothing; B leaves the slot for the menu root and B again resumes. A headless +# run has no scanned library (only a windowed run starts the scan), so the grid is empty and A launches nothing: the launch question +# itself (A on a tile -> "Close <game> and start <title>?", focus on No, Yes starts it) is tv_ui_smoke's ingame_library_launch_asks. +16610 expect menu-open=0 +16610 expect tv=1 +16610 key tap ctrl+L +16650 expect menu-open=1 +16650 expect paused=1 +16650 expect library-window=0 +16650 expect menu-slot=library +16650 expect menu-page=root +16650 key tap ctrl+L +16690 expect menu-open=1 +16690 expect menu-slot=library +16690 expect library-window=0 +16690 pad tap a +16730 expect loaded=1 +16730 expect menu-page=root +16730 expect menu-slot=library +16730 pad tap b +16770 expect menu-open=1 +16770 expect menu-slot=none +16770 expect tv-focus=Library +16770 pad tap b +16810 expect menu-open=0 +16810 expect paused=0 + +# The native menu path (Cmd::Library, which a synthesized ctrl+L cannot reach on a Mac): the same menu with the Library in the slot. +16850 command library +16890 expect menu-open=1 +16890 expect paused=1 +16890 expect library-window=0 +16890 expect menu-slot=library +16890 pad tap b +16930 expect menu-slot=none +16930 pad tap b +16970 expect menu-open=0 + +# Hardcore will not pause: the menu opens anyway over the running game, with the Library in the slot, and closing hands the pads back. +17010 hardcore-pause deny +17010 command library +17050 expect menu-open=1 +17050 expect paused=0 +17050 expect menu-owns-input=1 +17050 expect menu-slot=library +17050 expect library-window=0 +17050 pad tap b +17090 pad tap b +17130 expect menu-open=0 +17130 expect menu-owns-input=0 +17130 expect paused=0 +17130 hardcore-pause allow + +# With TV mode off the native Library command is the desktop window again (and toggles it). +17170 key tap ctrl+shift+T +17210 expect tv=0 +17210 command library +17250 expect library-window=1 +17250 expect menu-open=0 +17250 command library +17290 expect library-window=0 +# The desktop library window does not stay open when TV mode comes on (any trigger): its Library is the TV screen now. +17290 command library +17330 expect library-window=1 +17330 key tap ctrl+shift+T +17370 expect tv=1 +17370 expect library-window=0 + +# Hardcore's run-on menu (the game keeps running, its banner says so): F8 must not pause the game behind it. The pause-decay countdown can +# end while the menu is open, so the gate allows a pause by then -- F8 is still refused ("Close the menu to resume", as under a held pause). +17410 hardcore-pause deny +17410 key tap F4 +17450 expect menu-open=1 +17450 expect paused=0 +17450 expect menu-owns-input=1 +17450 hardcore-pause allow +17490 key tap F8 +17530 expect paused=0 +17530 expect menu-open=1 +17530 expect toast=Close the menu to resume +17530 pad tap b +17570 expect menu-open=0 +17570 expect menu-owns-input=0 +17570 expect paused=0 + +# "BIOS & boot settings" (Cmd::BiosSettings) in TV mode with NO game: no in-game menu to open under it -- the pad-driven window opens +# alone, and B closes it. The game is closed from its menu first (Down clamps on Close game, A asks, Up is Yes, A answers). +17610 key tap F4 +17650 expect menu-open=1 +17660 pad tap dpdown +17670 pad tap dpdown +17680 pad tap dpdown +17690 pad tap dpdown +17700 pad tap dpdown +17710 pad tap dpdown +17720 pad tap dpdown +17730 pad tap dpdown +17740 pad tap dpdown +17750 pad tap dpdown +17760 pad tap dpdown +17770 pad tap dpdown +17800 expect tv-focus=Close game +17820 pad tap a +17860 expect menu-page=confirm-close +17880 pad tap dpup +17900 pad tap a +17960 expect loaded=0 +17960 expect menu-open=0 +18000 command bios +18040 expect menu-open=0 +18040 expect settings-open=1 +18040 expect tv-more=1 +18040 pad tap b +18080 expect settings-open=0 +18080 expect tv-more=0 +18080 expect menu-open=0 diff --git a/app/tests/tv_mode_test.cpp b/app/tests/tv_mode_test.cpp index b7122d60..fd5f62a2 100644 --- a/app/tests/tv_mode_test.cpp +++ b/app/tests/tv_mode_test.cpp @@ -187,8 +187,100 @@ static void testSettingKeys() { CHECK(!settingFromKey("", s) && !settingFromKey("Auto", s)); CHECK(std::string(settingKey(TvSetting::Off)) == "off" && std::string(settingKey(TvSetting::Manual)) == "manual" && std::string(settingKey(TvSetting::Auto)) == "auto"); - CHECK(effectiveSetting(TvSetting::Auto) == TvSetting::Manual && effectiveSetting(TvSetting::Off) == TvSetting::Off); - CHECK(effectiveSetting(TvSetting::Manual) == TvSetting::Manual); + CHECK(defaultSetting("manual") == TvSetting::Manual && defaultSetting("off") == TvSetting::Off && defaultSetting("auto") == TvSetting::Auto && + defaultSetting("bogus") == TvSetting::Auto && defaultSetting(nullptr) == TvSetting::Auto); // the build default (VJ_TV_DEFAULT_SETTING) +} + +// The one-time hint (decision 8): the pad's own TV-menu button and the keyboard's, or the fixed shortcut when fewer than two are known. +static void testFirstUseHint() { + CHECK(firstUseHint("Share (hold)", "F4", false) == "Press Share (hold) or F4 for TV mode"); + CHECK(firstUseHint("Share (hold)", "F4", true) == "Press Share (hold) or F4 for TV mode"); // two bindings: no shortcut needed + CHECK(firstUseHint("", "F4", false) == "Press F4 or Ctrl+Shift+T for TV mode"); // a pad with no TV-menu button + CHECK(firstUseHint("", "", true) == "Press Cmd+Shift+T for TV mode"); // nothing bound: the fixed shortcut alone + CHECK(firstUseHint("", "", false) == "Press Ctrl+Shift+T for TV mode"); + CHECK(firstUseHint("Paddle P1", "", true) == "Press Paddle P1 or Cmd+Shift+T for TV mode"); // the keyboard binding was removed +} + +// Every spec 8 trigger order, as App feeds them: edges of fullscreen only, a mapped pad press, mouse travel, a printable key. +// (testAutoTriggers, testMouseTravel and the two fullscreen tests above cover the single triggers; these are the sequences.) +static void testTriggerOrders() { + using C = TvMode::Cause; + TvMode m; + + m.configure({TvSetting::Auto}, false); // pad -> fullscreen enter -> fullscreen leave + m.onPadPress(true); + CHECK(m.active() && m.cause() == C::Controller); + m.onFullscreen(true); + CHECK(m.active() && m.cause() == C::Controller); // already on: it keeps its own cause + m.onFullscreen(false); + CHECK(!m.active() && m.cause() == C::None); // leaving ends it whatever the cause + + m.configure({TvSetting::Auto}, false); // fullscreen enter -> pad -> mouse 100 px -> leave + m.onFullscreen(true); + CHECK(m.active() && m.cause() == C::Fullscreen); + m.onPadPress(true); + CHECK(m.active() && m.cause() == C::Fullscreen); + m.onMouseMove(100, 0); + CHECK(m.active() && m.cause() == C::Fullscreen); // fullscreen holds it + m.onFullscreen(false); + CHECK(!m.active() && m.cause() == C::None); + + m.configure({TvSetting::Auto}, false); // pad -> manual toggle -> pad -> key + m.onPadPress(true); + CHECK(m.active() && m.cause() == C::Controller); + m.toggle(); + CHECK(!m.active() && m.cause() == C::None); + m.onPadPress(true); + CHECK(m.active() && m.cause() == C::Controller); // a pad edge ends the manual override + m.onNonNavKey(); + CHECK(!m.active() && m.cause() == C::None); + + m.configure({TvSetting::Auto}, false); // fullscreen enter -> toggle -> pad -> mouse 40 px -> leave + m.onFullscreen(true); + CHECK(m.active() && m.cause() == C::Fullscreen); + m.toggle(); + CHECK(!m.active() && m.cause() == C::None); + m.onPadPress(true); + CHECK(m.active() && m.cause() == C::Controller); + m.onMouseMove(40, 0); + CHECK(m.active() && m.cause() == C::Controller); // fullscreen holds it + m.onFullscreen(false); + CHECK(!m.active() && m.cause() == C::None); + + m.configure({TvSetting::Auto}, true); // fullscreen at configure -> toggle -> a repeated enter + CHECK(m.active() && m.cause() == C::Fullscreen); + m.toggle(); + CHECK(!m.active()); + m.onFullscreen(true); + CHECK(m.active() && m.cause() == C::Fullscreen); // why App feeds edges only: a repeated enter undoes a manual off + + TvMode off; // Off: every trigger is a no-op, and so is the toggle + off.configure({TvSetting::Off}, true); + CHECK(!off.active()); + off.onFullscreen(true); + off.onPadPress(true); + off.onNonNavKey(); + off.onMouseMove(100, 0); + off.toggle(); + CHECK(!off.active() && off.cause() == C::None); + + TvMode man; // Manual: the triggers do nothing, the toggle works + man.configure({TvSetting::Manual}, true); + CHECK(!man.active()); + man.onFullscreen(true); + man.onPadPress(true); + man.onNonNavKey(); + man.onMouseMove(100, 0); + CHECK(!man.active() && man.cause() == C::None); + man.toggle(); + CHECK(man.active() && man.cause() == C::Manual); + man.onFullscreen(false); + man.onPadPress(true); + man.onNonNavKey(); + man.onMouseMove(100, 0); + CHECK(man.active() && man.cause() == C::Manual); // none of them touches a Manual TV mode + man.toggle(); + CHECK(!man.active() && man.cause() == C::None); } static void testInputOwner() { @@ -205,6 +297,8 @@ int main() { testFullscreenHoldIsState(); testFullscreenLeaveTurnsOff(); testSettingKeys(); + testFirstUseHint(); + testTriggerOrders(); testInputOwner(); std::printf("tv_mode_test: %d checks, %d failed\n", g_checks, g_fail); return g_fail ? 1 : 0; diff --git a/app/tests/tv_nav_test.cpp b/app/tests/tv_nav_test.cpp index 8afa6ea7..081b53a8 100644 --- a/app/tests/tv_nav_test.cpp +++ b/app/tests/tv_nav_test.cpp @@ -39,6 +39,20 @@ static void testDpadRepeat() { CHECK(n.take(2000).empty()); } +// A screen can tell a repeat from a press: lastWasRepeat() is true only after a take() whose last intent was the timer's. +static void testLastWasRepeat() { + NavInput n; + n.padButton(SDL_GAMEPAD_BUTTON_DPAD_RIGHT, true, 0); + CHECK(n.take(0) == Intents{Intent::Right} && !n.lastWasRepeat()); // the press + CHECK(n.take(100).empty() && !n.lastWasRepeat()); + CHECK(n.take(350).size() == 1 && n.lastWasRepeat()); // the first repeat + CHECK(n.take(360).empty() && !n.lastWasRepeat()); + CHECK(n.take(430).size() == 1 && n.lastWasRepeat()); + n.padButton(SDL_GAMEPAD_BUTTON_DPAD_RIGHT, false, 440); + n.padButton(SDL_GAMEPAD_BUTTON_DPAD_RIGHT, true, 450); // a new press arriving with the call: edge, then no repeat + CHECK(n.take(450) == Intents{Intent::Right} && !n.lastWasRepeat()); +} + static void testNoRepeatForButtons() { NavInput n; n.padButton(SDL_GAMEPAD_BUTTON_SOUTH, true, 0); @@ -355,6 +369,7 @@ static void testLegacyEntryPoints() { int main() { testBriefSequence(); testDpadRepeat(); + testLastWasRepeat(); testNoRepeatForButtons(); testStick(); testStickDirections(); diff --git a/app/tests/tv_settings_model_test.cpp b/app/tests/tv_settings_model_test.cpp index 5c12d734..f14c4c3e 100644 --- a/app/tests/tv_settings_model_test.cpp +++ b/app/tests/tv_settings_model_test.cpp @@ -1,13 +1,16 @@ -// TV mode (#991): the TV Settings rows built from plain inputs (tv_settings_model.h) -- core options with the hide / -// read-only / custom-value rules, the app's own toggles, volume, aspect, theme, TV mode and shader rows, and the step -// arithmetic Left / Right / Accept use. +// TV mode (#991): the TV Settings rows built from plain inputs (tv_settings_model.h) -- every visible core option by the core's +// categories with the hide / read-only / custom-value / restart rules, sliders as stepped lists, the app's own toggles, volume, +// aspect, theme, TV mode and shader rows, the groups, and the step arithmetic Left / Right / Accept use. // // vj_tv_settings_model_test // // Pure logic: no window, no core, no App. #include "tv/tv_settings_model.h" +#include <cmath> #include <cstdio> +#include <map> +#include <set> #include <string> #include <vector> @@ -27,56 +30,202 @@ static int g_fail = 0, g_checks = 0; } \ } while (0) -// settings_rows_core_option_rules (Review focus 5) -static void testCoreOptionRules() { +static OptionDef mkDef(const char* key, const char* desc, const char* cat, std::vector<OptionValue> values, const char* def = "", + const char* info = "") { OptionDef d; - d.key = "virtualjaguar_internal_resolution"; - d.values = {{"1x", "1x"}, {"2x", "2x"}}; - bool hidden = false; - SettingRow r = coreOptionRow("resolution", "Internal resolution", 0, &d, "2x", true, false, false, &hidden); - CHECK(!hidden && r.index == 1 && r.choices.size() == 2 && !r.readOnly && r.group == 0); - CHECK(r.id == "resolution" && r.label == "Internal resolution" && r.note.empty()); - coreOptionRow("resolution", "x", 0, &d, "2x", false, false, false, &hidden); - CHECK(hidden); // not visible - coreOptionRow("resolution", "x", 0, &d, "2x", true, true, false, &hidden); - CHECK(hidden); // locked by the app - r = coreOptionRow("resolution", "x", 0, &d, "2x", true, false, true, &hidden); - CHECK(!hidden && r.readOnly && !r.note.empty()); // a session override decides what the core reads - r = coreOptionRow("risc_clock", "x", 1, &d, "137%", true, false, false, &hidden); - CHECK(!hidden && r.choices.back() == "137% (custom)" && r.index == 2 && r.choices.size() == 3 && r.group == 1); - coreOptionRow("resolution", "x", 0, nullptr, "", true, false, false, &hidden); - CHECK(hidden); // no definition yet (no game has run) - coreOptionRow("resolution", "x", 0, nullptr, "", true, false, false, nullptr); // the flag is optional - // A label of "" shows the value; a label shows instead of it. - OptionDef e; - e.values = {{"disabled", ""}, {"enabled", "On (fast)"}}; - r = coreOptionRow("idle_skip", "x", 1, &e, "enabled", true, false, false, &hidden); - CHECK(!hidden && r.choices[0] == "disabled" && r.choices[1] == "On (fast)" && r.index == 1); - OptionDef none; - coreOptionRow("idle_skip", "x", 1, &none, "enabled", true, false, false, &hidden); - CHECK(hidden); // nothing to choose from + d.key = key; + d.desc = desc; + d.categoryKey = cat; + d.values = std::move(values); + d.defaultValue = def; + d.info = info; + return d; +} + +// The rows of a few options under one rule set: all visible, none overridden, nothing typed, every option at its default. +struct Rules { + std::map<std::string, std::string> value; // current value by key; absent = the default + std::set<std::string> hidden, overridden; + std::map<std::string, TypedOption> typed; + std::vector<SettingRow> rows(const std::vector<OptionCategory>& cats, const std::vector<OptionDef>& defs) const { + return coreOptionRows( + cats, defs, + [&](const OptionDef& d) { + auto it = value.find(d.key); + return it == value.end() ? d.defaultValue : it->second; + }, + [&](const std::string& k) { return hidden.count(k) == 0; }, [&](const std::string& k) { return overridden.count(k) != 0; }, + [&](const std::string& k) -> const TypedOption* { + auto it = typed.find(k); + return it == typed.end() ? nullptr : &it->second; + }); + } +}; + +// settings_rows_core_option_rules (Phase 5 decision 4): every visible option is a row, by the core's categories. +static void testCoreOptionRows() { + const std::vector<OptionCategory> cats = {{"video", "Video", ""}, {"speed", "Speed", ""}}; + OptionDef a = mkDef("a", "Plain flat title", "", {{"off", ""}, {"on", "Switched on"}}, "off"); + OptionDef b = mkDef("b", "Flat title", "video", {{"1x", "1x (native)"}, {"2x", ""}}, "1x"); + b.descCategorized = "Resolution"; + OptionDef clock = mkDef("clock", "Clock", "speed", {{"100%", "1x"}, {"200%", "2x"}}, "100%"); + OptionDef gone = mkDef("gone", "Hidden by a gate", "video", {{"x", ""}}, "x"); + OptionDef none = mkDef("none", "Nothing to choose from", "video", {}); + Rules r; + r.hidden = {"gone"}; + std::vector<SettingRow> rows = r.rows(cats, {a, b, clock, gone, none}); + CHECK(rows.size() == 3); // `gone` is hidden, `none` has nothing to pick + CHECK(rows.size() == 3 && rows[0].id == "core:a" && rows[1].id == "core:b" && rows[2].id == "core:clock"); + CHECK(rows.size() == 3 && rows[0].group == "core:" && rows[0].groupLabel == "General" && rows[1].group == "core:video" && + rows[1].groupLabel == "Video" && rows[2].group == "core:speed" && rows[2].groupLabel == "Speed"); + CHECK(rows.size() == 3 && rows[0].label == "Plain flat title" && rows[1].label == "Resolution"); // the categorized title wins + CHECK(rows.size() == 3 && rows[0].choices == std::vector<std::string>({"off", "Switched on"}) && rows[0].index == 0); // label, else value + CHECK(rows.size() == 3 && rows[1].choices == std::vector<std::string>({"1x (native)", "2x"}) && rows[1].index == 0 && !rows[1].readOnly && + rows[1].note.empty()); + // The current value picks the index; the groups come out in category order, "General" first whatever the definition order. + r.value["a"] = "on"; + r.value["clock"] = "200%"; + rows = r.rows(cats, {clock, b, a}); + CHECK(rows.size() == 3 && rows[0].id == "core:a" && rows[0].index == 1 && rows[1].id == "core:b" && rows[2].id == "core:clock" && + rows[2].index == 1); + const std::vector<SettingGroup> g = groupsOf(rows); + CHECK(g.size() == 3 && g[0].key == "core:" && g[1].key == "core:video" && g[2].key == "core:speed" && g[2].label == "Speed"); + // A category with nothing visible makes no group; an option naming a category the core never defined lists under General. + r.hidden = {"b"}; + OptionDef stray = mkDef("stray", "Stray", "elsewhere", {{"x", ""}}); + rows = r.rows(cats, {a, b, stray}); + CHECK(rows.size() == 2 && rows[0].id == "core:a" && rows[1].id == "core:stray" && groupsOf(rows).size() == 1); +} + +// A current value outside the core's list is kept (a typed RISC clock), shown as custom and selected. +static void testCoreOptionCustomValue() { + const std::vector<OptionCategory> cats = {{"speed", "Speed", ""}}; + OptionDef clock = mkDef("clock", "RISC overclock", "speed", {{"50%", "0.5x"}, {"100%", "1x (stock)"}}, "100%"); + Rules r; + r.typed["clock"] = TypedOption{"clock", OptKind::Percent, 25, 400, "%", "25-400"}; + r.value["clock"] = "137%"; + std::vector<SettingRow> rows = r.rows(cats, {clock}); + CHECK(rows.size() == 1 && rows[0].choices.size() == 3 && rows[0].choices.back() == "137% (custom)" && rows[0].index == 2); + CHECK(rows.size() == 1 && !rows[0].readOnly); // a typed number is steppable (stepping lands on the presets, the desktop has the box) + r.value["clock"] = "50%"; + rows = r.rows(cats, {clock}); + CHECK(rows.size() == 1 && rows[0].choices.size() == 2 && rows[0].index == 0); // a preset is no custom value + // Presets in numeric order: the custom value sits between its neighbours, so Left / Right reach the nearest presets. + OptionDef wide = mkDef("clock", "RISC overclock", "speed", {{"50%", "0.5x"}, {"100%", "1x"}, {"200%", "2x"}}, "100%"); + r.value["clock"] = "137%"; + rows = r.rows(cats, {wide}); + CHECK(rows.size() == 1 && rows[0].choices == std::vector<std::string>({"0.5x", "1x", "137% (custom)", "2x"}) && rows[0].index == 2); + CHECK(coreCustomPos(wide, "137%", &r.typed["clock"]) == 2 && coreCustomPos(wide, "100%", &r.typed["clock"]) == -1 && + coreCustomPos(wide, "30%", &r.typed["clock"]) == 0 && coreCustomPos(wide, "", &r.typed["clock"]) == -1); + // Presets out of order, or no number at all: the custom entry stays last. + OptionDef jumbled = mkDef("clock", "RISC overclock", "speed", {{"200%", ""}, {"50%", ""}, {"100%", ""}}, "100%"); + CHECK(coreCustomPos(jumbled, "137%", &r.typed["clock"]) == 3 && coreCustomPos(wide, "137%", nullptr) == 3); +} + +// An override (Netplay, --option) pins the value: read-only with the reason. A Host-kind typed key can only be typed. A "restart" +// in the help is a note, not a lock. +static void testCoreOptionNotes() { + const std::vector<OptionCategory> cats = {{"network", "Network", ""}}; + OptionDef mode = mkDef("mode", "Network Link", "network", {{"auto", "Automatic"}, {"off", "Off"}}, "auto"); + OptionDef host = mkDef("host", "Link host", "network", {{"192.168.1.2", ""}, {"10.0.0.2", ""}}, "192.168.1.2"); + OptionDef boot = mkDef("boot", "Boot", "network", {{"hle", ""}, {"real", ""}}, "hle", "Restart required: applied when content is loaded."); + OptionDef quiet = mkDef("quiet", "Quiet", "network", {{"a", ""}, {"b", ""}}, "a", "Takes effect at once."); + Rules r; + r.overridden = {"mode"}; + r.typed["host"] = TypedOption{"host", OptKind::Host, 0, 0, "", "name or IPv4 address"}; + std::vector<SettingRow> rows = r.rows(cats, {mode, host, boot, quiet}); + CHECK(rows.size() == 4); + CHECK(rows.size() == 4 && rows[0].readOnly && !rows[0].note.empty() && rows[0].note.find("Netplay") != std::string::npos); + CHECK(rows.size() == 4 && rows[1].readOnly && rows[1].note == "Type it in Desktop settings"); + CHECK(rows.size() == 4 && !rows[2].readOnly && rows[2].note == "Applies when the game next starts"); + CHECK(rows.size() == 4 && !rows[3].readOnly && rows[3].note.empty()); + // A typed host that is not one of the presets: shown as custom, still read-only. + r.value["host"] = "my.pc.local"; + rows = r.rows(cats, {mode, host}); + CHECK(rows.size() == 2 && rows[1].choices.back() == "my.pc.local (custom)" && rows[1].index == 2 && rows[1].readOnly); +} + +static void testSliderRows() { + SettingRow s = sliderRow("audio_buffer", "Audio buffer", "sound", "Sound", 80, 20, 200, 10, "%.0f ms"); + CHECK(s.choices.size() == 19 && s.index == 6 && s.choices[0] == "20 ms" && s.choices[6] == "80 ms" && s.choices.back() == "200 ms"); + CHECK(s.id == "audio_buffer" && s.label == "Audio buffer" && s.group == "sound" && s.groupLabel == "Sound" && !s.readOnly); + // A value off the grid is inserted between its neighbours and selected: Left and Right step to the nearest grid values. + s = sliderRow("audio_buffer", "Audio buffer", "sound", "Sound", 85, 20, 200, 10, "%.0f ms"); + CHECK(s.choices.size() == 20 && s.choices[6] == "80 ms" && s.choices[7] == "85 (custom)" && s.choices[8] == "90 ms" && s.index == 7); + CHECK(stepValue(s, 8, 20, 10, 85) == 90 && stepValue(s, 6, 20, 10, 85) == 80); + CHECK(stepValue(s, 0, 20, 10, 85) == 20 && stepValue(s, 19, 20, 10, 85) == 200); + CHECK(stepValue(s, 7, 20, 10, 85) == 85); // the "(custom)" entry changes nothing + CHECK(stepValue(s, -1, 20, 10, 85) == 85 && stepValue(s, 20, 20, 10, 85) == 85); // out of range too + // The app's own default (64 ms) is off the grid: it sits between 60 and 70, and Left is 60, Right is 70. + s = sliderRow("audio_buffer", "Audio buffer", "sound", "Sound", 64, 20, 200, 10, "%.0f ms"); + CHECK(s.choices.size() == 20 && s.index == 5 && s.choices[4] == "60 ms" && s.choices[5] == "64 (custom)" && s.choices[6] == "70 ms"); + CHECK(stepValue(s, stepIndex(s, -1, false), 20, 10, 64) == 60 && stepValue(s, stepIndex(s, +1, false), 20, 10, 64) == 70); + // On the grid at either end; a value beyond the ends is custom, last (or first), not clamped. + CHECK(sliderRow("x", "x", "g", "G", 20, 20, 200, 10, "%.0f").index == 0 && sliderRow("x", "x", "g", "G", 200, 20, 200, 10, "%.0f").index == 18); + s = sliderRow("x", "x", "g", "G", 230, 20, 200, 10, "%.0f"); + CHECK(s.choices.size() == 20 && s.choices.back() == "230 (custom)" && s.index == 19); + CHECK(stepValue(s, 18, 20, 10, 230) == 200 && stepValue(s, 19, 20, 10, 230) == 230); + s = sliderRow("x", "x", "g", "G", 5, 20, 200, 10, "%.0f"); + CHECK(s.choices.size() == 20 && s.choices[0] == "5 (custom)" && s.index == 0 && stepValue(s, 1, 20, 10, 5) == 20); + CHECK(stepValue(sliderRow("x", "x", "g", "G", 80, 20, 200, 10, "%.0f"), 18, 20, 10, 80) == 200); // no custom entry: the last index is a grid one + // Fractions: the tile size (0.5 to 2.5 by 0.1) and the Neuro pause (0 to 10 by 0.5). + s = sliderRow("tile_size", "Tile size", "library", "Library", 1.0f, 0.5f, 2.5f, 0.1f, "%.1fx"); + CHECK(s.choices.size() == 21 && s.choices[0] == "0.5x" && s.choices[5] == "1.0x" && s.index == 5 && s.choices.back() == "2.5x"); + CHECK(sliderRow("tile_size", "x", "g", "G", 1.3f, 0.5f, 2.5f, 0.1f, "%.1fx").index == 8); // 1.3f is not exactly 0.5 + 8 * 0.1: still on the grid + s = sliderRow("tile_size", "x", "g", "G", 1.25f, 0.5f, 2.5f, 0.1f, "%.1fx"); + CHECK(s.choices.size() == 22 && s.choices[8] == "1.25 (custom)" && s.index == 8); + CHECK(std::fabs(stepValue(s, 9, 0.5f, 0.1f, 1.25f) - 1.3f) < 1e-4f && std::fabs(stepValue(s, 7, 0.5f, 0.1f, 1.25f) - 1.2f) < 1e-4f); + s = sliderRow("neuro_interval", "Pause", "neuro", "Neuro", 1.0f, 0, 10, 0.5f, "%.1f s"); + CHECK(s.choices.size() == 21 && s.choices[2] == "1.0 s" && s.index == 2 && s.choices[3] == "1.5 s"); + // Stepping uses the same arithmetic as every row. + s = sliderRow("audio_buffer", "x", "g", "G", 20, 20, 200, 10, "%.0f"); + CHECK(stepIndex(s, -1, false) == 0 && stepIndex(s, -1, true) == 18 && stepIndex(s, 1, false) == 1); +} + +static void testChoiceRowAndGroups() { + SettingRow c = choiceRow("resume", "Resume where I left off", "library", "Library", {"Off", "Ask", "Always"}, 1); + CHECK(c.id == "resume" && c.label == "Resume where I left off" && c.group == "library" && c.groupLabel == "Library" && + c.choices.size() == 3 && c.index == 1 && !c.readOnly && c.note.empty()); + // groupsOf: first-seen order, each key once, the label of the first row that names it. + std::vector<SettingRow> rows = {choiceRow("a", "A", "interface", "Interface", {"x"}, 0), choiceRow("b", "B", "picture", "Picture", {"x"}, 0), + choiceRow("c", "C", "interface", "Interface", {"x"}, 0), choiceRow("d", "D", "core:video", "Video", {"x"}, 0), + choiceRow("e", "E", "picture", "Picture", {"x"}, 0)}; + const std::vector<SettingGroup> g = groupsOf(rows); + CHECK(g.size() == 3 && g[0].key == "interface" && g[0].label == "Interface" && g[1].key == "picture" && g[2].key == "core:video" && + g[2].label == "Video"); + CHECK(groupsOf({}).empty()); } static void testAppRows() { - CHECK(volumeRow(0.75f).index == 8 && volumeRow(0.75f).choices.size() == 11 && volumeRow(1.0f).choices[10] == "100 %"); + CHECK(volumeRow(0.7f).index == 7 && volumeRow(0.7f).choices.size() == 11 && volumeRow(1.0f).choices[10] == "100 %"); CHECK(volumeRow(0.0f).index == 0 && volumeRow(0.0f).choices[0] == "0 %" && volumeRow(1.0f).index == 10); - CHECK(volumeRow(0.33f).index == 3 && volumeRow(7.0f).index == 10 && volumeRow(-1.0f).index == 0); // clamped - CHECK(volumeRow(0.5f).group == 2 && volumeRow(0.5f).id == "volume"); - CHECK(onOffRow("fps", "Show FPS", 3, true).index == 1 && - (onOffRow("fps", "x", 3, false).choices == std::vector<std::string>{"Off", "On"})); - CHECK(onOffRow("fps", "Show FPS", 3, true).label == "Show FPS" && onOffRow("fps", "x", 3, true).group == 3); + // The desktop slider is finer than the list: a volume between two steps is shown as it is, selected, and kept until stepped. + CHECK(volumeRow(0.75f).index == 8 && volumeRow(0.75f).choices.size() == 12 && volumeRow(0.75f).choices[8] == "75 (custom)"); + CHECK(volumeRow(7.0f).index == 10 && volumeRow(-1.0f).index == 0); // out of range: the ends + CHECK(volumeRow(0.5f).group == "sound" && volumeRow(0.5f).groupLabel == "Sound" && volumeRow(0.5f).id == "volume"); + CHECK(stepValue(volumeRow(0.75f), 9, 0.0f, 10.0f, 75.0f) == 80.0f && stepValue(volumeRow(0.75f), 7, 0.0f, 10.0f, 75.0f) == 70.0f && + stepValue(volumeRow(0.75f), 8, 0.0f, 10.0f, 75.0f) == 75.0f); // Left 70, Right 80, the custom entry itself changes nothing + CHECK(stepIndex(volumeRow(0.75f), -1, false) == 7 && stepIndex(volumeRow(0.75f), +1, false) == 9); + CHECK(onOffRow("fps", "Show FPS", "interface", "Interface", true).index == 1 && + (onOffRow("fps", "x", "interface", "Interface", false).choices == std::vector<std::string>{"Off", "On"})); + CHECK(onOffRow("fps", "Show FPS", "interface", "Interface", true).label == "Show FPS" && + onOffRow("fps", "x", "interface", "Interface", true).group == "interface" && + onOffRow("fps", "x", "interface", "Interface", true).groupLabel == "Interface"); CHECK(tvModeRow(TvSetting::Auto).index == 2 && tvModeRow(TvSetting::Off).choices[1] == "Manual"); - CHECK(tvModeRow(TvSetting::Manual).index == 1 && tvModeRow(TvSetting::Off).id == "tv_mode" && tvModeRow(TvSetting::Off).group == 3); + CHECK(tvModeRow(TvSetting::Manual).index == 1 && tvModeRow(TvSetting::Off).id == "tv_mode" && tvModeRow(TvSetting::Off).group == "interface"); + // The two Auto checkboxes exist only under Auto (#991 phase 4): under Off / Manual they have no effect, so they are not offered. + CHECK(tvAutoRows(TvSetting::Manual, true, true).empty() && tvAutoRows(TvSetting::Off, true, true).empty()); + const std::vector<SettingRow> a = tvAutoRows(TvSetting::Auto, true, false); + CHECK((a.size() == 2 && a[0].id == "tv_auto_fs" && a[0].index == 1 && a[1].id == "tv_auto_pad" && a[1].index == 0 && a[0].group == "interface" && + a[1].choices == std::vector<std::string>{"Off", "On"})); // (the braces' comma needs the extra parentheses) + CHECK(a.size() == 2 && a[0].label == "Auto: in fullscreen" && a[1].label == "Auto: when a controller is used" && a[1].group == "interface" && a[1].groupLabel == "Interface"); const SettingRow more = moreRow(); - CHECK(more.id == "more" && more.choices.empty() && more.group == 3 && !more.readOnly); - const SettingRow note = speedNoteRow(); // Speed before a game has run: a line, not a control - CHECK(note.id == "speed_note" && note.label == "Available once a game has run" && note.group == 1 && note.readOnly && note.choices.empty()); - CHECK(stepIndex(note, +1, true) == 0); + CHECK(more.id == "more" && more.label == "Desktop settings\xE2\x80\xA6" && more.choices.empty() && more.group == "interface" && !more.readOnly); } static void testAspect() { SettingRow a = aspectRow(0.0f, kAspectChoices, kAspectChoiceCount); - CHECK(a.id == "aspect" && a.group == 0 && a.index == 0 && a.choices.size() == 4 && a.choices[0] == "Core default"); + CHECK(a.id == "aspect" && a.group == "picture" && a.groupLabel == "Picture" && a.index == 0 && a.choices.size() == 4 && a.choices[0] == "Core default"); CHECK(aspectRow(16.0f / 9.0f, kAspectChoices, kAspectChoiceCount).index == 2); CHECK(aspectRow(4.0f / 3.0f + 1e-4f, kAspectChoices, kAspectChoiceCount).index == 1); // as the desktop combo matches CHECK(aspectRow(2.39f, kAspectChoices, kAspectChoiceCount).index == 0); // none of them: the first @@ -94,12 +243,12 @@ static void testTheme() { // A custom theme whose file is gone is still the one in force: it is listed and selected, and picking it does nothing. t = themeRow("custom:Gone", names); CHECK(t.choices.size() == 6 && t.choices.back() == "Custom: Gone" && t.index == 5 && themeKeyAt(5, names).empty()); - CHECK(t.id == "theme" && t.group == 3); + CHECK(t.id == "theme" && t.group == "interface"); } static void testShaderAndStep() { SettingRow s = shaderRow("/p/crt-royale.slangp", true, {"/p/crt-royale.slangp", "/u/x/lcd.slangp"}); - CHECK(s.index == 1 && s.choices[0] == "Off" && s.choices[2] == "lcd" && s.choices[1] == "crt-royale" && s.id == "shader" && s.group == 0); + CHECK(s.index == 1 && s.choices[0] == "Off" && s.choices[2] == "lcd" && s.choices[1] == "crt-royale" && s.id == "shader" && s.group == "picture"); CHECK(shaderRow("/p/crt-royale.slangp", false, {"/p/crt-royale.slangp"}).index == 0); CHECK(shaderRow("", true, {"/p/a.slangp"}).index == 0); // enabled with no preset: nothing to show but Off // A preset outside both lists (a file picked in the desktop window) is listed, marked, and selected. @@ -113,19 +262,16 @@ static void testShaderAndStep() { CHECK(stepIndex(moreRow(), +1, true) == 0 && stepIndex(moreRow(), -1, false) == 0); // no choices: nothing moves } -static void testGroupLabels() { - CHECK(std::string(settingGroupLabel(0)) == "Picture" && std::string(settingGroupLabel(1)) == "Speed" && - std::string(settingGroupLabel(2)) == "Sound" && std::string(settingGroupLabel(3)) == "Interface"); - CHECK(std::string(settingGroupLabel(4)).empty() && std::string(settingGroupLabel(-1)).empty() && kSettingGroups == 4); -} - int main() { - testCoreOptionRules(); + testCoreOptionRows(); + testCoreOptionCustomValue(); + testCoreOptionNotes(); + testSliderRows(); + testChoiceRowAndGroups(); testAppRows(); testAspect(); testTheme(); testShaderAndStep(); - testGroupLabels(); std::printf("tv_settings_model_test: %d checks, %d failed\n", g_checks, g_fail); return g_fail ? 1 : 0; } diff --git a/app/tests/tv_ui_smoke.cpp b/app/tests/tv_ui_smoke.cpp index abac75d8..c8d2a4b2 100644 --- a/app/tests/tv_ui_smoke.cpp +++ b/app/tests/tv_ui_smoke.cpp @@ -6,6 +6,7 @@ #include "desktop_nav_popup.h" #include "tv/tv_screen.h" #include "tv/tv_shell.h" +#include "tv/tv_settings_model.h" #include "tv/tv_widgets.h" #include "imgui.h" @@ -13,6 +14,8 @@ #include <SDL3/SDL.h> +#include <algorithm> +#include <cmath> #include <cstdio> #include <cstdlib> #include <ctime> @@ -37,6 +40,7 @@ static int g_fail = 0, g_checks = 0, g_imguiErrors = 0; // What the menu asked of the app, and the app state it reads. struct FakeHost : Host { bool loaded = true, paused = false, canLoad = true, netplay = false; + bool runsOn = false; // menuPause() opens the menu over a running game (App: hardcore would not pause it) std::string refusePause; // non-empty: menuPause() refuses with this reason (hardcore rationing) bool pausedBeforeMenu = false, heldPause = false; double clock = 0; @@ -64,6 +68,7 @@ struct FakeHost : Host { return false; } if (netplay) return true; // the link keeps running + if (runsOn) return true; // hardcore: the game runs on under the menu pausedBeforeMenu = paused; paused = true; heldPause = true; @@ -75,6 +80,7 @@ struct FakeHost : Host { heldPause = false; } bool netplayActive() const override { return netplay; } + bool menuRunsOn() const override { return runsOn; } bool canLoadState() const override { return canLoad; } std::vector<SlotInfo> slots() override { std::vector<SlotInfo> v; @@ -99,8 +105,11 @@ struct FakeHost : Host { return intentForButton(b); } bool keyboardLast = false; // the keyboard was used last: it has no key for Menu / Alt / Aux (as AppHost::glyph) + std::string pageLt, pageRt; // the glyphs of PagePrev / PageNext ("" = the device has none, the default here) std::string glyph(Intent i) const override { switch (i) { + case Intent::PagePrev: return pageLt; + case Intent::PageNext: return pageRt; case Intent::Menu: return keyboardLast ? "" : "Start"; case Intent::Alt: return keyboardLast ? "" : "X"; case Intent::Aux: return keyboardLast ? "" : "Y"; @@ -130,14 +139,16 @@ struct FakeHost : Host { } return {}; } + bool hasGames() const override { return !all.empty(); } Art art(const GameRef&) override { return {}; } std::vector<StateRef> states(const GameRef& g) override { auto it = st.find(g.key); return it == st.end() ? std::vector<StateRef>() : it->second; } Art stateThumb(const GameRef&, const StateRef&) override { return {}; } - void launch(const GameRef& g) override { launchedKey = g.key; launches++; } - void launchState(const GameRef& g, const StateRef& state) override { launchedState = {g.key, state.slot}; launchedDisc = state.disc; } + // (A launch with a game running closes it, as the app's does: loaded is false after.) + void launch(const GameRef& g) override { launchedKey = g.key; launches++; loaded = false; } + void launchState(const GameRef& g, const StateRef& state) override { launchedState = {g.key, state.slot}; launchedDisc = state.disc; loaded = false; } bool isFavorite(const GameRef& g) override { return favSet.count(g.key) != 0; } void setFavorite(const GameRef& g, bool on) override { if (on) favSet.insert(g.key); @@ -275,7 +286,7 @@ static void menuOpensAndWalks() { CHECK(shell.menuPage() == Shell::Page::LoadSlots); frames(host, shell, 10); back(host, shell); - down(host, shell, 6); // Achievements, Overlays, Settings, Controls, Enter TV mode, Reset + down(host, shell, 7); // Library, Achievements, Overlays, Settings, Controls, Enter TV mode, Reset accept(host, shell); CHECK(shell.menuPage() == Shell::Page::ConfirmReset); frames(host, shell, 10); @@ -327,7 +338,7 @@ static void closeGameAsksFirst() { FakeHost host; Shell shell(host); shell.openMenu(); - down(host, shell, 9); + down(host, shell, 10); CHECK(std::string(focusedLabel(shell)) == "Close game"); accept(host, shell); // -> the confirm, focus on No CHECK(shell.menuPage() == Shell::Page::ConfirmClose && std::string(focusedLabel(shell)) == "No"); @@ -357,6 +368,48 @@ static void netplayBanner() { CHECK(!drew("Netplay: the game keeps running")); } +// Hardcore refusing the pause (#991): the menu still opens, the game keeps running, and the banner says so. +static void hardcoreBanner() { + FakeHost host; + host.runsOn = true; + Shell shell(host); + CHECK(shell.openMenu() && shell.menuOpen() && !host.paused); + g_texts.clear(); + frames(host, shell, 3); + CHECK(drew("Hardcore: the game keeps running")); + CHECK(!drew("Netplay: the game keeps running")); + CHECK(g_imguiErrors == 0); +} + +// Shell::openSettings (#991): TV off -> refused; no game -> the Settings section; a game -> the menu with Settings in the slot, +// from the root and from a page, and a second call changes nothing. +static void openSettingsRoutes() { + FakeHost host; + host.tv = false; + host.loaded = false; + Shell shell(host); + CHECK(!shell.openSettings() && !shell.menuOpen()); + host.tv = true; + CHECK(shell.openSettings() && shell.section() == Section::Settings && !shell.menuOpen()); + frames(host, shell, 3); + host.loaded = true; + Shell inGame(host); + CHECK(inGame.openSettings() && inGame.menuOpen() && inGame.menuScreenId() == "settings"); + frames(host, inGame, 3); + CHECK(inGame.openSettings() && inGame.menuScreenId() == "settings"); + back(host, inGame); + CHECK(inGame.menuOpen() && !inGame.menuHasScreen() && std::string(focusedLabel(inGame)) == "Settings"); + Shell paged(host); + paged.openMenu(); + down(host, paged, 1); // Save state + accept(host, paged); + CHECK(paged.menuPage() == Shell::Page::SaveSlots); + CHECK(paged.openSettings() && paged.menuScreenId() == "settings"); + back(host, paged); + CHECK(paged.menuPage() == Shell::Page::Root && std::string(focusedLabel(paged)) == "Settings"); + CHECK(g_imguiErrors == 0); +} + static void hintBarShowsWhatWorks() { FakeHost host; Shell shell(host); @@ -411,7 +464,7 @@ static void controlsRowShowsNeverHides() { Shell shell(host); shell.openMenu(); frames(host, shell, 2); - down(host, shell, 6); // Resume, Save state, Load state, Achievements, Overlays, Settings, Controls + down(host, shell, 7); // Resume, Save state, Load state, Library, Achievements, Overlays, Settings, Controls CHECK(std::string(focusedLabel(shell)) == "Controls"); accept(host, shell); CHECK(host.controls == 1 && host.controlsShown && !shell.menuOpen()); @@ -1463,6 +1516,28 @@ static void homeEmptyLibraryCard() { CHECK(host.launches == 0 && host.launchedState.first.empty()); } +// Finding B: games in the library but nothing on Home's shelves is not "empty": it points at the Library section (RB). +static void homeWithGamesButNoShelvesPointsAtLibrary() { + const char* empty = "Your library is empty. Add game folders in desktop mode: View > TV mode turns TV mode off."; + FakeHost host; + host.all = mkGames("Game", 20); // 20 cartridges, nothing played, no favorites, no discs + Shell shell(host); + g_texts.clear(); + homeSetup(host, shell); + frames(host, shell, 2); + CHECK(drew("Nothing to continue yet. Press RB for your Library.") && !drew(empty)); + CHECK(!drew("Play") && !drew("Continue playing")); + host.scanning = true; // a rescan changes nothing for a library that has games + g_texts.clear(); + frames(host, shell, 2); + CHECK(drew("Nothing to continue yet. Press RB for your Library.") && !drew("Scanning your library...")); + host.all.clear(); // and with none, the old wording is back (homeEmptyLibraryCard) + host.scanning = false; + g_texts.clear(); + frames(host, shell, 2); + CHECK(drew(empty) && !drew("Nothing to continue yet. Press RB for your Library.")); +} + static void formatDateIsEnglishDayMonthYear() { #ifndef _WIN32 setenv("TZ", "UTC", 1); @@ -2065,9 +2140,11 @@ static void ingameTvToggleHiddenWhenOff() { shell.openMenu(); g_texts.clear(); frames(host, shell, 3); - CHECK(drew("Enter TV mode") && !drew("Exit TV mode")); - down(host, shell, 7); // ... Settings, Controls, Enter TV mode + down(host, shell, 8); // ... Settings, Controls, Enter TV mode (past the rows the list shows at first: it scrolls) CHECK(std::string(focusedLabel(shell)) == "Enter TV mode"); + g_texts.clear(); + frames(host, shell, 3); + CHECK(drew("Enter TV mode") && !drew("Exit TV mode")); accept(host, shell); CHECK(host.tv && shell.menuOpen() && shell.menuPage() == Shell::Page::Root); CHECK(std::string(focusedLabel(shell)) == "Exit TV mode"); // the focus stayed on the row, its label flipped @@ -2341,24 +2418,28 @@ static void desktopWindowTakesInputFromBase() { // ---- TV Settings (phase 3, Task 12) ----------------------------------------------------------------------------------- -static SettingRow mkRow(const char* id, const char* label, int group, std::vector<std::string> choices, int index = 0) { +static SettingRow mkRow(const char* id, const char* label, const char* group, const char* groupLabel, std::vector<std::string> choices, int index = 0) { SettingRow r; r.id = id; r.label = label; r.group = group; + r.groupLabel = groupLabel; r.choices = std::move(choices); r.index = index; return r; } -// Picture: Aspect ratio; Sound: Volume; Interface: Show FPS, then More settings -- no Speed row, so no Speed group. +// The rail: Picture (Aspect ratio), Sound (Volume), Interface (Show FPS, then Desktop settings), then two of the core's categories, +// Video (Internal resolution) and Speed (RISC overclock) -- the order the host lists them in (Phase 5: the app's tabs, then the core's). static std::vector<SettingRow> settingsFixture() { std::vector<std::string> pct; for (int i = 0; i <= 10; i++) pct.push_back(std::to_string(i * 10) + " %"); - return {mkRow("aspect", "Aspect ratio", 0, {"Core default", "4:3", "16:9", "8:7 (square pixels)"}), - mkRow("volume", "Volume", 2, pct, 5), - mkRow("fps", "Show FPS", 3, {"Off", "On"}), - mkRow("more", "More settings\xE2\x80\xA6", 3, {})}; + return {mkRow("aspect", "Aspect ratio", "picture", "Picture", {"Core default", "4:3", "16:9", "8:7 (square pixels)"}), + mkRow("volume", "Volume", "sound", "Sound", pct, 5), + mkRow("fps", "Show FPS", "interface", "Interface", {"Off", "On"}), + mkRow("more", "More settings\xE2\x80\xA6", "interface", "Interface", {}), + mkRow("core:virtualjaguar_internal_resolution", "Internal resolution", "core:video", "Video", {"1x (native)", "2x"}), + mkRow("core:virtualjaguar_risc_clock_scale", "RISC overclock", "core:speed", "Speed", {"0.5x", "1x (stock)", "2x"}, 1)}; } // A TV host on the Settings section (reached through the shell, not by counting RB presses), drawn once. @@ -2391,8 +2472,8 @@ static void settingsRailAndList() { g_texts.clear(); frames(host, shell, 2); CHECK(shell.screenId() == "settings"); - CHECK(drew("Picture") && drew("Sound") && drew("Interface") && !drew("Speed")); - CHECK(drew("Aspect ratio") && !drew("Show FPS")); // the list is the focused group's: Picture + CHECK(drew("Picture") && drew("Sound") && drew("Interface") && drew("Video") && drew("Speed")); // the rail: the app's tabs, then the core's categories + CHECK(drew("Aspect ratio") && !drew("Show FPS") && !drew("Internal resolution")); // the list is the focused group's: Picture down(host, shell, 2); // Picture, Sound, Interface g_texts.clear(); frames(host, shell, 2); @@ -2454,7 +2535,7 @@ static void settingsHints() { // A read-only row changes nothing, so the bar does not offer it. FakeHost ro; Shell roShell(ro); - SettingRow r = mkRow("fps", "Show FPS", 3, {"Off", "On"}); + SettingRow r = mkRow("fps", "Show FPS", "interface", "Interface", {"Off", "On"}); r.readOnly = true; r.note = "Set for this session"; settingsSetup(ro, roShell, {r}); @@ -2469,7 +2550,7 @@ static void settingsHints() { static void settingsTvModeOffLeavesCleanly() { FakeHost host; Shell shell(host); - settingsSetup(host, shell, {mkRow("tv_mode", "TV mode", 3, {"Off", "Manual", "Auto"}, 1)}); + settingsSetup(host, shell, {mkRow("tv_mode", "TV mode", "interface", "Interface", {"Off", "Manual", "Auto"}, 1)}); CHECK(shell.ownsInput() && baseWindow() && baseWindow()->WasActive); rightN(host, shell, 1); // the list (only Interface exists) accept(host, shell); // Manual -> Auto @@ -2493,10 +2574,32 @@ static void settingsTvModeOffLeavesCleanly() { CHECK(g_imguiErrors == 0); } +// The Auto rows (#991 phase 4): two On / Off rows after the TV mode row, steppable like any other (Right moves without wrapping, +// Accept wraps over the two choices), each change handed to the host as (id, index). +static void settingsAutoRowsStep() { + FakeHost host; + Shell shell(host); + settingsSetup(host, shell, + {mkRow("tv_mode", "TV mode", "interface", "Interface", {"Off", "Manual", "Auto"}, 2), mkRow("tv_auto_fs", "Auto: in fullscreen", "interface", "Interface", {"Off", "On"}, 0), + mkRow("tv_auto_pad", "Auto: when a controller is used", "interface", "Interface", {"Off", "On"}, 1)}); + CHECK(shell.focusLabel() == "Interface"); // on the rail: the group (the headless script's `expect tv-focus=`) + rightN(host, shell, 1); // the list (only Interface exists) + CHECK(shell.focusLabel() == "TV mode"); + down(host, shell, 1); // Auto: in fullscreen + CHECK(shell.focusLabel() == "Auto: in fullscreen"); + rightN(host, shell, 1); + CHECK(lastSet(host, "tv_auto_fs", 1)); + accept(host, shell); + CHECK(lastSet(host, "tv_auto_fs", 0)); // Accept wraps over the two choices + g_texts.clear(); + frames(host, shell, 2); + CHECK(drew("Auto: in fullscreen") && drew("Auto: when a controller is used")); +} + static void settingsReadonlyRowIgnoresInput() { FakeHost host; Shell shell(host); - SettingRow r = mkRow("resolution", "Internal resolution", 0, {"1x", "2x"}, 1); + SettingRow r = mkRow("resolution", "Internal resolution", "picture", "Picture", {"1x", "2x"}, 1); r.readOnly = true; r.note = "Set for this session by Netplay"; settingsSetup(host, shell, {r}); @@ -2561,7 +2664,7 @@ static void settingsHiddenRowsAbsent() { g_texts.clear(); frames(host, shell, 2); CHECK(!drew("Shader")); - SettingRow shader = mkRow("shader", "Shader", 0, {"Off", "crt-royale", "lcd"}, 1); + SettingRow shader = mkRow("shader", "Shader", "picture", "Picture", {"Off", "crt-royale", "lcd"}, 1); host.rows.insert(host.rows.begin(), shader); g_texts.clear(); frames(host, shell, 2); @@ -2598,40 +2701,45 @@ static void baseHintsHiddenOverDesktopWindow() { CHECK(g_imguiErrors == 0); } -// Speed before a game has run (approved change G): the group is in the rail with its one line, which nothing steps; on the Base screen -// and in the menu's groups level. -static std::vector<SettingRow> speedNoteRows() { - SettingRow note = mkRow("speed_note", "Available once a game has run", 1, {}); - note.readOnly = true; - std::vector<SettingRow> rows = settingsFixture(); - rows.insert(rows.begin() + 1, note); // after the Picture row, before Sound - return rows; -} - -static void settingsSpeedNoteBeforeAGame() { +// The core's categories are rail items after the app's (Phase 5 decision 3): their rows are the core options, each a row whose id +// carries the option's key, stepped like any other; groups come and go with the rows, and the focus is remembered by the group's key. +static void settingsCoreGroups() { FakeHost host; Shell shell(host); - settingsSetup(host, shell, speedNoteRows()); + settingsSetup(host, shell); + down(host, shell, 3); // Picture, Sound, Interface, Video + CHECK(shell.focusLabel() == "Video"); g_texts.clear(); frames(host, shell, 2); - CHECK(drew("Speed")); // the rail item + CHECK(drew("Internal resolution") && drew("1x (native)") && !drew("RISC overclock") && !drew("Aspect ratio")); + rightN(host, shell, 1); // into the list + CHECK(shell.focusLabel() == "Internal resolution"); + rightN(host, shell, 1); + CHECK(lastSet(host, "core:virtualjaguar_internal_resolution", 1)); + leftN(host, shell, 1); + CHECK(lastSet(host, "core:virtualjaguar_internal_resolution", 0)); + leftN(host, shell, 1); // the left edge of the row: the rail down(host, shell); // Speed + CHECK(shell.focusLabel() == "Speed"); g_texts.clear(); frames(host, shell, 2); - CHECK(drew("Available once a game has run") && !drew("Aspect ratio")); - rightN(host, shell, 1); // into the list (it is the only row) ... - accept(host, shell); - rightN(host, shell, 2); - CHECK(host.set.empty() && host.moreOpened == 0); // ... where nothing steps or opens + CHECK(drew("RISC overclock") && drew("1x (stock)") && !drew("Internal resolution")); + down(host, shell); // the last group: the rail clamps + CHECK(shell.focusLabel() == "Speed"); + // The group the focus was on follows its key when an earlier group goes away: Speed stays Speed. + host.rows.erase(host.rows.begin() + 4); // Internal resolution: Video is empty + g_texts.clear(); + frames(host, shell, 2); + CHECK(shell.focusLabel() == "Speed" && !drew("Video")); CHECK(g_imguiErrors == 0); } static void settingsFocusRemembered() { FakeHost host; Shell shell(host); - settingsSetup(host, shell, {mkRow("aspect", "Aspect ratio", 0, {"Core default", "4:3"}), - mkRow("fps", "Show FPS", 3, {"Off", "On"}), mkRow("vsync", "VSync", 3, {"Off", "On"}), - mkRow("theme", "Theme", 3, {"System", "Dark", "Light"}), mkRow("more", "More settings", 3, {})}); + settingsSetup(host, shell, {mkRow("aspect", "Aspect ratio", "picture", "Picture", {"Core default", "4:3"}), + mkRow("fps", "Show FPS", "interface", "Interface", {"Off", "On"}), mkRow("vsync", "VSync", "interface", "Interface", {"Off", "On"}), + mkRow("theme", "Theme", "interface", "Interface", {"System", "Dark", "Light"}), mkRow("more", "More settings", "interface", "Interface", {})}); down(host, shell); // Interface rightN(host, shell, 1); down(host, shell, 2); // Theme @@ -2644,7 +2752,7 @@ static void settingsFocusRemembered() { accept(host, shell); // lands on the same row, in its list CHECK(lastSet(host, "theme", 1) && host.set.size() == 1); // The row is followed by what it is, not by where it was: a row inserted above it does not move the focus. - host.rows.insert(host.rows.begin() + 1, mkRow("shader2", "Extra", 3, {"Off", "On"})); + host.rows.insert(host.rows.begin() + 1, mkRow("shader2", "Extra", "interface", "Interface", {"Off", "On"})); frames(host, shell, 2); accept(host, shell); CHECK(lastSet(host, "theme", 2)); @@ -2653,8 +2761,8 @@ static void settingsFocusRemembered() { static void settingsMouse() { FakeHost host; Shell shell(host); - settingsSetup(host, shell, {mkRow("fps", "Show FPS", 3, {"Off", "On"}), mkRow("volume", "Volume", 2, {"0 %", "50 %", "100 %"}, 1), - mkRow("vsync", "VSync", 3, {"Off", "On"}, 1)}); + settingsSetup(host, shell, {mkRow("volume", "Volume", "sound", "Sound", {"0 %", "50 %", "100 %"}, 1), mkRow("fps", "Show FPS", "interface", "Interface", {"Off", "On"}), + mkRow("vsync", "VSync", "interface", "Interface", {"Off", "On"}, 1)}); ImGuiIO& io = ImGui::GetIO(); // Interface is the second group: the rail item hovers into view. 1920x1080: the rail at x 96, 54 high, 62 apart; the // list from x 504 to 1824, rows 72 high, 86 apart from y 196. @@ -2695,7 +2803,7 @@ static void settingsMouse() { static void settingsClickOnChevrons() { FakeHost host; Shell shell(host); - settingsSetup(host, shell, {mkRow("volume", "Volume", 2, {"0 %", "50 %", "100 %"}, 1)}); + settingsSetup(host, shell, {mkRow("volume", "Volume", "sound", "Sound", {"0 %", "50 %", "100 %"}, 1)}); ImGuiIO& io = ImGui::GetIO(); io.AddMousePosEvent(504 + 200, 196 + 36); // focus the row and let it finish growing frames(host, shell, 30); @@ -2727,14 +2835,14 @@ static void settingsWalk120FramesNoAsserts() { std::vector<std::string> pct; for (int i = 0; i <= 10; i++) pct.push_back(std::to_string(i * 10) + " %"); host.rows = settingsFixture(); - SettingRow ro = mkRow("resolution", "Internal resolution with a rather long label", 0, {"1x (native)", "2x"}, 1); + SettingRow ro = mkRow("resolution", "Internal resolution with a rather long label", "picture", "Picture", {"1x (native)", "2x"}, 1); ro.readOnly = true; ro.note = "Set for this session by the Netplay window (or --option); disconnect to change it here"; host.rows.push_back(ro); - host.rows.push_back(mkRow("risc_clock", "RISC overclock", 1, {"0.5x", "1x (stock)", "137% (custom)"}, 2)); - host.rows.push_back(mkRow("idle_skip", "Idle-loop skip", 1, {"disabled", "enabled"}, 1)); - for (int i = 0; i < 12; i++) host.rows.push_back(mkRow("extra", "An extra setting", 3, {"Off", "On"})); - host.rows.push_back(mkRow("theme", "Theme", 3, {"System", "Dark", "Light", "Custom: A very long theme name that does not fit"}, 3)); + host.rows.push_back(mkRow("risc_clock", "RISC overclock", "core:speed", "Speed", {"0.5x", "1x (stock)", "137% (custom)"}, 2)); + host.rows.push_back(mkRow("idle_skip", "Idle-loop skip", "core:speed", "Speed", {"disabled", "enabled"}, 1)); + for (int i = 0; i < 12; i++) host.rows.push_back(mkRow("extra", "An extra setting", "interface", "Interface", {"Off", "On"})); + host.rows.push_back(mkRow("theme", "Theme", "interface", "Interface", {"System", "Dark", "Light", "Custom: A very long theme name that does not fit"}, 3)); Shell shell(host); const int errors = g_imguiErrors; shell.setSection(Section::Settings); @@ -2763,6 +2871,282 @@ static void settingsWalk120FramesNoAsserts() { CHECK(g_imguiErrors == errors); } +// ---- TV Settings for long grouped lists (phase 5, Task 20) --------------------------------------------------------------------- + +static std::vector<widgets::TextAt> g_at; // where the widgets drew each string (the position probe, switched on by the Task 20 tests) + +static bool placeOf(const std::string& s, widgets::TextAt& out) { // the last place `s` was drawn + bool found = false; + for (const widgets::TextAt& t : g_at) + if (t.s == s) { + out = t; + found = true; + } + return found; +} + +// A drawn string lies wholly inside [top, bottom] (pixels). +static bool within(const widgets::TextAt& t, float top, float bottom) { return t.pos.y >= top - 0.5f && t.pos.y + t.px <= bottom + 0.5f; } + +static void pressButton(FakeHost& host, Shell& shell, SDL_GamepadButton b, bool isDown) { + SDL_Event e; + SDL_zero(e); + e.type = isDown ? SDL_EVENT_GAMEPAD_BUTTON_DOWN : SDL_EVENT_GAMEPAD_BUTTON_UP; + e.gbutton.button = (Uint8)b; + e.gbutton.down = isDown; + shell.handleEvent(e, g_ms); +} + +// n groups "g0".."g<n-1>" labelled "Group 0".., each with one row "Setting 0".. +static std::vector<SettingRow> manyGroups(int n) { + std::vector<SettingRow> v; + for (int i = 0; i < n; i++) + v.push_back(mkRow(("s" + std::to_string(i)).c_str(), ("Setting " + std::to_string(i)).c_str(), ("g" + std::to_string(i)).c_str(), + ("Group " + std::to_string(i)).c_str(), {"Off", "On"})); + return v; +} + +// One group "core:x" ("X") of n rows "Row 0".., each Off / On. +static std::vector<SettingRow> manyRows(int n) { + std::vector<SettingRow> v; + for (int i = 0; i < n; i++) v.push_back(mkRow(("r" + std::to_string(i)).c_str(), ("Row " + std::to_string(i)).c_str(), "core:x", "X", {"Off", "On"})); + return v; +} + +// How many strings starting with `prefix` (and not a caption "... \xC2\xB7 N settings") the last frames drew. +static int drewCount(const char* prefix) { + int n = 0; + for (const widgets::TextAt& t : g_at) + if (t.s.rfind(prefix, 0) == 0 && t.s.find("\xC2\xB7") == std::string::npos) n++; + return n; +} + +static void settledFrames(FakeHost& host, Shell& shell) { + frames(host, shell, 30); // the scroll's tween (120 ms) is done + g_at.clear(); + frames(host, shell, 1); +} + +// The rail lists the groups in the order the host gives them, and only those that have rows. +static void settingsGroupsInRowOrder() { + FakeHost host; + Shell shell(host); + settingsSetup(host, shell); // picture, sound, interface, core:video, core:speed + g_texts.clear(); + frames(host, shell, 2); + auto at = [&](const char* s) { return (int)(std::find(g_texts.begin(), g_texts.end(), s) - g_texts.begin()); }; + CHECK(drew("Picture") && at("Picture") < at("Sound") && at("Sound") < at("Interface") && at("Interface") < at("Video") && at("Video") < at("Speed")); + FakeHost noSound; + Shell s2(noSound); + std::vector<SettingRow> rows = settingsFixture(); + rows.erase(rows.begin() + 1); // Volume was the only Sound row + settingsSetup(noSound, s2, rows); + g_texts.clear(); + frames(noSound, s2, 2); + CHECK(drew("Picture") && drew("Interface") && !drew("Sound")); + CHECK(shell.focusGroup() == "picture"); +} + +// 20 groups at 1280x720: the rail follows the focus to the last group and it is drawn inside the content area; nothing is drawn +// above it or below it, and the rail comes back to the top. +static void settingsRailScrolls720p() { + ImGuiIO& io = ImGui::GetIO(); + FakeHost host; + Shell shell(host); + settingsSetup(host, shell, manyGroups(20)); + io.DisplaySize = ImVec2(1280, 720); + widgets::setTextPosProbe(&g_at); + frames(host, shell, 3); + const float u = 720.0f / 1080.0f, top = 150 * u, bottom = 968 * u; + CHECK(shell.focusLabel() == "Group 0"); + down(host, shell, 19); + CHECK(shell.focusLabel() == "Group 19" && shell.focusGroup() == "g19"); + settledFrames(host, shell); + widgets::TextAt at; + CHECK(placeOf("Group 19", at) && within(at, top, bottom)); + int shown = 0; + bool allInside = true; + for (const widgets::TextAt& t : g_at) + if (t.s.rfind("Group ", 0) == 0 && t.s.find("\xC2\xB7") == std::string::npos) { + shown++; + if (!within(t, top, bottom)) allInside = false; + } + CHECK(shown >= 8 && shown < 20 && allInside); // a window of the rail, whole items only + up(host, shell, 19); + settledFrames(host, shell); + CHECK(placeOf("Group 0", at) && within(at, top, bottom) && !placeOf("Group 19", at)); + // The memory is a key: leaving and coming back lands on the last group, scrolled to show it at once (no tween from the top). + down(host, shell, 19); + lb(host, shell); + lb(host, shell); + rb(host, shell); + rb(host, shell); + g_at.clear(); + frames(host, shell, 1); + CHECK(shell.focusLabel() == "Group 19" && placeOf("Group 19", at) && within(at, top, bottom)); + widgets::setTextPosProbe(nullptr); + io.DisplaySize = ImVec2(1920, 1080); + CHECK(g_imguiErrors == 0); +} + +// A many-step value: Right held repeats (350 ms, then every 80 ms, the nav's own), the index only goes up, and letting go stops it. +static void settingsHeldRightRepeats() { + FakeHost host; + Shell shell(host); + std::vector<std::string> choices; + for (int i = 0; i < 30; i++) choices.push_back("c" + std::to_string(i)); + settingsSetup(host, shell, {mkRow("core:x", "Many steps", "core:x", "X", choices)}); + rightN(host, shell, 1); // into the list + CHECK(host.set.empty() && shell.focusLabel() == "Many steps"); + pressButton(host, shell, SDL_GAMEPAD_BUTTON_DPAD_RIGHT, true); + frames(host, shell, 90); // ~1.4 s at the smoke clock + const size_t n = host.set.size(); + CHECK(n >= 10); + bool monotone = true; + for (size_t i = 1; i < n; i++) + if (host.set[i].second != host.set[i - 1].second + 1) monotone = false; + CHECK(monotone && host.set.back().second == (int)n); + pressButton(host, shell, SDL_GAMEPAD_BUTTON_DPAD_RIGHT, false); + frames(host, shell, 30); + CHECK(host.set.size() == n); // released: no further sets + // Holding Right from the rail enters the list and then stops: the repeats of that press are not steps of the first row. + FakeHost rail; + Shell rs(rail); + settingsSetup(rail, rs, {mkRow("core:x", "Many steps", "core:x", "X", choices)}); + pressButton(rail, rs, SDL_GAMEPAD_BUTTON_DPAD_RIGHT, true); + frames(rail, rs, 90); + pressButton(rail, rs, SDL_GAMEPAD_BUTTON_DPAD_RIGHT, false); + frames(rail, rs, 2); + CHECK(rail.set.empty() && rs.focusLabel() == "Many steps"); + rightN(rail, rs, 1); // a new press steps again + CHECK(rail.set.size() == 1 && rail.set.back().second == 1); + CHECK(g_imguiErrors == 0); +} + +// Left held down a many-step value stops at its first choice; only a new press leaves for the rail. +static void settingsHeldLeftStopsAtFirstChoice() { + FakeHost host; + Shell shell(host); + std::vector<std::string> choices; + for (int i = 0; i < 30; i++) choices.push_back("c" + std::to_string(i)); + settingsSetup(host, shell, {mkRow("core:x", "Many steps", "core:x", "X", choices, 10)}); + rightN(host, shell, 1); + pressButton(host, shell, SDL_GAMEPAD_BUTTON_DPAD_LEFT, true); + frames(host, shell, 150); // 10 steps take ~1.1 s; then the repeats go on pressing the edge + CHECK(host.set.size() == 10 && host.set.back().second == 0); + CHECK(shell.focusLabel() == "Many steps"); // still on the row, not the rail + pressButton(host, shell, SDL_GAMEPAD_BUTTON_DPAD_LEFT, false); + frames(host, shell, 2); + leftN(host, shell, 1); // a press at the left edge is the rail + CHECK(shell.focusLabel() == "X" && host.set.size() == 10); +} + +// LT / RT jump the list by the rows on screen, stop at the ends, and the focused row stays in view. +static void settingsPageJump() { + ImGuiIO& io = ImGui::GetIO(); + FakeHost host; + Shell shell(host); + settingsSetup(host, shell, manyRows(40)); + io.DisplaySize = ImVec2(1280, 720); + widgets::setTextPosProbe(&g_at); + frames(host, shell, 3); + const float u = 720.0f / 1080.0f, top = (150 + 46) * u, bottom = 968 * u; + rightN(host, shell, 1); // into the list + settledFrames(host, shell); + const int visible = drewCount("Row "); + CHECK(visible >= 5 && visible < 40); + pullTrigger(host, shell, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER); + CHECK(shell.focusLabel() == "Row " + std::to_string(visible)); + settledFrames(host, shell); + widgets::TextAt at; + CHECK(placeOf("Row " + std::to_string(visible), at) && within(at, top, bottom)); + pullTrigger(host, shell, SDL_GAMEPAD_AXIS_LEFT_TRIGGER); + CHECK(shell.focusLabel() == "Row 0"); + pullTrigger(host, shell, SDL_GAMEPAD_AXIS_LEFT_TRIGGER); // at the top it stays + CHECK(shell.focusLabel() == "Row 0"); + for (int i = 0; i < 6; i++) pullTrigger(host, shell, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER); // past the end it clamps to the last row + CHECK(shell.focusLabel() == "Row 39"); + settledFrames(host, shell); + CHECK(placeOf("Row 39", at) && within(at, top, bottom)); + pullTrigger(host, shell, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER); + CHECK(shell.focusLabel() == "Row 39"); + for (int i = 0; i < 6; i++) pullTrigger(host, shell, SDL_GAMEPAD_AXIS_LEFT_TRIGGER); + CHECK(shell.focusLabel() == "Row 0"); + // On the rail the triggers page the groups. + FakeHost g; + Shell gs(g); + settingsSetup(g, gs, manyGroups(30)); + pullTrigger(g, gs, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER); + CHECK(gs.focusLabel() != "Group 0" && gs.focusLabel() != "Group 1"); + for (int i = 0; i < 6; i++) pullTrigger(g, gs, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER); + CHECK(gs.focusLabel() == "Group 29"); + widgets::setTextPosProbe(nullptr); + io.DisplaySize = ImVec2(1920, 1080); + CHECK(g_imguiErrors == 0); +} + +// Each group's place is remembered under its key: row 3 of Video survives a visit to Speed. +static void settingsFocusMemoryPerGroupKey() { + FakeHost host; + Shell shell(host); + std::vector<SettingRow> rows; + for (int i = 0; i < 6; i++) rows.push_back(mkRow(("v" + std::to_string(i)).c_str(), ("Video row " + std::to_string(i)).c_str(), "core:video", "Video", {"Off", "On"})); + for (int i = 0; i < 3; i++) rows.push_back(mkRow(("p" + std::to_string(i)).c_str(), ("Speed row " + std::to_string(i)).c_str(), "core:speed", "Speed", {"Off", "On"})); + settingsSetup(host, shell, rows); + CHECK(shell.focusLabel() == "Video"); + rightN(host, shell, 1); + down(host, shell, 3); + CHECK(shell.focusLabel() == "Video row 3"); + back(host, shell); // the rail + down(host, shell); + CHECK(shell.focusLabel() == "Speed" && shell.focusGroup() == "core:speed"); + rightN(host, shell, 1); + CHECK(shell.focusLabel() == "Speed row 0"); + down(host, shell); + back(host, shell); + up(host, shell); + CHECK(shell.focusLabel() == "Video"); + rightN(host, shell, 1); + CHECK(shell.focusLabel() == "Video row 3"); + back(host, shell); + down(host, shell); + rightN(host, shell, 1); + CHECK(shell.focusLabel() == "Speed row 1"); + CHECK(g_imguiErrors == 0); +} + +// A value between two grid values ("(custom)") steps to the grid neighbours, in either direction; one beyond the grid is the last +// entry and Accept wraps from it to the first. +static void settingsCustomValueRow() { + { + FakeHost host; + Shell shell(host); + const SettingRow r = sliderRow("volume", "Volume", "sound", "Sound", 25.0f, 0, 100, 10, "%.0f %%"); + CHECK(r.choices[(size_t)r.index].find("(custom)") != std::string::npos && r.index == 3); + settingsSetup(host, shell, {r}); + rightN(host, shell, 1); // into the list + rightN(host, shell, 1); + CHECK(lastSet(host, "volume", 4)); // 30 % + FakeHost left; + Shell ls(left); + settingsSetup(left, ls, {r}); + rightN(left, ls, 1); + leftN(left, ls, 1); + CHECK(lastSet(left, "volume", 2)); // 20 % + } + FakeHost host; + Shell shell(host); + const SettingRow r = sliderRow("volume", "Volume", "sound", "Sound", 137.0f, 0, 100, 10, "%.0f %%"); + CHECK(r.index == (int)r.choices.size() - 1 && r.choices.back().find("(custom)") != std::string::npos); + settingsSetup(host, shell, {r}); + rightN(host, shell, 1); + rightN(host, shell, 1); // already the last entry + CHECK(host.set.empty()); + accept(host, shell); // wraps to the first + CHECK(lastSet(host, "volume", 0)); + CHECK(g_imguiErrors == 0); +} + // ---- TV Achievements (phase 3, Task 14) ----------------------------------------------------------------------------------- static AchItem mkItem(uint32_t id, const char* title, const char* status, bool unlocked, unsigned points = 10) { @@ -3234,7 +3618,7 @@ static void ingameRowsFullOrder() { shell.openMenu(); std::vector<std::string> labels; for (const Shell::Row& r : shell.menuRows()) labels.push_back(r.label); - const std::vector<std::string> want = {"Resume", "Save state", "Load state", "Achievements", "Overlays", + const std::vector<std::string> want = {"Resume", "Save state", "Load state", "Library", "Achievements", "Overlays", "Settings", "Controls", "Enter TV mode", "Reset", "Close game"}; CHECK(labels == want); // Hardcore (no Load row) and TV mode Off drop only their own rows; the three screens stay. @@ -3245,7 +3629,7 @@ static void ingameRowsFullOrder() { hardShell.openMenu(); labels.clear(); for (const Shell::Row& r : hardShell.menuRows()) labels.push_back(r.label); - CHECK((labels == std::vector<std::string>{"Resume", "Save state", "Achievements", "Overlays", "Settings", "Controls", "Reset", "Close game"})); + CHECK((labels == std::vector<std::string>{"Resume", "Save state", "Library", "Achievements", "Overlays", "Settings", "Controls", "Reset", "Close game"})); } static void overlaysToggleFps() { @@ -3398,25 +3782,65 @@ static void menuMoreSettingsKeepsMenu() { CHECK(g_imguiErrors == 0); } -// ... and in the in-game menu's slot the groups level lists it too. -static void ingameSpeedNoteBeforeAGame() { +// ... and in the in-game menu's slot the groups level lists them too, a core group included. +static void ingameCoreGroupsLevel() { FakeHost mh; - mh.rows = speedNoteRows(); + mh.rows = settingsFixture(); Shell ms(mh); menuSetup(mh, ms); menuTo(mh, ms, "Settings"); accept(mh, ms); g_texts.clear(); frames(mh, ms, 2); - CHECK(drew("Picture") && drew("Speed") && drew("Sound")); - down(mh, ms); + CHECK(drew("Picture") && drew("Video") && drew("Speed") && drew("Sound")); + down(mh, ms, 4); // Speed accept(mh, ms); g_texts.clear(); frames(mh, ms, 2); - CHECK(drew("Available once a game has run")); - accept(mh, ms); - rightN(mh, ms, 2); - CHECK(mh.set.empty()); + CHECK(drew("RISC overclock") && !drew("Internal resolution")); + rightN(mh, ms, 1); + CHECK(lastSet(mh, "core:virtualjaguar_risc_clock_scale", 2)); + CHECK(g_imguiErrors == 0); +} + +// The menu's slot has the same scrolling: 20 groups, then a group of 20 rows, at 1280x720 -- the focused group / row is drawn inside +// the menu's list area. +static void ingameSettingsSlotScrolls() { + ImGuiIO& io = ImGui::GetIO(); + widgets::setTextPosProbe(&g_at); + for (int branch = 0; branch < 2; branch++) { + FakeHost host; + host.rows = branch == 0 ? manyGroups(20) : manyRows(20); + Shell shell(host); + menuSetup(host, shell); + io.DisplaySize = ImVec2(1280, 720); + menuTo(host, shell, "Settings"); + accept(host, shell); + const Metrics m = metrics(720); + const float top = m.safeY + 48 * m.u; // the panel's list area (tv_ingame.cpp) + const float bottom = m.safeY + (720 - 2 * m.safeY) - 48 * m.u - (m.caption + 8 * m.u) - 24 * m.u; + if (branch == 1) accept(host, shell); // the only group's rows + down(host, shell, 19); + const std::string want = branch == 0 ? "Group 19" : "Row 19"; + CHECK(shell.menuHasScreen() && shell.menuScreenId() == "settings"); + settledFrames(host, shell); + widgets::TextAt at; + CHECK(placeOf(want, at) && within(at, top, bottom)); + bool allInside = true; + for (const widgets::TextAt& t : g_at) + if ((t.s.rfind("Group ", 0) == 0 || t.s.rfind("Row ", 0) == 0) && t.s.find("\xC2\xB7") == std::string::npos && !within(t, top, bottom)) allInside = false; + CHECK(allInside); + up(host, shell, 19); + settledFrames(host, shell); + CHECK(placeOf(branch == 0 ? "Group 0" : "Row 0", at) && within(at, top, bottom)); + if (branch == 0) { // a page jump on the groups level + pullTrigger(host, shell, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER); + CHECK(shell.menuScreenId() == "settings"); + } + shell.closeMenu(); + } + widgets::setTextPosProbe(nullptr); + io.DisplaySize = ImVec2(1920, 1080); CHECK(g_imguiErrors == 0); } @@ -3501,10 +3925,11 @@ static void ingameSlotLeavesMenuModelAlone() { static void menuWalkWithSlotsNoAsserts() { ImGuiIO& io = ImGui::GetIO(); const int errors = g_imguiErrors; - for (const char* row : {"Achievements", "Overlays", "Settings"}) { + for (const char* row : {"Achievements", "Overlays", "Settings", "Library"}) { FakeHost host; + host.all = mkGames("A long game title that wraps on its tile", 60); host.rows = settingsFixture(); - for (int i = 0; i < 12; i++) host.rows.push_back(mkRow("extra", "An extra setting", 0, {"Off", "On"})); + for (int i = 0; i < 12; i++) host.rows.push_back(mkRow("extra", "An extra setting", "picture", "Picture", {"Off", "On"})); host.hdr.signedIn = true; host.hdr.hasGame = true; host.hdr.title = "A game with a title that is far too long to fit beside its icon on a small screen"; @@ -3562,6 +3987,212 @@ static void menuWalkWithSlotsNoAsserts() { // The four tabs and both shoulder pills fit the top bar at 1280x720 with room left for the clock (the chrome's own sums: // the safe margin, the accent, the logo, the LB pill, each label at body size 40 apart, the RB pill). +// ---- the Library in TV mode (#991 Task 21): the in-game row, the slot, the launch confirm, the Base entry -------------------------- + +// A game running, the menu open, the Library row accepted: the Library in the slot, drawn. +static void libraryMenuSetup(FakeHost& host, Shell& shell) { + host.all = libraryFixture(); + host.st[host.all[0].key] = {{-1, 1760000000}}; // the first game has an auto-save + host.tv = true; + ImGui::GetIO().DisplaySize = ImVec2(1920, 1080); + shell.openMenu(); + frames(host, shell, 2); + down(host, shell, 3); // Save state, Load state, Library + CHECK(std::string(focusedLabel(shell)) == "Library"); + accept(host, shell); + frames(host, shell, 2); +} + +// The Library row is in the root after Load state, and puts the Library in the slot with the whole panel: its rail and grid are +// drawn, the menu's own rail (the game's title) is not. +static void ingameLibrarySlotWide() { + FakeHost host; + Shell shell(host); + libraryMenuSetup(host, shell); + CHECK(shell.menuScreenId() == "library" && shell.menuOpen() && host.loaded); + g_texts.clear(); + frames(host, shell, 3); + CHECK(drew("Alien vs Predator") && drew("All games \xC2\xB7 6 games") && drew("Favorites")); // a tile, the caption, the Library's rail + CHECK(!drew(host.gameTitle()) && !drew("Paused") && !drew("Library")); // the menu's rail is not drawn + CHECK(g_imguiErrors == 0); +} + +// A on a tile with a game running asks: No is the focus, No returns to the Library on the same tile, Yes (Up: it is above No) launches. +static void ingameLibraryLaunchAsks() { + FakeHost host; + Shell shell(host); + libraryMenuSetup(host, shell); + rightN(host, shell, 2); // the third tile: Battlemorph + const std::string key = host.all[2].key; + accept(host, shell); + CHECK(shell.menuPage() == Shell::Page::ConfirmLaunch && std::string(focusedLabel(shell)) == "No"); + CHECK(shell.menuTitle() == "Close " + host.gameTitle() + " and start Battlemorph?"); + CHECK(host.launchedKey.empty() && host.launches == 0 && host.loaded && shell.menuOpen()); + CHECK(shell.focusLabel() == "No"); + g_texts.clear(); + frames(host, shell, 2); + CHECK(drew("Yes") && drew("No") && drew("Close " + host.gameTitle() + " and start Battlemorph?")); + accept(host, shell); // No + CHECK(shell.menuScreenId() == "library" && shell.menuPage() == Shell::Page::Root && host.launches == 0 && host.loaded); + accept(host, shell); // the same tile is still focused + CHECK(shell.menuPage() == Shell::Page::ConfirmLaunch && shell.menuTitle().find("Battlemorph") != std::string::npos); + back(host, shell); // Back is No too + CHECK(shell.menuScreenId() == "library" && host.launches == 0); + accept(host, shell); + up(host, shell); // Yes (as Reset and Close: Yes above No) + CHECK(std::string(focusedLabel(shell)) == "Yes"); + const int armed = host.guardArmed; + accept(host, shell); + CHECK(host.launchedKey == key && host.launches == 1 && !host.loaded); + CHECK(host.guardArmed == armed + 1); // launched(): the A that said Yes must not reach the new game + CHECK(g_imguiErrors == 0); +} + +// X on a tile with a state asks the same question; Yes starts that state. +static void ingameLibraryResumeStateAsks() { + FakeHost host; + Shell shell(host); + libraryMenuSetup(host, shell); + tapButton(host, shell, SDL_GAMEPAD_BUTTON_WEST); // X (Alt): the first game's auto-save + CHECK(shell.menuPage() == Shell::Page::ConfirmLaunch && host.launchedState.first.empty() && host.loaded); + up(host, shell); + accept(host, shell); + CHECK(host.launchedState.first == host.all[0].key && host.launchedState.second == -1 && !host.loaded); + // A tile with no state: X does nothing. + FakeHost none; + Shell s2(none); + libraryMenuSetup(none, s2); + none.st.clear(); + tapButton(none, s2, SDL_GAMEPAD_BUTTON_WEST); + CHECK(s2.menuScreenId() == "library" && s2.menuPage() == Shell::Page::Root); +} + +// Typing filters the slot's grid; Menu (Start) and Aux (Y) do nothing there (they push onto the Base stack, which is not shown). +static void ingameLibraryTypedFilters() { + FakeHost host; + Shell shell(host); + libraryMenuSetup(host, shell); + CHECK(keyEvent(shell, SDL_SCANCODE_D, SDLK_D)); + g_texts.clear(); + frames(host, shell, 2); + CHECK(drew("Filter: d") && drew("All games \xC2\xB7 2 games")); // the titles with a d in them: Alien vs Predator, Doom + keyEvent(shell, SDL_SCANCODE_D, SDLK_D, false); + CHECK(keyEvent(shell, SDL_SCANCODE_BACKSPACE, SDLK_BACKSPACE)); // drops the filter, it is not Back + keyEvent(shell, SDL_SCANCODE_BACKSPACE, SDLK_BACKSPACE, false); + frames(host, shell, 2); + CHECK(shell.menuScreenId() == "library"); + tapButton(host, shell, SDL_GAMEPAD_BUTTON_START); // Menu: Details on the Base layer + tapButton(host, shell, SDL_GAMEPAD_BUTTON_NORTH); // Aux: the letter picker on the Base layer + CHECK(shell.menuScreenId() == "library" && shell.screenId() != "details" && shell.screenId() != "letters" && shell.menuOpen()); + g_texts.clear(); + frames(host, shell, 2); + CHECK(!drew("Jump to letter") && !drew("Details")); // not offered + // A desktop window's text field has the keyboard. + host.desktopOpen = true; + ImGui::GetIO().WantTextInput = true; + CHECK(!keyEvent(shell, SDL_SCANCODE_Z, SDLK_Z)); + ImGui::GetIO().WantTextInput = false; + host.desktopOpen = false; + CHECK(g_imguiErrors == 0); +} + +// Back leaves the slot for the menu, on the Library row; Back again resumes. +static void ingameLibraryBackReturnsToRow() { + FakeHost host; + Shell shell(host); + libraryMenuSetup(host, shell); + back(host, shell); + CHECK(!shell.menuHasScreen() && shell.menuOpen() && std::string(focusedLabel(shell)) == "Library"); + CHECK(shell.menuPage() == Shell::Page::Root); + // ... with the filter on, Back still leaves at once (the Library's own level is the grid). + accept(host, shell); + keyEvent(shell, SDL_SCANCODE_D, SDLK_D); + back(host, shell); + CHECK(!shell.menuHasScreen() && std::string(focusedLabel(shell)) == "Library"); + back(host, shell); + CHECK(!shell.menuOpen() && host.resumed == 1); +} + +// Shell::openLibrary: TV off -> refused; no game -> the Library section (again: unchanged); a game -> the menu with the Library in its +// slot, from the root and from a page, and a second call changes nothing; a refused menu opens nothing. +static void baseLibraryOpensFromApp() { + FakeHost host; + host.all = libraryFixture(); + host.tv = false; + host.loaded = false; + Shell shell(host); + CHECK(!shell.openLibrary() && !shell.menuOpen() && !shell.showingLibrary()); + host.tv = true; + CHECK(shell.openLibrary() && shell.screenId() == "library" && shell.section() == Section::Library && shell.showingLibrary()); + frames(host, shell, 3); + keyEvent(shell, SDL_SCANCODE_D, SDLK_D); + CHECK(shell.openLibrary() && shell.screenId() == "library"); // again: the same screen (its filter is still on) + g_texts.clear(); + frames(host, shell, 2); + CHECK(drew("Filter: d")); + shell.setSection(Section::Settings); + CHECK(!shell.showingLibrary()); + host.loaded = true; + Shell inGame(host); + CHECK(inGame.openLibrary() && inGame.menuOpen() && inGame.menuScreenId() == "library" && inGame.showingLibrary() && host.paused); + frames(host, inGame, 3); + CHECK(inGame.openLibrary() && inGame.menuScreenId() == "library"); + back(host, inGame); + CHECK(inGame.menuOpen() && !inGame.menuHasScreen() && std::string(focusedLabel(inGame)) == "Library" && !inGame.showingLibrary()); + Shell paged(host); + paged.openMenu(); + down(host, paged, 1); // Save state + accept(host, paged); + CHECK(paged.menuPage() == Shell::Page::SaveSlots); + CHECK(paged.openLibrary() && paged.menuScreenId() == "library"); + back(host, paged); + CHECK(paged.menuPage() == Shell::Page::Root && std::string(focusedLabel(paged)) == "Library"); + FakeHost hard; + hard.refusePause = "Hardcore: wait 20 s"; + hard.tv = true; + Shell hs(hard); + CHECK(!hs.openLibrary() && !hs.menuOpen() && hard.lastToast == "Hardcore: wait 20 s"); + // The Library opened while a launch question was up (Ctrl+L): the question is dropped, Back lands on the row. + Shell asking(host); + libraryMenuSetup(host, asking); + accept(host, asking); + CHECK(asking.menuPage() == Shell::Page::ConfirmLaunch); + CHECK(asking.openLibrary() && asking.menuScreenId() == "library"); + back(host, asking); + CHECK(asking.menuPage() == Shell::Page::Root && std::string(focusedLabel(asking)) == "Library"); + CHECK(g_imguiErrors == 0); +} + +// Every launch with no game running is immediate (Home, Library, Details): requestLaunch asks only over a game. +static void requestLaunchWithoutGameIsImmediate() { + FakeHost host; + host.all = libraryFixture(); + host.st[host.all[1].key] = {{3, 1760000000}}; + Shell shell(host); + librarySetup(host, shell); + accept(host, shell); // the first tile (a library with games opens on its grid) + CHECK(host.launchedKey == host.all[0].key && shell.menuPage() == Shell::Page::Root && !shell.menuOpen()); + host.loaded = false; + const int armed = host.guardArmed; + shell.requestLaunch(host.all[2], nullptr); + CHECK(host.launchedKey == host.all[2].key && host.guardArmed == armed + 1); + const StateRef st{3, 1760000000}; + shell.requestLaunch(host.all[1], &st); + CHECK(host.launchedState.first == host.all[1].key && host.launchedState.second == 3 && host.guardArmed == armed + 2); + // A game running and no menu to ask in: the menu opens with the question; nothing launches unasked. + host.loaded = true; + host.launches = 0; + shell.requestLaunch(host.all[2], nullptr); + CHECK(host.launches == 0 && host.loaded && shell.menuOpen() && shell.menuPage() == Shell::Page::ConfirmLaunch); + frames(host, shell, 2); // (the Base layer just lost the input: the nav starts over once) + back(host, shell); + CHECK(shell.menuPage() == Shell::Page::Root && host.launches == 0); + shell.closeMenu(); + host.refusePause = "Hardcore: wait 20 s"; // ... and where the menu is refused, nothing at all + shell.requestLaunch(host.all[2], nullptr); + CHECK(host.launches == 0 && !shell.menuOpen() && host.lastToast == "Hardcore: wait 20 s"); +} + static void tabBarFitsAt720p() { const float u = 720.0f / 1080.0f; float x = 96 * u + (22 + 16) * u + measure(34 * u, "VIRTUAL JAGUAR") + 56 * u; @@ -3573,6 +4204,268 @@ static void tabBarFitsAt720p() { CHECK(x + 24 * u < clockLeft); } +// ---- long labels, wrapped notes and placeholder titles (phase 5 polish, #991) ---------------------------------------------------------- + +static const char* const kLongDefaults = "Per-Title Enhancement Defaults: apply known-safe presets automatically for recognized games"; +static const char* const kLongHooks = "Per-Title Enhancement Hooks: apply per-game byte patches from the enhancement database to the cartridge"; +static const char* const kLongNote = "Applies when the game next starts: the new value is read at launch, never while it runs"; + +// Two long labels with the same first words, a short one, and a long note. +static std::vector<SettingRow> longLabelRows() { + SettingRow a = mkRow("core:a", kLongDefaults, "core:video", "Video", {"disabled", "enabled"}); + SettingRow b = mkRow("core:b", kLongHooks, "core:video", "Video", {"disabled", "enabled"}); + b.note = kLongNote; + return {a, b, mkRow("core:c", "Blitter", "core:video", "Video", {"Fast", "Accurate"})}; +} + +// A drawn string is part of `whole` (a line of a wrapped label or note). +static bool partOf(const widgets::TextAt& t, const char* whole) { return t.s.size() >= 8 && std::string(whole).find(t.s) != std::string::npos; } + +// Every string drawn lies inside [left, right] horizontally and [top, bottom] vertically (pixels). +static bool drawnInside(float left, float right, float top, float bottom) { + for (const widgets::TextAt& t : g_at) { + const float w = measure(t.px, t.s.c_str()); + if (t.pos.x < left - 0.5f || t.pos.x + w > right + 0.5f || t.pos.y < top - 0.5f || t.pos.y + t.px > bottom + 0.5f) { + std::fprintf(stderr, "outside: \"%s\" at %.1f,%.1f w %.1f px %.1f\n", t.s.c_str(), t.pos.x, t.pos.y, w, t.px); + return false; + } + } + return true; +} + +// A label that does not fit beside its value wraps to a second line instead of being cut: both long labels are drawn whole (the probe +// has each once, in full), on two lines, and distinguishably; so is a long note. Nothing leaves the content area / the safe area. +static void settingsLongLabelsWrap() { + ImGuiIO& io = ImGui::GetIO(); + for (float hh : {720.0f, 1080.0f}) { + FakeHost host; + Shell shell(host); + settingsSetup(host, shell, longLabelRows()); + io.DisplaySize = ImVec2(hh * 16 / 9, hh); + widgets::setTextPosProbe(&g_at); + frames(host, shell, 3); + rightN(host, shell, 1); // into the list + frames(host, shell, 30); + g_texts.clear(); + g_at.clear(); + frames(host, shell, 1); + const Metrics m = metrics(hh); + CHECK(drew(kLongDefaults) && drew(kLongHooks) && drew(kLongNote)); // each in full, once + CHECK(std::string(kLongDefaults) != kLongHooks); + int a = 0, b = 0, n = 0; + bool oneLine = false; + for (const widgets::TextAt& t : g_at) { + a += partOf(t, kLongDefaults) && t.s != kLongDefaults; + b += partOf(t, kLongHooks) && t.s != kLongHooks; + n += partOf(t, kLongNote) && t.s != kLongNote && t.px < m.body; + oneLine = oneLine || t.s == kLongDefaults || t.s == kLongHooks || t.s == kLongNote; + } + CHECK(a == 2 && b == 2 && n == 2 && !oneLine); // two lines each, never cut mid-word (the lines join to the whole text) + bool inside = true; + float firstA = 0, firstB = 0; + for (const widgets::TextAt& t : g_at) { // the rows' text lies in the content area (150 u to 968 u) and the safe margins + if (!(partOf(t, kLongDefaults) || partOf(t, kLongHooks) || partOf(t, kLongNote))) continue; + const float w = measure(t.px, t.s.c_str()); + inside = inside && within(t, 150 * m.u, 968 * m.u) && t.pos.x >= m.safeX && t.pos.x + w <= io.DisplaySize.x - m.safeX; + if (firstA == 0 && partOf(t, kLongDefaults)) firstA = t.pos.y; + if (firstB == 0 && partOf(t, kLongHooks)) firstB = t.pos.y; + } + CHECK(inside); + // The rows grew to fit: they are as tall as the tallest (two label lines, two note lines), so the next row is a whole row below. + CHECK(firstB - firstA >= 100 * m.u); + widgets::setTextPosProbe(nullptr); + CHECK(g_imguiErrors == 0); + } + io.DisplaySize = ImVec2(1920, 1080); +} + +// In the menu's slot Settings takes the whole panel (no rail of the menu's own), so labels that used to be cut fit on one line. +static void ingameSettingsSlotIsWide() { + ImGuiIO& io = ImGui::GetIO(); + for (float hh : {720.0f, 1080.0f}) { + FakeHost host; + host.rows = {mkRow("core:a", "Per-Title Enhancement Defaults", "core:video", "Video", {"disabled", "enabled"}), + mkRow("core:b", "Per-Title Enhancement Hooks", "core:video", "Video", {"disabled", "enabled"}), + mkRow("core:c", "True Color (Gouraud Shading)", "core:video", "Video", {"disabled", "enabled"}), + mkRow("core:d", "Internal Resolution (Restart)", "core:video", "Video", {"1x (native)", "2x"}), + mkRow("aspect", "Aspect ratio", "picture", "Picture", {"Core default", "4:3"})}; + host.rows[3].note = "Applies when the game next starts"; + Shell shell(host); + menuSetup(host, shell); + io.DisplaySize = ImVec2(hh * 16 / 9, hh); + menuTo(host, shell, "Settings"); + widgets::setTextPosProbe(&g_at); + accept(host, shell); + frames(host, shell, 30); + g_texts.clear(); + g_at.clear(); + frames(host, shell, 1); + CHECK(shell.menuHasScreen() && drew("Settings \xC2\xB7 2 groups") && !drew("Paused") && !drew(host.gameTitle())); // the screen names itself + accept(host, shell); // Video (the first group: the rows are listed in the host's order) + frames(host, shell, 30); + g_texts.clear(); + g_at.clear(); + frames(host, shell, 1); + const Metrics m = metrics(hh); + for (const char* label : {"Per-Title Enhancement Defaults", "Per-Title Enhancement Hooks", "True Color (Gouraud Shading)", "Internal Resolution (Restart)"}) { + widgets::TextAt at; + CHECK(drew(label) && placeOf(label, at)); // whole, on one line (a line of the same text) + } + CHECK(drew("Applies when the game next starts")); + const float panelW = std::min(io.DisplaySize.x - 2 * m.safeX, 1500 * m.u); + const float left = (io.DisplaySize.x - panelW) * 0.5f; + CHECK(drawnInside(left, left + panelW, m.safeY, io.DisplaySize.y - m.safeY)); + shell.closeMenu(); + widgets::setTextPosProbe(nullptr); + CHECK(g_imguiErrors == 0); + } + io.DisplaySize = ImVec2(1920, 1080); +} + +// The Page hint is listed on a rail (and the menu's groups level) that scrolls, with one glyph when the pad has one trigger, and not at all +// without a glyph or when everything fits. +static void settingsPageHintOnRail() { + ImGuiIO& io = ImGui::GetIO(); + for (int branch = 0; branch < 2; branch++) { // 0: the Base rail, 1: the menu's groups level + FakeHost host; + host.pageLt = "LT"; + host.pageRt = "RT"; + Shell shell(host); + io.DisplaySize = ImVec2(1280, 720); + if (branch == 0) { + settingsSetup(host, shell, manyGroups(30)); + io.DisplaySize = ImVec2(1280, 720); + } else { + host.rows = manyGroups(30); + menuSetup(host, shell); + io.DisplaySize = ImVec2(1280, 720); + menuTo(host, shell, "Settings"); + accept(host, shell); + } + frames(host, shell, 30); + g_texts.clear(); + frames(host, shell, 2); + CHECK(drew("Page") && drew("LT RT") && drew("Open")); + host.pageRt = ""; + g_texts.clear(); + frames(host, shell, 2); + CHECK(drew("Page") && drew("LT") && !drew("LT ")); // no stray space + host.pageLt = ""; + g_texts.clear(); + frames(host, shell, 2); + CHECK(!drew("Page")); + // Few groups: everything fits, nothing to page. + FakeHost few; + few.pageLt = "LT"; + few.pageRt = "RT"; + Shell fs(few); + settingsSetup(few, fs, settingsFixture()); + g_texts.clear(); + frames(few, fs, 2); + CHECK(!drew("Page")); + if (shell.menuOpen()) shell.closeMenu(); + CHECK(g_imguiErrors == 0); + } + io.DisplaySize = ImVec2(1920, 1080); +} + +// A placeholder tile (no box art): the title wraps at words and the last line sits at the tile's foot; a title too long for the tile ends +// in "..." on the last line that fits inside it, never below it. Tiles of the menu's Library (158 u) and the screen's (190 u) at 1080p. +static void tilePlaceholderWrapsAtWords() { + ImGuiIO& io = ImGui::GetIO(); + io.DisplaySize = ImVec2(1920, 1080); + const Metrics m = metrics(1080); + struct Case { + const char* title; + float w; + }; + const Case cases[] = {{"Checkered Flag (World)", 158}, {"Cybermorph (World) (Rev A)", 158}, {"Defender 2000 (World)", 158}, + {"Skyhammer (World)", 158}, {"Trevor McFur in the Crescent Galaxy (World) (Rev A)", 158}, + {"Trevor McFur in the Crescent Galaxy (World) (Rev A)", 190}}; + for (const Case& cs : cases) { + widgets::setTextPosProbe(&g_at); + g_at.clear(); + g_texts.clear(); + io.DeltaTime = 1.0f / 60.0f; + ImGui::NewFrame(); + widgets::TileArgs a; + a.title = cs.title; + const ImVec2 pos(100, 100), size(cs.w, cs.w * 7 / 5); + widgets::tile(ImGui::GetForegroundDrawList(), m, pos, size, a); + ImGui::Render(); + widgets::setTextPosProbe(nullptr); + CHECK(!g_at.empty()); + bool inside = true; + float lastBottom = 0; + for (const widgets::TextAt& t : g_at) { + const float w = measure(t.px, t.s.c_str()); + if (t.pos.x < pos.x || t.pos.x + w > pos.x + size.x || t.pos.y < pos.y || t.pos.y + t.px > pos.y + size.y) inside = false; + lastBottom = std::max(lastBottom, t.pos.y + t.px); + CHECK(t.px >= m.minText - 0.01f); // never under the 24 u floor + } + CHECK(inside); + CHECK(std::fabs(lastBottom - (pos.y + size.y - 8 * m.u)) < 0.6f); // the foot of the tile: the padding above its bottom edge + // Words are whole on their lines; the one cut a long title may take is the "..." on the last line. + const std::string title = cs.title; + const bool cut = !drew(title); + std::string last = g_at.back().s; + CHECK(cut == (last.size() >= 3 && last.compare(last.size() - 3, 3, "...") == 0)); + size_t i = 0; + while (i < title.size()) { + size_t j = title.find(' ', i); + if (j == std::string::npos) j = title.size(); + const std::string w = title.substr(i, j - i); + bool whole = false; + for (const widgets::TextAt& t : g_at) whole = whole || (" " + t.s + " ").find(" " + w + " ") != std::string::npos; + CHECK(whole || cut); // no word of an uncut title is split across lines + i = j + 1; + } + } + // The long title does not fit a narrow tile whole; the wide one fits all of it. + CHECK(g_imguiErrors == 0); +} + +// In the menu's Library the placeholder titles of the reported shot ("Checkered Flag") keep their words whole at 720p (the smoke font is wider than +// the app's, so "Cybermorph" -- a 10-letter word at the floor size -- is judged in the real shots, tilePlaceholderWrapsAtWords, not here). +static void ingameLibraryPlaceholderWordsWhole() { + ImGuiIO& io = ImGui::GetIO(); + FakeHost host; + Shell shell(host); + for (const char* t : {"Checkered Flag (World)", "Cybermorph (World) (Rev A)", "Defender 2000 (World)", "Skyhammer (World)", "Power Drive Rally (World)", + "Trevor McFur in the Crescent Galaxy (World) (Rev A)"}) { + GameRef g; + g.title = t; + g.key = std::string("/games/") + t + ".j64"; + g.index = (int)host.all.size(); + host.all.push_back(g); + } + host.tv = true; + io.DisplaySize = ImVec2(1920, 1080); + shell.openMenu(); + frames(host, shell, 2); + down(host, shell, 3); // Save state, Load state, Library + accept(host, shell); + io.DisplaySize = ImVec2(1280, 720); + widgets::setTextPosProbe(&g_at); + frames(host, shell, 30); + g_at.clear(); + frames(host, shell, 1); + for (const char* w : {"Checkered", "Skyhammer", "Defender", "Trevor", "Crescent", "Galaxy"}) { + bool whole = false; + for (const widgets::TextAt& t : g_at) whole = whole || (" " + t.s + " ").find(std::string(" ") + w + " ") != std::string::npos; + if (!whole) { + std::fprintf(stderr, "word split: %s; lines drawn:", w); + for (const widgets::TextAt& t : g_at) std::fprintf(stderr, " [%s @%.0f,%.0f px %.1f]", t.s.c_str(), t.pos.x, t.pos.y, t.px); + std::fprintf(stderr, "\n"); + } + CHECK(whole); + } + widgets::setTextPosProbe(nullptr); + shell.closeMenu(); + io.DisplaySize = ImVec2(1920, 1080); + CHECK(g_imguiErrors == 0); +} + int main() { ImGuiContext* ctx = ImGui::CreateContext(); ImGuiIO& io = ImGui::GetIO(); @@ -3594,6 +4487,8 @@ int main() { menuKeepsUserPause(); closeGameAsksFirst(); netplayBanner(); + hardcoreBanner(); + openSettingsRoutes(); hintBarShowsWhatWorks(); pointerFreeWhilePlaying(); controlsRowShowsNeverHides(); @@ -3630,6 +4525,7 @@ int main() { homeSurvivesRescan(); homeHeroShowsFocusedGame(); homeEmptyLibraryCard(); + homeWithGamesButNoShelvesPointsAtLibrary(); formatDateIsEnglishDayMonthYear(); homeWalk120FramesNoAsserts(); libraryRailGridHandoff(); @@ -3669,13 +4565,21 @@ int main() { settingsBackAndLeftEdge(); settingsHints(); settingsTvModeOffLeavesCleanly(); + settingsAutoRowsStep(); settingsReadonlyRowIgnoresInput(); settingsMoreOpensDesktop(); settingsHiddenRowsAbsent(); - settingsSpeedNoteBeforeAGame(); + settingsCoreGroups(); settingsFocusRemembered(); settingsMouse(); settingsClickOnChevrons(); + settingsGroupsInRowOrder(); + settingsRailScrolls720p(); + settingsHeldRightRepeats(); + settingsHeldLeftStopsAtFirstChoice(); + settingsPageJump(); + settingsFocusMemoryPerGroupKey(); + settingsCustomValueRow(); settingsWalk120FramesNoAsserts(); achNotSignedInLine(); achNotAvailableLine(); @@ -3696,12 +4600,25 @@ int main() { overlaysToolRowsFromRegistry(); overlaysHintsAndBack(); ingameSettingsSlotLevels(); - ingameSpeedNoteBeforeAGame(); + ingameCoreGroupsLevel(); + ingameSettingsSlotScrolls(); menuMoreSettingsKeepsMenu(); ingameSlotBackReturnsToRow(); ingameAchievementsSlotDraws(); ingameAchievementsGamesListIsReadOnly(); ingameSlotLeavesMenuModelAlone(); + ingameLibrarySlotWide(); + ingameLibraryLaunchAsks(); + ingameLibraryResumeStateAsks(); + ingameLibraryTypedFilters(); + ingameLibraryBackReturnsToRow(); + settingsLongLabelsWrap(); + ingameSettingsSlotIsWide(); + settingsPageHintOnRail(); + tilePlaceholderWrapsAtWords(); + ingameLibraryPlaceholderWordsWhole(); + baseLibraryOpensFromApp(); + requestLaunchWithoutGameIsImmediate(); menuWalkWithSlotsNoAsserts(); tabBarFitsAt720p(); // The menu at the other sizes the app runs at. @@ -3724,8 +4641,9 @@ int main() { } // The three screens of the menu's slot at the same sizes, inside the display (the rows with a long reason, a tall header and a // description included). - for (const char* row : {"Achievements", "Overlays", "Settings"}) { + for (const char* row : {"Achievements", "Overlays", "Settings", "Library"}) { FakeHost host; + host.all = mkGames("A long game title that wraps on its tile", 60); host.rows = settingsFixture(); host.hdr.signedIn = true; host.hdr.hasGame = true; @@ -3784,7 +4702,7 @@ int main() { host.tv = true; host.loaded = false; host.rows = settingsFixture(); - SettingRow ro = mkRow("resolution", "Internal resolution", 0, {"1x (native)", "2x"}, 1); + SettingRow ro = mkRow("resolution", "Internal resolution", "picture", "Picture", {"1x (native)", "2x"}, 1); ro.readOnly = true; ro.note = "Set for this session by Netplay or --option"; host.rows.push_back(ro); diff --git a/docs/app-tv-mode-plan.md b/docs/app-tv-mode-plan.md index 28c26f35..1324ef3e 100644 --- a/docs/app-tv-mode-plan.md +++ b/docs/app-tv-mode-plan.md @@ -49,7 +49,7 @@ These follow from the code or from the spec's own fallback rules; they need no n | §12 "Recently added" | `LibraryService` does not expose it; `ScanFile.mtime` is the file's own date, not when the file joined the library. **The shelf is dropped**, as the spec's own fallback rule says. | | §12 netplay | The menu opens without pausing when `netplay.configured()` (the test the auto-save already uses for "during netplay"), and shows the banner "Netplay: the game keeps running". | | §8 "remembered for the next launch only in Manual" | New key `ui.tv_manual_on` (bool), written only while the setting is Manual. | -| §9 nightly = Auto, first release = Manual | CMake option `VJ_TV_DEFAULT` (`auto` by default) becomes the compile definition `VJ_TV_DEFAULT_SETTING`. `release.yml` passes `-DVJ_TV_DEFAULT=manual` on every run that is not the `develop` nightly. | +| §9 nightly = Auto, first release = Manual | CMake option `VJ_TV_DEFAULT` (`auto` by default) becomes the compile definition `VJ_TV_DEFAULT_SETTING`, read only when `ui.tv_mode` is absent. The packages are built by `app-package.yml` (Phase 4 revision): it passes `-DVJ_TV_DEFAULT=manual` when its `release_tag` is a `vX.Y.Z` tag or `prerelease`; `nightly`, `prNNN` and `dev` builds stay Auto. | | §9 one-time hint | New key `ui.tv_hint_shown`. | ## Changes to the approved spec (each needs the user's approval before its phase starts) @@ -66,7 +66,7 @@ Reset / Close game row; the others apply from phase 2 on. | §4 Game details "Per-game settings" | Per-game settings exist only as control overrides for the *loaded* game, and Game details is reached only with no game running. **Row dropped**; the in-game menu's Settings > More settings covers it. | | Reset / Close game from a pad | Both ask first: a two-row Yes/No list with focus on No (one A press must not lose progress). | | §3.1 the TV-menu action with no game | With no game loaded and the setting not Off, `C_TVMENU` toggles TV mode (Task 7). The spec lists the action as live in that state without saying what it does; the §9 hint ("Press Share (hold) or F4 for TV mode") needs this meaning. | -| §9 the default per build | `release.yml` gains `-DVJ_TV_DEFAULT=manual` (Task 16). Another session owns the release workflow: coordinate before PR 4 touches it. | +| §9 the default per build | `app-package.yml` gains `-DVJ_TV_DEFAULT=manual` for tags and `prerelease` (Task 17; the packages moved out of `release.yml` after this plan was written). The v3.10.0 back-merge into develop carries the workflow files: that commit goes last and is rebased on it. | ## Review focus @@ -1734,85 +1734,773 @@ New in this revision: --- -## Phase 4 — auto-switching, the hint, tuning (PR 4) +## Phase 4 — auto-switching, the hint, the build default, tuning (PR 4) -### Phase 4 notes (to revise before Phase 4) +Revised 2026-10-21 against the code Phase 3 merged (#1060) and what landed after it: the emulation thread phase 3a (#1061), +the late-latch waker (#1058, #1062), the installer (#1059). Branch `feat/991-tv-phase4`, worktree +`~/Workspace/Provenance/vj-wt/tv-phase4`, off `libretro/develop` cf036d74. **Build and test on the Mac Studio only:** +`~/Workspace/Provenance/vj-eval/remote/tvb.sh "<ctest regex>"` (rsyncs the worktree, builds, runs the matching tests; +`--build-only` to just build; the log is on the Studio at `~/vj-remote/out/tvp4.log`). Never build the app on the developer's +Mac for this phase. -Written 2026-10-20 while revising Phase 3; Tasks 16–17 below are as first planned and need these corrections. +### Phase 4 constraints (overlap with other work) -- **`release.yml`:** the v3.10.0 release (#1042) is in flight and edits the workflow files; do not touch `release.yml` until - that release is out and master is merged back into develop. Flagged only; the `-DVJ_TV_DEFAULT=manual` line and where it - goes are to be re-planned against the post-release workflow. -- **`app.cpp` hooks already have their places:** `handleEvent` carries the comment "phase 4's Auto controller trigger, - `TvMode::onPadPress`, goes before this call" ahead of `deskPadHandleEvent` (the desktop nav must see a press only when it - did not switch TV on: `docs/app-pad-nav-design.md` §2). Fullscreen edges: hook `onFullscreen` where `fullscreen` changes - (`setFullscreen` and the SDL enter/leave events; #1034's `FullscreenMenu` reads the same flag). Nothing calls `onFullscreen`, - `onPadPress`, `onMouseMove` or `onNonNavKey` today; `TvMode` itself is complete and unit-tested. -- **The manual toggle paths grew:** View > TV mode, Ctrl/Cmd+Shift+T (#1024, `handleHotkey` `SDLK_T`), `C_TVMENU` with no - game, the in-game menu row, and Phase 3's TV-mode Settings row (which also writes `ui.tv_mode`). All go through - `App::toggleTvMode` / `configureTvMode`; Task 16's "manual wins until the next edge" rule is already in `TvMode::toggle`. -- **`ui.tv_mode` write rule:** `saveConfig()` deliberately never writes it; Phase 3's row writes it on change. Task 16's - Interface combo must do the same (write on change, then `configureTvMode()`), and `defaultSetting()` must apply only when the - key is absent (`cfg.has("ui.tv_mode")`), or the nightly default never moves for anyone who touched the row. -- **`drawInterfaceTab` layout changed:** it is now sections (Overlays, Appearance, Font, Performance, Save states, Game - library, Desktop, library lists). The "TV mode" block goes after Overlays. The TV Settings rows `tv_auto_fs` / `tv_auto_pad` - join Phase 3's `tvModeRow` in `tv_settings_model` (ids as listed; shown only when the stored setting is Auto). -- **`settings_reset_test`** already classifies the five `ui.tv_*` keys (Task 7 did it): drop that test from Task 16. -- **`effectiveSetting()`** is the Phase 2 mapping and `tv_mode.h` has no `defaultSetting()` yet; `VJ_TV_DEFAULT` is not in - `app/CMakeLists.txt`. Task 16's description of both still holds. Phase 3's approval E (Auto acts as Manual) ends here. -- **Task 17's hint:** the first pad press in the desktop library is seen in `deskPadHandleEvent` (#1030), which is where the - toast belongs; the glyph text is `padButtonLabel(g, input.navProfile(id), …)` for the pad's actual `C_TVMENU` binding - (`BindingStore::resolve` with its `PadCaps`), not a fixed "Share (hold)". -- **Task 17's README:** `app/README.md` already documents the TV-menu action and Ctrl/Cmd+Shift+T (#1020/#1024); extend that - section rather than adding a second one. -- **Headless script cases** for the Auto triggers need `focus`/`fullscreen` verbs the runner lacks (`fullscreen` exists only - as an expect); add `fullscreen on|off` (calls `setFullscreen`, headless: the flag only) and `mouse` is windowed-only, so the - "40 px mouse move turns it off" case is a smoke test on `TvMode` (already in `tv_mode_test`) plus a windowed Studio check. +- v3.10.0 is on `master` (#1042 merged) and **not yet merged back into `develop`**; the back-merge carries every workflow file. + The `app-package.yml` change (Task 17) is its own commit, last on the branch, so the rebase after the back-merge is one + cherry-pick; check `git log libretro/master..libretro/develop -- .github/workflows/app-package.yml` is empty before editing. +- An #967 phase 3b session edits `app.cpp` too. The Auto hooks go in **one** block of `handleEvent` plus one line in + `setFullscreen`, and no edit to how events reach `input.handleEvent` or the core. +- No `tv/` file calls `emu.call` / `emu.post` (as before); the only new App members are `setTvSetting`, `setTvAuto`, + `tvAutoPadPress`, `tvFullscreenEdge`, `tvNoteFsSeen_`, `tvHintShownThisRun_`, `toastCount_`. -### Task 16: Auto triggers, Interface settings, build default +### What Phase 3 left (read before any task) + +- **`TvMode` is complete and unit-tested** (`tv_mode.h/.cpp`, `tv_mode_test.cpp`): `configure(TvConfig, fullscreenNow)`, + `toggle()`, `setOn(bool)`, `onFullscreen(bool)`, `onPadPress(bool desktopOwnsInput)`, `onMouseMove(dx, dy)` (summed path + length over `kMouseOffPx = 32`), `onNonNavKey()`, `cause()` (`None / Manual / Fullscreen / Controller`). **Nothing calls the + last four.** Two behaviours matter for the wiring: a repeated `onFullscreen(true)` re-applies (it turns TV back on after a + manual off; `testFullscreenHoldIsState` relies on it), so App must feed it *edges only*; and `configure()` always starts from + off, so every caller that reconfigures while TV is on must restore it (`AppHost::setSetting("tv_mode")` already does: + `wasOn → setOn(true)`, which makes the cause Manual). +- **`effectiveSetting(stored)` is the Phase 2 mapping** (Auto → Manual). Five call sites: `app.cpp` `loadConfig` (×1 after + `configureTvMode`), `configureTvMode`, `saveConfig` (the `ui.tv_manual_on` rule); `tv_app_host.cpp` `tvAvailable()`; + `ui.cpp` the View > TV mode `MenuItem` enable. `tv_mode_test.cpp:190` checks `effectiveSetting(Auto) == Manual`. +- **The stored setting:** `App::tvSetting` (`TvSetting`), read in `loadConfig` as `cfg.get("ui.tv_mode", "manual")`; + `saveConfig` never writes `ui.tv_mode` (comment there); `AppHost::setSetting("tv_mode", i)` writes it on change, clears + `ui.tv_manual_on` on Off, `configureTvMode()`, then `setOn(true)` if it was on. The script verb `tvsetting` sets + `tvSetting` and calls `configureTvMode()` without writing the key (the script's `expect cfg=ui.tv_mode:absent` lines rely on + that). `settings_reset.cpp` already classifies all five `ui.tv_*` keys as Interface, and `settings_reset_test` covers them. +- **`App::handleEvent` order** (`app.cpp` ~1744): `LatchWaker::isWake` → `ImGui_ImplSDL3_ProcessEvent` → `fromExtraWindow` → + F10 step → `input.feedHoldEdges(e)` → `tvHost_->noteInput(e)` → `tvMoreSettingsHandleEvent` → `tvShell().handleEvent` (when + `ownsInput() && !input.capturing()`) → **the comment "phase 4's Auto controller trigger, TvMode::onPadPress, goes before this + call"** → `deskPadHandleEvent` → `input.handleEvent` → the `switch` (fullscreen events at `SDL_EVENT_WINDOW_ENTER/LEAVE_FULLSCREEN` + set `fullscreen` and call `fullscreenMenu.update(...)`; `SDL_EVENT_KEY_DOWN` calls `handleHotkey` unless ImGui wants the + keyboard). `App::setFullscreen(bool)` (~1093) sets the flag, updates `fullscreenMenu`, calls `SDL_SetWindowFullscreen` when + there is a window; the SDL event then arrives too (windowed), or never (headless). `main` runs `loadConfig()` before the window + exists (`fullscreen` is false then) and `setFullscreen(true)` for `--fullscreen` after it. +- **Desktop pad navigation (#1030)**, `desktop_nav_app.cpp`: `deskPadActive(app)` is false when `tvMode.active()`, the menu is + open, a capture runs, no library shows, a game runs, or a desktop window / foreign popup is open. `deskPadHandleEvent` consumes a + mapped button *down* (`navProfile(which).intentForButton(b)` not `None` and not `Menu`), the left stick and mapped triggers; + releases are fed but not consumed. `deskPadFrame` resets the nav (`d.in.reset()`, `pending.clear()`) on any edge of + `deskPadActive`, so a press that turned TV on leaves nothing queued, and its release is a "press never seen" release + (`tv::NavInput`'s per-source latch, pad-nav design §10). The TV shell's `nav_` is the same class, so the release reaching the + shell after a switch does nothing either. +- **The shell and keys** (`tv_shell.cpp` `handleEvent`): Esc at the root is not consumed; a typed character (`typedChar`: + letters, digits, Backspace) is offered to the top screen (`Screen::typed`; only `LibraryScreen` uses it) and consumed when used; + nav keys (`intentForKey`: arrows, Enter, Esc, Backspace, Tab, PageUp/Down) are consumed on down; **everything else returns + false** ("F4 and friends are the app's"). The hotkeys (`handleHotkey`, ~1671) are F1–F11, Esc, Tab, Ctrl/Cmd+letter. +- **Script runner** (`tv_script.cpp`): verbs `load, key, pad, axis, mouse (windowed only), focus, typing, capture, capture nav:, + navdefault, tvsetting, saveconfig, wait, shot, quit`; expects as the header of `tests/tv_menu_headless.txt.in` lists them, plus + `cfg=KEY:VALUE|absent`, `core-option`, `desk-pad`, `desk-pending`. `expect` ticks two frames first. The runner is `App` + member code, so `osd_` is in reach. No `fullscreen` verb exists (`fullscreen` is an expect only). Headless: no ImGui context, + no window (`window_` is null: `setFullscreen` only flips the flag), the config lives in a private temp dir, the virtual pad is a + generic Xbox-layout gamepad (A/B/X/Y, D-pad, shoulders, `misc1`, `start`, `touchpad`). +- **Toasts:** `App::toast(text, seconds = 3.0, level = 1)` pushes an `Osd {text, born, life, level}` onto `osd_` (last 6; + headless also prints `[osd] text`); `App::osd()` exposes the vector. +- **Binding text:** `remap_ui.cpp` has a file-local `tvMenuText(const std::vector<Binding>&)` ("Share (hold); Guide (hold) on + pads without Share", else `Binding::display()`); `InputManager` exposes `keyboardProfile()`, `padProfile(size_t i)` (by index, + matching `pads()`), `navProfile(joystickId)`, `lastPadId()`, `pads()` (`PadInfo {name, guid, hasMisc1, …}`), but **no per-pad + profile lookup by joystick id** (`Pad` is private; `input.cpp` ~464 reads `pad->profile.binds[C_TVMENU]` internally). + `padButtonLabel(SDL_Gamepad*, const NavProfile&, Intent)` names *nav* intents, not app actions; the hint needs the action's + binding, so it goes through `Binding::display()` ("Share (hold)", "F4"). A pad's default `C_TVMENU` list can hold several + bindings (the Share or Guide hold plus any paddles SDL reports; the virtual pad reports them all). +- **Settings UI:** `ui.cpp` `drawInterfaceTab` is `SeparatorText` sections: Overlays, (Appearance, Font via helpers), Performance, + Save states, Game library, then `drawDesktopSettings`, `drawLibraryListOptions`. `drawAppGroupReset(app, AppGroup::Interface)` + heads it. `tv_settings_model.cpp` has `tvModeRow(TvSetting)` (group 3, "Off / Manual / Auto") and `onOffRow(id, label, group, + on)`; `AppHost::settingRows` pushes `tvModeRow` right before `more`. +- **Build default:** `app/CMakeLists.txt` has no `VJ_TV_DEFAULT`; `virtualjaguar`'s compile definitions are at ~467. The app + packages are built by `.github/workflows/app-package.yml` (a `workflow_call` with `inputs.release_tag`: `vX.Y.Z` from a tag, + `nightly` from a `develop` push, `prerelease` from `release/**`, `prNNN` / `dev` from `app.yml`), with three `cmake -S app` + invocations (macOS ~193, Windows ~370, Linux ~638) and a Flatpak built by `flatpak-builder` straight from + `app/packaging/flatpak/io.github.libretro.VirtualJaguar.yml` (`config-opts`, ~80). `release.yml` only chooses the tag. +- **README:** `app/README.md` "Running" has the Ctrl/Cmd+Shift+T and F4 rows (113–114); "Controller in the desktop library" + (160) ends with "Share or Guide, held | toggle TV mode". There is no TV-mode section, no `--tv`, no keypad-chord note. + +### Phase 4 decisions (the approval list at the end repeats the ones that change the spec) + +1. **The controller trigger beats desktop pad navigation, and the switching press is consumed.** Under Auto with "when a + controller is used" on, a mapped button down (`navProfile(which).intentForButton(b) != Intent::None`, Start included) while + the desktop library owns the pad (`deskPadActive(app)`: TV off, no capture, the home library or the library window showing, + no desktop window or foreign popup) **and no game is loaded** (a paused game under the library window stays the desktop's) + and `!io.WantTextInput` turns TV mode on and **returns**: it neither moves the desktop focus nor reaches the TV shell (A would + otherwise play the Home hero game blind). Its release goes through as usual (both navs' press-seen latches ignore it). + Desktop pad navigation therefore serves Off, Manual, and Auto with the trigger unticked, exactly as the pad-nav design §2 + recorded the user's call. Buttons with no nav intent (Guide, Share, touchpad, paddles) are not triggers: they carry app + actions (`C_TVMENU` hold, keypad `#`, the keypad overlay on a Share tap, `C_HELP`), and a Share tap that also switched TV on + would be two actions on one press; sticks and triggers are not triggers either (a resting stick drifts). +2. **"Mouse movement over 32 px" is the summed path of `SDL_EVENT_MOUSE_MOTION` `xrel/yrel` since TV mode went on** (or since + the last pad press while on), as `TvMode` already implements; it is fed only while `!input.mouseCaptured()` and from the main + window (`fromExtraWindow` already filters). No new threshold. +3. **"A non-navigation key" is a printable key the TV UI did not use**: a `SDL_EVENT_KEY_DOWN` that reached `handleEvent` past + the shell (so a letter the Library's filter consumed does not count), with no Ctrl/Cmd/Alt held, not a repeat, not wanted + by ImGui (`io.WantCaptureKeyboard`: a text box, or "More settings" with its keyboard nav), and `SDL_GetKeyFromScancode` in + `0x20..0x7E`. F-keys, Esc, Tab, Enter, arrows and every Ctrl/Cmd shortcut are excluded: F4 and Ctrl/Cmd+Shift+T toggle TV + mode themselves, and "off by the key, then on again as Manual" would invert the toggle. +4. **The fullscreen trigger applies with a game running** (spec §8 as written; `TvMode` already does it). The visible effect + is deferred: `inputOwner` keeps the game, nothing is drawn over it, and when the game closes the TV Home shows instead of the + desktop library. App feeds `onFullscreen` only on an **edge** of `fullscreen` (`tvFullscreenEdge()` compares with + `tvNoteFsSeen_`), from `setFullscreen` and from the two SDL events, so a repeated enter event cannot undo a manual off. +5. **`effectiveSetting()` is removed**, not made the identity: its five call sites read `tvSetting` directly. Phase 3's + approval E ("Auto acts as Manual") ends. +6. **One App entry point for every setting write:** `App::setTvSetting(TvSetting)` (writes `ui.tv_mode` on change only, clears + `ui.tv_manual_on` on Off, reconfigures, restores on) and `App::setTvAuto(bool fullscreen, bool on)` (writes + `ui.tv_auto_fullscreen` / `ui.tv_auto_controller`, reconfigures, restores on). `AppHost::setSetting`, the Interface tab and the + `tvsetting` verb all call them (the verb keeps writing nothing: it calls `configureTvMode()` only). +7. **Build default:** `tv::defaultSetting(const char* buildDefault)` is pure (`"off" | "manual" | "auto"`, anything else and + null → Auto); `loadConfig` uses it when `!cfg.has("ui.tv_mode")`, else the stored key. CMake: `set(VJ_TV_DEFAULT "auto" CACHE + STRING ...)`, validated, passed as `VJ_TV_DEFAULT_SETTING="…"` to `virtualjaguar` only (the tests never see it). The packages + pass `-DVJ_TV_DEFAULT=manual` when `inputs.release_tag` starts with `v` or is `prerelease`; `nightly`, `prNNN` and `dev` stay + Auto. The Flatpak gets the same through one `sed` on the manifest's `config-opts` in the workflow before `flatpak-builder` + (the manifest itself stays Auto, like the CMake default). +8. **The hint** fires at the first desktop-nav press this *install* while the setting is Manual: in `deskPadHandleEvent`, on the + consumed button down, when `app.tvSetting == Manual && !cfg.getBool("ui.tv_hint_shown") && !tvHintShownThisRun_`. Text: + `"Press " + <pad binding> + " or " + <keyboard binding> + " for TV mode"`, where the pad part is the `display()` of that + pad's **first `C_TVMENU` binding, hold bindings first** ("Share (hold)" on a pad with Share, "Guide (hold)" without, a + paddle's name when only a paddle is bound; never the whole list, which would name four paddles) and the keyboard part the + keyboard profile's first `C_TVMENU` key ("F4"); with no pad binding the text is "Press F4 or Ctrl+Shift+T for TV mode" (Cmd on + macOS), with no keyboard binding either "Press Ctrl+Shift+T for TV mode". Shown with `toast(text, 6.0)`; `ui.tv_hint_shown` + is set and `saveConfig()` called before the toast. Never under Auto (the press switched instead) or Off (nothing to hint). A + Settings > Reset of Interface clears the key and the hint may show once more; that is the documented meaning of a reset. +9. **Interface tab:** a `SeparatorText("TV mode")` block after Overlays: a combo `Off / Manual / Auto` (→ `setTvSetting`), the + two checkboxes "Turn on in fullscreen" and "Turn on when a controller is used" shown only under Auto (→ `setTvAuto`), one + `ui::hint` naming the shortcuts and the build default. The TV Settings rows `tv_auto_fs` ("Auto: in fullscreen") and + `tv_auto_pad` ("Auto: when a controller is used") follow `tv_mode` in group 3 and exist only while the stored setting is Auto + (`tvAutoRows(stored, fs, pad)` in the model). +10. **Motion and repeat stay at the spec's starting values** (`kRepeatDelayMs 350`, `kRepeatMs 80`, `kTweenSeconds 0.12`, + `focusScale 1.08`, all unchanged since Phase 2): agents cannot test on a TV. Tuning on a real TV with real pads is owed + to the user and listed in the PR body as open; a follow-up PR carries whatever values come back. +11. **`--tv` under Auto** is unchanged (`setOn(true)` = Manual cause): a screenshot run stays in TV mode whatever the mouse does. +12. **Phase 3's `ui.tv_manual_on`** stays Manual-only: under Auto the triggers decide at every launch. + +--- + +### Task 16: the Auto triggers in App **Files:** -- Modify: - - `app.cpp`: - - `SDL_EVENT_WINDOW_ENTER/LEAVE_FULLSCREEN` → `tvMode.onFullscreen`; - - pad button down → `onPadPress(owner == DesktopUi && !io.WantTextInput && !input.capturing())`; - - mouse motion → `onMouseMove`; - - a key down that is neither a nav key (`intentForKey == None`) nor a modifier → `onNonNavKey`. - - `ui.cpp` `drawInterfaceTab`: a "TV mode" section with an Off/Manual/Auto combo and the two Auto - checkboxes (shown only for Auto). - - The TV Settings rows for the Auto checkboxes: ids `tv_auto_fs`, `tv_auto_pad`. - - `app/CMakeLists.txt`: `set(VJ_TV_DEFAULT "auto" CACHE STRING "...")` → - `target_compile_definitions(virtualjaguar PRIVATE VJ_TV_DEFAULT_SETTING="${VJ_TV_DEFAULT}")`. - - `.github/workflows/release.yml`: where the app is configured, add `-DVJ_TV_DEFAULT=manual` whenever - `github.ref != 'refs/heads/develop'`. -- `tv_mode.h/.cpp`: add `TvSetting defaultSetting();` (`VJ_TV_DEFAULT_SETTING`, Auto when undefined; it - replaces the `manual` config default from Task 7), and make `effectiveSetting()` the identity (Phase 2 mapped - Auto to Manual). -- Test: `tv_mode_test.cpp` (`defaultSetting()` under both definitions; compile a second tiny TU with the macro - set; flip the `effectiveSetting(Auto)` check to `== Auto`), plus `tv_menu_headless` script cases: - - fullscreen enter → TV on; - - pad press with a game running → stays off; - - a 40 px mouse move after a controller-on → off. -- [ ] **Step 1:** Write the failing cases. -- [ ] **Step 2:** Run `ctest --test-dir app/build -R "tv_mode_unit|tv_menu_headless"`. Expected: FAIL. -- [ ] **Step 3:** Implement. -- [ ] **Step 4:** Run the full suite. Expected: all PASS. -- [ ] **Step 5: Commit** `feat(app): TV mode auto-switching on fullscreen and controller use (#991)`. +- Modify `tv_mode.h/.cpp`: remove `effectiveSetting`; add `TvSetting defaultSetting(const char* buildDefault);`. +- Modify `app.h/.cpp`: `loadConfig`, `configureTvMode`, `saveConfig`, `handleEvent`, `setFullscreen`; new members. +- Modify `tv_app_host.cpp` (`tvAvailable`, `setSetting("tv_mode")` → `setTvSetting`), `ui.cpp` (the View menu enable), + `tv_script.cpp` (verb `fullscreen on|off`), `tests/tv_menu_headless.txt.in`, `tests/tv_mode_test.cpp`. +- Test: `tv_mode_unit`, `tv_menu_headless`. -### Task 17: first-use hint, tuning, PR 4 +**Interfaces:** -- [ ] **Step 1:** The first pad press in the desktop library while the setting is Manual and - `ui.tv_hint_shown` is false shows the toast "Press Share (hold) or F4 for TV mode" and sets the key. The - glyph text comes from the pad's actual `C_TVMENU` binding. Add the smoke test `hint_once`. -- [ ] **Step 2:** By hand on a real TV (Xbox, DualSense, Switch Pro, an 8BitDo; one machine where Steam takes - Guide): tune `kRepeatDelayMs`, `kRepeatMs`, `kTweenSeconds` and `focusScale` within the spec's ranges, and - record the final values and the pads tried in the PR body. -- [ ] **Step 3:** Update `app/README.md`: - - a "TV mode" section with the defaults, the bindings and the hold rule; - - the keypad-chord limitation; - - `--tv`. -- [ ] **Step 4: Commit** `feat(app): TV mode first-use hint and tuned motion (#991)`, then open PR 4 with - `Closes #991`. +```cpp +// tv_mode.h +TvSetting defaultSetting(const char* buildDefault); // "off" | "manual" | "auto" (VJ_TV_DEFAULT_SETTING); null or unknown -> Auto +// (effectiveSetting is gone) + +// app.h +void setTvSetting(tv::TvSetting s); // ui.tv_mode written on change only; Off clears ui.tv_manual_on; reconfigure; stays on if it was +void setTvAuto(bool fullscreenTrigger, bool on); // ui.tv_auto_fullscreen / ui.tv_auto_controller; reconfigure; stays on if it was +bool tvAutoPadPress(const SDL_Event& e); // decision 1: true = the press turned TV mode on and is consumed +void tvFullscreenEdge(); // decision 4: tvMode.onFullscreen(fullscreen) when it differs from tvNoteFsSeen_ +bool tvNoteFsSeen_ = false; // the fullscreen state TvMode was last told +``` + +Rules: +- `loadConfig`: `tvSetting = cfg.has("ui.tv_mode") ? parsed (unknown value → defaultSetting) : tv::defaultSetting(VJ_TV_DEFAULT_SETTING)`; + `configureTvMode()` passes `tvSetting` as is; `tvNoteFsSeen_ = fullscreen` after it. The `ui.tv_manual_on` restore and the + `saveConfig` write test `tvSetting == Manual`. +- `handleEvent`, one block where the comment sits (after the shell, before `deskPadHandleEvent`): + ```cpp + if (e.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && tvAutoPadPress(e)) return; + if (e.type == SDL_EVENT_MOUSE_MOTION && !input.mouseCaptured()) tvMode.onMouseMove(e.motion.xrel, e.motion.yrel); + if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat && !(e.key.mod & (SDL_KMOD_CTRL | SDL_KMOD_ALT | SDL_KMOD_GUI)) && + !(imguiUp_ && ImGui::GetIO().WantCaptureKeyboard)) { + const SDL_Keycode k = SDL_GetKeyFromScancode(e.key.scancode, SDL_KMOD_NONE, false); + if (k >= 0x20 && k <= 0x7E) tvMode.onNonNavKey(); // decision 3: a printable key the TV UI did not use + } + ``` + `tvAutoPadPress(e)`: `const Intent in = input.navProfile(e.gbutton.which).intentForButton(e.gbutton.button); if (in == None) return false;` + then, while TV is on, `tvMode.onPadPress(true)` (a fresh mouse budget under `Cause::Controller`) and `return false`; while off: + `if (deskPadActive(*this) && !host.isLoaded() && !(imguiUp_ && ImGui::GetIO().WantTextInput)) tvMode.onPadPress(true); return + tvMode.active();` (`TvMode` itself checks Auto and the trigger box; `deskPadActive` covers TV off, no capture, the library showing, + no desktop window). On a switch `input.armReleaseGuard()` is **not** needed: no game owns the input. +- `setFullscreen` and the two SDL fullscreen cases end with `tvFullscreenEdge();`. +- `tv_script.cpp`: verb `fullscreen on|off` → `setFullscreen(arg1 == "on")` (headless: the flag and the edge only); comment it in + the script header. + +- [ ] **Step 1: Write the failing tests.** + - `tv_mode_test.cpp`: replace the `effectiveSetting` line with + `CHECK(defaultSetting("manual") == TvSetting::Manual && defaultSetting("off") == TvSetting::Off && defaultSetting("auto") == TvSetting::Auto && defaultSetting("bogus") == TvSetting::Auto && defaultSetting(nullptr) == TvSetting::Auto);` + and add `testTriggerOrders()` with every §8 order not yet covered (assert `active()` and `cause()` after each line): + pad → fullscreen enter → fullscreen leave (off, whatever the cause); fullscreen enter → pad (stays Fullscreen cause) → mouse + 100 px (on) → leave (off); pad → manual toggle (off) → pad (on again, Controller) → key (off); fullscreen enter → toggle + (off) → pad (on, Controller: a pad edge ends the manual override) → mouse 40 px (stays on: fullscreen holds it) → leave + (off); `configure(Auto, fullscreenNow = true)` → toggle (off) → `onFullscreen(true)` repeated (on again: documents why App + feeds edges only); Off: every trigger is a no-op; Manual: every trigger is a no-op and `toggle` works. + - `tv_menu_headless.txt.in`, a new block before "A game to play with" (the setting is Manual there; shift later times): + ``` + # Auto (#991 phase 4): a mapped pad press in the desktop library turns TV mode on and is consumed (nothing queued, nothing + # launched); a printable key turns a controller-started TV mode off; fullscreen holds it; leaving fullscreen ends it. + 2760 tvsetting auto + 2760 expect tv=0 + 2760 expect desk-pad=1 + 2760 pad tap a + 2800 expect tv=1 + 2800 expect tv-screen=home + 2800 expect loaded=0 + 2800 expect desk-pending=0 + 2800 key tap H + 2840 expect tv=0 + 2840 pad tap dpdown + 2880 expect tv=1 + 2880 fullscreen on + 2900 expect fullscreen=1 + 2900 key tap H + 2940 expect tv=1 # fullscreen holds it + 2940 fullscreen off + 2980 expect tv=0 + 2980 fullscreen on + 3020 expect tv=1 # the fullscreen trigger on its own + 3020 key tap ctrl+shift+T + 3060 expect tv=0 # manual wins ... + 3060 key tap H + 3100 expect tv=0 + 3100 fullscreen off + 3100 fullscreen on + 3140 expect tv=1 # ... until the next edge + 3140 fullscreen off + 3180 expect tv=0 + 3180 pad tap touchpad # no nav intent (C_HELP's button): not a trigger. (Not misc1: a Share tap opens the keypad overlay) + 3220 expect tv=0 + 3220 pad tap start + 3260 expect tv=1 # Start is a mapped intent: it switches + 3260 key tap H + 3300 expect tv=0 + 3300 key tap ctrl+, # a desktop window takes the pad before the trigger sees it + 3340 pad tap dpdown + 3380 expect tv=0 + 3380 key tap ctrl+, + 3420 tvsetting manual + 3420 saveconfig + 3420 expect cfg=ui.tv_mode:absent + ``` + and, after the first `load` (the game runs): `tvsetting auto`, `pad tap dpdown` → `expect tv=0` (the game owns the pad), + `fullscreen on` → `expect tv=1`, `expect menu-open=0`, `expect paused=0`, `pad down dpright` → `expect core-input=1`, + `pad up dpright`, `fullscreen off` → `expect tv=0`, `tvsetting manual`. Also: the script's **first line becomes + `0 tvsetting manual`** (a fresh config now takes the build default, Auto on a CTest build), and the comment "acts as Manual + in phase 2" at line ~200 becomes "the toggle works under Auto too" (the `expect` values there are unchanged). New expect + `tv-setting=off|manual|auto` (`settingKey(tvSetting)`), used by hand in Step 3 of Task 17, not by the CTest script. +- [ ] **Step 2: Run** `~/Workspace/Provenance/vj-eval/remote/tvb.sh "tv_mode_unit|tv_menu_headless"`. Expected: FAIL (does not build: + `effectiveSetting` gone, no `fullscreen` verb). +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run** `~/Workspace/Provenance/vj-eval/remote/tvb.sh ""` (the full suite). Expected: all PASS; `tv_ui_smoke` and + `settings_reset` unchanged. +- [ ] **Step 5: Commit** `feat(app): TV mode switches on in fullscreen and on a controller press (#991)`. + +### Task 16a: layout fixes from the user's TV testing + +Closed with no work: on 2026-10-22 the owner reported that no layout fixes are needed, so PR 4 closes #991. + +### Task 17: the settings UI and the build default + +**Files:** +- Modify `ui.cpp` `drawInterfaceTab`, `tv_settings_model.h/.cpp`, `tv_app_host.cpp` (`settingRows`, `setSetting`), + `app/CMakeLists.txt`, `.github/workflows/app-package.yml` (own commit, see the constraints), `tests/tv_settings_model_test.cpp`, + `tests/tv_ui_smoke.cpp`, `tests/tv_menu_headless.txt.in`. +- Test: `tv_settings_model_unit`, `tv_ui_smoke`, `tv_menu_headless`. + +**Interfaces:** + +```cpp +// tv_settings_model.h +std::vector<SettingRow> tvAutoRows(TvSetting stored, bool autoFullscreen, bool autoController); // ids tv_auto_fs, tv_auto_pad; empty unless Auto +``` +```cmake +# app/CMakeLists.txt, next to VJ_DISCORD_APP_ID +set(VJ_TV_DEFAULT "auto" CACHE STRING "TV mode setting for a fresh config: auto (nightlies, PR builds) | manual (releases) | off") +if(NOT VJ_TV_DEFAULT MATCHES "^(auto|manual|off)$") + message(FATAL_ERROR "VJ_TV_DEFAULT must be auto, manual or off (got '${VJ_TV_DEFAULT}')") +endif() +# with the other virtualjaguar definitions (~467): VJ_TV_DEFAULT_SETTING="${VJ_TV_DEFAULT}" +``` +```yaml +# app-package.yml, env: (top level) +TV_DEFAULT: ${{ (startsWith(inputs.release_tag, 'v') || inputs.release_tag == 'prerelease') && 'manual' || 'auto' }} +# each of the three cmake -S app lines: -DVJ_TV_DEFAULT="$TV_DEFAULT" +# before the flatpak-builder step: sed -i "s|- -DVJ_APP_BUILD_TESTS=OFF|&\n - -DVJ_TV_DEFAULT=$TV_DEFAULT|" app/packaging/flatpak/io.github.libretro.VirtualJaguar.yml +``` + +Rules: +- `drawInterfaceTab`, after the Overlays section: `SeparatorText("TV mode")`; combo `##tv_mode` over `settingKey`-ordered labels + "Off / Manual / Auto" → `app.setTvSetting((TvSetting)i)` on change; `if (app.tvSetting == Auto)` two `Checkbox`es bound to + `cfg.getBool(...)` copies → `app.setTvAuto(true|false, v)`; `ui::hint("A 10-foot look for a pad and a TV. Manual: View > TV mode, + Ctrl/Cmd+Shift+T, or F4 / a Share hold with no game. Auto also turns it on in fullscreen and when a controller is used in the + library (mouse movement or typing turns it off again). Nightly builds start on Auto, releases on Manual.")`. +- `AppHost::settingRows`: after `tvModeRow`, append `tvAutoRows(app_.tvSetting, cfg.getBool("ui.tv_auto_fullscreen", true), + cfg.getBool("ui.tv_auto_controller", true))`. `setSetting("tv_auto_fs", i)` → `setTvAuto(true, i == 1)`; `"tv_auto_pad"` → + `setTvAuto(false, i == 1)`; `"tv_mode"` → `setTvSetting((TvSetting)index)` (the existing body moves into App). +- `setTvAuto` while TV is on under Auto: `configure` restarts from off, so restore with `setOn(true)` (Manual cause) as + `setSetting("tv_mode")` does; the next trigger edge takes over. + +- [ ] **Step 1: Write the failing tests.** + - `tv_settings_model_test.cpp`: `CHECK(tvAutoRows(TvSetting::Manual, true, true).empty() && tvAutoRows(TvSetting::Off, true, true).empty());` + `auto a = tvAutoRows(TvSetting::Auto, true, false); CHECK(a.size() == 2 && a[0].id == "tv_auto_fs" && a[0].index == 1 && a[1].id == "tv_auto_pad" && a[1].index == 0 && a[0].group == 3 && a[1].choices == std::vector<std::string>{"Off", "On"});` + - `tv_ui_smoke` `settingsAutoRowsStep`: fixture rows `tv_mode` (index 2) + the two auto rows; Right on "Auto: in fullscreen" → + `set.back() == {"tv_auto_fs", 1}`; Accept on it → `{"tv_auto_fs", 0}` (wrap over two choices); `drew("Auto: when a controller is used")`. + - `tv_menu_headless`, a block right after Task 16's Auto block (still before "A game to play with"; the only + `cfg=ui.tv_mode:absent` expects are earlier in the script, so the key this block writes disturbs nothing): `tvsetting auto`, + `key tap F4` → `expect tv=1`; RB ×3 → `expect tv-screen=settings`; Down ×3 (Interface), Right (the list at Show FPS), Down ×3 + (VSync, Theme, TV mode), Down (Auto: in fullscreen; the Interface list is now Show FPS, VSync, Theme, TV mode, Auto: in + fullscreen, Auto: when a controller is used, More settings...); `pad tap dpleft` → `expect cfg=ui.tv_auto_fullscreen:0`, + `expect tv=1` (still on: Manual cause after the reconfigure); `pad tap dpright` → `expect cfg=ui.tv_auto_fullscreen:1`; Up, + `pad tap dpleft` (TV mode: Auto → Manual) → `expect cfg=ui.tv_mode:manual`, `expect tv=1`; Down (the row under it is now + More settings..., the Auto rows are gone), `pad tap a` → `expect tv-more=1`, `pad tap b` → `expect tv-more=0`; `key tap F4` + → `expect tv=0`. The config holds `ui.tv_mode=manual` from here on, which is what the rest of the script assumes anyway. +- [ ] **Step 2: Run** `~/Workspace/Provenance/vj-eval/remote/tvb.sh "tv_settings_model_unit|tv_ui_smoke|tv_menu_headless"`. Expected: FAIL. +- [ ] **Step 3: Implement** the model rows, `AppHost`, the Interface block, CMake. Check by hand on the Studio that the macro + reaches the binary: a one-line script `0 expect tv-setting=auto` run with `--headless --frames 1 --tv-test-script` (a headless + run has a fresh config) passes on the default configure and `0 expect tv-setting=manual` passes after + `cmake -S app -B app/build -DVJ_TV_DEFAULT=manual`; reconfigure back to `auto` afterwards. (The CTest script itself starts with + `tvsetting manual`, so it is the same on either build: release packages run the smoke tests too.) +- [ ] **Step 4: Run** the full suite on the Studio. Expected: all PASS. +- [ ] **Step 5: Commit** `feat(app): TV mode in Settings > Interface, the Auto rows in TV Settings, VJ_TV_DEFAULT (#991)`. +- [ ] **Step 6: The workflow**, its own commit: `ci(app): release packages default TV mode to Manual, nightlies to Auto (#991)`. + Verify with `gh workflow run app-package.yml -f release_tag=v0.0.0-test` only if the user asks (it signs); otherwise rely on + the PR's own `app.yml` package job (`release_tag = prNNN` → Auto) and read the configure log for `VJ_TV_DEFAULT`. + +### Task 18: the first-use hint, README, Studio check, PR 4 + +**Files:** +- Modify `input.h/.cpp` (`tvMenuBindingText(SDL_JoystickID) const`, `tvMenuKeyText() const`), `desktop_nav_app.cpp` + (`deskPadHandleEvent` + a static `tvMaybeShowHint`), `app.h/.cpp` (`bool tvHintShownThisRun_ = false; unsigned toastCount_ = 0;`), + `tv_script.cpp` (expects `toast=TEXT`, `toast-count=N`), `app/README.md`, `tests/tv_menu_headless.txt.in`. +- Test: `tv_menu_headless`. + +**Interfaces:** + +```cpp +// input.h +std::string tvMenuBindingText(SDL_JoystickID id) const; // display() of that pad's first C_TVMENU binding, hold bindings first; "" when none / unknown pad +std::string tvMenuKeyText() const; // display() of the keyboard profile's first plain C_TVMENU key ("F4"); "" when none +// tv_mode.h (pure, so tv_mode_unit can pin the wording) +std::string firstUseHint(const std::string& padText, const std::string& keyText, bool macOS); // decision 8's wording +``` + +Rules: +- In `deskPadHandleEvent`, at the `return down;` of a consumed mapped button: `if (down) tvMaybeShowHint(app, e.gbutton.which)`, + a static in `desktop_nav_app.cpp` that applies decision 8 (`tvSetting == Manual`, key unset, not shown this run → set the key, + `saveConfig()`, `toast(tv::firstUseHint(input.tvMenuBindingText(which), input.tvMenuKeyText(), <macOS>), 6.0)`, + `tvHintShownThisRun_ = true`). +- README `## Running`: a new `### TV mode` after "Controller in the desktop library": what it is, the three settings and where + (Settings > Interface, TV Settings > Interface), the Auto rules in one paragraph (fullscreen; a controller press in the library; + 32 px of mouse or typing ends a controller-started TV mode; a manual toggle wins until the next edge), the build default + (nightlies Auto, releases Manual, `-DVJ_TV_DEFAULT`), the hint, `--tv`, the hold rule (500 ms) and the keypad-chord limitation + (from the controls screen's hint text). Keep the existing rows at 113–114 and 177 and link them here. + +- [ ] **Step 1: Write the failing tests.** `tv_mode_test.cpp`: `CHECK(firstUseHint("Share (hold)", "F4", false) == "Press Share + (hold) or F4 for TV mode" && firstUseHint("", "F4", false) == "Press F4 or Ctrl+Shift+T for TV mode" && firstUseHint("", "", true) + == "Press Cmd+Shift+T for TV mode");`. In the script's existing desktop-pad block (the first consumed press under Manual, today + `2080 pad tap rightshoulder`): before it `expect cfg=ui.tv_hint_shown:absent`, `expect toast-count=0`; after it + `expect toast=Press Share (hold) or F4 for TV mode` (the virtual pad has `misc1`, so its default is the Share hold), + `expect cfg=ui.tv_hint_shown:1`, `expect toast-count=1`; after the next press (`pad tap leftshoulder`) `expect toast-count=1`. + In the Auto block: `expect toast-count=1` after the switching press (no hint under Auto). `toast-count` counts `App::toast` + calls since start (`toastCount_`, incremented in `toast`; `osd_` is capped at 6 so it cannot be the counter); `toast` is the + last text. +- [ ] **Step 2: Run** `~/Workspace/Provenance/vj-eval/remote/tvb.sh tv_menu_headless`. Expected: FAIL. +- [ ] **Step 3: Implement**, then the README. +- [ ] **Step 4: Run** the full suite on the Studio. Expected: all PASS. +- [ ] **Step 5: Commit** `feat(app): one-time TV mode hint on the first controller press in the library (#991)` and + `docs(app): TV mode section in the README (#991)`. +- [ ] **Step 6: Windowed check on the Studio** (`vjr.sh push` / `run` / `pull`, never locally), with the windowed script: Auto, + `mouse` moves of 10 px ×4 after a pad press → `expect tv=0` (the only place the real motion path is exercised); F11 with + `expect tv=1`; the hint toast in a `shot hint.png` at 1280×720. Attach the shot. +- [ ] **Step 7: Open PR 4** into `develop`: title `feat(app): TV mode auto-switching, first-use hint and build default (TV mode + phase 4)`; body with the test summary, the shot, the approval items below as applied, the "owed to the user" list (below), and + `Closes #991` **only if Task 16a is done or the user has said the layout list is empty**; otherwise `Refs #991` and a comment on + #991 listing what remains. Bind it (`ccd_pr`), report CI, rebase after the v3.10.0 back-merge lands on develop. + +### What "complete" means for #991 + +PR 4 closes #991 when every spec section has shipped or has a recorded decision: §3 (PR 1), §4–§5 (PRs 2–3, with the approved +changes), §6 at the spec's starting values, §7, §8 and §9 (this PR), §10's CI tests, and Task 16a's layout list. It leaves +these **owed to the user**, listed in the PR body and in a final comment on #991 rather than blocking the close: +- tuning of `kRepeatDelayMs` / `kRepeatMs` / `kTweenSeconds` / `focusScale` (and whether 32 px is the right mouse budget) on a real + TV with Xbox, DualSense, Switch Pro and an 8BitDo pad, one machine where Steam or the OS takes Guide (spec §10 "by hand"); +- the §9 decision to make Auto the release default once nightly feedback is in (a one-line `app-package.yml` change); +- the layout list for Task 16a, if it arrives after PR 4 (then a follow-up PR, `Refs #991`). + +### Phase 4 review focus (added to the list above) + +17. **TV mode flapping on and off.** A repeated `SDL_EVENT_WINDOW_ENTER_FULLSCREEN` (macOS sends them on space and display + changes) must not re-enable TV mode after a manual off (Task 16 `tvFullscreenEdge`, the `testTriggerOrders` repeated-enter + case documents why); a Share hold that toggles TV mode must not also count as the controller trigger (decision 1: no nav + intent; script `pad tap misc1 → tv=0`); F4 / Ctrl+Shift+T must not first count as a "non-navigation key" (decision 3; script + `ctrl+shift+T → tv=0` while controller-started). +18. **A pad press that both switches and acts.** The switching press reaches neither the desktop library nor the TV shell + (script: `pad tap a → tv=1, loaded=0, desk-pending=0`); its release is ignored by both navs' press-seen latches (by hand on + the Studio: hold A, watch nothing launch on release). +19. **Auto turning on mid-game.** The controller trigger never fires with a game loaded (script: `pad tap dpdown → tv=0` after + `load`); the fullscreen trigger does, and must leave the game running, unpaused, with its input (script: `menu-open=0, + paused=0, core-input=0`… then a held D-pad reads `core-input=1`). +20. **A stored setting pinning the default.** `ui.tv_mode` is written only by `setTvSetting` on change (script: + `expect cfg=ui.tv_mode:absent` after `tvsetting` + `saveconfig`; `expect tv-setting=auto` first line proves the CMake default + reached the binary); a config from Phase 2/3 with no key picks up the nightly's Auto; Settings > Reset Interface returns to + the build default (`settings_reset_test`, already green). +21. **The hint showing twice, or under Auto.** The key is set and saved before the toast, a per-run flag backs it, and Auto's + switching press takes the `return` before the hint site (script `toast-count` lines in both blocks). + +### Approval needed before Phase 4 starts (spec changes for phase 4) + +The rows already in "Changes to the approved spec" that this phase uses: `C_TVMENU` toggles TV mode with no game (the hint's +wording relies on it); the build default through `VJ_TV_DEFAULT`, now in `app-package.yml` rather than `release.yml` (the +packages moved there after the plan was written; the rule is the same: tags and `prerelease` Manual, everything else Auto). + +New in this revision: +- **A.** The pad press that turns TV mode on under Auto is **consumed**: it does not also act in the desktop library or on the + TV screen it reveals. Spec §8 says the press "turns TV mode on" and no more; the pad-nav design §2 recorded that the trigger + wins, this fixes that the press does nothing else. Desktop pad navigation is therefore unreachable under Auto with the + controller trigger on (it serves Off, Manual, and Auto with that box unticked). +- **B.** Only buttons with a navigation intent in the pad's profile (D-pad, the four face buttons, LB/RB, Start) are the + controller trigger. Guide, Share, touchpad, paddles, the sticks and the triggers are not (they carry app actions or drift). + Spec §8 says "a pad button". +- **C.** "A non-navigation key" (§8) is read as a printable key the TV UI did not use, with no Ctrl/Cmd/Alt held. F-keys, + Esc, Tab, Enter and shortcuts never end TV mode by themselves (F4 and Ctrl/Cmd+Shift+T are toggles and would otherwise + invert). +- **D.** The fullscreen trigger stays live with a game running (spec §8 as written, confirmed rather than changed): the only + visible effect is which home shows when the game closes. Flagged because the plan's old Task 16 did not test it. +- **E.** The one-time hint's wording is "Press <this pad's TV-menu binding> or <the keyboard's> for TV mode" ("Press Share + (hold) or F4 for TV mode" on a pad with Share, "Guide (hold)" without, the paddle's name when only a paddle is bound), shown + as a 6 s toast at the first pad press in the desktop library, Manual only, once per install (a Settings > Reset of Interface + can show it once more). Spec §9 fixes the text to "Press Share (hold) or F4". +- **F.** No motion or repeat tuning ships in PR 4; the spec's starting values stay and the real-TV pass is owed to the user + (spec §9 step 4 bundles "tuning … on a real TV" into this delivery step). +- **G.** PR 4 may close #991 with Task 16a's layout list still outstanding only if the user says so; otherwise it stays + `Refs #991` and the layout PR closes it. + +--- + +## Phase 5 — TV mode owns Settings and Library, the full settings catalogue (more commits on PR 4) + +Written 2026-10-22 from the owner's test of PR 4 ("once you open a game, the tv mode isn't the mode for settings or library +until you close the game"; "you can't even see the game core settings in tv mode"; "finish the settings at least in tv mode"). +Same branch and worktree as Phase 4 (`feat/991-tv-phase4`, `~/Workspace/Provenance/vj-wt/tv-phase4`); the work lands as more +commits on PR #1067. Build and test on the Studio only: `~/Workspace/Provenance/vj-eval/remote/tvb.sh "<ctest regex>"`. + +### Task 18b (done separately, do not redo): Settings entry points + +`App::openSettings()` (`app.h:291`, `app.cpp:1429`) is what Ctrl/Cmd+, (`app.cpp:1788`), the Settings menu (`ui.cpp:671`), +the native `Cmd::Settings` (`platform/commands.cpp:306` → `desktop.cpp:127`), the BIOS openers (`ui.cpp:602`, `desktop.cpp:84`, +`library_ui.cpp:1102`) and `--show-window settings` call. While TV mode is on it calls `Shell::openSettings()` +(`tv_shell.cpp:67`: the Settings section with no game; with a game `openMenu()` + the Settings screen in the slot, so the hardcore +pause gate and its toast apply); otherwise the desktop window. New script expect `menu-slot`. Phase 5 builds on it. + +### What Phase 4 left (read before any task) + +- **Core options reach the app only through `CoreHost::startSession`** (`core_host.cpp:794` `retro_set_environment(envCb)` → + `libretro.c:1521` `libretro_set_core_options`), which `loadContent` / `loadNoContent` run for the first game. At startup `defs_` + is empty: `ui.cpp:126` prints "The core's options appear here once a game is loaded", `AppHost::settingRows` + (`tv_app_host.cpp:238`) hides the three curated core rows and shows `speedNoteRow()`. `captureOptions` (`core_host.cpp:196`) + reads a `retro_core_options_v2` into `OptionCategory` / `OptionDef` (`emu_snapshot.h:26-41`: `desc`, `descCategorized`, + `categoryKey`, `values[]` with labels), applies `optionLock` (`app.cpp:534`, `lockedCoreOptionFor`) and `optSerial_++`. The + table has 10 categories and 118 options (`libretro_core_options.h`: video, bios_boot, cdrom, network, input, input_p1, + input_p2, diagnostics, speed, accuracy); `update_option_visibility` (`libretro.c:1522`, installed through + `SET_CORE_OPTIONS_UPDATE_DISPLAY_CALLBACK`, `core_host.cpp:522`) hides gated rows by `SET_CORE_OPTIONS_DISPLAY`. +- **Core options are readable without a game.** `retro_set_environment` (`libretro.c:1502-1535`) only stores the callback, asks + for the log interface, pushes the options and the display callback, and asks for VFS and the microphone; libretro allows it + before `retro_init`, and `startSession` repeats it for every game anyway (`stopSession` then `retro_set_environment` again). + So one call at startup, with `CoreHost::env` answering only the option commands (`GET_CORE_OPTIONS_VERSION`, + `SET_CORE_OPTIONS_V2[_INTL]`, `SET_CORE_OPTIONS_UPDATE_DISPLAY_CALLBACK`, `SET_CORE_OPTIONS_DISPLAY`, `GET_VARIABLE`) and + refusing the rest, fills `defs_` / `categories_` / `visibility_` exactly as a load does. It must run on the UI thread before + `startEmuThread()` (`app.cpp:2135`; the thread publishes the table at once, `core_host.cpp:1213`) and after + `loadUserOptions` (`app.cpp:189`) and `wireHost()` (the lock). A write with no game is `setOptionValue` → `userValues_` → + `onOptionChanged` → `opt.<key>` in the config (`app.cpp:544`), read by the core at the next load through `GET_VARIABLE`. + Rejected alternative: including `libretro_core_options.h` in a C++ TU (it has `extern "C"` guards, lines 60 and 2103) — a + second copy of the table that bypasses `captureOptions`' lock and visibility rules. +- **Library entry points that open the desktop window today:** Ctrl/Cmd+L `app.cpp:1792` → `App::toggleLibrary` `app.cpp:1137` + (`library.show`, or the home search box); File > "Game library..." `ui.cpp:585`; native `Cmd::Library` `platform/commands.cpp:275` + → `desktop.cpp:88`; `--show-window library` `app.cpp:2188` (dev flag, stays). The native checkmark reads `s.libraryOpen` + (`desktop.cpp:35`). The script documents the current behaviour at `tv_menu_headless.txt.in:100-121` ("Ctrl+L opens the + desktop library WINDOW" with TV on): Phase 5 flips that block. `App::openContent` (`app.cpp:1160`) already closes the menu + (`:1166`) and auto-saves the running game (`:1171`), so a launch from the in-game Library keeps the running game's state. +- **The in-game menu slot** (`Shell::menuPushScreen`, `tv_shell.cpp:332`) hands the slot screen the menu's list area only + (`tv_ingame.cpp:89-94`: a 1500u panel minus a 470u rail); `Shell::intent` (`:349-352`) forwards every intent but Back, and + `typed()` goes to the Base stack's top only (`tv_shell.cpp:129`), never to the slot. `LibraryScreen` launches with + `host_.launch(*g)` + `shell_.launched()` (`tv_library.cpp:342`), `launchState` (`:359`), pushes `DetailsScreen` on Start (`:350`) + and `LettersScreen` on Y (`:482`) onto the Base stack — none of which can show while a game runs. +- **`NavInput` repeats held directions** (`tv_nav.h:30`, 350 ms then 80 ms, Left and Right included), so a held Right on a + settings row already steps every 80 ms; each step is a `setSetting` + `saveConfig()`. +- **Settings groups are the four ints of `tv_settings_model.h`** (`kSettingGroups`, `settingGroupLabel`, `SettingRow::group`); + the rail is not scrollable (`tv_settings.cpp:88-96` draws every group at `kItemH + kItemGap`). +- **App settings that are a choice, toggle or slider in the desktop tabs** (the catalogue Task 19 lists; everything else — folders, + BIOS files, the font file, RA sign-in, remapping, the per-device axis sliders `devices_ui.cpp:323`, the theme editor + `theme_ui.cpp:161`, the Discord app id, netplay host/port, cheats — stays desktop-only): Video `ui.cpp:184-196` (integer, smooth, + stretch, aspect, vsync, fullscreen), shader `shaders_ui.cpp:189`; Audio `ui.cpp:204-206` (volume, mute, buffer target); + Input `devices_ui.cpp:335-347` (stick dead zone, mouse drives, light gun aims with), `:362` (controls overlay at start); + Storage `ui.cpp:244` (flush saves); Interface `ui.cpp:285` (FPS), `:292` (emulation thread), `:304` (resume mode), `:315` (home + library), `desktop.cpp:201-230` (tray icon, in-window menu bar, auto-hide menu in fullscreen, windows outside, keep awake), + `theme_ui.cpp:111` (theme), `library_ui.cpp:237` (show as), `:284` (tile size), `:299` (download box art), `:312` (close on + launch); Achievements `achievements_ui.cpp:345` (download pictures); Discord `ui.cpp:332-337`; Neuro `neuro_app.cpp:178-197`. + +### Phase 5 decisions (lead's calls; told to the owner, no approval round) + +1. **While TV mode is on, Settings and Library are TV screens, never desktop windows** — with or without a game. Every opener + goes through `App::openSettings()` (Task 18b) or the new `App::openLibrary()`. The only desktop window TV mode still opens is + the one behind the single "Desktop settings…" row (folders, BIOS files, sign-in, remapping, text entry), which keeps Phase 3's + hand-off (`tv_more_settings.h`). +2. **Core options are shown always**, read at startup by `CoreHost::probeOptions()` (the environment probe above). Phase 3 + approval G (hidden until a game has run) and `speedNoteRow()` go. The three curated core rows (`resolution`, `risc_clock`, + `idle_skip`) go too: they are ordinary rows of the core's own Video and Speed categories. +3. **Groups are the core's categories plus the app's tabs**, in this rail order: Picture, Sound, Interface, Library, Achievements, + Discord, Neuro (build-gated), then the core's categories in definition order (`General` for options with no category, as + `drawCoreOptions` does). Groups with no visible row are absent. `SettingRow::group` becomes a string key; the rail scrolls. +4. **Every visible core option is a row**: label `descCategorized` (else `desc`), value `label` (else `value`), `info` is not shown + (no room; the desktop tooltip stays there). Hidden: `!optionVisible(key)` (covers the app's locks). Read-only with the note + "Set by Netplay or --option for this session": `optionOverridden(key)`. Typed keys (`typed_options.h`): the presets plus a + selected "<value> (custom)" when the current value is not a preset (Phase 3's rule, now for every typed key); `OptKind::Host` + (`virtualjaguar_netlink_host`) is read-only with the note "Type it in Desktop settings". Options whose `info` says "restart" + carry the note "Applies when the game next starts". No "reset" and no per-group reset in TV. Writes stay + `CoreHost::setOptionValue(key, def->values[i].value)` — the call `drawOption` makes, so `Achievements::checkSettings` and the + netplay / `--option` rules are unchanged. +5. **Sliders become stepped lists**: volume 0–100 in 10 %, audio buffer 20–200 ms in 10 ms, stick dead zone 0–40 % in 5 %, tile + size `kTileScaleMin..kTileScaleMax` in 0.1, Neuro pause 0–10 s in 0.5. The desktop slider keeps its finer value: a current value + off the grid shows as "<value> (custom)" and is selected; stepping lands on the grid. +6. **Long lists on a pad**: Up/Down clamp (no wrap) in both the rail and the list, as Phase 3; Left/Right clamp, Accept wraps; the + held-direction repeat of `NavInput` is the "many steps" path; the focus is remembered per group under + `settings/<groupKey>` (index + row id) and the rail under `settings/rail` (group key); the rail and the list both scroll to + show the focus with the Phase 3 tween; LT/RT (PagePrev/PageNext) jump the list a page. Each repeated step still saves the + config (12.5 Hz while held; `config.ini` is small — measured on the Studio in Task 20, step 4). +7. **The in-game menu gains a "Library" row** after Load state (root order: Resume, Save state, Load state, Library, + Achievements, Overlays, Settings, Controls, Enter/Exit TV mode, Reset, Close game). It puts `LibraryScreen` in the slot; + a slot screen may ask for the whole panel (`Screen::wide()`), which the Library does, so the rail + cover grid draw unchanged + over the dimmed game. In the slot: A = launch (with the confirm below), LT/RT and typing work; Start (Details) and Y (letter + picker) do nothing (they push onto the Base stack, which does not show under a game); X (resume a state) asks the same confirm. +8. **Launching from the in-game Library asks first**: a page `ConfirmLaunch` ("Close <running title> and start <title>?", rows + Yes / No, focus on No, as Reset and Close do). Yes → `host.launch(g)` / `launchState(g, st)` — `App::openContent`, which + auto-saves the running game and closes the menu — then `launched()`. The same confirm is used by `Shell::requestLaunch` + whenever a game is loaded; with no game it launches at once (Home, Library, Details, Achievements are unchanged). +9. **`App::openLibrary()`** replaces every call of `toggleLibrary()` that is a user action: TV on and no game → the Library + section; TV on and a game → the menu (through `openMenu()`, hardcore gate and toast as Settings) with the Library in the slot; + TV off → `toggleLibrary()` as today. `desktop.cpp:35` `libraryOpen` is also true while the TV Library section or slot shows. +10. **No `app.cpp` edits beyond**: `probeOptions()` in `run()` before `startEmuThread()`, `openLibrary()`, the Ctrl/Cmd+L line. + No `tv/` file calls `emu.call` / `emu.post`; the probe is a `CoreHost` member called from `App`. + +### Task 19: core options at startup, and the settings catalogue (model) + +**Files:** +- Modify `core_host.h/.cpp`: `void probeOptions();` (UI thread, before the emulation thread exists) and `bool probing_ = false;` + checked at the top of `env()`: while set, only the option commands listed above are handled, everything else returns false. + After `retro_set_environment(envCb)` call `updateDisplayCb_()` once (the core's `update_option_visibility` reads its gates + through `GET_VARIABLE` and hides rows through `SET_CORE_OPTIONS_DISPLAY`, both allowed), then `probing_ = false`. + `startSession` is unchanged (it clears and re-pushes). `probeOptions` is a no-op when `initialized_`. +- Modify `app.cpp` `run()`: `host.probeOptions();` after `netplayInit` and before the `runHeadless / runTvScript / runInteractive` + line (the thread starts inside `runInteractive`, `app.cpp:2135`; headless runs stay inline). +- Modify `tv_host.h` `SettingRow`: `int group` → `std::string group` (key) + `std::string groupLabel`; `tv_settings_model.h/.cpp`: + drop `kSettingGroups`, `settingGroupLabel`, `speedNoteRow`; add the builders below; `tv_app_host.h/.cpp`: the catalogue. +- Modify `ui.cpp` `drawCoreOptions`: drop the "appear here once a game is loaded" branch (the table is there); keep the rest. +- Test: `tv_settings_model_unit`, new `vj_core_options_probe_test` (`app/tests/core_options_probe_test.cpp`, links `vj_core` + and `core_host.cpp` as `core_host_thread_test` does), `tv_ui_smoke`. + +**Interfaces:** + +```cpp +// tv_settings_model.h (pure) +struct SettingGroup { std::string key, label; }; +// Every visible core option as a row, in category order: uncategorised first under {"core:", "General"}, then each category +// under {"core:" + cat.key, cat.desc}. id = "core:" + key. Rules of decision 4. `visible`, `locked`, `overridden`, `current` +// are per key (std::function), so the model never sees CoreHost. +std::vector<SettingRow> coreOptionRows(const std::vector<OptionCategory>& cats, const std::vector<OptionDef>& defs, + const std::function<std::string(const OptionDef&)>& current, + const std::function<bool(const std::string&)>& visible, + const std::function<bool(const std::string&)>& overridden, + const std::function<const TypedOption*(const std::string&)>& typed); +SettingRow choiceRow(const std::string& id, const std::string& label, const std::string& group, const std::string& groupLabel, + std::vector<std::string> choices, int index); +// A slider as a stepped list: "lo", "lo+step", ... "hi" printed with fmt ("%.0f ms"); a value off the grid is appended as +// "<value> (custom)" and selected. stepValue() maps an index back (the "(custom)" index gives `current` unchanged). +SettingRow sliderRow(const std::string& id, const std::string& label, const std::string& group, const std::string& groupLabel, + float current, float lo, float hi, float step, const char* fmt); +float stepValue(const SettingRow& r, int index, float lo, float step, float current); +std::vector<SettingGroup> groupsOf(const std::vector<SettingRow>& rows); // first-seen order, keys unique +// Existing builders keep their names; their `int group` parameter becomes (key, label); onOffRow(id, label, group, groupLabel, on). +``` + +Catalogue (`AppHost::settingRows`, in this order; ids in parentheses; every setter is the desktop control's member or method +followed by `app.saveConfig()`, as Phase 3 decision 9): +- **Picture** `picture`: shader (`shader`), integer (`integer`), smooth (`smooth`: `present.linearFilter`), stretch (`stretch`: + `present.stretch`), aspect (`aspect`), vsync (`vsync`, moved here from Interface), fullscreen (`fullscreen`: `setFullscreen`). +- **Sound** `sound`: volume (`volume`), mute (`mute`: `app.mute` + `applyAudioSettings`), buffer (`audio_buffer`: `audioTargetMs`, + 20–200 by 10, `"%.0f ms"` + `applyAudioSettings`). +- **Interface** `interface`: fps, controls overlay at start (`controls_at_start`: what `devices_ui.cpp:362` writes), theme, + tv_mode, tv_auto_fs, tv_auto_pad, in-window menu bar (`menu_in_window`: `showInWindowMenu`; absent where `desktop.cpp` hides the + box), auto-hide menu in fullscreen (`menu_autohide`), windows outside (`windows_outside`), keep awake (`keep_awake`), tray icon + (`tray`, platform-gated as `desktop.cpp:210`), emulation thread (`emu_thread`: `emuThreadPref`; note "Restart the app to apply" + while `emuThreadWanted() != emuThreadThisRun()`), stick dead zone (`dead_zone`: 0–40 by 5, `"%.0f %%"`), mouse drives + (`mouse_port`), light gun aims with (`gun_source`), flush saves (`flush_saves`: `autosaveSram`). +- **Library** `library`: resume mode (`resume`: `lib::resumeModeLabel` × `kResumeModeCount`), home library (`home_library`), show + as (`show_as`: `lib::showAsLabel`), tile size (`tile_size`: `cellScale`, `kTileScaleMin..Max` by 0.1, `"%.1fx"`), download box + art (`download_art`: `setDownloadArt`), close on launch (`close_on_launch`: what `library_ui.cpp:312` writes). +- **Achievements** `achievements`: download pictures (`ra_images`: `ach.setImagesWanted`); row absent when `!achHeader().available`. +- **Discord** `discord`: `discord_enabled`, `discord_art`, `discord_buttons` (`cfg.setBool` as `ui.cpp:332-337`). +- **Neuro** `neuro` (`VJ_HAVE_NEURO`): `neuro_enabled`, `neuro_auto_force`, `neuro_interval` (0–10 by 0.5, `"%.1f s"`). +- **The core's groups**: `coreOptionRows(host.optionCategories(), host.optionDefs(), …)`. +- Last row of Interface: `moreRow()` relabelled **"Desktop settings…"** (id stays `more`). +`setSetting("core:<key>", i)`: `defFor(key)`; not overridden, `i` within `def->values` (never the "(custom)" index) → +`host.setOptionValue(key, def->values[i].value)`. + +- [ ] **Step 1: Write the failing tests.** + - `core_options_probe_test.cpp`: `CoreHost h; h.probeOptions(); CHECK(h.optionDefs().size() >= 100 && h.optionCategories().size() == 10);` + a def for `virtualjaguar_internal_resolution` exists with `categoryKey == "video"`; `h.optionVisible("virtualjaguar_netlink_host")` + is false (netlink off by default hides its host; read the gate in `libretro.c update_option_visibility` when writing the line + and pick a key it hides); `h.setOptionValue("virtualjaguar_internal_resolution", "2x")` with no game → + `h.optionValueFor(key, "") == "2x"` and `onOptionChanged` was called with (`key`, `"2x"`); `probeOptions()` twice is one push + (`optionPushes()` unchanged); `h.isLoaded()` stays false. + - `tv_settings_model_test.cpp`: `coreOptionRows` with two categories and three defs (one uncategorised, one typed with a custom + current, one hidden) → 2 rows in order General, then the category; `ids == {"core:a", "core:b"}`; the typed row's last choice + is `"137% (custom)"` and selected; the hidden one absent; an overridden one `readOnly` with the note; a `Host`-kind typed key + read-only with "Type it in Desktop settings"; a def whose `info` contains "restart" carries the restart note. + `sliderRow("audio_buffer", …, 80, 20, 200, 10, "%.0f ms")` → 19 choices, `index == 6`, `choices[0] == "20 ms"`; + `sliderRow(…, 85, …)` → 20 choices, last `"85 (custom)"` selected; `stepValue(r, 7, 20, 10, 85) == 90`; `stepValue(r, 19, …, 85) == 85`. + `groupsOf` keeps first-seen order and drops duplicates. + - `tv_ui_smoke` fixtures: `mkRow` takes a group key + label; the Phase 3 tests that walked `Down ×3` to Interface are rewritten + against the new fixture (`settingsFixture()`: picture, sound, interface, "core:video", "core:speed"). +- [ ] **Step 2: Run** `~/Workspace/Provenance/vj-eval/remote/tvb.sh "core_options_probe|tv_settings_model_unit|tv_ui_smoke"`. Expected: FAIL. +- [ ] **Step 3: Implement** the probe, the model, the catalogue. Register `vj_core_options_probe_test` in `VJTvTests.cmake` the + way `core_host_thread_test` is registered. +- [ ] **Step 4: Run** the full suite on the Studio. Expected: all PASS (`settings_reset_test` and `core_host_test` unchanged). +- [ ] **Step 5: Commit** `feat(app): core options readable at startup; TV Settings catalogue of every core option and app choice (#991)`. + +### Task 20: the TV Settings screen for long grouped lists + +**Files:** +- Modify `tv_settings.cpp` (`SettingsScreen`), `tv_widgets.h/.cpp` only if a helper is missing, `tests/tv_ui_smoke.cpp`, + `tests/tv_menu_headless.txt.in`, `tv_script.cpp` (expect `tv-group=KEY`: the focused group's key). +- Test: `tv_ui_smoke`, `tv_menu_headless`. + +Rules: +- `refresh()` builds `groups_` with `groupsOf(all)`; the rail is a `LinearFocus` over them with its own `Tween` scroll and + `moreArrow`s, clipped to the content height (the list's pattern at `tv_settings.cpp:110-150`); the focused group's item is kept + in view. Memory: `settings/rail` → `putItem(groupKey)`; `settings/<groupKey>` → index + row id; `settings/list` as before. +- The list: as today, plus `PagePrev` / `PageNext` = `list_.move(∓visibleRows)`; the caption line is "<group> · N settings". +- Left on the first choice of a row still returns to the rail (Phase 3); a read-only row ignores Left/Right/Accept and shows its note. +- In the slot (embedded): the groups are the first level (as Phase 3) and that list scrolls the same way (17+ rows). +- The "Desktop settings…" row hint is glyph(Accept) "Open". + +- [ ] **Step 1: Write the failing tests.** + - `settings_groups_in_row_order`: fixture groups picture, sound, interface, core:video, core:speed → the rail labels in that + order; a fixture with no `sound` row → `!drew("Sound")`. + - `settings_rail_scrolls_720p`: 20 fixture groups at 1280×720, Down ×19 → `focusLabel()` is the last group and its item is + drawn inside the content area (track the rail items' y in the test through `g_texts`). + - `settings_held_right_repeats`: a `core:x` row with 30 choices; press Right and hold through `frames(host, shell, 90)` (≈1.5 s + at the smoke clock) → `host.set.size() >= 10` and the last index is monotone; release → no further sets. + - `settings_page_jump`: 40 rows, RT → the focus moved by the visible count, LT back to 0. + - `settings_focus_memory_per_group_key`: row 3 of `core:video`, Up to the rail, Down to `core:speed`, Right, Back, Up, Right → + row 3 of `core:video` again. + - `settings_custom_value_row`: a slider row with "(custom)" selected: Right steps to the next grid value (`set.back().second` + = the grid index), Left to the previous; Accept wraps to 0. + - `settings_embedded_groups_scroll`: in the menu slot with 20 groups, Down ×19 keeps the focused group row inside the list area. + - `tv_menu_headless`: in the existing Settings block (no game), after `expect tv-screen=settings`: `expect tv-group=picture`; + Down ×7 → `expect tv-group=core:video` (Picture, Sound, Interface, Library, Achievements, Discord, [Neuro], General?, Video — + count the groups the headless build has when writing the line: Neuro is build-gated and General exists only if an option has + no category); Right; `expect tv-focus=Internal resolution` after the Downs that reach it; `pad tap dpright` → + `expect core-option=virtualjaguar_internal_resolution:2x` **with no game loaded** (decision 2); `saveconfig` → + `expect cfg=opt.virtualjaguar_internal_resolution:2x`; `pad tap dpleft` to restore. Shift the later blocks' times. +- [ ] **Step 2: Run** `~/Workspace/Provenance/vj-eval/remote/tvb.sh "tv_ui_smoke|tv_menu_headless"`. Expected: FAIL. +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run** the full suite on the Studio; then by hand on the Studio (`vjr.sh run`, windowed) hold Right on the RISC + overclock row for 3 s and read the frame time in the FPS overlay: no visible hitch from the per-step `saveConfig()`. If there + is one, coalesce: `AppHost::setSetting` sets `saveDue_ = now + 0.5 s` and `AppHost` saves at the first frame past it. +- [ ] **Step 5: Commit** `feat(app): TV Settings scrolls its rail, pages its list, and shows every group (#991)`. + +### Task 21: Library in TV mode (every entry point, the in-game row, the launch confirm) + +**Files:** +- Modify `app.h/.cpp` (`void openLibrary();`, the Ctrl/Cmd+L line `app.cpp:1792`), `ui.cpp:585`, `desktop.cpp:35` and `:88`, + `tv_shell.h/.cpp` (`openLibrary()`, `Row::Library`, `Page::ConfirmLaunch`, `requestLaunch`, `typed()` to the slot, `wide()`), + `tv_screen.h` (`virtual bool wide() const { return false; }`), `tv_ingame.cpp` (a wide slot takes the panel: no rail, the + hint bar still the shell's), `tv_library.cpp` (slot rules of decision 7, `shell_.requestLaunch`), `tv_home.cpp`, + `tv_details.cpp`, `tv_achievements.cpp` (`requestLaunch` instead of `host_.launch` — a no-op change with no game), + `tests/tv_ui_smoke.cpp`, `tests/tv_menu_headless.txt.in`, `tv_script.cpp` (expect `menu-page=root|save|load|confirm-reset|confirm-close|confirm-launch`). +- Test: `tv_ui_smoke`, `tv_menu_headless`. + +**Interfaces:** + +```cpp +// app.h +void openLibrary(); // Ctrl/Cmd+L, File > Game library, Cmd::Library: the TV Library while TV mode is on (the in-game menu with a game), else toggleLibrary() +// tv_shell.h +bool openLibrary(); // as openSettings(), with makeLibraryScreen in the slot +void requestLaunch(const GameRef& g, const StateRef* state); // no game: launch now; a game running: Page::ConfirmLaunch first +``` + +Rules: +- `Shell::requestLaunch`: `!host_.gameLoaded()` → `launch` / `launchState` + `launched()`. Otherwise `pendingLaunch_ = {g, state}` + and `enter(Page::ConfirmLaunch, Row::No)` with `title_ = "Close " + host_.gameTitle() + " and start " + g.title + "?"`; `Yes` → + the launch (the app closes the menu and arms the guard) and `launched()`; `No` → back to the Library in the slot, focus kept + (the slot screen is retired by `enter`, so `requestLaunch` keeps it aside and `No` puts it back: `menuPushScreen(std::move(kept), Row::Library)`). +- `Shell::handleEvent` key path: `typed(c)` goes to `menuScreen_` when set, else the Base top. +- `LibraryScreen` when `embedded()`: `Intent::Menu` and `Intent::Aux` ignored; `hints()` drop "Details" and "Jump to letter"; + Accept → `shell_.requestLaunch(*g, nullptr)`; X → `requestLaunch(*g, &st[0])`; `wide()` true. +- `drawMenu` with a wide slot: `DrawCtx::pos/size` = the panel inset (`pp + inset`, `ps − 2·inset`), the rail is not drawn, the + hint bar is the screen's hints + Back "Menu". +- `App::openLibrary()`: `if (tvMode.active()) tvShell().openLibrary(); else toggleLibrary();` — a refused `openMenu()` toasts + (hardcore) and nothing opens. `ui.cpp:585` and `desktop.cpp:88` call it; the menu checkmark at `ui.cpp:585` is + `(library.show && !homeVisible()) || (tvMode.active() && tvShell().showingLibrary())` where `showingLibrary()` is the + Library section on the Base layer or `menuScreenId() == "library"`. + +- [ ] **Step 1: Write the failing tests.** + - `ingame_rows_full_order`: now `{"Resume", "Save state", "Load state", "Library", "Achievements", "Overlays", "Settings", + "Controls", "Enter TV mode", "Reset", "Close game"}`. + - `ingame_library_slot_wide`: `loaded = true`, open the menu, Down ×3, Accept → `menuScreenId() == "library"`, a tile title is + drawn, and the menu's rail text (the game title at `tv_ingame.cpp:86`) is not. + - `ingame_library_launch_asks`: Accept on a tile → `menuPage() == Page::ConfirmLaunch`, `focusedLabel(shell) == "No"`, + `launched.empty()`; Accept (No) → `menuScreenId() == "library"` and the same tile focused; Accept, Up, Accept (Yes) → + `launched == {tile.key}` and `guardArmed == 1` (the fake's `launch` sets `loaded = false` as the app's close does). + - `ingame_library_resume_state_asks`: X on a tile with a state → the confirm; Yes → `launchedState == {key, slot}`. + - `ingame_library_typed_filters`: `keyEvent` 'd' → the grid shows only titles with a 'd' initial (the Base Library's own test + pattern); `Intent::Aux` and `Intent::Menu` leave `screenId()`/`menuScreenId()` unchanged. + - `ingame_library_back_returns_to_row`: Back → the slot is gone and `focusedLabel(shell) == "Library"`. + - `base_library_opens_from_app`: `tvOn = true`, no game: `shell.openLibrary()` → `screenId() == "library"`; again → unchanged. + - `tv_menu_headless`: flip the Ctrl+L block (`:100-121`): with TV on, `key tap ctrl+L` → `expect library-window=0`, + `expect tv-screen=library`, `expect tv-owns-input=1`; again → unchanged; `pad tap b` → `tv-screen=home`. With TV off the block + keeps its first lines (the home search box). In the game block: `key tap ctrl+L` → `expect menu-open=1`, `expect menu-slot=library`, + `expect paused=1`; `pad tap a` → `expect menu-page=confirm-launch`, `expect tv-focus=No`, `expect loaded=1`; `pad tap b` → + `expect menu-slot=library`; `pad tap b` → `expect menu-slot=none`; `pad tap b` → `expect menu-open=0`. Hardcore: not scriptable + headless (no RA mock in this script); covered by `openMenu()`'s existing refusal test. +- [ ] **Step 2: Run** `~/Workspace/Provenance/vj-eval/remote/tvb.sh "tv_ui_smoke|tv_menu_headless"`. Expected: FAIL. +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run** the full suite on the Studio. Expected: all PASS. +- [ ] **Step 5: Commit** `feat(app): Library is a TV screen while TV mode is on; in-game Library row with a launch confirm (#991)`. + +### Task 22: Studio screenshots, README, the PR + +- [ ] Extend `~/Workspace/Provenance/vj-eval/tvshots/tv-shots.txt`: Settings → `shot settings-rail.png`; Down to `core:video`, + Right → `shot settings-core-video.png`; Down to `core:speed`, Right, Down to the last row → `shot settings-core-speed-end.png` + (the list scrolled); `load <rom>`, `key tap ctrl+L` → `shot menu-library.png`; `pad tap a` → `shot confirm-launch.png`; + `pad tap b` ×3, `key tap ctrl+,` → `shot menu-settings-core.png`; `quit`. 1280×720 and 1920×1080, never locally. +- [ ] README `### TV mode`: one paragraph — Settings and Library are TV screens whenever TV mode is on (Ctrl/Cmd+, and Ctrl/Cmd+L + included), every core option is there by the core's categories, the "Desktop settings…" row is for folders, BIOS files, + sign-in and remapping. +- [ ] Commit `docs(app): TV mode screenshots and README for the full TV Settings and Library (#991)`; update the PR #1067 body + (the Phase 5 decisions as applied, the shots); report CI. + +### Phase 5 review focus (added to the list above) + +22. **No path still opens a desktop Settings or Library window while TV mode is on**: `grep -n 'showSettings = true\|library.show = \|toggleLibrary()' app/src` + must list only `App::openSettings` / `openLibrary`, `--show-window`, and `tvMoreSettingsOpen` (Task 21 script lines; by hand + on the Studio: the native File and Settings menus with TV on, with and without a game). +23. **A hardcore-disallowed core option changed from TV Settings** (`Achievements::checkSettings`) behaves exactly as from the + desktop, with and without a game (Task 19 `setOptionValue` path unchanged; by hand with `--ra-mock app/tests/ra`, hardcore on, + change the RISC overclock from the TV Settings: the same refusal / downgrade as the desktop combo). +24. **A core option changed mid-game reaches the core** after the menu closes (Phase 3 review focus 14, re-run against the new + row location in the script) and **one changed with no game reaches the next game** (Task 20 script: `opt.*` in the config, + then `load` → `expect core-option=…:2x`). +25. **Launching from the in-game Library never loses the running game's state without the confirm**: `expect loaded=1` after the + first A; `openContent`'s auto-save runs on Yes (`app.cpp:1171`); the Phase 1 release guard masks the Yes press from the new + game (`guardArmed == 1`). +26. **A typed / numeric option is usable on a pad**: a held Right steps every 80 ms and stops at the end (clamp); a "(custom)" + value is shown, kept until stepped, and never written back as a non-preset (`settings_custom_value_row`, + `settings_held_right_repeats`). +27. **The startup probe must not change what a game sees**: `startSession` re-pushes the table (`core_host.cpp:775-795`), the + probe's `environ_cb` is replaced by the real one before `retro_init`, and no non-option environment command is answered while + probing (`core_options_probe_test`: `isLoaded()` false, `optionPushes()` counts one). With `--emu-thread on`, the UI reads the + published table from the first frame (`core_host.cpp:1213`): `tv_menu_headless` runs inline only (`VJTvTests.cmake:89`), so + add a `tv_menu_headless_threaded` twin with `--emu-thread on` the way `VJAchTests.cmake:41-56` registers `<name>_threaded`, + or at least run the no-game `core-option` block by hand on the Studio with the thread on. +28. **The rail with 17+ groups on a 720p window**: every group reachable and visible when focused (`settings_rail_scrolls_720p`, + `settings-core-speed-end.png`). --- @@ -1827,7 +2515,7 @@ Written 2026-10-20 while revising Phase 3; Tasks 16–17 below are as first plan - §6 → Task 4, plus every screen task; - §7 → the file lists; - §8 → Tasks 6, 7, 16; - - §9 → Tasks 16–17 and the phase order; + - §9 → Tasks 17–18 and the phase order; - §10 → the tests in every task, plus screenshots in Tasks 5, 11 and 15; - §11 → nothing here builds anything it excludes. - **Added beyond §7's hook list, because the code needs them:** `tv::Host` and its adapter (no test links