diff --git a/install.ps1 b/install.ps1 index cb5c5f372..21305373e 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "000b7e9e965f6d81c254d7ee7967077f9bbae8aca2286179f31d4f4f7b555d06" + $defaultInstallSha256 = "b7eec15ba746bbaaaea471396964a83a255a4a1c4bd51316fbdb0ff62a065169" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 94048a73e..ecb3c6a77 100644 --- a/install.py +++ b/install.py @@ -2617,11 +2617,12 @@ def did_you_mean( return f"{listing} lists all {len(known)}." -def normalize_platform(name: str) -> str: +def normalize_platform(name: str, say: bool = True) -> str: """Return the canonical platform id for user-supplied input. The site writes MiSTer FPGA and RetroArch: letters and digits decide, - as they do for verify.py and generate_pack.py. + as they do for verify.py and generate_pack.py. say is False where the + name is read again later, which says it once. """ key = _name_key(name) for plat in AVAILABLE_PLATFORMS: @@ -2631,8 +2632,9 @@ def normalize_platform(name: str) -> str: # refused: a system named there stands for the list it gets. for rule in _EMBEDDED_RULES: if key and rule.manifest and key == _name_key(rule.name): - print(f" {rule.name} gets the {display_name(rule.manifest)} file list: " - f"--platform {rule.manifest}.") + if say: + print(f" {rule.name} gets the {display_name(rule.manifest)} file list: " + f"--platform {rule.manifest}.") return rule.manifest print(f"Unknown platform '{name.strip()}'. " + did_you_mean([name], list(AVAILABLE_PLATFORMS), "--list-platforms"), @@ -4218,7 +4220,7 @@ def place_save_entries( if place.by_core: where += f"{os.sep}" print(f" {_plural(len(saved), 'file')} read from {display}'s save directory " - f"go to {where}") + f"{'goes' if len(saved) == 1 else 'go'} to {where}") EVENTS.emit("saves", platform=str(manifest.get("platform", "")), files=len(saved), folder=where) return files, [] @@ -5401,7 +5403,7 @@ def _report_outcome( print(f" ... and {len(failures) - 10} more") if declined and not downloaded and not errors and not interrupted: print(f"\nNothing installed: {_plural(declined, 'file')} not downloaded, " - "as answered.") + + ("which --offline does not do." if _offline_only() else "as answered.")) print(f"{_plural(up_to_date, 'file')} already up to date at {_locations(paths)}.") return kept = _kept_lines() @@ -6275,10 +6277,16 @@ def _choose_platform(title: str, default: str = "") -> str: "or q to quit.") -def _prompt_manual_platform(os_type: str) -> list[tuple[str, Path]]: - """Ask for a platform and a BIOS directory when nothing was detected.""" +def _prompt_manual_platform(os_type: str, ask: bool = True) -> list[tuple[str, Path]]: + """Ask for a platform and a BIOS directory when nothing was detected. + + A system that runs from its card gets the card's root asked for, as + --platform rocknix does: /storage/roms/bios was offered on a PC. + """ plat = _choose_platform("No supported platform found here. Choose the one to " "install for:", "retroarch") + if card_bound(plat, plat) is not None: + return [(plat, card_install_dest(plat, plat, True, ask))] default_dest = _default_dest(os_type, plat) raw = "" while not raw: @@ -6504,7 +6512,7 @@ INSTALLED_ONLY = "installed" def _prompt_custom_selection( plat: str, files: list[dict], omitted: list[dict], installed: tuple[list[dict], list[dict]] | None = None, - name: str = "", dry_run: bool = False, + name: str = "", dry_run: bool = False, data_trees: list[dict] | None = None, ) -> tuple[list[str], list[str], list[str]] | str | None: """Offer to narrow the install by system, core and region. @@ -6513,7 +6521,15 @@ def _prompt_custom_selection( installed read) holds, or the three choices. Each empty list keeps everything on its axis. """ - print(f"\n{name or display_name(plat)}: {_sized(files)}.") + trees = data_trees or [] + data = sum(len(entry.get("files") or {}) for entry in trees) + data_size = sum(meta.get("size", 0) for entry in trees + for meta in (entry.get("files") or {}).values()) + print(f"\n{name or display_name(plat)}: {_sized(files)}" + + (f", and {_plural(data, 'data file')} ({format_size(data_size)})" if data else "") + + ".") + if _offline_only(): + print(" --offline: only the files found on this machine are installed.") # A dry run installs nothing: "install everything" was asked before it. print(" Enter) list everything, nothing is written" if dry_run else " Enter) install everything") @@ -8226,7 +8242,7 @@ def _check_copy_lines( if fixed: verb = "is" if fixed == 1 else "are" them = "it" if fixed == 1 else "them" - lines.append(f" {fixed} of these files {verb} in the BIOS folder at the wrong place: " + lines.append(f" {fixed} of these files {verb} in the BIOS folder in the wrong place: " f"a run without --check copies {them} where the cores open {them}, and " f"leaves your {'copy' if fixed == 1 else 'copies'} as " f"{'it is' if fixed == 1 else 'they are'}.") @@ -9257,7 +9273,7 @@ def _open_local_sources(args: argparse.Namespace) -> None: + (f", {parts} parts" if parts > 1 or _PART_FILE.match(pack.files[0].name) else "")) spelled = pack.platforms() if args.platform is not None: - plat = normalize_platform(args.platform) + plat = normalize_platform(args.platform, say=False) if spelled and plat not in spelled: print(f"Error: {pack.name} is a pack for " f"{' and '.join(display_name(n) for n in spelled)}, not " @@ -9495,18 +9511,31 @@ def _ends_with(path: Path, relative: str) -> bool: return len(parts) >= len(tail) and parts[len(parts) - len(tail):] == tail -def card_dest(named: str, plat: str, dest: Path) -> tuple[str, Path]: - """The file list and folder for a --dest that names a handheld's card. +class CardChoice(NamedTuple): + """Where the files of a folder named for a card go, and what is said: a + note, or why the folder is not the card of the system named.""" + + plat: str + folder: Path + note: str = "" + refused: str = "" + # The system's folder below its card, and the file that names the card. + below: str = "" + marker: str = "" + + +def card_choice(named: str, plat: str, dest: Path) -> CardChoice: + """The file list and folder for a folder that may be a handheld's card. The card's root is where the user points, its BIOS folder is below it: `--platform muos --dest /media/me/SD1` wrote to the root, which muOS - never reads. A system named on the command line whose card this is not - is refused with the folder it reads. + never reads. A system named whose card this is not is refused with the + folder it reads. """ found = card_system(dest) if found and (not plat or found[0] == plat) and found[1] != dest: - print(f" {found[2]} card: {display_name(found[0])} files, in {found[1]}") - return found[0], found[1] + return CardChoice(found[0], found[1], + f"{found[2]} card: {display_name(found[0])} files, in {found[1]}") # Only a system named by its own name: --platform retroarch or knulli # says which list, not which card. key = _name_key(named) @@ -9516,23 +9545,48 @@ def card_dest(named: str, plat: str, dest: Path) -> tuple[str, Path]: if rule is None or found: if len({(match.manifest, match.bios) for match in matched}) > 1: names = " and ".join(sorted({match.name for match in matched})) - print(f" {screen_text(str(dest))} holds the files of {names}: installing into " - "it as named. Name the card's BIOS folder with --dest to write there.") - return plat, dest + return CardChoice(plat, dest, f"{screen_text(str(dest))} holds the files of " + f"{names}: installing into it as named. Name the card's BIOS " + "folder with --dest to write there.") + return CardChoice(plat, dest) # Its folder on the card, or one its device reads (/run/muos/storage/bios). own = {device.bios for device in _EMBEDDED_RULES if device.name == rule.name} if dest.as_posix() in own or _ends_with(dest, rule.bios): - return plat, dest + return CardChoice(plat, dest) if any(match.name == rule.name for match in matched): # A card two systems claim, the one named among them. - print(f" {rule.name} card: {display_name(plat)} files, in {dest / rule.bios}") - return plat, dest / rule.bios + return CardChoice(plat, dest / rule.bios, f"{rule.name} card: {display_name(plat)} " + f"files, in {dest / rule.bios}") marker = rule.values[0].split("/")[0] - print(f"Error: {dest} is not a {rule.name} card: it holds no {marker}. " - f"{rule.name} reads its BIOS files from {rule.bios} on its card: name the card's " - f"root (the folder holding {marker}) or that folder: --dest /{rule.bios}", - file=sys.stderr) - sys.exit(1) + return CardChoice(plat, dest, refused=f"{dest} is not a {rule.name} card: it holds no " + f"{marker}. {rule.name} reads its BIOS files from {rule.bios} on its " + "card", below=rule.bios, marker=marker) + + +def card_dest(named: str, plat: str, dest: Path) -> tuple[str, Path]: + """The file list and folder for a --dest that names a handheld's card, + the run ended on a folder that is not the card of the system named.""" + choice = card_choice(named, plat, dest) + if choice.refused: + print(f"Error: {choice.refused}: name the card's root (the folder holding " + f"{choice.marker}) or that folder: --dest /{choice.below}", + file=sys.stderr) + sys.exit(1) + if choice.note: + print(f" {choice.note}") + return choice.plat, choice.folder + + +def card_root_above(dest: Path) -> tuple[str, Path] | None: + """(platform, root) of the MinUI-family card dest is inside, when dest is + a folder below its root and not the root.""" + if card_platform(dest) is not None: + return None + for parent in list(dest.absolute().parents)[:2]: + holder = card_platform(parent) + if holder: + return holder, parent + return None def _check_card_root(plat: str, dest: Path) -> None: @@ -9541,16 +9595,13 @@ def _check_card_root(plat: str, dest: Path) -> None: The file list writes Bios// below --dest: given the card's Bios folder, every file would land in Bios/Bios//, which no core reads. """ + above = card_root_above(dest) + if above: + print(f"Error: {dest} is inside a {display_name(above[0])} card. " + f"Name the card's root: --dest {above[1]}", file=sys.stderr) + sys.exit(1) found = card_platform(dest) - if found is None: - for parent in list(dest.absolute().parents)[:2]: - holder = card_platform(parent) - if holder: - print(f"Error: {dest} is inside a {display_name(holder)} card. " - f"Name the card's root: --dest {parent}", file=sys.stderr) - sys.exit(1) - return - if found != plat: + if found is not None and found != plat: print(f" Note: this card holds {display_name(found)} ({CARD_MARKER}), and the " f"{display_name(plat)} file list was asked for.") @@ -9579,13 +9630,50 @@ def mounted_cards_of(system: str, plat: str) -> list[Path]: if any(rule.name == system for rule in _card_matches(mount))] -def card_install_dest(named: str, plat: str, interactive: bool) -> Path: +def card_root_folder(named: str, plat: str, root: Path) -> CardChoice: + """The BIOS folder for a root chosen as the card of a card-bound system, + or why root is not that card. + + A typed root was taken as it was: a typo four folders deep was created + and filled, a menu answer (8) became a relative card, and a folder that + was no Onion card ended the run. Each is refused here, with its reason, + for the question to be asked again. + """ + system, below = card_bound(named, plat) or (display_name(plat), "") + # The card is mounted, its BIOS folder may not be made yet. + card = (root.parents[len(PurePosixPath(below).parts) - 1] + if below and _ends_with(root, below) else root) + if not _is_dir(card): + return CardChoice(plat, root, refused=f"{card} does not exist, or is not a folder") + if plat in CARD_PLATFORMS: + above = card_root_above(root) + if above: + return CardChoice(plat, root, refused=f"{root} is inside a " + f"{display_name(above[0])} card, whose root is {above[1]}") + found = card_platform(root) + if found is None: + return CardChoice(plat, root, refused=f"{root} holds no {CARD_MARKER}: it is " + f"not a {system} card") + note = ("" if found == plat else f"Note: this card holds {display_name(found)} " + f"({CARD_MARKER}), and the {display_name(plat)} file list was asked for.") + return CardChoice(plat, root, note) + choice = card_choice(named, plat, root) + if choice.refused or choice.folder != root or not below or _ends_with(root, below): + return choice + # A KNULLI share or a ROCKNIX card read as a list's name: its folder + # below the root, as the device reads it. + return choice._replace(folder=root / below) + + +def card_install_dest(named: str, plat: str, interactive: bool, ask: bool = True) -> Path: """The folder for a card-bound system named without --dest, on a machine that is not the device. One mounted card that runs it is taken. At a terminal the card's root is - asked for, never this machine's own folder. Without questions the run - stops on the --dest that names the card. + asked for, never this machine's own folder, and asked again until it is + that card. Without questions the run stops on the --dest that names the + card. ask is whether creating several missing folders is asked first, + as it is for --dest. """ system, below = card_bound(named, plat) or (display_name(plat), "") cards = mounted_cards_of(system, plat) @@ -9606,21 +9694,27 @@ def card_install_dest(named: str, plat: str, interactive: bool) -> Path: f"Mount the card and name its root with --dest, for example: {example}", file=sys.stderr) sys.exit(1) + # The drives the interface lists, each with what its own files say. + mounts = _mounted_cards() print(f" {system} keeps its BIOS files on its SD card, not on this machine. " - "Mount the card, then type its root folder.") - raw = "" - while not raw: + "Mount the card, then type its root folder" + + (" or its number:" if mounts else ".")) + for index, mount in enumerate(mounts, 1): + print(f" {index}) {mount} ({_card_what(mount)})") + while True: raw = _ask("Card root (Ctrl+C to stop): ") - root = _typed_folder(raw) - if plat in CARD_PLATFORMS: - _check_card_root(plat, root) - return root - folder = card_dest(named, plat, root)[1] - if folder == root and below and not _ends_with(root, below): - # A KNULLI share or a ROCKNIX card read as a list's name: its - # folder below the root, as the device reads it. - return root / below - return folder + if not raw: + continue + root = (mounts[int(raw) - 1] if raw.isdigit() and 1 <= int(raw) <= len(mounts) + else _typed_folder(raw)) + choice = card_root_folder(named, plat, root) + if choice.refused: + print(f" {choice.refused}. Type the root of the {system} card.") + continue + if choice.note: + print(f" {choice.note}") + _missing_dest(choice.folder, ask, "typed") + return choice.folder def _platform_for_dest(os_type: str, dest: Path, interactive: bool) -> str: @@ -9759,8 +9853,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: elif bound is not None: # Its files go on its SD card, which is never this machine's # own folder. - platforms = [(args.platform, card_install_dest(named, args.platform, - interactive))] + platforms = [(args.platform, card_install_dest( + named, args.platform, interactive, + not (args.check or args.dry_run or args.yes)))] elif args.platform in PORTABLE_ONLY: # A portable emulator reads the folder beside its executable: # any default would be a folder it never opens. @@ -9828,9 +9923,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: for line in _manual_usage_hint(os_type, asked): print(line) sys.exit(1) - platforms = _prompt_manual_platform(os_type) + platforms = _prompt_manual_platform( + os_type, not (args.check or args.dry_run or args.yes)) plat, typed = platforms[0] - if typed != _default_dest(os_type, plat): + # A card's root was asked for, and its folders checked, there. + if typed != _default_dest(os_type, plat) and card_bound(plat, plat) is None: _missing_dest(typed, not (args.check or args.dry_run or args.yes), "typed") else: @@ -10100,6 +10197,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: selection = _prompt_custom_selection( plat_name, files, omitted_files, installed, display_name(plat_name, manifest), args.dry_run, + manifest.get("data_directories") or [], ) if selection is None: smaller = (" Nothing downloaded. To install less, run the same command " @@ -10298,9 +10396,25 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: continue if to_download: - dl_size = sum(f.get("size", 0) for f in to_download) fetched = [f for f in to_download if id(f) not in copies] + if interactive and not args.yes and _offline_only() and fetched: + # The screen offers no Install for these: the question asked + # to download 6.6 GB that --offline never fetches. + print(f"\n {_plural(len(fetched), 'file')} " + f"{'needs' if len(fetched) == 1 else 'need'} the internet, which " + "--offline does not use. Copy them from your old card or a folder " + "(--from), or run the installer again with a pack (--from-pack) or " + "without --offline.") + # Not done: the summary says they were not downloaded. + files_pending += len(fetched) + total_declined += 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) + if not to_download: + total_up_to_date += len(up_to_date) + continue if ( interactive and not args.yes @@ -10729,6 +10843,21 @@ def _split_word(word: str, room: int) -> list[str]: return pieces + [word] if word else pieces +_UNIT = re.compile(r"(B|KB|MB|GB)\b") + + +def _glued_words(text: str) -> list[str]: + """The words of text, a size kept with its unit: "5.1" / "KB" was + broken across two lines of a 40-column headline.""" + words: list[str] = [] + for word in text.split(): + if words and _UNIT.match(word) and re.fullmatch(r"\(?\d+(\.\d+)?", words[-1]): + words[-1] += f" {word}" + else: + words.append(word) + return words + + def _wrap(text: str, width: int, indent: str = " ") -> list[str]: """Lines of at most width cells, the ones after the first indented.""" if cell_width(text) <= width: @@ -10736,7 +10865,7 @@ def _wrap(text: str, width: int, indent: str = " ") -> list[str]: lead = text[:len(text) - len(text.lstrip(" "))] lines: list[str] = [] line = "" - for word in text.split(): + for word in _glued_words(text): for piece in _split_word(word, width - len(lead + indent)): prefix = lead + indent if lines else lead longer = f"{line} {piece}" if line else piece @@ -10795,7 +10924,9 @@ class TuiState: self.status: str = "" self.cols: int = 80 self.rows: int = 24 - self.now: float = 0.0 + # A check started before the loop's first tick counted the + # machine's uptime. + self.now: float = time.monotonic() # (platform, folder, label) for each setup found here. self.setups: list = [] self.notes: list = [] @@ -10814,6 +10945,8 @@ class TuiState: # (system, BIOS folder below the card root) when the setup asked for # runs from an SD card: the folder screen offers the mounted cards. self.card_bound: tuple[str, str] | None = None + # The mounted cards of that system, as last looked for. + self.cards: list | None = None # (width and text, lines) of the last pager wrapped. self.pager_cache: tuple | None = None self.path_text: str = "" @@ -10873,6 +11006,9 @@ class TuiState: self.must_confirm: bool = False # Choices, each one an option of the command line. self.ticked: set = set() + # The ticks the systems list opened with: Back with none ticked + # brings them back, nothing having been chosen. + self.ticked_before: set | None = None self.cli_systems: list = [] self.regions: list = [] self.cli_regions: list = [] @@ -11220,18 +11356,41 @@ def tui_rows(state: TuiState) -> list[TuiRow]: rows = [_filter_row(state)] for plat in AVAILABLE_PLATFORMS: named = _setup_matches(plat, systems.get(plat, []), needle) - if named is not None: + if named is None: + continue + # A system that runs from its SD card, named or run by a device + # typed, is a row of its own that asks for the card: "onion" on + # RetroArch's row led to this computer's RetroArch folder. + cards = [name for name in named + if name in systems.get(plat, []) and card_bound(name, plat)] + others = [name for name in named if name not in cards] + if not named or others: # The systems the filter named, those a device typed runs: # "miyoo" on RetroArch read "muOS, JELOS, KOLOS...". rows.append(TuiRow("platform", display_name(plat), - ", ".join(named or systems.get(plat, [])), plat)) + ", ".join(others or systems.get(plat, [])), plat)) + for name in cards: + devices = [device for device in _SETUP_DEVICES.get(name, ()) + if _matches(device, _device_needle(needle))] + rows.append(TuiRow("cardsystem", f"{name}, SD card", ", ".join(devices) + or f"{display_name(plat)} files", (plat, name))) return rows if screen == "folder": rows = [TuiRow("default", "Use this folder")] if state.folder_default else [] - if _tui_card_bound(state): - rows += [TuiRow("card", f"{_mount_name(mount)} ({_card_what(mount)})", value=mount) - for mount in _mounted_cards()] - return rows + [TuiRow("browse", "Browse for a folder"), TuiRow("type", "Type a path")] + bound = _tui_card_bound(state) + if not bound: + return rows + [TuiRow("browse", "Browse for a folder"), + TuiRow("type", "Type a path")] + # Only the cards whose own files say they run the system: A on the + # first mount wrote Onion's layout on a muOS card. + mounts = (state.cards if state.cards is not None + else mounted_cards_of(bound[0], state.platform)) + cards = [TuiRow("card", f"{_mount_name(mount)} ({_card_what(mount)})", value=mount) + for mount in mounts] + rows += cards + [TuiRow("browse", "Browse for a folder"), TuiRow("type", "Type a path")] + # On the device itself its folder is found, and no card is looked for. + here = state.folder_default is not None and state.folder_kind == "found" + return rows + ([] if cards or here else [TuiRow("rescan", "Look again")]) if screen == "browse": return list(state.browse_rows) if screen == "create": @@ -11261,6 +11420,9 @@ def tui_rows(state: TuiState) -> list[TuiRow]: if screen == "confirm": if state.running or state.dry is None: return [] + if _offline_only() and not _dry_copies(state): + # The plan's rule: Install would only fail every file. + return [TuiRow("back", "Back")] return [TuiRow("install", "Install"), TuiRow("back", "Back")] if screen == "error": rows = ([TuiRow("retry", "Try again")] @@ -11315,6 +11477,13 @@ def _plan_copies(state: TuiState, check: dict) -> list[str]: return [dest for dest in local if dest in sizes] +def _dry_copies(state: TuiState) -> list[str]: + """The files the confirm or preview run would install that this + machine holds.""" + local = {event["dest"] for event in state.found} + return [event["dest"] for event in state.dry_files if event["dest"] in local] + + def _short_of_space(state: TuiState, check: dict) -> bool: free = state.facts.get("free") return free is not None and check.get("bytes_needed", 0) > free @@ -11452,6 +11621,10 @@ def _back(state: TuiState) -> list[tuple]: state.stack.append("quit") state.focus["quit"] = 0 return effects + if state.screen == "systems" and not state.ticked and state.ticked_before: + # Enter on None, then Esc: the plan kept its headline over an + # Install the run refused. + state.ticked = set(state.ticked_before) state.stack.pop() if state.screen == "checking" and not state.running: # Stay after Esc on a check started from the command line: a @@ -11566,9 +11739,54 @@ def _choose(state: TuiState, plat: str, dest: Path) -> list[tuple]: return _start_check(state) +def _card_folder(state: TuiState, folder: Path) -> CardChoice | None: + """What a folder chosen for a setup that runs from its SD card resolves + to, as a card root typed in line mode does: None when no card is asked + for, or when the folder is inside a MinUI card, whose root _choose + offers.""" + bound = _tui_card_bound(state) + if bound is None or (state.platform in CARD_PLATFORMS and card_root_above(folder)): + return None + return card_root_folder(bound[0], state.platform, folder) + + +def _card_refusal(state: TuiState, folder: Path) -> str: + """Why folder is not the card the chosen setup runs from, or "".""" + choice = _card_folder(state, folder) + if choice is None or not choice.refused: + return "" + return f"{choice.refused}. Choose the root of the {_tui_card_bound(state)[0]} card." + + +def _choose_card(state: TuiState, folder: Path) -> list[tuple]: + """_choose for a folder picked on the folder, browse or path screen, or + given with --dest: for a setup that runs from its SD card, the card's + own BIOS folder, and a folder that is not its card refused in words.""" + choice = _card_folder(state, folder) + if choice is None: + return _choose(state, state.platform, folder) + if choice.refused: + state.status = _card_refusal(state, folder) + if state.stack and state.screen in ("folder", "browse", "path", "platforms"): + return [] + state.folder_default, state.folder_kind = None, "usual" + return _push(state, "folder") + card = card_system(folder) + if card and card[1] == choice.folder: + # Titled as the same card found by itself: "Onion (RetroArch files)". + _DETECTED_OS.setdefault((choice.plat, choice.folder), card[2]) + return _choose(state, choice.plat, choice.folder) + + def _open_folder_screen(state: TuiState, default: Path | None, kind: str = "usual") -> list[tuple]: state.folder_default, state.folder_kind = default, kind + # The cards are looked for when the screen opens and on Look again: a + # card inserted meanwhile moved the rows under the focus. + state.cards = None + bound = _tui_card_bound(state) + if bound: + state.cards = mounted_cards_of(bound[0], state.platform) refused = kind == "chosen" and (state.facts.get("writable") is False or _short_of_space(state, state.check or {})) # A folder just refused as full or read-only: Browse, not Use this @@ -11577,7 +11795,11 @@ def _open_folder_screen(state: TuiState, default: Path | None, def _mount_name(mount: Path) -> str: - """How a person names a mounted card: E:\\ on Windows, its label elsewhere.""" + """How a person names a mounted card: E:\\ on Windows, its label elsewhere. + Under WSL /mnt/d is the Windows D: drive, which "d" did not say.""" + if (_WSL["active"] and mount.parent == Path(_WSL["root"]) and len(mount.name) == 1 + and mount.name.isalpha()): + return f"{mount.name.upper()}:" return mount.name or str(mount) @@ -11740,14 +11962,22 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: if action == "another": return _push(state, "platforms", _platform_focus(state, "retroarch")) plat, path, _label = state.setups[row.value] + # A setup found here is its folder: a card looked at and backed + # out of described the RetroArch chosen next as that card. + state.card_bound = None return _choose(state, plat, path) if screen == "platforms": + plat, system = row.value if action == "cardsystem" else (row.value, row.value) + state.platform = plat + state.card_bound = card_bound(system, plat) if state.dest_given is not None: - return _choose(state, row.value, state.dest_given) - state.platform = row.value - state.card_bound = card_bound(row.value, row.value) + return _choose_card(state, state.dest_given) if state.card_bound: - return _open_folder_screen(state, None) + # On the device itself, the folder detection found there: a + # KNULLI was asked for its own card. + found = next((path for name, path, _label in state.setups if name == plat + and detected_os(name, path) == state.card_bound[0]), None) + return _open_folder_screen(state, found, "found" if found else "usual") # The folder detected here before the built-in one: the built-in # default was offered as where RetroArch reads, when the # retroarch.cfg found here names another. @@ -11758,9 +11988,16 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: if screen == "folder": if action == "default": return _choose(state, state.platform, state.folder_default) - if action == "card": - below = (_tui_card_bound(state) or ("", ""))[1] - return _choose(state, state.platform, row.value / below if below else row.value) + if action in ("card", "rescan"): + if action == "rescan": + state.cards = mounted_cards_of(_tui_card_bound(state)[0], state.platform) + cards = [index for index, item in enumerate(tui_rows(state)) + if item.action == "card"] + state.focus["folder"] = cards[0] if cards else state.focus.get("folder", 0) + state.status = ("" if cards else f"Still no {_tui_card_bound(state)[0]} card " + "is mounted.") + return [] + return _choose_card(state, row.value) if action == "browse": state.browse_for = "dest" _push(state, "browse") @@ -11784,7 +12021,7 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: # The check runs again with it: the plan then says what it holds. return _start_check(state) if action == "use": - return _choose(state, state.platform, state.browse_dir) + return _choose_card(state, state.browse_dir) if action == "up" and state.browse_dir is not None: if row.value == state.browse_dir: _browse_to(state, None if os.name == "nt" else state.browse_dir) @@ -11817,6 +12054,8 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: return _open_from_browser(state) if action in ("misplaced", "unreadable"): return _push(state, action) + if action == "systems": + state.ticked_before = set(state.ticked) if action == "systems" and len(state.systems) > TUI_LONG_LIST: # On None: picking one system of 66 began with Up, Up, A. return _push(state, action, 1) @@ -12147,8 +12386,12 @@ def _path_key(state: TuiState, key: TuiKey) -> list[tuple]: if closed is not None: state.status = _closed_status(state, closed) return [] + refused = _card_refusal(state, path) + if refused: + state.status = refused + return [] state.stack.pop() - return _choose(state, state.platform, path) + return _choose_card(state, path) return [] @@ -12505,6 +12748,9 @@ def _route_hint(state: TuiState, parts: tuple) -> tuple: named.append(key + sep + what) if state.pad == "muxterm" and state.screen == "path": named.append("Select Keyboard") + if state.pad == "miyoo" and state.screen in _CHECKLISTS: + tick = [entry for entry in named if entry.startswith("A ")] + named = named[:1] + tick + [entry for entry in named[1:] if entry not in tick] return tuple(named) @@ -12542,6 +12788,7 @@ _ROW_VERBS = { "jobs": "Change", "retry": "Try again", "back": "Back", "stay": "Stay", "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", } @@ -12773,15 +13020,18 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: lines.append("You cannot write to this folder: choose another one.") counts = (f"{check['present']} in place, {check['missing']} missing, " f"{wrong} wrong {'copy' if wrong == 1 else 'copies'}") + lines.append(counts) if copied and fetch: # 47 to download above 49 missing and 1 wrong copy: the 3 found on - # this machine are the difference, said where the sum is read. - counts += (f": {len(copied)} of them {'is' if len(copied) == 1 else 'are'} " - f"copied from this machine, {fetch} downloaded") - lines.append(counts) - saves = (state.saves if check is state.check + # this machine are the difference, on a line of its own ("0 wrong + # copies: 2 of them" read as two of the zero). + lines.append(f"{need} to install: {len(copied)} copied from this machine, {fetch} " + + ("from the pack" if LOCAL.pack is not None else "downloaded")) + after_counts = len(lines) + saves =(state.saves if check is state.check else state.dry_saves if check is state.dry else None) - lines += [_styled(line, "path") for line in _saves_lines(state, saves)] + saves_lines = _saves_lines(state, saves) + lines += [_styled(line, "path" if index else "") for index, line in enumerate(saves_lines)] found_in = _retroarch_config_at(dest) if dest is not None else None if found_in: lines.append(_styled(f"Named by {_short_path(Path(found_in[0]), state.home)}", "path")) @@ -12798,7 +13048,7 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: f"{'one' if wrong == 1 else 'ones'} kept in a backup folder.") misplaced = _tui_misplaced(state) if check is state.check else [] if misplaced: - lines.insert(3, f"{_plural(len(misplaced), 'file')} " + lines.insert(after_counts, f"{_plural(len(misplaced), 'file')} " f"{'is' if len(misplaced) == 1 else 'are'} in the wrong place, " f"{'it' if len(misplaced) == 1 else 'they'} will be copied where the " "emulators read " @@ -12821,7 +13071,7 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: lines += _unavailable_lines(state) if check is state.check: lines += _unplaced_lines(state.check_summary, state, - "Choose it in Options, Save folder.") + "Choose it in Options, Save folder (--save-dir).") return lines[:2] + offline_lines + lines[2:] @@ -12833,13 +13083,16 @@ def _saves_lines(state: TuiState, saves: dict | None) -> list[str]: return [] name = display_name(state.platform) if state.platform else "the frontend" count = saves["files"] + # The folder alone is cut in the middle: cut with the sentence, it + # lost "directory goes to" even at 80 columns. return [f"{_plural(count, 'file')} read from {name}'s save directory " - f"{'goes' if count == 1 else 'go'} to " - f"{_short_path(Path(saves['folder']), state.home)}"] + f"{'goes' if count == 1 else 'go'} to:", + _short_path(Path(saves['folder']), state.home)] def _unplaced_lines(summary: dict | None, state: TuiState, - remedy: str = "Name it with --save-dir.") -> list[str]: + remedy: str = "Choose it in Options, Save folder (--save-dir), " + "next time.") -> 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 [] @@ -12889,12 +13142,22 @@ def _view_platforms(state: TuiState, width: int, height: int) -> tuple: def _view_folder(state: TuiState, width: int, height: int) -> tuple: name = display_name(state.platform) bound = _tui_card_bound(state) - if bound: + if bound and state.folder_default is not None and state.folder_kind == "found": + # On the device itself: its own folder, which is no card to look for. + title = "Which folder?" + before = [f"{bound[0]} reads its BIOS files from:", str(state.folder_default)] + elif bound: title = "Where is the SD card?" before = [f"{bound[0]} keeps its BIOS files on its SD card. Choose the card's " "root folder" + (", the one holding .system." if state.platform in CARD_PLATFORMS else ".")] + if state.folder_default is not None: + # Use this folder takes it: named, not left to guess. + before += [f"The BIOS folder chosen for {bound[0]}:", str(state.folder_default)] + if any(row.action == "rescan" for row in tui_rows(state)): + before.append(f"No {bound[0]} card is mounted. Insert it, then choose " + "Look again.") elif state.folder_default is not None: title = "Which folder?" lead = {"found": f"{name} reads its BIOS files from:", @@ -13052,8 +13315,7 @@ def _view_systems(state: TuiState, width: int, height: int) -> tuple: footer.insert(0, OTHER_HELP) elif "shared" in focused.right: count = _shared_needed(state)[focused.value][0] - footer.insert(0, f"{count} of these files are shared with other systems, " - "counted once in the total.") + footer.insert(0, f"+{count} shared: counted once") needle = state.filters.get("systems", "") after = [f"No system matches '{needle}'."] if len(rows) == 3 and needle else [] if state.status: @@ -13109,7 +13371,9 @@ _STATE_WORDS = {"missing": "missing", "wrong_hash": "wrong copy"} def _pager_items(state: TuiState) -> list: """The text of a screen that scrolls, the same for its view and its keys.""" if state.screen == "misplaced": - return [f"{event['dest']}: {event['reason']}" for event in _tui_misplaced(state)] + return ["They are copied where the emulators read them. Your copies stay where " + "they are.", ""] + [f"{event['dest']}: {event['reason']}" + for event in _tui_misplaced(state)] if state.screen == "unreadable": return [f"{event['path']}" + (f", {event['member']}" if event["member"] else "") + f": {event['reason']}" for event in state.unreadable] @@ -13146,7 +13410,7 @@ def _view_unreadable(state: TuiState, width: int, height: int) -> tuple: def _view_misplaced(state: TuiState, width: int, height: int) -> tuple: - return ("Files in the wrong place", _pager(state, width, height, _pager_items(state)), + return ("Files in the wrong place", _pager(state, width, height, _pager_items(state), 2), ("A/Enter Back", "Up/Down Scroll")) @@ -13259,7 +13523,8 @@ def _tui_sources(state: TuiState, installed: int, failed: int) -> str: name = _last_name(folder) folders[name] = folders.get(name, 0) + 1 copied = fixed + sum(folders.values()) - return sources_line(list(folders.items()), fixed, max(0, installed - copied), failed) + return sources_line(list(folders.items()), fixed, max(0, installed - copied), failed, + "from the pack" if LOCAL.pack is not None else "downloaded") def _unavailable(state: TuiState) -> tuple[list[dict], int]: @@ -13359,7 +13624,7 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: ok = {dest for dest, good, _why in state.finished if good} if any(reason and dest in ok for dest, (_folder, reason) in state.local_dests.items()): # A fix copies: the misplaced files are the user's, never moved. - lines.append("Your copies at the wrong place were left as they are.") + lines.append("Your copies in the wrong place were left as they are.") lines.append(f"BIOS folder: {state.dest}") if installed + up_to_date: lines += _saves_lines(state, state.done_saves) @@ -13524,7 +13789,8 @@ def _help_lines(state: TuiState) -> list[tuple[str, str]]: keys = [(names["move"], "Move")] if len(tui_rows(state)) > TUI_LONG_LIST or screen in _PAGERS: keys += [*([(names["page"], "Page")] if names["page"] else []), - ("Home/End, g/G", "First, last")] + *([("Home/End, g/G", "First, last")] if state.pad in ("both", "keys") + else [])] primary = _primary(state) or _TUI_VIEWS[screen](state, 80, 24)[2][0] ok = names["ok"] if state.pad == "miyoo" and screen in _CHECKLISTS: @@ -13563,6 +13829,9 @@ def _primary(state: TuiState) -> str | None: verb = _ROW_VERBS.get(row.action) if state.screen == "systems" and row.action == "system" and not state.ticked: verb = "Only this system" + if state.screen == "systems" and row.action == "none" and not state.ticked: + # Nothing left to untick: A goes to the first system. + verb = "First system" return f"A/Enter {verb}" if verb else None @@ -13707,7 +13976,17 @@ def tui_start( if line.strip() and line.strip() not in unsaid] if plat and args.dest is not None: state.stack = [] - return state, _choose(state, plat, Path(args.dest)) + state.platform, state.card_bound = plat, card_bound(args.platform, plat) + dest = Path(args.dest) + if state.card_bound is None or plat in CARD_PLATFORMS: + return state, _choose(state, plat, dest) + # Read as the line mode reads --dest: a muOS card named for Onion + # gets MUOS/bios, a folder that is no Onion card is refused. + choice = card_choice(args.platform, plat, dest) + if choice.refused: + state.status = f"{choice.refused}. Choose the root of the {state.card_bound[0]} card." + return state, _open_folder_screen(state, None) + return state, _choose(state, choice.plat, choice.folder) if plat: bound = card_bound(args.platform, plat) # A system that runs from its card is its card or the device, never diff --git a/install.sh b/install.sh index 66a5029b2..eeee0e727 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="000b7e9e965f6d81c254d7ee7967077f9bbae8aca2286179f31d4f4f7b555d06" +DEFAULT_INSTALL_SHA256="b7eec15ba746bbaaaea471396964a83a255a4a1c4bd51316fbdb0ff62a065169" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index baf3ec412..fb20109e6 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": 636292, - "sha256": "000b7e9e965f6d81c254d7ee7967077f9bbae8aca2286179f31d4f4f7b555d06", + "size": 650926, + "sha256": "b7eec15ba746bbaaaea471396964a83a255a4a1c4bd51316fbdb0ff62a065169", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 688841, - "sha256": "65c0ff1debcc384c1a6da526e4c92505a1628036cc4c7f51928d5253fbd07484" + "size": 703475, + "sha256": "1928a1ee174ab57d2890fa7d668a34fa996637669446bb6ad4329fa0fc3d76dc" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 636292, - "sha256": "000b7e9e965f6d81c254d7ee7967077f9bbae8aca2286179f31d4f4f7b555d06", + "size": 650926, + "sha256": "b7eec15ba746bbaaaea471396964a83a255a4a1c4bd51316fbdb0ff62a065169", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 636292, - "sha256": "000b7e9e965f6d81c254d7ee7967077f9bbae8aca2286179f31d4f4f7b555d06", + "size": 650926, + "sha256": "b7eec15ba746bbaaaea471396964a83a255a4a1c4bd51316fbdb0ff62a065169", "executable": false }, { diff --git a/tests/fixtures/tui/folder-card-120x40.txt b/tests/fixtures/tui/folder-card-120x40.txt index 8019406eb..cc6fe0e7a 100644 --- a/tests/fixtures/tui/folder-card-120x40.txt +++ b/tests/fixtures/tui/folder-card-120x40.txt @@ -2,11 +2,11 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the one holding .system. + No NextUI card is mounted. Insert it, then choose Look again. > Browse for a folder Type a path - - + Look again diff --git a/tests/fixtures/tui/folder-card-40x12.txt b/tests/fixtures/tui/folder-card-40x12.txt index e704b9fa0..685c39f1c 100644 --- a/tests/fixtures/tui/folder-card-40x12.txt +++ b/tests/fixtures/tui/folder-card-40x12.txt @@ -3,10 +3,10 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the one holding .system. + No NextUI card is mounted. Insert it, + then choose Look again. > Browse for a folder Type a path - - - + Look again A/Enter Browse B/Esc Back ? Help diff --git a/tests/fixtures/tui/folder-card-40x15.txt b/tests/fixtures/tui/folder-card-40x15.txt index 9d517731c..07df2e20b 100644 --- a/tests/fixtures/tui/folder-card-40x15.txt +++ b/tests/fixtures/tui/folder-card-40x15.txt @@ -3,12 +3,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the one holding .system. + No NextUI card is mounted. Insert it, + then choose Look again. > Browse for a folder Type a path - - - + Look again diff --git a/tests/fixtures/tui/folder-card-80x24.txt b/tests/fixtures/tui/folder-card-80x24.txt index 1d8c2ad33..29ccce655 100644 --- a/tests/fixtures/tui/folder-card-80x24.txt +++ b/tests/fixtures/tui/folder-card-80x24.txt @@ -2,11 +2,11 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the one holding .system. + No NextUI card is mounted. Insert it, then choose Look again. > Browse for a folder Type a path - - + Look again diff --git a/tests/fixtures/tui/route-evmapy.txt b/tests/fixtures/tui/route-evmapy.txt index 4e6ede706..8e345f3e6 100644 --- a/tests/fixtures/tui/route-evmapy.txt +++ b/tests/fixtures/tui/route-evmapy.txt @@ -166,12 +166,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the one holding .system. + No NextUI card is mounted. Insert it, + then choose Look again. > Browse for a folder Type a path - - - + Look again A Browse B Back === folder-card 40x15 RetroBIOS: Where is the SD card? @@ -179,12 +179,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the one holding .system. + No NextUI card is mounted. Insert it, + then choose Look again. > Browse for a folder Type a path - - - + Look again @@ -194,11 +194,11 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the one holding .system. + No NextUI card is mounted. Insert it, then choose Look again. > Browse for a folder Type a path - - + Look again diff --git a/tests/fixtures/tui/route-gptokeyb.txt b/tests/fixtures/tui/route-gptokeyb.txt index 9eb848541..fb8dbe626 100644 --- a/tests/fixtures/tui/route-gptokeyb.txt +++ b/tests/fixtures/tui/route-gptokeyb.txt @@ -166,12 +166,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the one holding .system. + No NextUI card is mounted. Insert it, + then choose Look again. > Browse for a folder Type a path - - - + Look again A Browse B Back === folder-card 40x15 RetroBIOS: Where is the SD card? @@ -179,12 +179,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the one holding .system. + No NextUI card is mounted. Insert it, + then choose Look again. > Browse for a folder Type a path - - - + Look again @@ -194,11 +194,11 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the one holding .system. + No NextUI card is mounted. Insert it, then choose Look again. > Browse for a folder Type a path - - + Look again diff --git a/tests/fixtures/tui/route-gptokeyb_muxterm.txt b/tests/fixtures/tui/route-gptokeyb_muxterm.txt index 9eb848541..fb8dbe626 100644 --- a/tests/fixtures/tui/route-gptokeyb_muxterm.txt +++ b/tests/fixtures/tui/route-gptokeyb_muxterm.txt @@ -166,12 +166,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the one holding .system. + No NextUI card is mounted. Insert it, + then choose Look again. > Browse for a folder Type a path - - - + Look again A Browse B Back === folder-card 40x15 RetroBIOS: Where is the SD card? @@ -179,12 +179,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the one holding .system. + No NextUI card is mounted. Insert it, + then choose Look again. > Browse for a folder Type a path - - - + Look again @@ -194,11 +194,11 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the one holding .system. + No NextUI card is mounted. Insert it, then choose Look again. > Browse for a folder Type a path - - + Look again diff --git a/tests/fixtures/tui/route-keys.txt b/tests/fixtures/tui/route-keys.txt index 3d65eeba5..e63c23a8a 100644 --- a/tests/fixtures/tui/route-keys.txt +++ b/tests/fixtures/tui/route-keys.txt @@ -166,12 +166,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the one holding .system. + No NextUI card is mounted. Insert it, + then choose Look again. > Browse for a folder Type a path - - - + Look again Enter Browse Esc Back ? Help === folder-card 40x15 RetroBIOS: Where is the SD card? @@ -179,12 +179,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the one holding .system. + No NextUI card is mounted. Insert it, + then choose Look again. > Browse for a folder Type a path - - - + Look again @@ -194,11 +194,11 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the one holding .system. + No NextUI card is mounted. Insert it, then choose Look again. > Browse for a folder Type a path - - + Look again diff --git a/tests/fixtures/tui/route-miyoo.txt b/tests/fixtures/tui/route-miyoo.txt index 0453c4cd9..10ea786ff 100644 --- a/tests/fixtures/tui/route-miyoo.txt +++ b/tests/fixtures/tui/route-miyoo.txt @@ -166,12 +166,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the one holding .system. + No NextUI card is mounted. Insert it, + then choose Look again. > Browse for a folder Type a path - - - + Look again A Browse R2 Back === folder-card 40x15 RetroBIOS: Where is the SD card? @@ -179,12 +179,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the one holding .system. + No NextUI card is mounted. Insert it, + then choose Look again. > Browse for a folder Type a path - - - + Look again @@ -194,11 +194,11 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the one holding .system. + No NextUI card is mounted. Insert it, then choose Look again. > Browse for a folder Type a path - - + Look again @@ -550,7 +550,7 @@ 2 of 3 systems: 4 files, 1.0 MB - Start Continue R2 Back A Tick + Start Continue A Tick R2 Back === systems 40x15 RetroBIOS: Choose systems @@ -566,7 +566,7 @@ 2 of 3 systems: 4 files, 1.0 MB - Start Continue R2 Back A Tick + Start Continue A Tick R2 Back === systems 80x24 RetroBIOS: Choose systems @@ -591,7 +591,7 @@ 2 of 3 systems: 4 files, 1.0 MB - Start Continue R2 Back A Tick + Start Continue A Tick R2 Back === options 40x12 RetroBIOS: Options @@ -712,7 +712,7 @@ - Start Filter R2 Back A Tick + Start Filter A Tick R2 Back === cores 40x15 RetroBIOS: Cores @@ -728,7 +728,7 @@ - Start Filter R2 Back A Tick + Start Filter A Tick R2 Back === cores 80x24 RetroBIOS: Cores @@ -753,7 +753,7 @@ - Start Filter R2 Back A Tick + Start Filter A Tick R2 Back === preview 40x12 RetroBIOS: Preview diff --git a/tests/fixtures/tui/route-muxterm.txt b/tests/fixtures/tui/route-muxterm.txt index f8c4759d4..098956fd3 100644 --- a/tests/fixtures/tui/route-muxterm.txt +++ b/tests/fixtures/tui/route-muxterm.txt @@ -166,12 +166,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the one holding .system. + No NextUI card is mounted. Insert it, + then choose Look again. > Browse for a folder Type a path - - - + Look again Start Browse B Back === folder-card 40x15 RetroBIOS: Where is the SD card? @@ -179,12 +179,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the one holding .system. + No NextUI card is mounted. Insert it, + then choose Look again. > Browse for a folder Type a path - - - + Look again @@ -194,11 +194,11 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the one holding .system. + No NextUI card is mounted. Insert it, then choose Look again. > Browse for a folder Type a path - - + Look again diff --git a/tests/test_install_from.py b/tests/test_install_from.py index ef445ca50..d6bd96476 100644 --- a/tests/test_install_from.py +++ b/tests/test_install_from.py @@ -438,7 +438,7 @@ class MisplacedInTheBiosFolder(Scratch): for name, data in PAYLOAD.items(): self.place(f"system/{name}", data) out = self.check() - self.assertIn("4 of these files are in the BIOS folder at the wrong place: a run " + self.assertIn("4 of these files are in the BIOS folder in the wrong place: a run " "without --check copies them where the cores open them, and leaves " "your copies as they are.", out) self.assertFalse((self.dest / "scph5501.bin").exists()) diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 183d44cef..02d7a0128 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -40,6 +40,7 @@ sys.path.insert(0, str(REPO_ROOT)) sys.path.insert(0, str(REPO_ROOT / "tests")) import install # noqa: E402 +from test_install_app import make_card # noqa: E402 from test_install_selection import FakeRepository, _QuietHandler # noqa: E402 try: @@ -1294,6 +1295,20 @@ class ControlCharacters(unittest.TestCase): class SmallFixes(unittest.TestCase): + def test_a_system_name_is_said_once_with_a_pack(self): + """tui_audit_5 P3-8.1: "Onion gets the RetroArch file list" was + printed by the pack check, then again where the platform is read.""" + pack = mock.Mock(files=[Path("RetroArch_BIOS_Pack.zip")]) + pack.name = "RetroArch_BIOS_Pack.zip" + pack.platforms.return_value = ["retroarch"] + args = argparse.Namespace(manifest=None, from_pack=Path("p.zip"), platform="onion", + **{"from": None}) + with mock.patch.object(install, "PackSource", return_value=pack), \ + contextlib.redirect_stdout(io.StringIO()) as out: + install._open_local_sources(args) + self.assertNotIn("Onion gets", out.getvalue()) + install.LOCAL.reset() + def test_a_command_line_region_the_list_lacks(self): state = checked_state() state.cli_regions = ["europe"] @@ -1727,7 +1742,8 @@ class AuditRoundTwo(unittest.TestCase): state = fresh_state() state.stack = ["platforms"] press(state, "/", "m", "u", "o", "s") - self.assertEqual([row.value for row in install.tui_rows(state)][1:], ["retroarch"]) + self.assertEqual([row.value for row in install.tui_rows(state)][1:], + [("retroarch", "muOS")]) def test_p2_13_a_long_preview_wraps_only_what_is_in_view(self): state = checked_state() @@ -2028,7 +2044,7 @@ class CopyFromAnOldCard(unittest.TestCase): state.finished = [("scph5500.bin", True, ""), ("gba_bios.bin", True, "")] text = " ".join(" ".join(install.tui_outcome(state)[1]).split()) self.assertIn("1 file fixed from copies already in the BIOS folder, 1 downloaded. " - "Your copies at the wrong place were left as they are.", text) + "Your copies in the wrong place were left as they are.", text) def test_a_local_copy_is_no_download_speed(self): state = checked_state() @@ -2130,6 +2146,11 @@ class AuditRoundThree(unittest.TestCase): def test_p1_1_a_drive_letter_names_the_card(self): self.assertEqual(install._mount_name(PureWindowsPath("E:\\")), "E:\\") self.assertEqual(install._mount_name(Path("/media/me/MUOS")), "MUOS") + # tui_audit_5 P3-2: under WSL the card screen listed "d (card or drive)". + with mock.patch.dict(install._WSL, {"active": True}): + self.assertEqual(install._mount_name(Path("/mnt/d")), "D:") + self.assertEqual(install._mount_name(Path("/mnt/wslg")), "wslg") + self.assertEqual(install._mount_name(Path("/mnt/d")), "d") def test_p1_1_on_the_device_its_card_is_not_offered_twice(self): root = self.card(self.CARDS["muOS"][0]) @@ -2411,7 +2432,7 @@ class AuditRoundThree(unittest.TestCase): self.assertEqual(rows[three].right, "1 file, 100 B + 2 shared") state.focus["systems"] = three text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) - self.assertIn("2 of these files are shared with other systems, counted once", text) + self.assertIn("+2 shared: counted once", text) # The line mode counts the same files apart. self.assertIn("and 2 files, 500 B shared with other systems", install._system_sized([ {"dest": "panafz1.bin", "size": 100, "systems": ["3do"]}, @@ -2746,11 +2767,12 @@ class AuditRoundFour(unittest.TestCase): plan = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) # The plan names the row that sets it, the result the option a # command carries. - self.assertIn(line + "Choose it in Options, Save folder.", plan) + self.assertIn(line + "Choose it in Options, Save folder (--save-dir).", plan) state.stack.append("result") state.code = 0 state.summary = {**summary, "mode": "install", "installed": 5} - self.assertIn(line + "Name it with --save-dir.", install.tui_outcome(state)[1]) + self.assertIn(line + "Choose it in Options, Save folder (--save-dir), next time.", + install.tui_outcome(state)[1]) class TheSaveFolderHasARow(unittest.TestCase): @@ -2849,23 +2871,201 @@ class ACardSystemNamedOnAComputer(unittest.TestCase): self.assertEqual(install.card_install_dest("knulli", "knulli", True), knulli / "bios") - def test_the_interface_opens_the_card_screen_with_the_cards_first(self): + def ask(self, *answers: str): + """_ask answering in turn, and the prompts it was asked.""" + given = iter(answers) + asked: list = [] + + def answer(prompt: str) -> str: + asked.append(prompt) + return next(given) + return mock.patch.object(install, "_ask", side_effect=answer), asked + + def test_a_typed_root_that_does_not_exist_is_asked_again(self): + """code_audit_12 P2-3: a typo four folders deep was created and + filled, and a menu answer (8) became the card.""" + onion = self.card(self.ONION) + typo = REPO_ROOT / "tmp" / "no-such" / "media" / "NEXT" + patcher, asked = self.ask("8", str(typo), str(onion)) + with mock.patch.object(install, "_mounted_cards", return_value=[]), patcher, \ + contextlib.redirect_stdout(io.StringIO()) as out: + dest = install.card_install_dest("onion", "retroarch", interactive=True) + self.assertEqual(dest, onion / "BIOS") + self.assertEqual(len(asked), 3) + self.assertIn("8 does not exist, or is not a folder. Type the root of the Onion card.", + out.getvalue()) + self.assertFalse((REPO_ROOT / "tmp" / "no-such").exists()) + + def test_a_typed_root_that_is_no_card_of_that_system_is_asked_again(self): + """code_audit_12 P3-4: a folder that was no Onion card ended the run.""" + blank, onion = self.card({"roms/a": ""}), self.card(self.ONION) + patcher, asked = self.ask(str(blank), str(onion)) + with mock.patch.object(install, "_mounted_cards", return_value=[]), patcher, \ + contextlib.redirect_stdout(io.StringIO()) as out: + dest = install.card_install_dest("onion", "retroarch", interactive=True) + self.assertEqual((dest, len(asked)), (onion / "BIOS", 2)) + self.assertIn(f"{blank} is not a Onion card: it holds no .tmp_update.", out.getvalue()) + + def test_a_minui_root_is_a_minui_card(self): blank = self.card({"roms/a": ""}) - (blank / "BIOS").mkdir() + minui = self.card({".system/version.txt": "NextUI-20250101\n", "Bios/x": ""}) + patcher, asked = self.ask(str(minui / "Bios"), str(blank), str(minui)) + with mock.patch.object(install, "_mounted_cards", return_value=[]), patcher, \ + contextlib.redirect_stdout(io.StringIO()) as out: + dest = install.card_install_dest("nextui", "nextui", interactive=True) + self.assertEqual((dest, len(asked)), (minui, 3)) + self.assertIn(f"is inside a NextUI card, whose root is {minui}", out.getvalue()) + self.assertIn("holds no .system/version.txt: it is not a NextUI card", out.getvalue()) + + def test_the_mounted_drives_are_listed_and_taken_by_number(self): + """tui_audit_5 P3-8.4: the screen lists the mounted cards, the + prompt did not.""" + muos, blank = self.card({"MUOS/bios/x": ""}), self.card({"roms/a": ""}) + patcher, _asked = self.ask("2") + with mock.patch.object(install, "_mounted_cards", return_value=[muos, blank]), \ + patcher, contextlib.redirect_stdout(io.StringIO()) as out: + dest = install.card_install_dest("knulli", "knulli", interactive=True) + self.assertEqual(dest, blank / "bios") + self.assertIn(f"2) {blank} (card or drive)", out.getvalue()) + self.assertIn(f"1) {muos} (muOS card)", out.getvalue()) + + def test_creating_several_folders_on_the_card_is_asked(self): + root = self.card({"roms/a.txt": ""}) + rocknix = root / "R" + rocknix.mkdir() + patcher, asked = self.ask(str(rocknix), "n") + with mock.patch.object(install, "_mounted_cards", return_value=[]), patcher, \ + contextlib.redirect_stdout(io.StringIO()), \ + self.assertRaises(SystemExit) as stop: + install.card_install_dest("rocknix", "rocknix", interactive=True) + self.assertEqual(stop.exception.code, 2) + self.assertIn("[y/N]", asked[-1]) + self.assertFalse((rocknix / "roms").exists()) + + def test_the_menu_asks_a_card_system_for_its_card(self): + """code_audit_12 P3-3: ROCKNIX picked from the menu on a PC was + offered /storage/roms/bios.""" + card = self.card({"roms/a": ""}) + make_card(card, "ROCKNIX") + patcher, asked = self.ask(str(card)) + with mock.patch.object(install, "_choose_platform", return_value="rocknix"), \ + mock.patch.object(install, "_mounted_cards", return_value=[]), patcher, \ + contextlib.redirect_stdout(io.StringIO()): + chosen = install._prompt_manual_platform("linux") + self.assertEqual(chosen, [("rocknix", card / "roms" / "bios")]) + self.assertEqual(asked, ["Card root (Ctrl+C to stop): "]) + + def test_a_card_of_another_system_is_not_offered(self): + """tui_audit_5 P1-1: A on the first mount wrote Onion's BIOS/ on a + muOS card, which reads MUOS/bios.""" + muos, blank = self.card({"MUOS/bios/x": ""}), self.card({"roms/a": ""}) found = [("retroarch", SYSTEM_DIR)] - with mock.patch.object(install, "_mounted_cards", return_value=[blank]), \ + with mock.patch.object(install, "_mounted_cards", return_value=[muos, blank]), \ mock.patch.object(install, "detect_platforms", return_value=found), \ mock.patch.object(install, "detect_frontends", return_value=[]): state, effects = install.tui_start(_args(platform="onion"), "linux") self.assertEqual((state.screen, effects), ("folder", [])) rows = install.tui_rows(state) text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) - effects = install.tui_update(state, Key("enter")) - self.assertEqual([row.action for row in rows], ["card", "browse", "type"]) + self.assertEqual([row.action for row in rows], ["browse", "type", "rescan"]) + self.assertEqual(state.focus["folder"], 0) self.assertIn("Where is the SD card?", text) - self.assertIn("Onion keeps its BIOS files on its SD card", text) - self.assertEqual(effects, [("fetch", "retroarch")]) - self.assertEqual(state.dest, blank / "BIOS") + self.assertIn("No Onion card is mounted. Insert it, then choose Look again.", text) + + def test_only_the_cards_of_that_system_are_listed(self): + first, second = self.card(self.ONION), self.card(self.ONION) + muos = self.card({"MUOS/bios/x": ""}) + state = fresh_state() + state.stack = ["setup", "platforms"] + state.filters["platforms"] = "onion" + state.focus["platforms"] = 1 + with mock.patch.object(install, "_mounted_cards", return_value=[muos, first, second]): + press(state, "enter") + self.assertEqual(state.screen, "folder") + rows = install.tui_rows(state) + install.tui_update(state, Key("enter")) + self.assertEqual([(row.action, row.value) for row in rows][:2], + [("card", first), ("card", second)]) + self.assertNotIn("rescan", [row.action for row in rows]) + self.assertEqual(state.dest, first / "BIOS") + # Titled as the card found by itself (tui_audit_5 P3-7). + self.assertEqual(install.detected_os("retroarch", first / "BIOS"), "Onion") + + def test_look_again_finds_the_card_inserted(self): + onion = self.card(self.ONION) + mounts: list = [] + with mock.patch.object(install, "_mounted_cards", side_effect=lambda: list(mounts)), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]): + state, _effects = install.tui_start(_args(platform="onion"), "linux") + press(state, "down", "down", "enter") + self.assertEqual(state.status, "Still no Onion card is mounted.") + mounts.append(onion) + press(state, "enter") + rows = install.tui_rows(state) + self.assertEqual(rows[state.focus["folder"]].action, "card") + self.assertEqual(state.status, "") + + def test_a_folder_that_is_no_card_of_that_system_is_refused(self): + blank = self.card({"roms/a": ""}) + muos = self.card({"MUOS/bios/x": ""}) + with mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]): + state, _effects = install.tui_start(_args(platform="onion"), "linux") + press(state, "enter") + self.assertEqual(state.screen, "browse") + install._browse_to(state, blank) + effects = press(state, "enter") + self.assertEqual((effects, state.screen), ([], "browse")) + self.assertIn(f"{blank} is not a Onion card: it holds no .tmp_update.", + state.status) + self.assertIsNone(state.dest) + # A muOS card gets the folder muOS reads, as --dest does. + install._browse_to(state, muos) + press(state, "enter") + self.assertEqual(state.dest, muos / "MUOS" / "bios") + + def test_a_typed_card_root_is_checked_on_the_path_screen(self): + with mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]): + state, _effects = install.tui_start(_args(platform="rocknix"), "linux") + press(state, "down", "enter") + self.assertEqual(state.screen, "path") + state.path_text = str(REPO_ROOT / "tmp" / "no-such-card") + effects = press(state, "enter") + self.assertEqual((effects, state.screen), ([], "path")) + self.assertIn("does not exist, or is not a folder. Choose the root of the " + "ROCKNIX card.", state.status) + + def test_a_dest_that_is_no_card_of_that_system_is_refused(self): + blank = self.card({"roms/a": ""}) + with mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]): + state, effects = install.tui_start(_args(platform="onion", dest=str(blank)), + "linux") + self.assertEqual((effects, state.screen), ([], "folder")) + self.assertIn("is not a Onion card", state.status) + + def test_change_folder_names_the_folder_it_reuses(self): + """code_audit_12 P3-1: Use this folder on the card screen named + nothing.""" + onion = self.card(self.ONION) + state = checked_state() + state.platform, state.card_bound = "retroarch", ("Onion", "BIOS") + state.dest = onion / "BIOS" + press(state, "o") + rows = install.tui_rows(state) + state.focus["options"] = [row.action for row in rows].index("folder") + press(state, "enter") + with mock.patch.object(install, "_mounted_cards", return_value=[]): + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertEqual(state.screen, "folder") + self.assertIn("Where is the SD card?", text) + self.assertIn("The BIOS folder chosen for Onion:", text) + self.assertEqual(state.folder_default, onion / "BIOS") def test_the_interface_takes_the_one_onion_card_mounted(self): onion = self.card(self.ONION) @@ -2914,17 +3114,22 @@ class TheSetupFilterKnowsDevices(unittest.TestCase): state = fresh_state() state.stack = ["platforms"] state.filters["platforms"] = needle - return [row for row in install.tui_rows(state) if row.action == "platform"] + return [row for row in install.tui_rows(state) + if row.action in ("platform", "cardsystem")] def test_each_device_finds_every_setup_it_runs(self): for needle, setups in self.TYPED.items(): with self.subTest(needle=needle): - self.assertEqual({row.value for row in self.rows(needle)}, setups) + self.assertEqual({row.value[0] if row.action == "cardsystem" else row.value + for row in self.rows(needle)}, setups) def test_the_right_column_names_what_matched(self): - retroarch = {row.value: row for row in self.rows("miyoo")}["retroarch"] - self.assertEqual(retroarch.right.split(", "), - ["spruceOS", "Onion", "Allium", "Koriki", "MiyooCFW"]) + rows = self.rows("miyoo") + retroarch = {row.value: row for row in rows}["retroarch"] + self.assertEqual(retroarch.right, "MiyooCFW") + self.assertEqual([row.label for row in rows if row.action == "cardsystem"], + ["spruceOS, SD card", "Onion, SD card", "Allium, SD card", + "Koriki, SD card"]) deck = {row.value: row for row in self.rows("steam deck")} self.assertEqual(deck["retroarch"].right, "Steam Deck") self.assertIn("muOS", {row.value: row for row in self.rows("")}["retroarch"].right) @@ -2943,6 +3148,76 @@ class TheSetupFilterKnowsDevices(unittest.TestCase): tuple(str(name) for name in system["devices"])) +class ACardSetupIsOneDesign(unittest.TestCase): + """code_audit_12 P2-1 to P3-2, tui_audit_5 P1-2: a setup that runs from + its SD card, chosen on a computer, always asks for that card.""" + + def platforms(self, needle: str = "") -> install.TuiState: + state = fresh_state() + state.stack = ["setup", "platforms"] + state.filters["platforms"] = needle + state.focus["platforms"] = 1 + return state + + def test_a_handheld_typed_in_the_filter_asks_for_its_card(self): + for needle in ("onion", "miyoo mini", "rg35xx"): + with self.subTest(needle=needle): + state = self.platforms(needle) + with mock.patch.object(install, "_mounted_cards", return_value=[]): + row = install.tui_rows(state)[1] + effects = press(state, "enter") + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertEqual(row.action, "cardsystem") + self.assertEqual((effects, state.screen), ([], "folder")) + self.assertEqual(state.card_bound, install.card_bound(row.value[1], "retroarch")) + self.assertIn("Where is the SD card?", text) + self.assertNotIn("default", [r.action for r in install.tui_rows(state)]) + self.assertNotIn(str(SYSTEM_DIR), text) + + def test_retroarch_typed_is_still_this_computer(self): + state = self.platforms("retroarch") + press(state, "enter") + self.assertIsNone(state.card_bound) + self.assertEqual(state.folder_default, SYSTEM_DIR) + + def test_a_card_backed_out_of_is_forgotten(self): + state = fresh_state() + state.stack = ["setup"] + state.focus["setup"] = 2 + press(state, "enter") + state.focus["platforms"] = install._platform_focus(state, "rocknix") + with mock.patch.object(install, "_mounted_cards", return_value=[]): + press(state, "enter") + self.assertEqual(state.card_bound, ("ROCKNIX", "roms/bios")) + press(state, "esc", "esc") + self.assertEqual(state.screen, "setup") + state.focus["setup"] = 0 + with mock.patch.object(install.Path, "exists", return_value=True): + press(state, "enter") + self.assertIsNone(state.card_bound) + self.assertEqual((state.platform, state.dest), ("retroarch", SYSTEM_DIR)) + install._open_folder_screen(state, state.dest, "chosen") + title = install.tui_view(state, 80, 24)[0] + self.assertIn("Which folder?", title) + self.assertNotIn("card", [row.action for row in install.tui_rows(state)]) + + def test_on_the_device_its_own_folder_is_offered(self): + share = Path("/userdata/bios") + state = fresh_state() + state.setups = [("knulli", share, "KNULLI")] + state.stack = ["setup", "platforms"] + state.focus["platforms"] = install._platform_focus(state, "knulli") + with mock.patch.dict(install._DETECTED_OS, {("knulli", share): "KNULLI"}), \ + mock.patch.object(install, "_mounted_cards", return_value=[]): + press(state, "enter") + rows = [row.action for row in install.tui_rows(state)] + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertEqual(rows, ["default", "browse", "type"]) + self.assertEqual((state.folder_default, state.folder_kind), (share, "found")) + self.assertIn("Which folder? KNULLI reads its BIOS files from: /userdata/bios", text) + self.assertNotIn("No KNULLI card is mounted", text) + + class OneSystemInFewPresses(unittest.TestCase): """tui_audit_4 P3-10.11: one system with a controller took 15 presses on the fixture list, about 60 on muxterm, which has no page key.""" @@ -3167,6 +3442,164 @@ class OfflineInTheInterface(unittest.TestCase): self.assertIn("--offline", install.tui_command(state)) +class AuditFive(unittest.TestCase): + """tui_audit_5 P2-1, P2-2 and its P3 points.""" + + def long_list(self) -> install.TuiState: + state = OneSystemInFewPresses().long_list("keys") + state.cols, state.rows = 80, 24 + return state + + def test_back_from_none_keeps_the_ticks_it_opened_with(self): + state = self.long_list() + everything = set(state.ticked) + press(state, "down", "enter") + self.assertEqual(install.tui_rows(state)[state.focus["systems"]].action, "none") + press(state, "enter") + self.assertEqual(state.ticked, set()) + press(state, "up", "up") + self.assertEqual(install._primary(state), "A/Enter First system") + press(state, "esc") + self.assertEqual(state.screen, "plan") + self.assertEqual(state.ticked, everything) + text = " ".join(install.tui_view(state, 80, 24)) + self.assertNotIn("No system is ticked", text) + + def test_back_after_a_choice_keeps_the_choice(self): + state = self.long_list() + press(state, "down", "enter", "enter") + press(state, " ") + chosen = set(state.ticked) + press(state, "esc") + self.assertEqual((state.screen, state.ticked), ("plan", chosen)) + + def test_confirm_offline_with_nothing_on_hand_has_no_install(self): + state = checked_state() + state.stack.append("confirm") + state.dry = dict(CHECK) + state.dry_files = [dict(event, state="missing") for event in FILES] + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + rows = [row.action for row in install.tui_rows(state)] + text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + self.assertEqual(rows, ["back"]) + self.assertIn("Copy them from your old card or a folder", text) + state.found = [{"event": "found", "platform": "retroarch", + "dest": FILES[0]["dest"], "folder": "/media/old", + "path": "/media/old/x", "member": "", "reason": ""}] + self.assertEqual(install.tui_rows(state)[0].action, "install") + + def test_the_checking_clock_starts_at_zero(self): + state = install.TuiState() + self.assertLess(abs(state.now - time.monotonic()), 5) + + def test_a_pack_installs_from_the_pack(self): + state = checked_state() + state.found = [{"event": "found", "platform": "retroarch", "dest": FILES[0]["dest"], + "folder": "/media/old", "path": "/media/old/x", "member": "", + "reason": "misplaced"}] + with mock.patch.object(install.LOCAL, "pack", mock.Mock()): + text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + self.assertIn("1 copied from this machine,", text) + self.assertIn("from the pack", text) + self.assertNotIn("downloaded", text) + self.assertNotIn("of them", text) + state.finished = [(FILES[0]["dest"], True, ""), (FILES[1]["dest"], True, "")] + state.local_dests = {FILES[0]["dest"]: ("/media/old", "misplaced")} + self.assertIn("1 from the pack", install._tui_sources(state, 2, 0)) + + def test_the_wrong_place_screen_says_the_copies_stay(self): + state = checked_state() + state.stack.append("misplaced") + self.assertEqual(install._pager_items(state)[0], + "They are copied where the emulators read them. Your copies stay " + "where they are.") + + def test_the_save_folder_is_a_line_of_its_own(self): + state = checked_state() + saves = {"files": 1, "folder": str(HOME / ".config" / "retroarch" / "saves" / "")} + lines = install._saves_lines(state, saves) + self.assertEqual(lines, ["1 file read from RetroArch's save directory goes to:", + "~/.config/retroarch/saves/"]) + + def test_the_shared_note_is_one_line(self): + state = self.long_list() + state.cols, state.rows = 40, 12 + state.files = [{"event": "file", "dest": "mame.zip", "size": 10, + "systems": ["sys-00", "sys-01"]}] + state.stack.append("systems") + state.focus["systems"] = 3 + lines = install.tui_view(state, 40, 12) + self.assertIn(" +1 shared: counted once", lines) + + def test_the_save_folder_remedy_is_the_same_on_both_screens(self): + summary = {"skipped": [{"reason": "no_save_directory", "files": 1}]} + state = checked_state() + lines = install._unplaced_lines(summary, state) + self.assertIn("Options, Save folder (--save-dir)", lines[0]) + + def test_a_size_keeps_its_unit(self): + for width in range(20, 45): + for line in install._wrap("2 files to install from the pack, 5.1 KB", width): + self.assertNotEqual(line.strip(), "KB") + + def test_the_miyoo_bar_names_the_tick(self): + state = self.long_list() + state.pad = "miyoo" + press(state, "down", "enter") + state.ticked = set() + state.focus["systems"] = 3 + bar = install.tui_view(state, 40, 15)[-1] + self.assertIn("A Tick", bar) + self.assertTrue(bar.strip().startswith("Start Only this system")) + + def test_muxterm_help_names_no_home_key(self): + state = self.long_list() + state.pad = "muxterm" + press(state, "down", "enter") + keys = [key for key, _what in install._help_lines(state)] + self.assertNotIn("Home/End, g/G", keys) + state.pad = "keys" + self.assertIn("Home/End, g/G", [key for key, _what in install._help_lines(state)]) + + +class LineModeOffline(unittest.TestCase): + """tui_audit_5 P2-3 and P3-8.4: the line mode under --offline asked to + download, and said nothing of the data files.""" + + def test_nothing_is_offered_that_cannot_come(self): + with FakeRepository() as repo: + env = {**repo.env(), "TERM": "xterm", install.OFFLINE_VARIABLE: "1"} + term = Pty(["--plain", "--manifest", str(repo.root / "install" / "retroarch.json"), + "--dest", str(repo.dest), "--offline"], env) + term.wait("q) quit") + term.send("\r") + term.close() + out = term.raw.decode("utf-8", "replace") + self.assertIn("--offline: only the files found on this machine are installed.", out) + self.assertIn("5 files need the internet, which --offline does not use.", out) + self.assertNotIn("Downloading", out) + self.assertNotIn("failed", out.lower()) + self.assertIn("Nothing installed: 5 files not downloaded, which --offline does not do.", + out.replace("\r\n", " ").replace("\n", " ")) + + def test_the_data_files_are_counted_before_the_question(self): + files = [{"dest": "a.bin", "size": 1024}] + trees = [{"ref": "t", "files": {"x": {"size": 2048}, "y": {"size": 2048}}}] + with mock.patch.object(install, "_ask", return_value=""), \ + contextlib.redirect_stdout(io.StringIO()) as out: + install._prompt_custom_selection("retroarch", files, [], data_trees=trees) + self.assertIn("RetroArch: 1 file, 1.0 KB, and 2 data files (4.0 KB).", out.getvalue()) + + def test_one_save_file_goes(self): + files = [{"dest": "keys.txt", "root": "save", "size": 1}] + place = install.SavePlace(Path("/saves"), False) + with mock.patch.object(install, "save_directory_for", return_value=place), \ + contextlib.redirect_stdout(io.StringIO()) as out: + install.place_save_entries(files, Path("/bios"), {}, None, "RetroArch") + self.assertIn("1 file read from RetroArch's save directory goes to /saves", + out.getvalue()) + + class NarrowSystemNames(unittest.TestCase): """At 40 columns the size column took half the line: Game Boy, Game Boy Advance and Game Boy Color all read "Nintendo Gam".""" @@ -3280,7 +3713,7 @@ class TheSaveFolderLineFollowsTheChoice(unittest.TestCase): SAVES = {"event": "saves", "platform": "retroarch", "files": 1, "folder": "/home/user/MySaves/"} - LINE = "1 file read from RetroArch's save directory goes to ~/MySaves/" + LINE = "1 file read from RetroArch's save directory goes to: ~/MySaves/" def said(self, lines) -> str: return " ".join(" ".join(lines).split()) @@ -4082,11 +4515,11 @@ class EndToEnd(unittest.TestCase): self.assertIn("6 files to download", screen) # The second folder the run writes to, named before # anything is written, and again on the result. - self.assertIn("1 file read from RetroArch's save directory goes to " - "~/MySaves", screen) + self.assertIn("1 file read from RetroArch's save directory goes to:\n" + " ~/MySaves", screen) term.send("\r") screen = term.wait("6 files installed.") - self.assertIn("save directory goes to ~/MySaves", screen) + self.assertIn("save directory goes to:\n ~/MySaves", screen) term.send("\r") self.assertEqual(term.close(), 0) results.append(written(repo, saves)) @@ -4467,7 +4900,7 @@ class ThePlanNumbersAddUp(unittest.TestCase): state.found = [found("scph5500.bin", str(SYSTEM_DIR), "one folder too deep")] screen = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) self.assertIn("3 files to download", screen) - self.assertIn("1 in place, 3 missing, 1 wrong copy: 1 of them is copied from this " + self.assertIn("1 in place, 3 missing, 1 wrong copy 4 to install: 1 copied from this " "machine, 3 downloaded", screen) diff --git a/tests/test_save_root.py b/tests/test_save_root.py index e3cc58f81..0b1ae378a 100644 --- a/tests/test_save_root.py +++ b/tests/test_save_root.py @@ -736,7 +736,7 @@ class TheInstallerWritesThem(unittest.TestCase): b"keys below the save directory", ) self.assertFalse((app / "system" / "Azahar").exists()) - self.assertIn("read from RetroArch's save directory go to", proc.stdout) + self.assertIn("read from RetroArch's save directory goes to", proc.stdout) def test_where_save_dir_points(self): repo = _Repository(self, self.FILES) diff --git a/wiki/first-install.md b/wiki/first-install.md index b2ef663ec..8f61e7a76 100644 --- a/wiki/first-install.md +++ b/wiki/first-install.md @@ -11,8 +11,8 @@ From an empty BIOS folder to games that start. ## 1. Open a terminal and paste -A **terminal** is a window where you type commands. Paste the line for your -system, then press Enter. +A **terminal** is a window where you type commands. Paste your system's +line, then press Enter. @@ -81,9 +81,10 @@ It opens a menu: - **Right folder?** Press Enter. - **Wrong folder?** Choose **Another setup or folder**, then browse to yours. -- **A handheld's SD card in a reader?** It is listed, as **muOS SD card**. +- **A handheld's SD card?** Choose it, or its system under **Another setup + or folder**: **Where is the SD card?** -Arrows move, Enter chooses, Esc goes back. A handheld's A and B do the same. +Arrows move, Enter chooses, Esc goes back, as A and B on a handheld. ## 3. Install diff --git a/wiki/installer.md b/wiki/installer.md index ed96aee68..4ab99b424 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -132,7 +132,8 @@ much space is free, before anything is written. The other choices sit one press away. **Choose systems** ticks the consoles you play, with the total to download under the list. A long list opens on **None**: -A there unticks every system, and A on a system then installs that one alone. **Options** sets the +A or Enter there unticks every system, and A on a system then installs that one alone. Back +with nothing ticked keeps the ticks the list opened with. **Options** sets the preferred regions, game data, the installed cores only, the cores, the parallel downloads, the folder and, for a list with files read from the frontend's save directory, that directory. Each row names the option it sets, and **Show the @@ -144,7 +145,7 @@ files that folder holds and how many are left to download. After an install on a muOS, KNULLI or ROCKNIX card, the result offers **Put RetroBIOS on this card (update from the console later)**, and **Options** has the same row ([On a handheld](#on-a-handheld-the-retrobios-port)). Files the BIOS -folder holds at the wrong place are counted on the plan, and **Show the files +folder holds in the wrong place are counted on the plan, and **Show the files in the wrong place** lists each one with its reason. Back on the plan after a change of options, the folder is checked again, so its numbers are the exact ones for the new choice. Ticked systems get their exact count before the @@ -155,7 +156,7 @@ install starts. | Move | Up and Down, `k` and `j` | D-pad Up and Down | | Page | PgUp and PgDn | L1 and R1 | | First, last | Home and End, `g` and `G` | | -| Continue, open, choose | Enter, Space | A, Start | +| Continue, open, choose | Enter, and Space outside a list of ticks | A, Start | | Tick, untick | Space, Right, Left | X, D-pad Right and Left | | Back | Esc, Backspace | B, Select | | Options | `o`, Tab | Y | @@ -903,8 +904,8 @@ When a platform named with `--platform` is not found on the machine, the destination falls back to the directory its detection looks for: `/userdata/bios` for Batocera, `/recalbox/share/bios` for Recalbox, `/storage/system` for Lakka, `~/retrodeck` for RetroDECK, `~/Emulation/bios` -for EmuDeck (`C:\Emulation\bios` on Windows), `/storage/roms/bios` for ROCKNIX, -`/userdata/bios` for KNULLI, `/storage/roms` for AmberELEC and EmuELEC, `/media/fat/games` for MiSTer, +for EmuDeck (`C:\Emulation\bios` on Windows), `/storage/roms` for AmberELEC and +EmuELEC, `/media/fat/games` for MiSTer, `~/RetroPie/BIOS` for RetroPie, `/romm/library/bios` for RomM, `C:\RetroBat\bios` for RetroBat, and RetroArch's own default system directory for RetroArch. BizHawk has no such folder: it reads `Firmware` @@ -912,8 +913,15 @@ beside its executable wherever the archive was extracted, so a forced `--platform bizhawk` that finds no install stops and asks for `--dest`. MinUI, NextUI and MyMinUI keep their files on the SD card. Run on the device, the installer finds the card root. From a computer the card is mounted on, a -forced `--platform minui` stops and asks for the card's root as `--dest`, and a `--dest` that is a folder inside such a -card (its `Bios/`) is refused with the root to name instead. On Android a forced `--platform retroarch` falls back to +forced `--platform minui` uses the one MinUI card mounted. Otherwise the screen +opens **Where is the SD card?**, which lists only the cards of that system and +offers **Look again**, and `--plain` asks `Card root`, listing the mounted +drives by number. A root that does not exist is asked again, and so is a +folder without the card's own files for MinUI, NextUI, MyMinUI, Onion, muOS +and spruceOS. Without questions (`--no-input`) the run stops with a `--dest` +example. The same holds for Onion, muOS, spruceOS, KNULLI and ROCKNIX, also +when chosen from the setup list or its filter. A `--dest` that is a folder +inside such a card (its `Bios/`) is refused with the root to name instead. On Android a forced `--platform retroarch` falls back to `/RetroArch/system` instead, a home directory there belonging to Termux rather than to the frontend. Pass `--dest` to say where instead of relying on that. When detection met a folder this user cannot open, under the