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-.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=`. @@ -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 #include +// 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 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 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& 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& 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 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& 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 AppHost::settingRows() { CoreHost& host = app_.host; ShaderChain& sc = app_.video.shader; std::vector 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 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 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 slots() override; bool saveState(int slot) override; @@ -43,6 +44,7 @@ public: bool desktopWindowOpen() const override; bool libraryScanning() const override; std::vector games(List l) override; + bool hasGames() const override; Art art(const GameRef& g) override; std::vector 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 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 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 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 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 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 @@ -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 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 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 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 +#include 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 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 @@ -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 or 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 NavInput::take(uint64_t ms) { std::vector 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 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 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 acceptHeld_; // pad Accepts down, waiting to become a tap or AcceptLong std::vector 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 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/" (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 #include #include @@ -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 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& 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(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 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(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 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 groups_; // the groups that have rows, in order; the rail's items + std::vector groupCount_; // how many rows each of them has, from the last refresh() widgets::FocusTweens groupTweens_; // the menu's group rows std::vector rows_; // the focused group's rows, from the last refresh() + struct RowLay { + std::vector label, note; // the lines each is broken into (layoutRows) + }; + std::vector 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 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 +#include #include +#include +#include #include namespace vj::tv { namespace { -SettingRow rowOf(const std::string& id, const std::string& label, int group, std::vector choices, int index) { +SettingRow rowOf(const std::string& id, const std::string& label, const std::string& group, const std::string& groupLabel, + std::vector 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::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 coreOptionRows(const std::vector& cats, const std::vector& defs, + const std::function& current, + const std::function& visible, + const std::function& overridden, + const std::function& typed) { + std::vector 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 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 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 groupsOf(const std::vector& rows) { + std::vector 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 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& 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& 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 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& 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 #include #include #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 " (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): " (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 coreOptionRows(const std::vector& cats, const std::vector& defs, + const std::function& current, + const std::function& visible, + const std::function& overridden, + const std::function& 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 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 " (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 groupsOf(const std::vector& 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:. 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& customNames); +SettingRow themeRow(const std::string& choiceKey, const std::vector& customNames); // Interface std::string themeKeyAt(int index, const std::vector& 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 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 " (custom)". +// in the desktop window) is appended as " (custom)". Picture. SettingRow shaderRow(const std::string& currentPreset, bool enabled, const std::vector& 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 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 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 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 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 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 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 and start ?", 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 root); // ... with a root the caller prepared (Game details -> Achievements on a game) void push(std::unique_ptr 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 menuScreen_; // the slot; set only while open_ (closeMenu() and enter() retire it) + std::unique_ptr 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* g_probe = nullptr; +std::vector* 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(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* sink) { g_probe = sink; } +void setTextPosProbe(std::vector* 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 wrapLines(float px, const std::string& s, float maxW, int maxLines) { + std::vector 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 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(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& 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 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* 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* 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 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& 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 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 +#include +#include +#include +#include +#include +#include +#include +#include + +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 visibility(CoreHost& h) { + std::map 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> 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 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 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 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:, 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. 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 and start ?", 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