diff --git a/install.ps1 b/install.ps1 index 0abf59682..62a0450c8 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "7029f605591f09c020aafcd521d8a7134ace78cc566435d8e76be33c42a9f6dc" + $defaultInstallSha256 = "0165a8f39265725aa70cd19a7966c1e228fd9df480fb05d7338ca24530c26762" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index a18ab87c5..3014221d0 100644 --- a/install.py +++ b/install.py @@ -631,7 +631,8 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = { # refused certificate under a clock set in the past, or no_ca for no # trusted certificates on the machine) or detect (reason: conflict, two # systems found on one machine, or not_found, the platform named with - # --platform found nowhere and no folder to assume) or from (a folder named with --from, + # --platform, or by a file list or a pack in a run without questions, + # found nowhere and no folder to assume) or from (a folder named with --from, # reason: not_found, not_a_folder, unreadable, or system for the root # of the system and /proc, /sys, /dev) or dest (reason: unreadable, a # folder above it this user cannot open, or not_writable, the BIOS @@ -955,16 +956,6 @@ def game_data_state(left_out: bool, games: Tally) -> str: f"({_plural(games.games, 'file')}, {format_size(games.game_size)})") -def split_words(counts: Tally) -> str: - """BIOS files and game data apart, in words, "" when there is no game - data: the installer tells them apart (--no-game-data), and no count - showed it. Both parts come from the count they divide.""" - if not counts.games: - return "" - return (f"{_plural(counts.bios, 'BIOS file')} ({format_size(counts.bios_size)}) and " - f"{_plural(counts.games, 'game data file')} ({format_size(counts.game_size)})") - - def keep_declared(files: list[dict], omitted: list[dict]) -> tuple[list[dict], list[dict]]: """The files and omissions the platform's own list declares (cores: null). @@ -5253,6 +5244,12 @@ def _download_one( dest = _entry_path(bios_path, f) except ValueError: return str(f.get("dest", "?")), False, "unsafe destination" + # Nothing to write, no folder made: a pack lacking the list's files + # left 405 empty folders in the BIOS folder (tui_audit_13 P3-8). + if copy is None and LOCAL.pack is not None and LOCAL.pack.find(f) is None: + return f["dest"], False, NOT_IN_PACK + if copy is None and LOCAL.pack is None and offline_requested(): + return f["dest"], False, OfflineReason(f"not fetched: {offline_words()}") try: dest.parent.mkdir(parents=True, exist_ok=True) except OSError as exc: @@ -6176,8 +6173,11 @@ def game_data_missing(names: str, count: int, folders: list[Path]) -> str: places = list(dict.fromkeys(f"{shown_path(folder)}{os.sep}" for folder in folders)) where = (f" into {', '.join(places[:3])}" + (f" and {len(places) - 3} more folders" if len(places) > 3 else "")) if places else "" - return (f"Not available here, {GAME_DATA_WORDS}, read by " - f"{'its game' if one else 'their games'}: {names}. Copy " + # The names first, the kind after them in a word: the definition was + # spliced into this sentence, and said again on its own line + # (tui_audit_13 P3-5). + return (f"Not available here: {names}, game data " + f"{'its game reads' if one else 'their games read'}. Copy " f"{'it' if one else 'them'} from your own copy of the game{where}.") @@ -6206,6 +6206,8 @@ def _omission_lines(count: int, entries: list[dict], bios_path: Path | None = No except ValueError: continue lines.append(" " + game_data_missing(names, len(games), [f for f in folders if f])) + # Defined on a line of its own, the sentence above naming the files. + lines.append(" " + kinds_definition(True, False)) return lines @@ -6251,7 +6253,8 @@ def _needed_lines(entries: list[dict], heading: str, every: bool = False) -> lis if not every and len(systems) > 8: # A file several systems share is a group of its own, counted as one. unit = "groups" if any(", " in system for system in systems) else "systems" - lines.append(f" ... and {len(systems) - 8} more {unit} " + more = len(systems) - 8 + lines.append(f" ... and {more} more {unit if more > 1 else unit[:-1]} " "(--check --verbose lists them all)") lines.append(f" Each emulator's page says what these files are: {SITE_URL}emulators/") return lines @@ -6365,9 +6368,10 @@ def _print_plan( print(f" {term.bold(system)} {_sized(by_system[system])}") if len(largest) > PLAN_LARGEST_SYSTEMS: # Counted as the line above counts: the groups, when it names them. - unit = "groups" if "groups" in groups else "systems" - print(f" ... and {len(largest) - PLAN_LARGEST_SYSTEMS} more {unit}: " - "--verbose lists every system and its files.") + unit = "group" if "groups" in groups else "system" + more = len(largest) - PLAN_LARGEST_SYSTEMS + print(f" ... and {more} more {unit}{'s' if more > 1 else ''}: --verbose lists " + "every system and its files.") for entry, needed in trees: source = LOCAL.pack.name if LOCAL.pack is not None else entry["url"] print(f" {term.bold(entry['ref'] + ' data')} {_sized(list(needed.values()))}, " @@ -7184,17 +7188,26 @@ def drive_targets(plat: str) -> list[tuple[str, Path]]: found = card_system(mount) if (found is not None and found[0] != plat) or share_doubt(mount)[0]: continue - name = _mount_name(mount) if found is not None: below = found[1].relative_to(mount).as_posix() if found[1] != mount else "" what = f"{found[2]} {_card_word(found)}" - targets.append((f"{name}: {below} ({what})" if below else f"{name} ({what})", - found[1])) + targets.append((f"{_drive_folder(mount, below)} ({what})", found[1])) else: - targets.append((f"{name}: bios (card or drive)", mount / "bios")) + targets.append((f"{_drive_folder(mount, 'bios')} (card or drive)", mount / "bios")) return targets +def _drive_folder(mount: Path, below: str) -> str: + """A folder on a mounted drive as a row names it: D:\\bios where the + drive is a letter (Windows, and WSL's /mnt/d), USBSTICK: bios where it + is a label. "D:: bios" and "D:\\: bios" doubled the colon + (code_audit_20 P3-3).""" + name = _mount_name(mount) + if name.endswith((":", ":\\")): + return shown_path(mount / below) if below else shown_path(mount) + return f"{name}: {below}" if below else name + + def _card_word(system: tuple[str, Path, str]) -> str: """A mounted drive's kind as the first screen writes it: SD card, or SHARE drive.""" @@ -7243,7 +7256,8 @@ def ask_platform_folder(os_type: str, plat: str, ask: bool = True, print(f" {name} reads its BIOS files from: {shown_path(found)}.") elif default is not None: state = "" if _exists(default) else " (it does not exist yet and will be created)" - print(f" {name}'s default BIOS folder: {shown_path(default)}{state}.") + print(f" {side_name(os_type, plat)}'s default BIOS folder: {shown_path(default)}" + f"{state}.") # A drive is offered by the BIOS folder its system reads, the screen's # rows: never its root, and never for a system that reads another list. targets = (share_targets(plat) if shared else @@ -7374,6 +7388,14 @@ def other_side(os_type: str, plat: str) -> list[tuple[str, Path]]: return [(f"Windows {display_name(plat)}: {shown_path(windows)}", windows)] +def side_name(os_type: str, plat: str) -> str: + """The setup named by its side where the folder question offers both + (other_side): Linux RetroArch beside Windows RetroArch under WSL. The + Windows one alone was named so (code_audit_20 P3-3).""" + name = display_name(plat) + return f"Linux {name}" if other_side(os_type, plat) else name + + def _default_set_aside(os_type: str, platform_name: str) -> bool: """Whether the platform's usual folder was set aside because detection met a closed folder, as against having none on this OS.""" @@ -7412,7 +7434,7 @@ def _manual_usage_hint(os_type: str, listing: str = "") -> list[str]: f" The platforms: {hint.launcher()} --list-platforms", ] return [ - " Use --platform and --dest to specify manually, for example:", + " Name the setup with --platform and its folder with --dest, for example:", f" {hint.command('retroarch', example)}", f" Where each platform keeps its BIOS: {INSTALLER_PAGE}", ] @@ -7513,19 +7535,25 @@ def _system_sized(files: list[dict]) -> str: def _pick( title: str, rows: list[tuple[str, list[dict]]], ordered: bool = False, sizer: Callable[[list[dict]], str] = _sized, labels: dict | None = None, + note: str = "", ) -> list[str]: """Numbered list of names. An empty answer keeps every one. - sizer says what each name holds, labels how each one is shown. + sizer says what each name holds, labels how each one is shown, note + what the list needs said, under it. """ if not rows: return [] print(f"\n{title}") width = len(str(len(rows))) - shown = {name: (labels or {}).get(name, name) for name, _files in rows} - name_width = min(max(len(label) for label in shown.values()), 40) + shown = {name: screen_text((labels or {}).get(name, name)) for name, _files in rows} + # The sizes in one column, whatever the longest label: cut at 40, the + # longer names pushed theirs right (tui_audit_13 P3-13). + name_width = max(cell_width(label) for label in shown.values()) for index, (name, files) in enumerate(rows, 1): - print(f" {index:>{width}}) {screen_text(shown[name]):<{name_width}} {sizer(files)}") + print(f" {index:>{width}}) {_pad(shown[name], name_width)} {sizer(files)}") + if note: + print(f"\n{note}") hint = "order of preference, e.g. 2,1" if ordered else "e.g. 1,3-5" while True: text = _ask(f"Numbers ({hint}), Enter for all, q to quit: ") @@ -7563,6 +7591,16 @@ def choosable_systems(files: list[dict], omitted: list[dict], return listed_systems(files) +def emptied_words(names: list[str], game_data: bool) -> str: + """What a choice that keeps no file says, on the plan and in line mode, + in place of "All files up to date": Sega Dreamcast holds only game data.""" + subject = (", ".join(names[:-1]) + " and " + names[-1]) if len(names) > 1 else ( + names[0] if names else "This choice") + verb = "hold" if len(names) > 1 else "holds" + why = "only game data" if game_data else "no file under these options" + return f"{subject} {verb} {why}: nothing is left to install." + + def system_title(system: str, manifest: dict | None) -> str: """A system as the screen's Choose systems names it (_tui_system_label).""" names = ((manifest or {}).get("system_names") or {}).get(system) @@ -7806,6 +7844,15 @@ def _prompt_custom_selection( while answer not in keys: print(f" Type {', '.join(['Enter', *keys[2:]])} or q.") answer = _ask("> ").lower() + kept = menu_choice(files, omitted, installed, current, cutting)[0] + if answer == "" and isinstance(current, tuple) and not kept: + # A choice that keeps no file stops here, as the screen offers + # no Install for it: Enter answered "All files up to date" + # (tui_audit_13 P3-11). + names = [system_title(name, manifest) for name in current[0]] + print(f" {emptied_words(names, cutting)} Type " + f"{', '.join(keys[2:])} or q.") + continue if answer == "": return current if answer == "q": @@ -7846,9 +7893,14 @@ def _print_menu_head( elif isinstance(current, tuple): systems, cores, regions = current flag = current is given - total = len(choosable_systems(files, omitted, cores, regions)) + listed = choosable_systems(files, omitted, cores, regions) + total = len(listed) + # The systems chosen that still hold a file: Sega Dreamcast holding + # only game data, g left the line at "2 of 8" where the screen, + # which drops it, said "1 of 8" (tui_audit_13 P3-11). + held = len(set(systems) & set(listed)) named = ", " + ", ".join(filter(None, [ - f"for {len(systems)} of {total} systems" + (" (--system)" if flag else "") + f"for {held} of {total} systems" + (" (--system)" if flag else "") if systems else "", ("--core" if flag else f"{_plural(len(cores), 'core')} chosen") if cores else "", ("--region" if flag else "regions ranked") if regions else ""])) @@ -7917,9 +7969,11 @@ def _choose_narrowing(files: list[dict], omitted: list[dict], manifest: dict | N listed = listed_systems(files) if "systems" in recorded_axes(files, omitted) else [] systems_listed = (systems_in_order(listed, files, manifest, bios_path) if len(listed) > 1 else []) - if OTHER_SYSTEMS in systems_listed: - print(f"\n'{OTHER_TITLE}' holds what cores load for systems this platform") - print("does not list, such as game engines and extra computers.") + # Said under the list it explains, before the question: above + # "Systems:" it read as a heading (tui_audit_13 P3-13). + note = (f"'{OTHER_TITLE}' holds what cores load for systems this platform\n" + "does not list, such as game engines and extra computers." + if OTHER_SYSTEMS in systems_listed else "") labels = {} for system in systems_listed: title = system_title(system, manifest) @@ -7927,7 +7981,7 @@ def _choose_narrowing(files: list[dict], omitted: list[dict], manifest: dict | N else f"{title} {system}" systems = _pick("Systems:", [ (system, [f for f in files if _chosen(f, {system})]) for system in systems_listed - ], sizer=_system_sized, labels=labels) + ], sizer=_system_sized, labels=labels, note=note) kept, kept_omitted = narrow(files, omitted, systems, [], []) cores: list[str] = [] @@ -8628,6 +8682,22 @@ LOCAL = LocalSources() NOT_IN_PACK = "not in the pack" +def pack_lacks(entries: list[dict]) -> list[dict]: + """The entries the pack given to this run does not hold, none without + a pack: they cannot be installed from it.""" + return [] if LOCAL.pack is None else [e for e in entries if LOCAL.pack.find(e) is None] + + +def pack_lacks_words(count: int, total: int) -> str: + """The pack's missing files, said before the plan names a size.""" + name = LOCAL.pack.name if LOCAL.pack is not None else "the pack" + if count >= total: + return (f"{name} holds none of these {_plural(total, 'file')}: nothing can be " + "installed from it.") + return (f"{count} of these {total} files {'is' if count == 1 else 'are'} not in " + f"{name}: {'it' if count == 1 else 'they'} cannot be installed from it.") + + class _Offline(OSError): """A network request in a run told to make none.""" @@ -12050,8 +12120,13 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # RetroArch list serves a computer and a dozen handheld cards. # Without questions nothing found here stops, as without a list, # where --yes filled this computer's default folder. - message = (f"{display_name(args.platform)} was not found here, and the file list " - "does not say where it goes") + # Said as the stop without a list is said, and of the pack where + # a pack was named: "the file list" was one the user never gave + # (code_audit_20 P3-6, tui_audit_13 P3-13). + source = ("the pack" if LOCAL.pack is not None and LOCAL.manifest is None + else "the file list") + message = (f"No supported platform detected: {display_name(args.platform)} was " + f"not found here, and {source} does not say where it goes") EVENTS.emit("error", stage="detect", reason="not_found", platform=args.platform, message=message) print(f" {message}.", file=sys.stderr) @@ -12300,21 +12375,13 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: ) game_data_cut = 0 - if args.no_game_data: - if not manifest.get("categorized"): - # Without the field nothing is marked, and the run would - # install the game data it was asked to leave out. - print(f"Error: the {plat_name} file list records no file category, " - "which --no-game-data needs.", file=sys.stderr) - print(_older_list_advice(plat_name), file=sys.stderr) - sys.exit(1) - before = len(files) - files, omitted_files, left_out = leave_out_game_data(files, omitted_files) - game_data_cut = before - len(files) - left_out_seen.extend(left_out) - if left_out: - for line in _left_out_lines(left_out, args.verbose): - print(line) + if args.no_game_data and not manifest.get("categorized"): + # Without the field nothing is marked, and the run would + # install the game data it was asked to leave out. + print(f"Error: the {plat_name} file list records no file category, " + "which --no-game-data needs.", file=sys.stderr) + print(_older_list_advice(plat_name), file=sys.stderr) + sys.exit(1) if args.declared_only: before = len(files) @@ -12401,7 +12468,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: print(f" Narrowed {before} to {len(files)} files to the installed cores") if args.list_emulators: - _print_emulators(plat_name, manifest, files, omitted_files) + _print_emulators(plat_name, manifest, *( + leave_out_game_data(files, omitted_files)[:2] if args.no_game_data + else (files, omitted_files))) continue if args.emulator: @@ -12423,14 +12492,22 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: display = display_name(plat_name, manifest) files, unplaced = place_save_entries( files, bios_path, manifest, args.save_dir, display, say=False) + # --no-game-data leaves the game data out once the list is narrowed + # to the choice: taken out first, its line named the 5099 game data + # files of every system under a run for the PlayStation alone + # (tui_audit_13 P2-3). What is chosen among is the list without it. + bare, bare_omitted = (leave_out_game_data(files, omitted_files)[:2] + if args.no_game_data else (files, omitted_files)) # What --core resolves against, for advice that names one. - core_choices = set(available_choices(files, omitted_files)["cores"]) + core_choices = set(available_choices(bare, bare_omitted)["cores"]) + # A system named that holds only game data is a choice, said empty + # below: refused as unknown, it was named "not a system". selection = _requested_narrowing(args, plat_name, files, omitted_files, manifest) # What the systems are counted out of, on the plan and in Choose # systems: the screen reads it from this event. EVENTS.emit("choices", platform=plat_name, systems=choosable_systems( - files, omitted_files, *(selection[1:] if selection else ()))) + bare, bare_omitted, *(selection[1:] if selection else ()))) # What declining the size question points back at, when it was asked. smaller = "" given = selection @@ -12438,10 +12515,12 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: cut: list[bool] | None = None menu = interactive and not args.check and not listing if menu: - installed = ( + whole_installed = ( narrow_to_cores(manifest, found_cores, files, omitted_files) if found_cores and not args.installed_cores else None ) + installed = (whole_installed if whole_installed is None or not args.no_game_data + else leave_out_game_data(*whole_installed)[:2]) listed = len(manifest.get("files", [])) + len(variants if case_kept else []) # g, the plan's game data row: offered where the list marks its @@ -12459,7 +12538,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: return _INVOCATION.command(plat, folder) selection = _prompt_custom_selection( - plat_name, files, omitted_files + unplaced, installed, + plat_name, bare, bare_omitted + unplaced, installed, detected_os(plat_name, bios_path) or display, args.dry_run, manifest.get("data_directories") or [], bios_path, records_selection(files), given, manifest, @@ -12477,7 +12556,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: f"({format_size(_bytes(installed[0]))})") smaller += "." if selection == INSTALLED_ONLY: - files, omitted_files = installed + files, omitted_files = whole_installed _INVOCATION.chosen[plat_name] = chosen_flags(INSTALLED_ONLY) selection = None elif selection is not None and selection != given: @@ -12491,14 +12570,17 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: if len(files) != before: # Enter for every system narrows nothing: "Narrowed 23 to 23". print(f" Narrowed {before} to {len(files)} files") - if cut and cut[0]: - # Left out at g, as --no-game-data leaves it out, from what the - # choice kept: g offered the game data of the systems chosen, - # and the line below counted the whole list's. + if (cut and cut[0]) or args.no_game_data: + # Left out at g or by --no-game-data, from what the choice kept: + # g offered the game data of the systems chosen, and the line + # below counted the whole list's. + before = len(files) files, omitted_files, left_out = leave_out_game_data(files, omitted_files) + game_data_cut = before - len(files) left_out_seen.extend(left_out) - _INVOCATION.chosen[plat_name] = (_INVOCATION.chosen.get(plat_name, []) - + [("--no-game-data", None)]) + if not args.no_game_data: + _INVOCATION.chosen[plat_name] = (_INVOCATION.chosen.get(plat_name, []) + + [("--no-game-data", None)]) for line in _left_out_lines(left_out, args.verbose): print(line) if unplaced: @@ -12513,10 +12595,40 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # The count every later line of the platform divides: the files # checked, planned, offered and installed are these. run_counts = tally(files) - print(f" {_plural(run_counts.files, 'file')} ({format_size(run_counts.size)})") - split = split_words(run_counts) if manifest.get("categorized") else "" - if split: - print(f" {split}.") + # The kinds as the plan and the menu head say them (parts_words), + # the data files of the folders kept among them: this head said + # BIOS files and game data beside a plan of three kinds, neither + # defined (code_audit_20 P3-5). + head_trees = select_data_directories(manifest, files, bool( + target or args.system or args.core or args.region or args.installed_cores + or args.declared_only or len(files) + game_data_cut + len(unplaced) + != len(manifest.get("files", [])) + len(variants if case_kept else []))) + head_data = sum(len(entry.get("files") or {}) for entry in head_trees) + head_bytes = sum(meta.get("size", 0) for entry in head_trees + for meta in (entry.get("files") or {}).values()) + print(f" {_plural(run_counts.files, 'file')} ({format_size(run_counts.size)})" + + (f", and {_plural(head_data, 'data file')} ({format_size(head_bytes)})" + if head_data else "")) + categorized_list = bool(manifest.get("categorized")) + kinds = run_counts if categorized_list else tally( + {"size": entry.get("size")} for entry in files) + parts = parts_words(kinds, head_data, head_bytes, game_data_cut) + if parts: + print(f" {parts}.") + defined = kinds_definition(categorized_list and bool(run_counts.games or game_data_cut), + bool(head_data)) + if defined and not menu: + # The menu head defined them already. + print(f" {defined}") + # A choice that keeps no file says so where "All files up to date" + # was said of it (tui_audit_13 P3-11). + empty_choice = "" if run_counts.files or not (selection or game_data_cut) else ( + emptied_words([system_title(name, manifest) for name in + (selection[0] if selection else [])], bool(game_data_cut))) + if empty_choice: + print(f" {empty_choice}") + if not EVENTS.on: + continue if omitted_files: required_omitted = sum( 1 for entry in omitted_files if entry.get("required", True) @@ -12687,8 +12799,15 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: total_offline += len(fetched) to_download = [f for f in to_download if id(f) in copies] fetched = [] - dl_size = sum(f.get("size", 0) for f in to_download) - fetch_size = _bytes(fetched) + # What the pack does not hold is said before a size is promised: + # a pack older than the list gave "Copy 6.6 GB" and 8358 files + # failed (tui_audit_13 P3-8). They are still tried, and fail as + # not in the pack, the size and the question leaving them out. + lacking = {id(f) for f in pack_lacks(fetched)} + if lacking: + print(f"\n {pack_lacks_words(len(lacking), len(fetched))}") + dl_size = sum(f.get("size", 0) for f in to_download if id(f) not in lacking) + fetch_size = _bytes([f for f in fetched if id(f) not in lacking]) if not to_download: total_up_to_date += len(up_to_date) continue @@ -12778,6 +12897,14 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: to_download, bios_path, jobs=args.jobs, verbose=args.verbose, term=term, progress=progress, copies=copies, ) + if created and not progress.installed: + # Created for files none of which came: not left behind empty. + try: + bios_path.rmdir() + except OSError as exc: + if args.verbose: + print(f" {shown_path(bios_path)} stays: {exc.strerror}", + file=sys.stderr) bulk_lines += hint total_downloaded += progress.installed # The end line tells BIOS files from game data, as the menu @@ -13270,14 +13397,22 @@ def _split_word(word: str, room: int) -> list[str]: pieces = [] while cell_width(word) > room: head = fit(word, max(room, 1)) - cut = head.rfind("/") + 1 - if cut <= len(head) // 3: + # After the last separator that fits, a Windows one too: cut where + # a third of the room was left, a folder broke inside its name + # ("tui_au" / "dit13", tui_audit_13 P3-3). + cut = max(head.rfind("/"), head.rfind("\\")) + 1 + if cut <= 1: cut = len(head) pieces.append(word[:cut]) word = word[cut:] return pieces + [word] if word else pieces +def _is_path_word(word: str) -> bool: + """A word that is a path: it breaks after a separator, never inside a name.""" + return ("/" in word or "\\" in word) and not word.startswith(("http:", "https:")) + + _UNIT = re.compile(r"(B|KB|MB|GB)\b") @@ -13315,14 +13450,32 @@ def _wrap(text: str, width: int, indent: str = " ", whole: str = "") -> list[st def close(done: str) -> str: # A line one word wide that fits without the indent loses it: one # cell too wide, a folder was folded by the terminal inside a word - # ("syste" / "m", tui_audit_12 P3-7). + # ("syste" / "m", tui_audit_12 P3-7). The line's own lead too: a + # pack path of 75 cells was folded at 80 columns under an indent + # of 6 (tui_audit_13 P3-3). prefix = lead + indent if lines else lead - if lines and cell_width(prefix + done) > width >= cell_width(lead + done): + if cell_width(prefix + done) > width >= cell_width(lead + done): prefix = lead + elif cell_width(prefix + done) > width >= cell_width(done) and " " not in done: + prefix = "" return prefix + done for word in words: - pieces = [word] if whole else _split_word(word, width - len(lead + indent)) + # Kept whole, a path is still broken after a separator where it is + # wider than the screen: the terminal folded it inside a name. A + # command's words stay whole, copied as one. + room = width - len(lead + indent) + # A word that fits on a line of its own stays whole there (close). + pieces = ([word] if whole == "command" or (whole and not _is_path_word(word)) + or cell_width(lead + word) <= width else _split_word(word, room)) + if len(pieces) > 1 and _is_path_word(word): + # The path starts on the line it follows, broken at a separator + # that fits there: "BIOS folder:" stood alone above its folder. + prefix = lead + indent if lines else lead + left = width - cell_width(prefix + line) - (1 if line else 0) + head = _split_word(word, left)[0] if left >= 8 else "" + if head.endswith(("/", "\\")) and head != word: + pieces = [head, *_split_word(word[len(head):], room)] for index, piece in enumerate(pieces): prefix = lead + indent if lines else lead # The rest of a word broken at a slash joins it without a space: @@ -13539,6 +13692,15 @@ class TuiState: self.path_selected: bool = False # The ticks kept across a check run again for new options. self.keep_ticked: set | None = None + # The regions in the order Preferred regions opened on: kept while + # it is open, whatever is ticked. + self.region_order: list[str] = [] + # The cores of every system listed, after "Cores of the other systems". + self.cores_all: bool = False + # The names of the systems ticked before a check counted again, and + # those of them that hold nothing under its options. + self.ticked_names: dict[str, str] = {} + self.emptied: list[str] = [] # Choose systems asked on a plan --system narrowed: the systems are # opened once the check of every system is in. self.widen: bool = False @@ -14269,6 +14431,16 @@ def tui_choices(state: TuiState) -> dict[str, list[str]]: return available_choices(manifest.get("files", []), manifest.get("omitted_files", [])) +def _chosen_cores(state: TuiState) -> set[str] | None: + """The cores the files of the systems chosen name, None when every + system is chosen.""" + systems = [value for flag, value in tui_flags(state) if flag == "--system" and value] + if not systems: + return None + files = narrow((state.manifest or {}).get("files") or [], [], systems, [], [])[0] + return set(available_choices(files, [])["cores"]) + + def tui_systems(state: TuiState) -> list[str]: """The systems Choose systems lists for the run, as the check counted them (listed_systems): the file list's own count took in a system @@ -14304,10 +14476,16 @@ def tui_rows(state: TuiState) -> list[TuiRow]: # The drives mounted here, as line mode numbers them at the same # question, each by the BIOS folder its system reads, after the # Windows side's usual folder under WSL. + # The Windows side's folder said missing where it is: taken, the + # plan said RetroArch reads a folder no RetroArch had (tui_audit_13 + # P3-9). drives = [] if state.folder_kind == "share" else [ TuiRow("drive", label, value=folder) - for label, folder in [*(other_side(state.os_type, state.platform) - if state.folder_kind == "usual" else []), + for label, folder in [*((label + ("" if _is_dir(folder) + else " (does not exist yet)"), folder) + for label, folder in ( + other_side(state.os_type, state.platform) + if state.folder_kind == "usual" else [])), *drive_targets(state.platform)]] return rows + [TuiRow("browse", "Browse for a folder"), TuiRow("type", "Type a path")] + drives @@ -14351,18 +14529,35 @@ def tui_rows(state: TuiState) -> list[TuiRow]: if screen == "options": return _option_rows(state) if screen == "regions": - 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), key=region_title) - return ranked + [TuiRow("region", f"[ ] {region_title(name)}", value=name) - for name in known if name not in state.regions] + # The order drawn when the screen opened, ranked regions first: a + # tick moved its row to the top, and the next tap on the same spot + # ticked the region that slid under the finger (tui_audit_13 P2-2). + # The rank says the order, and the screen opens on it next time. + order = list(state.region_order or [*state.regions, *( + name for name in known if name not in state.regions)]) + order += [name for name in [*state.regions, *known] if name not in order] + ranks = {name: str(rank) for rank, name in enumerate(state.regions, 1)} + return [TuiRow("region", f"[{ranks.get(name, ' ')}] {region_title(name)}", value=name) + for name in order] if screen == "cores": needle = state.filters.get(screen, "") - return [_filter_row(state)] + [ + every = tui_choices(state)["cores"] + theirs = _chosen_cores(state) + # The cores of the systems chosen first, the others behind a row: + # with --system sony-playstation the list opened on a5200 and + # amiberry, 189 cores long (tui_audit_13 P3-12). A core ticked + # stays in view. + shown = every if theirs is None or state.cores_all else [ + name for name in every if name in theirs or name in state.cores] + rows = [_filter_row(state)] + [ TuiRow("core", f"[{'x' if name in state.cores else ' '}] {name}", value=name) - for name in tui_choices(state)["cores"] if _matches(name, needle) - ] + for name in shown if _matches(name, needle)] + hidden = len(every) - len(shown) + if hidden: + rows.append(TuiRow("morecores", f"Cores of the other systems: {hidden}")) + return rows if screen == "confirm": if state.running or state.dry is None: return [] @@ -14387,17 +14582,40 @@ def tui_rows(state: TuiState) -> list[TuiRow]: return [] +def _emptied_words(state: TuiState) -> str: + """The plan's headline when no system chosen holds a file under the + options changed since: "Sega Dreamcast holds only game data".""" + return emptied_words(state.emptied, state.no_game_data) + + def _plan_rows(state: TuiState) -> list[TuiRow]: check = state.check or {} + if state.emptied and state.systems and not state.ticked: + # The choice emptied by an option: Choose systems first, never an + # Install that would refuse or take the whole list. + rows = [TuiRow("systems", "Choose systems")] + game_data = _game_data_row(state) + return rows + ([game_data] if game_data is not None else []) + [ + TuiRow("options", "Options"), TuiRow("quit", "Quit")] if not (_needed(check) if check else 0) and not check.get("data_missing"): return [TuiRow("close", "Close"), TuiRow("options", "Options")] - size = check.get("bytes_needed", 0) + # The size the pack can give: "Install 6.6 GB" from a pack holding + # none of the files (tui_audit_13 P3-8). + lacking = set(_tui_pack_lacks(state, [event["dest"] for event in state.files])) + size = check.get("bytes_needed", 0) - sum(event["size"] for event in state.files + if event["dest"] in lacking) label = f"Install {format_size(size)}" if size >= CONFIRM_FROM_BYTES else "Install" rows = [TuiRow("install", label)] if _offline_only() and not _plan_copies(state, check): # Nothing here to copy, and the network refused: Install would # only fail every file, in this folder or another. rows = [] + files = {event["dest"] for event in state.files} + if (LOCAL.pack is not None and files and not _plan_copies(state, check) + and len(_tui_pack_lacks(state, sorted(files))) == len(files) + and not check.get("data_missing")): + # The pack holds none of them: Install would fail every one. + rows = [] if _on_its_card(state) and (state.facts.get("writable") is False or _short_of_space(state, check)): # Its system reads this card alone: room is made on it, or it is @@ -14470,12 +14688,29 @@ def _choice_entries(state: TuiState) -> list[dict]: system said 5089 game data files and 2.5 GB nothing would install.""" manifest = state.manifest or {} systems = [value for flag, value in tui_flags(state) if flag == "--system" and value] + if state.emptied and not state.ticked: + systems = sorted(state.ticked_names) # The second spellings the check installs, as line mode adds them where # the folder keeps case. variants = [entry for entry in manifest.get("case_variants") or [] if entry.get("dest") in state.checked_dests] - return narrow([*(manifest.get("files") or []), *variants], [], systems, + kept = narrow([*(manifest.get("files") or []), *variants], [], systems, list(state.cores), list(state.regions))[0] + # The check read the list as the run narrows it, --emulator, --target + # and the installed cores included, and only the systems ticked are + # left to apply: its files and the game data it left out are that + # list. Narrowed by the axes alone, the row offered the 8 game data + # files of every system under a plan for one (code_audit_20 P2-1). + reached = _checked_scope(state) + return [entry for entry in kept if entry.get("dest") in reached] if reached else kept + + +def _checked_scope(state: TuiState) -> set[str]: + """Every destination the last check read, the game data it left out + included: the list before the systems are chosen. Empty before a check.""" + left = {dest for group in (state.check_summary or {}).get("left_out") or [] + for dest in group.get("dests") or []} + return set(state.checked_dests) | left if state.checked_dests or left else set() def _game_data_row(state: TuiState) -> TuiRow | None: @@ -14506,6 +14741,8 @@ def _offline_only() -> bool: def _plan_copies(state: TuiState, check: dict) -> list[str]: """The files to install that this machine holds, by destination.""" + if check is state.dry and check is not state.check: + return _dry_copies(state) local = {event["dest"] for event in state.found} if check is state.check else set() sizes = {event["dest"] for event in state.files} return [dest for dest in local if dest in sizes] @@ -14676,6 +14913,8 @@ def _back(state: TuiState) -> list[tuple]: for field, value in before.items(): setattr(state, field, value) chose = False + if state.screen == "regions": + state.region_order = [] state.stack.pop() if chose and state.screen == "plan": # The systems chosen, the next press is Install: Back left the focus @@ -14691,12 +14930,30 @@ def _back(state: TuiState) -> list[tuple]: 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 +def _keep_choice(state: TuiState) -> None: + """The systems ticked, kept for the next check of the same platform's + list when they are not every system to install: one choice for the + whole run, whatever is changed on the way (game data, an option, a + folder, a folder to copy from). A choice already set aside (widening a + --system) is left as it is.""" + if (state.keep_ticked is not None or not state.systems + or state.manifest_for != state.platform): + return + if state.emptied and not state.ticked: + # Still the choice the options emptied: the same systems again. + state.keep_ticked = set(state.ticked_names) + return + needed = {row["system"] for row in state.systems if _needed(row)} + state.keep_ticked = None if needed <= state.ticked else set(state.ticked) + titles = _system_titles(state) + state.ticked_names = {row["system"]: _tui_system_label(row, titles) + for row in state.systems if row["system"] in state.ticked} + + def tui_quit(state: TuiState, code: int | None = None, asked: bool = True) -> list[tuple]: """Leave now: with the install's own code once it ran, else QUIT_CODE for a quit the user chose (q, Quit) and 130 for Ctrl-C. asked is a quit @@ -14750,7 +15007,9 @@ def _start_run(state: TuiState, run: str) -> list[tuple]: def _start_check(state: TuiState) -> list[tuple]: - """Check the chosen folder, after fetching the file list once.""" + """Check the chosen folder, after fetching the file list once, the + systems ticked kept (_keep_choice).""" + _keep_choice(state) if "plan" in state.stack: del state.stack[state.stack.index("plan"):] while state.stack and state.stack[-1] in ("checking", "error", "create"): @@ -14810,9 +15069,11 @@ def _choose(state: TuiState, plat: str, dest: Path, proposed: bool = False) -> l f"{detected_os(plat, dest) or display_name(plat)} reads it.") return _start_check(state) if proposed and not _exists(dest): + # Where no setup is there yet, nothing reads it: said as it is. + reads = (f", where {detected_os(plat, dest) or display_name(plat)} reads it" + if _is_dir(dest.parent) else ", a folder that does not exist yet") state.card_note = (f"{dest.name} is created in " - f"{_short_path(dest.parent, state.home)}, where " - f"{detected_os(plat, dest) or display_name(plat)} reads it.") + f"{_short_path(dest.parent, state.home)}{reads}.") return _start_check(state) if not dest.exists(): nearest = _nearest_folder(dest) @@ -15326,6 +15587,12 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: if action == "create": state.stack.pop() return _start_check(state) + if len(state.stack) == 1: + # The folder came from --dest: nothing is under this screen, and + # Back asked Quit? with Quit in focus (code_audit_20 P2-3). The + # folder screen takes its place, as the plan's row opens it. + state.stack.pop() + return _open_folder_screen(state, state.dest, "chosen", rebind=True) return _back(state) if screen == "cardroot": if action == "cardroot": @@ -15349,7 +15616,9 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: if action == "recheck": return _start_check(state) if action == "gamedata": - # One press, then counted again, as Back from Options counts. + # One press, then counted again, as Back from Options counts, + # the systems ticked kept: the check set them back to every + # system, and the install wrote all eight (tui_audit_13 P1-1). state.no_game_data = not state.no_game_data return _start_check(state) if action == "systems" and state.cli_systems: @@ -15396,6 +15665,9 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: _toggle_region(state, row.value) return [] if screen == "cores": + if action == "morecores": + state.cores_all = True + return [] return _back(state) if screen == "confirm": if action == "install": @@ -15877,7 +16149,12 @@ def _option(state: TuiState, action: str, step: int) -> list[tuple]: # 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"): + elif action == "regions": + state.region_order = [] + _push(state, action) + state.region_order = [row.value for row in tui_rows(state)] + return [] + elif action == "command": return _push(state, action) elif action == "folder": return _open_folder_screen(state, state.dest, "chosen", rebind=True) @@ -15930,12 +16207,14 @@ def _toggle_region(state: TuiState, name: str) -> None: def _move_region(state: TuiState, name: str, step: int) -> None: + """Left and Right: the region's rank, its row staying where it is.""" if name not in state.regions: return index = state.regions.index(name) target = max(0, min(len(state.regions) - 1, index + step)) state.regions.insert(target, state.regions.pop(index)) - state.focus["regions"] = target + rows = tui_rows(state) + state.focus["regions"] = next((i for i, row in enumerate(rows) if row.value == name), 0) def _begin_install(state: TuiState) -> list[tuple]: @@ -16258,6 +16537,7 @@ def tui_mouse(state: TuiState, event: TuiMouse) -> list[tuple]: hits = state.hits if (event.button & 32 or hits is None or hits.screen != state.screen or hits.help != state.help or hits.size != (state.cols, state.rows) + or hits.depth != len(state.stack) or state.cols < TUI_MIN_COLS or state.rows < TUI_MIN_ROWS or state.app_running): state.pressed = None return [] @@ -16270,11 +16550,15 @@ def tui_mouse(state: TuiState, event: TuiMouse) -> list[tuple]: state.pressed = None return [] button = event.button & 3 + # A tap is a press and a release on one target of one frame: a release + # on a frame drawn after its press is dropped, so the second tap of a + # double tap that changed the screen does nothing on the new one. + seen_on = (hits.frame, state.screen, len(state.stack), state.help, target) if not event.release: - state.pressed = (state.screen, state.help, target) if button == 0 else None + state.pressed = seen_on if button == 0 else None return [] pressed, state.pressed = state.pressed, None - if button not in (0, 3) or pressed != (state.screen, state.help, target): + if button not in (0, 3) or pressed != seen_on: return [] if state.help: # Any key closes the help, a tap too. @@ -16283,7 +16567,7 @@ def tui_mouse(state: TuiState, event: TuiMouse) -> list[tuple]: if target is None: return [] if target[0] == "key": - return tui_update(state, target[1]) + return _bar_tap(state, target[1]) if target[0] == "scroll": return _scroll_tap(state, target[1]) rows = tui_rows(state) @@ -16317,6 +16601,54 @@ def tui_mouse(state: TuiState, event: TuiMouse) -> list[tuple]: TUI_TICK_ROWS = ("system", "core", "region") +def _bar_commits(state: TuiState, key: TuiKey) -> bool: + """Whether a bar entry's key installs, quits or goes on to the last look + before an install: those take two taps, as a row does.""" + if key.name == "ctrl-c": + return True + if key.name != "enter": + return False + rows = tui_rows(state) + if not rows: + return False + row = rows[max(0, min(state.focus.get(state.screen, 0), len(rows) - 1))] + return row.action in ("install", "quit", "close") or ( + state.screen == "systems" and row.action == "system") + + +def _bar_tap(state: TuiState, key: TuiKey) -> list[tuple]: + """A tap on a bar entry. One that installs, quits or opens Confirm is + drawn armed by the first tap and pressed by the second on the same + screen: the first entry keeps its cell from screen to screen, and a + double tap installed from the plan's bar, or quit from the Checking + screen with Ctrl-C's code (tui_audit_13 P2-1).""" + if not _bar_commits(state, key): + return tui_update(state, key) + armed = ("bar", state.screen, len(state.stack), key) + if state.tapped != armed: + state.tapped = armed + return [] + state.tapped = None + if key.name == "ctrl-c": + if state.run == "install" and state.running: + return tui_interrupt(state) + # A quit chosen with a finger is a quit chosen: QUIT_CODE, never + # the code of an interrupted run. + return tui_quit(state) + return tui_update(state, key) + + +def _armed_hint(state: TuiState, parts: tuple) -> tuple: + """The bar with the entry a first tap armed saying that a second one + presses it.""" + tapped = state.tapped + if (state.help or not tapped or tapped[0] != "bar" or tapped[1] != state.screen + or tapped[2] != len(state.stack)): + return parts + return tuple(f"{part}: tap again" if _bar_click(state, part.split(" ", 1)[0]) == tapped[3] + and " " in part else part for part in parts) + + def _wheel(state: TuiState, hits: TuiHits, step: int) -> list[tuple]: """A notch of the wheel: the list, the message or the text moves, and the focus moves only when it would leave the rows in view.""" @@ -16497,8 +16829,14 @@ def tui_receive(state: TuiState, message: tuple) -> list[tuple]: if state.check is None: return _fail(state, tail, code) state.ticked = {row["system"] for row in state.systems if _needed(row)} + state.emptied = [] if state.keep_ticked is not None: state.ticked = state.keep_ticked & {row["system"] for row in state.systems} + if state.keep_ticked and not state.ticked: + # Every system chosen holds nothing under these options: + # said, never taken as every system (tui_audit_13 P1-1). + state.emptied = sorted(state.ticked_names.get(name, name) + for name in state.keep_ticked) state.keep_ticked = None state.facts = _tui_folder_facts(state) _replace(state, "plan") @@ -16522,7 +16860,8 @@ def tui_receive(state: TuiState, message: tuple) -> list[tuple]: # 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/tap ", "X/tap "), +_SHORT_KEYS = (("A/Enter/tap ", "A/tap "), ("A/Enter ", "A "), ("B/Esc ", "B "), + ("X/Space/tap ", "X/tap "), ("X/Space ", "X "), ("Y/o ", "Y "), ("Left/Right ", "<> "), ("Up/Down ", "^v ")) @@ -16586,10 +16925,11 @@ def _tap_hint(state: TuiState, parts: tuple) -> tuple: """The bar's tick entry naming the tap that ticks too, on a list of ticks where the terminal reports clicks: X/Space Tick said nothing of a finger, which went to the row and continued.""" - if not state.mouse or state.help or state.screen not in _CHECKLISTS: + if not state.mouse or state.help or state.screen not in (*_CHECKLISTS, "regions"): return parts + keys = ("A/Enter", "A") if state.screen == "regions" else ("X/Space", "Space") return tuple(f"{part.split(' ', 1)[0]}/tap {part.split(' ', 1)[1]}" - if part.endswith(" Tick") and part.split(" ", 1)[0] in ("X/Space", "Space") + if part.endswith(" Tick") and part.split(" ", 1)[0] in keys else part for part in parts) @@ -16642,7 +16982,7 @@ _ROW_VERBS = { "fromcard": "Choose", "from": "Add a folder", "misplaced": "Show", "zip": "Copy from it", "unreadable": "Show", "exit": "Exit", "addapp": "Put it on the card", "cardsystem": "Choose", "rescan": "Look again", "elsewhere": "Choose", "drive": "Choose", - "recheck": "Check again", "gamedata": "Change", "share": "Choose", + "recheck": "Check again", "gamedata": "Change", "share": "Choose", "morecores": "Show", } @@ -16799,8 +17139,21 @@ def _compose( else: budget = room - len(row_lines) top: list[TuiLine] = [] - for item in before: + for index, item in enumerate(before): lines = _text_lines([item], width) + if str(item).rstrip().endswith(":") and index + 1 < len(before): + # A line that leads into the next one gives way with it, the + # rest of a wrapped folder included: the save line ended on + # "under:" at 60x20, its folder cut (tui_audit_13 P3-7). + led = [before[index + 1]] + for more in before[index + 2:]: + if not str(more).startswith(" "): + break + led.append(more) + if len(top) + len(lines) + len(_text_lines(led, width)) > budget: + break + top += lines + continue if len(top) + len(lines) > budget: break top += lines @@ -16869,11 +17222,12 @@ def _tui_misplaced(state: TuiState) -> list[dict]: return [event for event in state.found if event["reason"]] -def _tui_found_elsewhere(state: TuiState) -> dict[str, list[dict]]: - """The files a --from folder holds, by the folder as it was named.""" +def _tui_found_elsewhere(state: TuiState, dests: set | None = None) -> dict[str, list[dict]]: + """The files a --from folder holds, by the folder as it was named, + those of dests alone when given (the files a dry run installs).""" found: dict[str, list[dict]] = {} for event in state.found: - if not event["reason"]: + if not event["reason"] and (dests is None or event["dest"] in dests): found.setdefault(event["folder"], []).append(event) return found @@ -16909,14 +17263,28 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: need = _needed(check) data = check.get("data_missing", 0) # A file found on this machine is copied, not downloaded: the count and - # the size to download leave it out, from the check's own events. - local = {event["dest"] for event in state.found} if check is state.check else set() - sizes = {event["dest"]: event["size"] for event in state.files} - copied = [dest_ for dest_ in local if dest_ in sizes] - fetch = max(0, need - len(copied)) - fetch_bytes = max(0, check["bytes_needed"] - sum(sizes[dest_] for dest_ in copied)) - elsewhere = _tui_found_elsewhere(state) if check is state.check else {} + # the size to download leave it out, from the check's own events. On + # Confirm the dry run's files are matched the same way: it called the + # files a --from folder holds downloads, and sent an offline user for + # a pack (code_audit_20 P2-2). + dry = check is state.dry and check is not state.check + sizes = {event["dest"]: event["size"] + for event in (state.dry_files if dry else state.files)} + copied = _plan_copies(state, check) if dry or check is state.check else [] + # The files the pack does not hold are said before a size is promised, + # and left out of it: the plan offered "Install 6.6 GB" from a pack that + # held none of them (tui_audit_13 P3-8). + lacking = _tui_pack_lacks(state, [dest_ for dest_ in sizes if dest_ not in copied]) + fetch = max(0, need - len(copied) - len(lacking)) + fetch_bytes = max(0, check["bytes_needed"] - sum(sizes[dest_] for dest_ in copied) + - sum(sizes[dest_] for dest_ in lacking)) + elsewhere = (_tui_found_elsewhere(state, set(copied) if dry else None) + if check is state.check or dry else {}) offline = _offline_only() + # The data files are files to fetch like the others: one count and one + # size, the kinds apart on the line under the folder. "8358 files and + # 2636 data files" read as two kinds of one thing (tui_audit_13 P3-6). + data_bytes = check.get("data_bytes", 0) if data else 0 if elsewhere: # The folder by its last name: the whole path pushed the BIOS # folder off a 40x15 screen. The Options row spells it out. @@ -16927,37 +17295,36 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: else f"{where}, {fetch} to download ({format_size(fetch_bytes)})" if fetch else f"{where}, nothing to download") elif need and fetch and offline: - headline = _plural(fetch, "file") + both = fetch + data > 1 + headline = (f"{_plural(fetch + data, 'file')} {'need' if both else 'needs'} the " + f"internet, which {offline_named()} does not use") elif need and fetch and LOCAL.pack is not None and copied: # One count: "21 files to install from the pack" above "23 to # install", and the result said 23 came from the pack, then 21. - headline = f"{_plural(need, 'file')} to install, {format_size(check['bytes_needed'])}" + held = check["bytes_needed"] - sum(sizes[dest_] for dest_ in lacking) + headline = (f"{_plural(need - len(lacking) + data, 'file')} to install, " + f"{format_size(held + data_bytes)}") elif need and fetch and LOCAL.pack is not None: # Nothing comes from the network: "to download" on a handheld with # no Wi-Fi read as a failure on its way. - headline = (f"{_plural(fetch, 'file')} to install from the pack, " - f"{format_size(fetch_bytes)}") + headline = (f"{_plural(fetch + data, 'file')} to install from the pack, " + f"{format_size(fetch_bytes + data_bytes)}") elif need and fetch: - headline = f"{_plural(fetch, 'file')} to download, {format_size(fetch_bytes)}" + headline = (f"{_plural(fetch + data, 'file')} to download, " + f"{format_size(fetch_bytes + data_bytes)}") elif need: headline = f"{_plural(len(copied), 'file')} to copy from this machine" # The data trees' size comes from their own event: "2636 data files" # beside "6.6 GB" said nothing of what they weigh. data_size = (f" ({format_size(check['data_bytes'])})" if data and "data_bytes" in check else "") - if need and fetch and offline and not elsewhere: - # The data files need the internet too: written after the reason, - # they read as if they were on hand. - both = fetch + data > 1 - headline += ((f" and {_plural(data, 'data file')}{data_size}" if data else "") - + f" {'need' if both else 'needs'} the internet, which " - f"{offline_named()} does not use") - elif need: - if data: - headline += f", and {_plural(data, 'data file')}{data_size}" + if need and data and (elsewhere or not fetch): + headline += f", and {_plural(data, 'data file')}{data_size}" if not need: headline = (f"{_plural(data, 'data file')} to fetch{data_size}" if data else "Everything is in place") + elif lacking and not fetch and not copied: + headline = pack_lacks_words(len(lacking), len(lacking)) wrong = check["wrong_hash"] offline_lines = ([_offline_remedy([_last_name(str(folder)) for folder in state.from_folders])] @@ -17039,7 +17406,12 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: held = {Path(folder).absolute() for folder in elsewhere} empty = [_last_name(str(folder)) for folder in state.from_folders if Path(folder).absolute() not in held] - if empty and offline_lines and len(empty) == len(state.from_folders): + if empty and not need: + # Every file is in place: "packx holds none of these files" read + # as an empty card, where it holds all 22 (tui_audit_13 P3-4). + offline_lines = [f"Nothing to copy from {', '.join(empty)}: every file is in " + "place."] + elif empty and offline_lines and len(empty) == len(state.from_folders): # Said once: "USBSTICK holds none of these files" was followed # by "They are not in USBSTICK". offline_lines = [f"{', '.join(empty)} {'holds' if len(empty) == 1 else 'hold'} " @@ -17048,6 +17420,8 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: else: offline_lines = [f"{name} holds none of these files." for name in empty] \ + offline_lines + if lacking and (fetch or copied): + offline_lines.insert(0, pack_lacks_words(len(lacking), len(lacking) + fetch)) # On Confirm the remedy comes right after the headline: at 40x12 it was # what got cut, Back left alone below it. On the plan it comes after the # folder and what named it: between them, Named by sat lines under its @@ -17056,6 +17430,18 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: return lines[:at] + offline_lines + lines[at:] +def _tui_pack_lacks(state: TuiState, dests: list[str]) -> list[str]: + """The destinations of a plan the pack given to the run does not hold.""" + if LOCAL.pack is None or not dests: + return [] + manifest = state.manifest or {} + entries = {str(entry.get("dest")): entry for entry in [ + *(manifest.get("files") or []), *(manifest.get("case_variants") or [])]} + held = {id(entry) for entry in pack_lacks([entries[dest] for dest in dests + if dest in entries])} + return [dest for dest in dests if dest in entries and id(entries[dest]) in held] + + def _choice_saves(state: TuiState) -> dict | None: """The check's saves event, counted again over the systems ticked while the plan estimates them: one ticked system without a save file kept @@ -17119,8 +17505,7 @@ def _saves_lines(state: TuiState, saves: dict | None, whole: bool = False) -> li def _unplaced_lines(summary: dict | None, state: TuiState, - remedy: str = "Choose it in Options, Save folder (--save-dir), " - "next time.") -> list[str]: + remedy: str = "") -> list[str]: """The files read from the frontend's save directory that the run left out, that directory being unknown: said, never silent.""" count = sum(group.get("files", 0) for group in (summary or {}).get("skipped") or [] @@ -17129,7 +17514,7 @@ def _unplaced_lines(summary: dict | None, state: TuiState, return [] name = display_name(state.platform) if state.platform else "the frontend" return [f"{_plural(count, 'file')} read from {name}'s save directory left out: " - f"its place is not known. {remedy}"] + f"its place is not known. {remedy or TUI_SAVE_REMEDY}"] def _view_setup(state: TuiState, width: int, height: int) -> tuple: @@ -17213,7 +17598,9 @@ def _view_folder(state: TuiState, width: int, height: int) -> tuple: title = "Which folder?" lead = {"found": f"{name} reads its BIOS files from:", "chosen": f"The BIOS folder chosen for {name}:"}.get( - state.folder_kind, f"{name} usually reads its BIOS files from:") + state.folder_kind, + f"{side_name(state.os_type, state.platform)} usually reads its BIOS " + "files from:") before = [lead, str(state.folder_default)] elif state.folder_kind == "closed": title = "Which folder?" @@ -17320,8 +17707,10 @@ def _view_checking(state: TuiState, width: int, height: int) -> tuple: # Not its count: the check counts the files it checks, which the # plan then gives (8179 after "File list: 8169 files"). lines += ["File list received.", _reading_line(state, elapsed)] + # Back first: a double tap on the Continue before it pressed the Quit + # drawn in the same cell (tui_audit_13 P2-1). return ("Checking", _compose(state, width, height, lines, []), - ("Ctrl-C Quit", "B/Esc Back")) + ("B/Esc Back", "Ctrl-C Quit")) def _reading_line(state: TuiState, elapsed: str) -> str: @@ -17346,7 +17735,10 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: choice = _choice_line(state) estimating = _tui_estimating(state) data, data_size = _plan_data(state, check) - if estimating: + if state.emptied and state.systems and not state.ticked: + lines = [_styled(_emptied_words(state), "title"), *lines[1:2]] + estimating = False + elif estimating: # The headline counts the systems ticked, as the list just did: at # 40x12 the line below it was dropped, and the plan showed the # total of all of them. Their data files too, as line mode's head. @@ -17385,22 +17777,30 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: *([f"{said[0].upper()}{said[1:]}."] if said else []), *choice, *([parts] if parts else [])] if state.version: - lines.append(state.version) + # Before the free space, which gives way first: appended last, the + # note was cut from the 40x12 plan with no sign (tui_audit_13 P3-1). + free = next((index for index, line in enumerate(lines) + if str(line).startswith("Free space:")), len(lines)) + lines.insert(free, state.version) if state.status: lines.append(state.status) if defined: - # Once, after the counts, the first line to give way: the help - # says it too. - lines.append(defined) - if width > 38: - # A folder that does not fit is wrapped, as the first screen wraps - # it: cut in its middle, the plan hid the part being checked before - # Install. Cut only on a 40-column screen (tui_audit_11 P3-7). - lines = [piece for line in lines for piece in ( - [_styled(part, "folder" if index and str(line).startswith(_FOLDER_LABEL) - else "") for index, part in enumerate(_wrap(str(line), width))] - if getattr(line, "style", "") == "path" and cell_width(str(line)) > width - else [line])] + # Once, after the counts and before the save line: last, it was + # the first line to give way at 80 columns, where the counts named + # data files nothing defined (tui_audit_13 P3-6). The help says it + # where the screen has no room. + saves = next((index for index, line in enumerate(lines) + if "'s save directory" in str(line)), len(lines)) + lines.insert(saves, defined) + # A folder that does not fit is wrapped, as the first screen wraps it: + # cut in its middle, the plan hid the part being checked before Install. + # At 40 columns too, broken after a separator: cut there, it was spelled + # one way on the plan and another on the result (tui_audit_13 P3-3). + lines = [piece for line in lines for piece in ( + [_styled(part, "folder" if index and str(line).startswith(_FOLDER_LABEL) + else "") for index, part in enumerate(_wrap(str(line), width))] + if getattr(line, "style", "") == "path" and cell_width(str(line)) > width + else [line])] rows = tui_rows(state) if width < 54: rows = [row._replace(label="Copy from your old card or a folder") @@ -17412,6 +17812,13 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: lines = [_styled(f"{wrong} wrong {'copy' if wrong == 1 else 'copies'}: backed up, " "replaced.") if str(line).startswith(long_form) else line for line in lines] + # Above the count it explains, which gives way first: the folder + # spelled whole takes two lines at 40 columns (tui_audit_13 P3-3). + counted = f"{check.get('present', 0)} in place, " + at = next((index for index, line in enumerate(lines) + if str(line).startswith(counted)), None) + if at is not None and at + 1 < len(lines) and "backed up, replaced" in str(lines[at + 1]): + lines[at], lines[at + 1] = lines[at + 1], lines[at] # A muOS card gets RetroArch's files: the title says both. system = detected_os(state.platform, state.dest) if state.dest is not None else "" name = display_name(state.platform, state.manifest) @@ -17448,10 +17855,29 @@ def _kinds_lines(state: TuiState, events: list[dict], data: int, if categorized and state.no_game_data: left = sum(1 for entry in _choice_entries(state) if entry.get("category") == GAME_DATA) parts = parts_words(counts, data, data_size, left) - defined = kinds_definition(categorized and bool(counts.games or left), bool(data)) + # Game data named as missing is defined too, on its own line: the + # definition was spliced into that sentence (tui_audit_13 P3-5). + missing = any(entry.get("category") == GAME_DATA for entry in _unavailable(state)[0]) + defined = kinds_definition(categorized and bool(counts.games or left or missing), + bool(data)) return (f"{parts}." if parts else ""), defined +def _screen_definition(state: TuiState) -> str: + """What the kinds the plan or Confirm counts are, as their screen says + it where there is room (kinds_definition), "" elsewhere.""" + if state.screen == "plan" and state.check: + estimating = _tui_estimating(state) + return _kinds_lines(state, _ticked_files(state) if estimating else [ + event for event in state.files if event["state"] != "present"], + *_plan_data(state, state.check))[1] + if state.screen == "confirm" and state.dry and not state.running: + data = state.dry.get("data_missing", 0) + return _kinds_lines(state, state.dry_files, data, + state.dry.get("data_bytes") if data else None)[1] + return "" + + def _counts_predate_options(state: TuiState) -> bool: """Whether the check's counts were read under other options than the ones chosen since: a region or a core changes what each system needs.""" @@ -17847,7 +18273,11 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: and not _tui_sources(state, installed, 0) else "") # The plan said the wrong copies are replaced: the result counts them. replaced = sum(kept.get("files", 0) for kept in summary.get("kept") or []) - head = (f"{_plural(installed, 'file')} installed{source}" + # One kind is named in the count, not on a line of its own: "78 + # files installed" above "Installed: 78 BIOS files" (tui_audit_13 + # P3-14). + word = "BIOS file" if _one_kind(state, installed) else "file" + head = (f"{_plural(installed, word)} installed{source}" + (f", {replaced} of them replaced" if replaced else "") + ".") if up_to_date: head += f" {_plural(up_to_date, 'file')} already in place." @@ -17864,6 +18294,16 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: if sources: lines.append(sources) if left: + # What is still missing by kind, as the copies are counted: 6 of the + # 16 were game data, and the old card's result said 16 files + # (tui_audit_13 P3-14). + ok_dests = {dest for dest, good, _why in state.finished if good} + missing = ([failure["dest"] for failure in offline_failed] + or [event["dest"] for event in state.files + if event["dest"] not in ok_dests][:left]) + apart = _tui_tally(state, [{"dest": dest, "size": 0} for dest in missing]) + if _tui_categories(state) and apart.bios and apart.games: + lines.append(f"Still missing: {installed_words(apart.bios, apart.games)}.") # Each folder spelled in full, as the result names its others. lines.append(_offline_remedy([shown_path(folder) for folder in state.from_folders])) ok = {dest for dest, good, _why in state.finished if good} @@ -17876,7 +18316,7 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: # files are the installed ones no file event names. done = _tui_tally(state, [{"dest": dest, "size": 0} for dest, good, _why in state.finished if good]) - if done.files: + if done.files and not (title == "Done" and _one_kind(state, installed)): lines.append("Installed: " + installed_words( done.bios, done.games, max(0, installed - done.files)) + ".") # The folder the plan named: MinUI's Bios, not the card's root. @@ -17939,9 +18379,28 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: lines += ["Same choice without questions:", _styled(f" {tui_command(state)}", "command")] if tui_keeps_log(state): lines.append(f"Details: {state.log}") + if state.mouse and typed: + # A drag over the command selected nothing: the terminal sends the + # press as a report. Said where it is read (tui_audit_13 P3-10). + lines.append(_styled(TUI_RESULT_COPY, "dim")) return title, lines +# How to copy from the result, under its command while the mouse is on. +TUI_RESULT_COPY = ("Shift and a drag select text. All of this is shown again in the " + "terminal after Exit.") + + +def _one_kind(state: TuiState, installed: int) -> bool: + """Whether every file a run installed is a BIOS file of a list that + marks its kinds: the count then names the kind.""" + if not _tui_categories(state): + return False + done = _tui_tally(state, [{"dest": dest, "size": 0} + for dest, good, _why in state.finished if good]) + return bool(done.files) and done.files == installed and not done.games + + def _tui_app_lines(state: TuiState) -> list[str]: """What became of the RetroBIOS app on the card, once the install finished.""" card = _tui_card(state) @@ -18063,6 +18522,9 @@ def _help_lines(state: TuiState) -> list[tuple[str, str]]: keys.append((ok, primary.split(" ", 1)[-1])) if screen in _CHECKLISTS: keys.append((names["tick"], "Tick, untick")) + # How a tap acts, before what the list says elsewhere: last, it was the + # line a 40x12 help cut (code_audit_20 P3-1). + keys += _tap_help(state) if screen == "systems": keys.append(("a, n", "All, none")) if screen == "platforms": @@ -18079,7 +18541,7 @@ def _help_lines(state: TuiState) -> list[tuple[str, str]]: keys.append((names["options"], "Options")) back = "Quit" if len(state.stack) == 1 else "Back" keys += [(names["back"], back), ("q, Ctrl-C", "Quit"), *kill, *_route_extras(state), - *_help_key(state), *_tap_help(state)] + *_help_key(state)] return keys @@ -18087,10 +18549,15 @@ def _tap_help(state: TuiState) -> list[tuple[str, str]]: """How a tap acts on this screen, where the terminal reports clicks.""" if not state.mouse: return [] - if state.screen in _CHECKLISTS or state.screen == "regions": + if state.screen in _CHECKLISTS: return [("Tap", "Tick or untick a row, as Space")] + if state.screen == "regions": + # Its keys name no Space: A ticks there (code_audit_20 P3-2). + return [("Tap", "Tick or untick a row, as A")] if state.screen in _PAGERS and not tui_rows(state): - return [("Tap", "A bottom line entry presses its key")] + return [("Tap", "A bottom line entry presses its key"), + *([("Shift+drag", "Select text to copy")] if state.screen == "result" + else [])] return [("Tap", "Choose a row, tap it again to open it")] @@ -18140,17 +18607,23 @@ def _view_help(state: TuiState, width: int, height: int) -> tuple: if state.screen in ("setup", "plan", "browse", "folder") and state.dest is not None: extra = ["", f"BIOS folder: {shown_path(state.dest)}"] more = ["", state.status_more] if state.status_more else [] + # What the plan's kinds are: at 40 columns the plan has no room for the + # sentence, and the game data row was a choice nothing explained + # (tui_audit_13 P3-2). Said before the keys a list also says elsewhere. + words = _screen_definition(state) + defined = ["", *textwrap.wrap(words, width)] if words else [] # Cut from its end at 40x12, the help lost Quit and This help with no # sign: what a list's keys also say elsewhere goes first. for spare in ("First, last", "All, none", "extra", "Filter by setup or device name", - "Choose a row, tap it again to open it"): - if len(drawn(keys)) + len(extra) + len(_text_lines(more, width)) <= height: + "Filter", "Options", "Choose a row, tap it again to open it", "This help"): + if (len(drawn(keys)) + len(extra) + len(defined) + + len(_text_lines(more, width)) <= height): break if spare == "extra": extra = [] else: keys = [entry for entry in keys if entry[1] != spare] - return ("Keys", _compose(state, width, height, drawn(keys) + extra + more, []), + return ("Keys", _compose(state, width, height, drawn(keys) + defined + extra + more, []), ("Any key or a tap closes this help" if state.mouse else "Any key closes this help",)) @@ -18179,7 +18652,14 @@ def tui_view(state: TuiState, cols: int, rows: int) -> list[str]: # Typing: Space and ? go into the filter, so the bar names the two # keys that end it, not Tick and Help. hint = ("Enter Done", "Esc Clear") - hint = _tap_hint(state, _route_hint(state, _back_hint(state, hint))) + routed = _route_hint(state, _back_hint(state, hint)) + tapped = _tap_hint(state, routed) + if (tapped != routed and "? Help" in _hint(width, *routed) + and "? Help" not in _hint(width, *tapped)): + # At 40 columns "/tap" pushed "? Help" off the bar: the help, which + # says how a tap acts, keeps its entry (code_audit_20 P3-1). + tapped = routed + hint = _armed_hint(state, tapped) lines = [_styled(fit(f" RetroBIOS: {title}", cols), "head"), _styled("")] # A long line is drawn whole, the terminal folding it into the rows # after it (_text_lines): cut to the width, a copied path lost its end. @@ -18210,6 +18690,10 @@ class TuiHits(NamedTuple): targets: tuple listing: tuple | None = None scroll: str = "" + # How many screens deep the frame was drawn, and its number: a frame + # whose targets differ from the one before it is another frame. + depth: int = 0 + frame: int = 0 def at(self, col: int, line: int) -> tuple | None: return next((target for row, first, end, target in self.targets @@ -18226,7 +18710,7 @@ _BAR_CLICKS = { "Space": TuiKey("char", " "), "Y/o": TuiKey("char", "o"), "Y": TuiKey("char", "o"), "o": TuiKey("char", "o"), "/": TuiKey("char", "/"), "Ctrl-U": TuiKey("ctrl-u"), "X/Space/tap": TuiKey("char", " "), "X/tap": TuiKey("char", " "), - "Space/tap": TuiKey("char", " "), + "Space/tap": TuiKey("char", " "), "A/Enter/tap": TuiKey("enter"), "A/tap": TuiKey("enter"), "Ctrl-C": TuiKey("ctrl-c"), # F1 and not ?, which the path screen types into the path. "?": TuiKey("f1"), @@ -18270,8 +18754,12 @@ def tui_hits(state: TuiState, lines: list[str]) -> TuiHits: if key is not None: targets.append((number, entry.start(), entry.end(), ("key", key))) rows = (min(shown), len(shown), listed) if listed is not None and shown else None - return TuiHits(state.screen, state.help, (state.cols, state.rows), tuple(targets), - rows, scroll) + hits = TuiHits(state.screen, state.help, (state.cols, state.rows), tuple(targets), + rows, scroll, len(state.stack)) + before = state.hits + same = before is not None and before[:7] == hits[:7] + return hits._replace(frame=before.frame if same else + (before.frame + 1 if before is not None else 0)) def _setup_label(plat: str, path: Path) -> str: @@ -18883,6 +19371,16 @@ def tui_idle_code(state: TuiState) -> int: def _farewell_lines(state: TuiState) -> list[str]: + lines = _said_lines(state) + # The version note in every farewell of a run that read it: with + # everything in place it was the one thing left to say, and the + # farewell said only that (tui_audit_13 P3-1). + if state.version and state.screen != "crashed" and state.version not in lines: + lines.append(state.version) + return lines + + +def _said_lines(state: TuiState) -> list[str]: if not state.idle_closed: return _closing_lines(state) idle = f"RetroBIOS: {TUI_IDLE_WORDS}" @@ -18896,12 +19394,12 @@ def _closing_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] + return [f"RetroBIOS: {title}", *_terminal_words(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] + return [f"RetroBIOS: {title}", *_terminal_words(lines)] if state.screen == "error": return [f"RetroBIOS: {state.error_title or 'something went wrong'}", *(line.replace("then choose Try again", "then run the installer again") @@ -18915,6 +19413,18 @@ def _closing_lines(state: TuiState) -> list[str]: TUI_QUIT_WORDS = "RetroBIOS: quit. Nothing was installed." +# The result's remedy for the save files left out, as the terminal says it +# once the screen is gone: Options are not there to choose from +# (code_audit_20 P3-4). Line mode's words. +TUI_SAVE_REMEDY = "Choose it in Options, Save folder (--save-dir), next time." +LINE_SAVE_REMEDY = "Name it with --save-dir next time." + + +def _terminal_words(lines: list[str]) -> list[str]: + """The result's lines as the terminal prints them after the screen.""" + return [line.replace(TUI_SAVE_REMEDY, LINE_SAVE_REMEDY) for line in lines + if line != TUI_RESULT_COPY] + def tui_quit_chosen(state: TuiState) -> bool: """Whether the screen ended on a quit the user chose, nothing installed.""" diff --git a/install.sh b/install.sh index 8afd6013e..11620c1bc 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="7029f605591f09c020aafcd521d8a7134ace78cc566435d8e76be33c42a9f6dc" +DEFAULT_INSTALL_SHA256="0165a8f39265725aa70cd19a7966c1e228fd9df480fb05d7338ca24530c26762" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index f095b7b4e..39578db5d 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 881442, - "sha256": "7029f605591f09c020aafcd521d8a7134ace78cc566435d8e76be33c42a9f6dc", + "size": 909943, + "sha256": "0165a8f39265725aa70cd19a7966c1e228fd9df480fb05d7338ca24530c26762", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 934234, - "sha256": "56675adcad36deb03879b7f5961203f6c65cc2f4f13170f6d38b37ed1fe5b367" + "size": 962735, + "sha256": "26c35ae513bf9ae770f9e0316a9f0000b57bcc24c054691143361c7f77e1f252" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 881442, - "sha256": "7029f605591f09c020aafcd521d8a7134ace78cc566435d8e76be33c42a9f6dc", + "size": 909943, + "sha256": "0165a8f39265725aa70cd19a7966c1e228fd9df480fb05d7338ca24530c26762", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 881442, - "sha256": "7029f605591f09c020aafcd521d8a7134ace78cc566435d8e76be33c42a9f6dc", + "size": 909943, + "sha256": "0165a8f39265725aa70cd19a7966c1e228fd9df480fb05d7338ca24530c26762", "executable": false }, { diff --git a/tests/fixtures/tui/checking-120x40.txt b/tests/fixtures/tui/checking-120x40.txt index a0eecdd76..09375c786 100644 --- a/tests/fixtures/tui/checking-120x40.txt +++ b/tests/fixtures/tui/checking-120x40.txt @@ -37,4 +37,4 @@ - Ctrl-C Quit B/Esc Quit + B/Esc Quit Ctrl-C Quit diff --git a/tests/fixtures/tui/checking-40x12.txt b/tests/fixtures/tui/checking-40x12.txt index 599dd3dc1..d35ce80ea 100644 --- a/tests/fixtures/tui/checking-40x12.txt +++ b/tests/fixtures/tui/checking-40x12.txt @@ -9,4 +9,4 @@ - Ctrl-C Quit B Quit + B Quit Ctrl-C Quit diff --git a/tests/fixtures/tui/checking-40x15.txt b/tests/fixtures/tui/checking-40x15.txt index 5345d7513..2523f7aef 100644 --- a/tests/fixtures/tui/checking-40x15.txt +++ b/tests/fixtures/tui/checking-40x15.txt @@ -12,4 +12,4 @@ - Ctrl-C Quit B Quit + B Quit Ctrl-C Quit diff --git a/tests/fixtures/tui/checking-80x24.txt b/tests/fixtures/tui/checking-80x24.txt index 0f2c59388..1a6aa694b 100644 --- a/tests/fixtures/tui/checking-80x24.txt +++ b/tests/fixtures/tui/checking-80x24.txt @@ -21,4 +21,4 @@ - Ctrl-C Quit B/Esc Quit + B/Esc Quit Ctrl-C Quit diff --git a/tests/fixtures/tui/colour-120x40.txt b/tests/fixtures/tui/colour-120x40.txt index c1b3e5023..43e542aa8 100644 --- a/tests/fixtures/tui/colour-120x40.txt +++ b/tests/fixtures/tui/colour-120x40.txt @@ -325,7 +325,7 @@ - ^[[0;1;35mCtrl-C^[[0m Quit ^[[0;2mB/Esc^[[0m Quit + ^[[0;1;35mB/Esc^[[0m Quit ^[[0;2mCtrl-C^[[0m Quit === plan ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m diff --git a/tests/fixtures/tui/colour-40x12.txt b/tests/fixtures/tui/colour-40x12.txt index 7a36c05a1..de15134f9 100644 --- a/tests/fixtures/tui/colour-40x12.txt +++ b/tests/fixtures/tui/colour-40x12.txt @@ -101,13 +101,13 @@ - ^[[0;1;35mCtrl-C^[[0m Quit ^[[0;2mB^[[0m Quit + ^[[0;1;35mB^[[0m Quit ^[[0;2mCtrl-C^[[0m Quit === plan ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m ^[[0;1m ^[[0;1;35m4^[[0;1m files to download, ^[[0;1;35m1.0 MB^[[0m - BIOS folder: ^[[0;1m~/.co.../retroarch/system^[[0m - ^[[0;32m1 in place^[[0m, ^[[0;33m3 missing^[[0m, ^[[0;33m1 wrong copy^[[0m + BIOS folder: + ^[[0;1m~/.config/retroarch/system^[[0m ^[[0;33m1 wrong copy^[[0m: backed up, replaced. ^[[0;7;35m > Install^[[0m Choose systems @@ -119,14 +119,14 @@ ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m ^[[0;1m ^[[0;1;32mEverything is in place^[[0m - BIOS folder: ^[[0;1m~/.co.../retroarch/system^[[0m + BIOS folder: + ^[[0;1m~/.config/retroarch/system^[[0m ^[[0;32m5 in place^[[0m, 0 missing, 0 wrong copies Free space: 50.0 GB ^[[0;7;35m > Close^[[0m Options - ^[[0;1;35mA^[[0m Close ^[[0;2mB^[[0m Back ^[[0;2mY^[[0m Options ^[[0;2m?^[[0m Help === systems ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Choose systems^[[0m @@ -240,8 +240,8 @@ ^[[0;1m/home/user/.config/retroarch/system^[[0m ^[[0;1;31mfailed:^[[0m scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - + /home/user/.config/retroarch/ + retrobios-backup/2026-10-23_1200 To put them back: python3 install.py ^[[0;2m 1-8 of 16^[[0m ^[[0;1;35mA^[[0m Exit ^[[0;2m^v^[[0m Scroll diff --git a/tests/fixtures/tui/colour-40x15.txt b/tests/fixtures/tui/colour-40x15.txt index d5a675117..1b2064684 100644 --- a/tests/fixtures/tui/colour-40x15.txt +++ b/tests/fixtures/tui/colour-40x15.txt @@ -125,14 +125,15 @@ - ^[[0;1;35mCtrl-C^[[0m Quit ^[[0;2mB^[[0m Quit + ^[[0;1;35mB^[[0m Quit ^[[0;2mCtrl-C^[[0m Quit === plan ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m ^[[0;1m ^[[0;1;35m4^[[0;1m files to download, ^[[0;1;35m1.0 MB^[[0m - BIOS folder: ^[[0;1m~/.co.../retroarch/system^[[0m - ^[[0;32m1 in place^[[0m, ^[[0;33m3 missing^[[0m, ^[[0;33m1 wrong copy^[[0m + BIOS folder: + ^[[0;1m~/.config/retroarch/system^[[0m ^[[0;33m1 wrong copy^[[0m: backed up, replaced. + ^[[0;32m1 in place^[[0m, ^[[0;33m3 missing^[[0m, ^[[0;33m1 wrong copy^[[0m Free space: 50.0 GB ^[[0;7;35m > Install^[[0m @@ -140,13 +141,13 @@ Copy from your old card or a folder Options Quit - ^[[0;1;35mA^[[0m Install ^[[0;2mB^[[0m Back ^[[0;2mY^[[0m Options ^[[0;2m?^[[0m Help === plan-in-place ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m ^[[0;1m ^[[0;1;32mEverything is in place^[[0m - BIOS folder: ^[[0;1m~/.co.../retroarch/system^[[0m + BIOS folder: + ^[[0;1m~/.config/retroarch/system^[[0m ^[[0;32m5 in place^[[0m, 0 missing, 0 wrong copies Free space: 50.0 GB @@ -156,7 +157,6 @@ - ^[[0;1;35mA^[[0m Close ^[[0;2mB^[[0m Back ^[[0;2mY^[[0m Options ^[[0;2m?^[[0m Help === systems ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Choose systems^[[0m @@ -294,8 +294,8 @@ ^[[0;1m/home/user/.config/retroarch/system^[[0m ^[[0;1;31mfailed:^[[0m scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - + /home/user/.config/retroarch/ + retrobios-backup/2026-10-23_1200 To put them back: python3 install.py --restore /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 diff --git a/tests/fixtures/tui/colour-80x24.txt b/tests/fixtures/tui/colour-80x24.txt index 2b194ae3d..8bf355418 100644 --- a/tests/fixtures/tui/colour-80x24.txt +++ b/tests/fixtures/tui/colour-80x24.txt @@ -197,7 +197,7 @@ - ^[[0;1;35mCtrl-C^[[0m Quit ^[[0;2mB/Esc^[[0m Quit + ^[[0;1;35mB/Esc^[[0m Quit ^[[0;2mCtrl-C^[[0m Quit === plan ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m diff --git a/tests/fixtures/tui/plan-40x12.txt b/tests/fixtures/tui/plan-40x12.txt index c4c967a9d..1bf24ccb2 100644 --- a/tests/fixtures/tui/plan-40x12.txt +++ b/tests/fixtures/tui/plan-40x12.txt @@ -1,8 +1,8 @@ RetroBIOS: RetroArch 4 files to download, 1.0 MB - BIOS folder: ~/.co.../retroarch/system - 1 in place, 3 missing, 1 wrong copy + BIOS folder: + ~/.config/retroarch/system 1 wrong copy: backed up, replaced. > Install Choose systems diff --git a/tests/fixtures/tui/plan-40x15.txt b/tests/fixtures/tui/plan-40x15.txt index fff88bc27..246ecf5bf 100644 --- a/tests/fixtures/tui/plan-40x15.txt +++ b/tests/fixtures/tui/plan-40x15.txt @@ -1,9 +1,10 @@ RetroBIOS: RetroArch 4 files to download, 1.0 MB - BIOS folder: ~/.co.../retroarch/system - 1 in place, 3 missing, 1 wrong copy + BIOS folder: + ~/.config/retroarch/system 1 wrong copy: backed up, replaced. + 1 in place, 3 missing, 1 wrong copy Free space: 50.0 GB > Install @@ -11,5 +12,4 @@ Copy from your old card or a folder Options Quit - 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 0bd14655a..e5768b6f9 100644 --- a/tests/fixtures/tui/plan-in-place-40x12.txt +++ b/tests/fixtures/tui/plan-in-place-40x12.txt @@ -1,12 +1,12 @@ RetroBIOS: RetroArch Everything is in place - BIOS folder: ~/.co.../retroarch/system + BIOS folder: + ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies Free space: 50.0 GB > Close 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 f4907958a..757eb2c59 100644 --- a/tests/fixtures/tui/plan-in-place-40x15.txt +++ b/tests/fixtures/tui/plan-in-place-40x15.txt @@ -1,7 +1,8 @@ RetroBIOS: RetroArch Everything is in place - BIOS folder: ~/.co.../retroarch/system + BIOS folder: + ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies Free space: 50.0 GB @@ -11,5 +12,4 @@ - A Close B Back Y Options ? Help diff --git a/tests/fixtures/tui/result-40x12.txt b/tests/fixtures/tui/result-40x12.txt index b9050871d..aacffd558 100644 --- a/tests/fixtures/tui/result-40x12.txt +++ b/tests/fixtures/tui/result-40x12.txt @@ -5,8 +5,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - + /home/user/.config/retroarch/ + retrobios-backup/2026-10-23_1200 To put them back: python3 install.py 1-8 of 16 A Exit ^v Scroll diff --git a/tests/fixtures/tui/result-40x15.txt b/tests/fixtures/tui/result-40x15.txt index 6f1adffc0..3b7775d28 100644 --- a/tests/fixtures/tui/result-40x15.txt +++ b/tests/fixtures/tui/result-40x15.txt @@ -5,8 +5,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - + /home/user/.config/retroarch/ + retrobios-backup/2026-10-23_1200 To put them back: python3 install.py --restore /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 diff --git a/tests/fixtures/tui/route-evmapy.txt b/tests/fixtures/tui/route-evmapy.txt index db9c01545..d43544f91 100644 --- a/tests/fixtures/tui/route-evmapy.txt +++ b/tests/fixtures/tui/route-evmapy.txt @@ -405,8 +405,8 @@ RetroBIOS: RetroArch 4 files to download, 1.0 MB - BIOS folder: ~/.co.../retroarch/system - 1 in place, 3 missing, 1 wrong copy + BIOS folder: + ~/.config/retroarch/system 1 wrong copy: backed up, replaced. > Install Choose systems @@ -418,9 +418,10 @@ RetroBIOS: RetroArch 4 files to download, 1.0 MB - BIOS folder: ~/.co.../retroarch/system - 1 in place, 3 missing, 1 wrong copy + BIOS folder: + ~/.config/retroarch/system 1 wrong copy: backed up, replaced. + 1 in place, 3 missing, 1 wrong copy Free space: 50.0 GB > Install @@ -428,7 +429,6 @@ Copy from your old card or a folder Options Quit - A Install B Back Y Options === plan 80x24 RetroBIOS: RetroArch @@ -459,20 +459,21 @@ RetroBIOS: RetroArch Everything is in place - BIOS folder: ~/.co.../retroarch/system + BIOS folder: + ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies Free space: 50.0 GB > Close Options - A Close B Back Y Options === plan-in-place 40x15 RetroBIOS: RetroArch Everything is in place - BIOS folder: ~/.co.../retroarch/system + BIOS folder: + ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies Free space: 50.0 GB @@ -482,7 +483,6 @@ - A Close B Back Y Options === plan-in-place 80x24 RetroBIOS: RetroArch @@ -891,8 +891,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - + /home/user/.config/retroarch/ + retrobios-backup/2026-10-23_1200 Details: /tmp/retrobios-x/install.log A Exit @@ -904,8 +904,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - + /home/user/.config/retroarch/ + retrobios-backup/2026-10-23_1200 Details: /tmp/retrobios-x/install.log diff --git a/tests/fixtures/tui/route-gptokeyb.txt b/tests/fixtures/tui/route-gptokeyb.txt index 5f9ba3a01..4e688a03b 100644 --- a/tests/fixtures/tui/route-gptokeyb.txt +++ b/tests/fixtures/tui/route-gptokeyb.txt @@ -359,7 +359,7 @@ - Select+Start Quit B Quit + B Quit Select+Start Quit === checking 40x15 RetroBIOS: Checking @@ -375,7 +375,7 @@ - Select+Start Quit B Quit + B Quit Select+Start Quit === checking 80x24 RetroBIOS: Checking @@ -400,13 +400,13 @@ - Select+Start Quit B Quit + B Quit Select+Start Quit === plan 40x12 RetroBIOS: RetroArch 4 files to download, 1.0 MB - BIOS folder: ~/.co.../retroarch/system - 1 in place, 3 missing, 1 wrong copy + BIOS folder: + ~/.config/retroarch/system 1 wrong copy: backed up, replaced. > Install Choose systems @@ -418,9 +418,10 @@ RetroBIOS: RetroArch 4 files to download, 1.0 MB - BIOS folder: ~/.co.../retroarch/system - 1 in place, 3 missing, 1 wrong copy + BIOS folder: + ~/.config/retroarch/system 1 wrong copy: backed up, replaced. + 1 in place, 3 missing, 1 wrong copy Free space: 50.0 GB > Install @@ -428,7 +429,6 @@ Copy from your old card or a folder Options Quit - A Install B Back Y Options === plan 80x24 RetroBIOS: RetroArch @@ -459,20 +459,21 @@ RetroBIOS: RetroArch Everything is in place - BIOS folder: ~/.co.../retroarch/system + BIOS folder: + ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies Free space: 50.0 GB > Close Options - A Close B Back Y Options === plan-in-place 40x15 RetroBIOS: RetroArch Everything is in place - BIOS folder: ~/.co.../retroarch/system + BIOS folder: + ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies Free space: 50.0 GB @@ -482,7 +483,6 @@ - A Close B Back Y Options === plan-in-place 80x24 RetroBIOS: RetroArch @@ -891,8 +891,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - + /home/user/.config/retroarch/ + retrobios-backup/2026-10-23_1200 Details: /tmp/retrobios-x/install.log A Exit @@ -904,8 +904,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - + /home/user/.config/retroarch/ + retrobios-backup/2026-10-23_1200 Details: /tmp/retrobios-x/install.log diff --git a/tests/fixtures/tui/route-gptokeyb_muxterm.txt b/tests/fixtures/tui/route-gptokeyb_muxterm.txt index 8e7658a13..fec9d3208 100644 --- a/tests/fixtures/tui/route-gptokeyb_muxterm.txt +++ b/tests/fixtures/tui/route-gptokeyb_muxterm.txt @@ -359,7 +359,7 @@ - Select+Start Quit B Quit + B Quit Select+Start Quit === checking 40x15 RetroBIOS: Checking @@ -375,7 +375,7 @@ - Select+Start Quit B Quit + B Quit Select+Start Quit === checking 80x24 RetroBIOS: Checking @@ -400,13 +400,13 @@ - Select+Start Quit B Quit + B Quit Select+Start Quit === plan 40x12 RetroBIOS: RetroArch 4 files to download, 1.0 MB - BIOS folder: ~/.co.../retroarch/system - 1 in place, 3 missing, 1 wrong copy + BIOS folder: + ~/.config/retroarch/system 1 wrong copy: backed up, replaced. > Install Choose systems @@ -418,9 +418,10 @@ RetroBIOS: RetroArch 4 files to download, 1.0 MB - BIOS folder: ~/.co.../retroarch/system - 1 in place, 3 missing, 1 wrong copy + BIOS folder: + ~/.config/retroarch/system 1 wrong copy: backed up, replaced. + 1 in place, 3 missing, 1 wrong copy Free space: 50.0 GB > Install @@ -428,7 +429,6 @@ Copy from your old card or a folder Options Quit - A Install B Back Y Options === plan 80x24 RetroBIOS: RetroArch @@ -459,20 +459,21 @@ RetroBIOS: RetroArch Everything is in place - BIOS folder: ~/.co.../retroarch/system + BIOS folder: + ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies Free space: 50.0 GB > Close Options - A Close B Back Y Options === plan-in-place 40x15 RetroBIOS: RetroArch Everything is in place - BIOS folder: ~/.co.../retroarch/system + BIOS folder: + ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies Free space: 50.0 GB @@ -482,7 +483,6 @@ - A Close B Back Y Options === plan-in-place 80x24 RetroBIOS: RetroArch @@ -891,8 +891,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - + /home/user/.config/retroarch/ + retrobios-backup/2026-10-23_1200 Details: /tmp/retrobios-x/install.log A Exit @@ -904,8 +904,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - + /home/user/.config/retroarch/ + retrobios-backup/2026-10-23_1200 Details: /tmp/retrobios-x/install.log diff --git a/tests/fixtures/tui/route-keys.txt b/tests/fixtures/tui/route-keys.txt index 885c857ab..74169d344 100644 --- a/tests/fixtures/tui/route-keys.txt +++ b/tests/fixtures/tui/route-keys.txt @@ -334,7 +334,7 @@ - Ctrl-C Quit Esc Quit + Esc Quit Ctrl-C Quit === checking 40x15 RetroBIOS: Checking @@ -350,7 +350,7 @@ - Ctrl-C Quit Esc Quit + Esc Quit Ctrl-C Quit === checking 80x24 RetroBIOS: Checking @@ -375,13 +375,13 @@ - Ctrl-C Quit Esc Quit + Esc Quit Ctrl-C Quit === plan 40x12 RetroBIOS: RetroArch 4 files to download, 1.0 MB - BIOS folder: ~/.co.../retroarch/system - 1 in place, 3 missing, 1 wrong copy + BIOS folder: + ~/.config/retroarch/system 1 wrong copy: backed up, replaced. > Install Choose systems @@ -393,9 +393,10 @@ RetroBIOS: RetroArch 4 files to download, 1.0 MB - BIOS folder: ~/.co.../retroarch/system - 1 in place, 3 missing, 1 wrong copy + BIOS folder: + ~/.config/retroarch/system 1 wrong copy: backed up, replaced. + 1 in place, 3 missing, 1 wrong copy Free space: 50.0 GB > Install @@ -403,7 +404,6 @@ Copy from your old card or a folder Options Quit - Enter Install Esc Back o Options === plan 80x24 RetroBIOS: RetroArch @@ -434,20 +434,21 @@ RetroBIOS: RetroArch Everything is in place - BIOS folder: ~/.co.../retroarch/system + BIOS folder: + ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies Free space: 50.0 GB > Close Options - Enter Close Esc Back o Options === plan-in-place 40x15 RetroBIOS: RetroArch Everything is in place - BIOS folder: ~/.co.../retroarch/system + BIOS folder: + ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies Free space: 50.0 GB @@ -457,7 +458,6 @@ - Enter Close Esc Back o Options === plan-in-place 80x24 RetroBIOS: RetroArch @@ -924,8 +924,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - + /home/user/.config/retroarch/ + retrobios-backup/2026-10-23_1200 To put them back: python3 install.py 1-8 of 16 Enter Exit ^v Scroll @@ -937,8 +937,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - + /home/user/.config/retroarch/ + retrobios-backup/2026-10-23_1200 To put them back: python3 install.py --restore /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 diff --git a/tests/fixtures/tui/route-miyoo.txt b/tests/fixtures/tui/route-miyoo.txt index 89e4c8bef..ca3a944af 100644 --- a/tests/fixtures/tui/route-miyoo.txt +++ b/tests/fixtures/tui/route-miyoo.txt @@ -434,8 +434,8 @@ RetroBIOS: RetroArch 4 files to download, 1.0 MB - BIOS folder: ~/.co.../retroarch/system - 1 in place, 3 missing, 1 wrong copy + BIOS folder: + ~/.config/retroarch/system 1 wrong copy: backed up, replaced. > Install Choose systems @@ -447,9 +447,10 @@ RetroBIOS: RetroArch 4 files to download, 1.0 MB - BIOS folder: ~/.co.../retroarch/system - 1 in place, 3 missing, 1 wrong copy + BIOS folder: + ~/.config/retroarch/system 1 wrong copy: backed up, replaced. + 1 in place, 3 missing, 1 wrong copy Free space: 50.0 GB > Install @@ -457,7 +458,6 @@ Copy from your old card or a folder Options Quit - A Install R2 Back L2 Options === plan 80x24 RetroBIOS: RetroArch @@ -488,20 +488,21 @@ RetroBIOS: RetroArch Everything is in place - BIOS folder: ~/.co.../retroarch/system + BIOS folder: + ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies Free space: 50.0 GB > Close Options - A Close R2 Back L2 Options === plan-in-place 40x15 RetroBIOS: RetroArch Everything is in place - BIOS folder: ~/.co.../retroarch/system + BIOS folder: + ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies Free space: 50.0 GB @@ -511,7 +512,6 @@ - A Close R2 Back L2 Options === plan-in-place 80x24 RetroBIOS: RetroArch @@ -920,8 +920,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - + /home/user/.config/retroarch/ + retrobios-backup/2026-10-23_1200 Details: /tmp/retrobios-x/install.log A Exit @@ -933,8 +933,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - + /home/user/.config/retroarch/ + retrobios-backup/2026-10-23_1200 Details: /tmp/retrobios-x/install.log diff --git a/tests/fixtures/tui/route-muxterm.txt b/tests/fixtures/tui/route-muxterm.txt index 64db33553..d02b5a181 100644 --- a/tests/fixtures/tui/route-muxterm.txt +++ b/tests/fixtures/tui/route-muxterm.txt @@ -388,7 +388,7 @@ - Menu Quit B Quit + B Quit Menu Quit === checking 40x15 RetroBIOS: Checking @@ -404,7 +404,7 @@ - Menu Quit B Quit + B Quit Menu Quit === checking 80x24 RetroBIOS: Checking @@ -429,13 +429,13 @@ - Menu Quit B Quit + B Quit Menu Quit === plan 40x12 RetroBIOS: RetroArch 4 files to download, 1.0 MB - BIOS folder: ~/.co.../retroarch/system - 1 in place, 3 missing, 1 wrong copy + BIOS folder: + ~/.config/retroarch/system 1 wrong copy: backed up, replaced. > Install Choose systems @@ -447,9 +447,10 @@ RetroBIOS: RetroArch 4 files to download, 1.0 MB - BIOS folder: ~/.co.../retroarch/system - 1 in place, 3 missing, 1 wrong copy + BIOS folder: + ~/.config/retroarch/system 1 wrong copy: backed up, replaced. + 1 in place, 3 missing, 1 wrong copy Free space: 50.0 GB > Install @@ -457,7 +458,6 @@ Copy from your old card or a folder Options Quit - Start Install B Back === plan 80x24 RetroBIOS: RetroArch @@ -488,20 +488,21 @@ RetroBIOS: RetroArch Everything is in place - BIOS folder: ~/.co.../retroarch/system + BIOS folder: + ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies Free space: 50.0 GB > Close Options - Start Close B Back === plan-in-place 40x15 RetroBIOS: RetroArch Everything is in place - BIOS folder: ~/.co.../retroarch/system + BIOS folder: + ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies Free space: 50.0 GB @@ -511,7 +512,6 @@ - Start Close B Back === plan-in-place 80x24 RetroBIOS: RetroArch @@ -978,8 +978,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - + /home/user/.config/retroarch/ + retrobios-backup/2026-10-23_1200 Details: /tmp/retrobios-x/install.log Start Exit @@ -991,8 +991,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - + /home/user/.config/retroarch/ + retrobios-backup/2026-10-23_1200 Details: /tmp/retrobios-x/install.log diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index bbcf0fb0e..8dc06ad8e 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -1241,7 +1241,7 @@ class OfflineWithNothingFound(unittest.TestCase): said = proc.stdout + proc.stderr self.assertEqual(proc.returncode, 1, said) self.assertNotIn("emudeck", said.lower()) - self.assertIn("Use --platform and --dest", said) + self.assertIn("Name the setup with --platform and its folder with --dest", said) class TheDryRunWords(unittest.TestCase): diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 9494cc670..baef612df 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -381,6 +381,16 @@ def checked_state(facts: dict | None = None) -> install.TuiState: return state +class HoldingPack: + """A pack given to the run that holds every file asked of it.""" + + name = "RetroArch_BIOS_Pack.zip" + + @staticmethod + def find(entry: dict) -> tuple: + return (None, entry) + + def press(state: install.TuiState, *pressed) -> list: effects = [] for key in pressed: @@ -496,7 +506,10 @@ class Flow(unittest.TestCase): self.assertEqual(state.regions, ["japan", "north-america"]) press(state, "left") self.assertEqual(state.regions, ["north-america", "japan"]) - self.assertEqual(state.focus["regions"], 0) + # The rank moves, the row stays where it is, under the focus. + self.assertEqual(state.focus["regions"], 1) + self.assertEqual([row.label for row in install.tui_rows(state)], + ["[2] Japan", "[1] North America"]) press(state, "shift-down") self.assertEqual(state.regions, ["japan", "north-america"]) press(state, "esc", "down", "enter", "down", "right", "down", "down", "right", "right") @@ -1113,8 +1126,12 @@ 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() + # Checking has no A: Back comes first, Quit never in the + # cell the screen before had Continue in. self.assertTrue(hint.startswith(("A/Enter", "A ", "Enter", "Ctrl-C", "Any key", - "B/Esc Stop", "B Stop")), hint) + "B/Esc Stop", "B Stop") + + (("B ",) if name == "checking" else ())), + hint) def test_focus_is_marked_without_colour(self): lines = install.tui_view(fresh_state(), 80, 24) @@ -2416,7 +2433,7 @@ class AuditRoundThree(unittest.TestCase): """With --from-pack and no Wi-Fi, the plan said "to download", which on a handheld with no network read as a failure on its way.""" state = checked_state() - with mock.patch.object(install.LOCAL, "pack", object()): + with mock.patch.object(install.LOCAL, "pack", HoldingPack()): headline = str(install.tui_view(state, 80, 24)[0]) plan = "\n".join(str(line) for line in install.tui_view(state, 80, 24)) state.summary = {"installed": 3, "failed": [], "up_to_date": 0} @@ -2494,9 +2511,13 @@ class AuditRoundThree(unittest.TestCase): state.found = [found("scph5500.bin", zipped)] state.cols, state.rows = cols, rows lines = [line.strip() for line in install.tui_view(state, cols, rows)] - folder = next(line for line in lines if line.startswith("BIOS folder: ")) - self.assertTrue(folder.endswith("retroarch/system"), folder) - self.assertIn("...", folder) + at = next(index for index, line in enumerate(lines) + if line.startswith("BIOS folder: ")) + # Whole, broken after a separator: cut in its middle at 40 + # columns, it was spelled one way on the plan and another on + # the result (tui_audit_13 P3-3). + self.assertEqual(lines[at] + lines[at + 1], "BIOS folder: /home/someone/with/" + "a/long/path/.config/retroarch/system") self.assertIn("1 found in PSX BIOS.zip,", " ".join(lines)) self.assertNotIn("/home/me/a/long", " ".join(lines)) @@ -5635,8 +5656,11 @@ class GameDataIsNoFirmwareAlarm(unittest.TestCase): self.assertNotIn("Anims.j2a", lines[0]) # The folder it goes in, and what game data is (the Android run of # 2026-10-27 said neither). - self.assertIn("Not available here, game data: files a game or a game engine reads", - lines[1]) + # The names first, the kind in a word, the definition on its own + # line of the plan (tui_audit_13 P3-5). + self.assertIn("Not available here: jazz2/Source/Anims.j2a (Jazz Jackrabbit 2), game " + "data its game reads. Copy it", lines[1]) + self.assertNotIn("files a game or a game engine reads", lines[1]) self.assertIn(f"into {SYSTEM_DIR / 'jazz2' / 'Source'}{os.sep}", lines[1]) self.assertIn("Anims.j2a (Jazz Jackrabbit 2)", lines[1]) @@ -6191,8 +6215,10 @@ class AuditSix(unittest.TestCase): state.found = [] state.check = dict(state.check, data_missing=2636, data_bytes=40 * 1024 * 1024) text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) - self.assertIn("4 files and 2636 data files (40.0 MB) need the internet, which " - "--offline does not use", text) + # One count of files, the kinds apart under the folder (tui_audit_13 + # P3-6): "4 files and 2636 data files" read as two kinds of one. + self.assertIn("2640 files need the internet, which --offline does not use", text) + self.assertIn("2636 data files (40.0 MB).", text) def test_the_error_row_and_the_bar_agree(self): """tui_audit_6 P3-9.3: "Choose another setup or folder" over @@ -7557,11 +7583,17 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, self.assertIn("RetroArch: 1 file, 3 B, for 1 of 2 systems.", after) # No game data among the systems chosen: nothing for g to leave out. self.assertNotIn("g) Game data", after) - text, _asked = self.line_run(_args(**args), ["c", "1", "g", ""]) + text, _asked = self.line_run(_args(**args), ["c", "1", "g", "", "g", ""]) after = text[text.index("Counted again"):] self.assertIn("g) Game data: kept (3 files, 120 B): leave it out", after) - last = after[after.rindex("Counted again"):] - self.assertIn("g) Game data: left out (3 files, 120 B): keep it", last) + # doom holds only game data: Enter is refused, as the screen offers + # no Install for it (tui_audit_13 P3-11), and g puts it back. + self.assertIn("g) Game data: left out (3 files, 120 B): keep it", after) + self.assertIn("doom holds only game data: nothing is left to install.", after) + self.assertNotIn("All files up to date", text) + self.assertIn("RetroArch: 3 files, 120 B, for 1 of 2 systems.", + after[after.rindex("Counted again"):]) + text, _asked = self.line_run(_args(**args), ["g", ""]) self.assertIn("4 game data files left out (--no-game-data), 1 of them not " "installable", text) @@ -9922,13 +9954,19 @@ class TheAndroidResultReadsWhole(unittest.TestCase): return install.tui_view(state, cols, 40) def test_a_path_or_a_command_is_never_split_inside_a_word(self): + full = "/sdcard/RetroArch/retrobios-backup/20261027-223524" for cols in (40, 50, 80): with self.subTest(cols=cols): lines = [str(line).strip() for line in self.result(cols)] - self.assertIn("/sdcard/RetroArch/retrobios-backup/20261027-223524", lines) words = [word for line in lines for word in line.split()] self.assertIn("--restore", words) - self.assertTrue(all(not word.endswith("/") for word in words), words) + # The command keeps it whole, to be copied as one word. + self.assertIn(full, words) + # Prose breaks it after a separator only, never inside a name + # (tui_audit_13 P3-3): glued again, it is whole. + self.assertIn(full, re.sub(r"/ ", "/", " ".join(lines))) + self.assertFalse(any(line.endswith(("retrobios-ba", "20261027-")) + for line in lines), lines) state = _installed_state(Path("/sdcard/RetroArch/system")) command = install._styled("curl -fsSL https://raw.githubusercontent.com/Abdess/" "retrobios/main/install.sh | sh -s -- --restore " @@ -9958,7 +9996,12 @@ class TheAndroidResultReadsWhole(unittest.TestCase): lines = install._omission_lines(1, [entry], bios) said = " ".join(lines) self.assertIn(f"into {bios / 'jazz2' / 'Source'}{os.sep}", said) + # Defined on its own line, never spliced into the sentence naming + # the file (tui_audit_13 P3-5). self.assertIn("files a game or a game engine reads", said) + self.assertEqual([line for line in lines if "game engine reads" in line], + [" Game data: files a game or a game engine reads, of no use to " + "the other cores."]) self.assertNotIn("Needed to start an emulator", said) @@ -10184,6 +10227,17 @@ class MouseOnEveryScreen(unittest.TestCase): label = str(lines[line])[first:end].split(" ", 1)[0] self.assertIn(label, install._BAR_CLICKS) effects = tap(state, first, line) + if install._bar_commits(base, target[1]): + # Install, Quit and Continue into Confirm: the + # first tap arms the entry, drawn so, the + # second on the same entry presses it. + self.assertEqual(effects, []) + self.assertEqual(state.stack, base.stack) + again = drawn(state, cols, rows) + self.assertIn("tap again", str(again[-1])) + at = next(first for row, first, _end, mark in state.hits.targets + if mark == target) + effects = tap(state, at, line) self.assertEqual(effects, install.tui_update(keyed, target[1])) self.assertEqual((state.stack, state.help), (keyed.stack, keyed.help)) @@ -10645,6 +10699,7 @@ class AuditNineteenAndTwelve(_CardFixtures, unittest.TestCase): "systems": ["sony-playstation"], "cores": None} for index in range(50)] for left in (False, True): state.no_game_data = left + state.checked_dests |= {f"game/{index}.dat" for index in range(50)} row = next(row for row in install.tui_rows(state) if row.action == "gamedata") line = str(install._row_line(row, False, 38)) word = "left out" if left else "kept" @@ -11067,5 +11122,767 @@ class TapsOnAPty(unittest.TestCase): self.assertGreater(term.raw.rfind(b"\x1b[?1000l"), mark) +class AuditTwentyAndThirteen(unittest.TestCase): + """code_audit_20 and tui_audit_13: one choice for the run, taps that + never commit what was not seen, and the words that fit.""" + + setUp = LineModeAndScreenDecideAlike.setUp + line_run = LineModeAndScreenDecideAlike.line_run + listing = LineModeAndScreenDecideAlike.listing + entry = staticmethod(LineModeAndScreenDecideAlike.entry) + + @staticmethod + def receive(state: install.TuiState, events: list, facts: dict | None = None) -> None: + """A check's events, then its end.""" + for event in events: + install.tui_receive(state, ("event", state.run_id, event)) + with mock.patch.object(install, "_tui_folder_facts", return_value=facts or { + "free": 50 * 1024 ** 3, "writable": True, "cores": []}): + install.tui_receive(state, ("ended", state.run_id, 1, [])) + + def game_manifest(self) -> dict: + """Three systems, each with one BIOS file and one game data file.""" + files = [] + for system in ("nintendo-ds", "sega-dreamcast", "sony-playstation"): + files += [{"dest": f"{system}.bin", "size": 100, "systems": [system], + "cores": None}, + {"dest": f"{system}/game.dat", "size": 1000, "systems": [system], + "cores": None, "category": install.GAME_DATA}] + # Dreamcast holds only game data. + files = [entry for entry in files if entry["dest"] != "sega-dreamcast.bin"] + return {"manifest_version": 2, "platform": "retroarch", "categorized": True, + "files": files} + + def game_events(self, manifest: dict, left_out: bool = False, + only: tuple = ()) -> list: + """The events of a check over manifest, game data left out or not, + the files of only those systems when given (--emulator).""" + kept = [entry for entry in manifest["files"] + if not only or entry["systems"][0] in only] + files = [entry for entry in kept + if not (left_out and entry.get("category") == install.GAME_DATA)] + events = [{"event": "file", "platform": "retroarch", "dest": entry["dest"], + "systems": entry["systems"], "size": entry["size"], "state": "missing"} + for entry in files] + systems = sorted({entry["systems"][0] for entry in files}) + events += [{"event": "system", "platform": "retroarch", "system": system, + "name": system, "files": 1, "present": 0, "missing": 1, "wrong_hash": 0, + "bytes_needed": 100} for system in systems] + games = [entry["dest"] for entry in kept if entry.get("category") == install.GAME_DATA] + events.append({"event": "summary", "mode": "check", "installed": 0, "up_to_date": 0, + "pending": len(files), "failed": [], "skipped": [], + "left_out": [{"category": install.GAME_DATA, "label": "game data", + "files": len(games), "dests": games}] + if left_out else [], "kept": [], "interrupted": False}) + events.append({"event": "check", "platform": "retroarch", "name": "RetroArch", + "bios_dir": str(SYSTEM_DIR), "files": len(files), "present": 0, + "missing": len(files), "wrong_hash": 0, + "bytes_needed": sum(entry["size"] for entry in files), + "data_missing": 0, "not_installable": 0}) + return events + + def game_plan(self, only: tuple = ()) -> install.TuiState: + state = fresh_state() + state.stack = ["setup"] + manifest = self.game_manifest() + with mock.patch.object(install.Path, "exists", return_value=True): + install.tui_update(state, Key("enter")) + install.tui_receive(state, ("manifest", "retroarch", manifest)) + self.receive(state, self.game_events(manifest, only=only)) + return state + + # Root cause 2: one choice for the whole run. + + def test_the_game_data_row_keeps_the_systems_ticked(self): + """tui_audit_13 P1-1: the plan's game data row set the ticks back to + every system, and the install wrote all of them.""" + state = self.game_plan() + state.ticked = {"nintendo-ds"} + rows = install.tui_rows(state) + state.focus["plan"] = next(index for index, row in enumerate(rows) + if row.action == "gamedata") + self.assertEqual(install.tui_update(state, Key("enter")), [("run", "check")]) + self.receive(state, self.game_events(self.game_manifest(), left_out=True)) + self.assertEqual(state.ticked, {"nintendo-ds"}) + self.assertIn(("--system", "nintendo-ds"), install.tui_flags(state)) + self.assertIn(("--no-game-data", None), install.tui_flags(state)) + + def test_every_check_of_the_same_list_keeps_the_choice(self): + """Root cause of tui_audit_13 P1-1: a folder to copy from added, the + save folder chosen or Check again set the ticks back to every system + too, as the game data row did.""" + for how in ("from", "savedir", "recheck"): + with self.subTest(how=how): + state = self.game_plan() + state.ticked = {"nintendo-ds"} + if how == "from": + state.browse_for, state.browse_dir = "from", self.root + state.stack.append("browse") + install._activate(state, install.TuiRow("use", "Use this folder")) + elif how == "savedir": + state.browse_for, state.browse_dir = "save", self.root + state.stack.append("browse") + install._activate(state, install.TuiRow("use", "Use this folder")) + else: + install._activate(state, install.TuiRow("recheck", "Check again")) + self.assertEqual(state.screen, "checking") + self.receive(state, self.game_events(self.game_manifest())) + self.assertEqual(state.ticked, {"nintendo-ds"}) + + def test_a_choice_the_toggle_empties_says_so_and_offers_choose_systems(self): + """tui_audit_13 P1-1: Dreamcast alone, game data left out: the plan + promised the whole list and Install refused.""" + state = self.game_plan() + state.ticked = {"sega-dreamcast"} + rows = install.tui_rows(state) + state.focus["plan"] = next(index for index, row in enumerate(rows) + if row.action == "gamedata") + install.tui_update(state, Key("enter")) + self.receive(state, self.game_events(self.game_manifest(), left_out=True)) + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertIn("sega-dreamcast holds only game data: nothing is left to install.", text) + actions = [row.action for row in install.tui_rows(state)] + self.assertNotIn("install", actions) + self.assertEqual(actions[0], "systems") + # Kept again, the same system comes back. + state.focus["plan"] = actions.index("gamedata") + install.tui_update(state, Key("enter")) + self.receive(state, self.game_events(self.game_manifest())) + self.assertEqual(state.ticked, {"sega-dreamcast"}) + + def test_the_row_counts_the_list_the_check_read(self): + """code_audit_20 P2-1: under --emulator the row counted the game + data of every system, line mode's g the emulator's alone.""" + state = self.game_plan(only=("nintendo-ds",)) + row = next(row for row in install.tui_rows(state) if row.action == "gamedata") + self.assertEqual(row.right, "1 file, 1000 B") + # Line mode, the same list narrowed as --emulator narrows it. + manifest = self.game_manifest() + listing = self.listing("retroarch", [ + self.entry(entry["dest"], b"x" * entry["size"], entry["systems"], + **({"category": entry["category"]} if "category" in entry else {})) + for entry in manifest["files"] if entry["systems"] == ["nintendo-ds"]], + categorized=True) + text, _asked = self.line_run(_args(manifest=listing, dest=self.here, + declared_only=False, dry_run=True), [""]) + self.assertIn("g) Game data: kept (1 file, 1000 B)", text) + + def test_line_mode_names_the_game_data_of_the_systems_chosen(self): + """tui_audit_13 P2-3: --system with --no-game-data named the game + data of the whole list.""" + files = [self.entry("gba_bios.bin", b"gba", ["nintendo-gba"]), + self.entry("doom/doom1.wad", b"wad" * 10, ["doom"], category="game_data")] + listing = self.listing("retroarch", files, categorized=True) + text, _asked = self.line_run(_args(manifest=listing, dest=self.here, + declared_only=False, dry_run=True, no_input=True, + system=["nintendo-gba"], no_game_data=True), []) + self.assertNotIn("doom1.wad", text) + self.assertNotIn("game data file left out", text) + text, _asked = self.line_run(_args(manifest=listing, dest=self.here, + declared_only=False, dry_run=True, no_input=True, + system=["doom"], no_game_data=True), []) + # The choice holds no file: said, never "All files up to date", nor + # "unknown system" for a system the list names. + self.assertIn("doom holds only game data: nothing is left to install.", text) + self.assertNotIn("All files up to date", text) + self.assertNotIn("unknown system", text) + + def test_the_run_head_counts_three_kinds_and_defines_them(self): + """code_audit_20 P3-5: a run without the menu split two kinds beside + the plan's three, from a second counting function.""" + self.assertFalse(hasattr(install, "split_words")) + files = [self.entry("gba_bios.bin", b"gba", ["nintendo-gba"]), + self.entry("doom/doom1.wad", b"wad" * 10, ["doom"], category="game_data")] + listing = self.listing("retroarch", files, categorized=True) + text, _asked = self.line_run(_args(manifest=listing, dest=self.here, + declared_only=False, dry_run=True, no_input=True), + []) + head = text[:text.index("Checking existing files")] + self.assertIn("1 BIOS file (3 B) and 1 game data file (30 B).", head) + self.assertIn("Game data: files a game or a game engine reads", head) + + def test_the_cores_of_the_systems_chosen_come_first(self): + """tui_audit_13 P3-12: with --system sony-playstation, Cores opened + on every core of the list.""" + state = checked_state() + state.cli_systems = ["sony-playstation"] + state.stack.append("cores") + values = [row.value for row in install.tui_rows(state) if row.action == "core"] + self.assertEqual(values, ["pcsx_rearmed"]) + rows = install.tui_rows(state) + self.assertEqual(rows[-1].label, "Cores of the other systems: 1") + state.focus["cores"] = len(rows) - 1 + install.tui_update(state, Key("enter")) + self.assertEqual([row.value for row in install.tui_rows(state) + if row.action == "core"], ["mesen", "pcsx_rearmed"]) + + def test_line_mode_counts_the_systems_that_hold_a_file(self): + """tui_audit_13 P3-11: "2 of 8" in line mode, "1 of 8" on the screen.""" + files = [self.entry("ds.bin", b"ds", ["nintendo-ds"]), + self.entry("dc/game.dat", b"dc", ["sega-dreamcast"], category="game_data"), + self.entry("psx.bin", b"psx", ["sony-playstation"])] + listing = self.listing("retroarch", files, categorized=True, + system_titles={"nintendo-ds": "Nintendo DS", + "sega-dreamcast": "Sega Dreamcast", + "sony-playstation": "Sony PlayStation"}) + text, _asked = self.line_run(_args(manifest=listing, dest=self.here, + declared_only=False, dry_run=True), + ["c", "1,2", "g"]) + last = text[text.rindex("Counted again"):] + self.assertIn("for 1 of 2 systems.", last) + # The screen, the same choice: DS and Dreamcast ticked, game data + # left out on the plan. + state = self.game_plan() + state.ticked = {"nintendo-ds", "sega-dreamcast"} + rows = install.tui_rows(state) + state.focus["plan"] = next(index for index, row in enumerate(rows) + if row.action == "gamedata") + install.tui_update(state, Key("enter")) + self.receive(state, self.game_events(self.game_manifest(), left_out=True)) + self.assertIn("for 1 of 2 systems", str(install.tui_view(state, 80, 24)[2])) + + # Root cause 3: copies and packs. + + def test_confirm_counts_the_copies_a_folder_holds(self): + """code_audit_20 P2-2: Confirm called the files packx holds + downloads, offline and online.""" + state = checked_state() + state.found = [found(event["dest"], "/media/old") for event in FILES] + state.stack.append("confirm") + state.dry, state.dry_files = dict(CHECK), list(FILES) + for env in ({install.OFFLINE_VARIABLE: install.OFFLINE_FLAG_VALUE}, {}): + with self.subTest(offline=bool(env)), mock.patch.dict(os.environ, env): + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertIn("4 found in old, nothing to download", text) + self.assertNotIn("need the internet", text) + self.assertNotIn("to download,", text) + + def test_an_old_card_holding_every_file_in_place_is_not_called_empty(self): + """tui_audit_13 P3-4: "packx holds none of these files" when all 22 + were in place.""" + state = checked_state() + state.check = {**CHECK, "present": 5, "missing": 0, "wrong_hash": 0, + "bytes_needed": 0} + state.files = [] + state.from_folders = [Path("/media/packx")] + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertIn("Nothing to copy from packx: every file is in place.", text) + self.assertNotIn("holds none of these files", text) + + def test_the_plan_says_what_the_pack_lacks_before_a_size(self): + """tui_audit_13 P3-8: "8358 files to install from the pack, 6.6 GB" + from a pack that held none of them.""" + state = checked_state() + + class Lacking: + name = "RetroArch_BIOS_Pack.zip" + + @staticmethod + def find(entry: dict) -> tuple | None: + return (None, entry) if entry["dest"] == "gba_bios.bin" else None + + with mock.patch.object(install.LOCAL, "pack", Lacking()): + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertIn("1 file to install from the pack, 16.0 KB", text) + self.assertIn("3 of these 4 files are not in RetroArch_BIOS_Pack.zip", text) + Lacking.find = staticmethod(lambda entry: None) + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertIn("RetroArch_BIOS_Pack.zip holds none of these 4 files", text) + self.assertNotIn("install", [row.action for row in install.tui_rows(state)]) + + def test_a_failed_pack_install_leaves_no_folder_behind(self): + """tui_audit_13 P3-8: 405 empty folders and a saves folder.""" + pack_dir = self.root / "downloads" + pack_dir.mkdir() + pack = pack_dir / "RetroArch_BIOS_Pack.zip" + with zipfile.ZipFile(pack, "w") as archive: + archive.writestr("manifest.json", json.dumps({"files": []})) + files = [self.entry(f"deep/folder{n}/file.bin", b"x", ["nintendo-gba"]) + for n in range(3)] + listing = self.listing("retroarch", files) + dest = self.root / "new" / "system" + dest.parent.mkdir() + text, _asked = self.line_run(_args(manifest=listing, from_pack=[pack], dest=dest, + declared_only=False, yes=True, no_input=True), []) + self.assertIn("RetroArch_BIOS_Pack.zip holds none of these 3 files", text) + self.assertFalse(dest.exists(), list(dest.rglob("*")) if dest.exists() else []) + + # Root cause 4: Choose another folder. + + def test_choose_another_folder_opens_the_folder_screen(self): + """code_audit_20 P2-3: with --dest the create screen is alone on the + stack, and Choose another folder asked Quit?.""" + state = fresh_state() + state.platform, state.dest = "retroarch", self.root / "missing" / "bios" + state.stack = ["create"] + state.facts = {"nearest": self.root, "risky": False} + state.focus["create"] = 1 + install.tui_update(state, Key("enter")) + self.assertEqual(state.screen, "folder") + self.assertEqual(install.tui_view(state, 80, 24)[0].strip(), + "RetroBIOS: Which folder?") + + # Root cause 5: text that fits. + + def test_the_version_note_stays_on_the_40x12_plan_and_in_every_farewell(self): + """tui_audit_13 P3-1.""" + note = "This list was built for RetroBat 8.3.1, this machine runs 8.2.0.1." + state = checked_state() + state.version = note + state.files = [] + state.check = {**CHECK, "present": 5, "missing": 0, "wrong_hash": 0, + "bytes_needed": 0} + text = " ".join(" ".join(install.tui_view(state, 40, 12)).split()) + self.assertIn(note, text) + state.exit_code = 0 + self.assertIn(note, install.tui_farewell(state)) + state.exit_code, state.quit_asked = install.QUIT_CODE, True + self.assertIn(note, install.tui_farewell(state)) + + def test_the_help_defines_game_data_at_40_columns(self): + """tui_audit_13 P3-2: the row was a choice nothing explained on the + Miyoo's screen.""" + state = _game_state() + state.mouse = True + state.help = True + for rows in (12, 15): + with self.subTest(rows=rows): + text = " ".join(" ".join(install.tui_view(state, 40, rows)).split()) + self.assertIn("Game data: files a game or a game engine reads", text) + + def test_the_40_column_bar_keeps_help_and_the_help_its_tap_line(self): + """code_audit_20 P3-1.""" + state = checked_state() + state.mouse = True + state.stack.append("systems") + state.focus["systems"] = 1 + bar = str(install.tui_view(state, 40, 12)[-1]) + self.assertIn("? Help", bar) + state.help = True + text = " ".join(" ".join(install.tui_view(state, 40, 12)).split()) + self.assertIn("Tap Tick or untick a row, as Space", text) + + def test_one_spelling_of_a_folder_on_the_plan_and_the_result(self): + """tui_audit_13 P3-3: "/home...i_audit13/rb/bios" on the plan, the + same folder whole and folded inside a name on the result.""" + dest = Path("/home/moi/retroarch_system/tmp/tui_audit13/rb/bios") + for cols in (40, 80): + with self.subTest(cols=cols): + plan = checked_state() + plan.dest = dest + result = _installed_state(dest) + spelled = [] + for state in (plan, result): + lines = [str(line).strip() for line in install.tui_view(state, cols, 24)] + at = next(i for i, line in enumerate(lines) + if line.startswith("BIOS folder:")) + pieces = [lines[at]] + while pieces[-1].endswith("/") and lines[at + len(pieces)]: + pieces.append(lines[at + len(pieces)]) + spelled.append(pieces) + self.assertNotIn("...", " ".join(lines)) + self.assertTrue(all(len(piece) <= cols - 2 for piece in pieces), pieces) + self.assertEqual(spelled[0], spelled[1]) + self.assertEqual("".join(spelled[0]), f"BIOS folder: {dest}") + + def test_a_long_path_fits_without_its_indent(self): + """tui_audit_13 P3-3: a pack path of 75 cells folded at 80 columns + under an indent of 6.""" + path = "/home/moi/retroarch_system/tmp/tui_audit13/pack/MinUI_2025-11_BIOS_Pack.zip" + self.assertEqual(len(path), 75) + lines = install._wrap(f" python3 install.py --from-pack {path}", 78, + whole="command") + self.assertIn(path, lines) + + def test_the_definition_is_said_once_and_never_spliced(self): + """tui_audit_13 P3-5.""" + state = checked_state() + state.systems = [] + state.manifest = {**MANIFEST, "categorized": True, "omitted_files": [ + {"dest": "jazz2/Source/Anims.j2a", "system": "jazz-jackrabbit-2", + "required": True, "category": "game_data"}]} + state.check = {**CHECK, "not_installable": 1} + text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + self.assertEqual(text.count("files a game or a game engine reads"), 1, text) + self.assertIn("Not available here: jazz2/Source/Anims.j2a (Jazz Jackrabbit 2), game " + "data its game reads.", text) + + def test_the_headline_counts_every_file_once(self): + """tui_audit_13 P3-6: "8358 files and 2636 data files", and the + definition dropped at 80 columns.""" + state = _game_state() + state.check = {**state.check, "data_missing": 7, "data_bytes": 2048} + lines = [str(line).strip() for line in install.tui_view(state, 80, 24)] + text = " ".join(" ".join(lines).split()) + self.assertIn("11 files to download, 1.0 MB", text) + self.assertNotIn("and 7 data files", lines[2]) + self.assertIn("Data files: the files an emulator reads", text) + + def test_the_save_line_never_ends_on_its_colon(self): + """tui_audit_13 P3-7: "...goes to a folder per core under:" with no + folder at 60x20.""" + state = _game_state() + state.saves = {"event": "saves", "files": 1, + "folder": "/home/user/.config/retroarch/saves/"} + shown = 0 + for rows in range(12, 30): + with self.subTest(rows=rows): + lines = [str(line).strip() for line in install.tui_view(state, 60, rows)] + for index, line in enumerate(lines): + if line.endswith("under:"): + shown += 1 + self.assertTrue(lines[index + 1].startswith("~/"), lines) + self.assertGreater(shown, 0) + + def test_the_result_says_how_to_copy_and_counts_one_kind_once(self): + """tui_audit_13 P3-10 and P3-14.""" + state = _installed_state(SYSTEM_DIR) + state.manifest = {**MANIFEST, "categorized": True} + state.mouse = True + state.finished = [(event["dest"], True, "") for event in FILES] + state.summary = {**state.summary, "installed": 4} + _title, lines = install.tui_outcome(state) + self.assertEqual(lines[0], "4 BIOS files installed.") + self.assertFalse(any(line.startswith("Installed:") for line in lines), lines) + self.assertEqual(lines[-1], install.TUI_RESULT_COPY) + self.assertNotIn(install.TUI_RESULT_COPY, install.tui_farewell(state)) + state.help = True + self.assertIn("Shift+drag", " ".join(install.tui_view(state, 80, 24))) + + def test_the_old_card_result_splits_what_is_missing(self): + """tui_audit_13 P3-14: "16 still missing" where 6 were game data.""" + state = _game_state() + state.from_folders = [Path("/media/OLD")] + state.local_dests = {"gba_bios.bin": ("/media/OLD", "")} + state.finished = [("gba_bios.bin", True, "")] + state.download = {"files": 4} + state.stack, state.run = ["result"], "install" + state.summary = {"event": "summary", "mode": "install", "installed": 1, + "up_to_date": 0, "pending": 3, "failed": [], "skipped": [], + "left_out": [], "kept": [], "interrupted": False} + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: + install.OFFLINE_FLAG_VALUE}): + _title, lines = install.tui_outcome(state) + self.assertIn("Still missing: 2 BIOS files and 1 game data file.", lines) + + # Root cause 6: every other word. + + def test_drives_are_named_once_under_wsl_and_windows(self): + """code_audit_20 P3-3: "D:: bios" and "D:\\: bios".""" + with mock.patch.dict(install._WSL, {"active": True}): + mount = Path(install._WSL["root"]) / "d" + self.assertEqual(install._drive_folder(mount, "bios"), "D:\\bios") + windows = PureWindowsPath("D:\\") + with mock.patch.object(install, "_mount_name", return_value="D:\\"), \ + mock.patch.object(install, "shown_path", side_effect=str): + self.assertEqual(install._drive_folder(windows, "bios"), "D:\\bios") + stick = Path("/media/moi/USBSTICK") + self.assertEqual(install._drive_folder(stick, "bios"), "USBSTICK: bios") + + def test_the_linux_side_is_named_by_its_side(self): + """code_audit_20 P3-3, tui_audit_13 P3-9.""" + self.assertEqual(install.side_name("wsl", "retroarch"), "Linux RetroArch") + self.assertEqual(install.side_name("linux", "retroarch"), "RetroArch") + state = fresh_state() + state.os_type, state.platform = "wsl", "retroarch" + state.stack = ["setup", "folder"] + state.folder_default, state.folder_kind = SYSTEM_DIR, "usual" + with mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "_is_dir", return_value=False): + text = "\n".join(install.tui_view(state, 100, 30)) + self.assertIn("Linux RetroArch usually reads its BIOS files from:", text) + self.assertIn("(does not exist yet)", text) + + def test_a_folder_created_where_no_setup_is_says_so(self): + """tui_audit_13 P3-9: "system is created in C:\\RetroArch-Win64, where + RetroArch reads it" with no RetroArch there.""" + state = fresh_state() + missing = self.root / "RetroArch-Win64" / "system" + with mock.patch.object(install, "_start_check", return_value=[]): + install._choose(state, "retroarch", missing, proposed=True) + self.assertIn("a folder that does not exist yet", state.card_note) + self.assertNotIn("reads it", state.card_note) + here = self.root / "pc" / "bios" + with mock.patch.object(install, "_start_check", return_value=[]): + install._choose(state, "retroarch", here, proposed=True) + self.assertIn("where RetroArch reads it", state.card_note) + + def test_the_needed_list_counts_one_more_system_in_words(self): + """tui_audit_13 P3-13, on the list of files an emulator needs too.""" + entries = [{"dest": f"s{n}.bin", "system": f"system-{n}", "required": True} + for n in range(9)] + said = " ".join(install._needed_lines(entries, "Needed")) + self.assertIn("... and 1 more system (--check --verbose lists them all)", said) + + def test_the_farewell_names_the_option_not_the_screen(self): + """code_audit_20 P3-4.""" + state = _installed_state(SYSTEM_DIR) + state.summary = {**state.summary, "skipped": [ + {"reason": "no_save_directory", "label": "", "files": 1, "required": False}]} + farewell = " ".join(install.tui_farewell(state)) + self.assertIn("Name it with --save-dir next time.", farewell) + self.assertNotIn("Options", farewell) + + def test_more_systems_is_counted_in_words(self): + """tui_audit_13 P3-13: "... and 1 more systems".""" + entries = [{"dest": f"s{n}.bin", "size": 10, "systems": [f"system-{n:02d}"]} + for n in range(install.PLAN_LARGEST_SYSTEMS + 1)] + out = io.StringIO() + with contextlib.redirect_stdout(out): + install._print_plan(entries, SYSTEM_DIR, install.Terminal(io.StringIO())) + self.assertIn("... and 1 more system:", out.getvalue()) + + def test_the_systems_question_aligns_and_explains_after_the_list(self): + """tui_audit_13 P3-13.""" + rows = [("mattel-intellivision", []), ("nec-pc-engine", []), + (install.OTHER_SYSTEMS, [])] + labels = {"mattel-intellivision": "Mattel Intellivision mattel-intellivision", + "nec-pc-engine": "NEC PC Engine nec-pc-engine", + install.OTHER_SYSTEMS: install.OTHER_TITLE + " " + "x" * 10} + out = io.StringIO() + with contextlib.redirect_stdout(out), \ + mock.patch.object(install, "_ask", return_value=""): + install._pick("Systems:", rows, labels=labels, note="A note.", + sizer=lambda files: "1 file") + lines = out.getvalue().splitlines() + listed = [line for line in lines if line.endswith("1 file")] + self.assertEqual(len({line.index("1 file") for line in listed}), 1, listed) + self.assertGreater(lines.index("A note."), lines.index(listed[-1])) + + def test_the_yes_stop_says_one_thing_with_a_list_or_without(self): + """tui_audit_13 P3-13, code_audit_20 P3-6: "the file list" said of a + pack, and two wordings for one stop.""" + pack = self.root / "RetroArch_BIOS_Pack.zip" + with zipfile.ZipFile(pack, "w") as archive: + archive.writestr("manifest.json", json.dumps({"files": []})) + env = {"HOME": str(self.root / "home"), "XDG_CONFIG_HOME": str(self.root / "cfg"), + "RETROBIOS_OS": "linux"} + said = install_run("--from-pack", str(pack), "--yes", env=env) + self.assertEqual(said.returncode, 1, said.stdout + said.stderr) + self.assertIn("No supported platform detected: RetroArch was not found here, and " + "the pack does not say where it goes.", said.stderr) + alone = install_run("--yes", env=env) + self.assertIn("No supported platform detected", alone.stdout + alone.stderr) + hint = install._manual_usage_hint("linux") + self.assertTrue(hint[0].startswith(" Name the setup with --platform and its folder " + "with --dest"), hint) + page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8") + self.assertIn("every run without questions stops before reading it", page) + self.assertIn("none stops it too, with `reason` `not_found`", page) + self.assertIn("So is a platform named by a file list or a pack", page) + + +class TapsSeenBeforeTheyAct(unittest.TestCase): + """code_audit_20 P3-2 and tui_audit_13 P2-1, P2-2: a tap acts on the + frame it was made on, and never commits on one tap.""" + + def test_a_release_on_a_later_frame_does_nothing(self): + state = checked_state() + state.mouse = True + lines = drawn(state, 80, 24) + row = next(i for i, line in enumerate(lines) + if getattr(line, "mark", None) and line.mark[0] == "row" + and line.mark[2][0] == "systems") + install.tui_update(state, install.TuiMouse(0, 5, row + 1)) + # The screen changes under the finger before it comes up. + state.stack.append("options") + drawn(state, 80, 24) + state.stack.pop() + drawn(state, 80, 24) + effects = install.tui_update(state, install.TuiMouse(0, 5, row + 1, True)) + self.assertEqual(effects, []) + self.assertIsNone(state.tapped) + + def test_the_bar_install_arms_then_presses(self): + state = checked_state() + state.mouse = True + lines = drawn(state, 80, 24) + bar = len(lines) - 1 + self.assertEqual(tap(state, 3, bar), []) + self.assertEqual(state.screen, "plan") + armed = drawn(state, 80, 24) + self.assertIn("A/Enter Install: tap again", str(armed[-1])) + self.assertEqual(tap(state, 3, bar), [("run", "install")]) + + def test_a_tapped_quit_quits_with_the_quit_code(self): + state = checked_state() + state.mouse = True + state.stack.append("checking") + state.running, state.run = True, "check" + lines = drawn(state, 80, 24) + self.assertFalse(str(lines[-1]).strip().startswith("Ctrl-C")) + at = str(lines[-1]).index("Ctrl-C") + tap(state, at, len(lines) - 1) + self.assertIsNone(state.exit_code) + drawn(state, 80, 24) + at = str(install.tui_view(state, 80, 24)[-1]).index("Ctrl-C") + tap(state, at, len(lines) - 1) + self.assertEqual(state.exit_code, install.QUIT_CODE) + + def test_two_taps_on_a_region_leave_it_unticked_where_it_was(self): + state = checked_state() + state.mouse = True + install.tui_update(state, Key("char", "o")) + install.tui_update(state, Key("enter")) + self.assertEqual(state.screen, "regions") + lines = drawn(state, 80, 24) + before = [row.value for row in install.tui_rows(state)] + row = next(i for i, line in enumerate(lines) + if getattr(line, "mark", None) and line.mark[0] == "row" + and line.mark[2][1] == "north-america") + tap(state, 5, row) + drawn(state, 80, 24) + self.assertEqual(state.regions, ["north-america"]) + self.assertEqual([row.value for row in install.tui_rows(state)], before) + tap(state, 5, row) + self.assertEqual(state.regions, []) + bar = str(drawn(state, 80, 24)[-1]) + self.assertIn("A/Enter/tap Tick", bar) + state.help = True + self.assertIn("as A", " ".join(install.tui_view(state, 80, 24))) + + +@unittest.skipUnless(pyte and pty and hasattr(pty, "fork"), + "screen tests need pyte (pip install pyte==0.8.2) and a pty") +class TapsOnAPtySeeFirst(unittest.TestCase): + """tui_audit_13 P1-1, P2-1, P2-2 on the real install.py --tui.""" + + repo = EndToEnd.repo + start = EndToEnd.start + line_of = staticmethod(MouseOnAPty.line_of) + click = MouseOnAPty.click + + def bar_entry(self, term: Pty, word: str) -> tuple[int, int]: + line = len(term.screen.display) - 1 + return term.screen.display[line].index(word), line + + def test_one_tap_on_the_bar_install_installs_nothing(self): + repo = self.repo() + term = self.start() + term.wait("Where do you play?") + term.send("\r") + term.wait("> Install") + term.settle() + self.click(term, *self.bar_entry(term, "Install")) + self.assertIn("tap again", term.settle().splitlines()[-1]) + self.assertEqual(repo.installed(), []) + self.click(term, *self.bar_entry(term, "Install")) + term.wait("files installed") + term.send("q") + term.close() + + def test_a_double_tap_on_continue_neither_quits_nor_installs(self): + repo = self.repo() + term = self.start() + term.wait("Where do you play?") + term.settle() + col, line = self.bar_entry(term, "Continue") + report = sgr(col + 1, line + 1) + sgr(col + 1, line + 1, release=True) + term.send(report + report) + term.settle() + term.send(report) + term.settle(0.6) + self.assertIsNone(os.waitpid(term.pid, os.WNOHANG)[0] or None) + self.assertEqual(repo.installed(), []) + term.send("q") + if "Quit?" in term.settle(): + term.send("\r") + self.assertIn(term.close(), (install.QUIT_CODE, 0)) + + def test_continue_then_install_in_one_cell_stops_on_confirm(self): + repo = self.repo() + term = self.start() + term.wait("Where do you play?") + term.send("\r") + term.wait("> Install") + row = self.line_of(term, "Choose systems") + self.click(term, 20, row) + term.settle() + self.click(term, 20, row) + term.wait("RetroBIOS: Choose systems") + row = self.line_of(term, "sony-playstation") + self.click(term, 20, row) + term.settle() + col, line = self.bar_entry(term, "Continue") + report = sgr(col + 1, line + 1) + sgr(col + 1, line + 1, release=True) + # A double tap: the first arms Continue, the second opens Confirm. + term.send(report + report) + term.wait("Counted again for your choices") + term.settle() + self.assertEqual(repo.installed(), []) + # Install in the same cell: one tap arms it, nothing is written. + term.send(report) + screen = term.settle(0.6) + self.assertIn("RetroBIOS: Confirm", screen) + self.assertIn("Install: tap again", screen.splitlines()[-1]) + self.assertEqual(repo.installed(), []) + term.send("q") + term.close() + + def test_the_game_data_row_keeps_the_ticks_to_the_install(self): + from test_install_tui import GameDataRepository # noqa: PLC0415 + + repo = self.repo(GameDataRepository) + term = self.start() + term.wait("Where do you play?") + term.send("\r") + term.wait("> Install") + for _step in range(8): + if "> Choose systems" in term.settle(): + break + term.send("\x1b[B") + term.send("\r") + term.wait("RetroBIOS: Choose systems") + term.send("n") + for _step in range(8): + if "> [ ] nintendo-gba" in term.settle(): + break + term.send("\x1b[B") + term.send(" ") + term.settle() + term.send("\x1b") + term.wait("> Install") + for _step in range(8): + if "> Game data: kept" in term.settle(): + break + term.send("\x1b[B") + term.send("\r") + term.wait("Game data: left out") + for _step in range(8): + if "> Install" in term.settle(): + break + term.send("\x1b[A") + term.send("\r") + screen = term.wait("RetroBIOS:") + if "Confirm" in term.settle(): + term.send("\r") + term.wait("files installed") + self.assertEqual(repo.installed(), ["gba_bios.bin", "mgba/extra.bin"]) + del screen + term.send("q") + term.close() + + def test_two_taps_on_a_region_untick_it_where_it_was(self): + self.repo() + term = self.start() + term.wait("Where do you play?") + term.send("\r") + term.wait("> Install") + term.send("o") + term.wait("Preferred regions") + term.send("\r") + term.wait("RetroBIOS: Preferred regions") + row = self.line_of(term, "North America") + self.click(term, 20, row) + screen = term.settle() + self.assertIn("[1] North America", screen.splitlines()[row]) + self.click(term, 20, row) + screen = term.settle() + self.assertIn("[ ] North America", screen.splitlines()[row]) + self.assertNotIn("[1]", screen) + term.send("q") + term.close() + + if __name__ == "__main__": unittest.main() diff --git a/wiki/installer.md b/wiki/installer.md index d229bde5f..ae593acdd 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -121,9 +121,12 @@ they do online, and a file of the list the pack does not hold fails as `not in the pack`. The list's platform has to be the one installed for. `--offline` forbids every request, for a device with no Wi-Fi: `--check` -still names the platform and its folder, then stops at the file list unless -`--manifest` or `--from-pack` gives it, and counts the files that need the -internet instead of offering to fetch them. The run installs the copies found +still names the platform and its folder where it finds them, then stops at the +file list unless `--manifest` or `--from-pack` gives it, and counts the files +that need the internet instead of offering to fetch them. A file list or a pack +names its platform, not where that platform is: when it is found nowhere here, +every run without questions stops before reading it, `--check`, `--dry-run` +and `--no-input` included, and names the `--dest` to give. The run installs the copies found on this machine and downloads nothing. The files left out are missing, not failed: it ends `Finished with files missing` (code 1), or `Nothing installed` (code 2) when nothing was installed, at a terminal, without questions and on @@ -245,11 +248,16 @@ does. On any other row it moves the cursor there, and a second one on that row does what Enter does: one row of a terminal is far smaller than a finger, and the row a screen opens on (**Install** on Confirm) is never chosen by one tap. The entries of the bottom line press their key, **Exit** on the result -too, the line that says which rows are in view shows the next page, and the -wheel scrolls a list or a text without moving the cursor unless it would leave -the screen. Nothing happens until the button or the finger comes up, and only -where it went down. The right and middle buttons do nothing. The help and the -bottom line of a list of boxes say how a tap acts. +too. One that installs, quits or goes on to Confirm takes two taps as a row +does: the first adds `tap again` to it, the second presses it. A row of +Preferred regions stays where it is when it is ticked, its number giving the +order, which the list shows sorted the next time it opens. The line that says +which rows are in view shows the next page, and the wheel scrolls a list or a +text without moving the cursor unless it would leave the screen. Nothing +happens until the button or the finger comes up, and only where it went down, +on the screen drawn when it went down: the second tap of a double tap that +changes the screen does nothing on the new one. The right and middle buttons +do nothing. The help and the bottom line of a list of boxes say how a tap acts. Clicks are off in the Windows console the PowerShell command runs in, which reports no click as a key, with `TERM` unset or set to `dumb`, `unknown`, @@ -259,9 +267,10 @@ them back on every way out, a stop with Ctrl+Z included. On the command line screen, any screen showing a command and the screen where a path is typed, the terminal keeps the mouse, so text is selected, copied and pasted as usual. The result takes taps, so a touch screen can leave it: it is printed again in -the terminal once the screen closes, its commands with it. Elsewhere, hold -Shift while selecting. `--plain` never asks for clicks. Clicks and taps were checked on -2026-10-27 in xterm, tmux, GNU screen 4.09, Windows Terminal (WSL) and Termux +the terminal once the screen closes, its commands with it, and its last line +says so. Elsewhere, and on the result, hold Shift while selecting. `--plain` +never asks for clicks. Clicks and taps were checked on 2026-10-27 in xterm, +tmux, GNU screen 4.09, Windows Terminal (WSL) and Termux on Android 17. macOS Terminal and iTerm2 are not checked yet, nor the key they use to select over the installer. @@ -845,7 +854,7 @@ what the options name, everything by default. | `--list-cores` | Print the cores whose extra files a platform installs | | `--list-emulators` | Print every name `--emulator` accepts, each buildbot name beside the emulator it stands for | | `--romm-firmware TEMPLATE` | RomM: where each platform's firmware goes in the library, as `filesystem.structure.firmware` in RomM's `config.yml` says (`{platform}/bios` for a library laid out as `{platform}/roms`). Read from `config.yml` beside the library when it is there. See [RomM firmware layout](#romm-firmware-layout) | -| `--no-input` | Never ask a question, even at a terminal. A run whose output goes to a file or a pipe asks nothing either, and says so on stderr. It finds the setups and the mounted cards the screen finds: one is taken, as Enter takes it, and two stop the run with an `error` event (`stage` `detect`, `reason` `conflict`) naming each with its `--platform` and `--dest` | +| `--no-input` | Never ask a question, even at a terminal. A run whose output goes to a file or a pipe asks nothing either, and says so on stderr. It finds the setups and the mounted cards the screen finds: one is taken, as Enter takes it, two stop the run with an `error` event (`stage` `detect`, `reason` `conflict`) naming each with its `--platform` and `--dest`, and none stops it too, with `reason` `not_found` when a file list or a pack names the platform | | `--check` | Report and exit without writing | | `--dry-run` | Show what would be downloaded, the totals first, then by system and with sizes, without writing anything. A plan taller than the screen names the ten largest systems, and `--verbose` lists every system and file | | `--yes`, `-y` | Do not ask before a download of 1 GB or more, nor anything else. Implies `--no-input`, so a run that needs an answer (nothing found here, a file list or a pack naming its platform included, or two setups found) stops on what to name instead. `--platform` names a setup, whose usual folder is then taken | @@ -1606,7 +1615,7 @@ other schemas under `api/v1/schemas/`. | `start` | `schema`, `installer`, `mode` | First line. `schema` is 1, `installer` the identity `--version` prints, `mode` one of `install`, `check`, `dry-run`, `list` | | `unsearched` | `folder`, `reason`, `message` | Before `detect`, once for each folder the search for setups could not look in, this user being refused it. What it would hold counts as absent, and the other places are still searched: `detect` then lists what they hold, and an empty `platforms` after an `unsearched` line means nothing was found where the installer could look. `reason` says why in words (`Permission denied`), `message` says it in a sentence | | `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, `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one), or `offline` when `--offline` forbids the request. A folder named with `--from` cannot be read (`stage` is `from`, `reason` `not_found`, `not_a_folder` for a file that is no ZIP, `unreadable`, or `system` for the root of the system, `/proc`, `/sys` or `/dev`). The BIOS folder sits under a folder this user cannot open (`stage` is `dest`, `reason` `unreadable`), and the run ends with code 1 before anything is written. The BIOS folder cannot be created (`stage` is `dest`, `reason` `not_writable`): a `summary` follows with what the run installed before it. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). The platform named with `--platform` is found nowhere and no folder can be assumed for it: a card system with no card named, a portable emulator, Batocera or Recalbox named on a computer (their folder is the `bios` folder of their SHARE drive), or no usual folder on this system or one set aside behind a folder this user cannot open (`stage` is `detect`, `reason` `not_found`). A `--dest` refused as the card of the system named carries the same `stage` and `reason`: another system's card, a folder inside a card, a drive with none of the files its card always has, or a folder that does not exist. `message` says it in words, and is the only thing that tells these cases apart | +| `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, `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one), or `offline` when `--offline` forbids the request. A folder named with `--from` cannot be read (`stage` is `from`, `reason` `not_found`, `not_a_folder` for a file that is no ZIP, `unreadable`, or `system` for the root of the system, `/proc`, `/sys` or `/dev`). The BIOS folder sits under a folder this user cannot open (`stage` is `dest`, `reason` `unreadable`), and the run ends with code 1 before anything is written. The BIOS folder cannot be created (`stage` is `dest`, `reason` `not_writable`): a `summary` follows with what the run installed before it. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). The platform named with `--platform` is found nowhere and no folder can be assumed for it: a card system with no card named, a portable emulator, Batocera or Recalbox named on a computer (their folder is the `bios` folder of their SHARE drive), or no usual folder on this system or one set aside behind a folder this user cannot open (`stage` is `detect`, `reason` `not_found`). So is a platform named by a file list or a pack, in a run without questions where it is found nowhere here. A `--dest` refused as the card of the system named carries the same `stage` and `reason`: another system's card, a folder inside a card, a drive with none of the files its card always has, or a folder that does not exist. `message` says it in words, and is the only thing that tells these cases apart | | `scanned` | `folder`, `files`, `truncated`, `other_drives` | Each folder named with `--from`, once read and before `detect`. `files` counts the files found, `truncated` is true when the search stopped at 200,000 of them, and `other_drives` lists the folders below it on another drive, which were not entered | | `version` | `platform`, `list_version`, `installed_version`, `message` | Before `choices`, when the system here runs another version than the one the list was built for: `list_version` is the list's, `installed_version` the one the system's version file states, and `message` says it and what it means. Not written when either version is unknown. The run goes on | | `choices` | `platform`, `systems` | Before `checking`: the systems the run's list lets one choose among, sorted by id, `other` last for the files no system names. The run's other options apply (`--core`, `--region`, `--no-game-data`, the files left out for an unknown save directory), `--system` does not: these are the systems a screen lists to choose from, and the count a `--system` is out of |