mirror of
https://github.com/libretro/virtualjaguar-libretro.git
synced 2026-11-07 04:36:00 -10:00
feat(app): TV mode auto-switching, first-use hint and build default (TV mode phase 4) (#1067)
* docs(app): revise TV mode plan Phase 4 against develop (#991) Phase 4 was written before Phases 1-3 and desktop pad navigation (#1030) landed. Re-point the tasks at the current code: the controller trigger gates on deskPadActive and consumes the switching press, package defaults are set in app-package.yml (not release.yml), the first-use hint text comes from the pad's actual TV-menu binding. Adds Phase 4 approvals A-G and a placeholder for the user's layout fixes. Refs #991 Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat(app): TV mode switches on in fullscreen and on a controller press (#991) Wires the Auto setting. Until now Auto acted as Manual (effectiveSetting), so the fullscreen and controller triggers TvMode already implements were never fed. Why each piece is shaped as it is: - App tells TvMode about fullscreen edges only (tvFullscreenEdge against tvNoteFsSeen_): TvMode re-applies a repeated onFullscreen(true), and macOS sends enter events on space and display changes, which would turn TV mode back on after a manual off. - A mapped pad button (a menu intent: D-pad, face buttons, shoulders, Start) pressed while the desktop library owns the pad and no game is loaded switches TV mode on and is consumed, so it neither moves desktop focus nor acts on the TV screen it reveals. Guide/Share/touchpad/paddles/sticks are not triggers: they carry app actions or drift. The call sits before the TV shell so presses the shell consumes still give a controller-started TV mode a fresh mouse budget (otherwise mouse jitter would sum up and drop TV mode while the pad is in use). - Mouse travel and a printable key the TV UI did not use hand a controller-started TV mode back; F-keys, Esc and Ctrl/Cmd/Alt shortcuts never count (F4 and Ctrl+Shift+T are toggles themselves). - effectiveSetting() is gone; a config with no ui.tv_mode takes defaultSetting(VJ_TV_DEFAULT_SETTING) (Auto), never written back, so the build default can move. setTvSetting/setTvAuto are the one way to change the settings; the script gains `fullscreen on|off` and `expect tv-setting`. Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat(app): TV mode in Settings > Interface, the Auto rows in TV Settings, VJ_TV_DEFAULT (#991) Settings > Interface gets a TV mode section (Off / Manual / Auto, plus the two Auto checkboxes while Auto is chosen) and TV Settings gets the matching Auto: in fullscreen / Auto: when a controller is used rows, so the Phase 4 triggers can be switched without editing the config. Both routes go through App::setTvSetting / setTvAuto and save afterwards; the setters themselves never save. The default a fresh config starts from now comes from CMake: VJ_TV_DEFAULT (auto | manual | off, validated) defines VJ_TV_DEFAULT_SETTING for the virtualjaguar target, so a nightly can start on Auto while a release package starts on Manual. A stored ui.tv_mode still wins, and is only written when the user changes it. The headless script also pins approval C at App level (Esc, Enter and a Ctrl shortcut never end a controller-started TV mode) and walks the new rows, shifting the script's later lines to make room. Co-Authored-By: Claude Opus 5.5 <[email protected]> * ci(app): release packages default TV mode to Manual, nightlies to Auto (#991) The TV mode default now comes from CMake's VJ_TV_DEFAULT. app-package.yml sets it per build from release_tag: vX.Y.Z tags and prerelease start a fresh config on Manual, nightly, prNNN and dev builds stay on Auto. The three native configures pass it as -DVJ_TV_DEFAULT, and the Flatpak, which is built straight from its manifest, gets the same value through one sed on config-opts before flatpak-builder runs (the committed manifest keeps CMake's default). Co-Authored-By: Claude Opus 5.5 <[email protected]> * test(app): make the TV Settings Auto-rows walk independent of earlier tests (#991) The headless block that walks to "Auto: in fullscreen" relied on the shell still remembering Settings > Interface with focus on More settings..., left by a test far above it. It now presses Back twice (Home from either level of the Settings screen), opens a fresh Settings visit and clamps its way to the row: Up x4 / Down x3 on the groups, Up x8 / Down x4 in the list. It also checks that the Auto checkbox change, which saves the config, still leaves ui.tv_mode absent, and the CMake comment now says app-package.yml always passes VJ_TV_DEFAULT. Co-Authored-By: Claude Opus 5.5 <[email protected]> * ci(app): fail the Flatpak build when the TV default sed did not apply (#991) Check the manifest for the exact config-opts line after the sed instead of printing it, so a changed manifest cannot silently ship the wrong default. Co-Authored-By: Claude Opus 5.5 <[email protected]> * test(app): anchor the Auto-rows walk with expect tv-focus (#991) The previous walk assumed a fresh Settings visit starts on the groups. It does not: the shell's memory is never cleared, so the Base layer resumes the level (list or groups) and row an earlier test left, and TV mode comes back on at Home, so the Back presses before the first visit did nothing. The stray Right after the Up/Down presses only passed because the later Up x8 re-anchored it. Add `expect tv-focus=LABEL` (Screen::focusLabel(), Shell::focusLabel(); Settings names the group on the groups and the row in its list) and rewrite the walk so every step is asserted before the next one acts: open Settings, one Back, LB x3 to Home, RB x3 back (now on the groups either way), Up x4 reads Picture, Down x3 Interface, Right enters the list, Up x8 reads Show FPS, Down x4 reads Auto: in fullscreen, and only then Left / Right change a value. Also fix the Settings screen's comment: the Base layer resumes the remembered level, only the menu slot always starts on the groups. Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat(app): one-time TV mode hint on the first controller press in the library (#991) Under Manual nothing tells a new player how to reach TV mode, and the button differs per pad (Share hold, Guide hold, a paddle) and per OS. The first pad press the desktop library takes now shows a 6 s toast naming this pad's own TV-menu button and the keyboard's (tv::firstUseHint; the fixed Ctrl/Cmd+Shift+T stands in until two are known). The key ui.tv_hint_shown is saved before the toast and a per-run flag backs it, so it cannot show twice; Auto (whose switching press returns before the hint site) and Off never show it. A Settings reset of Interface clears the key, so it may show once more. The pad text uses the short names (Share, Guide) rather than Binding::display()'s "Share / Capture". The TV script gains expect toast=TEXT, toast-count=N and hintreset, and the Settings > Interface hint now says nightly and local builds start on Auto. Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs(app): TV mode section in the README (#991) Document what TV mode is, the three settings and where they live, the Auto rules, the build default (-DVJ_TV_DEFAULT: Auto for local and nightly builds, Manual for releases), the first-use hint, --tv, the 500 ms hold and the Guide / keypad-chord limitation. The existing key-table rows link to it. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): hint names the pad's own button; mouse verb sends real motion; Home copy for a library with games (#991) Review of Task 18 and the Studio windowed check found four things. The first-use hint named "Share" on every pad, but SDL's MISC1 is the DualSense mic and the Switch Pro Capture button, and Guide is PS / Home on those. tvMenuButtonName(family, binding) names it per family (the virtual pad keeps "Share (hold)"). Settings > Reset group on Interface now also clears the per-run flag, so the hint may show again as the README says. The windowed mouse check left TV mode on because SDL reports a warped pointer with xrel = yrel = 0, so the 32 px budget never accumulated (the product path was fine: the events reached TvMode::onMouseMove with zero travel). `mouse` now also pushes a MOUSE_MOTION carrying the travel, in headless runs too, and a headless block pins the handleEvent wiring. Under Auto a new player with 20 games landed on Home saying the library was empty. With games but no shelves Home now says "Nothing to continue yet. Press RB for your Library."; the old text stays for a truly empty one. The Off "no hint" check now proves the press reached the desktop nav. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): Home empty state without a per-frame library copy; no doubled "(hold)"; README Off wording (#991) TV Home with no shelves is the landing state of a new Auto user, and it asked the host for List::All every frame to find out whether any games exist, which builds a GameRef (several strings) per library entry. A Host::hasGames() answers it with no copy and an early exit. tvMenuButtonName appended " (hold)" to navButtonName(), whose fallback Binding::display() already adds it, so a rebound hold on a paddle read "Paddle P1 (hold) (hold)". It now names a non-hold copy and suffixes once. README: Off never turns TV mode on, but with a game running the TV-menu button still opens the in-game menu (spec 8). Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs(app): TV mode plan Task 16a closed, no layout fixes needed (#991) The owner tested phases 1-3 on a TV and reported no layout fixes, so the last open task of the plan is done and phase 4 completes #991. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): Settings opens the TV Settings screen while TV mode is on (#991) Every Settings entry (Ctrl/Cmd+Comma, the menu bar, the native macOS menu / tray and Cmd::Settings) set showSettings, the desktop window, even in TV mode, so a player in a game could not reach settings without the plain UI. They now go through App::openSettings(): TV mode on with no game switches the Base layer to the Settings section; with a game it opens the in-game menu (same pause / hardcore gate as the TV-menu action) with the Settings screen in its slot, so B returns to the menu root. TV mode off is unchanged. Ctrl/Cmd+Comma still closes an open desktop window (More settings...); in TV mode a second press leaves the TV screen as it is. The BIOS-tab entries stay desktop (the TV Settings has no BIOS tab). Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): the TV menu opens without pausing when hardcore refuses; BIOS entries use the TV hand-off (#991) Settings must stay reachable mid-game, so when Achievements::allowPause refuses the in-game menu (the F4 action and the Settings route alike) opens anyway, like the netplay rule: the menu owns the pads, the game runs on, and the menu shows a 'Hardcore: the game keeps running' banner. menuResume restores the pad ownership as after a netplay menu. The refusal toast is dropped from this path (nothing refuses any more). The BIOS-tab entries (menu bar, native Cmd::BiosSettings) now go through App::openBiosSettings: with TV mode on it is the pad-driven More-settings hand-off (the menu first with a game), else the desktop window as before. Test seams: the 'command NAME' and 'hardcore-pause deny|allow' script verbs and the menu-owns-input expect. Co-Authored-By: Claude Opus 5.5 <[email protected]> * test(app): TV Settings routes through the native commands, the Save page, hardcore and BIOS (#991) Native-menu path via the new 'command' verb (a synthesized ctrl+, is ignored in a real window), the menu root and Save page routes, the hardcore-refused menu (runs on, owns the pads, hands them back), command bios in TV mode, and smoke tests for Shell::openSettings and the hardcore banner. Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs(app): TV mode plan Phase 5, every setting and the Library in TV mode (#991) The owner found TV mode stops at the game: Settings and the Library open the desktop windows mid-game, and TV Settings lists a handful of rows with the core options hidden until a game has run. Phase 5 reads the core's option definitions at startup so every core option and every choice-like app setting has a TV row, and routes the Library through TV screens too (an in-game Library row; a launch asks first). Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat(app): core options readable at startup; TV Settings catalogue of every core option and app choice (#991) The owner could not see the game core's settings in TV mode: the option table only reached the app through CoreHost::startSession, so until a game had run the TV Settings hid the three curated core rows and said "Available once a game has run". CoreHost::probeOptions() runs retro_set_environment once at startup, before the emulation thread exists, answering only the option commands (everything else is refused), then the core's own display pass, so defs, categories and visibility are filled exactly as a load fills them. The core is then taken back to a fresh process's state with retro_deinit, the call every reload makes before its retro_set_environment: the display pass leaves its show_* latches at "hidden", and without the reset the first game's own display pass finds no change and every gated row (netlink host, voice chat, mouse and rotary tuning, CD-only keys...) stays visible. core_options_probe checks that the first load after a probe has the same visibility as the second. The TV Settings model is now a catalogue: SettingRow::group is a string key plus a label, coreOptionRows lists every visible core option by the core's categories (overridden = read-only, Host-kind typed keys read-only, "(custom)" for a value off the list, a restart note), sliderRow turns the desktop sliders into stepped lists with a "(custom)" entry for a finer value, and AppHost lists every choice, toggle and slider of the desktop tabs (Picture, Sound, Interface, Library, Achievements, Discord, Neuro) before the core's groups. The curated core rows, the speed note and the fixed four-group enum are gone. tv_settings.cpp only follows the new group type; the rail's scroll and the long-list focus are the next task. tv_menu_headless walks the longer lists with Down x20 (clamping) and a new test-only `settings-group KEY` verb that sets the shell's rail memory, so no count of rail items depends on the build. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): a "(custom)" setting value sits between its neighbours, so Left and Right step the right way (#991) A slider value off the list (the audio buffer's own default of 64 ms, an off-grid volume, dead zone, tile size) was appended last, so Left jumped to the end of the list and Right did nothing. sliderRow now inserts "<value> (custom)" at its sorted position and stepValue maps indices past it back to the grid: 64 ms sits between 60 and 70. A typed numeric core option whose presets are in numeric order (a clock scale, a port) gets the same through coreCustomPos, which setSetting uses to map the stepped index back to the option's values; text, addresses and unordered presets keep the entry last. Also says in a comment why probeOptions drops the display callback: gating options do not show or hide dependent rows until the first game loads. Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat(app): TV Settings scrolls its rail, pages its list, and shows every group (#991) With the core's categories joining the app's tabs there are ~17 groups, and the rail and the in-game groups level drew every one at a fixed pitch, so the tail ran off the screen and the focus could land there. Both now scroll a whole item at a time (LinearFocus::first eased by the Phase 3 tween, snapped on the first draw), keep the focused item in view, show "more" arrows with room reserved for them, and draw no item that is wholly outside the window. The list scrolls the same way, so no row is cut mid-text at rest. LT / RT (PagePrev / PageNext) jump a screenful on the list, the rail and the in-menu groups level, and stop at the ends; a "Page" hint appears when the list scrolls. A held direction already repeats on the nav's timer, so Shell::repeatIntent() (NavInput::lastWasRepeat) lets the screen tell a repeat from a press where it matters: a held Left stops at a row's first choice instead of leaving for the rail, and a Right held from the rail enters the list without then stepping its first row. Test support: expect tv-group=KEY (Screen::focusGroup), an opt-in text position probe in the widgets, and a headless walk that steps the core's internal resolution with no game loaded, checks opt.* is saved and that the next game reads it. Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat(app): Library is a TV screen while TV mode is on; in-game Library row with a launch confirm (#991) While TV mode is on every Library entry point (Ctrl/Cmd+L, File > Game library, the native Cmd::Library) goes through App::openLibrary(): the Library section with no game, the in-game menu with the Library in its slot with one (the hardcore pause gate and its toast apply, as for Settings). The menu gains a "Library" row after Load state; the slot screen is wide() so the rail and cover grid draw over the dimmed game. Typing reaches the slot; Details and the letter picker (Base stack only) are not offered there. Launching from the slot asks first (Page::ConfirmLaunch, "Close <running> and start <title>?", No / Yes, focus on No); Home, Library and Details launch through Shell::requestLaunch, which starts at once when no game runs. Yes goes through the existing library launch (the app auto-saves and closes the menu), then launched() arms the release guard. Script expects: menu-page=..., and tv-focus reads the in-game menu while it is open. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): TV Library review fixes: no desktop library over TV mode at startup or on its on-edge; launch confirm rows match Reset/Close (#991) Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): TV Settings and placeholder tiles wrap long text instead of cutting it Studio shots showed long setting labels cut with "..." (two Per-Title Enhancement rows read the same; notes lost their ends) and Library placeholder titles split mid-word or running off the tile. - Settings in the in-game menu's slot takes the whole panel (wide(), as the Library does) and names itself in a caption line; the label gets the row width less a value column sized by the widest choice, and wraps at words to two lines (a note too); every row of a group is as tall as the tallest so whole-row scrolling still holds. - widgets::wrapLines / textLines: word wrap with a last-line "..." cut at a word; the text probe still sees the whole text once. - Placeholder tiles wrap at words, drop to the 24 u floor before splitting a word, and sit at the foot with the "..." only on the last line that fits; the menu's Library uses tighter columns so "Cybermorph" fits whole, and waits to draw a row until it fits above the panel's foot. - Scroll arrows sit clear of the focus ring (arrow room 26 u); the groups level has its caption so its up arrow stays inside the list area. - The rail and the groups level list "Page" for LT / RT, joining only the glyphs that exist. - Core options already prefer desc_categorized (coreRow); the Per-Title rows have none in libretro_core_options.h, so wrapping is what tells them apart. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): F8 does not pause the game behind the hardcore run-on menu While the menu runs on over a hardcore game (its banner says the game keeps running) a pause-decay countdown can end and F8 then paused the game the player cannot see, to be resumed as the menu closes. menuRunsOn_ is treated like menuHeldPause_: F8 is refused with the same toast. tv_menu_headless covers it (allow the pause while the menu is open, then F8) and `command bios` with no game (the window opens alone, B closes it). Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs(app): the launch confirm lists Yes above No Phase 5 item 8 said "rows No / Yes", and the test description said Down for Yes; the rows are Yes, No with the focus on No, so Yes is Up. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
6ce76c1cf8
commit
7e2f5967ee
45 files changed
+4727
-968
No files matched your search
@@ -72,6 +72,9 @@ env:
|
||||
# DISCORD_APP_ID, when set, overrides it (each configure below passes it only when non-empty, so an unset
|
||||
# variable never turns the feature off).
|
||||
DISCORD_APP_ID: ${{ vars.DISCORD_APP_ID }}
|
||||
# TV mode (#991): the setting a fresh config starts from. Releases (vX.Y.Z, prerelease) start on Manual; nightly, prNNN and dev
|
||||
# builds on Auto. Each configure below passes it as VJ_TV_DEFAULT, the Flatpak through its manifest.
|
||||
TV_DEFAULT: ${{ (startsWith(inputs.release_tag, 'v') || inputs.release_tag == 'prerelease') && 'manual' || 'auto' }}
|
||||
|
||||
jobs:
|
||||
# ------------------------------------------------------------------------------------------
|
||||
@@ -199,7 +202,7 @@ jobs:
|
||||
cmake -S app -B "build-$arch" -DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_OSX_ARCHITECTURES="$arch" -DCMAKE_OSX_DEPLOYMENT_TARGET="$MIN_MACOS" \
|
||||
-DVJ_APP_FETCH_LIBRASHADER=OFF -DVJ_LIBRASHADER_LIB="$PWD/librashader/librashader-$triple/librashader.dylib" \
|
||||
-DVJ_PACKAGE_VERSION="$SHORT_VERSION" \
|
||||
-DVJ_PACKAGE_VERSION="$SHORT_VERSION" -DVJ_TV_DEFAULT="$TV_DEFAULT" \
|
||||
${DISCORD_APP_ID:+-DVJ_DISCORD_APP_ID="$DISCORD_APP_ID"}
|
||||
cmake --build "build-$arch" --config Release -j4
|
||||
cmake --install "build-$arch" --prefix "$PWD/stage-$arch"
|
||||
@@ -374,7 +377,7 @@ jobs:
|
||||
run: |
|
||||
set -eu
|
||||
cmake -S app -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
||||
-DVJ_APP_WIN32_GUI=ON -DVJ_APP_FETCH_LIBRASHADER=ON -DVJ_PACKAGE_VERSION="$SHORT_VERSION" \
|
||||
-DVJ_APP_WIN32_GUI=ON -DVJ_APP_FETCH_LIBRASHADER=ON -DVJ_PACKAGE_VERSION="$SHORT_VERSION" -DVJ_TV_DEFAULT="$TV_DEFAULT" \
|
||||
${DISCORD_APP_ID:+-DVJ_DISCORD_APP_ID="$DISCORD_APP_ID"} \
|
||||
${{ matrix.cmake_extra }}
|
||||
grep -E 'librashader|CMAKE_SYSTEM_PROCESSOR|CMAKE_C_COMPILER:' build/CMakeCache.txt | head
|
||||
@@ -551,6 +554,14 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set the TV mode default in the manifest
|
||||
# The manifest itself stays on CMake's default (Auto); a release package gets Manual here, like the other packages.
|
||||
run: |
|
||||
set -eu
|
||||
sed -i "s|- -DVJ_APP_BUILD_TESTS=OFF|&\n - -DVJ_TV_DEFAULT=$TV_DEFAULT|" app/packaging/flatpak/io.github.libretro.VirtualJaguar.yml
|
||||
# Fail here, not silently, if the sed did not match (the manifest's config-opts changed).
|
||||
grep -qx " - -DVJ_TV_DEFAULT=$TV_DEFAULT" app/packaging/flatpak/io.github.libretro.VirtualJaguar.yml
|
||||
|
||||
- name: Build the bundle
|
||||
# flatpak/flatpak-github-actions v6.8 (pinned by commit). upload-artifact is OFF: its
|
||||
# own artifact would be named app-<arch>.flatpak and not app-package-*, and the release
|
||||
@@ -641,7 +652,7 @@ jobs:
|
||||
run: |
|
||||
set -eu
|
||||
cat librashader/BUILD-INFO.txt
|
||||
cmake -S app -B build -DCMAKE_BUILD_TYPE=Release -DVJ_PACKAGE_VERSION="$SHORT_VERSION" \
|
||||
cmake -S app -B build -DCMAKE_BUILD_TYPE=Release -DVJ_PACKAGE_VERSION="$SHORT_VERSION" -DVJ_TV_DEFAULT="$TV_DEFAULT" \
|
||||
-DVJ_LIBRASHADER_LIB="$PWD/librashader/librashader.so" \
|
||||
${DISCORD_APP_ID:+-DVJ_DISCORD_APP_ID="$DISCORD_APP_ID"}
|
||||
cmake --build build -j"$(nproc)"
|
||||
|
||||
+10
-1
@@ -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)
|
||||
|
||||
+40
-2
@@ -30,6 +30,9 @@ ctest --test-dir app/build -C Release --output-on-failure # headless smoke tes
|
||||
The binary is `app/build/virtualjaguar` (`app/build/Release/virtualjaguar.exe`
|
||||
with the Visual Studio generator).
|
||||
|
||||
`-DVJ_TV_DEFAULT=auto|manual|off` (default `auto`) is the TV mode setting a fresh config starts on, so a local build starts on Auto
|
||||
while release packages are built with `manual` (see [TV mode](#tv-mode)).
|
||||
|
||||
FreeType 2.14.1 is the default font rasteriser: it is fetched (pinned, SHA-256 checked), built static with every
|
||||
optional library off, so it needs no system libraries. `-DVJ_APP_FREETYPE=OFF` uses Dear ImGui's built-in
|
||||
stb_truetype instead (no hinting or anti-aliasing choices); offline builds use `-DFETCHCONTENT_SOURCE_DIR_FREETYPE=<dir>`.
|
||||
@@ -110,7 +113,7 @@ Loading another game while one is running does a full unload, `retro_deinit`,
|
||||
| number row / numpad, `-` `=` | Jaguar keypad 0-9, `*`, `#` |
|
||||
| A S D, Q E | Jaguar Pro Controller X Y Z and left / right shoulder (keypad 9 8 7, 4 6) |
|
||||
| F2 | keypad overlay |
|
||||
| Ctrl/Cmd+Shift+T | toggle TV mode (View > TV mode), with or without a game; the game keeps running. Fixed, not rebindable |
|
||||
| Ctrl/Cmd+Shift+T | toggle TV mode (View > TV mode), with or without a game; the game keeps running. Fixed, not rebindable (see [TV mode](#tv-mode)) |
|
||||
| F4, or a Share hold | the "TV menu / TV mode" action (rebindable): with a game it opens the in-game TV menu, without one it toggles TV mode |
|
||||
| F3 | controls overlay: a cheat sheet of your current bindings (also View menu) |
|
||||
| Esc / F10 | release the captured mouse; F10 or a click on the picture captures it again (see below) |
|
||||
@@ -174,13 +177,48 @@ and the BIOS and States tabs. Mouse and keyboard work as before.
|
||||
| LT / RT | previous / next initial letter |
|
||||
| Right stick | scroll the grid |
|
||||
| View / Back | focus the search box |
|
||||
| Share or Guide, held | toggle TV mode |
|
||||
| Share or Guide, held | toggle TV mode (see [TV mode](#tv-mode)) |
|
||||
|
||||
The buttons follow the controller family, detected automatically: Xbox, PlayStation, Nintendo Switch
|
||||
(A on the right confirms, B on the bottom goes back) and MFi / generic (Xbox layout). A custom layout
|
||||
is under **Settings > Input**: pick one controller in the device list, then **Menu buttons** (a Family
|
||||
choice, and **Set** / **Default** per button). **Reset menu buttons** clears that controller's overrides.
|
||||
|
||||
### TV mode
|
||||
|
||||
TV mode is a 10-foot look for a pad and a TV: large tiles and text, one focus ring, and a pad-driven Home,
|
||||
Library, Achievements and Settings, plus an in-game menu (see the rows in the key table above). With a game running it
|
||||
only changes which screen stands behind the game; the game keeps running.
|
||||
|
||||
The **TV mode** setting has three values, under **Settings > Interface** (and **TV Settings > Interface** in TV mode itself):
|
||||
|
||||
- **Off:** TV mode never turns on: F4, the Share hold, Ctrl/Cmd+Shift+T and `--tv` do nothing without a game. With a game running
|
||||
the TV-menu button still opens the in-game menu.
|
||||
- **Manual:** only when you ask: View > TV mode, Ctrl/Cmd+Shift+T, the TV-menu button (F4, or a hold of the pad's Share / Capture / Mic button, or of Guide on a pad
|
||||
without one, with no game running) or `--tv`. The on/off state is remembered for the next start (`--tv` is not).
|
||||
- **Auto:** also on its own, with two checkboxes you can untick, **Turn on in fullscreen** and **Turn on when a controller is used**.
|
||||
|
||||
Auto in one paragraph: entering fullscreen turns TV mode on and leaving it turns it off; a controller press on a navigation
|
||||
button (D-pad, face buttons, LB / RB, Start) in the library, with no game running, turns it on, and that press does nothing
|
||||
else. A TV mode the controller started is handed back to the desktop by 32 px of mouse movement or by typing (a printable key
|
||||
without Ctrl, Cmd or Alt; F-keys, Esc, Enter and shortcuts never count), unless fullscreen is holding it. Turning it on or off by
|
||||
hand wins until the next fullscreen change or controller press.
|
||||
|
||||
**Build default.** A fresh config starts on the build's default, which is Auto for a local build (the CMake default) and for
|
||||
nightly builds and the other CI packages, and Manual for release packages; `-DVJ_TV_DEFAULT=auto|manual|off` sets it when
|
||||
configuring. Nothing is stored until you change the setting in the UI, so a build can move its default under people who never
|
||||
chose one; **Reset group** at the top of Settings > Interface returns to the build default.
|
||||
|
||||
**First-use hint.** Under Manual, the first controller press in the desktop library shows a 6-second toast naming the buttons
|
||||
for this pad, as the pad names the button ("Press Share (hold) or F4 for TV mode" on an Xbox pad; "Mic (hold)" on a DualSense,
|
||||
"Capture (hold)" on a Switch Pro Controller, "Guide (hold)" on a pad without Share). It shows once per install
|
||||
(`ui.tv_hint_shown`), never under Auto or Off, and **Reset group** on Settings > Interface shows it once more.
|
||||
|
||||
**Starting and the hold.** `--tv` starts with TV mode on (when the setting is not Off). The TV-menu button on a pad is a hold
|
||||
of 500 ms, so a short Share tap keeps opening the keypad overlay. Limit: Guide held opens the TV menu on a pad with no Share
|
||||
button, and Guide is also the keypad's `#`, so a keypad chord that holds `#` (such as `*` + `#`) needs the keypad overlay, a pad
|
||||
with Share, or that binding removed (the controls screen says so next to the binding).
|
||||
|
||||
### Devices, players and the mouse
|
||||
|
||||
**Settings > Input > Devices & players** (everywhere, or this game only; stored as
|
||||
|
||||
@@ -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)
|
||||
|
||||
+120
-15
@@ -23,6 +23,12 @@
|
||||
#include <filesystem>
|
||||
#include <thread>
|
||||
|
||||
// app/CMakeLists.txt's VJ_TV_DEFAULT defines this for the virtualjaguar target (the single source of the default). The fallback
|
||||
// is for a build that compiles app.cpp outside CMake only; keep it equal to the option's default.
|
||||
#ifndef VJ_TV_DEFAULT_SETTING
|
||||
#define VJ_TV_DEFAULT_SETTING "auto"
|
||||
#endif
|
||||
|
||||
namespace vj {
|
||||
|
||||
namespace {
|
||||
@@ -211,18 +217,60 @@ void App::applySettingsFromConfig() {
|
||||
resumeMode = lib::clampResumeMode(cfg.getInt("saves.resume", lib::kResumeDefault));
|
||||
input.stickAsDpad = cfg.getBool("input.stick_dpad", true);
|
||||
emuThreadPref = cfg.has("ui.emu_thread") ? (cfg.getBool("ui.emu_thread", false) ? 1 : 0) : -1;
|
||||
tvSetting = tv::TvSetting::Manual;
|
||||
tv::settingFromKey(cfg.get("ui.tv_mode", "manual"), tvSetting); // an unknown value stays Manual
|
||||
// No stored ui.tv_mode: the build's default (Auto unless the package says otherwise). An unknown value does the same.
|
||||
tvSetting = tv::defaultSetting(VJ_TV_DEFAULT_SETTING);
|
||||
if (cfg.has("ui.tv_mode")) tv::settingFromKey(cfg.get("ui.tv_mode"), tvSetting);
|
||||
configureTvMode();
|
||||
if (tv::effectiveSetting(tvSetting) == tv::TvSetting::Manual && cfg.getBool("ui.tv_manual_on", false)) tvMode.setOn(true);
|
||||
if (tvSetting == tv::TvSetting::Manual && cfg.getBool("ui.tv_manual_on", false)) tvMode.setOn(true);
|
||||
}
|
||||
|
||||
void App::configureTvMode() {
|
||||
tv::TvConfig c;
|
||||
c.setting = tv::effectiveSetting(tvSetting); // Auto acts as Manual until its triggers are wired up
|
||||
c.setting = tvSetting;
|
||||
c.autoFullscreen = cfg.getBool("ui.tv_auto_fullscreen", true);
|
||||
c.autoController = cfg.getBool("ui.tv_auto_controller", true);
|
||||
tvMode.configure(c, fullscreen);
|
||||
tvNoteFsSeen_ = fullscreen; // what TvMode now knows: tvFullscreenEdge() feeds it changes only
|
||||
}
|
||||
|
||||
void App::setTvSetting(tv::TvSetting s) {
|
||||
const bool wasOn = tvMode.active();
|
||||
if (s != tvSetting) cfg.set("ui.tv_mode", tv::settingKey(s)); // on change only: a default that was never chosen stays free to move
|
||||
tvSetting = s;
|
||||
if (s == tv::TvSetting::Off) cfg.setBool("ui.tv_manual_on", false); // or a later Manual would switch TV mode on at once
|
||||
configureTvMode(); // starts from off ...
|
||||
if (wasOn) tvMode.setOn(true); // ... so a change of setting stays in TV mode; Off stays off (setOn is a no-op then)
|
||||
}
|
||||
|
||||
void App::setTvAuto(bool fullscreenTrigger, bool on) {
|
||||
const bool wasOn = tvMode.active();
|
||||
cfg.setBool(fullscreenTrigger ? "ui.tv_auto_fullscreen" : "ui.tv_auto_controller", on);
|
||||
configureTvMode();
|
||||
if (wasOn) tvMode.setOn(true);
|
||||
}
|
||||
|
||||
// The Auto controller trigger (spec 8, approvals A and B). Only a button with a menu intent counts: Guide, Share, the
|
||||
// touchpad and the paddles carry app actions, and a stick or trigger drifts. While TV mode is on the press is the TV UI's
|
||||
// (and a fresh mouse budget for a controller-started TV mode); while it is off and the desktop library owns the pad with no
|
||||
// game loaded, it turns TV mode on and is consumed, so it neither moves the desktop focus nor acts on the TV screen it
|
||||
// reveals. TvMode itself checks Auto and the "when a controller is used" box.
|
||||
bool App::tvAutoPadPress(const SDL_Event& e) {
|
||||
if (input.navProfile(e.gbutton.which).intentForButton(e.gbutton.button) == tv::Intent::None) return false;
|
||||
if (tvMode.active()) {
|
||||
tvMode.onPadPress(true);
|
||||
return false;
|
||||
}
|
||||
const bool typing = imguiUp_ && ImGui::GetCurrentContext() && ImGui::GetIO().WantTextInput;
|
||||
if (deskPadActive(*this) && !host.isLoaded() && !typing) tvMode.onPadPress(true); // a paused game under the library window stays the desktop's
|
||||
return tvMode.active();
|
||||
}
|
||||
|
||||
// TvMode re-applies a repeated onFullscreen(true) (it turns TV mode back on after a manual off), and macOS sends enter events
|
||||
// for space and display changes: it is told of changes only. setFullscreen() and the two SDL events all land here.
|
||||
void App::tvFullscreenEdge() {
|
||||
if (fullscreen == tvNoteFsSeen_) return;
|
||||
tvNoteFsSeen_ = fullscreen;
|
||||
tvMode.onFullscreen(fullscreen);
|
||||
}
|
||||
|
||||
void App::saveConfig() {
|
||||
@@ -240,8 +288,8 @@ void App::saveConfig() {
|
||||
if (!themeFromCli_) cfg.set("ui.theme", themePreviewing_ ? themeBeforePreview_ : theme::choiceKey());
|
||||
cfg.setBool("ui.home_library", homeLibrary);
|
||||
// ui.tv_mode is never written here: a stored value would pin a default that is meant to move (Auto on nightlies, #991
|
||||
// Task 16) for everyone who ran this build. Only the TV Settings row writes it, on change (AppHost::setSetting).
|
||||
if (tv::effectiveSetting(tvSetting) == tv::TvSetting::Manual && !tvFromCli_) cfg.setBool("ui.tv_manual_on", tvMode.active());
|
||||
// Task 16) for everyone who ran this build. Only setTvSetting() writes it, on change.
|
||||
if (tvSetting == tv::TvSetting::Manual && !tvFromCli_) cfg.setBool("ui.tv_manual_on", tvMode.active());
|
||||
cfg.setBool("saves.autosave", autosaveSram);
|
||||
cfg.setBool("ui.tray", trayIcon);
|
||||
cfg.setBool("ui.keep_awake", keepAwake);
|
||||
@@ -271,6 +319,7 @@ void App::addRecent(const std::string& path) {
|
||||
}
|
||||
|
||||
void App::toast(const std::string& text, double seconds, int level) {
|
||||
toastCount_++;
|
||||
Osd o;
|
||||
o.text = text;
|
||||
o.born = now();
|
||||
@@ -1101,6 +1150,7 @@ void App::setFullscreen(bool fs) {
|
||||
fullscreen = fs;
|
||||
fullscreenMenu.update(fullscreen, fullscreenAutoHideMenu, menuPinned);
|
||||
if (window_) SDL_SetWindowFullscreen(window_, fs);
|
||||
tvFullscreenEdge();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
@@ -1331,14 +1381,16 @@ void App::reset() {
|
||||
}
|
||||
|
||||
void App::togglePause() {
|
||||
if (menuHeldPause_) { // the TV menu holds the pause: a toggle here would be overwritten when it closes
|
||||
if (menuHeldPause_ || menuRunsOn_) { // the TV menu holds the pause: a toggle here would be overwritten when it closes
|
||||
// (Hardcore's run-on menu holds none, but its banner promises the game keeps running: a pause behind it would stall the game
|
||||
// the player cannot see, and the menu would resume it as it closes.)
|
||||
toast("Close the menu to resume", 1.5);
|
||||
return;
|
||||
}
|
||||
if (!paused) {
|
||||
// Hardcore rations pausing (rc_client_can_pause); softcore never refuses.
|
||||
std::string why;
|
||||
if (!ach.allowPause(why)) {
|
||||
if (!pauseAllowed(why)) {
|
||||
toast(why, 2.5, 2);
|
||||
return;
|
||||
}
|
||||
@@ -1351,15 +1403,29 @@ void App::togglePause() {
|
||||
|
||||
// The TV in-game menu's pause: the same gate and the same pause as togglePause(), without its toast, and it keeps
|
||||
// what was true before so that a game the player had paused stays paused when the menu closes. A netplay session
|
||||
// keeps running (a pause would stall the other side), so there the menu only lays over the picture.
|
||||
// keeps running (a pause would stall the other side), so there the menu only lays over the picture; hardcore's refusal to pause
|
||||
// (rc_client_can_pause) opens the menu the same way, so Settings stays reachable mid-game: the menu owns the pads, the game runs on, and
|
||||
// the menu's banner says so. `why` (the pause-decay countdown) is left unset: nothing toasts it.
|
||||
bool App::pauseAllowed(std::string& why) {
|
||||
if (testDenyPause) {
|
||||
why = "Hardcore: pausing is available again in 3 s";
|
||||
return false;
|
||||
}
|
||||
return ach.allowPause(why);
|
||||
}
|
||||
|
||||
bool App::menuPause(std::string& why) {
|
||||
if (menuHeldPause_) return true; // already open
|
||||
if (menuHeldPause_ || menuRunsOn_) return true; // already open
|
||||
if (netplay.configured()) {
|
||||
input.setMenuOwnsInput(true); // running on, but not for the pads: the menu has them
|
||||
return true;
|
||||
}
|
||||
if (!paused) {
|
||||
if (!ach.allowPause(why)) return false; // hardcore rations pausing (rc_client_can_pause)
|
||||
if (!pauseAllowed(why)) { // hardcore rations pausing (rc_client_can_pause): the menu opens anyway, over the running game
|
||||
menuRunsOn_ = true;
|
||||
input.setMenuOwnsInput(true);
|
||||
return true;
|
||||
}
|
||||
paused = true;
|
||||
audio.setPaused(true);
|
||||
pausedBeforeMenu_ = false;
|
||||
@@ -1373,6 +1439,7 @@ bool App::menuPause(std::string& why) {
|
||||
|
||||
void App::menuResume() {
|
||||
input.setMenuOwnsInput(false); // before the caller arms the release guard
|
||||
menuRunsOn_ = false;
|
||||
if (!menuHeldPause_) return;
|
||||
menuHeldPause_ = false;
|
||||
if (paused != pausedBeforeMenu_) {
|
||||
@@ -1383,6 +1450,26 @@ void App::menuResume() {
|
||||
|
||||
tv::Shell& App::tvShell() { return *tvShell_; }
|
||||
|
||||
void App::openLibrary() {
|
||||
if (tvMode.active()) tvShell().openLibrary(); // (as Settings: the in-game menu opens even when hardcore will not pause; a refused menu toasts and nothing opens)
|
||||
else toggleLibrary();
|
||||
}
|
||||
|
||||
void App::openSettings() {
|
||||
if (tvMode.active()) tvShell().openSettings(); // (the in-game menu opens even when hardcore will not pause: the game runs on under it)
|
||||
else showSettings = true;
|
||||
}
|
||||
|
||||
void App::openBiosSettings() {
|
||||
devSelectTab = "BIOS";
|
||||
if (tvMode.active()) {
|
||||
if (host.isLoaded() && !tvShell().menuOpen() && !tvShell().openMenu()) return; // the pads are the window's only over the menu
|
||||
tvMoreSettingsOpen(*this);
|
||||
} else {
|
||||
showSettings = true;
|
||||
}
|
||||
}
|
||||
|
||||
void App::showOpenDialog() {
|
||||
if (!window_) return;
|
||||
g_filterPattern = joinExtensions(CoreHost::validExtensions()) + ";m3u"; // multi-disc playlists: the app reads them (m3u.h)
|
||||
@@ -1735,10 +1822,13 @@ void App::handleHotkey(const SDL_KeyboardEvent& k) {
|
||||
if (mod) reset();
|
||||
break;
|
||||
case SDLK_COMMA:
|
||||
if (mod) showSettings = !showSettings;
|
||||
if (mod) { // a desktop window open (incl. "More settings...") closes; else Settings opens (TV mode: the TV screen, which a second press leaves be)
|
||||
if (showSettings) showSettings = false;
|
||||
else openSettings();
|
||||
}
|
||||
break;
|
||||
case SDLK_L:
|
||||
if (mod) toggleLibrary();
|
||||
if (mod) openLibrary();
|
||||
break;
|
||||
case SDLK_N:
|
||||
if (mod) newWindow();
|
||||
@@ -1778,9 +1868,17 @@ void App::handleEvent(const SDL_Event& e, bool headless) {
|
||||
}
|
||||
input.feedHoldEdges(e); // before anything can consume the event, so a release is never lost
|
||||
tvHost_->noteInput(e); // which device the hints should name (the shell consumes events InputManager never sees)
|
||||
if (e.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && tvAutoPadPress(e)) return; // Auto: this press turned TV mode on, and is only that
|
||||
if (tvMoreSettingsHandleEvent(*this, e, SDL_GetTicks())) return; // "More settings" is up: its window has the pads and keys, even over the in-game menu (tv_more_settings.h)
|
||||
if (tvShell().ownsInput() && !input.capturing() && tvShell().handleEvent(e, SDL_GetTicks())) return; // the TV UI has the pads and keys (not while a binding is being captured)
|
||||
// (phase 4's Auto controller trigger, TvMode::onPadPress, goes before this call: desktop nav sees a press only when it did not switch TV on)
|
||||
// The Auto triggers (#991): the mouse and the keyboard hand a controller-started TV mode back (the pad's half, tvAutoPadPress, runs
|
||||
// before the shell: its presses are the shell's). Desktop nav below sees a press only when it did not switch TV mode on.
|
||||
if (e.type == SDL_EVENT_MOUSE_MOTION && !input.mouseCaptured()) tvMode.onMouseMove(e.motion.xrel, e.motion.yrel);
|
||||
if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat && !(e.key.mod & (SDL_KMOD_CTRL | SDL_KMOD_ALT | SDL_KMOD_GUI)) &&
|
||||
!(imguiUp_ && ImGui::GetCurrentContext() && ImGui::GetIO().WantCaptureKeyboard)) {
|
||||
const SDL_Keycode k = SDL_GetKeyFromScancode(e.key.scancode, SDL_KMOD_NONE, false);
|
||||
if (k >= 0x20 && k <= 0x7E) tvMode.onNonNavKey(); // a printable key the TV UI did not use (F-keys, Esc and shortcuts never count)
|
||||
}
|
||||
if (deskPadHandleEvent(*this, e, SDL_GetTicks())) return; // the desktop library has the pad (desktop_nav_app.h)
|
||||
if (input.handleEvent(e)) return;
|
||||
switch (e.type) {
|
||||
@@ -1816,10 +1914,12 @@ void App::handleEvent(const SDL_Event& e, bool headless) {
|
||||
case SDL_EVENT_WINDOW_ENTER_FULLSCREEN:
|
||||
fullscreen = true; // also the green button / Ctrl+Cmd+F on macOS, which never go through setFullscreen()
|
||||
fullscreenMenu.update(fullscreen, fullscreenAutoHideMenu, menuPinned);
|
||||
tvFullscreenEdge();
|
||||
break;
|
||||
case SDL_EVENT_WINDOW_LEAVE_FULLSCREEN:
|
||||
fullscreen = false;
|
||||
fullscreenMenu.update(fullscreen, fullscreenAutoHideMenu, menuPinned);
|
||||
tvFullscreenEdge();
|
||||
break;
|
||||
case SDL_EVENT_USER: {
|
||||
platform::Cmd ncmd = platform::Cmd::None;
|
||||
@@ -2052,6 +2152,11 @@ int App::run(const CliArgs& a) {
|
||||
neuroInit(*this, a);
|
||||
#endif
|
||||
|
||||
// The core's option table, before any game and before the emulation thread exists (it publishes the table at once):
|
||||
// the TV Settings and the desktop Core tab show every option from the first frame (#991). After loadUserOptions,
|
||||
// wireHost (the lock) and the --option overrides, so the display rules read what the first game will.
|
||||
host.probeOptions();
|
||||
|
||||
int rc = a.headless ? (a.tvTestScript.empty() ? runHeadless(a) : runTvScript(a)) : runInteractive(a);
|
||||
|
||||
saveConfig();
|
||||
@@ -2123,7 +2228,7 @@ int App::runInteractive(const CliArgs& a) {
|
||||
ach.images.createTexture = &VideoOut::createTextureRGBA; // RetroAchievements avatar / icons / badges
|
||||
ach.images.destroyTexture = &VideoOut::destroyTexture;
|
||||
// With the home-screen library the boot screen already is the library; the floating window is for in-game use.
|
||||
library.start(paths, cfg, !host.isLoaded() && !homeLibrary);
|
||||
library.start(paths, cfg, !host.isLoaded() && !homeLibrary && !tvMode.active()); // (TV mode's Library is the TV screen, never this window)
|
||||
if (a.showWindow == "library") library.show = true;
|
||||
|
||||
// dev: drive the TV menu from a script (a screenshot of it). A script that cannot start ends the run through the normal teardown.
|
||||
|
||||
+20
-3
@@ -242,6 +242,8 @@ public:
|
||||
float uiScale_ = 1.0f; // the scale the style was set up at (the display's content scale times ui.scale)
|
||||
std::string themeBeforePreview_; // the choice to keep/restore while the theme editor previews an unsaved theme
|
||||
bool themePreviewing_ = false;
|
||||
bool tvHintShownThisRun_ = false; // the one-time TV mode hint (ui.tv_hint_shown) was shown, or tried, in this run
|
||||
unsigned toastCount_ = 0; // toast() calls since start (the TV script counts them: osd_ is capped)
|
||||
bool tvFromCli_ = false; // --tv was given: this run's TV state is not remembered (ui.tv_manual_on)
|
||||
bool themeFromCli_ = false; // --theme was given: this run's choice is not read from / written to config.ini (ui.theme)
|
||||
bool autosaveSram = true;
|
||||
@@ -276,21 +278,34 @@ public:
|
||||
bool homeVisible() const { return homeLibrary && !host.isLoaded() && !tvMode.active(); } // is the desktop library standing in for the game? (TV mode's screens stand in instead)
|
||||
bool desktopWindowOpen() const; // Settings, States, Cheats, About, the library window, the keypad overlay or any popup / menu is open (devices_ui.cpp)
|
||||
void newWindow(); // File > New Window (Ctrl/Cmd+N): another copy of the app (instance.h)
|
||||
void toggleLibrary(); // Ctrl/Cmd+L: the library window, or focus the home screen's search box
|
||||
void toggleLibrary(); // the desktop library window, or focus the home screen's search box (TV mode off)
|
||||
void openLibrary(); // Ctrl/Cmd+L, File > Game library, Cmd::Library: the TV Library while TV mode is on (over the paused game, from the in-game menu, with one), else toggleLibrary() (#991)
|
||||
void reset();
|
||||
void togglePause();
|
||||
// The TV in-game menu's pause (tv/tv_app_host.cpp): like togglePause() but silent, and it remembers whether the
|
||||
// game was already paused, so closing the menu gives back exactly that. False = refused (hardcore rations pausing;
|
||||
// `why` says so). Under netplay it returns true without pausing: the link keeps running.
|
||||
bool menuPause(std::string& why);
|
||||
bool menuPause(std::string& why); // always true: hardcore's refusal (or netplay) opens the menu over a game that keeps running
|
||||
bool menuRunsOn() const { return menuRunsOn_; } // ... because hardcore would not let the menu pause it (the banner says so)
|
||||
bool testDenyPause = false; // tv_script `hardcore-pause deny`: allowPause() refuses, as hardcore does between pauses
|
||||
void menuResume();
|
||||
tv::Shell& tvShell();
|
||||
tv::TvMode tvMode; // TV mode (#991): when it is on
|
||||
bool tvMoreSettings_ = false; // the Settings window was opened by "More settings..." in TV mode (tv_more_settings.h)
|
||||
DesktopPad deskPad; // pad navigation of the desktop library (#1021, desktop_nav_app.h)
|
||||
tv::TvSetting tvSetting = tv::TvSetting::Manual; // ui.tv_mode as stored; effectiveSetting() is what TvMode acts on
|
||||
tv::TvSetting tvSetting = tv::TvSetting::Auto; // ui.tv_mode as stored, else the build default (tv::defaultSetting); TvMode acts on it as is
|
||||
void openBiosSettings(); // the desktop Settings on the BIOS tab; with TV mode on, the pad-driven "More settings" hand-off (the menu first with a game)
|
||||
void openSettings(); // Settings: the TV Settings screen while TV mode is on (the in-game menu with a game), else the desktop window (#991)
|
||||
void toggleTvMode() { tvMode.toggle(); } // View > TV Mode, F4 with no game; a no-op when the setting is Off
|
||||
void configureTvMode(); // tvMode.configure() from tvSetting + the config (also when Settings changes it)
|
||||
// The one way to change the TV-mode settings (Settings > Interface, the TV Settings rows): each writes its key, reconfigures,
|
||||
// and keeps TV mode on if it was on. Neither saves the config; the caller does.
|
||||
void setTvSetting(tv::TvSetting s); // ui.tv_mode, written on change only (saveConfig never writes it); Off clears ui.tv_manual_on
|
||||
void setTvAuto(bool fullscreenTrigger, bool on); // ui.tv_auto_fullscreen (true) / ui.tv_auto_controller (false)
|
||||
bool tvAutoPadPress(const SDL_Event& e); // the Auto controller trigger: true = this press turned TV mode on and is consumed
|
||||
void tvFullscreenEdge(); // tells TvMode `fullscreen` when it differs from tvNoteFsSeen_ (edges only)
|
||||
bool tvSeenOn_ = false; // tvMode.active() as tvInputFrame last saw it (the on edge closes the desktop library window)
|
||||
bool tvNoteFsSeen_ = false; // the fullscreen state TvMode was last told
|
||||
void setFullscreen(bool fs);
|
||||
void applyVsync();
|
||||
void syncSwapInterval();
|
||||
@@ -429,6 +444,8 @@ private:
|
||||
bool waitEventUntil(SDL_Event& e, uint64_t dueNs, Sint32 ms);
|
||||
void latchLog();
|
||||
void latchUpdatePeriod();
|
||||
bool menuRunsOn_ = false; // the menu is open over a running game because allowPause() refused (netplay is its own case)
|
||||
bool pauseAllowed(std::string& why); // ach.allowPause(), or the test seam
|
||||
bool menuHeldPause_ = false; // menuPause() took the game's pause (menuResume gives it back)
|
||||
bool pausedBeforeMenu_ = false; // ... and the game was already paused by the user
|
||||
std::unique_ptr<tv::AppHost> tvHost_; // the TV module's view of this App
|
||||
|
||||
+29
-1
@@ -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
|
||||
|
||||
@@ -227,6 +227,12 @@ public:
|
||||
// `sessionOnly` = the --option overrides; false = the saved config.ini ones.
|
||||
std::vector<std::string> checkOverrides(bool sessionOnly) const;
|
||||
unsigned visibleOptionCount() const;
|
||||
// Reads the core's option definitions without a game (#991): the table the settings screens show from the first
|
||||
// frame. UI thread, once, before the emulation thread exists (App::run, after loadUserOptions and wireHost);
|
||||
// a no-op once the options are known or while a session runs. Only the option commands are answered (everything else is
|
||||
// refused), the core's display pass runs once, and the core is then reset to a fresh process's state (retro_deinit), so the
|
||||
// first load re-pushes everything (startSession) and sees exactly what it would have without a probe.
|
||||
void probeOptions();
|
||||
|
||||
// ---- memory (achievements) --------------------------------------------
|
||||
// The map the core published with SET_MEMORY_MAPS (valid while content is
|
||||
@@ -333,6 +339,7 @@ private:
|
||||
bool inFrame_ = false; // inside retro_run()
|
||||
uint64_t eventsSeen_ = 0;
|
||||
bool initialized_ = false; // retro_init has run (and retro_deinit has not)
|
||||
bool probing_ = false; // probeOptions() is inside retro_set_environment: env() answers the option commands only
|
||||
bool loaded_ = false;
|
||||
std::string contentPath_, contentName_; // the disc in the tray after a swap
|
||||
std::string loadedPath_, loadedName_; // what was opened
|
||||
|
||||
+6
-8
@@ -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();
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -283,6 +283,24 @@ void InputManager::feedHoldEdges(const SDL_Event& e) {
|
||||
}
|
||||
}
|
||||
|
||||
std::string InputManager::tvMenuBindingText(SDL_JoystickID id) const {
|
||||
for (const Pad& p : pads_) {
|
||||
if (p.id != id) continue;
|
||||
const std::vector<Binding>& v = p.profile.binds[C_TVMENU];
|
||||
for (int pass = 0; pass < 2; pass++) // hold bindings first: a hold is the one the default pad uses
|
||||
for (const Binding& b : v)
|
||||
if (b.kind == Binding::PadButton && b.hold == (pass == 0)) return tvMenuButtonName(navProfile(id).family, b);
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string InputManager::tvMenuKeyText() const {
|
||||
for (const Binding& b : kbProfile_.binds[C_TVMENU])
|
||||
if (b.kind == Binding::Key && !b.hold) return b.display();
|
||||
return "";
|
||||
}
|
||||
|
||||
void InputManager::tickHolds(uint64_t ms, bool live) {
|
||||
holds_.setLive(live);
|
||||
if (holds_.takeFires(ms) > 0) tvMenu_ = true;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -28,7 +28,10 @@ void syncToConfig(App& app) { app.saveConfig(); }
|
||||
|
||||
void applyAfterErase(App& app, const std::vector<KeyClass>& touched) {
|
||||
app.applySettingsFromConfig();
|
||||
if (has(touched, KeyClass::Interface)) app.library.resetDisplaySettings();
|
||||
if (has(touched, KeyClass::Interface)) {
|
||||
app.library.resetDisplaySettings();
|
||||
app.tvHintShownThisRun_ = false; // ui.tv_hint_shown was erased with the group: the one-time TV mode hint may show again
|
||||
}
|
||||
if (has(touched, KeyClass::Shader)) {
|
||||
app.video.shader.clearPreset(); // the shader switch and preset came back from the config; drop the loaded chain
|
||||
app.video.shader.retry();
|
||||
|
||||
+235
-56
@@ -8,6 +8,10 @@
|
||||
|
||||
#include "app.h"
|
||||
#include "aspect_choices.h"
|
||||
#include "devices.h"
|
||||
#include "devices_ui.h"
|
||||
#include "platform/platform.h"
|
||||
#include "typed_options.h"
|
||||
#include "ra_dash.h"
|
||||
#include "shaders_ui.h"
|
||||
#include "theme.h"
|
||||
@@ -22,6 +26,7 @@ std::string AppHost::gameTitle() const { return app_.host.contentName(); }
|
||||
bool AppHost::menuPause(std::string& why) { return app_.menuPause(why); }
|
||||
void AppHost::menuResume() { app_.menuResume(); }
|
||||
bool AppHost::netplayActive() const { return app_.netplay.configured(); }
|
||||
bool AppHost::menuRunsOn() const { return app_.menuRunsOn(); }
|
||||
// With a game running: what the achievements policy says. With none, hardcoreActive() is always false (no game is being
|
||||
// looked up), so ask whether hardcore would apply to the next one: a state load started from Home is refused then.
|
||||
bool AppHost::canLoadState() const { return app_.host.isLoaded() ? app_.ach.allowStateLoad() : !app_.ach.hardcoreNextGame(); }
|
||||
@@ -68,7 +73,7 @@ void AppHost::clearHolds() { app_.input.clearHolds(); }
|
||||
void AppHost::toast(const std::string& text) { app_.toast(text, 2.5, 2); }
|
||||
|
||||
bool AppHost::tvActive() const { return app_.tvMode.active(); }
|
||||
bool AppHost::tvAvailable() const { return effectiveSetting(app_.tvSetting) != TvSetting::Off; }
|
||||
bool AppHost::tvAvailable() const { return app_.tvSetting != TvSetting::Off; }
|
||||
|
||||
void AppHost::setTvActive(bool on) {
|
||||
if (on != app_.tvMode.active()) app_.toggleTvMode(); // a no-op when the setting is Off
|
||||
@@ -152,6 +157,12 @@ std::vector<GameRef> AppHost::games(List l) {
|
||||
return out;
|
||||
}
|
||||
|
||||
bool AppHost::hasGames() const {
|
||||
for (const lib::Entry& e : app_.library.entries())
|
||||
if (e.section != lib::Section::Bios) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
Art AppHost::art(const GameRef& g) {
|
||||
const lib::Entry* e = entryFor(g);
|
||||
return e ? artOf(app_.library.art(*e)) : Art();
|
||||
@@ -196,23 +207,28 @@ void AppHost::setFavorite(const GameRef& g, bool on) {
|
||||
|
||||
namespace {
|
||||
|
||||
// The three core options on the TV list: the row id, the option key, the label, the group.
|
||||
struct CoreRow {
|
||||
const char* id;
|
||||
const char* key;
|
||||
const char* label;
|
||||
int group;
|
||||
};
|
||||
const CoreRow kResolution = {"resolution", "virtualjaguar_internal_resolution", "Internal resolution", 0};
|
||||
const CoreRow kRiscClock = {"risc_clock", "virtualjaguar_risc_clock_scale", "RISC overclock", 1};
|
||||
const CoreRow kIdleSkip = {"idle_skip", "virtualjaguar_risc_idle_skip", "Idle-loop skip", 1};
|
||||
|
||||
const OptionDef* defFor(const CoreHost& host, const char* key) {
|
||||
const OptionDef* defFor(const CoreHost& host, const std::string& key) {
|
||||
for (const OptionDef& d : host.optionDefs())
|
||||
if (d.key == key) return &d;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
constexpr float kDeadZoneLo = 0, kDeadZoneHi = 40, kDeadZoneStep = 5;
|
||||
constexpr float kBufferLo = 20, kBufferHi = 200, kBufferStep = 10;
|
||||
constexpr float kPauseLo = 0, kPauseHi = 10, kPauseStep = 0.5f;
|
||||
constexpr float kTileStep = 0.1f;
|
||||
|
||||
// Where a device setting is written: the game's own scope when it already overrides the key (the desktop's "This game only"),
|
||||
// else everywhere -- so the row's value is the one the change takes effect in.
|
||||
std::string deviceScope(App& app, const char* key) {
|
||||
const std::string game = app.input.content();
|
||||
return !game.empty() && DeviceSettings::defined(app.cfg, game, key) ? game : std::string();
|
||||
}
|
||||
|
||||
SettingRow pictureRow(const char* id, const char* label, bool on) { return onOffRow(id, label, "picture", "Picture", on); }
|
||||
SettingRow interfaceRow(const char* id, const char* label, bool on) { return onOffRow(id, label, "interface", "Interface", on); }
|
||||
SettingRow libraryRow(const char* id, const char* label, bool on) { return onOffRow(id, label, "library", "Library", on); }
|
||||
|
||||
} // namespace
|
||||
|
||||
// The custom themes the folder holds; read again once a second (Settings > Appearance does the same: a theme can be added by
|
||||
@@ -226,42 +242,125 @@ const std::vector<std::string>& AppHost::themeNames() {
|
||||
return themeNames_;
|
||||
}
|
||||
|
||||
// Only the rows that exist: no shader runtime, or no core option definitions yet (no game has run this session), or an
|
||||
// option the app locks, means no row -- never a greyed one. The one exception is the Speed group before a game has run: its
|
||||
// only row is the read-only "Available once a game has run" line, so the group is there and says why.
|
||||
// Every setting that is a choice, a toggle or a slider on the desktop (the plan's catalogue), grouped as the rail shows them:
|
||||
// Picture, Sound, Interface, Library, Achievements, Discord, Neuro, then the core's own categories. Only the rows that exist:
|
||||
// no shader runtime, no RetroAchievements client or Discord application means no row -- never a greyed one. A core option the
|
||||
// core hides (or the app locks) is no row either. Settings that need text (folders, BIOS files, sign-in, addresses) are the
|
||||
// "Desktop settings..." row's.
|
||||
std::vector<SettingRow> AppHost::settingRows() {
|
||||
CoreHost& host = app_.host;
|
||||
ShaderChain& sc = app_.video.shader;
|
||||
std::vector<SettingRow> rows;
|
||||
auto core = [&](const CoreRow& cr) {
|
||||
const OptionDef* def = defFor(host, cr.key);
|
||||
bool hidden = false;
|
||||
SettingRow r = coreOptionRow(cr.id, cr.label, cr.group, def, def ? host.optionValue(*def) : std::string(), host.optionVisible(cr.key),
|
||||
host.optionLocked(cr.key), host.optionOverridden(cr.key), &hidden);
|
||||
if (!hidden) rows.push_back(std::move(r));
|
||||
};
|
||||
auto add = [&](SettingRow r) { rows.push_back(std::move(r)); };
|
||||
|
||||
// ---- Picture
|
||||
sc.probeLibrary(); // cached after the first call (the desktop Shader section probes the same way)
|
||||
if (ShaderChain::builtWithHeaders() && sc.libraryLoaded()) rows.push_back(shaderRow(sc.preset, sc.enabled, shaderPresetPaths(app_)));
|
||||
rows.push_back(onOffRow("integer", "Integer scaling", 0, app_.present.integerScale));
|
||||
rows.push_back(aspectRow(app_.present.aspectOverride, kAspectChoices, kAspectChoiceCount));
|
||||
core(kResolution);
|
||||
core(kRiscClock);
|
||||
core(kIdleSkip);
|
||||
if (!defFor(host, kRiscClock.key) && !defFor(host, kIdleSkip.key)) rows.push_back(speedNoteRow()); // no game has run: say why the group is empty
|
||||
rows.push_back(volumeRow(app_.volume));
|
||||
rows.push_back(onOffRow("fps", "Show FPS", 3, app_.showFps));
|
||||
rows.push_back(onOffRow("vsync", "VSync", 3, app_.vsync));
|
||||
rows.push_back(themeRow(theme::choiceKey(), themeNames()));
|
||||
rows.push_back(tvModeRow(app_.tvSetting));
|
||||
rows.push_back(moreRow());
|
||||
if (ShaderChain::builtWithHeaders() && sc.libraryLoaded()) add(shaderRow(sc.preset, sc.enabled, shaderPresetPaths(app_)));
|
||||
add(pictureRow("integer", "Integer scaling", app_.present.integerScale));
|
||||
add(pictureRow("smooth", "Smooth (bilinear) filter", app_.present.linearFilter));
|
||||
add(pictureRow("stretch", "Stretch to window (ignore aspect ratio)", app_.present.stretch));
|
||||
add(aspectRow(app_.present.aspectOverride, kAspectChoices, kAspectChoiceCount));
|
||||
add(pictureRow("vsync", "VSync", app_.vsync));
|
||||
add(pictureRow("fullscreen", "Fullscreen", app_.fullscreen));
|
||||
|
||||
// ---- Sound
|
||||
add(volumeRow(app_.volume));
|
||||
add(onOffRow("mute", "Mute", "sound", "Sound", app_.mute));
|
||||
add(sliderRow("audio_buffer", "Audio buffer", "sound", "Sound", app_.audioTargetMs, kBufferLo, kBufferHi, kBufferStep, "%.0f ms"));
|
||||
|
||||
// ---- Interface
|
||||
add(interfaceRow("fps", "Show FPS", app_.showFps));
|
||||
add(interfaceRow("controls_at_start", "Show the controls overlay when a game starts", app_.cfg.getBool("ui.controls.autoshow", false)));
|
||||
add(themeRow(theme::choiceKey(), themeNames()));
|
||||
add(tvModeRow(app_.tvSetting));
|
||||
for (SettingRow& r : tvAutoRows(app_.tvSetting, app_.cfg.getBool("ui.tv_auto_fullscreen", true),
|
||||
app_.cfg.getBool("ui.tv_auto_controller", true)))
|
||||
add(std::move(r));
|
||||
if (platform::nativeMenuActive()) add(interfaceRow("menu_in_window", "Show the menu bar inside the window", app_.showInWindowMenu));
|
||||
add(interfaceRow("menu_autohide", "Auto-hide the menu bar in fullscreen", app_.fullscreenAutoHideMenu));
|
||||
{
|
||||
SettingRow r = interfaceRow("windows_outside", "Let windows move outside the main window", app_.windowsOutside);
|
||||
r.note = app_.viewportsNote(); // why it is ignored right now, or ""
|
||||
add(std::move(r));
|
||||
}
|
||||
add(interfaceRow("keep_awake", "Keep the display awake while a game is running", app_.keepAwake));
|
||||
add(interfaceRow("tray", "Show a tray icon", app_.trayIcon));
|
||||
{
|
||||
SettingRow r = interfaceRow("emu_thread", "Emulation thread", app_.emuThreadWanted());
|
||||
if (app_.emuThreadWanted() != app_.emuThreadThisRun()) r.note = "Restart the app to apply";
|
||||
add(std::move(r));
|
||||
}
|
||||
{
|
||||
const DeviceSettings ds = DeviceSettings::load(app_.cfg, app_.input.content());
|
||||
add(sliderRow("dead_zone", "Stick dead zone", "interface", "Interface", (float)ds.deadzonePct, kDeadZoneLo, kDeadZoneHi, kDeadZoneStep, "%.0f %%"));
|
||||
add(choiceRow("mouse_port", "Mouse drives", "interface", "Interface", {"Automatic", "Jaguar port 1", "Jaguar port 2"},
|
||||
std::clamp(ds.mousePort + 1, 0, 2)));
|
||||
add(choiceRow("gun_source", "Light gun aims with", "interface", "Interface", {"Automatic", "Mouse only", "Stick / keys only"},
|
||||
std::clamp(ds.gunSource, 0, 2)));
|
||||
}
|
||||
add(interfaceRow("flush_saves", "Flush saves every few seconds", app_.autosaveSram));
|
||||
add(moreRow());
|
||||
|
||||
// ---- Library
|
||||
{
|
||||
std::vector<std::string> modes, shows;
|
||||
for (int m = 0; m < lib::kResumeModeCount; m++) modes.push_back(lib::resumeModeLabel(m));
|
||||
for (int m = 0; m < lib::kShowAsCount; m++) shows.push_back(lib::showAsLabel(m));
|
||||
add(choiceRow("resume", "Resume where I left off", "library", "Library", modes, std::clamp(app_.resumeMode, 0, lib::kResumeModeCount - 1)));
|
||||
add(libraryRow("home_library", "Show the game library on the home screen", app_.homeLibrary));
|
||||
add(choiceRow("show_as", "Favorites show as", "library", "Library", shows, std::clamp(app_.library.favoritesShow, 0, lib::kShowAsCount - 1)));
|
||||
add(choiceRow("show_as_recent", "Recently played shows as", "library", "Library", shows,
|
||||
std::clamp(app_.library.recentsShow, 0, lib::kShowAsCount - 1)));
|
||||
add(sliderRow("tile_size", "Tile size", "library", "Library", app_.library.cellScale, lib::kTileScaleMin, lib::kTileScaleMax, kTileStep, "%.1fx"));
|
||||
add(libraryRow("download_art", "Download box art", app_.library.downloadArt.load()));
|
||||
add(libraryRow("close_on_launch", "Close the library on launch", app_.library.hideOnLaunch));
|
||||
}
|
||||
|
||||
// ---- Achievements (a build without the client has nothing to set)
|
||||
if (app_.ach.available()) add(onOffRow("ra_images", "Download pictures", "achievements", "Achievements", app_.ach.imagesWanted()));
|
||||
|
||||
// ---- Discord (no application ID, or a second copy of the app, publishes nothing)
|
||||
if (app_.discordPresence.available()) {
|
||||
add(onOffRow("discord_enabled", "Show what I'm playing on Discord", "discord", "Discord", app_.cfg.getBool("discord.enabled", true)));
|
||||
add(onOffRow("discord_art", "Show game artwork", "discord", "Discord", app_.cfg.getBool("discord.art", true)));
|
||||
add(onOffRow("discord_buttons", "Show buttons", "discord", "Discord", app_.cfg.getBool("discord.buttons", true)));
|
||||
}
|
||||
|
||||
#ifdef VJ_HAVE_NEURO
|
||||
add(onOffRow("neuro_enabled", "Connect to Neuro", "neuro", "Neuro", app_.cfg.getBool("neuro.enabled", false)));
|
||||
add(onOffRow("neuro_auto_force", "Ask for the next move when the last one is done", "neuro", "Neuro", app_.cfg.getBool("neuro.auto_force", true)));
|
||||
add(sliderRow("neuro_interval", "Pause between moves", "neuro", "Neuro", app_.cfg.getFloat("neuro.force_interval", 1.0f), kPauseLo, kPauseHi, kPauseStep, "%.1f s"));
|
||||
#endif
|
||||
|
||||
// ---- the core's own groups (read at startup: CoreHost::probeOptions)
|
||||
for (SettingRow& r : coreOptionRows(
|
||||
host.optionCategories(), host.optionDefs(), [&](const OptionDef& d) { return host.optionValue(d); },
|
||||
[&](const std::string& k) { return host.optionVisible(k); }, [&](const std::string& k) { return host.optionOverridden(k); },
|
||||
[](const std::string& k) { return typedOptionFor(k); }))
|
||||
rows.push_back(std::move(r));
|
||||
return rows;
|
||||
}
|
||||
|
||||
// What the desktop control does for each row, then the config is saved. An index the row cannot have (the "(custom)" entry
|
||||
// of a typed option, a theme whose file is gone) changes nothing.
|
||||
// of a typed option or a slider, a theme whose file is gone) changes nothing.
|
||||
void AppHost::setSetting(const std::string& id, int index) {
|
||||
CoreHost& host = app_.host;
|
||||
if (id == "shader") {
|
||||
const bool on = index != 0;
|
||||
auto deviceChanged = [&] { // devices_ui.cpp's deviceChanged (the config is saved below)
|
||||
app_.input.reresolve();
|
||||
applyPortDevices(app_);
|
||||
};
|
||||
if (id.rfind("core:", 0) == 0) {
|
||||
const std::string key = id.substr(5);
|
||||
const OptionDef* def = defFor(host, key);
|
||||
// The call drawOption makes: the app's own hardcore handling (Achievements::checkSettings) sees it as any other change.
|
||||
if (def && host.optionVisible(key) && !host.optionOverridden(key) && index >= 0) {
|
||||
// The row may carry a "(custom)" entry among the values (coreCustomPos): that index changes nothing, the ones after it shift.
|
||||
const int custom = coreCustomPos(*def, host.optionValue(*def), typedOptionFor(key));
|
||||
const int at = custom >= 0 && index > custom ? index - 1 : index;
|
||||
if (index != custom && at < (int)def->values.size()) host.setOptionValue(key, def->values[(size_t)at].value);
|
||||
}
|
||||
} else if (id == "shader") {
|
||||
ShaderChain& sc = app_.video.shader;
|
||||
const std::vector<std::string> paths = shaderPresetPaths(app_);
|
||||
if (index == 0) {
|
||||
@@ -271,35 +370,115 @@ void AppHost::setSetting(const std::string& id, int index) {
|
||||
applyShaderPreset(app_, paths[(size_t)index - 1]);
|
||||
}
|
||||
} else if (id == "integer") {
|
||||
app_.present.integerScale = index != 0;
|
||||
app_.present.integerScale = on;
|
||||
} else if (id == "smooth") {
|
||||
app_.present.linearFilter = on;
|
||||
} else if (id == "stretch") {
|
||||
app_.present.stretch = on;
|
||||
} else if (id == "aspect") {
|
||||
if (index >= 0 && index < kAspectChoiceCount) app_.present.aspectOverride = kAspectChoices[index].value;
|
||||
} else if (id == "resolution" || id == "risc_clock" || id == "idle_skip") {
|
||||
const CoreRow& cr = id == "resolution" ? kResolution : id == "risc_clock" ? kRiscClock : kIdleSkip;
|
||||
const OptionDef* def = defFor(host, cr.key);
|
||||
// The call drawOption makes: the app's own hardcore handling (Achievements::checkSettings) sees it as any other change.
|
||||
if (def && !host.optionOverridden(cr.key) && index >= 0 && index < (int)def->values.size())
|
||||
host.setOptionValue(cr.key, def->values[(size_t)index].value);
|
||||
} else if (id == "vsync") {
|
||||
app_.vsync = on;
|
||||
app_.applyVsync();
|
||||
} else if (id == "fullscreen") {
|
||||
app_.setFullscreen(on);
|
||||
} else if (id == "volume") {
|
||||
app_.volume = (float)std::clamp(index, 0, 10) / 10.0f;
|
||||
app_.volume = stepValue(volumeRow(app_.volume), index, 0.0f, 10.0f, app_.volume * 100.0f) / 100.0f;
|
||||
app_.applyAudioSettings();
|
||||
} else if (id == "mute") {
|
||||
app_.mute = on;
|
||||
app_.applyAudioSettings();
|
||||
} else if (id == "audio_buffer") {
|
||||
const SettingRow r = sliderRow(id, "", "sound", "Sound", app_.audioTargetMs, kBufferLo, kBufferHi, kBufferStep, "%.0f");
|
||||
app_.audioTargetMs = stepValue(r, index, kBufferLo, kBufferStep, app_.audioTargetMs);
|
||||
app_.applyAudioSettings();
|
||||
} else if (id == "fps") {
|
||||
app_.showFps = index != 0;
|
||||
} else if (id == "vsync") {
|
||||
app_.vsync = index != 0;
|
||||
app_.applyVsync();
|
||||
app_.showFps = on;
|
||||
} else if (id == "controls_at_start") {
|
||||
app_.cfg.setBool("ui.controls.autoshow", on);
|
||||
} else if (id == "theme") {
|
||||
const std::string key = themeKeyAt(index, themeNames());
|
||||
if (!key.empty()) app_.setThemeChoice(key);
|
||||
} else if (id == "tv_mode") {
|
||||
if (index >= 0 && index <= (int)TvSetting::Auto) {
|
||||
const bool wasOn = app_.tvMode.active();
|
||||
app_.tvSetting = (TvSetting)index;
|
||||
app_.cfg.set("ui.tv_mode", settingKey(app_.tvSetting)); // written on change only: saveConfig() leaves the default free to move
|
||||
if (app_.tvSetting == TvSetting::Off) app_.cfg.setBool("ui.tv_manual_on", false); // or a later Manual would switch TV on at once
|
||||
app_.configureTvMode(); // starts from off ...
|
||||
if (wasOn) app_.tvMode.setOn(true); // ... so a Manual <-> Auto change stays in TV mode; Off stays off (setOn is a no-op then)
|
||||
app_.setTvSetting((TvSetting)index); // writes ui.tv_mode on change only, stays in TV mode across Manual <-> Auto
|
||||
}
|
||||
} else if (id == "tv_auto_fs") {
|
||||
app_.setTvAuto(true, index == 1); // ui.tv_auto_fullscreen; a trigger-started TV mode stays on, as a Manual one
|
||||
} else if (id == "tv_auto_pad") {
|
||||
app_.setTvAuto(false, index == 1); // ui.tv_auto_controller
|
||||
} else if (id == "menu_in_window") {
|
||||
app_.showInWindowMenu = on;
|
||||
} else if (id == "menu_autohide") {
|
||||
app_.fullscreenAutoHideMenu = on;
|
||||
} else if (id == "windows_outside") {
|
||||
app_.windowsOutside = on;
|
||||
} else if (id == "keep_awake") {
|
||||
app_.keepAwake = on;
|
||||
} else if (id == "tray") {
|
||||
app_.trayIcon = on;
|
||||
if (!app_.applyTray()) { // as the desktop checkbox: a desktop with no tray puts it back
|
||||
app_.trayIcon = false;
|
||||
app_.toast("No system tray is available on this desktop", 4.0, 2);
|
||||
}
|
||||
} else if (id == "emu_thread") {
|
||||
app_.emuThreadPref = on ? 1 : 0;
|
||||
} else if (id == "dead_zone") {
|
||||
const float cur = (float)DeviceSettings::load(app_.cfg, app_.input.content()).deadzonePct;
|
||||
const SettingRow r = sliderRow(id, "", "interface", "Interface", cur, kDeadZoneLo, kDeadZoneHi, kDeadZoneStep, "%.0f");
|
||||
const float pct = stepValue(r, index, kDeadZoneLo, kDeadZoneStep, cur);
|
||||
if (pct != cur) {
|
||||
DeviceSettings::set(app_.cfg, deviceScope(app_, "deadzone"), "deadzone", std::to_string((int)pct));
|
||||
deviceChanged();
|
||||
}
|
||||
} else if (id == "mouse_port") {
|
||||
if (index >= 0 && index <= 2) {
|
||||
DeviceSettings::set(app_.cfg, deviceScope(app_, "mouse_port"), "mouse_port", std::to_string(index - 1));
|
||||
deviceChanged();
|
||||
}
|
||||
} else if (id == "gun_source") {
|
||||
if (index >= 0 && index <= 2) {
|
||||
DeviceSettings::set(app_.cfg, deviceScope(app_, "gun_source"), "gun_source", std::to_string(index));
|
||||
deviceChanged();
|
||||
}
|
||||
} else if (id == "flush_saves") {
|
||||
app_.autosaveSram = on;
|
||||
} else if (id == "resume") {
|
||||
if (index >= 0 && index < lib::kResumeModeCount) app_.resumeMode = index;
|
||||
} else if (id == "home_library") {
|
||||
app_.homeLibrary = on;
|
||||
} else if (id == "show_as" || id == "show_as_recent") {
|
||||
if (index >= 0 && index < lib::kShowAsCount) {
|
||||
(id == "show_as" ? app_.library.favoritesShow : app_.library.recentsShow) = index;
|
||||
app_.library.saveSettings();
|
||||
}
|
||||
} else if (id == "tile_size") {
|
||||
const SettingRow r = sliderRow(id, "", "library", "Library", app_.library.cellScale, lib::kTileScaleMin, lib::kTileScaleMax, kTileStep, "%.1f");
|
||||
app_.library.cellScale = lib::clampTileScale(stepValue(r, index, lib::kTileScaleMin, kTileStep, app_.library.cellScale));
|
||||
app_.library.saveSettings();
|
||||
} else if (id == "download_art") {
|
||||
app_.library.setDownloadArt(on);
|
||||
} else if (id == "close_on_launch") {
|
||||
app_.library.hideOnLaunch = on;
|
||||
app_.library.saveSettings();
|
||||
} else if (id == "ra_images") {
|
||||
app_.ach.setImagesWanted(on);
|
||||
} else if (id == "discord_enabled") {
|
||||
app_.cfg.setBool("discord.enabled", on);
|
||||
} else if (id == "discord_art") {
|
||||
app_.cfg.setBool("discord.art", on);
|
||||
} else if (id == "discord_buttons") {
|
||||
app_.cfg.setBool("discord.buttons", on);
|
||||
#ifdef VJ_HAVE_NEURO
|
||||
} else if (id == "neuro_enabled") {
|
||||
app_.cfg.setBool("neuro.enabled", on);
|
||||
} else if (id == "neuro_auto_force") {
|
||||
app_.cfg.setBool("neuro.auto_force", on);
|
||||
} else if (id == "neuro_interval") {
|
||||
const float cur = app_.cfg.getFloat("neuro.force_interval", 1.0f);
|
||||
const SettingRow r = sliderRow(id, "", "neuro", "Neuro", cur, kPauseLo, kPauseHi, kPauseStep, "%.1f");
|
||||
app_.cfg.setFloat("neuro.force_interval", stepValue(r, index, kPauseLo, kPauseStep, cur));
|
||||
#endif
|
||||
}
|
||||
app_.saveConfig();
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ public:
|
||||
bool menuPause(std::string& why) override;
|
||||
void menuResume() override;
|
||||
bool netplayActive() const override;
|
||||
bool menuRunsOn() const override;
|
||||
bool canLoadState() const override;
|
||||
std::vector<SlotInfo> slots() override;
|
||||
bool saveState(int slot) override;
|
||||
@@ -43,6 +44,7 @@ public:
|
||||
bool desktopWindowOpen() const override;
|
||||
bool libraryScanning() const override;
|
||||
std::vector<GameRef> games(List l) override;
|
||||
bool hasGames() const override;
|
||||
Art art(const GameRef& g) override;
|
||||
std::vector<StateRef> states(const GameRef& g) override;
|
||||
Art stateThumb(const GameRef& g, const StateRef& state) override;
|
||||
|
||||
@@ -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_));
|
||||
|
||||
+15
-8
@@ -242,8 +242,7 @@ private:
|
||||
void play() {
|
||||
const GameRef* g = focused();
|
||||
if (!g) return;
|
||||
host_.launch(*g);
|
||||
shell_.launched();
|
||||
shell_.requestLaunch(*g, nullptr);
|
||||
}
|
||||
|
||||
// Start / a right-click: Game details of the focused game. Back from there lands on this tile again.
|
||||
@@ -259,8 +258,8 @@ private:
|
||||
if (!g || !host_.canLoadState()) return;
|
||||
const std::vector<StateRef> st = host_.states(*g);
|
||||
if (st.empty()) return;
|
||||
host_.launchState(*g, st[0]);
|
||||
shell_.launched();
|
||||
const StateRef newest = st[0];
|
||||
shell_.requestLaunch(*g, &newest);
|
||||
}
|
||||
|
||||
widgets::TileArgs tileArgs(const GameRef& g) {
|
||||
@@ -277,10 +276,18 @@ private:
|
||||
void drawEmpty(const DrawCtx& c) {
|
||||
const Metrics& m = c.m;
|
||||
const float u = m.u, w = std::min(1100 * u, c.size.x - 2 * m.safeX), pad = 48 * u;
|
||||
// The first scan of a new install (or of an empty cache) is still running: the library is not empty yet.
|
||||
const std::string msg = host_.libraryScanning()
|
||||
? "Scanning your library..."
|
||||
: "Your library is empty. Add game folders in desktop mode: View > TV mode turns TV mode off.";
|
||||
// Games but no shelves (a new player: nothing played, no favorites, no discs) is not an empty library: the Library
|
||||
// section lists them. Otherwise the first scan of a new install (or of an empty cache) may still be running.
|
||||
std::string msg;
|
||||
if (host_.hasGames()) {
|
||||
std::string next = host_.glyph(Intent::SectionNext); // the button the section bar shows
|
||||
if (next.empty()) next = "RB";
|
||||
msg = "Nothing to continue yet. Press " + next + " for your Library.";
|
||||
} else {
|
||||
msg = host_.libraryScanning()
|
||||
? "Scanning your library..."
|
||||
: "Your library is empty. Add game folders in desktop mode: View > TV mode turns TV mode off.";
|
||||
}
|
||||
const ImVec2 ts = widgets::textSize(m.body, msg, w - 2 * pad);
|
||||
const ImVec2 size(w, ts.y + 2 * pad);
|
||||
const ImVec2 pos(c.pos.x + (c.size.x - size.x) * 0.5f, c.pos.y + (c.size.y - size.y) * 0.5f);
|
||||
|
||||
@@ -52,12 +52,13 @@ enum class List { All, Favorites, Recent, Disc };
|
||||
// One row of the TV Settings screen, by value (AppHost fills them from the app's settings and the core's options, so the
|
||||
// screen never sees either). `choices` are what Left / Right / Accept step through; "more" has none.
|
||||
struct SettingRow {
|
||||
std::string id, label; // ids: shader integer aspect resolution risc_clock idle_skip speed_note volume fps vsync theme tv_mode more
|
||||
std::string id, label; // app rows: shader integer smooth stretch aspect vsync fullscreen volume mute audio_buffer fps ... more; a core option: "core:" + its key
|
||||
std::vector<std::string> choices; // empty for "more"
|
||||
int index = 0;
|
||||
bool readOnly = false;
|
||||
std::string note; // shown under the value: why it is read-only
|
||||
int group = 0; // 0 Picture, 1 Speed, 2 Sound, 3 Interface
|
||||
std::string note; // shown under the value: why it is read-only, or what to know about it
|
||||
std::string group; // the rail item's key: "picture" "sound" "interface" "library" "achievements" "discord" "neuro", "core:" + a category key ("core:" = no category)
|
||||
std::string groupLabel; // ... and what the rail shows for it
|
||||
};
|
||||
|
||||
// One achievement of the loaded game, or one active leaderboard tracker (kind Tracker: read-only, no status). AppHost fills them
|
||||
@@ -102,9 +103,10 @@ public:
|
||||
virtual double now() const = 0;
|
||||
virtual bool gameLoaded() const = 0;
|
||||
virtual std::string gameTitle() const = 0;
|
||||
virtual bool menuPause(std::string& why) = 0; // false = refused (hardcore rationing); netplay: true, not paused
|
||||
virtual bool menuPause(std::string& why) = 0; // App: always true -- netplay and hardcore's refusal open the menu over a game that keeps running
|
||||
virtual void menuResume() = 0; // restores the pause state from before the menu
|
||||
virtual bool netplayActive() const = 0; // netplay.configured()
|
||||
virtual bool menuRunsOn() const { return false; } // the menu is over a running game because hardcore would not pause it (banner)
|
||||
virtual bool canLoadState() const = 0; // ach.allowStateLoad()
|
||||
virtual std::vector<SlotInfo> slots() = 0; // 0..9, thumbnails from App::slotThumb
|
||||
virtual bool saveState(int slot) = 0;
|
||||
@@ -140,6 +142,7 @@ public:
|
||||
// All / Disc / Favorites: by title, case-insensitive; Recent: newest first. Never BIOS. Fetched fresh every frame.
|
||||
virtual bool libraryScanning() const = 0; // a scan is running (an empty library may still fill up)
|
||||
virtual std::vector<GameRef> games(List l) = 0;
|
||||
virtual bool hasGames() const = 0; // the library lists at least one game (never BIOS); no copy, cheap enough for a frame
|
||||
virtual Art art(const GameRef& g) = 0; // starts the load on first use; tex 0 until it is ready
|
||||
virtual std::vector<StateRef> states(const GameRef& g) = 0;
|
||||
virtual Art stateThumb(const GameRef& g, const StateRef& state) = 0;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// TV mode (#991): the in-game menu drawn over the dimmed game -- Shell's row model (tv_shell.h) as a rail and a list.
|
||||
// The rail names the game and the page; the list is the rows (a slot page shows each slot's picture) -- or, while a screen
|
||||
// sits in the menu's slot (Achievements, Overlays, Settings), that screen drawn in the list area; a hint bar at the
|
||||
// sits in the menu's slot (Achievements, Overlays, Settings), that screen drawn in the list area (the Library takes the whole panel, no
|
||||
// rail: Screen::wide()); a hint bar at the
|
||||
// bottom lists only what the focused row (or the screen) can do. Hover and click are rectangle tests (no ImGui item), so this window
|
||||
// never holds ImGui's active item and never makes io.WantCaptureKeyboard true.
|
||||
#include <SDL3/SDL.h>
|
||||
@@ -76,20 +77,24 @@ void Shell::drawMenu(ImVec2 pos, ImVec2 size) {
|
||||
|
||||
// ---- the rail: the page, and the game -------------------------------------------------------------------------
|
||||
const bool screen = menuScreen_ != nullptr; // a screen in the slot: its name is the page
|
||||
const bool wide = screen && menuScreen_->wide(); // ... that takes the whole panel (the Library): no rail
|
||||
const bool root = page_ == Page::Root && !screen;
|
||||
const float railX = pp.x + inset;
|
||||
float y = pp.y + inset;
|
||||
const ImVec2 t1 = widgets::text(dl, m.titleSmall, ImVec2(railX, y), widgets::col(pal.text),
|
||||
screen ? capitalised(menuScreen_->id()) : title_, 0, railW);
|
||||
y += t1.y + 12 * m.u;
|
||||
widgets::text(dl, m.caption, ImVec2(railX, y), widgets::col(pal.textMuted),
|
||||
root ? std::string(host_.netplayActive() ? "Menu" : "Paused") : host_.gameTitle(), railW);
|
||||
if (!wide) {
|
||||
float y = pp.y + inset;
|
||||
const ImVec2 t1 = widgets::text(dl, m.titleSmall, ImVec2(railX, y), widgets::col(pal.text),
|
||||
screen ? capitalised(menuScreen_->id()) : title_, 0, railW);
|
||||
y += t1.y + 12 * m.u;
|
||||
widgets::text(dl, m.caption, ImVec2(railX, y), widgets::col(pal.textMuted),
|
||||
root ? std::string(host_.netplayActive() || host_.menuRunsOn() ? "Menu" : "Paused") : host_.gameTitle(), railW);
|
||||
}
|
||||
|
||||
// ---- the list -------------------------------------------------------------------------------------------------
|
||||
const float listX = railX + railW + inset;
|
||||
const float listX = wide ? railX : railX + railW + inset;
|
||||
const float listW = pp.x + ps.x - inset - listX;
|
||||
float listY = pp.y + inset;
|
||||
if (host_.netplayActive()) listY += widgets::banner(dl, m, ImVec2(listX, listY), listW, "Netplay: the game keeps running") + 20 * m.u;
|
||||
else if (host_.menuRunsOn()) listY += widgets::banner(dl, m, ImVec2(listX, listY), listW, "Hardcore: the game keeps running") + 20 * m.u;
|
||||
const float rowH = (slotPage ? 124 : 88) * m.u, gap = 14 * m.u;
|
||||
const float listH = hintY - 24 * m.u - listY;
|
||||
const int visible = std::max(1, (int)((listH + gap) / (rowH + gap)));
|
||||
@@ -160,7 +165,7 @@ void Shell::drawMenu(ImVec2 pos, ImVec2 size) {
|
||||
const char* verb = "Select";
|
||||
if (page_ == Page::SaveSlots) verb = "Save";
|
||||
else if (page_ == Page::LoadSlots) verb = "Load";
|
||||
else if (page_ == Page::ConfirmReset || page_ == Page::ConfirmClose) verb = "Choose";
|
||||
else if (page_ == Page::ConfirmReset || page_ == Page::ConfirmClose || page_ == Page::ConfirmLaunch) verb = "Choose";
|
||||
hints.push_back({host_.glyph(Intent::Accept), verb});
|
||||
}
|
||||
hints.push_back({host_.glyph(Intent::Back), root ? "Resume" : "Back"});
|
||||
|
||||
+32
-16
@@ -27,7 +27,7 @@ constexpr int kCols = 6;
|
||||
constexpr float kRailW = 344, kRailGap = 64, kItemH = 54, kItemGap = 8; // design units
|
||||
constexpr float kGridTop = 196 - 150; // from the top of the content area (y = 150)
|
||||
constexpr float kRowExtra = 72; // under each tile: room for the focused tile's caption
|
||||
constexpr float kColGap = 32;
|
||||
constexpr float kColGap = 32, kColGapMenu = 12;
|
||||
constexpr size_t kMaxFilter = 32;
|
||||
const char* const kLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; // the picker's cells, row by row
|
||||
constexpr int kLetterCount = 36;
|
||||
@@ -63,6 +63,7 @@ public:
|
||||
}
|
||||
|
||||
const char* id() const override { return "library"; }
|
||||
bool wide() const override { return embedded(); } // in the in-game menu's slot the rail + grid take the whole panel
|
||||
|
||||
void intent(Intent in) override {
|
||||
refresh();
|
||||
@@ -115,7 +116,10 @@ public:
|
||||
const Metrics& m = c.m;
|
||||
const theme::Palette& pal = theme::pal();
|
||||
const float u = m.u;
|
||||
const float x0 = c.pos.x + m.safeX, right = c.pos.x + c.size.x - m.safeX;
|
||||
// In the in-game menu's slot the area is the menu's panel inside its inset: no safe margin, only the room the focused tile and its
|
||||
// ring grow into.
|
||||
const float edge = embedded() ? 12 * u : m.safeX;
|
||||
const float x0 = c.pos.x + edge, right = c.pos.x + c.size.x - edge;
|
||||
|
||||
if (!mouseSeen_) { // a pointer already resting on a tile when the screen appears has not chosen it
|
||||
lastMouse_ = c.mouse;
|
||||
@@ -124,7 +128,7 @@ public:
|
||||
const bool moved = c.mouse.x != lastMouse_.x || c.mouse.y != lastMouse_.y;
|
||||
lastMouse_ = c.mouse;
|
||||
// The letter picker is drawn over this screen with the same pointer: it is the one that gets it.
|
||||
const bool live = c.hovered && shell_.screenId() == id();
|
||||
const bool live = c.hovered && (embedded() || shell_.screenId() == id()); // (the slot's screen is not on the Base stack)
|
||||
const bool pick = live && (moved || c.clicked || c.rightClicked);
|
||||
const bool clicked = live && c.clicked, rightClicked = live && c.rightClicked;
|
||||
|
||||
@@ -147,7 +151,10 @@ public:
|
||||
|
||||
// ---- the grid ------------------------------------------------------------------------------------------------
|
||||
const float gx = x0 + (kRailW + kRailGap) * u, gridW = right - gx;
|
||||
const float tileW = std::max(1.0f, (gridW - (kCols - 1) * kColGap * u) / (float)kCols), tileH = tileW * 7 / 5;
|
||||
// In the menu's slot the grid is narrower (the panel, not the screen): tighter columns keep a placeholder tile wide enough for a
|
||||
// whole word of a title ("Cybermorph") at the 24 u floor.
|
||||
const float colGap = (embedded() ? kColGapMenu : kColGap) * u;
|
||||
const float tileW = std::max(1.0f, (gridW - (kCols - 1) * colGap) / (float)kCols), tileH = tileW * 7 / 5;
|
||||
const float pitch = tileH + kRowExtra * u;
|
||||
const float top = c.pos.y + kGridTop * u, bottom = c.pos.y + c.size.y;
|
||||
const int visibleRows = std::max(1, (int)((bottom - top + kRowExtra * u) / pitch));
|
||||
@@ -192,10 +199,13 @@ public:
|
||||
for (int r = 0; r < grid_.rows(); r++) {
|
||||
const float y = top + (float)r * pitch - scroll;
|
||||
if (y + pitch <= clipTop || y >= bottom) continue;
|
||||
// In the menu's slot a row the panel's foot would cut through (its title lies at the tile's foot) waits until it fits whole
|
||||
// (while the rows scroll it may enter by the edge).
|
||||
if (embedded() && scroll == scroll_.to && y + tileH > bottom + 0.5f) continue;
|
||||
for (int col = 0; col < kCols; col++) {
|
||||
const int j = r * kCols + col;
|
||||
if (j >= n) break;
|
||||
const float tx = gx + (float)col * (tileW + kColGap * u);
|
||||
const float tx = gx + (float)col * (tileW + colGap);
|
||||
widgets::TileArgs a = tileArgs(games_[(size_t)j]);
|
||||
a.focus = !onRail_ && j == grid_.index ? tweens_.at(j, c.now) : 0.0f;
|
||||
const widgets::Hit hit = widgets::tile(c.dl, m, ImVec2(tx, y), ImVec2(tileW, tileH), a);
|
||||
@@ -232,15 +242,18 @@ public:
|
||||
}
|
||||
|
||||
std::vector<widgets::Hint> hints() const override {
|
||||
if (games_.empty()) return {{host_.glyph(Intent::Back), "Home"}}; // an empty grid: no game to open, play or jump in
|
||||
if (onRail_) return {{host_.glyph(Intent::Accept), "Open"}, {host_.glyph(Intent::Back), "Home"}};
|
||||
// In the menu's slot Back leaves for the menu, and Details / the letter picker (which push onto the Base stack, not shown under a
|
||||
// game) are not offered.
|
||||
const char* way = embedded() ? "Menu" : "Home";
|
||||
if (games_.empty()) return {{host_.glyph(Intent::Back), way}}; // an empty grid: no game to open, play or jump in
|
||||
if (onRail_) return {{host_.glyph(Intent::Accept), "Open"}, {host_.glyph(Intent::Back), way}};
|
||||
std::vector<widgets::Hint> h = {{host_.glyph(Intent::Accept), "Play"}};
|
||||
if (resumeOk_) h.push_back({host_.glyph(Intent::Alt), "Resume state"});
|
||||
h.push_back({host_.glyph(Intent::Menu), "Details"});
|
||||
h.push_back({host_.glyph(Intent::Aux), "Jump to letter"});
|
||||
if (!embedded()) h.push_back({host_.glyph(Intent::Menu), "Details"});
|
||||
if (!embedded()) h.push_back({host_.glyph(Intent::Aux), "Jump to letter"});
|
||||
const std::string lt = host_.glyph(Intent::PagePrev), rt = host_.glyph(Intent::PageNext);
|
||||
h.push_back({lt.empty() && rt.empty() ? std::string() : lt + " " + rt, "A\xE2\x80\x93Z"});
|
||||
h.push_back({host_.glyph(Intent::Back), "Home"});
|
||||
h.push_back({host_.glyph(Intent::Back), way});
|
||||
return h;
|
||||
}
|
||||
|
||||
@@ -322,10 +335,14 @@ private:
|
||||
case Intent::Down: grid_.move(0, 1); break;
|
||||
case Intent::Accept: play(); break;
|
||||
case Intent::Alt: resume(); break;
|
||||
case Intent::Aux: openLetters(); break;
|
||||
case Intent::Aux:
|
||||
if (!embedded()) openLetters(); // (a screen pushed on the Base stack does not show under a game)
|
||||
break;
|
||||
case Intent::PagePrev: grid_.index = jumpInitial(titles_, grid_.index, -1); break;
|
||||
case Intent::PageNext: grid_.index = jumpInitial(titles_, grid_.index, 1); break;
|
||||
case Intent::Menu: details(); break;
|
||||
case Intent::Menu:
|
||||
if (!embedded()) details();
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
@@ -339,8 +356,7 @@ private:
|
||||
void play() {
|
||||
const GameRef* g = focused();
|
||||
if (!g) return;
|
||||
host_.launch(*g);
|
||||
shell_.launched();
|
||||
shell_.requestLaunch(*g, nullptr); // asks first when a game is running (the menu's slot)
|
||||
}
|
||||
|
||||
// Start / a right-click: Game details of the focused game. Back from there lands on this tile again.
|
||||
@@ -356,8 +372,8 @@ private:
|
||||
if (!g || !host_.canLoadState()) return;
|
||||
const std::vector<StateRef> st = host_.states(*g);
|
||||
if (st.empty()) return;
|
||||
host_.launchState(*g, st[0]);
|
||||
shell_.launched();
|
||||
const StateRef newest = st[0];
|
||||
shell_.requestLaunch(*g, &newest);
|
||||
}
|
||||
|
||||
widgets::TileArgs tileArgs(const GameRef& g) {
|
||||
|
||||
+15
-2
@@ -1,6 +1,7 @@
|
||||
#include "tv/tv_mode.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
namespace vj::tv {
|
||||
|
||||
@@ -21,8 +22,20 @@ bool settingFromKey(const std::string& k, TvSetting& out) {
|
||||
return true;
|
||||
}
|
||||
|
||||
TvSetting effectiveSetting(TvSetting stored) {
|
||||
return stored == TvSetting::Auto ? TvSetting::Manual : stored;
|
||||
TvSetting defaultSetting(const char* buildDefault) {
|
||||
TvSetting s = TvSetting::Auto;
|
||||
if (buildDefault) settingFromKey(buildDefault, s); // an unknown value leaves Auto
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string firstUseHint(const std::string& padText, const std::string& keyText, bool macOS) {
|
||||
std::vector<std::string> ways;
|
||||
if (!padText.empty()) ways.push_back(padText);
|
||||
if (!keyText.empty()) ways.push_back(keyText);
|
||||
if (ways.size() < 2) ways.push_back(macOS ? "Cmd+Shift+T" : "Ctrl+Shift+T");
|
||||
std::string s = "Press " + ways[0];
|
||||
for (size_t i = 1; i < ways.size(); i++) s += " or " + ways[i];
|
||||
return s + " for TV mode";
|
||||
}
|
||||
|
||||
void TvMode::turnOn(Cause c) {
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
// whether TV mode is active.
|
||||
//
|
||||
// Three stored settings: Off (never), Manual (only the toggle), Auto (also on fullscreen and on a controller press).
|
||||
// TvMode itself acts on the setting it is given; mapping the stored setting to the effective one is
|
||||
// effectiveSetting()'s job (Auto is Manual until the Auto triggers are wired up).
|
||||
// TvMode itself acts on the setting it is given; defaultSetting() is what a config with no stored setting gets.
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
@@ -15,7 +14,13 @@ enum class TvSetting { Off, Manual, Auto };
|
||||
|
||||
const char* settingKey(TvSetting s); // "off" | "manual" | "auto"
|
||||
bool settingFromKey(const std::string& k, TvSetting& out); // false (out untouched) on an unknown key
|
||||
TvSetting effectiveSetting(TvSetting stored); // Auto -> Manual until the Auto triggers exist
|
||||
// The setting a config with no ui.tv_mode starts from: the build's (VJ_TV_DEFAULT_SETTING: "off" | "manual" | "auto").
|
||||
// Null or anything else is Auto. Never written back, so a build can move its default under people who never chose.
|
||||
TvSetting defaultSetting(const char* buildDefault);
|
||||
|
||||
// The one-time first-use hint (decision 8): "Press <the pad's TV-menu button> or <the keyboard's> for TV mode". A missing half is
|
||||
// dropped, and the fixed shortcut (Ctrl+Shift+T, Cmd+Shift+T on macOS) stands in until there are two, so it is never empty.
|
||||
std::string firstUseHint(const std::string& padText, const std::string& keyText, bool macOS);
|
||||
|
||||
struct TvConfig {
|
||||
TvSetting setting = TvSetting::Manual;
|
||||
|
||||
@@ -171,6 +171,7 @@ void NavInput::padAxis(int axis, int16_t v, uint64_t ms) {
|
||||
std::vector<Intent> NavInput::take(uint64_t ms) {
|
||||
std::vector<Intent> out;
|
||||
out.swap(pending_);
|
||||
repeated_ = false;
|
||||
for (HeldAccept& a : acceptHeld_) {
|
||||
if (a.swallowed || ms < a.downAt || ms - a.downAt < kLongMs) continue;
|
||||
a.swallowed = true; // fires once; its release is swallowed
|
||||
@@ -182,6 +183,7 @@ std::vector<Intent> NavInput::take(uint64_t ms) {
|
||||
}
|
||||
if (!held_.empty() && ms >= nextRepeat_) {
|
||||
out.push_back(held_.back().dir);
|
||||
repeated_ = true; // always the last of the call
|
||||
// One repeat per call however late the call is; the clock catches up so a stall does not burst.
|
||||
nextRepeat_ += kRepeatMs * ((ms - nextRepeat_) / kRepeatMs + 1);
|
||||
}
|
||||
@@ -195,6 +197,7 @@ void NavInput::reset() {
|
||||
acceptHeld_.clear();
|
||||
trigOn_.clear();
|
||||
rearm_ = false;
|
||||
repeated_ = false;
|
||||
nextRepeat_ = 0;
|
||||
for (int& s : stickState_) s = 0;
|
||||
trig_[0] = trig_[1] = false;
|
||||
|
||||
@@ -46,6 +46,9 @@ public:
|
||||
void forgetPad(uint32_t pad); // pad unplugged: drop what it held; emits nothing, edges already queued stay
|
||||
void key(int scancode, bool down, bool shift, uint64_t ms);
|
||||
std::vector<Intent> take(uint64_t ms); // edges since the last call, plus repeats (directions only)
|
||||
// The last intent take() returned was a repeat of a held direction (not a press). A screen that does something once per press
|
||||
// (leave a level on Left at its first choice) asks Shell::repeatIntent().
|
||||
bool lastWasRepeat() const { return repeated_; }
|
||||
void reset(); // forget everything held and pending
|
||||
|
||||
private:
|
||||
@@ -64,6 +67,7 @@ private:
|
||||
bool longAccept_ = false;
|
||||
std::vector<HeldAccept> acceptHeld_; // pad Accepts down, waiting to become a tap or AcceptLong
|
||||
std::vector<int> trigOn_; // padTrigger latches that are on, by (pad, axis) source
|
||||
bool repeated_ = false; // take()'s last intent was a repeat
|
||||
bool rearm_ = false; // forgetPad handed the repeat to an older hold: restart its delay at the next take
|
||||
};
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@ public:
|
||||
virtual const char* id() const = 0; // "home" | "library" | "letters" | "details" | "achievements" | "settings" | "overlays": FocusMemory keys, tests
|
||||
virtual void intent(Intent in) = 0;
|
||||
virtual bool typed(char c) { return false; } // 'a'..'z', '0'..'9', '\b'; true = used
|
||||
// The label of what has the focus (Settings: the group on the rail, or the row in its list), "" when the screen has none to name.
|
||||
// For tests and the headless script (`expect tv-focus=LABEL`); not drawn. Not const: a screen may refresh its model first.
|
||||
virtual std::string focusLabel() { return {}; }
|
||||
// Settings: the key of the group on the rail ("picture", "core:video"); "" for any other screen. Tests and the headless script
|
||||
// (`expect tv-group=KEY`).
|
||||
virtual std::string focusGroup() { return {}; }
|
||||
virtual bool back() { return false; } // Back: true = the screen stepped up inside itself; false = the shell's (pop / Home)
|
||||
virtual void draw(const DrawCtx& c) = 0; // draws, and does hover-focus / click / wheel from c
|
||||
virtual std::vector<widgets::Hint> hints() const = 0; // last = the way out
|
||||
@@ -54,6 +60,9 @@ public:
|
||||
// a screen draws no rail of its own and no safe margin, and its way out is "Menu" (Back always leaves for the menu, after the
|
||||
// screen's own back()).
|
||||
void setEmbedded(bool on) { embedded_ = on; }
|
||||
// An embedded screen that wants the whole panel (the Library: its own rail and grid) instead of the list area beside the menu's
|
||||
// rail. The menu then draws no rail, and DrawCtx::pos / size are the panel inside its inset.
|
||||
virtual bool wide() const { return false; }
|
||||
|
||||
protected:
|
||||
bool embedded() const { return embedded_; }
|
||||
|
||||
+231
-87
@@ -1,4 +1,4 @@
|
||||
// TV mode (#991): the Settings screen -- a rail of groups (Picture, Speed, Sound, Interface) beside the focused group's
|
||||
// TV mode (#991): the Settings screen -- a rail of groups (the app's tabs, then the core's categories) beside the focused group's
|
||||
// rows, each a label on the left and its value on the right between two chevrons. Left / Right step a row, Accept steps it
|
||||
// forward and wraps (so a one-handed player can still change everything), and the last row of Interface opens the desktop
|
||||
// Settings window. Geometry is the Library's rail and the plan's "Screen geometry", in design units (u = viewport
|
||||
@@ -7,11 +7,21 @@
|
||||
// The rows come from Host::settingRows() on every draw and every intent: a row the build or the session does not have
|
||||
// is simply absent, and one that changed under the player is followed by its id. Where focus was lives in
|
||||
// Shell::memory(): the group under "settings/rail", each group's row under "settings/<group>" (index and row id), and
|
||||
// whether the list had the focus under "settings/list", so coming back lands there.
|
||||
// whether the list had the focus under "settings/list", so coming back lands there. A group is its key (SettingRow::group), so
|
||||
// the memory survives groups that come and go.
|
||||
//
|
||||
// In the in-game menu's slot (Screen::setEmbedded) the screen has no rail of its own -- the menu names the page -- so the groups
|
||||
// are a first list level in the menu's list area: Accept on a group opens its rows, Back returns to the groups, and Back on the
|
||||
// groups is the menu's. Every visit starts on the groups.
|
||||
// Both the rail and the list are long (the core's categories join the app's tabs; a category can hold a dozen rows), so each scrolls
|
||||
// a whole item at a time (Scroll: LinearFocus::first eased by the Phase 3 tween), keeps the focus in view, and shows an arrow where
|
||||
// there is more. LT / RT (PagePrev / PageNext) jump a screenful, stopping at the ends. A held direction repeats on the nav's own
|
||||
// timer; the screen only has to tell a repeat from a press (Shell::repeatIntent) where a press means more than the repeat does.
|
||||
//
|
||||
// In the in-game menu's slot (Screen::setEmbedded) the screen has no rail of its own, so the groups are a first list level: Accept on
|
||||
// a group opens its rows, Back returns to the groups, and Back on the groups is the menu's. Every visit starts on the groups. It takes
|
||||
// the whole panel (wide(), as the Library does: the menu's own rail is not drawn) so the labels have the room, and names itself in the
|
||||
// caption line above its list.
|
||||
//
|
||||
// A label that does not fit beside its value wraps at words to a second line (and a note under the value the same) instead of being cut;
|
||||
// every row of a group then has the height of the tallest, so the window still scrolls a whole row at a time.
|
||||
#include <algorithm>
|
||||
#include <cfloat>
|
||||
#include <cstdlib>
|
||||
@@ -29,18 +39,75 @@ constexpr float kRailW = 344, kRailGap = 64, kItemH = 54, kItemGap = 8; // the
|
||||
constexpr float kListTop = 196 - 150; // from the top of the content area (y = 150)
|
||||
constexpr float kRowH = 72, kRowGap = 14;
|
||||
constexpr float kRowsMargin = 12; // room above the first row for a focused row's ring and shadow
|
||||
constexpr float kRailPitch = kItemH + kItemGap;
|
||||
constexpr float kArrowRoom = 26; // under a list that scrolls: the "more" arrow's place
|
||||
constexpr float kArrowGap = 19; // the arrow's centre, from a row list's first / last row (clear of its ring)
|
||||
constexpr float kRailArrowGap = 16; // ... from the rail's first / last item (its ring does not grow)
|
||||
constexpr float kRowVPad = 7; // above and below a row's text (72 u holds a label with a note line)
|
||||
constexpr const char* kMoreId = "more";
|
||||
|
||||
std::string memoryKey(int group) { return "settings/" + std::to_string(group); }
|
||||
std::string memoryKey(const std::string& group) { return "settings/" + group; }
|
||||
|
||||
// How many whole items of `pitch` fit under `viewH` (the last one has no gap after it). A run that does not fit whole keeps
|
||||
// `arrowRoom` free under its last item for the "more" arrow.
|
||||
int itemsThatFit(float viewH, float pitch, float gap, int count, float arrowRoom) {
|
||||
const int all = std::max(1, (int)((viewH + gap) / pitch));
|
||||
if (all >= count) return all;
|
||||
return std::max(1, (int)((viewH - arrowRoom + gap) / pitch));
|
||||
}
|
||||
|
||||
// A run of equal items scrolled a whole item at a time: LinearFocus::first is the first one showing and the pixel offset eases
|
||||
// to it, so at rest no item is cut by the edge of its view. `restart`: another run took the place (start from the top, no easing).
|
||||
struct Scroll {
|
||||
Tween tween;
|
||||
bool init = false;
|
||||
float lastU = 0, lastPitch = 0;
|
||||
float update(LinearFocus& f, int visible, float pitch, float u, bool restart, double now) {
|
||||
if (restart) f.first = 0;
|
||||
f.first = std::clamp(f.first, 0, std::max(0, f.count - visible));
|
||||
f.scrollToShow(visible);
|
||||
const float target = (float)f.first * pitch;
|
||||
if (!init || lastU != u || lastPitch != pitch || restart) {
|
||||
tween.from = tween.to = target;
|
||||
tween.start = now;
|
||||
init = true;
|
||||
lastU = u;
|
||||
lastPitch = pitch;
|
||||
} else if (target != tween.to) {
|
||||
tween.retarget(target, now);
|
||||
}
|
||||
return tween.at(now);
|
||||
}
|
||||
};
|
||||
|
||||
// LT / RT: the focus and the window both move a screenful, and stop at the ends.
|
||||
void pageBy(LinearFocus& f, int visible, int dir) {
|
||||
const int step = std::max(1, visible);
|
||||
f.index = std::clamp(f.index + dir * step, 0, std::max(0, f.count - 1));
|
||||
f.first = std::clamp(f.first + dir * step, 0, std::max(0, f.count - visible));
|
||||
}
|
||||
|
||||
class SettingsScreen : public Screen {
|
||||
public:
|
||||
SettingsScreen(Host& h, Shell& s) : host_(h), shell_(s) {}
|
||||
|
||||
const char* id() const override { return "settings"; }
|
||||
bool wide() const override { return embedded(); } // in the in-game menu's slot the labels get the whole panel (the menu draws no rail)
|
||||
|
||||
std::string focusLabel() override {
|
||||
refresh();
|
||||
if (group_.empty() || rows_.empty()) return {};
|
||||
return onRail_ ? groupLabel() : rows_[(size_t)list_.index].label;
|
||||
}
|
||||
|
||||
std::string focusGroup() override {
|
||||
refresh();
|
||||
return group_;
|
||||
}
|
||||
|
||||
void intent(Intent in) override {
|
||||
refresh();
|
||||
if (!shell_.repeatIntent()) enteredByRight_ = false; // a fresh press: whatever the last hold started is over
|
||||
if (onRail_) railIntent(in);
|
||||
else listIntent(in);
|
||||
remember();
|
||||
@@ -73,16 +140,31 @@ public:
|
||||
lastMouse_ = c.mouse;
|
||||
const bool pick = c.hovered && (moved || c.clicked);
|
||||
|
||||
// ---- the rail --------------------------------------------------------------------------------------------------
|
||||
// ---- the rail: the groups, a window of whole items that follows the focus ---------------------------------------
|
||||
int hoverItem = -1;
|
||||
for (int i = 0; !emb && i < (int)groups_.size(); i++) {
|
||||
const ImVec2 p0(x0, c.pos.y + (float)i * (kItemH + kItemGap) * u), p1(x0 + kRailW * u, p0.y + kItemH * u);
|
||||
const bool sel = i == rail_.index;
|
||||
if (sel) c.dl->AddRectFilled(p0, p1, widgets::col(pal.cardBgHover), 10 * u);
|
||||
if (sel && onRail_) c.dl->AddRect(p0, p1, widgets::col(pal.text), 10 * u, 0, m.ring);
|
||||
widgets::text(c.dl, m.body, ImVec2(p0.x + 20 * u, p0.y + 12 * u), widgets::col(sel ? pal.text : pal.textMuted),
|
||||
settingGroupLabel(groups_[(size_t)i]), (kRailW - 40) * u);
|
||||
if (c.hovered && c.mouse.x >= p0.x && c.mouse.x < p1.x && c.mouse.y >= p0.y && c.mouse.y < p1.y) hoverItem = i;
|
||||
if (!emb) {
|
||||
const float pitch = kRailPitch * u, itemH = kItemH * u;
|
||||
railVisible_ = itemsThatFit(c.size.y, pitch, kItemGap * u, (int)groups_.size(), kArrowRoom * u);
|
||||
const float scroll = railScroll_.update(rail_, railVisible_, pitch, u, false, c.now);
|
||||
const float winBottom = c.pos.y + (float)railVisible_ * pitch - kItemGap * u; // the window's lower edge
|
||||
const float ringRoom = m.ring; // the ring is drawn a little outside its item
|
||||
c.dl->PushClipRect(ImVec2(c.pos.x, c.pos.y - ringRoom), ImVec2(c.pos.x + c.size.x, winBottom + ringRoom), true);
|
||||
for (int i = 0; i < (int)groups_.size(); i++) {
|
||||
const float y = c.pos.y + (float)i * pitch - scroll;
|
||||
if (y + itemH <= c.pos.y - ringRoom || y >= winBottom) continue; // wholly outside: not drawn at all
|
||||
const ImVec2 p0(x0, y), p1(x0 + kRailW * u, y + itemH);
|
||||
const bool sel = i == rail_.index;
|
||||
if (sel) c.dl->AddRectFilled(p0, p1, widgets::col(pal.cardBgHover), 10 * u);
|
||||
if (sel && onRail_) c.dl->AddRect(p0, p1, widgets::col(pal.text), 10 * u, 0, m.ring);
|
||||
widgets::text(c.dl, m.body, ImVec2(p0.x + 20 * u, p0.y + 12 * u), widgets::col(sel ? pal.text : pal.textMuted),
|
||||
groups_[(size_t)i].label, (kRailW - 40) * u);
|
||||
if (c.hovered && c.mouse.x >= p0.x && c.mouse.x < p1.x && c.mouse.y >= std::max(p0.y, c.pos.y) &&
|
||||
c.mouse.y < std::min(p1.y, winBottom))
|
||||
hoverItem = i;
|
||||
}
|
||||
c.dl->PopClipRect();
|
||||
drawMoreArrows(c, x0 + kRailW * u * 0.5f, c.pos.y - kRailArrowGap * u, winBottom + kRailArrowGap * u, rail_.first > 0,
|
||||
rail_.first + railVisible_ < (int)groups_.size());
|
||||
}
|
||||
if (hoverItem >= 0 && pick) { // hovering a group selects it: its rows show at once
|
||||
selectGroup(hoverItem);
|
||||
@@ -97,39 +179,26 @@ public:
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- the rows: scrolled so the focused one shows ---------------------------------------------------------------
|
||||
// ---- the rows: a window of whole rows that follows the focus ----------------------------------------------------
|
||||
const float lx = emb ? x0 : x0 + (kRailW + kRailGap) * u, lw = std::max(1.0f, right - lx);
|
||||
const std::string count = std::to_string(rows_.size()) + (rows_.size() == 1 ? " setting" : " settings");
|
||||
widgets::text(c.dl, m.caption, ImVec2(lx, c.pos.y), widgets::col(pal.textMuted),
|
||||
std::string(settingGroupLabel(group_)) + " \xC2\xB7 " + count, lw);
|
||||
groupLabel() + " \xC2\xB7 " + count, lw);
|
||||
|
||||
const float rowsTop = c.pos.y + kListTop * u, viewBottom = c.pos.y + c.size.y, viewH = viewBottom - rowsTop;
|
||||
const float pitch = (kRowH + kRowGap) * u, rowH = kRowH * u;
|
||||
const float total = (float)rows_.size() * pitch - kRowGap * u;
|
||||
const float focusTop = (float)list_.index * pitch;
|
||||
float target = scrollTo_;
|
||||
if (group_ != lastGroup_) target = 0;
|
||||
if (focusTop < target) target = focusTop;
|
||||
else if (focusTop + rowH > target + viewH) target = focusTop + rowH - viewH;
|
||||
target = std::clamp(target, 0.0f, std::max(0.0f, total - viewH));
|
||||
scrollTo_ = target;
|
||||
if (!scrollInit_ || lastU_ != u || group_ != lastGroup_) {
|
||||
scroll_.from = scroll_.to = target;
|
||||
scroll_.start = c.now;
|
||||
scrollInit_ = true;
|
||||
lastU_ = u;
|
||||
lastGroup_ = group_;
|
||||
} else if (target != scroll_.to) {
|
||||
scroll_.retarget(target, c.now);
|
||||
}
|
||||
const float scroll = scroll_.at(c.now);
|
||||
tweens_.update((int)rows_.size(), list_.index, group_, c.now);
|
||||
layoutRows(m, lw);
|
||||
const float rowsTop = c.pos.y + kListTop * u, viewBottom = c.pos.y + c.size.y, viewH = std::max(1.0f, viewBottom - rowsTop);
|
||||
const float pitch = rowH_ + kRowGap * u, rowH = rowH_;
|
||||
listVisible_ = itemsThatFit(viewH, pitch, kRowGap * u, (int)rows_.size(), kArrowRoom * u);
|
||||
const float scroll = listScroll_.update(list_, listVisible_, pitch, u, group_ != lastGroup_, c.now);
|
||||
lastGroup_ = group_;
|
||||
tweens_.update((int)rows_.size(), list_.index, groupSerial_, c.now);
|
||||
const float winBottom = rowsTop + (float)listVisible_ * pitch - kRowGap * u; // the window's lower edge
|
||||
|
||||
int hoverRow = -1;
|
||||
c.dl->PushClipRect(ImVec2(c.pos.x, rowsTop - kRowsMargin * u), ImVec2(c.pos.x + c.size.x, viewBottom), true);
|
||||
c.dl->PushClipRect(ImVec2(c.pos.x, rowsTop - kRowsMargin * u), ImVec2(c.pos.x + c.size.x, winBottom + 8 * u), true);
|
||||
for (int i = 0; i < (int)rows_.size(); i++) {
|
||||
const float y = rowsTop + (float)i * pitch - scroll;
|
||||
if (y + rowH < rowsTop - kRowsMargin * u || y > viewBottom) continue;
|
||||
if (y + rowH <= rowsTop - kRowsMargin * u || y >= winBottom) continue; // wholly outside: not drawn at all
|
||||
const float f = !onRail_ && i == list_.index ? tweens_.at(i, c.now) : 0.0f;
|
||||
widgets::RowArgs a; // the card and the focus ring; the label and the value are drawn here
|
||||
a.focus = f;
|
||||
@@ -138,12 +207,12 @@ public:
|
||||
// The unscaled card, widened by what a focused row grows: its right chevron lies beyond the unscaled edge.
|
||||
const float slack = (m.focusScale - 1.0f) * 0.5f * lw;
|
||||
if (c.hovered && c.mouse.x >= lx - slack && c.mouse.x < lx + lw + slack && c.mouse.y >= y && c.mouse.y < y + rowH &&
|
||||
c.mouse.y >= rowsTop && c.mouse.y < viewBottom) // not the part the clip hides
|
||||
c.mouse.y >= rowsTop && c.mouse.y < winBottom) // not the part the clip hides
|
||||
hoverRow = i;
|
||||
}
|
||||
c.dl->PopClipRect();
|
||||
if (scroll > 0.5f) widgets::moreArrow(c.dl, m, ImVec2(lx + lw * 0.5f, rowsTop - 4 * u), true);
|
||||
if (scroll + viewH < total - 0.5f) widgets::moreArrow(c.dl, m, ImVec2(lx + lw * 0.5f, viewBottom - 10 * u), false);
|
||||
drawMoreArrows(c, lx + lw * 0.5f, rowsTop - kArrowGap * u, winBottom + kArrowGap * u, list_.first > 0,
|
||||
list_.first + listVisible_ < (int)rows_.size());
|
||||
|
||||
// ---- the pointer: hovering a row focuses it, a click on the value's left / right half steps that way, a click
|
||||
// elsewhere on the row is Accept, a notch is a row --------------------------------------------------------------
|
||||
@@ -168,6 +237,7 @@ public:
|
||||
std::vector<widgets::Hint> h;
|
||||
if (onRail_ || rows_.empty()) {
|
||||
if (!rows_.empty()) h.push_back({host_.glyph(Intent::Accept), "Open"});
|
||||
pageHint(h, (int)groups_.size() > railVisible_); // a rail (or groups level) that scrolls
|
||||
h.push_back({host_.glyph(Intent::Back), embedded() ? "Menu" : "Home"});
|
||||
return h;
|
||||
}
|
||||
@@ -179,33 +249,69 @@ public:
|
||||
h.push_back({l.empty() ? rt : rt.empty() || l == rt ? l : l + " " + rt, "Change"}); // "D-pad" once, "Left Right" on a keyboard
|
||||
h.push_back({host_.glyph(Intent::Accept), "Next"});
|
||||
}
|
||||
pageHint(h, (int)rows_.size() > listVisible_); // a list that scrolls
|
||||
h.push_back({host_.glyph(Intent::Back), "Groups"});
|
||||
return h;
|
||||
}
|
||||
|
||||
private:
|
||||
// Fetches the rows, keeps the groups that have any, and settles the focus: the rail follows the group (not its
|
||||
// position: Speed appears once the core's options are known) and the list follows the row's id.
|
||||
// "LT RT Page" on a list that scrolls: one glyph if the other has none (or both are the same key), nothing if neither has.
|
||||
void pageHint(std::vector<widgets::Hint>& h, bool scrolls) const {
|
||||
if (!scrolls) return;
|
||||
const std::string lt = host_.glyph(Intent::PagePrev), rt = host_.glyph(Intent::PageNext);
|
||||
const std::string glyph = lt.empty() ? rt : rt.empty() || lt == rt ? lt : lt + " " + rt;
|
||||
if (!glyph.empty()) h.push_back({glyph, "Page"});
|
||||
}
|
||||
|
||||
// How each row of the focused group is laid out at rest (the label and the note broken into lines) and how tall its rows are: the
|
||||
// tallest row's, never under kRowH. The label gets the row's width less the value column, which is sized by the widest choice
|
||||
// (not the one showing) so that stepping a value never rewraps the label; a note under the value is right-aligned in half a row.
|
||||
void layoutRows(const Metrics& m, float lw) {
|
||||
const float u = m.u, pad = 24 * u, inner = std::max(1.0f, lw - 2 * pad), half = inner * 0.5f;
|
||||
const float chevW = 9 * u, gap = 24 * u;
|
||||
lay_.assign(rows_.size(), RowLay());
|
||||
rowH_ = kRowH * u;
|
||||
for (size_t i = 0; i < rows_.size(); i++) {
|
||||
const SettingRow& r = rows_[i];
|
||||
RowLay& L = lay_[i];
|
||||
float avail = inner; // a note row ("Available once a game has run"): the whole row is its text
|
||||
if (!r.choices.empty()) {
|
||||
float widest = 0;
|
||||
for (const std::string& ch : r.choices) widest = std::max(widest, widgets::textWidth(m.body, ch));
|
||||
avail = inner - (std::min(widest, half - chevW - gap) + 2 * (chevW + gap)) - gap;
|
||||
} else if (!r.readOnly) {
|
||||
avail = inner - chevW - gap; // "more": a chevron
|
||||
}
|
||||
L.label = widgets::wrapLines(m.body, r.label, std::max(avail, 0.4f * inner), 2);
|
||||
if (!r.choices.empty() && !r.note.empty()) L.note = widgets::wrapLines(m.minText, r.note, half, 2);
|
||||
const float n = (float)std::max<size_t>(1, L.label.size());
|
||||
const float labelH = n * m.body + (n - 1) * 4 * u;
|
||||
const float noteH = L.note.empty() ? 0.0f : 4 * u + (float)L.note.size() * m.minText + (float)(L.note.size() - 1) * 2 * u;
|
||||
rowH_ = std::max(rowH_, std::max(labelH, m.body + noteH) + 2 * kRowVPad * u);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetches the rows, takes their groups in first-seen order (the host lists them in the rail's order), and settles the focus:
|
||||
// the rail follows the group's key (not its position: groups come and go with the session) and the list follows the row's id.
|
||||
void refresh() {
|
||||
const std::vector<SettingRow> all = host_.settingRows();
|
||||
int count[kSettingGroups] = {};
|
||||
groups_ = groupsOf(all);
|
||||
groupCount_.assign(groups_.size(), 0);
|
||||
for (const SettingRow& r : all)
|
||||
if (r.group >= 0 && r.group < kSettingGroups) count[r.group]++;
|
||||
groups_.clear();
|
||||
for (int g = 0; g < kSettingGroups; g++) {
|
||||
groupCount_[g] = count[g];
|
||||
if (count[g] > 0) groups_.push_back(g);
|
||||
}
|
||||
for (size_t g = 0; g < groups_.size(); g++)
|
||||
if (groups_[g].key == r.group) groupCount_[g]++;
|
||||
if (!built_) {
|
||||
built_ = true;
|
||||
onRail_ = embedded() || shell_.memory().get("settings/list") == 0; // a fresh visit starts on the rail (the menu: the groups)
|
||||
onRail_ = embedded() || shell_.memory().get("settings/list") == 0; // the Base layer resumes the level it was left on (list or rail); the menu's slot always starts on the groups
|
||||
}
|
||||
rail_.count = (int)groups_.size();
|
||||
rail_.index = 0;
|
||||
const int wantGroup = shell_.memory().get("settings/rail", groups_.empty() ? 0 : groups_[0]);
|
||||
const std::string wantGroup = shell_.memory().getItem("settings/rail");
|
||||
for (int i = 0; i < (int)groups_.size(); i++)
|
||||
if (groups_[(size_t)i] == wantGroup) rail_.index = i;
|
||||
group_ = groups_.empty() ? -1 : groups_[(size_t)rail_.index];
|
||||
if (groups_[(size_t)i].key == wantGroup) rail_.index = i;
|
||||
const std::string now = groups_.empty() ? std::string() : groups_[(size_t)rail_.index].key;
|
||||
if (now != group_) groupSerial_++;
|
||||
group_ = now;
|
||||
rows_.clear();
|
||||
for (const SettingRow& r : all)
|
||||
if (r.group == group_) rows_.push_back(r);
|
||||
@@ -221,8 +327,8 @@ private:
|
||||
|
||||
void remember() {
|
||||
if (!embedded()) shell_.memory().put("settings/list", onRail_ ? 0 : 1); // the menu's visits always start on the groups
|
||||
if (group_ < 0 || rows_.empty()) return;
|
||||
shell_.memory().put("settings/rail", group_);
|
||||
if (group_.empty() || rows_.empty()) return;
|
||||
shell_.memory().putItem("settings/rail", group_);
|
||||
shell_.memory().put(memoryKey(group_), list_.index);
|
||||
shell_.memory().putItem(memoryKey(group_), rows_[(size_t)list_.index].id);
|
||||
}
|
||||
@@ -231,7 +337,7 @@ private:
|
||||
void selectGroup(int k) {
|
||||
if (k < 0 || k >= (int)groups_.size() || k == rail_.index) return;
|
||||
remember();
|
||||
shell_.memory().put("settings/rail", groups_[(size_t)k]);
|
||||
shell_.memory().putItem("settings/rail", groups_[(size_t)k].key);
|
||||
refresh();
|
||||
}
|
||||
|
||||
@@ -244,9 +350,19 @@ private:
|
||||
case Intent::Down:
|
||||
if (f.move(1)) selectGroup(f.index);
|
||||
break;
|
||||
case Intent::PagePrev:
|
||||
case Intent::PageNext: {
|
||||
pageBy(f, railVisible_, in == Intent::PageNext ? 1 : -1);
|
||||
rail_.first = f.first;
|
||||
selectGroup(f.index);
|
||||
break;
|
||||
}
|
||||
case Intent::Right:
|
||||
case Intent::Accept:
|
||||
if (!rows_.empty()) onRail_ = false; // to the list, at the remembered row
|
||||
if (!rows_.empty()) { // to the list, at the remembered row
|
||||
onRail_ = false;
|
||||
enteredByRight_ = in == Intent::Right; // a Right still held repeats into the list: those repeats are not steps
|
||||
}
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
@@ -257,17 +373,22 @@ private:
|
||||
switch (in) {
|
||||
case Intent::Up: list_.move(-1); break;
|
||||
case Intent::Down: list_.move(1); break;
|
||||
case Intent::PagePrev: pageBy(list_, listVisible_, -1); break;
|
||||
case Intent::PageNext: pageBy(list_, listVisible_, 1); break;
|
||||
case Intent::Left:
|
||||
if (canStep(focused(), -1)) step(focused(), -1, false);
|
||||
else onRail_ = true; // the left edge of the row: the groups
|
||||
else if (!shell_.repeatIntent()) onRail_ = true; // the left edge of the row: the groups (a held Left stops there)
|
||||
break;
|
||||
case Intent::Right:
|
||||
if (!(shell_.repeatIntent() && enteredByRight_)) step(focused(), 1, false);
|
||||
break;
|
||||
case Intent::Right: step(focused(), 1, false); break;
|
||||
case Intent::Accept: accept(); break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
const SettingRow& focused() const { return rows_[(size_t)list_.index]; }
|
||||
std::string groupLabel() const { return rail_.index >= 0 && rail_.index < (int)groups_.size() ? groups_[(size_t)rail_.index].label : std::string(); }
|
||||
|
||||
static bool canStep(const SettingRow& r, int delta) {
|
||||
return !r.readOnly && !r.choices.empty() && r.index + delta >= 0 && r.index + delta < (int)r.choices.size();
|
||||
@@ -296,21 +417,33 @@ private:
|
||||
else step(r, mouseX < valueMid_[(size_t)list_.index] ? -1 : 1, false);
|
||||
}
|
||||
|
||||
// The in-menu first level: one row per group with how many settings it holds. Hovering one focuses it, a click opens it, a
|
||||
// notch is a group.
|
||||
// The in-menu first level: one row per group with how many settings it holds, a window of whole rows that follows the focus.
|
||||
// Hovering one focuses it, a click opens it, a notch is a group.
|
||||
void drawGroups(const DrawCtx& c, float lx, float lw, bool pick) {
|
||||
const Metrics& m = c.m;
|
||||
const float u = m.u, top = c.pos.y + kRowsMargin * u, pitch = (kRowH + kRowGap) * u;
|
||||
const float u = m.u, top = c.pos.y + kListTop * u, pitch = (kRowH + kRowGap) * u, rowH = kRowH * u;
|
||||
widgets::text(c.dl, m.caption, ImVec2(lx, c.pos.y), widgets::col(theme::pal().textMuted), // this level's name: the menu draws no rail
|
||||
"Settings \xC2\xB7 " + std::to_string(groups_.size()) + (groups_.size() == 1 ? " group" : " groups"), lw);
|
||||
railVisible_ = itemsThatFit(std::max(1.0f, c.pos.y + c.size.y - top), pitch, kRowGap * u, (int)groups_.size(), kArrowRoom * u);
|
||||
const float scroll = railScroll_.update(rail_, railVisible_, pitch, u, false, c.now);
|
||||
const float winBottom = top + (float)railVisible_ * pitch - kRowGap * u;
|
||||
groupTweens_.update((int)groups_.size(), rail_.index, 0, c.now);
|
||||
int hover = -1;
|
||||
c.dl->PushClipRect(ImVec2(c.pos.x, top - kRowsMargin * u), ImVec2(c.pos.x + c.size.x, winBottom + 8 * u), true);
|
||||
for (int i = 0; i < (int)groups_.size(); i++) {
|
||||
const int n = groupCount_[groups_[(size_t)i]];
|
||||
const float y = top + (float)i * pitch - scroll;
|
||||
if (y + rowH <= top - kRowsMargin * u || y >= winBottom) continue; // wholly outside: not drawn at all
|
||||
const int n = groupCount_[(size_t)i];
|
||||
widgets::RowArgs a;
|
||||
a.label = settingGroupLabel(groups_[(size_t)i]);
|
||||
a.label = groups_[(size_t)i].label;
|
||||
a.value = std::to_string(n) + (n == 1 ? " setting" : " settings");
|
||||
a.focus = i == rail_.index ? groupTweens_.at(i, c.now) : 0.0f;
|
||||
if (widgets::row(c.dl, m, ImVec2(lx, top + (float)i * pitch), ImVec2(lw, kRowH * u), a).hovered) hover = i;
|
||||
const bool h = widgets::row(c.dl, m, ImVec2(lx, y), ImVec2(lw, rowH), a).hovered;
|
||||
if (h && c.mouse.y >= top && c.mouse.y < winBottom) hover = i; // not the part the clip hides
|
||||
}
|
||||
c.dl->PopClipRect();
|
||||
drawMoreArrows(c, lx + lw * 0.5f, top - kArrowGap * u, winBottom + kArrowGap * u, rail_.first > 0,
|
||||
rail_.first + railVisible_ < (int)groups_.size());
|
||||
if (c.hovered && c.mouse.x >= lx && c.mouse.x < lx + lw && c.mouse.y >= c.pos.y && c.mouse.y < c.pos.y + c.size.y) wheel_ += c.wheel;
|
||||
const int steps = (int)wheel_;
|
||||
wheel_ -= (float)steps;
|
||||
@@ -320,24 +453,33 @@ private:
|
||||
if (hover >= 0 && c.clicked) intent(Intent::Accept); // opens the group
|
||||
}
|
||||
|
||||
// The label, the value between its chevrons, and a read-only row's note under the value.
|
||||
// The two arrows of a scrolling run, centred on x: above its first item (y = `upY`) and below its last (`downY`).
|
||||
static void drawMoreArrows(const DrawCtx& c, float x, float upY, float downY, bool up, bool down) {
|
||||
if (up) widgets::moreArrow(c.dl, c.m, ImVec2(x, upY), true);
|
||||
if (down) widgets::moreArrow(c.dl, c.m, ImVec2(x, downY), false);
|
||||
}
|
||||
|
||||
// The label (its lines from layoutRows), the value between its chevrons, and a note under the value.
|
||||
void drawRow(const DrawCtx& c, int index, ImVec2 pos, ImVec2 size, float f) {
|
||||
const SettingRow& r = rows_[(size_t)index];
|
||||
const RowLay& L = lay_[(size_t)index];
|
||||
const Metrics& m = c.m;
|
||||
const theme::Palette& pal = theme::pal();
|
||||
const float u = m.u, k = 1.0f + (m.focusScale - 1.0f) * f; // the row grows about its centre (widgets::row)
|
||||
const ImVec2 mid(pos.x + size.x * 0.5f, pos.y + size.y * 0.5f);
|
||||
const ImVec2 p0(mid.x - size.x * k * 0.5f, mid.y - size.y * k * 0.5f), p1(mid.x + size.x * k * 0.5f, mid.y + size.y * k * 0.5f);
|
||||
const float pad = 24 * u * (0.6f + 0.4f * k), half = (p1.x - p0.x - 2 * pad) * 0.5f;
|
||||
const float px = m.body * (1.0f + 0.08f * f);
|
||||
const float px = m.body * (1.0f + 0.08f * f), notePx = m.minText * (1.0f + 0.08f * f);
|
||||
const ImU32 tc = widgets::col(r.readOnly ? pal.textMuted : pal.text), mc = widgets::col(pal.textMuted);
|
||||
const bool hasNote = !r.note.empty();
|
||||
const float hgt = p1.y - p0.y;
|
||||
valueMid_.resize(rows_.size(), p1.x);
|
||||
const float total = hasNote ? px + m.minText + 4 * u : px;
|
||||
const float y = p0.y + (p1.y - p0.y - total) * 0.5f;
|
||||
const bool line = r.choices.empty() && r.readOnly; // a note ("Available once a game has run"): the whole row is its text
|
||||
widgets::text(c.dl, px, ImVec2(p0.x + pad, p0.y + (p1.y - p0.y - px) * 0.5f), tc, r.label, line ? 2 * half : half);
|
||||
const float nL = (float)std::max<size_t>(1, L.label.size());
|
||||
const float labelH = nL * px + (nL - 1) * 4 * u;
|
||||
widgets::textLines(c.dl, px, ImVec2(p0.x + pad, p0.y + (hgt - labelH) * 0.5f), tc, L.label, 4 * u);
|
||||
|
||||
// The value's side: the value (and, under it, the note's lines), as one block centred in the row.
|
||||
const float noteH = L.note.empty() ? 0.0f : 4 * u + (float)L.note.size() * notePx + (float)(L.note.size() - 1) * 2 * u;
|
||||
const float y = p0.y + (hgt - (px + noteH)) * 0.5f;
|
||||
const float chevW = 9 * u * k, chevH = 18 * u * k, gap = 24 * u * k, thick = 3 * u;
|
||||
const float cy = y + px * 0.5f;
|
||||
auto chevron = [&](float x, bool toRight, ImU32 col) { // an open "<" or ">" (a font may lack the glyphs)
|
||||
@@ -358,28 +500,30 @@ private:
|
||||
valueMid_[(size_t)index] = valueRight - vw * 0.5f;
|
||||
if (canStep(r, -1)) chevron(valueRight - vw - gap - chevW, false, mc);
|
||||
if (canStep(r, 1)) chevron(inner - chevW, true, mc);
|
||||
if (hasNote) {
|
||||
const float nw = std::min(widgets::textWidth(m.minText, r.note), half);
|
||||
widgets::text(c.dl, m.minText, ImVec2(inner - nw, y + px + 4 * u), mc, r.note, nw);
|
||||
}
|
||||
if (!L.note.empty()) widgets::textLines(c.dl, notePx, ImVec2(inner, y + px + 4 * u), mc, L.note, 2 * u, true);
|
||||
}
|
||||
|
||||
Host& host_;
|
||||
Shell& shell_;
|
||||
std::vector<int> groups_; // the groups that have rows, in order; the rail's items
|
||||
int groupCount_[kSettingGroups] = {}; // how many rows each group has, from the last refresh()
|
||||
std::vector<SettingGroup> groups_; // the groups that have rows, in order; the rail's items
|
||||
std::vector<int> groupCount_; // how many rows each of them has, from the last refresh()
|
||||
widgets::FocusTweens groupTweens_; // the menu's group rows
|
||||
std::vector<SettingRow> rows_; // the focused group's rows, from the last refresh()
|
||||
struct RowLay {
|
||||
std::vector<std::string> label, note; // the lines each is broken into (layoutRows)
|
||||
};
|
||||
std::vector<RowLay> lay_; // per row of rows_, from the last draw
|
||||
float rowH_ = 0; // the rows' height at the last draw, pixels
|
||||
LinearFocus rail_, list_;
|
||||
std::vector<float> valueMid_; // per row of the last draw: x of the middle of its value (where a click turns from back to forward)
|
||||
int group_ = -1; // the focused group (settingGroupLabel); -1 when there are no rows
|
||||
int lastGroup_ = -1;
|
||||
std::string group_; // the focused group's key; "" when there are no rows
|
||||
std::string lastGroup_;
|
||||
int groupSerial_ = 0; // moves with group_: what the row tweens restart on
|
||||
bool built_ = false;
|
||||
bool onRail_ = true;
|
||||
Tween scroll_; // vertical scroll of the rows, in pixels
|
||||
float scrollTo_ = 0; // ... and where it is heading
|
||||
bool scrollInit_ = false;
|
||||
float lastU_ = 0;
|
||||
Scroll railScroll_, listScroll_; // the rail's window and the rows' (whole items; first is LinearFocus::first)
|
||||
int railVisible_ = 8, listVisible_ = 8; // how many items each window showed at the last draw (LT / RT page by them)
|
||||
bool enteredByRight_ = false; // the last move from the rail to the list was a Right: its repeats are not steps
|
||||
widgets::FocusTweens tweens_;
|
||||
ImVec2 lastMouse_{-FLT_MAX, -FLT_MAX};
|
||||
bool mouseSeen_ = false;
|
||||
|
||||
@@ -1,23 +1,48 @@
|
||||
#include "tv/tv_settings_model.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
namespace vj::tv {
|
||||
|
||||
namespace {
|
||||
|
||||
SettingRow rowOf(const std::string& id, const std::string& label, int group, std::vector<std::string> choices, int index) {
|
||||
SettingRow rowOf(const std::string& id, const std::string& label, const std::string& group, const std::string& groupLabel,
|
||||
std::vector<std::string> choices, int index) {
|
||||
SettingRow r;
|
||||
r.id = id;
|
||||
r.label = label;
|
||||
r.group = group;
|
||||
r.groupLabel = groupLabel;
|
||||
r.choices = std::move(choices);
|
||||
r.index = index;
|
||||
return r;
|
||||
}
|
||||
|
||||
constexpr const char* kCustomSuffix = " (custom)";
|
||||
|
||||
bool endsWith(const std::string& s, const char* suffix) {
|
||||
const size_t n = std::char_traits<char>::length(suffix);
|
||||
return s.size() >= n && s.compare(s.size() - n, n, suffix) == 0;
|
||||
}
|
||||
|
||||
bool mentionsRestart(const std::string& text) {
|
||||
std::string t;
|
||||
for (char c : text) t.push_back((char)std::tolower((unsigned char)c));
|
||||
return t.find("restart") != std::string::npos;
|
||||
}
|
||||
|
||||
// "%g": 85 -> "85", 0.55 -> "0.55" (the value of a slider position that is not on the list's grid).
|
||||
std::string plainNumber(float v) {
|
||||
char b[32];
|
||||
std::snprintf(b, sizeof b, "%g", (double)v);
|
||||
return b;
|
||||
}
|
||||
|
||||
// The file name without its folders (either separator) and its extension.
|
||||
std::string stemOf(const std::string& path) {
|
||||
const size_t slash = path.find_last_of("/\\");
|
||||
@@ -29,48 +54,151 @@ std::string stemOf(const std::string& path) {
|
||||
|
||||
} // namespace
|
||||
|
||||
const char* settingGroupLabel(int group) {
|
||||
switch (group) {
|
||||
case 0: return "Picture";
|
||||
case 1: return "Speed";
|
||||
case 2: return "Sound";
|
||||
case 3: return "Interface";
|
||||
}
|
||||
return "";
|
||||
namespace {
|
||||
|
||||
// The number a typed option's value stands for ("200%", "8000"); false for a Host key or text that is not one.
|
||||
bool typedNumber(const TypedOption& t, const std::string& v, long& out) {
|
||||
if (t.kind == OptKind::Percent) return parseTypedPercent(v, out);
|
||||
if (t.kind == OptKind::Int) return parseTypedInt(v, out);
|
||||
return false;
|
||||
}
|
||||
|
||||
SettingRow coreOptionRow(const std::string& id, const std::string& label, int group, const OptionDef* def, const std::string& current,
|
||||
bool visible, bool locked, bool overridden, bool* hidden) {
|
||||
SettingRow r = rowOf(id, label, group, {}, 0);
|
||||
const bool hide = !def || def->values.empty() || !visible || locked;
|
||||
if (hidden) *hidden = hide;
|
||||
if (hide) return r;
|
||||
// One option as a row of `group`; empty (`ok` false) when it has no row.
|
||||
SettingRow coreRow(const OptionDef& d, const std::string& group, const std::string& groupLabel, const std::string& current,
|
||||
bool overridden, const TypedOption* typed, bool* ok) {
|
||||
SettingRow r = rowOf("core:" + d.key, !d.descCategorized.empty() ? d.descCategorized : d.desc, group, groupLabel, {}, 0);
|
||||
*ok = !d.values.empty();
|
||||
if (!*ok) return r;
|
||||
int found = -1;
|
||||
for (size_t i = 0; i < def->values.size(); i++) {
|
||||
const OptionValue& v = def->values[i];
|
||||
for (size_t i = 0; i < d.values.size(); i++) {
|
||||
const OptionValue& v = d.values[i];
|
||||
r.choices.push_back(v.label.empty() ? v.value : v.label);
|
||||
if (v.value == current) found = (int)i;
|
||||
}
|
||||
if (found < 0 && !current.empty()) { // a typed value the core's list does not have
|
||||
r.choices.push_back(current + " (custom)");
|
||||
found = (int)r.choices.size() - 1;
|
||||
const int custom = coreCustomPos(d, current, typed);
|
||||
if (custom >= 0) { // a value the core's list does not have (a typed RISC clock), where stepping from it goes both ways
|
||||
r.choices.insert(r.choices.begin() + custom, current + kCustomSuffix);
|
||||
found = custom;
|
||||
}
|
||||
r.index = std::max(found, 0);
|
||||
if (overridden) { // the desktop row is disabled for the same reason (drawOption)
|
||||
r.readOnly = true;
|
||||
r.note = "Set for this session by Netplay or --option";
|
||||
r.note = "Set by Netplay or --option for this session";
|
||||
} else if (typed && typed->kind == OptKind::Host) { // only ever typed: the desktop window has the box
|
||||
r.readOnly = true;
|
||||
r.note = "Type it in Desktop settings";
|
||||
} else if (mentionsRestart(d.info) || mentionsRestart(d.infoCategorized)) {
|
||||
r.note = "Applies when the game next starts";
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
SettingRow onOffRow(const std::string& id, const std::string& label, int group, bool on) {
|
||||
return rowOf(id, label, group, {"Off", "On"}, on ? 1 : 0);
|
||||
} // namespace
|
||||
|
||||
int coreCustomPos(const OptionDef& d, const std::string& current, const TypedOption* typed) {
|
||||
if (current.empty()) return -1;
|
||||
for (const OptionValue& v : d.values)
|
||||
if (v.value == current) return -1;
|
||||
const int last = (int)d.values.size(); // text, a Host key, or presets that are not in numeric order: the entry goes last
|
||||
long cur = 0;
|
||||
if (!typed || !typedNumber(*typed, current, cur)) return last;
|
||||
long prev = 0;
|
||||
int pos = last;
|
||||
for (int i = 0; i < last; i++) {
|
||||
long v = 0;
|
||||
if (!typedNumber(*typed, d.values[(size_t)i].value, v) || (i > 0 && v < prev)) return last;
|
||||
prev = v;
|
||||
if (pos == last && v > cur) pos = i;
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
std::vector<SettingRow> coreOptionRows(const std::vector<OptionCategory>& cats, const std::vector<OptionDef>& defs,
|
||||
const std::function<std::string(const OptionDef&)>& current,
|
||||
const std::function<bool(const std::string&)>& visible,
|
||||
const std::function<bool(const std::string&)>& overridden,
|
||||
const std::function<const TypedOption*(const std::string&)>& typed) {
|
||||
std::vector<SettingRow> rows;
|
||||
auto add = [&](const OptionDef& d, const std::string& group, const std::string& label) {
|
||||
if (visible && !visible(d.key)) return;
|
||||
bool ok = false;
|
||||
SettingRow r = coreRow(d, group, label, current ? current(d) : d.defaultValue, overridden && overridden(d.key),
|
||||
typed ? typed(d.key) : nullptr, &ok);
|
||||
if (ok) rows.push_back(std::move(r));
|
||||
};
|
||||
auto known = [&](const std::string& key) {
|
||||
for (const OptionCategory& c : cats)
|
||||
if (c.key == key) return true;
|
||||
return false;
|
||||
};
|
||||
for (const OptionDef& d : defs) // no category, or one the core never defined: the desktop lists those under General too
|
||||
if (d.categoryKey.empty() || !known(d.categoryKey)) add(d, "core:", "General");
|
||||
for (const OptionCategory& c : cats)
|
||||
for (const OptionDef& d : defs)
|
||||
if (d.categoryKey == c.key) add(d, "core:" + c.key, c.desc);
|
||||
return rows;
|
||||
}
|
||||
|
||||
SettingRow choiceRow(const std::string& id, const std::string& label, const std::string& group, const std::string& groupLabel,
|
||||
std::vector<std::string> choices, int index) {
|
||||
return rowOf(id, label, group, groupLabel, std::move(choices), index);
|
||||
}
|
||||
|
||||
SettingRow sliderRow(const std::string& id, const std::string& label, const std::string& group, const std::string& groupLabel,
|
||||
float current, float lo, float hi, float step, const char* fmt) {
|
||||
std::vector<std::string> labels;
|
||||
const int n = step > 0 && hi >= lo ? (int)std::lround((hi - lo) / step) + 1 : 1;
|
||||
char b[64];
|
||||
for (int i = 0; i < n; i++) {
|
||||
std::snprintf(b, sizeof b, fmt, (double)(lo + (float)i * step));
|
||||
labels.push_back(b);
|
||||
}
|
||||
const int at = step > 0 ? (int)std::lround((current - lo) / step) : 0;
|
||||
int index = -1;
|
||||
if (at >= 0 && at < n && std::fabs(current - (lo + (float)at * step)) <= step * 1e-3f) index = at;
|
||||
if (index < 0) { // off the grid (the desktop slider is finer): shown as it is, between its neighbours, selected, kept until the player steps
|
||||
int pos = n;
|
||||
for (int i = 0; i < n; i++)
|
||||
if (lo + (float)i * step > current) {
|
||||
pos = i;
|
||||
break;
|
||||
}
|
||||
labels.insert(labels.begin() + pos, plainNumber(current) + kCustomSuffix);
|
||||
index = pos;
|
||||
}
|
||||
return rowOf(id, label, group, groupLabel, std::move(labels), index);
|
||||
}
|
||||
|
||||
float stepValue(const SettingRow& r, int index, float lo, float step, float current) {
|
||||
int custom = -1; // where sliderRow put the "(custom)" entry, if it did
|
||||
for (size_t i = 0; i < r.choices.size(); i++)
|
||||
if (endsWith(r.choices[i], kCustomSuffix)) custom = (int)i;
|
||||
const int grid = (int)r.choices.size() - (custom >= 0 ? 1 : 0);
|
||||
if (index < 0 || index >= (int)r.choices.size() || index == custom) return current; // the "(custom)" entry: nothing to change
|
||||
const int g = custom >= 0 && index > custom ? index - 1 : index;
|
||||
return g < grid ? lo + (float)g * step : current;
|
||||
}
|
||||
|
||||
std::vector<SettingGroup> groupsOf(const std::vector<SettingRow>& rows) {
|
||||
std::vector<SettingGroup> out;
|
||||
for (const SettingRow& r : rows) {
|
||||
bool seen = false;
|
||||
for (const SettingGroup& g : out)
|
||||
if (g.key == r.group) {
|
||||
seen = true;
|
||||
break;
|
||||
}
|
||||
if (!seen) out.push_back({r.group, r.groupLabel});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
SettingRow onOffRow(const std::string& id, const std::string& label, const std::string& group, const std::string& groupLabel, bool on) {
|
||||
return rowOf(id, label, group, groupLabel, {"Off", "On"}, on ? 1 : 0);
|
||||
}
|
||||
|
||||
SettingRow volumeRow(float volume) {
|
||||
std::vector<std::string> pct;
|
||||
for (int i = 0; i <= 10; i++) pct.push_back(std::to_string(i * 10) + " %");
|
||||
return rowOf("volume", "Volume", 2, std::move(pct), std::clamp((int)std::lround(volume * 10.0f), 0, 10));
|
||||
return sliderRow("volume", "Volume", "sound", "Sound", std::round(std::clamp(volume, 0.0f, 1.0f) * 1000.0f) / 10.0f, 0.0f, 100.0f, 10.0f, "%.0f %%");
|
||||
}
|
||||
|
||||
SettingRow aspectRow(float aspectOverride, const AspectChoice* table, int n) {
|
||||
@@ -80,11 +208,11 @@ SettingRow aspectRow(float aspectOverride, const AspectChoice* table, int n) {
|
||||
labels.push_back(table[i].label);
|
||||
if (std::abs(aspectOverride - table[i].value) < 1e-3f) sel = i;
|
||||
}
|
||||
return rowOf("aspect", "Aspect ratio", 0, std::move(labels), sel);
|
||||
return rowOf("aspect", "Aspect ratio", "picture", "Picture", std::move(labels), sel);
|
||||
}
|
||||
|
||||
SettingRow themeRow(const std::string& choiceKey, const std::vector<std::string>& customNames) {
|
||||
SettingRow r = rowOf("theme", "Theme", 3, {"System", "Dark", "Light"}, 0);
|
||||
SettingRow r = rowOf("theme", "Theme", "interface", "Interface", {"System", "Dark", "Light"}, 0);
|
||||
for (const std::string& n : customNames) r.choices.push_back("Custom: " + n);
|
||||
if (choiceKey == "dark") r.index = 1;
|
||||
else if (choiceKey == "light") r.index = 2;
|
||||
@@ -106,11 +234,17 @@ std::string themeKeyAt(int index, const std::vector<std::string>& customNames) {
|
||||
}
|
||||
|
||||
SettingRow tvModeRow(TvSetting stored) {
|
||||
return rowOf("tv_mode", "TV mode", 3, {"Off", "Manual", "Auto"}, (int)stored);
|
||||
return rowOf("tv_mode", "TV mode", "interface", "Interface", {"Off", "Manual", "Auto"}, (int)stored);
|
||||
}
|
||||
|
||||
std::vector<SettingRow> tvAutoRows(TvSetting stored, bool autoFullscreen, bool autoController) {
|
||||
if (stored != TvSetting::Auto) return {};
|
||||
return {onOffRow("tv_auto_fs", "Auto: in fullscreen", "interface", "Interface", autoFullscreen),
|
||||
onOffRow("tv_auto_pad", "Auto: when a controller is used", "interface", "Interface", autoController)};
|
||||
}
|
||||
|
||||
SettingRow shaderRow(const std::string& currentPreset, bool enabled, const std::vector<std::string>& presetPaths) {
|
||||
SettingRow r = rowOf("shader", "Shader", 0, {"Off"}, 0);
|
||||
SettingRow r = rowOf("shader", "Shader", "picture", "Picture", {"Off"}, 0);
|
||||
int sel = 0;
|
||||
for (size_t i = 0; i < presetPaths.size(); i++) {
|
||||
r.choices.push_back(stemOf(presetPaths[i]));
|
||||
@@ -124,13 +258,7 @@ SettingRow shaderRow(const std::string& currentPreset, bool enabled, const std::
|
||||
return r;
|
||||
}
|
||||
|
||||
SettingRow speedNoteRow() {
|
||||
SettingRow r = rowOf("speed_note", "Available once a game has run", 1, {}, 0);
|
||||
r.readOnly = true;
|
||||
return r;
|
||||
}
|
||||
|
||||
SettingRow moreRow() { return rowOf("more", "More settings\xE2\x80\xA6", 3, {}, 0); }
|
||||
SettingRow moreRow() { return rowOf("more", "Desktop settings\xE2\x80\xA6", "interface", "Interface", {}, 0); }
|
||||
|
||||
int stepIndex(const SettingRow& r, int delta, bool wrap) {
|
||||
const int n = (int)r.choices.size();
|
||||
|
||||
@@ -3,39 +3,69 @@
|
||||
// core's list is kept and shown as custom) are unit-tested on their own; AppHost only gathers the inputs.
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "aspect_choices.h"
|
||||
#include "emu_snapshot.h" // OptionDef
|
||||
#include "emu_snapshot.h" // OptionDef, OptionCategory
|
||||
#include "typed_options.h"
|
||||
#include "tv/tv_host.h"
|
||||
#include "tv/tv_mode.h"
|
||||
|
||||
namespace vj::tv {
|
||||
|
||||
constexpr int kSettingGroups = 4;
|
||||
const char* settingGroupLabel(int group); // "Picture" "Speed" "Sound" "Interface"; "" outside 0..3
|
||||
// A rail item: its key (SettingRow::group) and what the rail shows (SettingRow::groupLabel).
|
||||
struct SettingGroup {
|
||||
std::string key, label;
|
||||
};
|
||||
|
||||
// One core option as a row. hidden: no definition (no game has run yet), not visible, locked by the app, or nothing to
|
||||
// choose from. readOnly + note when a session override (Netplay, --option) decides what the core reads. A current value
|
||||
// outside the list (a typed RISC clock) is appended as "<value> (custom)" and selected. `hidden` may be null.
|
||||
SettingRow coreOptionRow(const std::string& id, const std::string& label, int group, const OptionDef* def, const std::string& current,
|
||||
bool visible, bool locked, bool overridden, bool* hidden);
|
||||
SettingRow onOffRow(const std::string& id, const std::string& label, int group, bool on);
|
||||
SettingRow volumeRow(float volume); // "0 %" .. "100 %", index = round(volume * 10)
|
||||
SettingRow aspectRow(float aspectOverride, const AspectChoice* table, int n);
|
||||
// Every visible core option as a row, in category order: the options with no category first under {"core:", "General"}
|
||||
// (an option naming a category the core never defined counts as none), then each category in definition order under
|
||||
// {"core:" + cat.key, cat.desc}; a category with nothing to show makes no group. id = "core:" + key, label = the
|
||||
// categorized title (else the flat one), value = the choice's label (else its value). The rules of decision 4:
|
||||
// * not visible (the core's own gates and the app's locks both end in `visible`), or nothing to choose from: no row;
|
||||
// * `overridden` (Netplay, --option): read-only, with the note that says so;
|
||||
// * a value that is not one of the core's presets (a typed RISC clock): "<value> (custom)" is selected, placed by
|
||||
// coreCustomPos (between its numeric neighbours for an ordered typed number, else last);
|
||||
// * a Host-kind typed key (the Network Link address) is read-only: it can only be typed, in the desktop window;
|
||||
// * an option whose help says it needs a restart carries the note "Applies when the game next starts".
|
||||
// `current`, `visible`, `overridden` and `typed` are per option, so the model never sees CoreHost.
|
||||
std::vector<SettingRow> coreOptionRows(const std::vector<OptionCategory>& cats, const std::vector<OptionDef>& defs,
|
||||
const std::function<std::string(const OptionDef&)>& current,
|
||||
const std::function<bool(const std::string&)>& visible,
|
||||
const std::function<bool(const std::string&)>& overridden,
|
||||
const std::function<const TypedOption*(const std::string&)>& typed);
|
||||
// Where the "(custom)" entry of an option row sits in its choices (and the row's index), or -1 when `current` is one of the
|
||||
// core's values or empty. A typed numeric option whose presets run in numeric order gets it between its neighbours, so Left and
|
||||
// Right step to the nearest presets; any other (a text, an address, presets out of order) gets it last. AppHost maps a stepped
|
||||
// index back to `def.values` with it.
|
||||
int coreCustomPos(const OptionDef& d, const std::string& current, const TypedOption* typed);
|
||||
SettingRow choiceRow(const std::string& id, const std::string& label, const std::string& group, const std::string& groupLabel,
|
||||
std::vector<std::string> choices, int index);
|
||||
// A slider as a stepped list: "lo", "lo+step", ... "hi" printed with fmt ("%.0f ms"); a value off the grid is inserted between
|
||||
// its grid neighbours (last when beyond "hi") as "<value> (custom)" and selected, so Left / Right step to the nearest grid values.
|
||||
// stepValue() maps an index back (the "(custom)" index gives `current` unchanged).
|
||||
SettingRow sliderRow(const std::string& id, const std::string& label, const std::string& group, const std::string& groupLabel,
|
||||
float current, float lo, float hi, float step, const char* fmt);
|
||||
float stepValue(const SettingRow& r, int index, float lo, float step, float current);
|
||||
std::vector<SettingGroup> groupsOf(const std::vector<SettingRow>& rows); // first-seen order, keys unique
|
||||
|
||||
SettingRow onOffRow(const std::string& id, const std::string& label, const std::string& group, const std::string& groupLabel, bool on);
|
||||
SettingRow volumeRow(float volume); // Sound: "0 %" .. "100 %" in tens (a volume between two is shown as custom)
|
||||
SettingRow aspectRow(float aspectOverride, const AspectChoice* table, int n); // Picture
|
||||
// keys: system dark light custom:<name>. A custom theme that is no longer in the list is still the one in force: it is
|
||||
// appended and selected (and themeKeyAt() gives "" for it, so picking it changes nothing).
|
||||
SettingRow themeRow(const std::string& choiceKey, const std::vector<std::string>& customNames);
|
||||
SettingRow themeRow(const std::string& choiceKey, const std::vector<std::string>& customNames); // Interface
|
||||
std::string themeKeyAt(int index, const std::vector<std::string>& customNames); // "" when out of range
|
||||
SettingRow tvModeRow(TvSetting stored); // Off / Manual / Auto
|
||||
SettingRow tvModeRow(TvSetting stored); // Interface: Off / Manual / Auto
|
||||
// The two "Auto: ..." On / Off rows (tv_auto_fs, tv_auto_pad; Interface) that follow the TV mode row: offered only while the
|
||||
// stored setting is Auto, since they decide nothing under Off or Manual. Empty otherwise.
|
||||
std::vector<SettingRow> tvAutoRows(TvSetting stored, bool autoFullscreen, bool autoController);
|
||||
// "Off" then each preset's file name without its extension. The preset in force when it is in neither list (a file picked
|
||||
// in the desktop window) is appended as "<stem> (custom)".
|
||||
// in the desktop window) is appended as "<stem> (custom)". Picture.
|
||||
SettingRow shaderRow(const std::string& currentPreset, bool enabled, const std::vector<std::string>& presetPaths);
|
||||
// The Speed group's only row while no game has run this session (its core options are not known yet): a read-only line, never
|
||||
// steppable, saying why.
|
||||
SettingRow speedNoteRow();
|
||||
SettingRow moreRow(); // "More settings..." (opens the desktop window): no choices
|
||||
SettingRow moreRow(); // Interface: "Desktop settings..." (opens the desktop window): no choices
|
||||
|
||||
// Left / Right (wrap = false: stops at the ends) and Accept (wrap = true). A row with no choices stays where it is.
|
||||
int stepIndex(const SettingRow& r, int delta, bool wrap);
|
||||
|
||||
+105
-5
@@ -58,12 +58,63 @@ void Shell::closeMenu() {
|
||||
if (!open_) return;
|
||||
open_ = false;
|
||||
retire(std::move(menuScreen_)); // the slot's screen may be the caller (Overlays closes the menu from its own Accept)
|
||||
retire(std::move(keptScreen_)); // ... and so may the one ConfirmLaunch was keeping (the Library's Yes)
|
||||
nav_.reset();
|
||||
host_.menuResume();
|
||||
host_.armReleaseGuard(); // whatever is down now (the A that picked Resume) stays masked from the game
|
||||
host_.clearHolds(); // ... and no hold or tap pulse in flight survives the hand-off
|
||||
}
|
||||
|
||||
bool Shell::openSettings() {
|
||||
if (!host_.tvActive()) return false;
|
||||
if (!host_.gameLoaded()) { // the Base layer
|
||||
if (section_ != Section::Settings || stack_.empty()) setSection(Section::Settings);
|
||||
return true;
|
||||
}
|
||||
if (!openMenu()) return false; // already open: true. A sub-screen pushed on the slot's screen (or a deeper level of it) is left as is.
|
||||
if (menuScreenId() != "settings") menuPushScreen(makeSettingsScreen(host_, *this), Row::Settings);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Shell::openLibrary() {
|
||||
if (!host_.tvActive()) return false;
|
||||
if (!host_.gameLoaded()) { // the Base layer
|
||||
if (section_ != Section::Library || stack_.empty()) setSection(Section::Library);
|
||||
return true;
|
||||
}
|
||||
if (!openMenu()) return false; // as openSettings(): the hardcore gate and its toast
|
||||
if (menuScreenId() != "library") menuPushScreen(makeLibraryScreen(host_, *this), Row::Library);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Shell::showingLibrary() const {
|
||||
if (open_) return menuScreenId() == "library"; // (with a game the Base stack is kept but not shown)
|
||||
return baseActive() && section_ == Section::Library;
|
||||
}
|
||||
|
||||
void Shell::requestLaunch(const GameRef& g, const StateRef* state) {
|
||||
if (!host_.gameLoaded()) { // nothing to lose (the Base layer): start it now
|
||||
if (state) host_.launchState(g, *state);
|
||||
else host_.launch(g);
|
||||
launched();
|
||||
return;
|
||||
}
|
||||
if (!openMenu()) return; // a game is running and the question has no menu to sit in (not reachable from a screen today): never launch over it unasked
|
||||
pending_.game = g;
|
||||
pending_.hasState = state != nullptr;
|
||||
if (state) pending_.state = *state;
|
||||
std::unique_ptr<Screen> kept = std::move(menuScreen_); // enter() would retire the slot's screen: No wants it back
|
||||
enter(Page::ConfirmLaunch, Row::No);
|
||||
keptScreen_ = std::move(kept);
|
||||
}
|
||||
|
||||
// ConfirmLaunch's No / Back: the root with the Library back in its slot (the screen kept aside, with its focus).
|
||||
void Shell::declineLaunch() {
|
||||
std::unique_ptr<Screen> kept = std::move(keptScreen_);
|
||||
enter(Page::Root, Row::Library);
|
||||
if (kept) menuPushScreen(std::move(kept), Row::Library);
|
||||
}
|
||||
|
||||
void Shell::toggleMenu() {
|
||||
if (open_) closeMenu();
|
||||
else openMenu();
|
||||
@@ -107,6 +158,11 @@ bool Shell::handleEvent(const SDL_Event& e, uint64_t ms) {
|
||||
// Alt+Enter and the like are hotkeys (a press only: a release always reaches the nav, or a repeat could stick).
|
||||
if (down && (e.key.mod & (SDL_KMOD_CTRL | SDL_KMOD_ALT | SDL_KMOD_GUI))) return false;
|
||||
const bool shift = (e.key.mod & SDL_KMOD_SHIFT) != 0;
|
||||
// Typing goes to the slot's screen under the menu too (the Library's filter); a text field of a desktop window has the keyboard first.
|
||||
if (!base && down && menuScreen_ && !host_.desktopWindowOpen() && !(ImGui::GetCurrentContext() && ImGui::GetIO().WantTextInput)) {
|
||||
const char c = typedChar(e.key);
|
||||
if (c && menuScreen_->typed(c)) return true;
|
||||
}
|
||||
if (base && down) {
|
||||
// A text field of a desktop window has the keyboard: every key is the field's (Enter, Tab, Backspace, the
|
||||
// arrows, Esc too). Key-ups still reach the nav below, so a repeat that began earlier can stop.
|
||||
@@ -161,16 +217,24 @@ void Shell::frame(uint64_t ms) {
|
||||
if (!baseActive()) return;
|
||||
if (stack_.empty()) setSection(Section::Home);
|
||||
if (!live) return; // a desktop window has the pads and keys: the screens wait
|
||||
for (Intent in : nav_.take(ms)) baseIntent(in);
|
||||
const std::vector<Intent> in = nav_.take(ms);
|
||||
for (size_t i = 0; i < in.size(); i++) {
|
||||
repeat_ = nav_.lastWasRepeat() && i + 1 == in.size(); // a repeat is the last intent of its call
|
||||
baseIntent(in[i]);
|
||||
}
|
||||
repeat_ = false;
|
||||
return;
|
||||
}
|
||||
// (Not under a slot screen: enter() would clear it. Leaving the screen rebuilds the root anyway.)
|
||||
if (!menuScreen_ && page_ == Page::Root && (host_.canLoadState() != rootHasLoad_ || tvRowState() != rootTvRow_))
|
||||
enter(Page::Root, rows_[(size_t)focus_.index].kind);
|
||||
for (Intent in : nav_.take(ms)) {
|
||||
intent(in);
|
||||
const std::vector<Intent> in = nav_.take(ms);
|
||||
for (size_t i = 0; i < in.size(); i++) {
|
||||
repeat_ = nav_.lastWasRepeat() && i + 1 == in.size();
|
||||
intent(in[i]);
|
||||
if (!open_) break;
|
||||
}
|
||||
repeat_ = false;
|
||||
}
|
||||
|
||||
void Shell::draw(Layer layer, ImVec2 pos, ImVec2 size) {
|
||||
@@ -220,6 +284,20 @@ void Shell::retire(std::unique_ptr<Screen> s) {
|
||||
if (s) retired_.push_back(std::move(s));
|
||||
}
|
||||
|
||||
// With the in-game menu open it is the menu that has the focus (the Base stack is kept under the game, not shown): the slot's screen, or the row.
|
||||
std::string Shell::focusLabel() {
|
||||
if (open_) {
|
||||
if (menuScreen_) return menuScreen_->focusLabel();
|
||||
return focus_.index >= 0 && focus_.index < (int)rows_.size() ? rows_[(size_t)focus_.index].label : std::string();
|
||||
}
|
||||
return stack_.empty() ? std::string() : stack_.back()->focusLabel();
|
||||
}
|
||||
|
||||
std::string Shell::focusGroup() {
|
||||
if (open_) return menuScreen_ ? menuScreen_->focusGroup() : std::string();
|
||||
return stack_.empty() ? std::string() : stack_.back()->focusGroup();
|
||||
}
|
||||
|
||||
std::string Shell::screenId() const { return stack_.empty() ? std::string() : std::string(stack_.back()->id()); }
|
||||
|
||||
// While a binding is being captured App::handleEvent keeps every event from the shell: a direction that was down when the
|
||||
@@ -269,6 +347,7 @@ void Shell::buildRoot() {
|
||||
rows_.push_back({Row::Save, "Save state"});
|
||||
rootHasLoad_ = host_.canLoadState();
|
||||
if (rootHasLoad_) rows_.push_back({Row::Load, "Load state"}); // hardcore refuses loads: no row at all
|
||||
rows_.push_back({Row::Library, "Library"}); // the TV Library over the paused game (a launch from it asks first)
|
||||
rows_.push_back({Row::Achievements, "Achievements"}); // spec 4's order; the three open a screen in the slot
|
||||
rows_.push_back({Row::Overlays, "Overlays"});
|
||||
rows_.push_back({Row::Settings, "Settings"});
|
||||
@@ -281,6 +360,7 @@ void Shell::buildRoot() {
|
||||
|
||||
void Shell::enter(Page p, Row::Kind focusKind) {
|
||||
retire(std::move(menuScreen_)); // a page replaces whatever the slot held
|
||||
retire(std::move(keptScreen_)); // (declineLaunch() and requestLaunch() move theirs out first)
|
||||
page_ = p;
|
||||
switch (p) {
|
||||
case Page::Root: buildRoot(); break;
|
||||
@@ -306,6 +386,12 @@ void Shell::enter(Page p, Row::Kind focusKind) {
|
||||
rows_.push_back({Row::Yes, "Yes"});
|
||||
rows_.push_back({Row::No, "No"}); // focus starts on the safe answer (enter() is given Row::No)
|
||||
break;
|
||||
case Page::ConfirmLaunch: // as Reset and Close: Yes, No, the focus on No (every confirm is answered the same way)
|
||||
title_ = "Close " + host_.gameTitle() + " and start " + pending_.game.title + "?";
|
||||
rows_.clear();
|
||||
rows_.push_back({Row::Yes, "Yes"});
|
||||
rows_.push_back({Row::No, "No"});
|
||||
break;
|
||||
}
|
||||
focus_ = LinearFocus();
|
||||
focus_.count = (int)rows_.size();
|
||||
@@ -320,6 +406,7 @@ void Shell::menuPushScreen(std::unique_ptr<Screen> s, Row::Kind from) {
|
||||
if (!open_ || !s) return;
|
||||
s->setEmbedded(true);
|
||||
retire(std::move(menuScreen_));
|
||||
retire(std::move(keptScreen_)); // (a Settings / Library opened over a pending launch)
|
||||
menuScreen_ = std::move(s);
|
||||
menuFrom_ = from;
|
||||
}
|
||||
@@ -360,6 +447,7 @@ void Shell::accept() {
|
||||
case Row::Achievements: menuPushScreen(makeAchievementsScreen(host_, *this), Row::Achievements); break;
|
||||
case Row::Overlays: menuPushScreen(makeOverlaysScreen(host_, *this), Row::Overlays); break;
|
||||
case Row::Settings: menuPushScreen(makeSettingsScreen(host_, *this), Row::Settings); break;
|
||||
case Row::Library: menuPushScreen(makeLibraryScreen(host_, *this), Row::Library); break;
|
||||
case Row::Controls:
|
||||
closeMenu(); // the sheet is drawn over the game, so the game has to be showing
|
||||
host_.showControlsOverlay();
|
||||
@@ -377,9 +465,20 @@ void Shell::accept() {
|
||||
if (host_.loadState(r.slot)) closeMenu();
|
||||
}
|
||||
break;
|
||||
case Row::No: enter(Page::Root, page_ == Page::ConfirmReset ? Row::Reset : Row::Close); break;
|
||||
case Row::No:
|
||||
if (page_ == Page::ConfirmLaunch) declineLaunch();
|
||||
else enter(Page::Root, page_ == Page::ConfirmReset ? Row::Reset : Row::Close);
|
||||
break;
|
||||
case Row::Yes:
|
||||
if (page_ == Page::ConfirmReset) {
|
||||
if (page_ == Page::ConfirmLaunch) {
|
||||
// The app closes the running game's menu and auto-saves it on the way (App::openContent, a queued event); until it does the
|
||||
// Library is what the menu shows again, so a refused launch (the debugger has the game halted) leaves the player somewhere.
|
||||
const Pending p = pending_;
|
||||
if (p.hasState) host_.launchState(p.game, p.state);
|
||||
else host_.launch(p.game);
|
||||
launched();
|
||||
if (open_) declineLaunch(); // (a host that closed the menu on the way has nothing to come back to)
|
||||
} else if (page_ == Page::ConfirmReset) {
|
||||
host_.reset();
|
||||
closeMenu();
|
||||
} else {
|
||||
@@ -400,6 +499,7 @@ void Shell::back() {
|
||||
case Page::LoadSlots: enter(Page::Root, Row::Load); break;
|
||||
case Page::ConfirmReset: enter(Page::Root, Row::Reset); break;
|
||||
case Page::ConfirmClose: enter(Page::Root, Row::Close); break;
|
||||
case Page::ConfirmLaunch: declineLaunch(); break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+32
-5
@@ -3,8 +3,8 @@
|
||||
// pads and keyboard belong to while either is up (spec 3.1). Everything it needs of the app goes through tv::Host.
|
||||
//
|
||||
// The in-game menu is plain data here: pages of rows, a focus index, and what Accept / Back do on each. Drawing it
|
||||
// (tv_ingame.cpp) only reads that model. Beside the pages sits one slot for a tv::Screen (Achievements, Overlays, Settings): while
|
||||
// it is set the panel's list area is that screen's, it gets every intent but Back, and Back clears it onto the row it came from. The Base layer is a stack of tv::Screen (tv_screen.h) with a section tab
|
||||
// (tv_ingame.cpp) only reads that model. Beside the pages sits one slot for a tv::Screen (Achievements, Overlays, Settings, Library): while
|
||||
// it is set the panel's list area is that screen's (the whole panel for a wide() one), it gets every intent but Back and the typing, and Back clears it onto the row it came from. The Base layer is a stack of tv::Screen (tv_screen.h) with a section tab
|
||||
// per root screen; the chrome around it is tv_chrome.cpp.
|
||||
//
|
||||
// Input: while the shell owns the input handleEvent() feeds NavInput with the buttons / keys / axes that mean
|
||||
@@ -40,9 +40,9 @@ enum class Layer { Base, Menu }; // Base: TV screens, under desktop windows; M
|
||||
class Shell {
|
||||
public:
|
||||
// ---- the in-game menu model (read by tv_ingame.cpp) ---------------------------------------------------------
|
||||
enum class Page { Root, SaveSlots, LoadSlots, ConfirmReset, ConfirmClose };
|
||||
enum class Page { Root, SaveSlots, LoadSlots, ConfirmReset, ConfirmClose, ConfirmLaunch };
|
||||
struct Row {
|
||||
enum Kind { Resume, Save, Load, Achievements, Overlays, Settings, Controls, TvMode, Reset, Close, Slot, No, Yes };
|
||||
enum Kind { Resume, Save, Load, Library, Achievements, Overlays, Settings, Controls, TvMode, Reset, Close, Slot, No, Yes };
|
||||
Kind kind;
|
||||
std::string label;
|
||||
int slot = -1; // Slot rows: the state slot
|
||||
@@ -52,9 +52,24 @@ public:
|
||||
explicit Shell(Host& h);
|
||||
|
||||
bool menuOpen() const { return open_; }
|
||||
bool openMenu(); // menuPause(); refused -> toast(why), false
|
||||
bool openMenu(); // menuPause(); a host may still refuse (toast(why), false)
|
||||
void closeMenu(); // menuResume(), armReleaseGuard(), clearHolds()
|
||||
void toggleMenu();
|
||||
// Settings, wherever the shell is showing: TV mode on with no game -> the Settings section; with a game -> the in-game menu (opened
|
||||
// the way C_TVMENU opens it, so the same pause / hardcore gate) with the Settings screen in its slot. Idempotent (already there:
|
||||
// nothing changes). False = TV mode is off (or a host refused the menu).
|
||||
bool openSettings();
|
||||
// The Library, wherever the shell is showing: as openSettings(), with the Library in the slot (a game) or as the Library section.
|
||||
// Idempotent; False = TV mode is off (or a host refused the menu).
|
||||
bool openLibrary();
|
||||
// The Library is what the shell is showing now: the Library section on the Base layer, or the Library in the menu's slot (the
|
||||
// native File menu's checkmark).
|
||||
bool showingLibrary() const;
|
||||
// A screen wants to start `g` (with `state` loaded when given; null = fresh). No game running: it starts at once (host.launch /
|
||||
// launchState, then launched()). A game running (the screen is the menu's slot): the menu asks first -- Page::ConfirmLaunch,
|
||||
// "Close <running> and start <g>?", focus on No. Yes starts it (the app closes the menu on its way); No and Back return to the slot.
|
||||
// Rows Yes, No with the focus on No, as the Reset and Close confirms.
|
||||
void requestLaunch(const GameRef& g, const StateRef* state);
|
||||
bool ownsInput() const; // §3.1: inputOwner() == TvUi: the menu is open, or TV is active with no game running
|
||||
bool handleEvent(const SDL_Event& e, uint64_t ms); // nav input while ownsInput(); true = consumed
|
||||
void frame(uint64_t ms); // NavInput::take -> the top screen
|
||||
@@ -68,6 +83,10 @@ public:
|
||||
void openSection(Section s, std::unique_ptr<Screen> root); // ... with a root the caller prepared (Game details -> Achievements on a game)
|
||||
void push(std::unique_ptr<Screen> s);
|
||||
void pop(); // never pops the root
|
||||
std::string focusLabel(); // the top screen's focusLabel(); with the in-game menu open, the menu's (the slot screen's, else the row's); "" when none
|
||||
std::string focusGroup(); // the top screen's focusGroup() (Settings: the rail's group key), or the slot screen's with the menu open
|
||||
// The intent being handled is a repeat of a held direction, not a press (NavInput's timer). Only meaningful inside Screen::intent.
|
||||
bool repeatIntent() const { return repeat_; }
|
||||
std::string screenId() const; // the top screen's id(), "" when the stack is empty
|
||||
FocusMemory& memory() { return memory_; }
|
||||
void launched(); // a game was started from a screen: armReleaseGuard() + clearHolds()
|
||||
@@ -93,6 +112,7 @@ private:
|
||||
int tvRowState() const; // rootTvRow_ as the host says it is now
|
||||
void accept();
|
||||
void back();
|
||||
void declineLaunch(); // ConfirmLaunch's No / Back: the Library comes back in the slot, where it was
|
||||
void clearMenuScreen(); // the slot's Back: retire the screen and return to the root, focused on the row it came from
|
||||
void drawMenu(ImVec2 pos, ImVec2 size); // tv_ingame.cpp: the menu model drawn over the dimmed game
|
||||
void drawBase(ImVec2 pos, ImVec2 size); // tv_chrome.cpp: the Base window -- chrome, then the stack
|
||||
@@ -113,6 +133,12 @@ private:
|
||||
bool focusWindow_ = false; // the menu just opened: its window takes the focus once
|
||||
bool rootHasLoad_ = false; // the Load-state row is in the root page (re-evaluated while it shows)
|
||||
std::unique_ptr<Screen> menuScreen_; // the slot; set only while open_ (closeMenu() and enter() retire it)
|
||||
std::unique_ptr<Screen> keptScreen_; // the slot's screen while ConfirmLaunch asks (enter() would retire it); No puts it back
|
||||
struct Pending {
|
||||
GameRef game;
|
||||
StateRef state{-1, 0};
|
||||
bool hasState = false;
|
||||
} pending_; // what ConfirmLaunch's Yes starts
|
||||
Row::Kind menuFrom_ = Row::Resume; // the root row that opened it
|
||||
int rootTvRow_ = 0; // the TV-mode row of the root page: 0 = none, 1 = "Enter TV mode", 2 = "Exit TV mode" (re-evaluated while it shows)
|
||||
|
||||
@@ -123,6 +149,7 @@ private:
|
||||
unsigned stackGen_ = 0; // bumped on every push / pop / section change: drawBase stops when its list went stale
|
||||
FocusMemory memory_;
|
||||
bool capturing_ = false;
|
||||
bool repeat_ = false; // the intent in hand came from NavInput's repeat timer
|
||||
bool baseWas_ = false; // baseActive() with no desktop window open over it, at the last frame(): a change resets the nav
|
||||
bool padDriven_ = false; // the last input was a pad: the pointer is hidden until the mouse moves
|
||||
};
|
||||
|
||||
+109
-4
@@ -12,6 +12,7 @@ namespace widgets {
|
||||
namespace {
|
||||
|
||||
std::vector<std::string>* g_probe = nullptr;
|
||||
std::vector<TextAt>* g_posProbe = nullptr;
|
||||
|
||||
// The UTF-8 string cut to the longest prefix (plus an ellipsis) that fits in maxW.
|
||||
std::string fit(float px, const std::string& s, float maxW) {
|
||||
@@ -26,6 +27,29 @@ std::string fit(float px, const std::string& s, float maxW) {
|
||||
return dots;
|
||||
}
|
||||
|
||||
// The UTF-8 string's longest prefix (at least one character) that fits in maxW at px.
|
||||
size_t prefixThatFits(float px, const std::string& s, float maxW) {
|
||||
size_t best = 0, n = 0;
|
||||
while (n < s.size()) {
|
||||
n++;
|
||||
while (n < s.size() && (static_cast<unsigned char>(s[n]) & 0xC0) == 0x80) n++; // on to the next character boundary
|
||||
if (best > 0 && textWidth(px, s.substr(0, n)) > maxW) break;
|
||||
best = n;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// Every space-separated word of `s` is no wider than maxW at px.
|
||||
bool wordsFit(float px, const std::string& s, float maxW) {
|
||||
for (size_t i = 0; i < s.size();) {
|
||||
size_t j = s.find(' ', i);
|
||||
if (j == std::string::npos) j = s.size();
|
||||
if (j > i && textWidth(px, s.substr(i, j - i)) > maxW) return false;
|
||||
i = j + 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
ImVec2 scaledAbout(ImVec2 pos, ImVec2 size, float k, ImVec2* outSize) {
|
||||
const ImVec2 c(pos.x + size.x * 0.5f, pos.y + size.y * 0.5f);
|
||||
*outSize = ImVec2(size.x * k, size.y * k);
|
||||
@@ -35,6 +59,7 @@ ImVec2 scaledAbout(ImVec2 pos, ImVec2 size, float k, ImVec2* outSize) {
|
||||
} // namespace
|
||||
|
||||
void setTextProbe(std::vector<std::string>* sink) { g_probe = sink; }
|
||||
void setTextPosProbe(std::vector<TextAt>* sink) { g_posProbe = sink; }
|
||||
|
||||
void FocusTweens::update(int count, int focusIndex, int pageKey, double now) {
|
||||
if (pageKey != key || (int)t.size() != count) {
|
||||
@@ -89,10 +114,82 @@ std::string formatDate(int64_t secs) {
|
||||
return std::to_string(tm.tm_mday) + " " + kMonths[tm.tm_mon % 12] + " " + std::to_string(tm.tm_year + 1900);
|
||||
}
|
||||
|
||||
std::vector<std::string> wrapLines(float px, const std::string& s, float maxW, int maxLines) {
|
||||
std::vector<std::string> words;
|
||||
for (size_t i = 0; i < s.size();) {
|
||||
while (i < s.size() && s[i] == ' ') i++;
|
||||
size_t j = i;
|
||||
while (j < s.size() && s[j] != ' ') j++;
|
||||
if (j > i) words.push_back(s.substr(i, j - i));
|
||||
i = j;
|
||||
}
|
||||
std::vector<std::string> lines;
|
||||
if (words.empty()) return lines;
|
||||
if (maxW <= 0) {
|
||||
std::string one;
|
||||
for (const std::string& w : words) one += (one.empty() ? "" : " ") + w;
|
||||
return {one};
|
||||
}
|
||||
std::string line;
|
||||
for (const std::string& w : words) {
|
||||
const std::string joined = line.empty() ? w : line + " " + w;
|
||||
if (textWidth(px, joined) <= maxW) {
|
||||
line = joined;
|
||||
continue;
|
||||
}
|
||||
if (!line.empty()) lines.push_back(line);
|
||||
line = w;
|
||||
while (textWidth(px, line) > maxW) { // a word wider than the line: split where it must be
|
||||
const size_t n = prefixThatFits(px, line, maxW);
|
||||
if (n >= line.size()) break;
|
||||
lines.push_back(line.substr(0, n));
|
||||
line = line.substr(n);
|
||||
}
|
||||
}
|
||||
lines.push_back(line);
|
||||
if (maxLines > 0 && (int)lines.size() > maxLines) { // the last line that fits ends in "...", cut at a word
|
||||
lines.resize((size_t)maxLines);
|
||||
std::string& last = lines.back();
|
||||
const std::string dots = "...";
|
||||
while (!last.empty() && textWidth(px, last + dots) > maxW) {
|
||||
const size_t sp = last.rfind(' ');
|
||||
if (sp != std::string::npos) {
|
||||
last.resize(sp);
|
||||
continue;
|
||||
}
|
||||
size_t n = last.size() - 1; // one word left: cut it back to a character boundary
|
||||
while (n > 0 && (static_cast<unsigned char>(last[n]) & 0xC0) == 0x80) n--;
|
||||
last.resize(n);
|
||||
}
|
||||
last += dots;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
ImVec2 textLines(ImDrawList* dl, float px, ImVec2 pos, ImU32 c, const std::vector<std::string>& lines, float lineGap, bool alignRight) {
|
||||
ImFont* f = ImGui::GetFont();
|
||||
if (g_probe) {
|
||||
std::string all;
|
||||
for (const std::string& l : lines) all += (all.empty() ? "" : " ") + l;
|
||||
g_probe->push_back(all);
|
||||
}
|
||||
float w = 0, y = pos.y;
|
||||
for (const std::string& l : lines) {
|
||||
const float lw = textWidth(px, l);
|
||||
const ImVec2 at(alignRight ? pos.x - lw : pos.x, y);
|
||||
if (g_posProbe) g_posProbe->push_back({l, at, px});
|
||||
dl->AddText(f, px, at, c, l.c_str(), l.c_str() + l.size());
|
||||
w = std::max(w, lw);
|
||||
y += px + lineGap;
|
||||
}
|
||||
return ImVec2(w, lines.empty() ? 0.0f : y - pos.y - lineGap);
|
||||
}
|
||||
|
||||
ImVec2 text(ImDrawList* dl, float px, ImVec2 pos, ImU32 c, const std::string& s, float maxW, float wrapW) {
|
||||
ImFont* f = ImGui::GetFont();
|
||||
const std::string shown = wrapW > 0 ? s : fit(px, s, maxW);
|
||||
if (g_probe) g_probe->push_back(shown);
|
||||
if (g_posProbe) g_posProbe->push_back({shown, pos, px});
|
||||
dl->AddText(f, px, pos, c, shown.c_str(), shown.c_str() + shown.size(), wrapW);
|
||||
return f->CalcTextSizeA(px, FLT_MAX, wrapW, shown.c_str(), shown.c_str() + shown.size());
|
||||
}
|
||||
@@ -188,10 +285,18 @@ Hit tile(ImDrawList* dl, const Metrics& m, ImVec2 pos, ImVec2 size, const TileAr
|
||||
const ImVec2 i0(p0.x + (sz.x - iw) * 0.5f, p0.y + (sz.y - ih) * 0.5f);
|
||||
dl->AddImageRounded((ImTextureID)(intptr_t)a.tex, i0, ImVec2(i0.x + iw, i0.y + ih), ImVec2(0, 0), ImVec2(1, 1),
|
||||
IM_COL32_WHITE, round);
|
||||
} else { // no picture (yet): the title, wrapped inside the padding and sitting at the foot
|
||||
const float pad = 18 * m.u * k, px = m.caption * k, wrap = sz.x - 2 * pad;
|
||||
const ImVec2 ts = textSize(px, a.title, wrap);
|
||||
text(dl, px, ImVec2(p0.x + pad, std::max(p0.y + pad, p1.y - pad - ts.y)), col(p.placeholderText), a.title, 0, wrap);
|
||||
} else { // no picture (yet): the title, wrapped at words inside the padding and sitting at the foot
|
||||
// The lines are broken at the tile's size at rest and drawn at the focused size: both grow together, so focus never rewraps.
|
||||
const float pad = 8 * m.u, wrap = size.x - 2 * pad;
|
||||
float px0 = m.caption;
|
||||
for (const float tryPx : {m.caption, m.minText}) { // the caption size if the longest word fits the line, else the 24 u floor
|
||||
px0 = tryPx;
|
||||
if (wordsFit(tryPx, a.title, wrap)) break;
|
||||
}
|
||||
const int maxLines = std::max(1, (int)((size.y - 2 * pad) / px0));
|
||||
const std::vector<std::string> lines = wrapLines(px0, a.title, wrap, maxLines);
|
||||
const float px = px0 * k, h = (float)lines.size() * px;
|
||||
textLines(dl, px, ImVec2(p0.x + pad * k, std::max(p0.y + pad * k, p1.y - pad * k - h)), col(p.placeholderText), lines);
|
||||
}
|
||||
if (a.disc) {
|
||||
const float px = m.minText * k, padX = 8 * m.u * k, padY = 3 * m.u * k;
|
||||
|
||||
@@ -22,6 +22,14 @@ namespace widgets {
|
||||
|
||||
// Tests: every string the widgets draw is also appended to `sink` (nullptr = off, the default).
|
||||
void setTextProbe(std::vector<std::string>* sink);
|
||||
// Tests: ... and where each one was drawn (the pen position, pixels). Separate from the string probe so a test that wants places
|
||||
// switches it on alone (nullptr = off, the default).
|
||||
struct TextAt {
|
||||
std::string s;
|
||||
ImVec2 pos;
|
||||
float px;
|
||||
};
|
||||
void setTextPosProbe(std::vector<TextAt>* sink);
|
||||
|
||||
// One 0..1 "how focused" value per item of a list; moving the focus retargets two of them (the old item eases out,
|
||||
// the new one eases in). A change of `key` (another page) or of the item count starts over with no animation.
|
||||
@@ -50,6 +58,15 @@ ImVec2 text(ImDrawList* dl, float px, ImVec2 pos, ImU32 col, const std::string&
|
||||
float textWidth(float px, const std::string& s);
|
||||
// The size `text` would draw `s` at, wrapped at wrapW (0 = no wrap).
|
||||
ImVec2 textSize(float px, const std::string& s, float wrapW = 0);
|
||||
// `s` broken into lines no wider than maxW, at spaces (never inside a word). More than maxLines (0 = any number) and the last line
|
||||
// is cut at a word, plus "..."; a single word wider than maxW cannot be broken at a space, so it is split where it must (the only
|
||||
// time a word is cut), and the ellipsis does not apply to it. UTF-8 safe. maxW <= 0 = one line.
|
||||
std::vector<std::string> wrapLines(float px, const std::string& s, float maxW, int maxLines = 0);
|
||||
// Draws wrapLines' result one line under the other (`lineGap` between them), the left edge at pos.x, or -- `alignRight` -- every line
|
||||
// ending at pos.x. The text probe gets the lines joined with spaces once (the whole text, as `text` would), the position probe each
|
||||
// line at its place. Returns the block's size.
|
||||
ImVec2 textLines(ImDrawList* dl, float px, ImVec2 pos, ImU32 col, const std::vector<std::string>& lines, float lineGap = 0,
|
||||
bool alignRight = false);
|
||||
// "16 Oct 2026": a date in the local time zone, from seconds since the epoch (English month names, whatever the locale).
|
||||
std::string formatDate(int64_t secs);
|
||||
|
||||
|
||||
+66
-3
@@ -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
|
||||
|
||||
+38
-14
@@ -123,13 +123,7 @@ bool matchesFilter(const OptionDef& d, const std::string& f) {
|
||||
|
||||
void drawCoreOptions(App& app) {
|
||||
CoreHost& host = app.host;
|
||||
if (!host.isLoaded() && host.optionDefs().empty()) {
|
||||
ImGui::TextWrapped(
|
||||
"The core's options appear here once a game is loaded (or the console is booted). "
|
||||
"The menu is generated from the core's own option definitions.");
|
||||
return;
|
||||
}
|
||||
if (host.isLoaded()) {
|
||||
if (host.isLoaded()) { // the table is there from startup (CoreHost::probeOptions), game or not
|
||||
if (ImGui::Button("Restart game to apply")) app.reloadContent();
|
||||
ui::hint("'Restart required' options apply on the next load.");
|
||||
}
|
||||
@@ -245,6 +239,38 @@ void drawStorageTab(App& app) {
|
||||
ui::hint("SRAM, EEPROM and the Memory Track. HLE boot is the default, so no BIOS file is needed (Settings > BIOS).");
|
||||
}
|
||||
|
||||
// TV mode (#991 phase 4): Off / Manual / Auto, and under Auto the two triggers. Both setters reconfigure TvMode, which turns a
|
||||
// trigger-started TV mode into a Manual one (it stays on; mouse and key hand-back stops), and neither saves: this does.
|
||||
void drawTvModeSettings(App& app) {
|
||||
ImGui::SeparatorText("TV mode");
|
||||
static const char* const kLabels[] = {"Off", "Manual", "Auto"}; // tv::TvSetting order
|
||||
int cur = (int)app.tvSetting;
|
||||
ImGui::SetNextItemWidth(ui::em(10.0f));
|
||||
if (ImGui::BeginCombo("##tv_mode", kLabels[cur])) {
|
||||
for (int i = 0; i < 3; i++)
|
||||
if (ImGui::Selectable(kLabels[i], cur == i) && i != cur) {
|
||||
app.setTvSetting((tv::TvSetting)i);
|
||||
app.saveConfig();
|
||||
}
|
||||
ImGui::EndCombo();
|
||||
}
|
||||
if (app.tvSetting == tv::TvSetting::Auto) {
|
||||
bool fs = app.cfg.getBool("ui.tv_auto_fullscreen", true);
|
||||
if (ImGui::Checkbox("Turn on in fullscreen", &fs)) {
|
||||
app.setTvAuto(true, fs);
|
||||
app.saveConfig();
|
||||
}
|
||||
bool pad = app.cfg.getBool("ui.tv_auto_controller", true);
|
||||
if (ImGui::Checkbox("Turn on when a controller is used", &pad)) {
|
||||
app.setTvAuto(false, pad);
|
||||
app.saveConfig();
|
||||
}
|
||||
}
|
||||
ui::hint("A 10-foot look for a pad and a TV. Manual: View > TV mode, Ctrl/Cmd+Shift+T, or F4 / a Share hold with no game. "
|
||||
"Auto also turns it on in fullscreen and when a controller is used in the library (mouse movement or typing "
|
||||
"turns it off again). Nightly and local builds start on Auto, releases on Manual.");
|
||||
}
|
||||
|
||||
// Settings > Interface: how the app itself looks and behaves (as opposed to Video's picture and Storage's saves).
|
||||
// The config keys are the ones these controls always had (ui.*, saves.resume, library.*): nothing is renamed.
|
||||
void drawInterfaceTab(App& app) {
|
||||
@@ -252,6 +278,7 @@ void drawInterfaceTab(App& app) {
|
||||
ImGui::SeparatorText("Overlays");
|
||||
ImGui::Checkbox("Show frame rate", &app.showFps);
|
||||
drawControlsOverlaySettings(app);
|
||||
drawTvModeSettings(app);
|
||||
drawAppearanceSettings(app);
|
||||
drawFontSettings(app);
|
||||
ImGui::SeparatorText("Performance");
|
||||
@@ -549,7 +576,7 @@ void drawMenuBar(App& app) {
|
||||
if (ImGui::BeginMenu("File")) {
|
||||
if (ImGui::MenuItem("Open...", "Ctrl+O")) app.showOpenDialog();
|
||||
if (ImGui::MenuItem("New window", "Ctrl+N")) app.newWindow();
|
||||
if (ImGui::MenuItem("Game library...", "Ctrl+L", app.library.show && !app.homeVisible())) app.toggleLibrary();
|
||||
if (ImGui::MenuItem("Game library...", "Ctrl+L", (app.library.show && !app.homeVisible()) || (app.tvMode.active() && app.tvShell().showingLibrary()))) app.openLibrary();
|
||||
if (ImGui::BeginMenu("Open recent", !app.recents.empty())) {
|
||||
std::vector<std::string> list = app.recents; // opening reorders the list
|
||||
for (size_t i = 0; i < list.size(); i++) {
|
||||
@@ -565,10 +592,7 @@ void drawMenuBar(App& app) {
|
||||
if (ImGui::MenuItem("CD BIOS (no disc)")) app.bootBios(bios::Cd);
|
||||
if (ImGui::MenuItem("Cartridge boot ROM (empty slot)")) app.bootBios(bios::CartBoot);
|
||||
ImGui::Separator();
|
||||
if (ImGui::MenuItem("BIOS & boot settings...")) {
|
||||
app.showSettings = true;
|
||||
app.devSelectTab = "BIOS";
|
||||
}
|
||||
if (ImGui::MenuItem("BIOS & boot settings...")) app.openBiosSettings();
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
ImGui::Separator();
|
||||
@@ -628,14 +652,14 @@ void drawMenuBar(App& app) {
|
||||
overlayEvent(app.input.controls, OverlayEvent::Toggle);
|
||||
}
|
||||
ImGui::MenuItem("Library on home screen", nullptr, &app.homeLibrary);
|
||||
if (ImGui::MenuItem("TV mode", "Ctrl+Shift+T", app.tvMode.active(), tv::effectiveSetting(app.tvSetting) != tv::TvSetting::Off))
|
||||
if (ImGui::MenuItem("TV mode", "Ctrl+Shift+T", app.tvMode.active(), app.tvSetting != tv::TvSetting::Off))
|
||||
app.toggleTvMode();
|
||||
if (ImGui::MenuItem("VSync", nullptr, &app.vsync)) app.applyVsync();
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
drawToolsMenu(app);
|
||||
if (ImGui::BeginMenu("Settings")) {
|
||||
if (ImGui::MenuItem("Settings...", "Ctrl+,")) app.showSettings = true;
|
||||
if (ImGui::MenuItem("Settings...", "Ctrl+,")) app.openSettings();
|
||||
if (ImGui::MenuItem("Mute", nullptr, &app.mute)) app.applyAudioSettings();
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// The core's option table without a game (#991 Task 19): CoreHost::probeOptions() runs retro_set_environment with only the option
|
||||
// commands answered, so the settings screens list every option from the first frame, and the game that loads later sees the core
|
||||
// exactly as if nothing had been probed.
|
||||
//
|
||||
// vj_core_options_probe_test [rom.j64]
|
||||
//
|
||||
// Part 1 (no game): the table is there (118 options in 10 categories at the time of writing), the display rules ran (a gated row
|
||||
// is hidden), a write with no game is stored and reported, a second probe pushes nothing, and the core never loaded anything.
|
||||
// Part 1b (the emulation thread, #967): the thread started after the probe publishes the probed table at once, so the UI reads it from
|
||||
// its first frame, and a write made from the UI thread reaches both copies.
|
||||
// Part 2 (with a ROM): the visibility of every option after the FIRST load that follows the probe is the visibility after a second
|
||||
// load of the same game -- the probe must not leave the core's display latches ("hidden" already) where the load's own display pass
|
||||
// finds no change and never hides the row again.
|
||||
#include "core_host.h"
|
||||
#include "emu_thread.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
using namespace vj;
|
||||
|
||||
static int g_fail = 0, g_checks = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
g_checks++; \
|
||||
if (!(cond)) { \
|
||||
g_fail++; \
|
||||
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
// The emulation thread's client: no game, so it only owns the core and idles.
|
||||
struct IdleClient : EmuThread::Client {
|
||||
CoreHost& host = CoreHost::instance();
|
||||
void threadStarted() override { host.setOwner(std::this_thread::get_id()); }
|
||||
void threadStopping() override { host.setOwner(std::thread::id()); }
|
||||
bool canRun() override { return false; }
|
||||
double framePeriod() override { return 1.0 / 60.0; }
|
||||
double audioQueuedMs() override { return -1.0; }
|
||||
void runFrame() override {}
|
||||
void afterCommands() override { host.publishSnapshot(); }
|
||||
};
|
||||
|
||||
static const OptionDef* findDef(CoreHost& h, const std::string& key) {
|
||||
for (const OptionDef& d : h.optionDefs())
|
||||
if (d.key == key) return &d;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static std::map<std::string, bool> visibility(CoreHost& h) {
|
||||
std::map<std::string, bool> m;
|
||||
for (const OptionDef& d : h.optionDefs()) m[d.key] = h.optionVisible(d.key);
|
||||
return m;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
namespace fs = std::filesystem;
|
||||
std::error_code ec;
|
||||
const fs::path tmp = fs::temp_directory_path(ec) / ("vj-core-options-probe-" + std::to_string((long long)std::time(nullptr)));
|
||||
fs::create_directories(tmp / "saves", ec);
|
||||
fs::create_directories(tmp / "system", ec);
|
||||
CoreHost& h = CoreHost::instance();
|
||||
h.setDirectories((tmp / "system").string() + "/", (tmp / "saves").string() + "/");
|
||||
h.inputPoll = [] {};
|
||||
h.inputState = [](unsigned, unsigned, unsigned, unsigned) -> int16_t { return 0; };
|
||||
std::vector<std::pair<std::string, std::string>> changed;
|
||||
h.onOptionChanged = [&](const std::string& k, const std::string& v, bool erased) { if (!erased) changed.push_back({k, v}); };
|
||||
|
||||
// ---- part 1: no game ---------------------------------------------------------------------------------------------------------
|
||||
CHECK(h.optionDefs().empty() && h.optionCategories().empty() && h.optionPushes() == 0);
|
||||
h.probeOptions();
|
||||
CHECK(h.optionDefs().size() >= 100);
|
||||
CHECK(h.optionCategories().size() == 10);
|
||||
CHECK(!h.isLoaded());
|
||||
const unsigned pushes = h.optionPushes();
|
||||
CHECK(pushes >= 1);
|
||||
h.probeOptions(); // once is all it takes
|
||||
CHECK(h.optionPushes() == pushes && h.optionDefs().size() >= 100 && !h.isLoaded());
|
||||
|
||||
const std::string res = "virtualjaguar_internal_resolution";
|
||||
const OptionDef* d = findDef(h, res);
|
||||
CHECK(d != nullptr && d->categoryKey == "video");
|
||||
if (d) CHECK(!d->values.empty() && h.optionValue(*d) == d->defaultValue);
|
||||
|
||||
// The display pass ran: libretro.c update_option_visibility() hides the Network Link host and port while the mode is its default
|
||||
// (auto), the texture-dump preview knob while the dump is off, and the voice chat sub-options while voice chat is off; options
|
||||
// with no gate stay visible; with no content loaded the CD-only and cartridge-only groups both stay (the type is unknown).
|
||||
CHECK(!h.optionVisible("virtualjaguar_netlink_host"));
|
||||
CHECK(!h.optionVisible("virtualjaguar_netlink_port"));
|
||||
CHECK(!h.optionVisible("virtualjaguar_texdump_16bpp"));
|
||||
CHECK(!h.optionVisible("virtualjaguar_voice_chat_gate"));
|
||||
CHECK(h.optionVisible(res) && h.optionVisible("virtualjaguar_netlink"));
|
||||
CHECK(h.optionVisible("virtualjaguar_cd_bios_type") && h.optionVisible("virtualjaguar_bios"));
|
||||
|
||||
// A write with no game is kept, reported (the app saves it to config.ini there) and read back; the next load reads it.
|
||||
h.setOptionValue(res, "2x");
|
||||
CHECK(h.optionValueFor(res, "") == "2x");
|
||||
CHECK(changed.size() == 1 && changed[0].first == res && changed[0].second == "2x");
|
||||
if (d) CHECK(h.optionValue(*d) == "2x");
|
||||
CHECK(h.optionPushes() == pushes && !h.isLoaded());
|
||||
h.resetOptionToDefault(res);
|
||||
|
||||
// ---- part 1b: the emulation thread starts after the probe -------------------------------------------------------------------
|
||||
{
|
||||
EmuThread emu;
|
||||
IdleClient client;
|
||||
h.post = [&emu](std::function<void()> fn) { emu.post(std::move(fn)); };
|
||||
emu.setPaused(true);
|
||||
CHECK(emu.start(EmuThread::Mode::Threaded, client));
|
||||
CHECK(h.threaded() && !h.onOwner());
|
||||
h.pinSnapshot();
|
||||
CHECK(h.optionDefs().size() >= 100 && h.optionCategories().size() == 10 && h.optionPushes() == pushes); // the UI's copy, from the first pin
|
||||
CHECK(!h.optionVisible("virtualjaguar_netlink_host") && h.optionVisible(res));
|
||||
CHECK(h.optionValueFor(res, "") == "");
|
||||
changed.clear();
|
||||
h.setOptionValue(res, "2x"); // off the owner: the UI's copy now, the config write here, the core's copy at the boundary
|
||||
CHECK(h.optionValueFor(res, "") == "2x" && changed.size() == 1 && changed[0].second == "2x");
|
||||
CHECK(emu.call([&] { return h.optionValueFor(res, ""); }) == "2x");
|
||||
h.resetOptionToDefault(res);
|
||||
CHECK(h.optionValueFor(res, "") == "" && emu.call([&] { return h.optionValueFor(res, ""); }) == "");
|
||||
CHECK(emu.stop());
|
||||
h.post = nullptr;
|
||||
CHECK(!h.threaded() && h.onOwner() && !h.isLoaded());
|
||||
changed.clear();
|
||||
}
|
||||
|
||||
// ---- part 2: the first game after a probe sees what the second one does ------------------------------------------------------
|
||||
if (argc > 1) {
|
||||
const std::string rom = argv[1];
|
||||
std::string err;
|
||||
CHECK(h.loadContent(rom, err));
|
||||
CHECK(h.isLoaded() && h.optionPushes() > pushes); // startSession re-pushed the table
|
||||
const std::map<std::string, bool> first = visibility(h);
|
||||
CHECK(!first.empty());
|
||||
// The load's gates: content is a cartridge, so the CD-only group is hidden, and the netlink rows stay hidden.
|
||||
CHECK(first.count("virtualjaguar_netlink_host") && !first.at("virtualjaguar_netlink_host"));
|
||||
CHECK(first.count("virtualjaguar_cd_bios_type") && !first.at("virtualjaguar_cd_bios_type"));
|
||||
h.unloadContent();
|
||||
CHECK(h.loadContent(rom, err));
|
||||
const std::map<std::string, bool> second = visibility(h);
|
||||
size_t diff = 0;
|
||||
for (const auto& kv : first) {
|
||||
auto it = second.find(kv.first);
|
||||
if (it == second.end() || it->second != kv.second) {
|
||||
diff++;
|
||||
std::fprintf(stderr, " visibility differs: %s first=%d second=%d\n", kv.first.c_str(), kv.second,
|
||||
it == second.end() ? -1 : (int)it->second);
|
||||
}
|
||||
}
|
||||
CHECK(diff == 0 && first.size() == second.size());
|
||||
h.unloadContent();
|
||||
}
|
||||
// A probe after a game is a no-op: the options are known, and a session may be running.
|
||||
h.probeOptions();
|
||||
CHECK(!h.isLoaded());
|
||||
|
||||
std::fprintf(stderr, "core_options_probe_test: %d checks, %d failed\n", g_checks, g_fail);
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+1002
-464
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+984
-66
File diff suppressed because it is too large.
Load diff
+762
-74
@@ -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
|
||||
|
||||
Reference in new issue
Block a user