From 588a8d33506b355adb71126488271a82c4a3609c Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:10:09 +0200 Subject: [PATCH] fix: settle the interface audit findings A stop by SIGTERM (Start+Select under gptokeyb) reads the summary like Ctrl-C and prints the count on disk. The systems filter keeps the ticks and the focus. The bar names what A does on the focused row. Options check the folder again, a missing network fails in 15 s with plain words, an unwritable folder offers another one, a folder inside a card offers its root, and only a failure keeps its log. New event kind: checking, with files_done and files_total, while the files in place are hashed. The exact event lists of the check and install runs now carry two of them, for the two files in place. --- install.ps1 | 2 +- install.py | 616 ++++++++++++++++----- install.sh | 2 +- tests/fixtures/tui/browse-120x40.txt | 2 +- tests/fixtures/tui/browse-40x12.txt | 2 +- tests/fixtures/tui/browse-40x15.txt | 2 +- tests/fixtures/tui/browse-80x24.txt | 2 +- tests/fixtures/tui/cores-120x40.txt | 2 +- tests/fixtures/tui/cores-40x12.txt | 2 +- tests/fixtures/tui/cores-40x15.txt | 2 +- tests/fixtures/tui/cores-80x24.txt | 2 +- tests/fixtures/tui/create-120x40.txt | 2 +- tests/fixtures/tui/create-40x12.txt | 2 +- tests/fixtures/tui/create-40x15.txt | 2 +- tests/fixtures/tui/create-80x24.txt | 2 +- tests/fixtures/tui/error-120x40.txt | 6 +- tests/fixtures/tui/error-40x12.txt | 12 +- tests/fixtures/tui/error-40x15.txt | 6 +- tests/fixtures/tui/error-80x24.txt | 6 +- tests/fixtures/tui/folder-120x40.txt | 2 +- tests/fixtures/tui/folder-40x12.txt | 2 +- tests/fixtures/tui/folder-40x15.txt | 2 +- tests/fixtures/tui/folder-80x24.txt | 2 +- tests/fixtures/tui/folder-card-120x40.txt | 2 +- tests/fixtures/tui/folder-card-40x12.txt | 2 +- tests/fixtures/tui/folder-card-40x15.txt | 2 +- tests/fixtures/tui/folder-card-80x24.txt | 2 +- tests/fixtures/tui/help-120x40.txt | 4 +- tests/fixtures/tui/help-40x12.txt | 4 +- tests/fixtures/tui/help-40x15.txt | 4 +- tests/fixtures/tui/help-80x24.txt | 4 +- tests/fixtures/tui/installing-120x40.txt | 4 +- tests/fixtures/tui/installing-40x12.txt | 6 +- tests/fixtures/tui/installing-40x15.txt | 6 +- tests/fixtures/tui/installing-80x24.txt | 4 +- tests/fixtures/tui/options-120x40.txt | 4 +- tests/fixtures/tui/options-40x12.txt | 6 +- tests/fixtures/tui/options-40x15.txt | 6 +- tests/fixtures/tui/options-80x24.txt | 4 +- tests/fixtures/tui/path-120x40.txt | 4 +- tests/fixtures/tui/path-40x12.txt | 4 +- tests/fixtures/tui/path-40x15.txt | 4 +- tests/fixtures/tui/path-80x24.txt | 4 +- tests/fixtures/tui/plan-40x12.txt | 2 +- tests/fixtures/tui/plan-40x15.txt | 2 +- tests/fixtures/tui/plan-in-place-40x12.txt | 2 +- tests/fixtures/tui/plan-in-place-40x15.txt | 2 +- tests/fixtures/tui/platforms-120x40.txt | 4 +- tests/fixtures/tui/platforms-40x12.txt | 6 +- tests/fixtures/tui/platforms-40x15.txt | 6 +- tests/fixtures/tui/platforms-80x24.txt | 4 +- tests/fixtures/tui/quit-120x40.txt | 2 +- tests/fixtures/tui/quit-40x12.txt | 2 +- tests/fixtures/tui/quit-40x15.txt | 2 +- tests/fixtures/tui/quit-80x24.txt | 2 +- tests/fixtures/tui/regions-120x40.txt | 4 +- tests/fixtures/tui/regions-40x12.txt | 6 +- tests/fixtures/tui/regions-40x15.txt | 6 +- tests/fixtures/tui/regions-80x24.txt | 4 +- tests/fixtures/tui/result-40x12.txt | 2 +- tests/fixtures/tui/result-40x15.txt | 2 +- tests/fixtures/tui/setup-120x40.txt | 4 +- tests/fixtures/tui/setup-80x24.txt | 4 +- tests/fixtures/tui/systems-40x12.txt | 2 +- tests/fixtures/tui/systems-40x15.txt | 2 +- tests/test_install.py | 3 + tests/test_install_tui.py | 422 +++++++++++++- wiki/installer.md | 24 +- 68 files changed, 1012 insertions(+), 271 deletions(-) diff --git a/install.ps1 b/install.ps1 index e3777537..e8fcc20f 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "d0a92cac2d09da791fe3dbacdad37ec7e40b5a3685a39e3cc02f0dcbd5a556e6" + $defaultInstallSha256 = "84ba56c9498a8bf89db4c4af8bd3413871f467771b36aea36158458aa4644ef8" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index a734074d..a83ada0f 100644 --- a/install.py +++ b/install.py @@ -500,6 +500,8 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = { "file": ("platform", "dest", "systems", "size", "state"), "system": ("platform", "system", "name", "files", "present", "missing", "wrong_hash", "bytes_needed"), + # The files in place being read, at most every half second, and the last. + "checking": ("files_done", "files_total"), "check": ("platform", "name", "bios_dir", "files", "present", "missing", "wrong_hash", "bytes_needed", "data_missing", "not_installable"), "download": ("platform", "files", "bytes"), @@ -3109,7 +3111,9 @@ def _address_lines(url: str) -> list[str]: return lines -def _fetch_json(url: str, limit: int, label: str) -> object: +def _fetch_json( + url: str, limit: int, label: str, attempts: int = MAX_RETRIES, timeout: float = 30, +) -> object: """A JSON document, tried MAX_RETRIES times like every file download. A file list fetched once ended the run on one timeout over flaky Wi-Fi. @@ -3117,26 +3121,26 @@ def _fetch_json(url: str, limit: int, label: str) -> object: nothing. """ _network_allowed(label) - for attempt in range(1, MAX_RETRIES + 1): + for attempt in range(1, attempts + 1): try: - with urllib.request.urlopen(url, timeout=30) as resp: + with urllib.request.urlopen(url, timeout=timeout) as resp: return _read_limited_json(resp, limit, label) except urllib.error.HTTPError as exc: - if exc.code < 500 and exc.code != 429 or attempt == MAX_RETRIES: + if exc.code < 500 and exc.code != 429 or attempt == attempts: raise delay = _retry_delay(attempt, exc.headers.get("Retry-After") if exc.headers else None) problem = f"HTTP {exc.code}" except (urllib.error.URLError, OSError, http.client.HTTPException) as exc: code, problem = network_words(exc) # A wrong clock or no certificate list fails every attempt alike. - if attempt == MAX_RETRIES or code != "network": + if attempt == attempts or code != "network": if isinstance(exc, http.client.HTTPException): raise ConnectionError(f"connection cut: {exc!r}") from exc raise delay = _retry_delay(attempt, None) # Said, or three 30-second attempts read as a frozen program. print(f" The {label} did not arrive ({problem}), trying again " - f"({attempt + 1}/{MAX_RETRIES}) in {delay:.0f} s.", file=sys.stderr) + f"({attempt + 1}/{attempts}) in {delay:.0f} s.", file=sys.stderr) time.sleep(delay) raise AssertionError("unreachable") @@ -3840,13 +3844,20 @@ def check_local( lambda item: (item[0], _classify_local(item[0], item[1])), present ) live = term is not None and term.live and not term.quiet - last = 0.0 + last = evented = 0.0 for done, (entry, verdict) in enumerate(verdicts, 1): if verdict == "up_to_date": up_to_date.append(entry) else: mismatched.append(entry) now = time.monotonic() + # The program driving the installer gets the count the + # terminal gets: a screen with a bare timer over 6 GB on an + # SD card read as hung. + if EVENTS.on and (now - evented >= EVENT_PROGRESS_INTERVAL + or done == len(present)): + evented = now + EVENTS.emit("checking", files_done=done, files_total=len(present)) if live and (now - last >= 0.1 or done == len(present)): last = now print(f"\r read {done}/{len(present)}", end="", file=term.stream, @@ -6915,7 +6926,9 @@ def main() -> None: _INVOCATION.os_type = detect_os() parser = argparse.ArgumentParser( allow_abbrev=False, - description="Download missing BIOS files for retrogaming emulators.", + description="Download missing BIOS files for retrogaming emulators. " + "For questions line by line, as a screen reader reads them, " + "add --plain.", epilog=_help_epilog(), formatter_class=argparse.RawDescriptionHelpFormatter, ) @@ -7145,7 +7158,10 @@ def main() -> None: if args.tui: reason = tui_blocker(args) if reason: - parser.error(f"--tui cannot open the full-screen interface here: {reason}") + # The reason alone: fourteen lines of usage hid it. + print(f"{parser.prog}: error: --tui cannot open the full-screen interface " + f"here: {reason}", file=sys.stderr) + sys.exit(2) if args.restore is not None: # The folder says where each file goes: nothing else applies. alone = ("platform", "dest", "target", "every_board", "check", "list_platforms", @@ -8128,7 +8144,8 @@ _WINDOWS_KEYS = {"H": "up", "P": "down", "K": "left", "M": "right", "I": "pgup", def _plain_key(char: str) -> TuiKey | None: """The key one character stands for, None for a control nobody bound.""" named = {"\r": "enter", "\n": "enter", "\x7f": "backspace", "\x08": "backspace", - "\t": "tab", "\x1b": "esc", "\x03": "ctrl-c", "\x04": "ctrl-d"}.get(char) + "\t": "tab", "\x1b": "esc", "\x03": "ctrl-c", "\x04": "ctrl-d", + "\x15": "ctrl-u"}.get(char) if named: return TuiKey(named) if char < " " or unicodedata.category(char) in ("Cc", "Cs"): @@ -8275,6 +8292,16 @@ def screen_text(text: str) -> str: return _CONTROLS.sub("?", text) +def ascii_text(text: str) -> str: + """The text in ASCII, accents dropped (Edition) and any other character + as one ? per cell it took, so the columns stay where they were.""" + if text.isascii(): + return text + plain = "".join(char for char in unicodedata.normalize("NFKD", text) + if not unicodedata.combining(char)) + return "".join(char if char.isascii() else "?" * _char_width(char) for char in plain) + + def fit(text: str, width: int) -> str: """The text cut to width cells, never through a character.""" text = screen_text(text) @@ -8406,7 +8433,15 @@ class TuiState: self.manifest_path: Path | None = None self.manifest_given: bool = False self.error: list = [] + self.error_title: str = "" self.error_code: int = 1 + # Typed over in one key: a field opening on a long path took one + # Backspace per character before anything could be typed. + self.path_selected: bool = False + # The ticks kept across a check run again for new options. + self.keep_ticked: set | None = None + # (files read, files to read) while the check hashes what is in place. + self.reading: tuple | None = None # The child run in progress: check, confirm, preview or install. self.run: str = "" self.running: bool = False @@ -8584,9 +8619,13 @@ def _needed(row: dict) -> int: return row["missing"] + row["wrong_hash"] -def _tui_system_label(row: dict) -> str: - """The name a person reads: the platform's own when it is one (Sony - - PlayStation), the id with the platform's short name otherwise (psx).""" +def _tui_system_label(row: dict, titles: dict | None = None) -> str: + """The name a person reads: the file list's title when it has one, the + platform's own name when it is one (Sony - PlayStation), the id with the + platform's short name otherwise (psx).""" + title = (titles or {}).get(row["system"]) + if isinstance(title, str) and title.strip(): + return title.strip() name = row.get("name") or row["system"] if name == row["system"]: return name @@ -8608,9 +8647,28 @@ def _filter_row(state: TuiState) -> TuiRow: return TuiRow("filter", "Filter (/ to type)", style="dim") +def _system_titles(state: TuiState) -> dict: + titles = (state.manifest or {}).get("system_titles") + return titles if isinstance(titles, dict) else {} + + def _sorted_systems(state: TuiState) -> list[dict]: - """Systems with something to download first, the ones in place last.""" - return sorted(state.systems, key=lambda row: (_needed(row) == 0, row["system"])) + """Systems with something to download first, the ones in place last, each + group in the order of the names a person reads.""" + titles = _system_titles(state) + return sorted(state.systems, key=lambda row: ( + _needed(row) == 0, _tui_system_label(row, titles).casefold())) + + +# Region names as a person writes them, where title case alone is wrong. +_REGION_TITLES = {"asia-ntsc": "Asia (NTSC)", "asia-pal": "Asia (PAL)", + "uk": "United Kingdom", WORLD_REGION: "World"} + + +def region_title(name: str) -> str: + """north-america as North America, asia-ntsc as Asia (NTSC).""" + return _REGION_TITLES.get(name) or " ".join( + word.capitalize() for word in name.split("-")) def tui_choices(state: TuiState) -> dict[str, list[str]]: @@ -8623,15 +8681,17 @@ def tui_rows(state: TuiState) -> list[TuiRow]: """The rows of the current screen, in the order they are shown.""" screen = state.screen if screen == "setup": - rows = [TuiRow("setup", label, _short_path(path, state.home), value=index) - for index, (_plat, path, label) in enumerate(state.setups)] + # The focused one's folder is spelled out below the list. + rows = [TuiRow("setup", label, value=index) + for index, (_plat, _path, label) in enumerate(state.setups)] return rows + [TuiRow("another", "Another setup or folder")] if screen == "platforms": needle = state.filters.get(screen, "") + systems = _systems_reading() return [_filter_row(state)] + [ - TuiRow("platform", display_name(plat), value=plat) + TuiRow("platform", display_name(plat), ", ".join(systems.get(plat, [])), plat) for plat in AVAILABLE_PLATFORMS - if _matches(f"{plat} {display_name(plat)}", needle) + if _matches(" ".join([plat, display_name(plat), *systems.get(plat, [])]), needle) ] if screen == "folder": rows = [TuiRow("default", "Use this folder")] if state.folder_default else [] @@ -8639,8 +8699,10 @@ def tui_rows(state: TuiState) -> list[TuiRow]: if screen == "browse": return list(state.browse_rows) if screen == "create": - return [TuiRow("create", "Create it and continue"), - TuiRow("other", "Choose another folder")] + label = "Create the whole path" if state.facts.get("risky") else "Create it and continue" + return [TuiRow("create", label), TuiRow("other", "Choose another folder")] + if screen == "cardroot": + return [TuiRow("cardroot", "Use the card root"), TuiRow("other", "Choose another folder")] if screen == "plan": return _plan_rows(state) if screen == "systems": @@ -8648,10 +8710,11 @@ def tui_rows(state: TuiState) -> list[TuiRow]: if screen == "options": return _option_rows(state) if screen == "regions": - ranked = [TuiRow("region", f"[{rank}] {name}", value=name) + ranked = [TuiRow("region", f"[{rank}] {region_title(name)}", value=name) for rank, name in enumerate(state.regions, 1)] - known = sorted(set(tui_choices(state)["regions"]) | set(state.cli_regions)) - return ranked + [TuiRow("region", f"[ ] {name}", value=name) + known = sorted(set(tui_choices(state)["regions"]) | set(state.cli_regions), + key=region_title) + return ranked + [TuiRow("region", f"[ ] {region_title(name)}", value=name) for name in known if name not in state.regions] if screen == "cores": needle = state.filters.get(screen, "") @@ -8664,7 +8727,8 @@ def tui_rows(state: TuiState) -> list[TuiRow]: return [] return [TuiRow("install", "Install"), TuiRow("back", "Back")] if screen == "error": - rows = [TuiRow("retry", "Try again")] if state.run == "check" else [] + rows = ([TuiRow("retry", "Try again")] + if state.run == "check" and state.error_title else []) return rows + [TuiRow("back", "Choose another setup or folder" if state.run == "check" else "Back"), TuiRow("quit", "Quit")] @@ -8680,6 +8744,9 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: size = check.get("bytes_needed", 0) label = f"Install {format_size(size)}" if size >= CONFIRM_FROM_BYTES else "Install" rows = [TuiRow("install", label)] + if state.facts.get("writable") is False: + # Every file would fail alike: the way out is another folder. + rows = [TuiRow("folder", "Choose another folder"), TuiRow("install", "Install anyway")] if records_selection((state.manifest or {}).get("files", [])) and len(state.systems) > 1: rows.append(TuiRow("systems", "Choose systems")) return rows + [TuiRow("options", "Options"), TuiRow("quit", "Quit")] @@ -8687,9 +8754,10 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: def _system_rows(state: TuiState) -> list[TuiRow]: needle = state.filters.get("systems", "") + titles = _system_titles(state) rows = [TuiRow("all", "All"), TuiRow("none", "None"), _filter_row(state)] for row in _sorted_systems(state): - label = _tui_system_label(row) + label = _tui_system_label(row, titles) if not _matches(f"{label} {row['system']}", needle): continue box = "[x]" if row["system"] in state.ticked else "[ ]" @@ -8715,7 +8783,8 @@ def _option_rows(state: TuiState) -> list[TuiRow]: rows = [] if choices["regions"]: rows.append(TuiRow("regions", "Preferred regions: " - + (", ".join(state.regions) or "any"), "--region")) + + (", ".join(map(region_title, state.regions)) or "any"), + "--region")) if manifest.get("categorized"): rows.append(TuiRow("game_data", f"Leave out game data: {_yes(state.no_game_data)}", "--no-game-data")) @@ -8732,7 +8801,8 @@ def _option_rows(state: TuiState) -> list[TuiRow]: rows.append(TuiRow("cores", f"Cores: {chosen}", "--core")) rows += [ TuiRow("jobs", f"Parallel downloads: {state.jobs}", "--jobs"), - TuiRow("folder", f"BIOS folder: {_short_path(state.dest, state.home)}", "--dest"), + TuiRow("folder", f"BIOS folder: {_short_path(state.dest, state.home)}", "--dest", + style="path"), TuiRow("preview", "Preview the file list", "--dry-run"), TuiRow("command", "Show the command line"), ] @@ -8765,6 +8835,12 @@ def _back(state: TuiState) -> list[tuple]: # Stay after Esc on a check started from the command line: a # Checking screen nothing feeds counted seconds forever. return effects + _start_check(state) + if state.screen == "plan" and _counts_predate_options(state): + # The plan's numbers were read under the options before: they are + # read again, the systems ticked kept. + needed = {row["system"] for row in state.systems if _needed(row)} + state.keep_ticked = None if needed <= state.ticked else set(state.ticked) + return effects + _start_check(state) return effects @@ -8772,7 +8848,7 @@ def tui_quit(state: TuiState, code: int = 130) -> list[tuple]: """Leave now: with the install's own code once it ran, else 130.""" if state.screen == "result": code = state.code if state.code is not None else 1 - elif state.screen == "error" and code != 130: + elif state.screen == "error": code = state.error_code state.exit_code = code return [("kill",)] if state.running else [] @@ -8794,12 +8870,14 @@ def _start_run(state: TuiState, run: str) -> list[tuple]: state.run_id += 1 state.started = state.now if run == "check": + # The systems of an earlier check go first: ticks read against them + # dropped the --system the command line gave. + state.check, state.files, state.systems, state.reading = None, [], [], None state.checked_flags = tui_flags(state) - state.check, state.files, state.systems = None, [], [] elif run in ("confirm", "preview"): state.dry, state.dry_files, state.dry_summary = None, [], None else: - state.download = state.progress = None + state.download = state.progress = state.reading = None state.samples = [] state.finished, state.summary, state.code, state.stopping = [], None, None, False return [("run", run)] @@ -8823,9 +8901,21 @@ def _start_check(state: TuiState) -> list[tuple]: def _choose(state: TuiState, plat: str, dest: Path) -> list[tuple]: state.platform, state.dest = plat, dest + if plat in CARD_PLATFORMS and card_platform(dest) is None: + # The engine refuses a folder inside the card, the list writing + # Bios// below the root: the root is offered instead. + for parent in list(dest.absolute().parents)[:2]: + holder = card_platform(parent) + if holder: + state.facts = {"card_root": parent, "card": holder} + return _push(state, "cardroot") if not dest.exists(): - state.facts = {"nearest": _nearest_folder(dest)} - return _push(state, "create") + nearest = _nearest_folder(dest) + missing = len(dest.absolute().parts) - len(nearest.parts) + state.facts = {"nearest": nearest, "risky": nearest.parent == nearest or missing > 2} + # A path whose start was mistyped would be created from the root: + # the safe answer is the one under A. + return _push(state, "create", 1 if state.facts["risky"] else 0) return _start_check(state) @@ -8851,7 +8941,8 @@ def _mounted_cards() -> list[Path]: except OSError: continue for child in children: - if child.name in ("wsl", "wslg"): + # WSL's own mounts, and the Windows system drive, hold no card. + if child.name in ("wsl", "wslg") or (root.name == "mnt" and child.name == "c"): continue if os.path.ismount(child): found.append(child) @@ -8904,9 +8995,8 @@ def _nearest_folder(path: Path) -> Path: def typed_path(text: str) -> Path | None: """A folder typed or pasted: quotes off, ~ and variables expanded.""" - text = text.strip() - if len(text) >= 2 and text[0] == text[-1] and text[0] in "\"'": - text = text[1:-1].strip() + # Explorer's "Copy as path" quotes both ends, a half-selected copy one. + text = text.strip().strip("\"'").strip() if not text: return None return Path(os.path.expanduser(os.path.expandvars(text))) @@ -8948,6 +9038,7 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: _browse_to(state, _nearest_folder(state.folder_default or state.home)) return [] state.path_text = str(state.folder_default or "") + state.path_selected = bool(state.path_text) return _push(state, "path") if screen == "browse": if action == "use": @@ -8965,6 +9056,11 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: state.stack.pop() return _start_check(state) return _back(state) + if screen == "cardroot": + if action == "cardroot": + state.stack.pop() + return _choose(state, state.platform, state.facts["card_root"]) + return _back(state) if screen == "plan": if action == "install": return _begin_install(state) @@ -8973,6 +9069,8 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: return [] if action == "quit": return tui_quit(state) + if action == "folder": + return _open_folder_screen(state, state.dest) # On the first system, so A continues and the D-pad ticks. return _push(state, action, 3 if action == "systems" else 0) if screen == "systems": @@ -9008,6 +9106,17 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: return [] +def _systems_reading() -> dict[str, list[str]]: + """The device systems that get each platform's files, as detection knows + them: someone on muOS or ArkOS looks for that name, not RetroArch.""" + names: dict[str, list[str]] = {} + for rule in _EMBEDDED_RULES: + if (rule.manifest and rule.name != display_name(rule.manifest) + and rule.name not in names.setdefault(rule.manifest, [])): + names[rule.manifest].append(rule.name) + return names + + def _platform_focus(state: TuiState, plat: str) -> int: """The platform list's row for plat, below its filter row.""" return 1 + AVAILABLE_PLATFORMS.index(plat) if plat in AVAILABLE_PLATFORMS else 1 @@ -9020,8 +9129,12 @@ def _option(state: TuiState, action: str, step: int) -> list[tuple]: elif action == "installed" and not _cores_dir_given(state): state.installed_only = step > 0 if step else not state.installed_only elif action == "jobs": - state.jobs = (state.jobs - 1 + (step or 1)) % 32 + 1 - elif action in ("regions", "cores", "command"): + state.jobs = max(1, min(32, state.jobs + (step or 1))) + elif action == "cores": + # On the first core: the filter row under A opened a field a + # controller cannot type into. + return _push(state, action, 1 if tui_choices(state)["cores"] else 0) + elif action in ("regions", "command"): return _push(state, action) elif action == "folder": return _open_folder_screen(state, state.dest) @@ -9070,6 +9183,7 @@ def _same_plan(one: dict | None, other: dict | None) -> bool: def _fail(state: TuiState, lines: list[str], code: int) -> list[tuple]: + state.error_title = "" state.error = [line for line in lines if line.strip()] or [ f"The installer stopped with code {code} and said nothing."] state.error_code = code or 1 @@ -9098,34 +9212,44 @@ def _tick(state: TuiState, row: TuiRow, on: bool | None) -> None: state.cores.remove(value) -def _edit_text(text: str, key: TuiKey) -> str | None: - """The field after a key, None when the key is not an edit.""" +def _edit_text(text: str, key: TuiKey, selected: bool = False) -> str | None: + """The field after a key, None when the key is not an edit. A selected + text is replaced by what is typed or pasted, and cleared by Backspace.""" if key.name in ("char", "paste"): - return text + key.text + return key.text if selected else text + key.text if key.name in ("backspace", "delete"): - return text[:-1] + return "" if selected else text[:-1] + if key.name == "ctrl-u": + return "" return None def _filter_key(state: TuiState, key: TuiKey) -> list[tuple]: + """Typing in a list's filter. It never ticks or unticks anything, and + focus never lands on All or None: the next A or X there undid a choice.""" screen = state.screen edited = _edit_text(state.filters.get(screen, ""), key) + rows = tui_rows(state) + filter_row = next((i for i, row in enumerate(rows) if row.action == "filter"), 0) if edited is not None: state.filters[screen] = edited - state.focus[screen] = 0 + state.focus[screen] = filter_row return [] if key.name == "esc": state.filters[screen] = "" state.editing = "" - if key.name in ("enter", "down"): - state.focus[screen] = 1 if len(tui_rows(state)) > 1 else 0 + rows = tui_rows(state) + if key.name in ("enter", "down") and len(rows) > filter_row + 1: + state.focus[screen] = filter_row + 1 + else: + state.focus[screen] = filter_row return [] def _path_key(state: TuiState, key: TuiKey) -> list[tuple]: - edited = _edit_text(state.path_text, key) + edited = _edit_text(state.path_text, key, state.path_selected) if edited is not None: - state.path_text = edited + state.path_text, state.path_selected = edited, False return [] if key.name == "esc": return _back(state) @@ -9155,18 +9279,30 @@ def _pager_key(state: TuiState, key: TuiKey) -> list[tuple]: elif name == "home" or char == "g": top = 0 elif name == "end" or char == "G": - top = len(_text_lines(_pager_items(state), min(state.cols, TUI_MAX_WIDTH) - 2)) + top = 1 << 30 elif name in ("enter", "esc", "backspace") or char == " ": if screen == "result": return tui_quit(state) return _back(state) - total = len(_text_lines(_pager_items(state), min(state.cols, TUI_MAX_WIDTH) - 2)) - height = state.rows - 3 - last = total - (height - 1) if total > height else 0 - state.focus[screen] = max(0, min(top, last)) + state.focus[screen] = max(0, min(top, _pager_last(state, _pager_items(state)))) return [] +def _pager_last(state: TuiState, items: list) -> int: + """The first item shown when the end of a pager is in view, found from + the end: only the last screenful is wrapped.""" + width = min(state.cols, TUI_MAX_WIDTH) - 2 + room = max(1, state.rows - 4) + used, index = 0, len(items) + while index > 0: + size = len(_text_lines([items[index - 1]], width)) + if used + size > room: + break + used += size + index -= 1 + return index + + def tui_update(state: TuiState, key: TuiKey) -> list[tuple]: """What a key does on the current screen, and the work it starts.""" if key.name in ("ctrl-c", "ctrl-d"): @@ -9191,6 +9327,9 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]: return tui_quit(state) return _back(state) if key.name in ("esc", "backspace") else [] if screen == "installing": + # B, Esc and q are the stops a controller or a plain keyboard has. + if key.name in ("esc", "backspace") or char == "q": + return tui_interrupt(state) return [] if char == "q": return tui_quit(state) @@ -9218,9 +9357,12 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]: elif name == "end" or char == "G": focus = last elif name in ("esc", "backspace") and state.filters.get(screen): - # The filter goes first, the screen with the second press. + # The filter goes first, the screen with the second press, and the + # row in focus stays in focus. state.filters[screen] = "" - focus = 0 + rows = tui_rows(state) + focus = next((i for i, item in enumerate(rows) + if item.action == row.action and item.value == row.value), focus) elif name in ("esc", "backspace"): return _back(state) elif (name == "tab" or char == "o") and screen in ("plan", "systems", "confirm"): @@ -9240,6 +9382,9 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]: char == " " or name in ("right", "left")): _tick(state, row, None if char == " " else name == "right") return [] + elif screen in _CHECKLISTS and char == " ": + # X on All, None or the filter: only A, an explicit choice, acts there. + return [] elif screen == "systems" and char in ("a", "n"): return _activate(state, rows[0 if char == "a" else 1]) elif screen == "options" and name in ("left", "right"): @@ -9253,7 +9398,9 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]: def _take_event(state: TuiState, event: dict) -> None: kind = event.get("event") - if state.run == "check": + if kind == "checking" and state.run in ("check", "install"): + state.reading = (event["files_done"], event["files_total"]) + elif state.run == "check": if kind == "check": state.check = event elif kind == "file" and event.get("state") != "present": @@ -9291,10 +9438,12 @@ def tui_receive(state: TuiState, message: tuple) -> list[tuple]: state.manifest, state.manifest_for = manifest, plat return _start_run(state, "check") if kind == "manifest_failed": - _kind, plat, lines, code = message + _kind, plat, title, lines, code = message if plat != state.platform or state.screen != "checking": return [] - return _fail(state, lines, code) + _fail(state, lines, code) + state.error_title = title + return [] _kind, run_id, *rest = message if run_id != state.run_id: return [] @@ -9303,11 +9452,15 @@ def tui_receive(state: TuiState, message: tuple) -> list[tuple]: return [] code, tail = rest state.running = False - state.tail = tail + state.tail = [line for line in tail if not line.startswith("RetroBIOS ")] + tail = state.tail if state.run == "check": if state.check is None: return _fail(state, tail, code) state.ticked = {row["system"] for row in state.systems if _needed(row)} + if state.keep_ticked is not None: + state.ticked = state.keep_ticked & {row["system"] for row in state.systems} + state.keep_ticked = None state.facts = _tui_folder_facts(state) _replace(state, "plan") elif state.run == "confirm" and state.screen == "confirm": @@ -9321,15 +9474,46 @@ def tui_receive(state: TuiState, message: tuple) -> list[tuple]: _replace(state, "result") return [] + +# Shorter key names for a narrow bar: at 40 columns "X/Space Tick" was cut, +# and nothing said how to tick with a controller. +_SHORT_KEYS = (("A/Enter ", "A "), ("B/Esc ", "B "), ("X/Space ", "X "), ("Y/o ", "Y "), + ("Left/Right ", "<> "), ("Up/Down ", "^v ")) + + def _hint(width: int, *parts: str) -> str: - """The keys that matter now, cut by whole entries from the right.""" - text = "" + """The keys that matter now, short names first given up, then whole + entries from the right.""" + full = " ".join(parts) + if cell_width(full) <= width: + return full + short = [] for part in parts: + for long_name, short_name in _SHORT_KEYS: + if part.startswith(long_name): + part = short_name + part[len(long_name):] + short.append(part) + text = "" + for part in short: longer = f"{text} {part}" if text else part if cell_width(longer) > width: break text = longer - return text or fit(parts[0], width) + return text or fit(short[0], width) + + +# What A does on each kind of row: the bar names it for the row in focus. +_ROW_VERBS = { + "install": "Install", "close": "Close", "quit": "Quit", "systems": "Open", + "options": "Open", "setup": "Continue", "another": "Open", "platform": "Choose", + "default": "Use this folder", "browse": "Browse", "type": "Type a path", + "use": "Use this folder", "up": "Go up", "dir": "Open", "create": "Create", + "other": "Choose another", "cardroot": "Use the root", "all": "Tick all", + "none": "Untick all", "filter": "Filter", "system": "Continue", "core": "Done", + "region": "Tick", "regions": "Open", "cores": "Open", "command": "Open", + "preview": "Open", "folder": "Choose", "game_data": "Change", "installed": "Change", + "jobs": "Change", "retry": "Try again", "back": "Back", "stay": "Stay", +} def _row_line(row: TuiRow, focused: bool, width: int) -> TuiLine: @@ -9347,14 +9531,17 @@ def _text_lines(items: list, width: int) -> list[TuiLine]: return lines -def _scrolled(lines: list[TuiLine], first: int, height: int) -> list[TuiLine]: - """height lines of a longer list from first, the last one saying where.""" +def _scrolled(lines: list[TuiLine], first: int, height: int, lead: int = 0) -> list[TuiLine]: + """height lines of a longer list from first, the last one saying where. + The lead rows (All, None, Filter) are not counted among the entries.""" if len(lines) <= height: return lines shown = max(1, height - 1) first = max(0, min(first, len(lines) - shown)) end = first + shown - return lines[first:end] + [_styled(f"{first + 1}-{end} of {len(lines)}", "dim")] + count = len(lines) - lead + where = f"{max(1, first + 1 - lead)}-{max(1, end - lead)} of {count}" + return lines[first:end] + [_styled(where, "dim")] def _compose( @@ -9369,7 +9556,9 @@ def _compose( row_lines = [_row_line(row, index == focus, width) for index, row in enumerate(rows)] if len(row_lines) >= room: first = focus - (room - 1) // 2 - body = _scrolled(row_lines, first, room) + lead = next((i for i, row in enumerate(rows) + if row.action not in ("all", "none", "filter")), 0) + body = _scrolled(row_lines, first, room, lead) else: budget = room - len(row_lines) top: list[TuiLine] = [] @@ -9389,9 +9578,22 @@ def _compose( def _pager(state: TuiState, width: int, height: int, items: list) -> list[TuiLine]: - lines = _text_lines(items, width) - first = state.focus.get(state.screen, 0) - return _scrolled(lines, first, height) + """The items in view, from the first shown on, each wrapped alone: the + 8,000 lines of a full preview were wrapped again at every key.""" + first = max(0, min(state.focus.get(state.screen, 0), _pager_last(state, items))) + if len(items) <= height and first == 0: + lines = _text_lines(items, width) + if len(lines) <= height: + return lines + room = max(1, height - 1) + lines: list[TuiLine] = [] + index = first + while index < len(items) and len(lines) < room: + lines += _text_lines([items[index]], width) + index += 1 + whole = index if len(lines) <= room else index - 1 + lines = lines[:room] + return lines + [_styled(f"{first + 1}-{max(first + 1, whole)} of {len(items)}", "dim")] def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: @@ -9422,8 +9624,9 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: if wrong: lines.append("They are replaced, and the old copies are kept in a backup folder.") if state.facts.get("writable") is False: - lines.append("This folder cannot be written: choose another one in Options, " - "or run the installer as an administrator.") + lines.insert(1, "You cannot write to this folder: choose another one, or run " + + ("the installer as an administrator." if state.os_type == "windows" + else "the installer with sudo.")) if check.get("not_installable"): lines.append(f"{_plural(check['not_installable'], 'file')} of the list " f"{'is' if check['not_installable'] == 1 else 'are'} not in " @@ -9436,10 +9639,6 @@ def _view_setup(state: TuiState, width: int, height: int) -> tuple: focus = rows[min(state.focus.get("setup", 0), len(rows) - 1)] after = ([f"BIOS folder: {state.setups[focus.value][1]}"] if focus.action == "setup" else []) - if width < 60: - # The folder of the focused one is spelled out below: beside each - # name it cut both. - rows = [row._replace(right="") for row in rows] before = ["Found on this machine:" if state.setups else "Nothing was found here."] return ("Where do you play?", _compose(state, width, height, before, rows, after + state.notes), @@ -9489,20 +9688,32 @@ def _view_path(state: TuiState, width: int, height: int) -> tuple: if cell_width(text) > width - 2: text = "..." + fit(text[::-1], width - 5)[::-1] lines = [f"The folder for {display_name(state.platform)}:", _styled(f"> {text}", "focus"), - "", "Quotes are removed, ~ and variables are expanded."] + "", ("Typing or pasting replaces it. " if state.path_selected else "") + + "Ctrl-U clears. Quotes are removed, ~ and variables are expanded."] if state.status: lines.append(state.status) return ("Type a path", _compose(state, width, height, lines, []), - ("Enter Use this folder", "Esc Back")) + ("Enter Use this folder", "Esc Back", "Ctrl-U Clear")) def _view_create(state: TuiState, width: int, height: int) -> tuple: before = [f"{state.dest} does not exist.", f"The nearest folder that does: {state.facts.get('nearest', '')}"] + if state.facts.get("risky"): + before.append("Every folder of this path would be made: check its start before " + "creating it.") return ("Create the folder?", _compose(state, width, height, before, tui_rows(state)), ("A/Enter Choose", "B/Esc Back")) +def _view_cardroot(state: TuiState, width: int, height: int) -> tuple: + name = display_name(state.facts.get("card", "")) + before = [f"{state.dest} is inside a {name} card. {name} reads its BIOS files under " + f"the card's root: {state.facts.get('card_root', '')}"] + return ("Use the card root?", _compose(state, width, height, before, tui_rows(state)), + ("A/Enter Choose", "B/Esc Back")) + + def _view_checking(state: TuiState, width: int, height: int) -> tuple: lines = [f"{display_name(state.platform)}: {_short_path(state.dest, state.home)}", ""] elapsed = format_duration(max(0.0, state.now - state.started)) @@ -9510,31 +9721,37 @@ def _view_checking(state: TuiState, width: int, height: int) -> tuple: lines.append(f"Getting the file list... {elapsed}") else: files = len(state.manifest.get("files", [])) - lines += [f"File list: {_plural(files, 'file')}.", - f"Reading the files already in the folder... {elapsed}"] + lines += [f"File list: {_plural(files, 'file')}.", _reading_line(state, elapsed)] return ("Checking", _compose(state, width, height, lines, []), ("Ctrl-C Quit", "B/Esc Back")) +def _reading_line(state: TuiState, elapsed: str) -> str: + if state.reading: + done, total = state.reading + return f"Reading the files already in the folder: {done} of {total}" + return f"Reading the files already in the folder... {elapsed}" + + def _choice_line(state: TuiState) -> list[str]: - flags = tui_flags(state) - if flags == state.checked_flags: + """What the plan's numbers do not include yet. The options are read + again by a check of their own, so only ticked systems are left here.""" + if tui_flags(state) == state.checked_flags: return [] - words = " ".join(flag if value is None else f"{flag} {value}" for flag, value in flags - if (flag, value) not in state.passthrough) - return [f"Your choices: {words}. The exact count comes before the install."] + return [f"For your {len(state.ticked)} of {len(state.systems)} systems, the exact " + "count comes before the install."] def _view_plan(state: TuiState, width: int, height: int) -> tuple: check = state.check or {} - lines = _plan_lines(check, state.dest, state) + _choice_line(state) + lines = _plan_lines(check, state.dest, state) + lines[1:1] = _choice_line(state) if state.status: lines.append(state.status) rows = tui_rows(state) - primary = "A/Enter Install" if rows[0].action == "install" else "A/Enter Close" return (display_name(state.platform, state.manifest), _compose(state, width, height, lines, rows), - (primary, "B/Esc Back", "Y/o Options", "? Help")) + ("A/Enter Install", "B/Esc Back", "Y/o Options", "? Help")) def _counts_predate_options(state: TuiState) -> bool: @@ -9559,9 +9776,11 @@ def _view_systems(state: TuiState, width: int, height: int) -> tuple: rows = [row._replace(right=row.right.split(", ")[-1]) for row in rows] elif width >= 78: # The id --system takes, beside a name that does not show it. + # Only where it fits whole: a cut id ("maker26-co") said nothing. rows = [row._replace(label=f"{row.label} {row.value}") - if row.action == "system" and row.value not in row.label else row - for row in rows] + if row.action == "system" and row.value not in row.label + and cell_width(f"> {row.label} {row.value} {row.right}") + 2 <= width + else row for row in rows] chosen = _ticked_files(state) footer = [f"{len(state.ticked)} of {len(state.systems)} systems: " f"{_plural(len(chosen), 'file')}, " @@ -9587,6 +9806,10 @@ def _view_options(state: TuiState, width: int, height: int) -> tuple: before = [] after = [f"Sets {focus.right}"] if focus.right else [] rows = [row._replace(right="") for row in rows] + # A folder is cut in its middle, its last levels being what tells it apart. + room = width - 4 - (0 if width < 60 else 2 + max(cell_width(r.right) for r in rows)) + rows = [row._replace(label="BIOS folder: " + middle(row.label[13:], room - 13), style="") + if row.style == "path" else row for row in rows] return ("Options", _compose(state, width, height, before, rows, after), ("A/Enter Change", "B/Esc Back", "Left/Right Set", "? Help")) @@ -9658,7 +9881,7 @@ def _view_installing(state: TuiState, width: int, height: int) -> tuple: lines: list = [] if state.download is None: elapsed = format_duration(max(0.0, state.now - state.started)) - lines.append(f"Reading the files already in the folder... {elapsed}") + lines.append(_reading_line(state, elapsed)) else: total = state.download["bytes"] progress = state.progress or {} @@ -9676,9 +9899,10 @@ def _view_installing(state: TuiState, width: int, height: int) -> tuple: lines.append(f"{files_done} of {_plural(state.download['files'], 'file')}") if files_done >= state.download["files"] and state.summary is None: lines.append("Finishing...") - lines.append(_styled("Stopping, finishing the files in flight. Press Ctrl-C again " - "to quit now." if state.stopping - else "What is done stays done if you stop.", "dim")) + lines.append(_styled("Stopping, finishing the files in flight. Press B or Ctrl-C " + "again to stop now." if state.stopping + else "B, Ctrl-C or Start+Select stops. What is done stays done.", + "dim")) room = height - len(lines) - 1 failed = [item for item in reversed(state.finished) if not item[1]] good = [item for item in reversed(state.finished) if item[1]] @@ -9688,7 +9912,7 @@ def _view_installing(state: TuiState, width: int, height: int) -> tuple: lines += [f"{'ok ' if ok else 'FAILED'} {dest}" + (f" ({reason})" if reason else "") for dest, ok, reason in shown] return ("Installing", _compose(state, width, height, lines, []), - ("Ctrl-C / Start+Select stops",)) + ("B/Esc Stop", "? Help")) def tui_outcome(state: TuiState) -> tuple[str, list[str]]: @@ -9722,10 +9946,22 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: title = "Done" head = "Everything is in place." lines = [head, f"BIOS folder: {state.dest}"] - for dest, reason in [(f["dest"], f["reason"]) for f in failed[:20]]: - lines.append(f" failed: {dest}" + (f" ({reason})" if reason else "")) - if len(failed) > 20: - lines.append(f" ... and {len(failed) - 20} more") + causes: dict[str, list[str]] = {} + for failure in failed: + causes.setdefault(failure["reason"], []).append(failure["dest"]) + shown = 0 + for reason, dests in causes.items(): + if len(dests) > 3: + # A read-only folder fails every file alike: said once. + lines.append(f" {len(dests)} files failed ({reason or 'no reason given'}), " + f"the first {dests[0]}") + continue + for dest in dests: + if shown < 20: + lines.append(f" failed: {dest}" + (f" ({reason})" if reason else "")) + shown += 1 + if shown > 20: + lines.append(f" ... and {shown - 20} more") if not summary: lines += [f" {line}" for line in state.tail[-8:]] run = Invocation() @@ -9740,22 +9976,32 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: needed = sum(group["required"] for group in skipped) lines.append(f"{_plural(count, 'file')} of the list " f"{'is' if count == 1 else 'are'} not in the collection" - + (f", {needed} of them needed to start an emulator." if needed else ".")) + + ("." if not needed + else ", and an emulator needs it to start." if count == 1 + else f", {needed} of them needed to start an emulator.")) for group in summary.get("left_out") or []: lines.append(f"Left out as {group['label']}: {_plural(group['files'], 'file')}.") lines += ["Same choice without questions:", f" {tui_command(state)}"] - if state.code not in (0, None) and state.log: + if tui_keeps_log(state): lines.append(f"Details: {state.log}") return title, lines +def tui_keeps_log(state: TuiState) -> bool: + """Whether the run's log is kept: when a failure names it, and only then. + 57 folders of logs from stopped and finished runs were left in /tmp.""" + if state.screen == "crashed": + return True + return bool(state.log) and state.code not in (0, 130, None) + + def _view_result(state: TuiState, width: int, height: int) -> tuple: title, lines = tui_outcome(state) return (title, _pager(state, width, height, lines), ("A/Enter Exit", "Up/Down Scroll")) def _view_error(state: TuiState, width: int, height: int) -> tuple: - return ("Something went wrong", + return (state.error_title or "Something went wrong", _compose(state, width, height, state.error, tui_rows(state)), ("A/Enter Choose", "B/Esc Back")) @@ -9774,38 +10020,52 @@ _TUI_VIEWS: dict[str, Callable[[TuiState, int, int], tuple]] = { "options": _view_options, "regions": _view_regions, "cores": _view_cores, "preview": _view_preview, "command": _view_command, "confirm": _view_confirm, "installing": _view_installing, "result": _view_result, "error": _view_error, - "quit": _view_quit, + "quit": _view_quit, "cardroot": _view_cardroot, } def _help_lines(state: TuiState) -> list[tuple[str, str]]: screen = state.screen if screen == "installing": - return [("Ctrl-C, Start+Select", "Stop: what is done stays done"), - ("?, F1", "This help")] + return [("B/Esc, Ctrl-C", "Stop: what is done stays done"), + ("Start+Select", "Stop"), ("?, F1", "This help")] if screen == "path": return [("Enter", "Use this folder"), ("Backspace", "Delete a character"), - ("Esc", "Back"), ("Ctrl-C", "Quit")] - primary = _TUI_VIEWS[screen](state, 80, 24)[2][0].split(" ", 1) - keys = [("Up/Down, k/j", "Move"), ("PgUp/PgDn, L1/R1", "Page"), - ("Home/End, g/G", "First, last"), - ("A/Enter, Start", primary[1] if len(primary) > 1 else primary[0])] + ("Ctrl-U", "Clear"), ("Esc", "Back"), ("Ctrl-C", "Quit")] + keys = [("Up/Down, k/j", "Move")] + if len(tui_rows(state)) > 9 or screen in _PAGERS: + keys += [("PgUp/PgDn, L1/R1", "Page"), ("Home/End, g/G", "First, last")] + primary = _primary(state) or _TUI_VIEWS[screen](state, 80, 24)[2][0] + keys.append(("A/Enter, Start", primary.split(" ", 1)[-1])) if screen in _CHECKLISTS: - keys.append(("X/Space, Right/Left", "Tick, untick")) + keys.append(("X, Right/Left", "Tick, untick")) if screen == "systems": keys.append(("a, n", "All, none")) if screen in ("platforms", "systems", "cores"): - keys.append(("/", "Filter, Esc clears it")) + keys.append(("/", "Filter (Esc clears)")) if screen == "regions": keys.append(("Left/Right", "Move a region up or down the order")) if screen == "options": keys.append(("Left/Right", "Change a value")) if screen in ("plan", "systems", "confirm"): keys.append(("Y, o, Tab", "Options")) - keys += [("B/Esc, Backspace", "Back"), ("q, Ctrl-C", "Quit"), ("?, F1", "This help")] + back = "Quit" if len(state.stack) == 1 else "Back" + keys += [("B/Esc, Backspace", back), ("q, Ctrl-C", "Quit"), ("?, F1", "This help")] return keys +def _primary(state: TuiState) -> str | None: + """What A does on the row in focus, for the first entry of the bar.""" + if state.screen in _PAGERS: + return None + rows = tui_rows(state) + if not rows: + return None + row = rows[max(0, min(state.focus.get(state.screen, 0), len(rows) - 1))] + verb = _ROW_VERBS.get(row.action) + return f"A/Enter {verb}" if verb else None + + def _view_help(state: TuiState, width: int, height: int) -> tuple: keys = _help_lines(state) column = min(22, max(cell_width(key) for key, _what in keys) + 2) @@ -9815,6 +10075,8 @@ def _view_help(state: TuiState, width: int, height: int) -> tuple: return ("Keys", _compose(state, width, height, lines, []), ("Any key closes this help",)) + + def tui_view(state: TuiState, cols: int, rows: int) -> list[str]: """The screen as rows lines of at most cols cells, from the state alone.""" if cols < TUI_MIN_COLS or rows < TUI_MIN_ROWS: @@ -9827,6 +10089,9 @@ def tui_view(state: TuiState, cols: int, rows: int) -> list[str]: height = rows - 3 view = _view_help if state.help else _TUI_VIEWS[state.screen] title, body, hint = view(state, width, height) + primary = _primary(state) if not state.help and hint[0].startswith("A/Enter") else None + if primary: + hint = (primary, *hint[1:]) lines = [_styled(fit(f" RetroBIOS: {title}", cols), "title"), _styled("")] lines += [_styled(fit(" " + line, cols), line.style) for line in body[:height]] lines += [_styled("")] * (rows - 1 - len(lines)) @@ -9840,6 +10105,29 @@ def _setup_label(plat: str, path: Path) -> str: return f"{system} ({name} files)" if system and system != name else name +def _install_kind(path: Path) -> str: + """What sets one install of a frontend apart from another of the same.""" + # The config detection read it from says how it was installed: the + # Flatpak's system directory can be anywhere its config names. + found = _RETROARCH_CONFIGS.get(path) + text = path.as_posix() + (" " + Path(found[0]).as_posix() if found else "") + for marker, word in (("/.var/app/", "Flatpak"), ("/steamapps/", "Steam"), + ("/snap/", "Snap")): + if marker in text: + return word + if (path.parent / "retroarch.cfg").is_file(): + return "portable" + return "/".join(path.parts[-2:]) + + +def tell_apart(setups: list[tuple]) -> list[tuple]: + """Two setups that read alike get the word that separates them: the + Flatpak and the native RetroArch were both "RetroArch" at 40 columns.""" + labels = [label for _plat, _path, label in setups] + return [(plat, path, f"{label} ({_install_kind(path)})" if labels.count(label) > 1 + else label) for plat, path, label in setups] + + def _tui_passthrough(args: argparse.Namespace) -> list[tuple[str, str | None]]: """Options the screens do not change, handed to every run unchanged.""" flags: list[tuple[str, str | None]] = [] @@ -9887,6 +10175,7 @@ def tui_start( state.setups = [(name, path, _setup_label(name, path)) for name, path in found] state.setups += [(card, mount, f"{display_name(card)} SD card") for card, mount in cards if card and (card, mount) not in found] + state.setups = tell_apart(state.setups) if "esde" in frontends: state.notes.append("ES-DE found: its BIOS files go to each emulator, " "not to ES-DE itself.") @@ -9928,7 +10217,9 @@ def _log_tail(path: Path, count: int = 12) -> list[str]: except OSError as exc: return [f"The log {path} cannot be read: {exc.strerror or exc}"] lines = [line.rstrip() for line in text.replace("\r", "\n").splitlines()] - return [line for line in lines if line.strip()][-count:] + # The banner says which installer ran, not what went wrong. + return [line for line in lines if line.strip() and not line.startswith("RetroBIOS ") + ][-count:] class _EngineRun: @@ -9990,27 +10281,44 @@ class _EngineRun: self.proc.wait() +# One try, for a screen with Try again on it: three tries of 30 s were 85 s +# of a bare timer before the error said there was no network. +TUI_FETCH_TIMEOUT = 15 + + def _tui_fetch(plat: str, inbox: queue.Queue, log: io.StringIO) -> None: """The file list, fetched once in this process, or what went wrong.""" - mark = len(log.getvalue()) + if LOCAL.manifest is not None or LOCAL.pack is not None: + mark = len(log.getvalue()) + try: + inbox.put(("manifest", plat, fetch_manifest(plat))) + except SystemExit as exc: + inbox.put(("manifest_failed", plat, "", log.getvalue()[mark:].splitlines(), + _event_exit_code(exc.code))) + return + url = MANIFEST_URL.format(platform=plat) try: - manifest = fetch_manifest(plat) - except SystemExit as exc: - inbox.put(("manifest_failed", plat, log.getvalue()[mark:].splitlines(), - _event_exit_code(exc.code))) + data = _fetch_json(url, MAX_MANIFEST_BYTES, "file list", attempts=1, + timeout=TUI_FETCH_TIMEOUT) + inbox.put(("manifest", plat, _validate_manifest(data, plat))) return - except (OSError, ValueError) as exc: - inbox.put(("manifest_failed", plat, [f"The file list could not be read: {exc}"], 1)) - return - inbox.put(("manifest", plat, manifest)) - - -class _TuiStop(Exception): - """A signal that ends the interface: the terminal is restored on the way out.""" - - def __init__(self, signum: int) -> None: - super().__init__(signum) - self.signum = signum + except ValueError as exc: + title, lines = "The file list is not valid", [f"What arrived is not a file list: {exc}."] + except urllib.error.HTTPError as exc: + title = "No file list" + lines = [f"There is no file list for {display_name(plat)} at this address." + if exc.code == 404 else f"The server answered HTTP {exc.code}."] + except (urllib.error.URLError, OSError) as exc: + code, words = network_words(exc) + title = {"clock": "The clock is wrong", + "no_ca": "No trusted certificates"}.get(code, "No connection") + lines = [words[0].upper() + words[1:] + "."] + if code == "network": + lines = [f"The list of BIOS files could not be downloaded: {words}.", + "Check the Wi-Fi or the cable, then choose Try again."] + if os.environ.get("RETROBIOS_BASE_URL"): + lines.append(f"RETROBIOS_BASE_URL chose the address: {url}") + inbox.put(("manifest_failed", plat, title, lines, 1)) class _TuiTerminal: @@ -10024,6 +10332,14 @@ class _TuiTerminal: self.text = codecs.getincrementaldecoder( getattr(stdin, "encoding", None) or "utf-8")(errors="replace") self.encoding = getattr(stdout, "encoding", None) or "utf-8" + locale_name = os.environ.get("LC_ALL") or os.environ.get("LANG") or "" + try: + utf8 = codecs.lookup(self.encoding).name == "utf-8" + except LookupError: + utf8 = False + # Python turns a C locale into UTF-8 for itself, not for the + # terminal: a console font there has no CJK, often no accents. + self.ascii = not utf8 or locale_name in ("C", "POSIX") self.ended = False self.heard = 0.0 self.frame: list[tuple[str, str]] = [] @@ -10072,6 +10388,8 @@ class _TuiTerminal: if index < len(self.frame) and self.frame[index] == (text, style): continue text = screen_text(text) + if getattr(self, "ascii", False): + text = ascii_text(text) if style == "focus": text = _pad(text, min(cols, TUI_MAX_WIDTH) - (index == len(frame) - 1)) out.append(f"\x1b[{index + 1};1H{_STYLES.get(style, '')}{text}\x1b[0m\x1b[K") @@ -10132,13 +10450,19 @@ def tui_farewell(state: TuiState) -> list[str]: def _farewell_lines(state: TuiState) -> list[str]: + 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) + return [f"RetroBIOS: {title}", *lines] if state.screen == "crashed": return [f"RetroBIOS stopped on an unexpected error. What it was is in {state.log}."] if state.screen == "result": title, lines = tui_outcome(state) return [f"RetroBIOS: {title}", *lines] if state.screen == "error": - return ["RetroBIOS: something went wrong", *state.error] + return [f"RetroBIOS: {state.error_title or 'something went wrong'}", + *(line.replace("then choose Try again", "then run the installer again") + for line in state.error)] if state.exit_code == 0: return [f"RetroBIOS: everything is in place in {state.dest}."] return ["RetroBIOS: stopped. Nothing was installed."] @@ -10151,12 +10475,16 @@ def run_tui(args: argparse.Namespace) -> int: _enter_wsl() _open_local_sources(args) state, effects = tui_start(args, os_type) - work = Path(tempfile.mkdtemp(prefix="retrobios-")) + work = Path(tempfile.mkdtemp(prefix="retrobios-tui-")) script = str(Path(__file__).resolve()) inbox: queue.Queue = queue.Queue() current: list[_EngineRun] = [] written: dict[str, Path] = {} - flags = {"interrupt": False, "resized": False, "suspend": False, "resume": False} + flags = {"interrupt": False, "resized": False, "suspend": False, "resume": False, + "term": 0} + # Start+Select on a handheld is gptokeyb's pkill: SIGTERM, then SIGKILL + # 3 s later. The install gets 2 s to say what it wrote. + terminating = {"signal": 0, "deadline": 0.0} ignore_interrupt_until = [0.0] terminal = _TuiTerminal(sys.stdin, sys.stdout) real_out, real_err = sys.stdout, sys.stderr @@ -10205,7 +10533,7 @@ def run_tui(args: argparse.Namespace) -> int: flags["resized"] = True def on_stop(signum: int, _frame: object) -> None: - raise _TuiStop(signum) + flags["term"] = flags["term"] or signum def on_suspend(_signum: int, _frame: object) -> None: flags["suspend"] = True @@ -10251,6 +10579,19 @@ def run_tui(args: argparse.Namespace) -> int: flags["interrupt"] = False if time.monotonic() >= ignore_interrupt_until[0]: perform(tui_interrupt(state), from_key=False) + if flags["term"] and not terminating["signal"]: + terminating["signal"] = flags["term"] + if state.run == "install" and state.running: + terminating["deadline"] = time.monotonic() + 2 + if not state.stopping: + perform(tui_interrupt(state)) + elif state.screen == "result" and state.code is not None: + state.exit_code = state.code + else: + state.exit_code = 128 + terminating["signal"] + if terminating["signal"] and state.running and \ + time.monotonic() > terminating["deadline"]: + perform([("kill",)]) while state.exit_code is None: try: message = inbox.get_nowait() @@ -10258,8 +10599,9 @@ def run_tui(args: argparse.Namespace) -> int: break state.now = time.monotonic() perform(tui_receive(state, message)) - except _TuiStop as stop: - state.exit_code = 128 + stop.signum + if terminating["signal"] and state.screen == "result": + # The result is printed in the terminal on the way out. + state.exit_code = state.code if state.code is not None else 1 except Exception: # noqa: BLE001 # A traceback is where every reporter of #71, #73 and #74 stopped: # it goes to a file, and the terminal says one sentence. @@ -10276,7 +10618,7 @@ def run_tui(args: argparse.Namespace) -> int: for path in written.values(): if path.exists(): path.unlink() - if not (state.log and state.code not in (0, None)): + if not tui_keeps_log(state): shutil.rmtree(work, ignore_errors=True) stream = sys.stderr if state.screen in ("error", "crashed") else sys.stdout lines = tui_farewell(state) diff --git a/install.sh b/install.sh index bd969d5e..8aba468d 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="d0a92cac2d09da791fe3dbacdad37ec7e40b5a3685a39e3cc02f0dcbd5a556e6" +DEFAULT_INSTALL_SHA256="84ba56c9498a8bf89db4c4af8bd3413871f467771b36aea36158458aa4644ef8" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/fixtures/tui/browse-120x40.txt b/tests/fixtures/tui/browse-120x40.txt index 414e380f..40ee7335 100644 --- a/tests/fixtures/tui/browse-120x40.txt +++ b/tests/fixtures/tui/browse-120x40.txt @@ -37,4 +37,4 @@ - A/Enter Open B/Esc Back ? Help + A/Enter Use this folder B/Esc Back ? Help diff --git a/tests/fixtures/tui/browse-40x12.txt b/tests/fixtures/tui/browse-40x12.txt index 916c3ee2..19277c4b 100644 --- a/tests/fixtures/tui/browse-40x12.txt +++ b/tests/fixtures/tui/browse-40x12.txt @@ -9,4 +9,4 @@ system/ - A/Enter Open B/Esc Back ? Help + A Use this folder B Back ? Help diff --git a/tests/fixtures/tui/browse-40x15.txt b/tests/fixtures/tui/browse-40x15.txt index 71af88e3..ed1a32b3 100644 --- a/tests/fixtures/tui/browse-40x15.txt +++ b/tests/fixtures/tui/browse-40x15.txt @@ -12,4 +12,4 @@ - A/Enter Open B/Esc Back ? Help + A Use this folder B Back ? Help diff --git a/tests/fixtures/tui/browse-80x24.txt b/tests/fixtures/tui/browse-80x24.txt index b4c55535..aff0cb83 100644 --- a/tests/fixtures/tui/browse-80x24.txt +++ b/tests/fixtures/tui/browse-80x24.txt @@ -21,4 +21,4 @@ - A/Enter Open B/Esc Back ? Help + A/Enter Use this folder B/Esc Back ? Help diff --git a/tests/fixtures/tui/cores-120x40.txt b/tests/fixtures/tui/cores-120x40.txt index 45fafa53..d7a27d39 100644 --- a/tests/fixtures/tui/cores-120x40.txt +++ b/tests/fixtures/tui/cores-120x40.txt @@ -37,4 +37,4 @@ - A/Enter Done B/Esc Back X/Space Tick ? Help + A/Enter Filter B/Esc Back X/Space Tick ? Help diff --git a/tests/fixtures/tui/cores-40x12.txt b/tests/fixtures/tui/cores-40x12.txt index f6f68b55..0a09139e 100644 --- a/tests/fixtures/tui/cores-40x12.txt +++ b/tests/fixtures/tui/cores-40x12.txt @@ -9,4 +9,4 @@ - A/Enter Done B/Esc Back X/Space Tick + A Filter B Back X Tick ? Help diff --git a/tests/fixtures/tui/cores-40x15.txt b/tests/fixtures/tui/cores-40x15.txt index a7b625f6..0015d8e9 100644 --- a/tests/fixtures/tui/cores-40x15.txt +++ b/tests/fixtures/tui/cores-40x15.txt @@ -12,4 +12,4 @@ - A/Enter Done B/Esc Back X/Space Tick + A Filter B Back X Tick ? Help diff --git a/tests/fixtures/tui/cores-80x24.txt b/tests/fixtures/tui/cores-80x24.txt index 02b71903..1c98e53b 100644 --- a/tests/fixtures/tui/cores-80x24.txt +++ b/tests/fixtures/tui/cores-80x24.txt @@ -21,4 +21,4 @@ - A/Enter Done B/Esc Back X/Space Tick ? Help + A/Enter Filter B/Esc Back X/Space Tick ? Help diff --git a/tests/fixtures/tui/create-120x40.txt b/tests/fixtures/tui/create-120x40.txt index b317d7c8..f7641cbb 100644 --- a/tests/fixtures/tui/create-120x40.txt +++ b/tests/fixtures/tui/create-120x40.txt @@ -37,4 +37,4 @@ - A/Enter Choose B/Esc Back + A/Enter Create B/Esc Back diff --git a/tests/fixtures/tui/create-40x12.txt b/tests/fixtures/tui/create-40x12.txt index 08e604a6..44bd0751 100644 --- a/tests/fixtures/tui/create-40x12.txt +++ b/tests/fixtures/tui/create-40x12.txt @@ -9,4 +9,4 @@ - A/Enter Choose B/Esc Back + A/Enter Create B/Esc Back diff --git a/tests/fixtures/tui/create-40x15.txt b/tests/fixtures/tui/create-40x15.txt index e9c0b898..aa60b32b 100644 --- a/tests/fixtures/tui/create-40x15.txt +++ b/tests/fixtures/tui/create-40x15.txt @@ -12,4 +12,4 @@ - A/Enter Choose B/Esc Back + A/Enter Create B/Esc Back diff --git a/tests/fixtures/tui/create-80x24.txt b/tests/fixtures/tui/create-80x24.txt index 158fc30b..37aefbd4 100644 --- a/tests/fixtures/tui/create-80x24.txt +++ b/tests/fixtures/tui/create-80x24.txt @@ -21,4 +21,4 @@ - A/Enter Choose B/Esc Back + A/Enter Create B/Esc Back diff --git a/tests/fixtures/tui/error-120x40.txt b/tests/fixtures/tui/error-120x40.txt index f7053714..5e818142 100644 --- a/tests/fixtures/tui/error-120x40.txt +++ b/tests/fixtures/tui/error-120x40.txt @@ -4,8 +4,7 @@ known Address: https://raw.githubusercontent.com/Abdess/retrobios/main/install/retroarch.json - > Try again - Choose another setup or folder + > Choose another setup or folder Quit @@ -37,4 +36,5 @@ - A/Enter Choose B/Esc Back + + A/Enter Back B/Esc Back diff --git a/tests/fixtures/tui/error-40x12.txt b/tests/fixtures/tui/error-40x12.txt index 8752b130..73f17a02 100644 --- a/tests/fixtures/tui/error-40x12.txt +++ b/tests/fixtures/tui/error-40x12.txt @@ -3,10 +3,10 @@ Could not fetch the file list for retroarch after 3 attempts: [Errno -2] Name or service not known - - > Try again - Choose another setup or folder + Address: + https://raw.githubusercontent.com/ + Abdess/retrobios/main/install/ + retroarch.json + > Choose another setup or folder Quit - - - A/Enter Choose B/Esc Back + A/Enter Back B/Esc Back diff --git a/tests/fixtures/tui/error-40x15.txt b/tests/fixtures/tui/error-40x15.txt index 58bfabca..37d3614b 100644 --- a/tests/fixtures/tui/error-40x15.txt +++ b/tests/fixtures/tui/error-40x15.txt @@ -8,8 +8,8 @@ Abdess/retrobios/main/install/ retroarch.json - > Try again - Choose another setup or folder + > Choose another setup or folder Quit - A/Enter Choose B/Esc Back + + A/Enter Back B/Esc Back diff --git a/tests/fixtures/tui/error-80x24.txt b/tests/fixtures/tui/error-80x24.txt index accf801b..bed745e3 100644 --- a/tests/fixtures/tui/error-80x24.txt +++ b/tests/fixtures/tui/error-80x24.txt @@ -5,8 +5,7 @@ Address: https://raw.githubusercontent.com/Abdess/retrobios/main/install/ retroarch.json - > Try again - Choose another setup or folder + > Choose another setup or folder Quit @@ -21,4 +20,5 @@ - A/Enter Choose B/Esc Back + + A/Enter Back B/Esc Back diff --git a/tests/fixtures/tui/folder-120x40.txt b/tests/fixtures/tui/folder-120x40.txt index 4b8bc798..1aefeaee 100644 --- a/tests/fixtures/tui/folder-120x40.txt +++ b/tests/fixtures/tui/folder-120x40.txt @@ -37,4 +37,4 @@ - A/Enter Choose B/Esc Back ? Help + A/Enter Use this folder B/Esc Back ? Help diff --git a/tests/fixtures/tui/folder-40x12.txt b/tests/fixtures/tui/folder-40x12.txt index 42b180a4..e560d2ce 100644 --- a/tests/fixtures/tui/folder-40x12.txt +++ b/tests/fixtures/tui/folder-40x12.txt @@ -9,4 +9,4 @@ - A/Enter Choose B/Esc Back ? Help + A Use this folder B Back ? Help diff --git a/tests/fixtures/tui/folder-40x15.txt b/tests/fixtures/tui/folder-40x15.txt index 7ea474ed..360bf2a8 100644 --- a/tests/fixtures/tui/folder-40x15.txt +++ b/tests/fixtures/tui/folder-40x15.txt @@ -12,4 +12,4 @@ - A/Enter Choose B/Esc Back ? Help + A Use this folder B Back ? Help diff --git a/tests/fixtures/tui/folder-80x24.txt b/tests/fixtures/tui/folder-80x24.txt index 2b967a91..7ed7c112 100644 --- a/tests/fixtures/tui/folder-80x24.txt +++ b/tests/fixtures/tui/folder-80x24.txt @@ -21,4 +21,4 @@ - A/Enter Choose B/Esc Back ? Help + A/Enter Use this folder B/Esc Back ? Help diff --git a/tests/fixtures/tui/folder-card-120x40.txt b/tests/fixtures/tui/folder-card-120x40.txt index d8141aaf..8019406e 100644 --- a/tests/fixtures/tui/folder-card-120x40.txt +++ b/tests/fixtures/tui/folder-card-120x40.txt @@ -37,4 +37,4 @@ - A/Enter Choose B/Esc Back ? Help + A/Enter Browse B/Esc Back ? Help diff --git a/tests/fixtures/tui/folder-card-40x12.txt b/tests/fixtures/tui/folder-card-40x12.txt index 4fc95fe4..e704b9fa 100644 --- a/tests/fixtures/tui/folder-card-40x12.txt +++ b/tests/fixtures/tui/folder-card-40x12.txt @@ -9,4 +9,4 @@ - A/Enter Choose B/Esc Back ? Help + A/Enter Browse B/Esc Back ? Help diff --git a/tests/fixtures/tui/folder-card-40x15.txt b/tests/fixtures/tui/folder-card-40x15.txt index 94f71e09..9d517731 100644 --- a/tests/fixtures/tui/folder-card-40x15.txt +++ b/tests/fixtures/tui/folder-card-40x15.txt @@ -12,4 +12,4 @@ - A/Enter Choose B/Esc Back ? Help + A/Enter Browse B/Esc Back ? Help diff --git a/tests/fixtures/tui/folder-card-80x24.txt b/tests/fixtures/tui/folder-card-80x24.txt index ffb1de62..1d8c2ad3 100644 --- a/tests/fixtures/tui/folder-card-80x24.txt +++ b/tests/fixtures/tui/folder-card-80x24.txt @@ -21,4 +21,4 @@ - A/Enter Choose B/Esc Back ? Help + A/Enter Browse B/Esc Back ? Help diff --git a/tests/fixtures/tui/help-120x40.txt b/tests/fixtures/tui/help-120x40.txt index 8a077571..71ba7809 100644 --- a/tests/fixtures/tui/help-120x40.txt +++ b/tests/fixtures/tui/help-120x40.txt @@ -1,8 +1,6 @@ RetroBIOS: Keys Up/Down, k/j Move - PgUp/PgDn, L1/R1 Page - Home/End, g/G First, last A/Enter, Start Install Y, o, Tab Options B/Esc, Backspace Back @@ -34,6 +32,8 @@ + + diff --git a/tests/fixtures/tui/help-40x12.txt b/tests/fixtures/tui/help-40x12.txt index 52eab439..09b22304 100644 --- a/tests/fixtures/tui/help-40x12.txt +++ b/tests/fixtures/tui/help-40x12.txt @@ -1,12 +1,12 @@ RetroBIOS: Keys Up/Down, k/j Move - PgUp/PgDn, L1/R1 Page - Home/End, g/G First, last A/Enter, Start Install Y, o, Tab Options B/Esc, Backspace Back q, Ctrl-C Quit ?, F1 This help + BIOS folder: + /home/user/.config/retroarch/system Any key closes this help diff --git a/tests/fixtures/tui/help-40x15.txt b/tests/fixtures/tui/help-40x15.txt index d993ad9b..830acb87 100644 --- a/tests/fixtures/tui/help-40x15.txt +++ b/tests/fixtures/tui/help-40x15.txt @@ -1,8 +1,6 @@ RetroBIOS: Keys Up/Down, k/j Move - PgUp/PgDn, L1/R1 Page - Home/End, g/G First, last A/Enter, Start Install Y, o, Tab Options B/Esc, Backspace Back @@ -12,4 +10,6 @@ BIOS folder: /home/user/.config/retroarch/system + + Any key closes this help diff --git a/tests/fixtures/tui/help-80x24.txt b/tests/fixtures/tui/help-80x24.txt index df4be525..557dd217 100644 --- a/tests/fixtures/tui/help-80x24.txt +++ b/tests/fixtures/tui/help-80x24.txt @@ -1,8 +1,6 @@ RetroBIOS: Keys Up/Down, k/j Move - PgUp/PgDn, L1/R1 Page - Home/End, g/G First, last A/Enter, Start Install Y, o, Tab Options B/Esc, Backspace Back @@ -21,4 +19,6 @@ + + Any key closes this help diff --git a/tests/fixtures/tui/installing-120x40.txt b/tests/fixtures/tui/installing-120x40.txt index 0f9b2508..1f81f09d 100644 --- a/tests/fixtures/tui/installing-120x40.txt +++ b/tests/fixtures/tui/installing-120x40.txt @@ -4,7 +4,7 @@ 528.0 KB of 1.0 MB 128.9 KB/s, 0:04 left 2 of 4 files - What is done stays done if you stop. + B, Ctrl-C or Start+Select stops. What is done stays done. FAILED scph5500.bin (HTTP 404) ok gba_bios.bin @@ -37,4 +37,4 @@ - Ctrl-C / Start+Select stops + B/Esc Stop ? Help diff --git a/tests/fixtures/tui/installing-40x12.txt b/tests/fixtures/tui/installing-40x12.txt index 81e38ca8..ff117ddb 100644 --- a/tests/fixtures/tui/installing-40x12.txt +++ b/tests/fixtures/tui/installing-40x12.txt @@ -4,9 +4,9 @@ 528.0 KB of 1.0 MB 128.9 KB/s, 0:04 left 2 of 4 files - What is done stays done if you stop. + B, Ctrl-C or Start+Select stops. What + is done stays done. FAILED scph5500.bin (HTTP 404) ok gba_bios.bin - - Ctrl-C / Start+Select stops + B/Esc Stop ? Help diff --git a/tests/fixtures/tui/installing-40x15.txt b/tests/fixtures/tui/installing-40x15.txt index 052cc9ce..1cb62307 100644 --- a/tests/fixtures/tui/installing-40x15.txt +++ b/tests/fixtures/tui/installing-40x15.txt @@ -4,12 +4,12 @@ 528.0 KB of 1.0 MB 128.9 KB/s, 0:04 left 2 of 4 files - What is done stays done if you stop. + B, Ctrl-C or Start+Select stops. What + is done stays done. FAILED scph5500.bin (HTTP 404) ok gba_bios.bin - - Ctrl-C / Start+Select stops + B/Esc Stop ? Help diff --git a/tests/fixtures/tui/installing-80x24.txt b/tests/fixtures/tui/installing-80x24.txt index c5bac006..77f01560 100644 --- a/tests/fixtures/tui/installing-80x24.txt +++ b/tests/fixtures/tui/installing-80x24.txt @@ -4,7 +4,7 @@ 528.0 KB of 1.0 MB 128.9 KB/s, 0:04 left 2 of 4 files - What is done stays done if you stop. + B, Ctrl-C or Start+Select stops. What is done stays done. FAILED scph5500.bin (HTTP 404) ok gba_bios.bin @@ -21,4 +21,4 @@ - Ctrl-C / Start+Select stops + B/Esc Stop ? Help diff --git a/tests/fixtures/tui/options-120x40.txt b/tests/fixtures/tui/options-120x40.txt index 1c9dddcd..c1ef19f0 100644 --- a/tests/fixtures/tui/options-120x40.txt +++ b/tests/fixtures/tui/options-120x40.txt @@ -2,7 +2,7 @@ Each row is an option of the command line, named on the right. - > Preferred regions: north-america, japan --region + > Preferred regions: North America, Japan --region Leave out game data: no --no-game-data Installed cores only: no (1 found) --installed-cores Cores: all --core @@ -37,4 +37,4 @@ - A/Enter Change B/Esc Back Left/Right Set ? Help + A/Enter Open B/Esc Back Left/Right Set ? Help diff --git a/tests/fixtures/tui/options-40x12.txt b/tests/fixtures/tui/options-40x12.txt index 92fbda28..dc4891b3 100644 --- a/tests/fixtures/tui/options-40x12.txt +++ b/tests/fixtures/tui/options-40x12.txt @@ -1,12 +1,12 @@ RetroBIOS: Options - > Preferred regions: north-america, ja + > Preferred regions: North America, Ja Leave out game data: no Installed cores only: no (1 found) Cores: all Parallel downloads: 8 - BIOS folder: ~/.config/retroarch/sys + BIOS folder: ~/.config...ch/system Preview the file list Show the command line - A/Enter Change B/Esc Back + A Open B Back <> Set ? Help diff --git a/tests/fixtures/tui/options-40x15.txt b/tests/fixtures/tui/options-40x15.txt index 05df5f9a..b86d8de3 100644 --- a/tests/fixtures/tui/options-40x15.txt +++ b/tests/fixtures/tui/options-40x15.txt @@ -1,15 +1,15 @@ RetroBIOS: Options - > Preferred regions: north-america, ja + > Preferred regions: North America, Ja Leave out game data: no Installed cores only: no (1 found) Cores: all Parallel downloads: 8 - BIOS folder: ~/.config/retroarch/sys + BIOS folder: ~/.config...ch/system Preview the file list Show the command line Sets --region - A/Enter Change B/Esc Back + A Open B Back <> Set ? Help diff --git a/tests/fixtures/tui/options-80x24.txt b/tests/fixtures/tui/options-80x24.txt index 7f3a8a1c..7ff142b9 100644 --- a/tests/fixtures/tui/options-80x24.txt +++ b/tests/fixtures/tui/options-80x24.txt @@ -2,7 +2,7 @@ Each row is an option of the command line, named on the right. - > Preferred regions: north-america, japan --region + > Preferred regions: North America, Japan --region Leave out game data: no --no-game-data Installed cores only: no (1 found) --installed-cores Cores: all --core @@ -21,4 +21,4 @@ - A/Enter Change B/Esc Back Left/Right Set ? Help + A/Enter Open B/Esc Back Left/Right Set ? Help diff --git a/tests/fixtures/tui/path-120x40.txt b/tests/fixtures/tui/path-120x40.txt index 7cf44034..86273995 100644 --- a/tests/fixtures/tui/path-120x40.txt +++ b/tests/fixtures/tui/path-120x40.txt @@ -3,7 +3,7 @@ The folder for RetroArch: > ~/bios_ - Quotes are removed, ~ and variables are expanded. + Ctrl-U clears. Quotes are removed, ~ and variables are expanded. @@ -37,4 +37,4 @@ - Enter Use this folder Esc Back + Enter Use this folder Esc Back Ctrl-U Clear diff --git a/tests/fixtures/tui/path-40x12.txt b/tests/fixtures/tui/path-40x12.txt index 4b73884d..2da01e7c 100644 --- a/tests/fixtures/tui/path-40x12.txt +++ b/tests/fixtures/tui/path-40x12.txt @@ -3,8 +3,8 @@ The folder for RetroArch: > ~/bios_ - Quotes are removed, ~ and variables - are expanded. + Ctrl-U clears. Quotes are removed, ~ + and variables are expanded. diff --git a/tests/fixtures/tui/path-40x15.txt b/tests/fixtures/tui/path-40x15.txt index d555dba8..f8dc01d0 100644 --- a/tests/fixtures/tui/path-40x15.txt +++ b/tests/fixtures/tui/path-40x15.txt @@ -3,8 +3,8 @@ The folder for RetroArch: > ~/bios_ - Quotes are removed, ~ and variables - are expanded. + Ctrl-U clears. Quotes are removed, ~ + and variables are expanded. diff --git a/tests/fixtures/tui/path-80x24.txt b/tests/fixtures/tui/path-80x24.txt index 33b44a9a..d6e119ed 100644 --- a/tests/fixtures/tui/path-80x24.txt +++ b/tests/fixtures/tui/path-80x24.txt @@ -3,7 +3,7 @@ The folder for RetroArch: > ~/bios_ - Quotes are removed, ~ and variables are expanded. + Ctrl-U clears. Quotes are removed, ~ and variables are expanded. @@ -21,4 +21,4 @@ - Enter Use this folder Esc Back + Enter Use this folder Esc Back Ctrl-U Clear diff --git a/tests/fixtures/tui/plan-40x12.txt b/tests/fixtures/tui/plan-40x12.txt index 4907b477..e6157c28 100644 --- a/tests/fixtures/tui/plan-40x12.txt +++ b/tests/fixtures/tui/plan-40x12.txt @@ -9,4 +9,4 @@ Choose systems Options Quit - A/Enter Install B/Esc Back + A Install B Back Y Options ? Help diff --git a/tests/fixtures/tui/plan-40x15.txt b/tests/fixtures/tui/plan-40x15.txt index 99360d0d..ccdc512e 100644 --- a/tests/fixtures/tui/plan-40x15.txt +++ b/tests/fixtures/tui/plan-40x15.txt @@ -12,4 +12,4 @@ Choose systems Options Quit - A/Enter Install B/Esc Back + A Install B Back Y Options ? Help diff --git a/tests/fixtures/tui/plan-in-place-40x12.txt b/tests/fixtures/tui/plan-in-place-40x12.txt index cba026c0..8755e793 100644 --- a/tests/fixtures/tui/plan-in-place-40x12.txt +++ b/tests/fixtures/tui/plan-in-place-40x12.txt @@ -9,4 +9,4 @@ > Close Options - A/Enter Close B/Esc Back Y/o Options + A Close B Back Y Options ? Help diff --git a/tests/fixtures/tui/plan-in-place-40x15.txt b/tests/fixtures/tui/plan-in-place-40x15.txt index 602c11bd..0aa6b200 100644 --- a/tests/fixtures/tui/plan-in-place-40x15.txt +++ b/tests/fixtures/tui/plan-in-place-40x15.txt @@ -12,4 +12,4 @@ - A/Enter Close B/Esc Back Y/o Options + A Close B Back Y Options ? Help diff --git a/tests/fixtures/tui/platforms-120x40.txt b/tests/fixtures/tui/platforms-120x40.txt index dbd4179b..2641ecbf 100644 --- a/tests/fixtures/tui/platforms-120x40.txt +++ b/tests/fixtures/tui/platforms-120x40.txt @@ -3,7 +3,7 @@ Choose the setup that will use the BIOS files. Filter (/ to type) - > RetroArch + > RetroArch muOS, JELOS, KOLOS, Uno...Anbernic stock firmware Batocera Recalbox RetroBat @@ -15,7 +15,7 @@ BizHawk MiSTer FPGA RetroPie - AmberELEC + AmberELEC PAN4ELEC EmuELEC KNULLI MinUI diff --git a/tests/fixtures/tui/platforms-40x12.txt b/tests/fixtures/tui/platforms-40x12.txt index 73140e6c..d012896f 100644 --- a/tests/fixtures/tui/platforms-40x12.txt +++ b/tests/fixtures/tui/platforms-40x12.txt @@ -1,12 +1,12 @@ RetroBIOS: Which setup? Filter (/ to type) - > RetroArch + > RetroArch muOS, JE...firmware Batocera Recalbox RetroBat EmuDeck Lakka RetroDECK - 1-8 of 19 - A/Enter Choose B/Esc Back / Filter + 1-7 of 18 + A Choose B Back / Filter ? Help diff --git a/tests/fixtures/tui/platforms-40x15.txt b/tests/fixtures/tui/platforms-40x15.txt index e4ba9d26..a72ff3d8 100644 --- a/tests/fixtures/tui/platforms-40x15.txt +++ b/tests/fixtures/tui/platforms-40x15.txt @@ -1,7 +1,7 @@ RetroBIOS: Which setup? Filter (/ to type) - > RetroArch + > RetroArch muOS, JE...firmware Batocera Recalbox RetroBat @@ -11,5 +11,5 @@ ROCKNIX RomM BizHawk - 1-11 of 19 - A/Enter Choose B/Esc Back / Filter + 1-10 of 18 + A Choose B Back / Filter ? Help diff --git a/tests/fixtures/tui/platforms-80x24.txt b/tests/fixtures/tui/platforms-80x24.txt index c2abf8e3..e1769a0a 100644 --- a/tests/fixtures/tui/platforms-80x24.txt +++ b/tests/fixtures/tui/platforms-80x24.txt @@ -3,7 +3,7 @@ Choose the setup that will use the BIOS files. Filter (/ to type) - > RetroArch + > RetroArch muOS, JELOS, KOLOS...nic stock firmware Batocera Recalbox RetroBat @@ -15,7 +15,7 @@ BizHawk MiSTer FPGA RetroPie - AmberELEC + AmberELEC PAN4ELEC EmuELEC KNULLI MinUI diff --git a/tests/fixtures/tui/quit-120x40.txt b/tests/fixtures/tui/quit-120x40.txt index 99556b15..eac62de0 100644 --- a/tests/fixtures/tui/quit-120x40.txt +++ b/tests/fixtures/tui/quit-120x40.txt @@ -37,4 +37,4 @@ - A/Enter Choose B/Esc Stay + A/Enter Quit B/Esc Stay diff --git a/tests/fixtures/tui/quit-40x12.txt b/tests/fixtures/tui/quit-40x12.txt index cc30f08b..8d91077c 100644 --- a/tests/fixtures/tui/quit-40x12.txt +++ b/tests/fixtures/tui/quit-40x12.txt @@ -9,4 +9,4 @@ - A/Enter Choose B/Esc Stay + A/Enter Quit B/Esc Stay diff --git a/tests/fixtures/tui/quit-40x15.txt b/tests/fixtures/tui/quit-40x15.txt index 0681cde0..675109b6 100644 --- a/tests/fixtures/tui/quit-40x15.txt +++ b/tests/fixtures/tui/quit-40x15.txt @@ -12,4 +12,4 @@ - A/Enter Choose B/Esc Stay + A/Enter Quit B/Esc Stay diff --git a/tests/fixtures/tui/quit-80x24.txt b/tests/fixtures/tui/quit-80x24.txt index 14ff9c56..3bf86815 100644 --- a/tests/fixtures/tui/quit-80x24.txt +++ b/tests/fixtures/tui/quit-80x24.txt @@ -21,4 +21,4 @@ - A/Enter Choose B/Esc Stay + A/Enter Quit B/Esc Stay diff --git a/tests/fixtures/tui/regions-120x40.txt b/tests/fixtures/tui/regions-120x40.txt index 5fce3024..17076bca 100644 --- a/tests/fixtures/tui/regions-120x40.txt +++ b/tests/fixtures/tui/regions-120x40.txt @@ -3,8 +3,8 @@ Tick the regions to keep, best first. Where a system has one BIOS per region, the best one found is installed. - > [1] north-america - [ ] japan + > [1] North America + [ ] Japan diff --git a/tests/fixtures/tui/regions-40x12.txt b/tests/fixtures/tui/regions-40x12.txt index 3f4d5255..077ec334 100644 --- a/tests/fixtures/tui/regions-40x12.txt +++ b/tests/fixtures/tui/regions-40x12.txt @@ -5,8 +5,8 @@ region, the best one found is installed. - > [1] north-america - [ ] japan + > [1] North America + [ ] Japan - A/Enter Tick B/Esc Done + A Tick B Done <> Order ? Help diff --git a/tests/fixtures/tui/regions-40x15.txt b/tests/fixtures/tui/regions-40x15.txt index ccc9ecf3..ed7a42e4 100644 --- a/tests/fixtures/tui/regions-40x15.txt +++ b/tests/fixtures/tui/regions-40x15.txt @@ -5,11 +5,11 @@ region, the best one found is installed. - > [1] north-america - [ ] japan + > [1] North America + [ ] Japan - A/Enter Tick B/Esc Done + A Tick B Done <> Order ? Help diff --git a/tests/fixtures/tui/regions-80x24.txt b/tests/fixtures/tui/regions-80x24.txt index 943a3d8f..f34ca81a 100644 --- a/tests/fixtures/tui/regions-80x24.txt +++ b/tests/fixtures/tui/regions-80x24.txt @@ -3,8 +3,8 @@ Tick the regions to keep, best first. Where a system has one BIOS per region, the best one found is installed. - > [1] north-america - [ ] japan + > [1] North America + [ ] Japan diff --git a/tests/fixtures/tui/result-40x12.txt b/tests/fixtures/tui/result-40x12.txt index e68120c9..85613a75 100644 --- a/tests/fixtures/tui/result-40x12.txt +++ b/tests/fixtures/tui/result-40x12.txt @@ -8,5 +8,5 @@ /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 To put them back: python3 install.py - 1-8 of 20 + 1-4 of 9 A/Enter Exit Up/Down Scroll diff --git a/tests/fixtures/tui/result-40x15.txt b/tests/fixtures/tui/result-40x15.txt index 296bc519..86cb62b4 100644 --- a/tests/fixtures/tui/result-40x15.txt +++ b/tests/fixtures/tui/result-40x15.txt @@ -11,5 +11,5 @@ --restore /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 - 1-11 of 20 + 1-5 of 9 A/Enter Exit Up/Down Scroll diff --git a/tests/fixtures/tui/setup-120x40.txt b/tests/fixtures/tui/setup-120x40.txt index 79d2190a..b1155958 100644 --- a/tests/fixtures/tui/setup-120x40.txt +++ b/tests/fixtures/tui/setup-120x40.txt @@ -2,8 +2,8 @@ Found on this machine: - > RetroArch ~/.config/retroarch/system - KNULLI (Batocera files) /userdata/bios + > RetroArch + KNULLI (Batocera files) Another setup or folder BIOS folder: /home/user/.config/retroarch/system diff --git a/tests/fixtures/tui/setup-80x24.txt b/tests/fixtures/tui/setup-80x24.txt index 5a979d68..5211e2b3 100644 --- a/tests/fixtures/tui/setup-80x24.txt +++ b/tests/fixtures/tui/setup-80x24.txt @@ -2,8 +2,8 @@ Found on this machine: - > RetroArch ~/.config/retroarch/system - KNULLI (Batocera files) /userdata/bios + > RetroArch + KNULLI (Batocera files) Another setup or folder BIOS folder: /home/user/.config/retroarch/system diff --git a/tests/fixtures/tui/systems-40x12.txt b/tests/fixtures/tui/systems-40x12.txt index 3cc98993..175f36cc 100644 --- a/tests/fixtures/tui/systems-40x12.txt +++ b/tests/fixtures/tui/systems-40x12.txt @@ -9,4 +9,4 @@ 2 of 3 systems: 4 files, 1.0 MB - A/Enter Continue B/Esc Back + A Continue B Back X Tick ? Help diff --git a/tests/fixtures/tui/systems-40x15.txt b/tests/fixtures/tui/systems-40x15.txt index 78b818a8..60f7b0cc 100644 --- a/tests/fixtures/tui/systems-40x15.txt +++ b/tests/fixtures/tui/systems-40x15.txt @@ -12,4 +12,4 @@ 2 of 3 systems: 4 files, 1.0 MB - A/Enter Continue B/Esc Back + A Continue B Back X Tick ? Help diff --git a/tests/test_install.py b/tests/test_install.py index 14f27fc4..d5103312 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -5049,6 +5049,9 @@ class EventsDescribeTheRun(unittest.TestCase, _SyntheticTree): {"event": "detect", "os": "linux", "os_name": install._os_name(), "platforms": [{"platform": "retroarch", "name": "RetroArch", "bios_dir": str(self.bios)}]}, + # a.bin and b.bin are in place: the first read and the last. + {"event": "checking", "files_done": 1, "files_total": 2}, + {"event": "checking", "files_done": 2, "files_total": 2}, {"event": "file", "platform": "retroarch", "dest": "a.bin", "systems": ["sys-a"], "size": 100, "state": "present"}, {"event": "file", "platform": "retroarch", "dest": "b.bin", diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 699c4f33..64202553 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -484,7 +484,8 @@ class Flow(unittest.TestCase): self.assertEqual(state.screen, "cores") self.assertEqual([row.value for row in install.tui_rows(state)][1:], ["mesen", "pcsx_rearmed"]) - press(state, "down", " ") + self.assertEqual(install.tui_rows(state)[state.focus["cores"]].value, "mesen") + press(state, " ") self.assertEqual(state.cores, ["mesen"]) press(state, "left") self.assertEqual(state.cores, []) @@ -644,13 +645,14 @@ class Flow(unittest.TestCase): state = fresh_state() with mock.patch.object(install.Path, "exists", return_value=True): press(state, "enter") - install.tui_receive(state, ("manifest_failed", "retroarch", + install.tui_receive(state, ("manifest_failed", "retroarch", "No connection", [" Could not fetch the file list for retroarch", ""], 1)) + self.assertIn("RetroBIOS: No connection", install.tui_view(state, 80, 24)[0]) self.assertEqual(state.screen, "error") self.assertEqual([row.action for row in install.tui_rows(state)], ["retry", "back", "quit"]) self.assertEqual(press(state, "enter"), [("fetch", "retroarch")]) - install.tui_receive(state, ("manifest_failed", "retroarch", ["no"], 1)) + install.tui_receive(state, ("manifest_failed", "retroarch", "No connection", ["no"], 1)) press(state, "down", "down", "enter") self.assertEqual(state.exit_code, 1) @@ -697,7 +699,13 @@ class PlanWarnings(unittest.TestCase): state = checked_state({"free": 1024, "writable": False, "cores": []}) text = " ".join(install.tui_view(state, 120, 40)) self.assertIn("Not enough free space for this download", text) - self.assertIn("This folder cannot be written", text) + self.assertIn("You cannot write to this folder: choose another one, or run the " + "installer with sudo.", text) + rows = install.tui_rows(state) + self.assertEqual([row.action for row in rows[:2]], ["folder", "install"]) + self.assertEqual(state.focus.get("plan", 0), 0) + self.assertEqual(press(state, "enter"), []) + self.assertEqual(state.screen, "folder") self.assertNotIn("--installed-cores", [row.right for row in install._option_rows(state)]) @@ -716,7 +724,8 @@ class Widgets(unittest.TestCase): press(state, "end") lines = install.tui_view(state, 40, 15) self.assertIn("> [x] system-111", "\n".join(lines)) - self.assertTrue(any(re.fullmatch(r" \d+-115 of 115", line) for line in lines), lines) + # The All, None and Filter rows are not counted among the systems. + self.assertTrue(any(re.fullmatch(r" \d+-112 of 112", line) for line in lines), lines) press(state, "pgup") self.assertLess(state.focus["systems"], 114) press(state, "home") @@ -755,7 +764,10 @@ class Widgets(unittest.TestCase): press(state, "a") self.assertEqual(len(state.ticked), 5) state.focus["systems"] = 1 + # X on None does nothing: only A, an explicit choice, unticks all. press(state, " ") + self.assertEqual(len(state.ticked), 5) + press(state, "enter") self.assertEqual(state.ticked, set()) def test_running_total_counts_each_file_once(self): @@ -909,8 +921,8 @@ class Views(unittest.TestCase): for name, state in golden_states().items(): with self.subTest(screen=name): hint = install.tui_view(state, 40, 15)[-1].strip() - self.assertTrue(hint.startswith(("A/Enter", "Enter", "Ctrl-C", "Any key")), - hint) + self.assertTrue(hint.startswith(("A/Enter", "A ", "Enter", "Ctrl-C", "Any key", + "B/Esc Stop")), hint) def test_focus_is_marked_without_colour(self): lines = install.tui_view(fresh_state(), 80, 24) @@ -1092,6 +1104,19 @@ class SmallFixes(unittest.TestCase): self.assertTrue(state.installed_only) self.assertNotIn("--installed-cores", [f for f, _v in install.tui_flags(state)]) + def test_the_end_of_a_wrapped_pager_is_reachable(self): + state = checked_state() + state.stack.append("result") + state.code, state.summary = 0, {"installed": 3} + state.dest = Path("/media/" + "long-folder-name/" * 8) + state.cols, state.rows = 40, 12 + press(state, "end") + lines = install.tui_view(state, 40, 12) + self.assertIn("python3 install.py", "\n".join(lines)) + top = state.focus["result"] + press(state, "up") + self.assertEqual(state.focus["result"], top - 1) + def test_end_then_up_in_a_pager(self): state = checked_state() state.stack += ["options", "preview"] @@ -1127,7 +1152,7 @@ class SmallFixes(unittest.TestCase): "event": "downloaded", "dest": "a.bin", "ok": True, "reason": "", "size": 1, "files_done": 1, "files_total": 4, "bytes_done": 1, "bytes_total": 100})) press(state, "ctrl-c") - self.assertIn("Press Ctrl-C again to quit now.", + self.assertIn("Press B or Ctrl-C again to stop now.", " ".join(install.tui_view(state, 120, 40))) self.assertEqual(press(state, "ctrl-c"), [("kill",)]) install.tui_receive(state, ("ended", state.run_id, -9, [])) @@ -1206,6 +1231,346 @@ class CleanupSurvivesABrokenTerminal(unittest.TestCase): self.assertEqual(signal.getsignal(number), handler) +class AuditRoundTwo(unittest.TestCase): + """tui_audit_1.md, each finding held where it was found.""" + + def systems(self) -> install.TuiState: + state = checked_state() + press(state, "down", "enter") + self.assertEqual(state.screen, "systems") + return state + + def focused(self, state: install.TuiState) -> install.TuiRow: + rows = install.tui_rows(state) + return rows[state.focus[state.screen]] + + def test_p1_3_the_filter_never_changes_the_ticks(self): + state = self.systems() + press(state, "n") + state.focus["systems"] = next(i for i, row in enumerate(install.tui_rows(state)) + if row.value == "nintendo-gba") + press(state, "right") + self.assertEqual(state.ticked, {"nintendo-gba"}) + press(state, "/", "p", "l", "a", "y") + self.assertEqual(self.focused(state).action, "filter") + press(state, "enter") + # Enter lands on the first match, never on All or None. + self.assertEqual(self.focused(state).value, "sony-playstation") + press(state, " ") + self.assertEqual(state.ticked, {"nintendo-gba", "sony-playstation"}) + press(state, " ") + press(state, "esc") + # The filter is cleared, the row in focus stays in focus, and A there + # continues instead of ticking everything. + self.assertEqual(state.filters["systems"], "") + self.assertEqual(self.focused(state).value, "sony-playstation") + self.assertEqual(state.ticked, {"nintendo-gba"}) + self.assertIn("A/Enter Continue", install.tui_view(state, 80, 24)[-1]) + state.focus["systems"] = 2 + press(state, " ") + self.assertEqual(state.editing, "") + self.assertEqual(state.ticked, {"nintendo-gba"}) + + def test_p2_2_the_bar_names_what_a_does_on_the_focused_row(self): + state = checked_state() + self.assertTrue(install.tui_view(state, 80, 24)[-1].strip().startswith("A/Enter Install")) + press(state, "down") + self.assertTrue(install.tui_view(state, 80, 24)[-1].strip().startswith("A/Enter Open")) + state = self.systems() + state.focus["systems"] = 0 + self.assertIn("A/Enter Tick all", install.tui_view(state, 80, 24)[-1]) + + def test_p2_2_b_esc_and_q_stop_an_install(self): + for key in ("esc", "backspace", "q"): + with self.subTest(key=key): + state = checked_state() + press(state, "enter") + self.assertEqual(press(state, key), [("stop",)]) + self.assertTrue(state.stopping) + self.assertEqual(press(state, key), [("kill",)]) + + def test_p2_3_options_read_the_plan_again(self): + state = self.systems() + press(state, "n") + state.focus["systems"] = 3 + press(state, "right") + ticked = set(state.ticked) + press(state, "esc", "o") + self.assertEqual(state.screen, "options") + state.focus["options"] = 1 + press(state, "enter") + self.assertTrue(state.no_game_data) + effects = press(state, "esc") + self.assertEqual(effects, [("run", "check")]) + self.assertIn("--no-game-data", [f for f, _v in state.checked_flags]) + self.assertNotIn("--system", [f for f, _v in state.checked_flags]) + for event in [*FILES, *SYSTEMS, {**CHECK, "missing": 2, "bytes_needed": 600}]: + install.tui_receive(state, ("event", state.run_id, event)) + with mock.patch.object(install, "_tui_folder_facts", return_value={}): + install.tui_receive(state, ("ended", state.run_id, 1, [])) + self.assertEqual(state.screen, "plan") + self.assertEqual(state.ticked, ticked) + for cols, rows in ((80, 24), (40, 15)): + text = "\n".join(install.tui_view(state, cols, rows)) + self.assertIn("3 files to download, 600 B", text) + self.assertIn("For your 1 of 3 systems", text) + + def test_p2_4_no_network_is_said_at_once(self): + import socket + + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + inbox = __import__("queue").Queue() + started = time.monotonic() + with mock.patch.object(install, "MANIFEST_URL", + f"http://127.0.0.1:{port}/install/{{platform}}.json"): + install._tui_fetch("retroarch", inbox, io.StringIO()) + self.assertLess(time.monotonic() - started, 5) + kind, plat, title, lines, code = inbox.get_nowait() + self.assertEqual((kind, title, code), ("manifest_failed", "No connection", 1)) + self.assertIn("could not be downloaded: the server refused the connection", lines[0]) + self.assertNotIn("--", " ".join(lines)) + state = fresh_state() + with mock.patch.object(install.Path, "exists", return_value=True): + press(state, "enter") + install.tui_receive(state, (kind, plat, title, lines, code)) + self.assertEqual([row.action for row in install.tui_rows(state)], + ["retry", "back", "quit"]) + + def test_p2_4_the_clock_says_so(self): + import ssl + + inbox = __import__("queue").Queue() + error = urllib_error(ssl.SSLCertVerificationError("certificate is not yet valid")) + words = install.network_words(error, today=install.datetime.date(1980, 1, 1)) + with mock.patch.object(install, "_fetch_json", side_effect=error), \ + mock.patch.object(install, "network_words", return_value=words): + install._tui_fetch("retroarch", inbox, io.StringIO()) + _kind, _plat, title, lines, _code = inbox.get_nowait() + self.assertEqual(title, "The clock is wrong") + self.assertIn("The clock reads 1980-01-01", lines[0]) + + def test_p2_5_one_line_per_cause(self): + state = checked_state() + state.stack.append("result") + state.code = 2 + state.summary = {"installed": 0, "up_to_date": 0, "failed": [ + {"dest": f"f{n}.bin", "reason": "folder not writable"} for n in range(50)]} + _title, lines = install.tui_outcome(state) + self.assertEqual([line for line in lines if "failed" in line], + ["Nothing was installed: 50 files failed.", + " 50 files failed (folder not writable), the first f0.bin"]) + + def test_p2_6_a_folder_inside_a_card_offers_its_root(self): + card = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, card, True) + (card / ".system").mkdir() + (card / ".system" / "version.txt").write_text("MinUI-20250101\n") + (card / "Bios").mkdir() + state = fresh_state() + state.stack = ["folder"] + self.assertEqual(install._choose(state, "minui", card / "Bios"), []) + self.assertEqual(state.screen, "cardroot") + text = " ".join(line.strip() for line in install.tui_view(state, 120, 40)) + self.assertIn(f"under the card's root: {card}", text) + self.assertEqual(press(state, "enter"), [("fetch", "minui")]) + self.assertEqual(state.dest, card) + + def test_p2_6_the_banner_is_not_an_error_line(self): + state = fresh_state() + state.platform, state.dest = "retroarch", SYSTEM_DIR + state.manifest, state.manifest_for = MANIFEST, "retroarch" + install._start_check(state) + install.tui_receive(state, ("ended", state.run_id, 1, + ["RetroBIOS 532f0b8d96e0", "Error: refused."])) + self.assertEqual(state.error, ["Error: refused."]) + self.assertNotIn("retry", [row.action for row in install.tui_rows(state)]) + + def test_p2_7_a_typed_path_replaces_the_one_offered(self): + state = fresh_state() + state.platform, state.stack = "retroarch", ["folder"] + state.folder_default = SYSTEM_DIR + state.focus["folder"] = 2 + press(state, "enter") + self.assertEqual((state.screen, state.path_selected), ("path", True)) + install.tui_update(state, Key("paste", '"/media/sd card/bios"')) + self.assertEqual(state.path_text, '"/media/sd card/bios"') + install.tui_update(state, Key("ctrl-u")) + self.assertEqual(state.path_text, "") + install.tui_update(state, Key("paste", '/media/x"')) + self.assertEqual(install.typed_path(state.path_text), Path("/media/x")) + + def test_p2_7_a_path_from_the_root_is_not_created_on_a(self): + state = fresh_state() + state.stack = ["folder"] + install._choose(state, "retroarch", Path("/no/such/deep/folder")) + self.assertEqual(state.screen, "create") + self.assertEqual(self.focused(state).action, "other") + self.assertIn("Every folder of this path would be made", + " ".join(install.tui_view(state, 120, 40))) + near = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, near, True) + install._choose(state, "retroarch", near / "bios") + self.assertEqual(self.focused(state).action, "create") + + def test_p2_9_two_setups_of_one_name(self): + flatpak = HOME / ".var/app/org.libretro.RetroArch/config/retroarch/system" + setups = install.tell_apart([("retroarch", flatpak, "RetroArch"), + ("retroarch", SYSTEM_DIR, "RetroArch"), + ("emudeck", HOME / "Emulation/bios", "EmuDeck")]) + self.assertEqual([label for _p, _f, label in setups], + ["RetroArch (Flatpak)", "RetroArch (retroarch/system)", "EmuDeck"]) + state = fresh_state() + state.setups = setups + self.assertIn("RetroArch (Flatpak)", "\n".join(install.tui_view(state, 40, 15))) + # A Flatpak whose config names a system folder outside its sandbox. + elsewhere = HOME / "bios" + config = HOME / ".var/app/org.libretro.RetroArch/config/retroarch/retroarch.cfg" + with mock.patch.dict(install._RETROARCH_CONFIGS, {elsewhere: (config, config.parent)}): + setups = install.tell_apart([("retroarch", elsewhere, "RetroArch"), + ("retroarch", SYSTEM_DIR, "RetroArch")]) + self.assertEqual(setups[0][2], "RetroArch (Flatpak)") + + def test_p2_10_the_check_counts_while_it_reads(self): + state = fresh_state() + state.platform, state.dest = "retroarch", SYSTEM_DIR + state.manifest, state.manifest_for = MANIFEST, "retroarch" + install._start_check(state) + install.tui_receive(state, ("event", state.run_id, + {"event": "checking", "files_done": 120, "files_total": 400})) + self.assertIn("Reading the files already in the folder: 120 of 400", + "\n".join(install.tui_view(state, 80, 24))) + + def test_p2_11_a_narrow_bar_keeps_the_keys_to_finish(self): + state = self.systems() + self.assertEqual(install.tui_view(state, 40, 15)[-1].strip(), + "A Continue B Back X Tick ? Help") + + def test_p2_12_a_device_name_finds_its_setup(self): + state = fresh_state() + state.stack = ["platforms"] + press(state, "/", "m", "u", "o", "s") + self.assertEqual([row.value for row in install.tui_rows(state)][1:], ["retroarch"]) + + def test_p2_13_a_long_preview_wraps_only_what_is_in_view(self): + state = checked_state() + state.stack += ["options", "preview"] + state.dry = dict(CHECK) + state.dry_files = [{**FILES[0], "dest": f"file{n:05d}.bin"} for n in range(8000)] + state.cols, state.rows = 40, 15 + real = install._text_lines + calls = [] + + def counted(items, width): + calls.append(len(items)) + return real(items, width) + + with mock.patch.object(install, "_text_lines", counted): + press(state, "end") + lines = install.tui_view(state, 40, 15) + self.assertLess(sum(calls), 40) + self.assertIn("file07999.bin", "\n".join(lines)) + + def test_p2_1_titles_and_regions(self): + state = checked_state() + state.manifest = {**MANIFEST, "system_titles": {"nintendo-gba": "Game Boy Advance"}} + state.stack.append("systems") + text = "\n".join(install.tui_view(state, 40, 15)) + self.assertIn("[x] Game Boy Advance", text) + self.assertEqual(install.region_title("asia-ntsc"), "Asia (NTSC)") + self.assertEqual(install.region_title("north-america"), "North America") + + def test_p3_jobs_stop_at_the_ends_and_q_keeps_the_error_code(self): + state = checked_state() + state.jobs = 1 + install._option(state, "jobs", -1) + self.assertEqual(state.jobs, 1) + state.jobs = 32 + install._option(state, "jobs", 1) + self.assertEqual(state.jobs, 32) + state = fresh_state() + state.stack, state.error_code = ["setup", "error"], 2 + press(state, "q") + self.assertEqual(state.exit_code, 2) + + def test_p3_ascii_keeps_the_columns(self): + self.assertEqual(install.ascii_text("Édition ファミ"), "Edition ??????") + + def test_p3_a_refused_tui_says_why_without_the_usage(self): + proc = install_run("--tui") + self.assertEqual(proc.returncode, 2) + self.assertEqual(proc.stderr.strip().splitlines(), + ["install.py: error: --tui cannot open the full-screen interface " + "here: the input is not a terminal"]) + + +def urllib_error(reason: BaseException): + import urllib.error + + return urllib.error.URLError(reason) + + +class WorkFolders(unittest.TestCase): + """Every run's folder of logs goes, but the one a failure names.""" + + class Terminal: + state_to = None + + def __init__(self, *_streams) -> None: + self.frame = [] + + def enter(self) -> None: + pass + + def leave(self) -> None: + pass + + def size(self) -> tuple: + return 80, 24 + + def draw(self, lines, cols) -> None: + pass + + def keys(self, timeout): + WorkFolders.Terminal.state_to(self) + return [Key("enter")] + + def run_to(self, code: int) -> tuple: + work = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, work, True) + state = checked_state() + state.stack.append("result") + state.code = code + state.log = str(work / "install.log") + state.summary = {"installed": 1, "failed": [] if code == 0 else [ + {"dest": "a.bin", "reason": "HTTP 404"}]} + WorkFolders.Terminal.state_to = lambda _t: None + out = io.StringIO() + with mock.patch.object(install, "_TuiTerminal", self.Terminal), \ + mock.patch.object(install, "tui_start", return_value=(state, [])), \ + mock.patch.object(install, "_open_local_sources"), \ + mock.patch.object(install, "detect_os", return_value="linux"), \ + mock.patch.object(install.tempfile, "mkdtemp", return_value=str(work)), \ + mock.patch.object(sys, "stdout", out), mock.patch.object(sys, "stderr", out): + self.assertEqual(install.run_tui(_args()), code) + return work, out.getvalue() + + def test_a_success_leaves_nothing(self): + work, out = self.run_to(0) + self.assertFalse(work.exists()) + self.assertNotIn("Details:", out) + + def test_a_failure_keeps_the_log_it_names(self): + work, out = self.run_to(1) + self.assertTrue(work.exists()) + self.assertIn(f"Details: {work / 'install.log'}", out) + + def test_a_stop_leaves_nothing(self): + work, out = self.run_to(130) + self.assertFalse(work.exists()) + + class Crash(unittest.TestCase): """An unexpected error ends on one sentence and a log, never a traceback.""" @@ -1441,13 +1806,11 @@ class EndToEnd(unittest.TestCase): term.send("\r") term.wait("Preferred regions") term.send(DOWN, "\r", "\x1b") - screen = term.wait("Preferred regions: north-america") + screen = term.wait("Preferred regions: North America") term.send(DOWN, RIGHT, "\x1b") - screen = term.wait("> Install") - self.assertIn("Your choices: --region north-america --no-game-data", screen) - term.send("\r") + # Back on the plan, the check runs again under the new options. screen = term.wait("4 files to download") - self.assertIn("RetroBIOS: Confirm", screen) + self.assertIn("> Install", screen) term.send("\r") screen = term.wait("installed.") self.assertIn("Left out as game data: 1 file.", screen) @@ -1477,11 +1840,36 @@ class EndToEnd(unittest.TestCase): term.wait("1 of 6 files") term.send("\x03") screen = term.wait("Stopped:") - self.assertRegex(screen, r"Stopped: \d of 6 installed, run again to finish\.") term.send("\r") self.assertEqual(term.close(), 130) - self.assertLess(len(repo.installed()), 6) - self.assertIn("RetroBIOS: Stopped", term.after()) + # The count said is the count on disk: "6 of 6" after a stop at 1 + # passed a regex and was false. + on_disk = len(repo.installed()) + self.assertLess(on_disk, 6) + self.assertIn(f"Stopped: {on_disk} of 6 installed, run again to finish.", screen) + self.assertIn(f"Stopped: {on_disk} of 6 installed", term.after()) + + def test_sigterm_during_the_download_is_a_stop(self): + """Start+Select on a handheld is gptokeyb's pkill: SIGTERM, SIGKILL 3 s on.""" + import signal + + repo = self.repo(GameDataRepository, slow=True) + term = self.start("--platform", "retroarch", "--dest", str(repo.dest), "--jobs", "1") + term.wait("> Install") + term.send("\r") + term.wait("1 of 6 files") + started = time.monotonic() + os.kill(term.pid, signal.SIGTERM) + code = term.close() + self.assertLess(time.monotonic() - started, 3) + self.assertEqual(code, 130) + on_disk = len(repo.installed()) + self.assertGreaterEqual(on_disk, 1) + after = term.after() + self.assertIn(f"Stopped: {on_disk} of 6 installed, run again to finish.", after) + self.assertNotIn("Nothing was installed", after) + time.sleep(1) + self.assertEqual(len(repo.installed()), on_disk) def test_ctrl_c_on_the_first_screen(self): self.repo() @@ -1524,7 +1912,7 @@ class EndToEnd(unittest.TestCase): term.resize(100, 30) term.send(DOWN) screen = term.wait("> Another setup or folder") - self.assertIn("A/Enter Continue", screen.splitlines()[29]) + self.assertIn("B/Esc Quit", screen.splitlines()[29]) term.resize(30, 10) term.wait("Make the window larger") term.resize(40, 12) diff --git a/wiki/installer.md b/wiki/installer.md index 717fb99e..c7c3e5ff 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -92,8 +92,10 @@ play, with the total to download under the list. **Options** sets the preferred regions, game data, the installed cores only, the cores, the parallel downloads and the folder. Each row names the option it sets, and **Show the command line** gives the same choice as one command for a script. **Preview the -file list** shows what would be downloaded and writes nothing. When a choice -changes the plan, the exact count appears before the install starts. +file list** shows what would be downloaded and writes nothing. Back on the plan +after a change of options, the folder is checked again, so its numbers are the +exact ones for the new choice. Ticked systems get their exact count before the +install starts. | Action | Keyboard | Controller | |--------|----------|------------| @@ -106,14 +108,19 @@ changes the plan, the exact count appears before the install starts. | Options | `o`, Tab | Y | | Filter a long list | `/` then type | the Filter row | | Keys of the screen | `?`, F1 | | +| Stop an install | Esc, `q`, Ctrl+C | B, Start+Select | | Quit | `q`, Ctrl+C | B on the first screen | -The bottom line of each screen names its keys, with both spellings (`A/Enter`, -`B/Esc`), since A and B swap places between controller layouts. On the regions -screen, Left and Right move the region under the cursor up and down the order of -preference. During the download, Ctrl+C stops after the files in progress, a -second Ctrl+C stops at once, and the result says how many were installed. The result stays printed in the -terminal once the screen closes, with the command that repeats the run. +The bottom line of each screen names what A does on the row under the cursor, +with both spellings (`A/Enter`, `B/Esc`), since A and B swap places between +controller layouts. A narrow screen shortens them to `A`, `B` and `X`. On the +regions screen, Left and Right move the region under the cursor up and down the +order of preference. During the download, a stop waits for the files in +progress, and a second one stops at once. Start+Select on a handheld ends the +installer with a signal (SIGTERM), which stops it the same way. Either way the +result says how many files were installed, and it stays printed in the terminal +once the screen closes, with the command that repeats the run. A run that failed +keeps its log and names it, and every other run leaves nothing behind. It opens only for an install, at a terminal of at least 40 columns and 12 rows that is not `TERM=dumb`. A listing, `--check`, `--dry-run`, `--events`, @@ -1099,6 +1106,7 @@ kind, and each kind has exactly the keys below, in this order. The schema is | `start` | `schema`, `installer`, `mode` | First line. `schema` is 1, `installer` the identity `--version` prints, `mode` one of `install`, `check`, `dry-run`, `list` | | `detect` | `os`, `os_name`, `platforms` | Once the platforms are known. `os_name` comes from `/etc/os-release`. `platforms` lists `{platform, name, bios_dir}`, and is empty when nothing supported was found | | `error` | `stage`, `reason`, `platform`, `message` | The file list could not be read (`stage` is `manifest`). `reason` is `network`, `not_found` or `invalid`, `clock` when the server's certificate was refused and the system clock reads a date before the installer was written, or `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one). `message` says it in words | +| `checking` | `files_done`, `files_total` | While the files already in the folder are read and hashed, at most every half second and once at the end. `files_total` counts the files found in place | | `file` | `platform`, `dest`, `systems`, `size`, `state` | Each file of the list after the check. `state` is `present` (hash verified), `wrong_hash` or `missing` | | `system` | `platform`, `system`, `name`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed` | Each system, sorted by id, and with `--system` only the systems named. Files with no system count under `other`. `name` is the platform's own name for it | | `check` | `platform`, `name`, `bios_dir`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed`, `data_missing`, `not_installable` | The platform's totals. `data_missing` counts data-tree files to fetch, `not_installable` the files the collection cannot supply |