diff --git a/install.ps1 b/install.ps1 index 3224f28a..d9cc53e2 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "079fcba3b83003932af8f5ed513e556675832ab20e55bbbbc356581b2daf1a14" + $defaultInstallSha256 = "4d0a09cb5df35b5ccac1addbcd31feaf1ae00153694c11c7a1894e73bc063833" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index de751a65..a02b083f 100644 --- a/install.py +++ b/install.py @@ -9965,6 +9965,8 @@ class TuiState: self.dry: dict | None = None self.dry_files: list = [] self.dry_summary: dict | None = None + # Closed by RETROBIOS_IDLE_EXIT, no key ever pressed. + self.idle_closed = False # The check run's summary: what it left out and why. self.check_summary: dict | None = None # The confirm screen waits for A even when the plan is unchanged: @@ -10021,6 +10023,24 @@ TUI_PAD_ROUTES = ("gptokeyb", "muxterm", "evmapy", "miyoo") _STEAM_MARKERS = ("SteamGamepadUI", "SteamDeck", "SteamGameId", "GAMESCOPE_WAYLAND_DISPLAY") +# Seconds the first screen waits for a first key before it closes, set by a +# launcher whose mapper may never open the pad: the screen would otherwise +# hold a handheld that has no other way out. +TUI_IDLE_ENV = "RETROBIOS_IDLE_EXIT" +TUI_IDLE_WORDS = "No button pressed, closing." + + +def tui_idle_exit(env: Mapping[str, str] | None = None) -> float | None: + """The seconds RETROBIOS_IDLE_EXIT names, None when unset or not a + positive number.""" + env = os.environ if env is None else env + try: + seconds = float(env.get(TUI_IDLE_ENV, "").strip()) + except ValueError: + return None + return seconds if 0 < seconds < float("inf") else None + + def tui_pad_route(os_type: str, env: Mapping[str, str] | None = None) -> str: """The keys the hints name: a launcher's pad route, "keys" at a desktop, "both" (button and key) where nothing says which one is held.""" @@ -12580,6 +12600,8 @@ def tui_farewell(state: TuiState) -> list[str]: def _farewell_lines(state: TuiState) -> list[str]: + if state.idle_closed: + return [f"RetroBIOS: {TUI_IDLE_WORDS}"] if state.screen != "result" and state.download is not None: # An install ran: what it wrote is said, whatever ended it. title, lines = tui_outcome(state) @@ -12705,6 +12727,8 @@ def run_tui(args: argparse.Namespace) -> int: number = getattr(signal, name, None) if number is not None: handlers[number] = signal.signal(number, handler) + idle = tui_idle_exit() + first_screen, opened, pressed = state.screen, time.monotonic(), False sys.stdout = sys.stderr = buffer try: _prune_work_folders(work.parent, time.time()) @@ -12735,10 +12759,15 @@ def run_tui(args: argparse.Namespace) -> int: state.now = time.monotonic() terminal.draw(tui_view(state, cols, rows), cols) for key in terminal.keys(TUI_FRAME): + pressed = True state.now = time.monotonic() perform(tui_update(state, key)) if state.exit_code is not None: break + if (idle is not None and not pressed and state.exit_code is None + and state.screen == first_screen + and time.monotonic() - opened >= idle): + state.idle_closed, state.exit_code = True, 0 if flags["interrupt"]: flags["interrupt"] = False if time.monotonic() >= ignore_interrupt_until[0]: diff --git a/install.sh b/install.sh index 1cc244ce..bb28d346 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="079fcba3b83003932af8f5ed513e556675832ab20e55bbbbc356581b2daf1a14" +DEFAULT_INSTALL_SHA256="4d0a09cb5df35b5ccac1addbcd31feaf1ae00153694c11c7a1894e73bc063833" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index f9bd298d..64c06276 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -2525,6 +2525,22 @@ class AuditRoundThree(unittest.TestCase): sorted(path.name for path in (running, failed, unknown, other))) +class IdleExit(unittest.TestCase): + def test_the_variable(self): + for value, seconds in (("30", 30.0), (" 2.5 ", 2.5), ("", None), ("0", None), + ("-4", None), ("soon", None), ("inf", None), ("nan", None)): + with self.subTest(value=value): + self.assertEqual(install.tui_idle_exit({install.TUI_IDLE_ENV: value}), + seconds) + self.assertIsNone(install.tui_idle_exit({})) + + def test_the_farewell(self): + state = fresh_state() + state.idle_closed, state.exit_code = True, 0 + self.assertEqual(install.tui_farewell(state), + ["RetroBIOS: No button pressed, closing."]) + + class AuditRoundFour(unittest.TestCase): """tui_audit_3.md, each finding held where it was found.""" @@ -3044,6 +3060,26 @@ class EndToEnd(unittest.TestCase): self.assertEqual(term.status, 129) self.assertEqual(list(scratch.glob("retrobios-tui-*/error.log")), []) + def test_no_key_on_the_first_screen_closes_it(self): + """A mapper that never opens the pad left the first screen up for + good: a handheld then has no way out but the power button.""" + self.repo() + self.env[install.TUI_IDLE_ENV] = "1" + term = self.start() + term.wait("Where do you play?") + self.assertEqual(term.close(), 0) + self.assertIn("No button pressed, closing.", term.after()) + # A key pressed, the screen stays: the person is there. + term = self.start() + term.wait("Where do you play?") + term.send(DOWN) + end = time.monotonic() + 2.5 + while time.monotonic() < end: + term._pump(0.05) + self.assertEqual(os.waitpid(term.pid, os.WNOHANG), (0, 0)) + term.send("q") + self.assertEqual(term.close(), 130) + def test_a_desktop_names_its_keys_and_a_pad_its_buttons(self): self.repo() for env, bar in (({"DISPLAY": ":0"}, "Enter Continue Esc Quit ? Help"),