diff --git a/install.ps1 b/install.ps1 index 615575afc..0abf59682 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "7757b36db5beb3cbeb3717e0736db630237e87da5189a14d5092e679b69156af" + $defaultInstallSha256 = "7029f605591f09c020aafcd521d8a7134ace78cc566435d8e76be33c42a9f6dc" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index d7b2a6bda..a18ab87c5 100644 --- a/install.py +++ b/install.py @@ -360,13 +360,13 @@ COLOUR_ROLES = { # zero is no news and stays plain: "0 missing" is not a warning. _STATUS_WORDS = tuple((re.compile(pattern), role) for pattern, role in ( (r"(? str: return f"{_plural(counts.files, 'file')}, {format_size(counts.size)}" -def kinds_words(bios: int, games: int, data: int = 0) -> str: - """BIOS files and game data files, counted apart, and the files of the - data trees when some were installed.""" - parts = [_plural(bios, "BIOS file"), _plural(games, "game data file")] +def installed_words(bios: int, games: int, data: int = 0) -> str: + """The kinds a run installed, those it installed none of left out: + "6 BIOS files" where the old card's result said no kind at all.""" + parts = [_plural(count, word) for count, word in ( + (bios, "BIOS file"), (games, "game data file"), (data, "data file")) if count] + if not parts: + return _plural(0, "file") + return f"{', '.join(parts[:-1])} and {parts[-1]}" if len(parts) > 1 else parts[0] + + +def parts_words(counts: Tally, data: int = 0, data_size: int | None = None, + left_out: int = 0) -> str: + """The files of a plan by kind, each with its size: BIOS files, game + data and the data files of the emulators' data folders, then the game + data left out. "" when one kind alone is counted and nothing is left + out. "8358 files and 2636 data files" sat above "3269 BIOS files and + 5089 game data files", which add up to 8358 only, and "No game data" + read as a list holding none.""" + parts = [] + if counts.bios or not (counts.games or data): + parts.append(f"{_plural(counts.bios, 'BIOS file')} ({format_size(counts.bios_size)})") + if counts.games: + parts.append(f"{_plural(counts.games, 'game data file')} " + f"({format_size(counts.game_size)})") if data: - parts.append(_plural(data, "data file")) - return f"{', '.join(parts[:-1])} and {parts[-1]}" + parts.append(_plural(data, "data file") + + (f" ({format_size(data_size)})" if data_size is not None else "")) + if len(parts) < 2 and not left_out: + return "" + text = f"{', '.join(parts[:-1])} and {parts[-1]}" if len(parts) > 1 else parts[0] + return text + (f". {_plural(left_out, 'game data file')} left out" if left_out else "") + + +def kinds_definition(games: bool, data: bool) -> str: + """What game data and data files are, one sentence each, for the kinds + a plan counts: said once, after the counts.""" + said = [words[0].upper() + words[1:] + "." for words, shown in ( + (GAME_DATA_WORDS, games), (DATA_FILES_WORDS, data)) if shown] + return " ".join(said) + + +def game_data_state(left_out: bool, games: Tally) -> str: + """The one label of the game data choice, its count after: the plan's + row and the line menu's g.""" + return (f"Game data: {'left out' if left_out else 'kept'} " + f"({_plural(games.games, 'file')}, {format_size(games.game_size)})") def split_words(counts: Tally) -> str: @@ -981,11 +1020,16 @@ def _left_out_lines(left_out: list[dict], every: bool) -> list[str]: omitted = sum(1 for entry in left_out if entry.get("reason") and entry.get("category", "") == group["category"]) aside = f", {omitted} of them not installable" if omitted else "" - lines.append(f" Left out as {group['label']} (--no-game-data): " - f"{_plural(len(dests), 'file')}{aside}: {', '.join(shown)}{more}") + lines.append(f" {left_out_words(group)} (--no-game-data){aside}: " + f"{', '.join(shown)}{more}") return lines +def left_out_words(group: dict) -> str: + """A left_out group in the plan's words: 6 game data files left out.""" + return f"{_plural(group['files'], group['label'] + ' file')} left out" + + def _left_out_events(left_out: list[dict]) -> list[dict]: """What --no-game-data left out, per category, every file named.""" groups: dict[str, dict] = {} @@ -5686,9 +5730,7 @@ class Invocation: ("--no-game-data", args.no_game_data), ("--declared-only", args.declared_only), ("--standalone-copies", args.standalone_copies), - # RETROBIOS_INSTALL_OFFLINE too: sudo does not pass it on, and the - # command it was told to run fetched what the run had left out. - ("--offline", getattr(args, "offline", False) or offline_requested()), + ("--offline", offline_option(getattr(args, "offline", False))), ("--check", args.check), ("--dry-run", args.dry_run), ): @@ -5994,6 +6036,10 @@ def _report_outcome( "install the new ones.") paths = paths + [folder for folder in elsewhere if folder not in paths] + # The kinds installed, on every end that installed some, under where + # they came from: as the screen's result says them. + installed_kinds = (f" Installed: {installed_words(kinds[0].bios, kinds[0].games, kinds[1])}." + if kinds and kinds[0].files and downloaded else "") if offline: remedy = _offline_remedy() if not downloaded and not errors: @@ -6018,6 +6064,8 @@ def _report_outcome( f"({offline_named()}){failed}.") if sources: say(f" {sources}") + if installed_kinds: + say(installed_kinds) # Under the files it is about: after the save-directory line, "They" # read as that one file. say(f" {remedy}") @@ -6046,6 +6094,8 @@ def _report_outcome( f"{_plural(errors, 'file')} failed.") if sources: say(f" {sources}") + if installed_kinds: + say(installed_kinds) say(f"Location: {_locations(paths)}") for line in kept + advice: say(line) @@ -6055,10 +6105,10 @@ def _report_outcome( say(f"\nDone. {_plural(downloaded, 'file')} installed, " f"{_plural(up_to_date, 'file')} already up to date.") - if kinds and kinds[0].games: - say(f" Installed: {kinds_words(kinds[0].bios, kinds[0].games, kinds[1])}.") if sources: say(f" {sources}") + if installed_kinds: + say(installed_kinds) say(f"Location: {_locations(paths)}") for line in kept: say(line) @@ -6113,6 +6163,10 @@ def _offline_failure(reason: str) -> bool: # What game data is, said wherever it is counted or chosen. GAME_DATA_WORDS = "game data: files a game or a game engine reads, of no use to the other cores" +# The third kind: the files of an emulator's data folder (a data_directories +# entry of the list), apart from BIOS files and game data. +DATA_FILES_WORDS = ("data files: the files an emulator reads from a data folder of its own, " + "such as Dolphin's Sys and PPSSPP's assets") def game_data_missing(names: str, count: int, folders: list[Path]) -> str: @@ -6195,7 +6249,10 @@ def _needed_lines(entries: list[dict], heading: str, every: bool = False) -> lis readers = f" ({', '.join(sorted(cores))})" if cores else "" lines.append(f" {system}{readers}: {', '.join(sorted(names))}") if not every and len(systems) > 8: - lines.append(f" ... and {len(systems) - 8} more systems (--check --verbose lists them all)") + # 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} " + "(--check --verbose lists them all)") lines.append(f" Each emulator's page says what these files are: {SITE_URL}emulators/") return lines @@ -6235,9 +6292,10 @@ def _print_plan( kept: dict[Path, list[int]] | None = None, copied: list[str] = (), verbose: bool = False, + manifest: dict | None = None, ) -> None: - """What a real run would fetch: the totals first, then by system, and - nothing written. + """What a real run would fetch: the totals first, then by system, each + by the title the list and the screen give it, and nothing written. The data trees count in the total and come before the closing line: printed after it, they left 2636 RetroArch files out of the figure a @@ -6278,7 +6336,8 @@ def _print_plan( print(f"\n Nothing to {verb}.") by_system: dict[str, list[dict]] = {} for entry in to_download: - system = ", ".join(entry_systems(entry)) or "other" + system = (", ".join(system_title(name, manifest) for name in entry_systems(entry)) + or OTHER_TITLE) by_system.setdefault(system, []).append(entry) shown = None if verbose else PLAN_FILES_PER_SYSTEM listing: list[str] = [] @@ -6305,7 +6364,9 @@ def _print_plan( for system in largest[:PLAN_LARGEST_SYSTEMS]: print(f" {term.bold(system)} {_sized(by_system[system])}") if len(largest) > PLAN_LARGEST_SYSTEMS: - print(f" ... and {len(largest) - PLAN_LARGEST_SYSTEMS} more 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.") for entry, needed in trees: source = LOCAL.pack.name if LOCAL.pack is not None else entry["url"] @@ -7185,10 +7246,15 @@ def ask_platform_folder(os_type: str, plat: str, ask: bool = True, print(f" {name}'s default BIOS folder: {shown_path(default)}{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 drive_targets(plat) + targets = (share_targets(plat) if shared else + [*(other_side(os_type, plat) if found is None else []), *drive_targets(plat)]) for index, (label, folder) in enumerate(targets, 1): made = "" if _is_dir(folder) else ", created" - print(f" {index}) {label} ({shown_path(folder)}{made})") + # A label that spells its folder (the Windows side's) is not + # followed by it again. + shown = shown_path(folder) + print(f" {index}) {label}" + (f" ({shown}{made})" if shown not in label + else f" ({made[2:]})" if made else "")) left_out = "" if shared else drives_left_out(plat) if left_out: print(f" {left_out}") @@ -7295,6 +7361,19 @@ def _built_in_dest(os_type: str, platform_name: str) -> Path | None: or (None if platform_name == "retrobat" else DEFAULT_DESTS.get(platform_name))) +def other_side(os_type: str, plat: str) -> list[tuple[str, Path]]: + """(label, folder) of the Windows side's usual folder under WSL, where + the one offered first is this Linux's: a Windows RetroArch outside the + folders detection knows never reads ~/.config/retroarch/system, and no + row reached C:\\ (tui_audit_12 P3-11).""" + if os_type != "wsl": + return [] + windows = _os_default_dests("windows").get(plat) + if windows is None or windows == _default_dest(os_type, plat): + return [] + return [(f"Windows {display_name(plat)}: {shown_path(windows)}", windows)] + + 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.""" @@ -7391,10 +7470,16 @@ def _ask(prompt: str) -> str: raise KeyboardInterrupt from None +# A quit chosen at a question or on the screen: nothing was installed, as +# gh ends a prompt cancelled. 130 is 128 plus SIGINT, which shells read as a +# run killed by Ctrl-C, and 0 let `install.sh && retroarch` start RetroArch +# on no BIOS. Ctrl-C and Ctrl-D keep 130. +QUIT_CODE = 2 + + def _quit() -> None: - """q at a question ends the run where Ctrl+C does, on 130: with 0, - `install.sh && retroarch` started RetroArch on no BIOS.""" - sys.exit(130) + """q at a question ends the run on QUIT_CODE.""" + sys.exit(QUIT_CODE) def _sized(files: list[dict]) -> str: @@ -7665,9 +7750,16 @@ def _prompt_custom_selection( cutting = bool(cut and cut[0]) shown, shown_omitted = ((leave_out_game_data(files, omitted)[:2]) if cutting else (files, omitted)) + # The game data of the choice the head counts, which g takes out of + # it or puts back: counted over the whole list, it said 5089 files + # under a head counting 12 for one system. Not offered for a + # choice that holds none. + games = tally(menu_choice(files, omitted, installed, current, False)[0] + if cut is not None else []) _print_menu_head(plat, shown, shown_omitted, installed, name, trees, bios_path, current, object() if toggled else given, manifest, narrowed, - menu_choice(files, omitted, installed, current, cutting)) + menu_choice(files, omitted, installed, current, cutting), + games.games if cutting else 0) if (current is not given or toggled) and command is not None: print(f" Same choice without questions: {screen_text(command(current))}") offline = _offline_only() @@ -7702,16 +7794,10 @@ def _prompt_custom_selection( copying = not dry_run if copying: print(" f) copy from a folder or an old card") - # The game data of the choice the head counts, which g takes out of - # it or puts back: counted over the whole list, it said 5089 files - # under a head counting 12 for one system. Not offered for a - # choice that holds none. - games = tally(menu_choice(files, omitted, installed, current, False)[0] - if cut is not None else []) if cut is not None and games.games: - print(f" g) {'keep' if cut[0] else 'leave out'} the " - f"{_plural(games.games, 'game data file')} ({format_size(games.game_size)}), " - f"{GAME_DATA_WORDS}") + # The screen's row: one label, its state first. + print(f" g) {game_data_state(cut[0], games)}: " + f"{'keep it' if cut[0] else 'leave it out'}") print(" q) quit") keys = ["", "q", *(["c"] if choosable else []), *(["i"] if installed is not None and current != INSTALLED_ONLY else []), @@ -7740,11 +7826,12 @@ def _print_menu_head( installed: tuple[list[dict], list[dict]] | None, name: str, trees: list[dict], bios_path: Path | None, current: object, given: object, manifest: dict | None, narrowed: bool, chosen: tuple[list[dict], list[dict]] | None = None, + cut_games: int = 0, ) -> None: """The plan above the line menu: what the choice holds, its folder, what stands in its way and where the save files go, as the screen's plan. chosen is what the choice keeps (menu_choice), the list every count of - the head is taken from.""" + the head is taken from. cut_games is the game data g left out of it.""" shown, kept_omitted = chosen or menu_choice(files, omitted, installed, current, False) # The trees of the systems kept: 2636 data files of blueMSX, Dolphin # and PPSSPP were counted for every --system tried. @@ -7772,9 +7859,14 @@ def _print_menu_head( print(f"\n{name or display_name(plat)}: {sized_words(counts)}" + (f", and {_plural(data, 'data file')} ({format_size(data_size)})" if data else "") + named + ".") - split = split_words(counts) if (manifest or {}).get("categorized") else "" - if split: - print(f" {split}.") + categorized = bool((manifest or {}).get("categorized")) + kinds = counts if categorized else tally({"size": e.get("size")} for e in shown) + parts = parts_words(kinds, data, data_size, cut_games) + if parts: + print(f" {parts}.") + defined = kinds_definition(categorized and bool(counts.games or cut_games), bool(data)) + if defined: + print(f" {defined}") if bios_path is None: return # Named before anything is written: line mode said where only after @@ -8570,6 +8662,14 @@ def offline_words() -> str: return f"{OFFLINE_WORDS} ({offline_named()})" +def offline_option(given: bool) -> bool: + """Whether a command given back to the user carries --offline: given, + or RETROBIOS_INSTALL_OFFLINE set. sudo does not pass the variable on, + and the command it was told to run fetched what the run had left out. + Line mode and the screen build their commands with this one rule.""" + return given or offline_requested() + + def offline_requested() -> bool: return os.environ.get(OFFLINE_VARIABLE, "") not in ("", "0") @@ -10609,14 +10709,13 @@ def main() -> None: blocker = "" if args.tui else tui_blocker(args) if args.tui or not (plain_requested(args) or args.declared_only or blocker): sys.exit(run_tui(args)) - small = re.match(r"the terminal is (\d+x\d+), smaller than", blocker or "") - # A pseudo-terminal nobody sized (script fed from a pipe) is 0x0: no window. - if (small and "0" not in small[1].split("x") and not plain_requested(args) - and not args.declared_only): + if not (plain_requested(args) or args.declared_only): # The questions came line by line with no word of why: the page - # says a smaller window says so (tui_audit_11 P3-4). - print(f"The window is {small[1]}: questions come line by line " - f"({TUI_MIN_COLS}x{TUI_MIN_ROWS} for the full screen).") + # says a smaller window says so (tui_audit_11 P3-4), and a TERM + # unset or dumb said nothing (tui_audit_12 P3-14). + said = line_mode_reason(blocker) + if said: + print(said) if args.events: # The events own stdout. The text a person reads goes to stderr, so a # program never has to tell one from the other. @@ -11112,6 +11211,11 @@ def _doubt_kinds(plats: tuple[str, ...]) -> str: for plat in plats) +def doubt_cards(plat: str) -> list[Path]: + """The mounted drives whose files fit plat and another system.""" + return [mount for mount in _mounted_cards() if plat in share_doubt(mount)[0]] + + def doubt_words(root: Path) -> str: """What is said of a mounted drive share_doubt cannot name, and how to name it: the same words on the first screen and in line mode.""" @@ -11654,6 +11758,7 @@ def card_install_dest(named: str, plat: str, interactive: bool, ask: bool = True # Pasted, the name of a system in words is two arguments: its key is one. word = named.strip() if _name_key(named) == named.strip().lower() else _name_key(named) example = f"--platform {word or plat} --dest {drive_example(detect_os(), '')}" + blank = [mount for mount in blank_cards_of(system, plat) if mount not in cards] if setup.known() and len(cards) > 1: print(f" {len(cards)} {system} cards are mounted: " + ", ".join(_mount_name(card) for card in cards) + ".", file=sys.stderr) @@ -11670,8 +11775,9 @@ def card_install_dest(named: str, plat: str, interactive: bool, ask: bool = True + ("" if setup.known() else f"RetroBIOS cannot tell {_a(system)} card by its files. ") + ("Type its number or its top folder:" if cards + else _blank_offer(system, len(blank)) if blank else "Mount the card, then type its top folder.")) - listed = _list_cards(cards, blank_cards_of(system, plat)) + listed = _list_cards(cards, blank) while True: numbers = ("" if not listed else " or 1" if len(listed) == 1 else f" or 1 to {len(listed)}") @@ -11705,6 +11811,15 @@ def card_install_dest(named: str, plat: str, interactive: bool, ask: bool = True return choice.folder +def _blank_offer(system: str, count: int) -> str: + """What line mode says above drives no file names, numbered for a + system whose card they may be: "Mount the card" was said of a card in + the reader.""" + if count == 1: + return f"Type 1 if it is the {system} card, or type its top folder." + return f"Type its number if one is the {system} card, or type its top folder." + + def _list_cards(cards: list[Path], blank: list[Path], none: str = "") -> list[Path]: """Number a system's cards, then the blank drives it may take (the screen's rows), and return them in that order. none is said when @@ -11930,6 +12045,23 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: print(f" {display_name(args.platform)} was not found here.") platforms = [(args.platform, ask_platform_folder( os_type, args.platform, not (args.check or args.dry_run or args.yes)))] + elif getattr(args, "platform_from_list", False): + # The list names the files, not the setup they are for: a + # 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") + EVENTS.emit("error", stage="detect", reason="not_found", platform=args.platform, + message=message) + print(f" {message}.", file=sys.stderr) + hint = Invocation() + hint.os_type, hint.options = os_type, _INVOCATION.options + example = _default_dest(os_type, args.platform) + print(" Name its folder with --dest, for example:\n " + + hint.command(args.platform, example if example is not None + else ""), file=sys.stderr) + sys.exit(1) elif args.platform in PORTABLE_ONLY: # A portable emulator reads the folder beside its executable: # any default would be a folder it never opens. @@ -12356,7 +12488,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: before = len(files) files, omitted_files = narrow(files, omitted_files, *selection) unplaced = narrow([], unplaced, *selection)[1] - print(f" Narrowed {before} to {len(files)} files") + 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, @@ -12533,7 +12667,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: _print_plan([f for f in to_download if id(f) not in copies], bios_path, term, trees, kept, copy_plan_lines(to_download, copies, bios_index, bios_path, files, args.verbose, True), - verbose=args.verbose) + verbose=args.verbose, manifest=manifest) for line in unreadable_lines(unread, args.verbose): print(line) for line in hint: @@ -12594,11 +12728,14 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # names it: "found on this machine" followed "from E:\". sources = list(dict.fromkeys(copy.index.label for copy in copies.values())) named = ", ".join(screen_text(shown_path(label)) for label in sources) - origin = (f"copied from {named}" if all( + origin = (f"from {named}" if all( copy.index is not bios_index for copy in copies.values()) else "found on this machine") - print(f"\nCopying {_plural(len(copies), 'file')} {origin}" - f"{rest}{where} ({format_size(dl_size)})...") + # The size beside what it weighs: after "and downloading + # 19" it read as theirs alone. + sized = f" ({format_size(dl_size)})" + print(f"\nCopying {_plural(len(copies), 'file')}{'' if rest else sized} " + f"{origin}{rest}{where}{sized if rest else ''}...") elif LOCAL.pack is not None: print(f"\nCopying {len(to_download)} files ({format_size(dl_size)}) " f"from {LOCAL.pack.name}...") @@ -12826,7 +12963,7 @@ _STYLES = {"title": "\x1b[1m", "head": "\x1b[1m", "focus": "\x1b[7m", "dim": "\x _COLOUR_BASES = {"title": "strong", "head": "strong", "focus": "selected", "dim": "muted", "hint": "muted", "refusal": "failure"} # A count or a size on a headline: "4 files to download, 1.0 MB". -_KEY_NUMBER = re.compile(r"\b\d[\d,.]*(?: (?:B|KB|MB|GB|TB)\b)?") +_KEY_NUMBER = re.compile(r"(? TuiMouse | None: def _x10_mouse(data: str) -> TuiMouse | None: - """A legacy report: three characters, each 32 plus its value. A - position past 95 is a byte UTF-8 cannot read, and the report is dropped.""" + """A legacy report: three characters, each 32 plus its value, read from + the bytes before UTF-8 decoding (TerminalInput): a position byte past + 127 is no character of its own.""" codes = [ord(char) - 32 for char in data] if any(not 0 <= code <= 223 for code in codes): return None @@ -13008,6 +13146,58 @@ class KeyDecoder: return keys +_X10_START = b"\x1b[M" + + +class TerminalInput: + """The text the keys are read from, out of the bytes a terminal sends. + + A legacy mouse report is three raw bytes after ESC [ M: a column from + 162 and a row from 96 made two of them one UTF-8 character, so the + report came one character short and took the next key as its row. Its + three bytes go to the key decoder one character each, whatever UTF-8 + makes of them, and the rest is decoded as text.""" + + def __init__(self, encoding: str = "utf-8") -> None: + self.text = codecs.getincrementaldecoder(encoding)(errors="replace") + # The report bytes still to come from the next read, and the last + # two bytes read, where a report's ESC [ may have started. + self.need = 0 + self.tail = b"" + + def decode(self, data: bytes) -> str: + out: list[str] = [] + if self.need: + raw, data = data[:self.need], data[self.need:] + self.need -= len(raw) + out.append(raw.decode("latin-1")) + joined = self.tail + data + shift = len(self.tail) + start = shift + search = 0 + while True: + at = joined.find(_X10_START, search) + if at < 0: + break + report = at + len(_X10_START) + if report <= shift: + # Read whole before: decoded then. + search = at + 1 + continue + out.append(self.text.decode(joined[start:report])) + raw = joined[report:report + 3] + out.append(raw.decode("latin-1")) + self.need = 3 - len(raw) + start = search = report + len(raw) + out.append(self.text.decode(joined[start:])) + self.tail = joined[-2:] + return "".join(out) + + def flush(self) -> None: + """The wait for the rest of a report is over: none is owed.""" + self.need = 0 + + def decode_windows_key(first: str, read_next: Callable[[], str]) -> TuiKey | None: """A key from msvcrt.getwch: arrows and pages come as a prefix, then a code.""" if first in ("\x00", "\xe0"): @@ -13121,6 +13311,16 @@ def _wrap(text: str, width: int, indent: str = " ", whole: str = "") -> list[st lines: list[str] = [] line = "" words = (_SHELL_WORD.findall(text) if whole == "command" else _glued_words(text)) + + 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). + prefix = lead + indent if lines else lead + if lines and cell_width(prefix + done) > width >= cell_width(lead + done): + prefix = lead + return prefix + done + for word in words: pieces = [word] if whole else _split_word(word, width - len(lead + indent)) for index, piece in enumerate(pieces): @@ -13128,12 +13328,13 @@ def _wrap(text: str, width: int, indent: str = " ", whole: str = "") -> list[st # The rest of a word broken at a slash joins it without a space: # a path was drawn "retroarch/ system", and copied wrong. longer = f"{line}{'' if index else ' '}{piece}" if line else piece - if not line or cell_width(prefix + longer) <= width: + if not line or cell_width(prefix + longer) <= width or ( + lines and " " not in longer and cell_width(lead + longer) <= width): line = longer else: - lines.append(prefix + line) + lines.append(close(line)) line = piece - lines.append((lead + indent if lines else lead) + line) + lines.append(close(line)) return lines @@ -13147,6 +13348,9 @@ class TuiLine(str): style = "" mark: tuple | None = None + # A line of a command, however it was wrapped: the mouse is left to the + # terminal on a frame showing one (tui_wants_mouse). + command = False def _styled(text: str, style: str = "") -> TuiLine: @@ -13157,14 +13361,30 @@ def _styled(text: str, style: str = "") -> TuiLine: def _bar_keys() -> set[str]: """Every name a key or a button takes on the bar, on every route.""" - keys = {"Enter", "Esc", "Space", "Start", "Select", "Ctrl-C", "?", "/"} + keys = {"Enter", "Esc", "Space", "Start", "Select", "Ctrl-C", "?", "/", "X/Space/tap", + "Space/tap"} for route in TUI_ROUTES.values(): keys.update(route.bar) keys.update(name for name in route.bar.values() if name) - keys.update(short.strip() for _long, short in _SHORT_KEYS) + keys.update(name.strip() for pair in _SHORT_KEYS for name in pair) + keys.update(f"{route.help['page'] or 'PgUp/PgDn'}".split(", ")[0] + for route in TUI_ROUTES.values()) return keys +def _bar_spans(text: str, first: str) -> list[tuple[int, int, str]]: + """The bar's key names faint, the first in first, its actions plain: + drawn faint, Back and Quit were the hardest words to find on a + handheld, B being its way back (tui_audit_12 P3-3).""" + spans = [] + for number, entry in enumerate(re.finditer(r"\S+(?: \S+)*", text)): + key = entry.group().split(" ", 1)[0] + if key in _bar_keys() and " " in entry.group(): + spans.append((entry.start(), entry.start() + len(key), + first if number == 0 else "muted")) + return spans + + def tui_paint(text: str, style: str, colour: bool) -> str: """A drawn line as the terminal gets it: its style, and with colour the roles of its words (COLOUR_ROLES). The characters are those of text. @@ -13174,19 +13394,20 @@ def tui_paint(text: str, style: str, colour: bool) -> str: plan and the result the BIOS folder in bold. """ if not colour: + if style == "hint": + return painted(text, _bar_spans(text, "strong")) return _STYLES.get(style, "") + text base = _COLOUR_BASES.get(style, "") + if style == "folder": + # The rest of a BIOS folder wrapped onto its own line, as bold as + # its start (tui_audit_12 P3-7). + start = len(text) - len(text.lstrip(" ")) + return painted(text, [(start, len(text.rstrip(" ")), "strong")], base) if style not in ("", "head", "title", "hint", "refusal"): return painted(text, [], base) spans: list[tuple[int, int, str]] = [] if style == "hint": - start = len(text) - len(text.lstrip(" ")) - key = text[start:].split(" ", 1)[0] - if key in _bar_keys(): - end = text.find(" ", start) - end = len(text) if end < 0 else end - spans = [(start, start + len(key), "accent"), (start + len(key), end, "plain")] - return painted(text, spans, base) + return painted(text, _bar_spans(text, "accent")) if style == "head": at = text.find("RetroBIOS") if at >= 0: @@ -13197,7 +13418,9 @@ def tui_paint(text: str, style: str, colour: bool) -> str: lead = text.lstrip(" ") if lead.startswith(_FOLDER_LABEL): start = len(text) - len(lead) + len(_FOLDER_LABEL) - spans.append((start, len(text.rstrip(" ")), "strong")) + # The folder alone: on Options the bold ran on into --dest. + end = text.find(" ", start) + spans.append((start, len(text.rstrip(" ")) if end < 0 else end, "strong")) bar = _PROGRESS_BAR.match(text) if bar: spans += [(bar.start(1), bar.end(1), "accent"), (bar.start(2), bar.end(2), "muted")] @@ -13228,7 +13451,8 @@ def _folded(text: str) -> str: # What a check run tells the screen, kept whole when Choose systems widens a # --system plan: Back with the same ticks gives that plan back. _CHECK_FIELDS = ("check", "files", "systems", "choices", "found", "scanned", "unreadable", - "saves", "check_summary", "checked_flags", "cli_systems", "facts", "ticked") + "saves", "check_summary", "checked_flags", "cli_systems", "facts", "ticked", + "version", "checked_dests") class TuiState: @@ -13256,6 +13480,9 @@ class TuiState: self.now: float = time.monotonic() # (platform, folder, label) for each setup found here. self.setups: list = [] + # (platform, card) of each mounted drive whose files fit two systems + # (share_doubt): offered under each, never taken for one. + self.doubts: list = [] # (platform, folder) of the setups that are a card in a reader, not # this machine: a setup chosen for that system asks for its card. self.mounted_setups: set = set() @@ -13285,6 +13512,13 @@ class TuiState: # wheel scrolled. self.hits: TuiHits | None = None self.pressed: tuple | None = None + # The row a tap last focused, with its screen and depth: a second + # tap on it opens it. A screen just opened has none, so the row it + # opens on is never chosen by one tap (tui_audit_12 P1-1). + self.tapped: tuple | None = None + # Whether the terminal reports clicks here (tui_mouse_blocker): the + # help and the bar say how to tap only then. + self.mouse: bool = False self.list_top: dict = {} # The mounted cards of that system, as last looked for. self.cards: list | None = None @@ -13334,6 +13568,9 @@ class TuiState: # The systems the check's list lets one choose among (choices event). self.choices: list | None = None self.files: list = [] + # Every destination the check named, in place or not: the second + # spellings a folder that keeps case installs are among them. + self.checked_dests: set = set() self.systems: list = [] self.facts: dict = {} self.dry: dict | None = None @@ -13345,6 +13582,10 @@ class TuiState: self.check_summary: dict | None = None # The saves event of the check: the files that go to the save folder. self.saves: dict | None = None + # The version event of the check: the list built for another + # release than the one this machine runs, said on the plan and the + # result as line mode says it. + self.version: str = "" # The same of the confirm or preview run, and of the install: a # choice of systems can leave the save file out, or keep it. self.dry_saves: dict | None = None @@ -13362,6 +13603,9 @@ class TuiState: self.cli_regions: list = [] self.cores: list = [] self.no_game_data: bool = False + # --no-game-data on the command line: the plan has no game data row, + # as line mode has no g then. + self.cli_no_game_data: bool = False # A quit the user chose, said as one (tui_quit). self.quit_asked: bool = False self.installed_only: bool = False @@ -13748,6 +13992,27 @@ def tui_blocker( return "" +def line_mode_reason(blocker: str, env: Mapping[str, str] | None = None) -> str: + """Why the questions come line by line, in one line, for a terminal the + full screen cannot draw on: its size or its TERM. "" for a run that + asks nothing (--check, a pipe), and for a pseudo-terminal nobody sized + (script fed from a pipe is 0x0), which is no window.""" + env = os.environ if env is None else env + small = re.match(r"the terminal is (\d+x\d+), smaller than", blocker) + if small: + if "0" in small[1].split("x"): + return "" + return (f"The window is {small[1]}: questions come line by line " + f"({TUI_MIN_COLS}x{TUI_MIN_ROWS} for the full screen).") + if blocker == "TERM is dumb or not set": + term = screen_text(env.get("TERM", "")) + return (f"TERM={term}: questions come line by line." if term + else "TERM is not set: questions come line by line.") + if blocker.startswith("TERM="): + return f"{blocker}: questions come line by line." + return "" + + # TERM values of terminals that report no mouse the xterm way: the Linux # console (gpm is its own protocol), the DEC VTs and BSD consoles. _NO_MOUSE_TERMS = ("linux", "vt", "cons", "ansi") @@ -13787,11 +14052,16 @@ def tui_wants_mouse(state: TuiState, lines: list[str]) -> bool: where a path is typed, which a middle or right click pastes into.""" if state.help: return True - if state.screen in ("result", "command", "path"): + if state.screen == "result": + # A tap on the bar's Exit closes it, where a touch screen has no + # Enter: the result is printed again in the terminal once the + # screen closes, its commands selected there as anywhere. + return True + if state.screen in ("command", "path"): return False - hint = tui_hint(state) - text = " ".join(" ".join(str(line).split()) for line in lines) - return not any(launcher in text for launcher in (hint.launcher(), hint.launcher(True))) + # Read from the frame's model: a command wrapped over lines was missed + # by a match on the drawn text, its URL cut in two (code_audit_19 P3-5). + return not any(getattr(line, "command", False) for line in lines) def tui_flags(state: TuiState) -> list[tuple[str, str | None]]: @@ -13852,6 +14122,9 @@ def tui_hint(state: TuiState) -> Invocation: hint.options += tui_flags(state) if state.add_app and _tui_app_ready(state): hint.options.append(("--add-app", None)) + # The runs inherit the variable, a command shown to the user does not. + if offline_option(False) and ("--offline", None) not in hint.options: + hint.options.append(("--offline", None)) return hint @@ -13907,6 +14180,9 @@ class TuiRow(NamedTuple): # The right-hand text is never cut: the label is, once its shortest # form does not fit beside it (a system's size). keep_right: bool = False + # The column the right-hand text starts at, one for the whole list: the + # setup list's devices had three right edges at 120 columns. + align: int = 0 # Screens that scroll text instead of listing rows. @@ -13956,10 +14232,11 @@ def _filter_row(state: TuiState) -> TuiRow: "gptokeyb_muxterm": "with a keyboard", "evmapy": "with a keyboard", "miyoo": "with a keyboard"}.get(state.pad, "/ to type") if state.screen == "platforms": - # A device name finds its setups: said where it is typed. + # A device name finds its setups: said where it is typed. Plain, as + # the first screen sends a beginner there (tui_audit_12 P3-3). return TuiRow("filter", "Filter: setup or device (/)" if how == "/ to type" - else f"Filter ({how})", style="dim") - return TuiRow("filter", f"Filter ({how})", style="dim") + else f"Filter ({how})") + return TuiRow("filter", f"Filter ({how})") def _system_titles(state: TuiState) -> dict: @@ -14008,6 +14285,10 @@ def tui_rows(state: TuiState) -> list[TuiRow]: # The focused one's folder is spelled out below the list. rows = [TuiRow("setup", label, value=index) for index, (_plat, _path, label) in enumerate(state.setups)] + # A drive two systems fit, under each: "Nothing was found here" + # beside a KNULLI card in the reader (tui_audit_12 P2-5). + rows += [TuiRow("doubt", f"{_mount_name(mount)}: if it is {_doubt_kinds((plat,))}", + value=(plat, mount)) for plat, mount in state.doubts] return rows + [TuiRow("another", "Another setup or folder")] if screen == "platforms": return _platform_rows(state) @@ -14021,10 +14302,13 @@ def tui_rows(state: TuiState) -> list[TuiRow]: for label, folder in share_targets(state.platform)] if not bound: # The drives mounted here, as line mode numbers them at the same - # question, each by the BIOS folder its system reads. + # question, each by the BIOS folder its system reads, after the + # Windows side's usual folder under WSL. drives = [] if state.folder_kind == "share" else [ TuiRow("drive", label, value=folder) - for label, folder in drive_targets(state.platform)] + 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 # Only the cards whose own files say they run the system: A on the @@ -14033,6 +14317,13 @@ def tui_rows(state: TuiState) -> list[TuiRow]: else mounted_cards_of(bound[0], state.platform)) cards = [TuiRow("card", f"{_mount_name(mount)} ({_card_what(mount)})", value=mount) for mount in mounts] + # A drive this system and another both fit is its card, as --dest + # takes it: "No KNULLI card is mounted" sat beside it. + doubtful = [mount for mount in doubt_cards(state.platform) if mount not in mounts] + cards += [TuiRow("card", f"{_mount_name(mount)} (could be " + f"{_doubt_kinds(share_doubt(mount)[0])})", value=mount) + for mount in doubtful] + mounts = [*mounts, *doubtful] # 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" blank = [] if here else [ @@ -14164,14 +14455,6 @@ def _tui_tally(state: TuiState, events: Iterable[dict]) -> Tally: for event in events) -def _tui_split(state: TuiState, events: Iterable[dict]) -> str: - """BIOS files and game data apart, for file events, "" when the list - marks none or holds no game data among them.""" - if not _tui_categories(state): - return "" - return split_words(_tui_tally(state, events)) - - def _tui_estimating(state: TuiState) -> bool: """Whether the plan counts the systems ticked since the check, not the check itself.""" @@ -14179,30 +14462,41 @@ def _tui_estimating(state: TuiState) -> bool: and not _offline_only()) -def _plan_files(state: TuiState) -> list[dict]: - """The file events the plan's headline counts: the systems ticked when - it estimates them, the check's own otherwise.""" - return _ticked_files(state) if _tui_estimating(state) else state.files +def _choice_entries(state: TuiState) -> list[dict]: + """The list's files the choice made keeps, game data included: the + systems ticked (or named with --system), the cores and the regions, as + line mode's menu_choice narrows them. The game data row, the save line + and the data files count these: over the whole list, a plan of one + 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] + # 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, + list(state.cores), list(state.regions))[0] def _game_data_row(state: TuiState) -> TuiRow | None: """The plan's game data row, one press to leave it out or keep it, for a list that marks game data: inside Options alone, it went unseen. - Counted over the files the plan counts, as g at the line menu: over - the whole list, it said 5089 files beside a plan of 12. Once left out - the check holds none, and the list's own game data is counted.""" - if not _tui_categories(state): + Counted over the choice, as g at the line menu counts it, kept or left + out alike, and gone when the choice holds none. --no-game-data given + has none, as line mode offers no g then: one decision in both modes.""" + if not _tui_categories(state) or state.cli_no_game_data: return None - games = _tui_tally(state, _plan_files(state)) - if not games.games: - games = tally(entry for entry in (state.manifest or {}).get("files", []) - if entry.get("category") == GAME_DATA) + games = tally(entry for entry in _choice_entries(state) + if entry.get("category") == GAME_DATA) if not games.games: return None + # The state first, its count after and never cut inside a word: at 40 + # columns "kept, leave out" lost its state (tui_audit_12 P2-2). sized = f"{_plural(games.games, 'file')}, {format_size(games.game_size)}" - return TuiRow("gamedata", f"Game data ({sized}): " - + ("left out, change" if state.no_game_data else "kept, leave out")) + return TuiRow("gamedata", f"Game data: {'left out' if state.no_game_data else 'kept'}", + sized, narrow=(f"{games.games}, {format_size(games.game_size)}",), + keep_right=True) def _offline_only() -> bool: @@ -14296,7 +14590,7 @@ def _option_rows(state: TuiState) -> list[TuiRow]: if manifest.get("categorized"): # "Leave out game data: no" was a double negative. rows.append(TuiRow("game_data", "Game data: " - + ("left out, firmware only" if state.no_game_data else "kept"), + + ("left out" if state.no_game_data else "kept"), "--no-game-data")) found = len(state.facts.get("cores", [])) if _cores_dir_given(state): @@ -14341,6 +14635,10 @@ def _save_folder_words(state: TuiState) -> str: return _short_path(given, state.home) if state.saves and state.saves.get("folder"): return _short_path(Path(save_where_words(state.saves["folder"])[1]), state.home) + place = (save_directory_for(state.dest, state.manifest, None) + if state.dest is not None and state.manifest else None) + if isinstance(place, SavePlace): + return _short_path(spelled(place.directory), state.home) return "not known" @@ -14399,11 +14697,14 @@ def _back(state: TuiState) -> list[tuple]: return effects -def tui_quit(state: TuiState, code: int = 130, asked: bool = True) -> list[tuple]: - """Leave now: with the install's own code once it ran, else 130. asked - is a quit the user chose (q, Quit), said as one: it read "stopped", the - word of an interrupted run (tui_audit_11 P3-19).""" +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 + the user chose, said as one: it read "stopped", the word of an + interrupted run (tui_audit_11 P3-19).""" state.quit_asked = asked + if code is None: + code = QUIT_CODE if asked else 130 if state.screen == "result": code = state.code if state.code is not None else 1 elif state.screen == "error": @@ -14431,9 +14732,11 @@ def _start_run(state: TuiState, run: str) -> list[tuple]: # The systems of an earlier check go first: ticks read against them # dropped the --system the command line gave. state.check, state.files, state.systems, state.reading = None, [], [], None + state.checked_dests = set() state.choices = None state.found, state.scanned, state.unreadable = [], [], [] state.check_summary = state.saves = None + state.version = "" state.checked_flags = tui_flags(state) elif run in ("confirm", "preview"): state.dry, state.dry_files, state.dry_summary = None, [], None @@ -14718,16 +15021,19 @@ def _card_what(mount: Path) -> str: return f"{system[2]} {drive_kind(system)}" if system else "card or drive" -def _browse_rows(folder: Path | None, zips: bool = False) -> tuple[list[TuiRow], str]: +def _browse_rows(folder: Path | None, zips: bool = False, + skip: Path | None = None) -> tuple[list[TuiRow], str]: """A directory's rows for the browser, cards first, and what went wrong. - With zips, the ZIP files too: a backup is often one archive.""" + With zips, the ZIP files too: a backup is often one archive. skip is a + card never offered: the one being installed to, for the copy browser, + as line mode's Copy from leaves it out.""" if folder is None: drives = [Path(f"{letter}:\\") for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" if os.path.exists(f"{letter}:\\")] return [TuiRow("dir", str(drive), value=drive) for drive in drives], "" rows = [TuiRow("use", "Use this folder"), TuiRow("up", "..", value=folder.parent)] for card in _mounted_cards(): - if card != folder: + if card not in (folder, skip): # Under WSL by its letter, as every other screen writes D:\BIOS. name = _mount_name(card) rows.append(TuiRow("dir", f"{name if name.endswith(':') else card} " @@ -14774,7 +15080,9 @@ def _os_folder(path: Path, folder: Path) -> bool: def _browse_to(state: TuiState, folder: Path | None, came_from: Path | None = None) -> None: state.browse_dir = folder - state.browse_rows, state.status = _browse_rows(folder, state.browse_for == "from") + copying = state.browse_for == "from" + state.browse_rows, state.status = _browse_rows(folder, copying, + _dest_card(state) if copying else None) focus = 0 if (folder is not None and state.browse_for != "from" and folder in (state.home, Path(folder.anchor))): @@ -14911,6 +15219,16 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: if screen == "setup": if action == "another": return _push(state, "platforms", _platform_focus(state, "retroarch")) + if action == "doubt": + # Taken as the system chosen, as --platform with --dest takes it. + plat, mount = row.value + state.card_bound = card_bound(plat, plat) + state.platform = plat + choice = card_choice(plat, plat, mount) + if choice.refused: + _card_status(state, choice) + return [] + return _take_card(state, choice, mount) 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. @@ -15509,6 +15827,24 @@ def _platform_rows(state: TuiState) -> list[TuiRow]: state.os_type, LOCAL.serves())] +# The narrowest column of devices the setup list aligns them in: below it +# they stand against the right edge, one edge for every row. +TUI_DEVICE_ROOM = 12 + + +def _aligned_devices(rows: list[TuiRow], width: int) -> list[TuiRow]: + """The setup list's devices starting at one column, two cells after the + longest setup name, where that leaves them room: at 80 columns they + began anywhere from column 20 to 70 (tui_audit_12 P3-1).""" + named = [row for row in rows if row.action in ("platform", "cardsystem")] + if not named: + return rows + column = 2 + max(cell_width(row.label) for row in named) + 2 + if min(width, TUI_ROW_SPREAD) - column < TUI_DEVICE_ROOM: + return rows + return [row._replace(align=column) if row in named else row for row in rows] + + def _cards_first(setups: list) -> bool: """Whether the card rows follow RetroArch: nothing was found here, so this is a computer a handheld's card is prepared on, or a console the @@ -15766,6 +16102,8 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]: """What a key does on the current screen, and the work it starts.""" if key.name == "mouse": return tui_mouse(state, key) # type: ignore[arg-type] + # A key moves the focus its own way: the next tap focuses again. + state.tapped = None if key.name in ("ctrl-c", "ctrl-d"): return tui_interrupt(state) if state.cols < TUI_MIN_COLS or state.rows < TUI_MIN_ROWS: @@ -15906,12 +16244,16 @@ def tui_mouse(state: TuiState, event: TuiMouse) -> list[tuple]: a row no longer where it was drawn, does nothing. A tap acts when the button goes up, on the target it went down on: a - swipe that ends elsewhere does nothing. On a row it focuses the row, - and on the row in focus it does what Enter does: one terminal row is - far smaller than a finger, and a single tap acting on it would act on - a near miss. On a bar entry it presses that entry's key. The right and - middle buttons do nothing, nor a click with Shift, Meta or Ctrl held, - which the terminal keeps for its own selection and links. + swipe that ends elsewhere does nothing. What it does follows the row's + kind. A tick (a system, a core, a region) is ticked or unticked, as + Space does: a tap that unticks a box elsewhere continued here, and the + next one installed every file. A choice is focused, and opened by a + second tap on the row this screen saw tapped: one terminal row is far + smaller than a finger, and the row a screen opens on (Install on + Confirm) is never chosen by one tap. On a bar entry it presses that + entry's key. The right and middle buttons do nothing, nor a click with + Shift, Meta or Ctrl held, which the terminal keeps for its own + selection and links. """ hits = state.hits if (event.button & 32 or hits is None or hits.screen != state.screen @@ -15949,15 +16291,32 @@ def tui_mouse(state: TuiState, event: TuiMouse) -> list[tuple]: if index >= len(rows) or (rows[index].action, rows[index].value) != target[2]: return [] screen = state.screen + row = rows[index] + seen = (screen, len(state.stack), row.action, row.value) if state.editing: state.editing = "" - elif max(0, min(state.focus.get(screen, 0), len(rows) - 1)) == index: + elif row.action in TUI_TICK_ROWS: + state.status = state.status_more = "" + state.focus[screen] = index + state.tapped = seen + if screen == "regions": + _toggle_region(state, row.value) + else: + _tick(state, row, None) + return [] + elif (max(0, min(state.focus.get(screen, 0), len(rows) - 1)) == index + and state.tapped == seen): return tui_update(state, TuiKey("enter")) state.status = state.status_more = "" state.focus[screen] = index + state.tapped = seen return [] +# The rows a tap ticks or unticks, as Space does: the lists of ticks. +TUI_TICK_ROWS = ("system", "core", "region") + + 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.""" @@ -16055,6 +16414,8 @@ def _take_event(state: TuiState, event: dict) -> None: state.unreadable.append(event) elif kind == "choices" and state.run == "check": state.choices = list(event["systems"]) + elif kind == "version" and state.run == "check": + state.version = str(event["message"]) elif kind == "saves" and state.run == "check": state.saves = event elif kind == "saves" and state.run in ("confirm", "preview"): @@ -16070,8 +16431,10 @@ def _take_event(state: TuiState, event: dict) -> None: elif state.run == "check": if kind == "check": state.check = event - elif kind == "file" and event.get("state") != "present": - state.files.append(event) + elif kind == "file": + state.checked_dests.add(event.get("dest")) + if event.get("state") != "present": + state.files.append(event) elif kind == "system": state.systems.append(event) elif kind == "summary": @@ -16159,8 +16522,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 ", "X "), ("Y/o ", "Y "), - ("Left/Right ", "<> "), ("Up/Down ", "^v ")) +_SHORT_KEYS = (("A/Enter ", "A "), ("B/Esc ", "B "), ("X/Space/tap ", "X/tap "), + ("X/Space ", "X "), ("Y/o ", "Y "), ("Left/Right ", "<> "), ("Up/Down ", "^v ")) def _focus_ticks(state: TuiState) -> bool: @@ -16219,6 +16582,17 @@ def _route_hint(state: TuiState, parts: tuple) -> tuple: return tuple(named) +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: + return parts + 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") + else part for part in parts) + + # Shorter verbs for a narrow bar, tried before an entry is dropped. _SHORT_VERBS = ((" Only this system", " Only this"), (" First system", " First")) @@ -16279,6 +16653,16 @@ TUI_ROW_SPREAD = 78 def _row_line(row: TuiRow, focused: bool, width: int) -> TuiLine: left = ("> " if focused else " ") + row.label + if row.align and (row.right or row.narrow or row.cut): + # One column for the list: the longest form that fits after it, a + # single device's name cut before nothing. + room = min(width, TUI_ROW_SPREAD) - row.align + right = next((form for form in (row.right, *row.narrow) + if form and cell_width(form) <= room), "") + if not right and row.cut and room >= 9: + right = fit(row.cut, room - 3).rstrip() + "..." + return _styled(_pad(left, row.align) + right if right else left, + "focus" if focused else row.style) if row.right: width = min(width, max(TUI_ROW_SPREAD, cell_width(left) + 2 + cell_width(row.right))) if row.keep_right and row.right: @@ -16330,13 +16714,22 @@ def _text_lines(items: list, width: int, whole: bool = False) -> list[TuiLine]: # One line, cut in its middle: the end of a path tells it apart. lines.append(_styled(middle(str(item), width))) continue - kind = ("command" if style == "command" else "text") if whole else "" - for piece in _wrap(str(item), width, whole=kind): + # A command is wrapped between its shell words on every screen: the + # plan's root command was cut inside its URL (code_audit_19 P3-5). + command = style == "command" or getattr(item, "command", False) + kind = "command" if command else "text" if whole else "" + folder = str(item).lstrip(" ").startswith(_FOLDER_LABEL) + for number, piece in enumerate(_wrap(str(item), width, whole=kind)): if cell_width(piece) <= width: - lines.append(_styled(fit(piece, width), "" if style in ("path", "command") - else style)) + # The rest of a BIOS folder's line keeps its weight. + line = _styled(fit(piece, width), "folder" if folder and number + else "" if style in ("path", "command") else style) + line.command = command + lines.append(line) continue - lines.append(_styled(piece, "long")) + line = _styled(piece, "long") + line.command = command + lines.append(line) lines += [_styled("", "cont")] * (-(-cell_width(piece) // width) - 1) return lines @@ -16590,7 +16983,8 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: lines += filter(None, [_no_setup_reads(state, dest)]) counts = (f"{check['present']} in place, {check['missing']} missing, " f"{wrong} wrong {'copy' if wrong == 1 else 'copies'}") - lines.append(counts) + # A first install's missing files are its normal state, not a warning. + lines.append(_styled(counts, "quiet") if not check["present"] and not wrong else counts) # The backup line goes right under the count it explains, before the # pack and copy split: at 40x12 and 40x15 it came after it, and was cut. count_at = len(lines) @@ -16604,7 +16998,7 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: lines.append(f"{need} to install: {len(copied)} copied from this machine, " f"{fetch} downloaded") after_counts = len(lines) - saves = (state.saves if check is state.check + saves = (_choice_saves(state) if check is state.check else state.dry_saves if check is state.dry else None) saves_lines = _saves_lines(state, saves) lines += [_styled(line, "path" if index else "") for index, line in enumerate(saves_lines)] @@ -16630,14 +17024,14 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: f"{'is' if wrong == 1 else 'are'} replaced, the old " f"{'one' if wrong == 1 else 'ones'} kept in a backup folder.") if state.facts.get("writable") is False and dest is not None: - lines += write_remedy(dest, state.platform, tui_hint(state), - state.pad not in TUI_PAD_ROUTES) + lines += _commands(state, write_remedy(dest, state.platform, tui_hint(state), + state.pad not in TUI_PAD_ROUTES)) if check is state.check: lines += _scan_lines(state) if check.get("not_installable"): lines += _unavailable_lines(state) if check is state.check: - lines += _unplaced_lines(state.check_summary, state, + lines += _unplaced_lines(_choice_summary(state), state, "Choose it in Options, Save folder (--save-dir).") if check is state.check and state.from_folders: # A folder copied from that holds none of them: the plan came back @@ -16655,11 +17049,51 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: offline_lines = [f"{name} holds none of these files." for name in empty] \ + offline_lines # On Confirm the remedy comes right after the headline: at 40x12 it was - # what got cut, Back left alone below it. - at = 1 if check is state.dry else 2 + # 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 + # folder (tui_audit_12 P3-15). + at = 1 if check is state.dry else 3 if found_in else 2 return lines[:at] + offline_lines + lines[at:] +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 + the line of the whole list.""" + if not _tui_estimating(state) or not state.saves: + return state.saves + count = _choice_save_count(state) + return {**state.saves, "files": count} if count else None + + +def _choice_summary(state: TuiState) -> dict | None: + """The check's summary, its save files left out counted over the + systems ticked while the plan estimates them.""" + summary = state.check_summary + if not _tui_estimating(state) or not summary: + return summary + count = _choice_save_count(state) + skipped = [group if group.get("reason") != "no_save_directory" + else {**group, "files": count} for group in summary.get("skipped") or []] + return {**summary, "skipped": skipped} + + +def _choice_save_count(state: TuiState) -> int: + """The files of the choice read from the frontend's save directory, + game data left out when it is.""" + return sum(1 for entry in _choice_entries(state) if entry.get("root") == "save" + and not (state.no_game_data and entry.get("category") == GAME_DATA)) + + +def _commands(state: TuiState, lines: list[str]) -> list[str]: + """Lines holding a command the user may run, drawn as commands: wrapped + between shell words, and the mouse left to the terminal over them.""" + hint = tui_hint(state) + launchers = (hint.launcher(), hint.launcher(True)) + return [_styled(line, "command") if any(launcher in line for launcher in launchers) + else line for line in lines] + + def _plan_folder(state: TuiState, dest: Path | None) -> Path | None: """The BIOS folder a plan names (shown_bios_folder), from the list.""" if dest is None or not state.manifest: @@ -16667,10 +17101,11 @@ def _plan_folder(state: TuiState, dest: Path | None) -> Path | None: return shown_bios_folder(dest, state.manifest.get("files") or []) -def _saves_lines(state: TuiState, saves: dict | None) -> list[str]: +def _saves_lines(state: TuiState, saves: dict | None, whole: bool = False) -> list[str]: """Where the files read from the frontend's save directory go: a second folder this run writes to, named before anything is written. saves is - the event of the run the screen shows.""" + the event of the run the screen shows. whole spells the folder in full, + as the result names its other folders.""" if not saves or not saves.get("files"): return [] name = display_name(state.platform) if state.platform else "the frontend" @@ -16680,7 +17115,7 @@ def _saves_lines(state: TuiState, saves: dict | None) -> list[str]: # 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{' ' + words if words else ''}:", - _short_path(Path(folder), state.home)] + shown_path(folder) if whole else _short_path(Path(folder), state.home)] def _unplaced_lines(summary: dict | None, state: TuiState, @@ -16702,7 +17137,9 @@ def _view_setup(state: TuiState, width: int, height: int) -> tuple: focus = rows[min(state.focus.get("setup", 0), len(rows) - 1)] after = ([f"BIOS folder: {shown_path(setup_bios_folder(*state.setups[focus.value][:2]))}"] if focus.action == "setup" else []) - before = ["Found on this machine:" if state.setups else "Nothing was found here."] + before = ["Found on this machine:" if state.setups + else "A drive here could be either of these:" if state.doubts + else "Nothing was found here."] status = [state.status] if state.status else [] return ("Where do you play?", _compose(state, width, height, before, rows, after + status + state.notes, @@ -16736,7 +17173,8 @@ def _view_platforms(state: TuiState, width: int, height: int) -> tuple: after += [state.status] if state.status else [] # The first screen quits on B, as its help says: the bar said Back. back = "B/Esc Quit" if len(state.stack) == 1 else "B/Esc Back" - return (title, _compose(state, width, height, before, rows, after, pinned=pinned), + return (title, _compose(state, width, height, before, _aligned_devices(rows, width), after, + pinned=pinned), ("A/Enter Choose", back, "/ Filter", "? Help")) @@ -16874,7 +17312,7 @@ def _view_checking(state: TuiState, width: int, height: int) -> tuple: # Titled as the plan is: ArkOS's card was "RetroArch:" here, "ArkOS # (RetroArch files)" one screen on. lines = [f"{_setup_label(state.platform, state.dest)}: " - f"{_short_path(state.dest, state.home)}", ""] + f"{_short_path(_plan_folder(state, state.dest), state.home)}", ""] elapsed = format_duration(max(0.0, state.now - state.started)) if state.manifest is None: lines.append(f"Getting the file list... {elapsed}") @@ -16906,13 +17344,17 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: check = state.check or {} lines = _plan_lines(check, state.dest, state) choice = _choice_line(state) - if _tui_estimating(state): + estimating = _tui_estimating(state) + data, data_size = _plan_data(state, check) + if 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. + # total of all of them. Their data files too, as line mode's head. chosen = _ticked_files(state) + more = (f", and {_plural(data, 'data file')} ({format_size(data_size or 0)})" + if data else "") lines[0] = _styled(f"About {_plural(len(chosen), 'file')}, " - f"{format_size(sum(e['size'] for e in chosen))}, for " + f"{format_size(sum(e['size'] for e in chosen))}{more}, for " f"{len(state.ticked)} of {len(state.systems)} systems", "title") # The in place and missing counts are the whole list's: under an # estimate for some systems they read as its own. @@ -16932,26 +17374,31 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: # Named on the command line: said in line mode's words, and widened by # Choose systems. --core and --region were said in line mode alone # (tui_audit_11 P3-11). + # Of the files the headline counts, by kind: those ticked when it + # estimates them, those to install otherwise. Under the folder, never + # above it: inserted second, it pushed the folder off a 40x12 plan + # (tui_audit_12 P3-10). + parts, defined = _kinds_lines(state, _ticked_files(state) if estimating else [ + event for event in state.files if event["state"] != "present"], data, data_size) lines[at:at] = [*([state.card_note] if state.card_note and state.card_note != state.status else []), - *([f"{said[0].upper()}{said[1:]}."] if said else []), *choice] - # Of the files the headline counts: those ticked when it estimates - # them, those to install otherwise. - split = _tui_split(state, _ticked_files(state) if _tui_estimating(state) else [ - event for event in state.files if event["state"] != "present"]) - if split or (state.no_game_data and _game_data_row(state) is not None): - # BIOS files and game data apart, and what game data is, beside the - # row that leaves it out. - lines[1:1] = [f"{split or 'No game data'}. {GAME_DATA_WORDS[0].upper()}" - f"{GAME_DATA_WORDS[1:]}."] + *([f"{said[0].upper()}{said[1:]}."] if said else []), *choice, + *([parts] if parts else [])] + if state.version: + lines.append(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) for part in _wrap(str(line), width)] + [_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) @@ -16973,6 +17420,38 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: ("A/Enter Install", "B/Esc Back", "Y/o Options", "? Help")) +def _plan_data(state: TuiState, check: dict) -> tuple[int, int | None]: + """The data files the plan counts and their size: the check's, or, + while the plan estimates the systems ticked, those of the data folders + the choice keeps, as line mode's head counts them.""" + if not _tui_estimating(state): + data = check.get("data_missing", 0) + return data, check.get("data_bytes") if data else None + trees = select_data_directories(state.manifest or {}, _choice_entries(state), True) + sizes = [meta.get("size", 0) for entry in trees for meta in (entry.get("files") or {}).values()] + return len(sizes), sum(sizes) + + +def _kinds_lines(state: TuiState, events: list[dict], data: int, + data_size: int | None) -> tuple[str, str]: + """The plan's files by kind (parts_words) and what each kind is: the + same words as line mode's head, game data left out counted over the + choice.""" + categories = _tui_categories(state) + categorized = bool(categories) + if state.no_game_data: + # Counted apart as left out, never also among the files installed. + events = [event for event in events if categories.get(event["dest"]) != GAME_DATA] + counts = (_tui_tally(state, events) if categorized + else tally({"size": event["size"]} for event in events)) + left = 0 + 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)) + return (f"{parts}." if parts else ""), defined + + 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.""" @@ -17152,6 +17631,14 @@ def _view_confirm(state: TuiState, width: int, height: int) -> tuple: # systems and options chosen, the last look before anything is # written. lines = _plan_lines(state.dry, state.dest, state) + # The kinds apart, as the plan counts them, under the folder. + data = state.dry.get("data_missing", 0) + parts, _defined = _kinds_lines(state, state.dry_files, data, + state.dry.get("data_bytes") if data else None) + if parts: + at = next((index + 1 for index, line in enumerate(lines) + if str(line).startswith(_FOLDER_LABEL)), len(lines)) + lines.insert(at, parts) if any(row.action == "install" for row in tui_rows(state)): lines.insert(0, "Counted again for your choices. Nothing is written before " "Install.") @@ -17193,9 +17680,10 @@ def _view_installing(state: TuiState, width: int, height: int) -> tuple: if files_done >= state.download["files"] and state.summary is None: lines.append("Finishing...") stops = TUI_ROUTES.get(state.pad, TUI_ROUTES["both"]).stop - lines.append(_styled(f"Stopping, finishing the files in flight. Press {stops[0]} " - "again to stop now." if state.stopping - else f"{stops[1]} stops. What is done stays done.", "dim")) + # Plain: how to stop is no aside. + lines.append(f"Stopping, finishing the files in flight. Press {stops[0]} " + "again to stop now." if state.stopping + else f"{stops[1]} stops. What is done stays done.") room = height - len(lines) - 1 failed = [item for item in reversed(state.finished) if not item[1]] good = [item for item in reversed(state.finished) if item[1]] @@ -17357,21 +17845,16 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: # the pack" sat above "21 from the pack". source = (" from the pack" if LOCAL.pack is not None and not _tui_sources(state, installed, 0) else "") - head = f"{_plural(installed, 'file')} installed{source}." + # 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}" + + (f", {replaced} of them replaced" if replaced else "") + ".") if up_to_date: head += f" {_plural(up_to_date, 'file')} already in place." else: title = "Done" head = "Everything is in place." lines = [head] - if _tui_categories(state) and title != "Stopped": - # As the plan counted them, and as line mode ends: the data tree - # 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.games: - lines.append("Installed: " + kinds_words( - done.bios, done.games, max(0, installed - done.files)) + ".") sources = _tui_sources(state, installed, len(failed) + left) if left and installed and sources: # One count, then the remedy, as line mode says it: the count came @@ -17381,15 +17864,31 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: if sources: lines.append(sources) if left: - lines.append(_offline_remedy([_short_path(Path(folder), state.home) - for folder in state.from_folders])) + # 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} 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 in the wrong place were left as they are.") - lines.append(f"BIOS folder: {shown_path(state.dest)}") + if _tui_categories(state) and title != "Stopped" and installed: + # As the plan counted them, and as line mode ends, on every result: + # the old card's said "6 files copied" and no kind. The data tree + # 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: + 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. + lines.append(f"BIOS folder: {shown_path(_plan_folder(state, state.dest))}") if installed + up_to_date: - lines += _saves_lines(state, state.done_saves) + # Spelled as the BIOS folder above it is, whole: "~/" beside a full + # path was two spellings on one screen (tui_audit_12 P3-7). + lines += _saves_lines(state, state.done_saves, whole=True) + if state.version: + # Said again with the result, as line mode ends: the system's own + # BIOS check may mark the files just put in place. + lines.append(state.version) if space: lines.append("Free some space on the card, then run again." if _on_its_card(state) else "Free some space, or run again and choose another folder.") @@ -17413,8 +17912,8 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: for failure in failed): # The plan's own advice: the result offered nothing after 22 files # failed on a read-only card. - lines += write_remedy(state.dest, state.platform, tui_hint(state), - state.pad not in TUI_PAD_ROUTES) + lines += _commands(state, write_remedy(state.dest, state.platform, tui_hint(state), + state.pad not in TUI_PAD_ROUTES)) if not summary and not space and state.code != 130: # A stop asked for (Ctrl-C, Select+Start, the window closed) is said # by the line above: the log's last lines are for a run that died. @@ -17433,7 +17932,7 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: if summary and not summary.get("interrupted") and installed + up_to_date: lines += _unavailable_lines(state) for group in summary.get("left_out") or []: - lines.append(f"Left out as {group['label']}: {_plural(group['files'], 'file')}.") + lines.append(f"{left_out_words(group)}.") lines += _unplaced_lines(summary, state) lines += _tui_app_lines(state) if typed: @@ -17580,10 +18079,21 @@ 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)] + *_help_key(state), *_tap_help(state)] return keys +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": + return [("Tap", "Tick or untick a row, as Space")] + if state.screen in _PAGERS and not tui_rows(state): + return [("Tap", "A bottom line entry presses its key")] + return [("Tap", "Choose a row, tap it again to open it")] + + def _help_key(state: TuiState) -> list[tuple[str, str]]: """? and F1, named only where a keyboard may send them: no pad route maps a button to either.""" @@ -17632,7 +18142,8 @@ def _view_help(state: TuiState, width: int, height: int) -> tuple: more = ["", state.status_more] if state.status_more 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"): + 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: break if spare == "extra": @@ -17640,7 +18151,8 @@ def _view_help(state: TuiState, width: int, height: int) -> tuple: else: keys = [entry for entry in keys if entry[1] != spare] return ("Keys", _compose(state, width, height, drawn(keys) + extra + more, []), - ("Any key closes this help",)) + ("Any key or a tap closes this help" if state.mouse + else "Any key closes this help",)) def tui_view(state: TuiState, cols: int, rows: int) -> list[str]: @@ -17667,7 +18179,7 @@ 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 = _route_hint(state, _back_hint(state, hint)) + hint = _tap_hint(state, _route_hint(state, _back_hint(state, hint))) 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. @@ -17675,6 +18187,7 @@ def tui_view(state: TuiState, cols: int, rows: int) -> list[str]: drawn = _styled(" " + line if line.style == "long" else fit(" " + line, cols), line.style) drawn.mark = line.mark + drawn.command = getattr(line, "command", False) lines.append(drawn) lines += [_styled("")] * (rows - 1 - len(lines)) # Never the last cell of the screen: a terminal may scroll when it is written. @@ -17712,6 +18225,8 @@ _BAR_CLICKS = { "Esc": TuiKey("esc"), "X/Space": TuiKey("char", " "), "X": TuiKey("char", " "), "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", " "), "Ctrl-C": TuiKey("ctrl-c"), # F1 and not ?, which the path screen types into the path. "?": TuiKey("f1"), @@ -17882,7 +18397,7 @@ def tui_start( state.cores = list(dict.fromkeys( token.strip() for value in args.core or [] for token in value.split(",") if token.strip())) - state.no_game_data = bool(args.no_game_data) + state.no_game_data = state.cli_no_game_data = bool(args.no_game_data) state.installed_only = bool(args.installed_cores) state.jobs = args.jobs state.add_app = bool(getattr(args, "add_app", False)) @@ -17960,7 +18475,10 @@ def tui_start( state.stack = [] return state, _take_card(state, choice, Path(args.dest)) state.dest_given = Path(args.dest) - if state.dest_given is not None or not state.setups: + if state.dest_given is None and not args.platform: + state.doubts = [(plat, mount) for mount in _mounted_cards() + for plat in share_doubt(mount)[0]] + if state.dest_given is not None or not (state.setups or state.doubts): state.stack = ["platforms"] state.focus["platforms"] = _platform_focus(state, dest_default(state.setups)) return state, [] @@ -18138,8 +18656,7 @@ class _TuiTerminal: self.fd = stdin.fileno() self.saved: list | None = None self.decoder = KeyDecoder() - self.text = codecs.getincrementaldecoder( - getattr(stdin, "encoding", None) or "utf-8")(errors="replace") + self.input = TerminalInput(getattr(stdin, "encoding", None) or "utf-8") self.encoding = getattr(stdout, "encoding", None) or "utf-8" locale_name = os.environ.get("LC_ALL") or os.environ.get("LANG") or "" try: @@ -18256,6 +18773,7 @@ class _TuiTerminal: if select.select([self.fd], [], [], TUI_ESC_WAIT)[0]: keys += self._read() else: + self.input.flush() keys += self.decoder.flush() break return keys @@ -18267,7 +18785,7 @@ class _TuiTerminal: # The terminal went away: what Ctrl-D at a question does. self.ended = True return [TuiKey("ctrl-d")] - return self.decoder.feed(self.text.decode(data)) + return self.decoder.feed(self.input.decode(data)) def _console_keys(self, timeout: float) -> list[TuiKey]: import msvcrt # noqa: PLC0415 @@ -18355,13 +18873,13 @@ def tui_farewell(state: TuiState) -> list[str]: def tui_idle_code(state: TuiState) -> int: """The exit code of an idle close: an error's or a result's on those screens, so a launcher tells them from a close with nothing to say. - Elsewhere nothing was installed, and 0 said it all was: 130, as a quit - before the install.""" + Elsewhere nothing was installed, and 0 said it all was: QUIT_CODE, as a + quit before the install.""" if state.screen == "error": return state.error_code if state.screen == "result" and state.code is not None: return state.code - return 130 + return QUIT_CODE def _farewell_lines(state: TuiState) -> list[str]: @@ -18391,10 +18909,18 @@ def _closing_lines(state: TuiState) -> list[str]: if state.exit_code == 0: return [f"RetroBIOS: everything is in place in {state.dest}."] if state.quit_asked: - return ["RetroBIOS: quit. Nothing was installed."] + return [TUI_QUIT_WORDS] return ["RetroBIOS: stopped. Nothing was installed."] +TUI_QUIT_WORDS = "RetroBIOS: quit. Nothing was installed." + + +def tui_quit_chosen(state: TuiState) -> bool: + """Whether the screen ended on a quit the user chose, nothing installed.""" + return tui_farewell(state) == [TUI_QUIT_WORDS] + + CTRL_Z_NO_SHELL = ("Ctrl-Z is ignored: no shell started this installer, and none could " "bring it back.") @@ -18486,6 +19012,7 @@ def run_tui(args: argparse.Namespace) -> int: terminating = {"signal": 0, "deadline": 0.0} ignore_interrupt_until = [0.0] terminal = _TuiTerminal(sys.stdin, sys.stdout) + state.mouse = getattr(terminal, "mouse_allowed", False) real_out, real_err = sys.stdout, sys.stderr buffer = io.StringIO() @@ -18652,7 +19179,10 @@ def run_tui(args: argparse.Namespace) -> int: shutil.rmtree(work, ignore_errors=True) else: _mark_kept(work) - stream = sys.stderr if state.screen in ("error", "crashed") else sys.stdout + # A quit chosen goes where an error goes, its code not 0: a launcher + # reads its first line there and stays silent (ports/shared/tui.txt). + stream = (sys.stderr if state.screen in ("error", "crashed") or tui_quit_chosen(state) + else sys.stdout) lines = tui_farewell(state) if problems: lines.append(f"The terminal may not be restored ({problems[0]}): type reset " diff --git a/install.sh b/install.sh index 629d48f74..8afd6013e 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="7757b36db5beb3cbeb3717e0736db630237e87da5189a14d5092e679b69156af" +DEFAULT_INSTALL_SHA256="7029f605591f09c020aafcd521d8a7134ace78cc566435d8e76be33c42a9f6dc" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 0f5e5493c..f095b7b4e 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": 854609, - "sha256": "7757b36db5beb3cbeb3717e0736db630237e87da5189a14d5092e679b69156af", + "size": 881442, + "sha256": "7029f605591f09c020aafcd521d8a7134ace78cc566435d8e76be33c42a9f6dc", "executable": false }, { @@ -62,8 +62,8 @@ { "dest": "roms/ports/retrobios/tui.txt", "repo_path": "ports/shared/tui.txt", - "size": 35356, - "sha256": "d2d56e2c67f0a5d0d11fc75e28f337516006cc4414e18662e100359caa642a97", + "size": 35599, + "sha256": "1925428b1bb42384980a3336fa95051c173600367c6d15a4656bae08e6a6473c", "executable": false } ] @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 907158, - "sha256": "15c404a5dfb8d084caa11843d3969e7f0222f434c9b2320b04a0d89ca7336f21" + "size": 934234, + "sha256": "56675adcad36deb03879b7f5961203f6c65cc2f4f13170f6d38b37ed1fe5b367" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 854609, - "sha256": "7757b36db5beb3cbeb3717e0736db630237e87da5189a14d5092e679b69156af", + "size": 881442, + "sha256": "7029f605591f09c020aafcd521d8a7134ace78cc566435d8e76be33c42a9f6dc", "executable": false }, { @@ -141,8 +141,8 @@ { "name": "RetroBIOS/tui.txt", "repo_path": "ports/shared/tui.txt", - "size": 35356, - "sha256": "d2d56e2c67f0a5d0d11fc75e28f337516006cc4414e18662e100359caa642a97", + "size": 35599, + "sha256": "1925428b1bb42384980a3336fa95051c173600367c6d15a4656bae08e6a6473c", "executable": false } ] @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 854609, - "sha256": "7757b36db5beb3cbeb3717e0736db630237e87da5189a14d5092e679b69156af", + "size": 881442, + "sha256": "7029f605591f09c020aafcd521d8a7134ace78cc566435d8e76be33c42a9f6dc", "executable": false }, { @@ -208,8 +208,8 @@ { "dest": ".config/modules/retrobios/tui.txt", "repo_path": "ports/shared/tui.txt", - "size": 35356, - "sha256": "d2d56e2c67f0a5d0d11fc75e28f337516006cc4414e18662e100359caa642a97", + "size": 35599, + "sha256": "1925428b1bb42384980a3336fa95051c173600367c6d15a4656bae08e6a6473c", "executable": false } ] diff --git a/ports/shared/tui.txt b/ports/shared/tui.txt index b8350fbe1..b591a1294 100644 --- a/ports/shared/tui.txt +++ b/ports/shared/tui.txt @@ -881,6 +881,10 @@ rb_here() { # Ended by Start + Select or a signal: the player asked to leave, and no # button may be left to close a message. : + elif [ "$status" = "2" ] && [ "$(head -n 1 "$RB_STATE/tui.err" 2>/dev/null)" = "RetroBIOS: quit. Nothing was installed." ]; then + # Quit chosen (B on the first screen, Quit): the player asked to leave, + # as with Start + Select. + : elif [ "$status" != "0" ] && [ "$(head -n 1 "$RB_STATE/tui.err" 2>/dev/null)" = "RetroBIOS: No button pressed, closing." ]; then # Closed for want of a button: the mapper never opened the pad, so no A # is coming. The error it closed is the next line, shown for diff --git a/tests/fixtures/tui/colour-120x40.txt b/tests/fixtures/tui/colour-120x40.txt index 7a89fbf7f..c1b3e5023 100644 --- a/tests/fixtures/tui/colour-120x40.txt +++ b/tests/fixtures/tui/colour-120x40.txt @@ -38,13 +38,13 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Continue^[[0;2m B/Esc Quit ? Help^[[0m + ^[[0;1;35mA/Enter^[[0m Continue ^[[0;2mB/Esc^[[0m Quit ^[[0;2m?^[[0m Help === platforms ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which setup?^[[0m Choose the setup that will use the BIOS files. -^[[0;2m Filter: setup or device (/)^[[0m + Filter: setup or device (/) ^[[0;7;35m > RetroArch^[[0m Batocera Recalbox @@ -56,30 +56,30 @@ BizHawk MiSTer FPGA RetroPie - Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip - AmberELEC, SD card RG351, RG552 - Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX - ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, Odroid Go Super, RG351V, +6 - ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, R35S, RGB10 - CrossMix-OS, SD card TrimUI Smart Pro - dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 - EmuELEC, SD card Odroid Go - JELOS, SD card RG351M, RG351V, RG353M, RG353P, RG353V, RG353VS, RG503, RG552, R33S, R36S, +24 - KNULLI, SD card RG ARC-S, RG28XX, RG34XX, RG35XX, RG35XX Plus, RG35XX H, RG35XX 2024, +15 - KOLOS, SD card RG353P, RG353M, RG353V, RG353VS, RG503, RK2023, RGB30, RGB10 Max 3 - Koriki, SD card Miyoo Mini - MinUI, SD card RG28XX, RG34XX, RG34XX SP, RG35XX, RG35XX Plus, RG35XX H, RG35XX SP, +16 - MiyooCFW, SD card BittBoy, Pocket Go, PowKiddy Q90, PowKiddy V90, PowKiddy Q20 Mini, Sup M3, +1 - muOS, SD card RG35XX H, RG35XX Plus, RG35XX SP, RG35XX 2024, RG34XX, RG40XX H, RG40XX V, +4 - MyMinUI, SD card RG35XX, Miyoo Mini, Miyoo Mini Flip, Miyoo Mini Plus, Miyoo A30, +16 - NextUI, SD card TrimUI Brick, TrimUI Smart Pro - Onion, SD card Miyoo Mini, Miyoo Mini Plus - plumOS, SD card MagicX XU20 V32 - RetroOZ, SD card Odroid Go Super, Odroid Go Advance, RGB10 Max, RGB10 Max 2 - ROCKNIX, SD card RG351, RG353, RG503, RG552, RG 55G1, RG ARC, RG35XX 2024, RG35XX Plus, +37 - spruceOS, SD card Miyoo A30, Miyoo Flip, Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip, +5 - UnofficialOS, SD card R35S, R36S, RG351M, RG351P, RG351V, RG353M, RG353P, RG353V, RG503, +5 -^[[0;2m ^[[0;1;35mA/Enter^[[0m Choose^[[0;2m B/Esc Back / Filter ? Help^[[0m + Allium, SD card Miyoo Mini, Miyoo Mini Plus, +1 + AmberELEC, SD card RG351, RG552 + Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX + ArchR, SD card R36S, R33S, R36S Soysauce, +9 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, +4 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go + JELOS, SD card RG351M, RG351V, RG353M, RG353P, +30 + KNULLI, SD card RG ARC-S, RG28XX, RG34XX, RG35XX, +18 + KOLOS, SD card RG353P, RG353M, RG353V, RG353VS, RG503, +3 + Koriki, SD card Miyoo Mini + MinUI, SD card RG28XX, RG34XX, RG34XX SP, RG35XX, +19 + MiyooCFW, SD card BittBoy, Pocket Go, PowKiddy Q90, +4 + muOS, SD card RG35XX H, RG35XX Plus, RG35XX SP, +8 + MyMinUI, SD card RG35XX, Miyoo Mini, Miyoo Mini Flip, +18 + NextUI, SD card TrimUI Brick, TrimUI Smart Pro + Onion, SD card Miyoo Mini, Miyoo Mini Plus + plumOS, SD card MagicX XU20 V32 + RetroOZ, SD card Odroid Go Super, Odroid Go Advance, +2 + ROCKNIX, SD card RG351, RG353, RG503, RG552, RG 55G1, +40 + spruceOS, SD card Miyoo A30, Miyoo Flip, Miyoo Mini, +7 + UnofficialOS, SD card R35S, R36S, RG351M, RG351P, RG351V, +9 + ^[[0;1;35mA/Enter^[[0m Choose ^[[0;2mB/Esc^[[0m Back ^[[0;2m/^[[0m Filter ^[[0;2m?^[[0m Help === folder ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which folder?^[[0m @@ -120,7 +120,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Use this folder^[[0;2m B/Esc Back ? Help^[[0m + ^[[0;1;35mA/Enter^[[0m Use this folder ^[[0;2mB/Esc^[[0m Back ^[[0;2m?^[[0m Help === folder-card ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Where is the SD card?^[[0m @@ -161,7 +161,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Browse^[[0;2m B/Esc Back ? Help^[[0m + ^[[0;1;35mA/Enter^[[0m Browse ^[[0;2mB/Esc^[[0m Back ^[[0;2m?^[[0m Help === browse ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Browse for a folder^[[0m @@ -202,7 +202,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Use this folder^[[0;2m B/Esc Back ? Help^[[0m + ^[[0;1;35mA/Enter^[[0m Use this folder ^[[0;2mB/Esc^[[0m Back ^[[0;2m?^[[0m Help === path ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Type a path^[[0m @@ -243,7 +243,7 @@ -^[[0;2m ^[[0;1;35mEnter^[[0m Use this folder^[[0;2m Esc Back Ctrl-U Clear^[[0m + ^[[0;1;35mEnter^[[0m Use this folder ^[[0;2mEsc^[[0m Back ^[[0;2mCtrl-U^[[0m Clear === create ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Create the folder?^[[0m @@ -284,7 +284,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Create^[[0;2m B/Esc Quit^[[0m + ^[[0;1;35mA/Enter^[[0m Create ^[[0;2mB/Esc^[[0m Quit === checking ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Checking^[[0m @@ -325,7 +325,7 @@ -^[[0;2m ^[[0;1;35mCtrl-C^[[0m Quit^[[0;2m B/Esc Quit^[[0m + ^[[0;1;35mCtrl-C^[[0m Quit ^[[0;2mB/Esc^[[0m Quit === plan ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m @@ -366,7 +366,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Install^[[0;2m B/Esc Back Y/o Options ? Help^[[0m + ^[[0;1;35mA/Enter^[[0m Install ^[[0;2mB/Esc^[[0m Back ^[[0;2mY/o^[[0m Options ^[[0;2m?^[[0m Help === plan-in-place ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m @@ -407,13 +407,13 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Close^[[0;2m B/Esc Back Y/o Options ? Help^[[0m + ^[[0;1;35mA/Enter^[[0m Close ^[[0;2mB/Esc^[[0m Back ^[[0;2mY/o^[[0m Options ^[[0;2m?^[[0m Help === systems ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Choose systems^[[0m All None -^[[0;2m Filter (/ to type)^[[0m + Filter (/ to type) ^[[0;7;35m > [x] nintendo-gba (gba) 1 file, 16.0 KB^[[0m [x] sony-playstation 3 files, 1.0 MB ^[[0;2m [ ] nintendo-famicom-disk (ファミコン) in place^[[0m @@ -448,7 +448,7 @@ ^[[0;1m ^[[0;1;35m2^[[0;1m of ^[[0;1;35m3^[[0;1m systems: ^[[0;1;35m4^[[0;1m files, ^[[0;1;35m1.0 MB^[[0m -^[[0;2m ^[[0;1;35mA/Enter^[[0m Continue^[[0;2m B/Esc Back X/Space Tick ? Help^[[0m + ^[[0;1;35mA/Enter^[[0m Continue ^[[0;2mB/Esc^[[0m Back ^[[0;2mX/Space^[[0m Tick ^[[0;2m?^[[0m Help === options ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Options^[[0m @@ -459,7 +459,7 @@ Installed cores only: no (1 found) --installed-cores Cores: all --core Parallel downloads: 8 --jobs - BIOS folder: ^[[0;1m~/.config/retroarch/system --dest^[[0m + BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m --dest Copy from: none --from Preview the file list --dry-run Show the command line @@ -489,7 +489,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Open^[[0;2m B/Esc Back Left/Right Set ? Help^[[0m + ^[[0;1;35mA/Enter^[[0m Open ^[[0;2mB/Esc^[[0m Back ^[[0;2mLeft/Right^[[0m Set ^[[0;2m?^[[0m Help === regions ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preferred regions^[[0m @@ -530,7 +530,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Tick^[[0;2m B/Esc Done Left/Right Order ? Help^[[0m + ^[[0;1;35mA/Enter^[[0m Tick ^[[0;2mB/Esc^[[0m Done ^[[0;2mLeft/Right^[[0m Order ^[[0;2m?^[[0m Help === cores ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Cores^[[0m @@ -571,7 +571,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Filter^[[0;2m B/Esc Back X/Space Tick ? Help^[[0m + ^[[0;1;35mA/Enter^[[0m Filter ^[[0;2mB/Esc^[[0m Back ^[[0;2mX/Space^[[0m Tick ^[[0;2m?^[[0m Help === preview ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preview^[[0m @@ -612,7 +612,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Back + ^[[0;1;35mA/Enter^[[0m Back === command ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Command line^[[0m @@ -653,14 +653,14 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Back + ^[[0;1;35mA/Enter^[[0m Back === confirm ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Confirm^[[0m Counted again for your choices. Nothing is written before Install. ^[[0;1m ^[[0;1;35m1^[[0;1m file to download, ^[[0;1;35m16.0 KB^[[0m BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m - 0 in place, ^[[0;33m1 missing^[[0m, 0 wrong copies + 0 in place, 1 missing, 0 wrong copies Free space: 50.0 GB ^[[0;7;35m > Install^[[0m @@ -694,7 +694,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Install^[[0;2m B/Esc Back ? Help^[[0m + ^[[0;1;35mA/Enter^[[0m Install ^[[0;2mB/Esc^[[0m Back ^[[0;2m?^[[0m Help === installing ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Installing^[[0m @@ -702,7 +702,7 @@ 528.0 KB of 1.0 MB 128.9 KB/s, 0:04 left 2 of 4 files -^[[0;2m B, Ctrl-C or Select+Start stops. What is done stays done.^[[0m + B, Ctrl-C or Select+Start stops. What is done stays done. ^[[0;1;31mFAILED^[[0m scph5500.bin (HTTP 404) ^[[0;32m ok^[[0m gba_bios.bin @@ -735,7 +735,7 @@ -^[[0;2m ^[[0;1;35mB/Esc^[[0m Stop^[[0;2m ? Help^[[0m + ^[[0;1;35mB/Esc^[[0m Stop ^[[0;2m?^[[0m Help === result ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;31mFinished with errors^[[0m @@ -776,7 +776,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Exit + ^[[0;1;35mA/Enter^[[0m Exit === error ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;31mSomething went wrong^[[0m @@ -817,7 +817,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Choose^[[0;2m B/Esc Back^[[0m + ^[[0;1;35mA/Enter^[[0m Choose ^[[0;2mB/Esc^[[0m Back === quit ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Quit?^[[0m @@ -858,7 +858,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Quit^[[0;2m B/Esc Stay^[[0m + ^[[0;1;35mA/Enter^[[0m Quit ^[[0;2mB/Esc^[[0m Stay === help ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Keys^[[0m @@ -899,4 +899,4 @@ -^[[0;2m Any key closes this help^[[0m + Any key closes this help diff --git a/tests/fixtures/tui/colour-40x12.txt b/tests/fixtures/tui/colour-40x12.txt index 396127fd0..7a36c05a1 100644 --- a/tests/fixtures/tui/colour-40x12.txt +++ b/tests/fixtures/tui/colour-40x12.txt @@ -8,13 +8,13 @@ Another setup or folder BIOS folder: - /home/user/.config/retroarch/system + ^[[0;1m/home/user/.config/retroarch/system^[[0m ES-DE found: its BIOS files go to each -^[[0;2m ^[[0;1;35mA^[[0m Continue^[[0;2m B Quit ? Help^[[0m + ^[[0;1;35mA^[[0m Continue ^[[0;2mB^[[0m Quit ^[[0;2m?^[[0m Help === platforms ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which setup?^[[0m -^[[0;2m Filter: setup or device (/)^[[0m + Filter: setup or device (/) ^[[0;7;35m > RetroArch^[[0m Batocera Recalbox @@ -23,7 +23,7 @@ Lakka RetroDECK ^[[0;2m 1-7 of 34^[[0m -^[[0;2m ^[[0;1;35mA^[[0m Choose^[[0;2m B Back / Filter ? Help^[[0m + ^[[0;1;35mA^[[0m Choose ^[[0;2mB^[[0m Back ^[[0;2m/^[[0m Filter ^[[0;2m?^[[0m Help === folder ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which folder?^[[0m @@ -36,7 +36,7 @@ Type a path -^[[0;2m ^[[0;1;35mA^[[0m Use this folder^[[0;2m B Back ? Help^[[0m + ^[[0;1;35mA^[[0m Use this folder ^[[0;2mB^[[0m Back ^[[0;2m?^[[0m Help === folder-card ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Where is the SD card?^[[0m @@ -49,7 +49,7 @@ ^[[0;7;35m > Browse for a folder^[[0m Type a path Look again -^[[0;2m ^[[0;1;35mA^[[0m Browse^[[0;2m B Back ? Help^[[0m + ^[[0;1;35mA^[[0m Browse ^[[0;2mB^[[0m Back ^[[0;2m?^[[0m Help === browse ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Browse for a folder^[[0m @@ -62,7 +62,7 @@ system/ -^[[0;2m ^[[0;1;35mA^[[0m Use this folder^[[0;2m B Back ? Help^[[0m + ^[[0;1;35mA^[[0m Use this folder ^[[0;2mB^[[0m Back ^[[0;2m?^[[0m Help === path ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Type a path^[[0m @@ -75,7 +75,7 @@ -^[[0;2m ^[[0;1;35mEnter^[[0m Use this folder^[[0;2m Esc Back^[[0m + ^[[0;1;35mEnter^[[0m Use this folder ^[[0;2mEsc^[[0m Back === create ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Create the folder?^[[0m @@ -88,7 +88,7 @@ -^[[0;2m ^[[0;1;35mA^[[0m Create^[[0;2m B Quit^[[0m + ^[[0;1;35mA^[[0m Create ^[[0;2mB^[[0m Quit === checking ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Checking^[[0m @@ -101,7 +101,7 @@ -^[[0;2m ^[[0;1;35mCtrl-C^[[0m Quit^[[0;2m B Quit^[[0m + ^[[0;1;35mCtrl-C^[[0m Quit ^[[0;2mB^[[0m Quit === plan ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m @@ -114,7 +114,7 @@ Copy from your old card or a folder Options Quit -^[[0;2m ^[[0;1;35mA^[[0m Install^[[0;2m B Back Y Options ? Help^[[0m + ^[[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 @@ -127,20 +127,20 @@ Options -^[[0;2m ^[[0;1;35mA^[[0m Close^[[0;2m B Back Y Options ? Help^[[0m + ^[[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 All None -^[[0;2m Filter (/ to type)^[[0m + Filter (/ to type) ^[[0;7;35m > [x] nintendo-gba (gba) 16.0 KB^[[0m [x] sony-playstation 1.0 MB ^[[0;2m [ ] nintendo-famicom-disk in place^[[0m ^[[0;1m ^[[0;1;35m2^[[0;1m of ^[[0;1;35m3^[[0;1m systems: ^[[0;1;35m4^[[0;1m files, ^[[0;1;35m1.0 MB^[[0m -^[[0;2m ^[[0;1;35mA^[[0m Continue^[[0;2m B Back X Tick ? Help^[[0m + ^[[0;1;35mA^[[0m Continue ^[[0;2mB^[[0m Back ^[[0;2mX^[[0m Tick ^[[0;2m?^[[0m Help === options ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Options^[[0m @@ -153,7 +153,7 @@ Copy from: none Preview the file list Show the command line -^[[0;2m ^[[0;1;35mA^[[0m Open^[[0;2m B Back <> Set ? Help^[[0m + ^[[0;1;35mA^[[0m Open ^[[0;2mB^[[0m Back ^[[0;2m<>^[[0m Set ^[[0;2m?^[[0m Help === regions ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preferred regions^[[0m @@ -166,7 +166,7 @@ [ ] Japan -^[[0;2m ^[[0;1;35mA^[[0m Tick^[[0;2m B Done <> Order ? Help^[[0m + ^[[0;1;35mA^[[0m Tick ^[[0;2mB^[[0m Done ^[[0;2m<>^[[0m Order ^[[0;2m?^[[0m Help === cores ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Cores^[[0m @@ -179,7 +179,7 @@ -^[[0;2m ^[[0;1;35mA^[[0m Filter^[[0;2m B Back X Tick ? Help^[[0m + ^[[0;1;35mA^[[0m Filter ^[[0;2mB^[[0m Back ^[[0;2mX^[[0m Tick ^[[0;2m?^[[0m Help === preview ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preview^[[0m @@ -192,7 +192,7 @@ missing 16.0 KB gba_bios.bin -^[[0;2m ^[[0;1;35mA^[[0m Back + ^[[0;1;35mA^[[0m Back === command ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Command line^[[0m @@ -205,7 +205,7 @@ -^[[0;2m ^[[0;1;35mA^[[0m Back + ^[[0;1;35mA^[[0m Back === confirm ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Confirm^[[0m @@ -213,12 +213,12 @@ Nothing is written before Install. ^[[0;1m ^[[0;1;35m1^[[0;1m file to download, ^[[0;1;35m16.0 KB^[[0m BIOS folder: ^[[0;1m~/.co.../retroarch/system^[[0m - 0 in place, ^[[0;33m1 missing^[[0m, 0 wrong copies + 0 in place, 1 missing, 0 wrong copies Free space: 50.0 GB ^[[0;7;35m > Install^[[0m Back -^[[0;2m ^[[0;1;35mA^[[0m Install^[[0;2m B Back ? Help^[[0m + ^[[0;1;35mA^[[0m Install ^[[0;2mB^[[0m Back ^[[0;2m?^[[0m Help === installing ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Installing^[[0m @@ -226,25 +226,25 @@ 528.0 KB of 1.0 MB 128.9 KB/s, 0:04 left 2 of 4 files -^[[0;2m B, Ctrl-C or Select+Start stops. What^[[0m -^[[0;2m is done stays done.^[[0m + B, Ctrl-C or Select+Start stops. What + is done stays done. ^[[0;1;31mFAILED^[[0m scph5500.bin (HTTP 404) ^[[0;32m ok^[[0m gba_bios.bin -^[[0;2m ^[[0;1;35mB^[[0m Stop^[[0;2m ? Help^[[0m + ^[[0;1;35mB^[[0m Stop ^[[0;2m?^[[0m Help === result ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;31mFinished with errors^[[0m ^[[0;1m ^[[0;1;32m3 installed^[[0;1m, ^[[0;1;31m1 failed^[[0;1m.^[[0m BIOS folder: - /home/user/.config/retroarch/system + ^[[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 To put them back: python3 install.py -^[[0;2m 1-8 of 17^[[0m -^[[0;2m ^[[0;1;35mA^[[0m Exit^[[0;2m ^v Scroll^[[0m +^[[0;2m 1-8 of 16^[[0m + ^[[0;1;35mA^[[0m Exit ^[[0;2m^v^[[0m Scroll === error ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;31mSomething went wrong^[[0m @@ -257,7 +257,7 @@ ^[[0;7;35m > Choose another setup or folder^[[0m Quit -^[[0;2m ^[[0;1;35mA^[[0m Choose^[[0;2m B Back PgUp/PgDn Scroll^[[0m + ^[[0;1;35mA^[[0m Choose ^[[0;2mB^[[0m Back ^[[0;2mPgUp/PgDn^[[0m Scroll === quit ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Quit?^[[0m @@ -270,7 +270,7 @@ -^[[0;2m ^[[0;1;35mA^[[0m Quit^[[0;2m B Stay^[[0m + ^[[0;1;35mA^[[0m Quit ^[[0;2mB^[[0m Stay === help ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Keys^[[0m @@ -282,5 +282,5 @@ ?, F1 This help BIOS folder: - /home/user/.config/retroarch/system -^[[0;2m Any key closes this help^[[0m + ^[[0;1m/home/user/.config/retroarch/system^[[0m + Any key closes this help diff --git a/tests/fixtures/tui/colour-40x15.txt b/tests/fixtures/tui/colour-40x15.txt index 05721261e..d5a675117 100644 --- a/tests/fixtures/tui/colour-40x15.txt +++ b/tests/fixtures/tui/colour-40x15.txt @@ -8,16 +8,16 @@ Another setup or folder BIOS folder: - /home/user/.config/retroarch/system + ^[[0;1m/home/user/.config/retroarch/system^[[0m ES-DE found: its BIOS files go to each emulator, not to ES-DE itself. -^[[0;2m ^[[0;1;35mA^[[0m Continue^[[0;2m B Quit ? Help^[[0m + ^[[0;1;35mA^[[0m Continue ^[[0;2mB^[[0m Quit ^[[0;2m?^[[0m Help === platforms ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which setup?^[[0m -^[[0;2m Filter: setup or device (/)^[[0m + Filter: setup or device (/) ^[[0;7;35m > RetroArch^[[0m Batocera Recalbox @@ -29,7 +29,7 @@ BizHawk MiSTer FPGA ^[[0;2m 1-10 of 34^[[0m -^[[0;2m ^[[0;1;35mA^[[0m Choose^[[0;2m B Back / Filter ? Help^[[0m + ^[[0;1;35mA^[[0m Choose ^[[0;2mB^[[0m Back ^[[0;2m/^[[0m Filter ^[[0;2m?^[[0m Help === folder ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which folder?^[[0m @@ -45,7 +45,7 @@ -^[[0;2m ^[[0;1;35mA^[[0m Use this folder^[[0;2m B Back ? Help^[[0m + ^[[0;1;35mA^[[0m Use this folder ^[[0;2mB^[[0m Back ^[[0;2m?^[[0m Help === folder-card ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Where is the SD card?^[[0m @@ -61,7 +61,7 @@ Look again -^[[0;2m ^[[0;1;35mA^[[0m Browse^[[0;2m B Back ? Help^[[0m + ^[[0;1;35mA^[[0m Browse ^[[0;2mB^[[0m Back ^[[0;2m?^[[0m Help === browse ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Browse for a folder^[[0m @@ -77,7 +77,7 @@ -^[[0;2m ^[[0;1;35mA^[[0m Use this folder^[[0;2m B Back ? Help^[[0m + ^[[0;1;35mA^[[0m Use this folder ^[[0;2mB^[[0m Back ^[[0;2m?^[[0m Help === path ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Type a path^[[0m @@ -93,7 +93,7 @@ -^[[0;2m ^[[0;1;35mEnter^[[0m Use this folder^[[0;2m Esc Back^[[0m + ^[[0;1;35mEnter^[[0m Use this folder ^[[0;2mEsc^[[0m Back === create ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Create the folder?^[[0m @@ -109,7 +109,7 @@ -^[[0;2m ^[[0;1;35mA^[[0m Create^[[0;2m B Quit^[[0m + ^[[0;1;35mA^[[0m Create ^[[0;2mB^[[0m Quit === checking ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Checking^[[0m @@ -125,7 +125,7 @@ -^[[0;2m ^[[0;1;35mCtrl-C^[[0m Quit^[[0;2m B Quit^[[0m + ^[[0;1;35mCtrl-C^[[0m Quit ^[[0;2mB^[[0m Quit === plan ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m @@ -141,7 +141,7 @@ Options Quit -^[[0;2m ^[[0;1;35mA^[[0m Install^[[0;2m B Back Y Options ? Help^[[0m + ^[[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 @@ -157,13 +157,13 @@ -^[[0;2m ^[[0;1;35mA^[[0m Close^[[0;2m B Back Y Options ? Help^[[0m + ^[[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 All None -^[[0;2m Filter (/ to type)^[[0m + Filter (/ to type) ^[[0;7;35m > [x] nintendo-gba (gba) 16.0 KB^[[0m [x] sony-playstation 1.0 MB ^[[0;2m [ ] nintendo-famicom-disk in place^[[0m @@ -173,7 +173,7 @@ ^[[0;1m ^[[0;1;35m2^[[0;1m of ^[[0;1;35m3^[[0;1m systems: ^[[0;1;35m4^[[0;1m files, ^[[0;1;35m1.0 MB^[[0m -^[[0;2m ^[[0;1;35mA^[[0m Continue^[[0;2m B Back X Tick ? Help^[[0m + ^[[0;1;35mA^[[0m Continue ^[[0;2mB^[[0m Back ^[[0;2mX^[[0m Tick ^[[0;2m?^[[0m Help === options ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Options^[[0m @@ -189,7 +189,7 @@ Sets --region -^[[0;2m ^[[0;1;35mA^[[0m Open^[[0;2m B Back <> Set ? Help^[[0m + ^[[0;1;35mA^[[0m Open ^[[0;2mB^[[0m Back ^[[0;2m<>^[[0m Set ^[[0;2m?^[[0m Help === regions ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preferred regions^[[0m @@ -205,7 +205,7 @@ -^[[0;2m ^[[0;1;35mA^[[0m Tick^[[0;2m B Done <> Order ? Help^[[0m + ^[[0;1;35mA^[[0m Tick ^[[0;2mB^[[0m Done ^[[0;2m<>^[[0m Order ^[[0;2m?^[[0m Help === cores ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Cores^[[0m @@ -221,7 +221,7 @@ -^[[0;2m ^[[0;1;35mA^[[0m Filter^[[0;2m B Back X Tick ? Help^[[0m + ^[[0;1;35mA^[[0m Filter ^[[0;2mB^[[0m Back ^[[0;2mX^[[0m Tick ^[[0;2m?^[[0m Help === preview ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preview^[[0m @@ -237,7 +237,7 @@ -^[[0;2m ^[[0;1;35mA^[[0m Back + ^[[0;1;35mA^[[0m Back === command ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Command line^[[0m @@ -253,7 +253,7 @@ -^[[0;2m ^[[0;1;35mA^[[0m Back + ^[[0;1;35mA^[[0m Back === confirm ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Confirm^[[0m @@ -261,7 +261,7 @@ Nothing is written before Install. ^[[0;1m ^[[0;1;35m1^[[0;1m file to download, ^[[0;1;35m16.0 KB^[[0m BIOS folder: ^[[0;1m~/.co.../retroarch/system^[[0m - 0 in place, ^[[0;33m1 missing^[[0m, 0 wrong copies + 0 in place, 1 missing, 0 wrong copies Free space: 50.0 GB ^[[0;7;35m > Install^[[0m @@ -269,7 +269,7 @@ -^[[0;2m ^[[0;1;35mA^[[0m Install^[[0;2m B Back ? Help^[[0m + ^[[0;1;35mA^[[0m Install ^[[0;2mB^[[0m Back ^[[0;2m?^[[0m Help === installing ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Installing^[[0m @@ -277,21 +277,21 @@ 528.0 KB of 1.0 MB 128.9 KB/s, 0:04 left 2 of 4 files -^[[0;2m B, Ctrl-C or Select+Start stops. What^[[0m -^[[0;2m is done stays done.^[[0m + B, Ctrl-C or Select+Start stops. What + is done stays done. ^[[0;1;31mFAILED^[[0m scph5500.bin (HTTP 404) ^[[0;32m ok^[[0m gba_bios.bin -^[[0;2m ^[[0;1;35mB^[[0m Stop^[[0;2m ? Help^[[0m + ^[[0;1;35mB^[[0m Stop ^[[0;2m?^[[0m Help === result ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;31mFinished with errors^[[0m ^[[0;1m ^[[0;1;32m3 installed^[[0;1m, ^[[0;1;31m1 failed^[[0;1m.^[[0m BIOS folder: - /home/user/.config/retroarch/system + ^[[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 @@ -300,8 +300,8 @@ --restore /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 -^[[0;2m 1-11 of 17^[[0m -^[[0;2m ^[[0;1;35mA^[[0m Exit^[[0;2m ^v Scroll^[[0m +^[[0;2m 1-11 of 16^[[0m + ^[[0;1;35mA^[[0m Exit ^[[0;2m^v^[[0m Scroll === error ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;31mSomething went wrong^[[0m @@ -317,7 +317,7 @@ Quit -^[[0;2m ^[[0;1;35mA^[[0m Choose^[[0;2m B Back^[[0m + ^[[0;1;35mA^[[0m Choose ^[[0;2mB^[[0m Back === quit ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Quit?^[[0m @@ -333,7 +333,7 @@ -^[[0;2m ^[[0;1;35mA^[[0m Quit^[[0;2m B Stay^[[0m + ^[[0;1;35mA^[[0m Quit ^[[0;2mB^[[0m Stay === help ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Keys^[[0m @@ -345,8 +345,8 @@ ?, F1 This help BIOS folder: - /home/user/.config/retroarch/system + ^[[0;1m/home/user/.config/retroarch/system^[[0m -^[[0;2m Any key closes this help^[[0m + Any key closes this help diff --git a/tests/fixtures/tui/colour-80x24.txt b/tests/fixtures/tui/colour-80x24.txt index fd8f432c9..2b194ae3d 100644 --- a/tests/fixtures/tui/colour-80x24.txt +++ b/tests/fixtures/tui/colour-80x24.txt @@ -22,11 +22,11 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Continue^[[0;2m B/Esc Quit ? Help^[[0m + ^[[0;1;35mA/Enter^[[0m Continue ^[[0;2mB/Esc^[[0m Quit ^[[0;2m?^[[0m Help === platforms ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which setup?^[[0m -^[[0;2m Filter: setup or device (/)^[[0m + Filter: setup or device (/) ^[[0;7;35m > RetroArch^[[0m Batocera Recalbox @@ -38,16 +38,16 @@ BizHawk MiSTer FPGA RetroPie - Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip - AmberELEC, SD card RG351, RG552 - Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX - ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, +8 - ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, +2 - CrossMix-OS, SD card TrimUI Smart Pro - dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 - EmuELEC, SD card Odroid Go + Allium, SD card Miyoo Mini, Miyoo Mini Plus, +1 + AmberELEC, SD card RG351, RG552 + Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX + ArchR, SD card R36S, R33S, R36S Soysauce, +9 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, +4 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go ^[[0;2m 1-19 of 34^[[0m -^[[0;2m ^[[0;1;35mA/Enter^[[0m Choose^[[0;2m B/Esc Back / Filter ? Help^[[0m + ^[[0;1;35mA/Enter^[[0m Choose ^[[0;2mB/Esc^[[0m Back ^[[0;2m/^[[0m Filter ^[[0;2m?^[[0m Help === folder ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which folder?^[[0m @@ -72,7 +72,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Use this folder^[[0;2m B/Esc Back ? Help^[[0m + ^[[0;1;35mA/Enter^[[0m Use this folder ^[[0;2mB/Esc^[[0m Back ^[[0;2m?^[[0m Help === folder-card ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Where is the SD card?^[[0m @@ -97,7 +97,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Browse^[[0;2m B/Esc Back ? Help^[[0m + ^[[0;1;35mA/Enter^[[0m Browse ^[[0;2mB/Esc^[[0m Back ^[[0;2m?^[[0m Help === browse ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Browse for a folder^[[0m @@ -122,7 +122,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Use this folder^[[0;2m B/Esc Back ? Help^[[0m + ^[[0;1;35mA/Enter^[[0m Use this folder ^[[0;2mB/Esc^[[0m Back ^[[0;2m?^[[0m Help === path ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Type a path^[[0m @@ -147,7 +147,7 @@ -^[[0;2m ^[[0;1;35mEnter^[[0m Use this folder^[[0;2m Esc Back Ctrl-U Clear^[[0m + ^[[0;1;35mEnter^[[0m Use this folder ^[[0;2mEsc^[[0m Back ^[[0;2mCtrl-U^[[0m Clear === create ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Create the folder?^[[0m @@ -172,7 +172,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Create^[[0;2m B/Esc Quit^[[0m + ^[[0;1;35mA/Enter^[[0m Create ^[[0;2mB/Esc^[[0m Quit === checking ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Checking^[[0m @@ -197,7 +197,7 @@ -^[[0;2m ^[[0;1;35mCtrl-C^[[0m Quit^[[0;2m B/Esc Quit^[[0m + ^[[0;1;35mCtrl-C^[[0m Quit ^[[0;2mB/Esc^[[0m Quit === plan ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m @@ -222,7 +222,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Install^[[0;2m B/Esc Back Y/o Options ? Help^[[0m + ^[[0;1;35mA/Enter^[[0m Install ^[[0;2mB/Esc^[[0m Back ^[[0;2mY/o^[[0m Options ^[[0;2m?^[[0m Help === plan-in-place ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m @@ -247,13 +247,13 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Close^[[0;2m B/Esc Back Y/o Options ? Help^[[0m + ^[[0;1;35mA/Enter^[[0m Close ^[[0;2mB/Esc^[[0m Back ^[[0;2mY/o^[[0m Options ^[[0;2m?^[[0m Help === systems ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Choose systems^[[0m All None -^[[0;2m Filter (/ to type)^[[0m + Filter (/ to type) ^[[0;7;35m > [x] nintendo-gba (gba) 1 file, 16.0 KB^[[0m [x] sony-playstation 3 files, 1.0 MB ^[[0;2m [ ] nintendo-famicom-disk (ファミコン) in place^[[0m @@ -272,7 +272,7 @@ ^[[0;1m ^[[0;1;35m2^[[0;1m of ^[[0;1;35m3^[[0;1m systems: ^[[0;1;35m4^[[0;1m files, ^[[0;1;35m1.0 MB^[[0m -^[[0;2m ^[[0;1;35mA/Enter^[[0m Continue^[[0;2m B/Esc Back X/Space Tick ? Help^[[0m + ^[[0;1;35mA/Enter^[[0m Continue ^[[0;2mB/Esc^[[0m Back ^[[0;2mX/Space^[[0m Tick ^[[0;2m?^[[0m Help === options ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Options^[[0m @@ -283,7 +283,7 @@ Installed cores only: no (1 found) --installed-cores Cores: all --core Parallel downloads: 8 --jobs - BIOS folder: ^[[0;1m~/.config/retroarch/system --dest^[[0m + BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m --dest Copy from: none --from Preview the file list --dry-run Show the command line @@ -297,7 +297,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Open^[[0;2m B/Esc Back Left/Right Set ? Help^[[0m + ^[[0;1;35mA/Enter^[[0m Open ^[[0;2mB/Esc^[[0m Back ^[[0;2mLeft/Right^[[0m Set ^[[0;2m?^[[0m Help === regions ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preferred regions^[[0m @@ -322,7 +322,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Tick^[[0;2m B/Esc Done Left/Right Order ? Help^[[0m + ^[[0;1;35mA/Enter^[[0m Tick ^[[0;2mB/Esc^[[0m Done ^[[0;2mLeft/Right^[[0m Order ^[[0;2m?^[[0m Help === cores ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Cores^[[0m @@ -347,7 +347,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Filter^[[0;2m B/Esc Back X/Space Tick ? Help^[[0m + ^[[0;1;35mA/Enter^[[0m Filter ^[[0;2mB/Esc^[[0m Back ^[[0;2mX/Space^[[0m Tick ^[[0;2m?^[[0m Help === preview ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preview^[[0m @@ -372,7 +372,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Back + ^[[0;1;35mA/Enter^[[0m Back === command ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Command line^[[0m @@ -397,14 +397,14 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Back + ^[[0;1;35mA/Enter^[[0m Back === confirm ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Confirm^[[0m Counted again for your choices. Nothing is written before Install. ^[[0;1m ^[[0;1;35m1^[[0;1m file to download, ^[[0;1;35m16.0 KB^[[0m BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m - 0 in place, ^[[0;33m1 missing^[[0m, 0 wrong copies + 0 in place, 1 missing, 0 wrong copies Free space: 50.0 GB ^[[0;7;35m > Install^[[0m @@ -422,7 +422,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Install^[[0;2m B/Esc Back ? Help^[[0m + ^[[0;1;35mA/Enter^[[0m Install ^[[0;2mB/Esc^[[0m Back ^[[0;2m?^[[0m Help === installing ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Installing^[[0m @@ -430,7 +430,7 @@ 528.0 KB of 1.0 MB 128.9 KB/s, 0:04 left 2 of 4 files -^[[0;2m B, Ctrl-C or Select+Start stops. What is done stays done.^[[0m + B, Ctrl-C or Select+Start stops. What is done stays done. ^[[0;1;31mFAILED^[[0m scph5500.bin (HTTP 404) ^[[0;32m ok^[[0m gba_bios.bin @@ -447,7 +447,7 @@ -^[[0;2m ^[[0;1;35mB/Esc^[[0m Stop^[[0;2m ? Help^[[0m + ^[[0;1;35mB/Esc^[[0m Stop ^[[0;2m?^[[0m Help === result ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;31mFinished with errors^[[0m @@ -472,7 +472,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Exit + ^[[0;1;35mA/Enter^[[0m Exit === error ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;31mSomething went wrong^[[0m @@ -497,7 +497,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Choose^[[0;2m B/Esc Back^[[0m + ^[[0;1;35mA/Enter^[[0m Choose ^[[0;2mB/Esc^[[0m Back === quit ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Quit?^[[0m @@ -522,7 +522,7 @@ -^[[0;2m ^[[0;1;35mA/Enter^[[0m Quit^[[0;2m B/Esc Stay^[[0m + ^[[0;1;35mA/Enter^[[0m Quit ^[[0;2mB/Esc^[[0m Stay === help ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Keys^[[0m @@ -547,4 +547,4 @@ -^[[0;2m Any key closes this help^[[0m + Any key closes this help diff --git a/tests/fixtures/tui/platforms-120x40.txt b/tests/fixtures/tui/platforms-120x40.txt index 0389c94a8..258c9ce65 100644 --- a/tests/fixtures/tui/platforms-120x40.txt +++ b/tests/fixtures/tui/platforms-120x40.txt @@ -14,27 +14,27 @@ BizHawk MiSTer FPGA RetroPie - Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip - AmberELEC, SD card RG351, RG552 - Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX - ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, Odroid Go Super, RG351V, +6 - ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, R35S, RGB10 - CrossMix-OS, SD card TrimUI Smart Pro - dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 - EmuELEC, SD card Odroid Go - JELOS, SD card RG351M, RG351V, RG353M, RG353P, RG353V, RG353VS, RG503, RG552, R33S, R36S, +24 - KNULLI, SD card RG ARC-S, RG28XX, RG34XX, RG35XX, RG35XX Plus, RG35XX H, RG35XX 2024, +15 - KOLOS, SD card RG353P, RG353M, RG353V, RG353VS, RG503, RK2023, RGB30, RGB10 Max 3 - Koriki, SD card Miyoo Mini - MinUI, SD card RG28XX, RG34XX, RG34XX SP, RG35XX, RG35XX Plus, RG35XX H, RG35XX SP, +16 - MiyooCFW, SD card BittBoy, Pocket Go, PowKiddy Q90, PowKiddy V90, PowKiddy Q20 Mini, Sup M3, +1 - muOS, SD card RG35XX H, RG35XX Plus, RG35XX SP, RG35XX 2024, RG34XX, RG40XX H, RG40XX V, +4 - MyMinUI, SD card RG35XX, Miyoo Mini, Miyoo Mini Flip, Miyoo Mini Plus, Miyoo A30, +16 - NextUI, SD card TrimUI Brick, TrimUI Smart Pro - Onion, SD card Miyoo Mini, Miyoo Mini Plus - plumOS, SD card MagicX XU20 V32 - RetroOZ, SD card Odroid Go Super, Odroid Go Advance, RGB10 Max, RGB10 Max 2 - ROCKNIX, SD card RG351, RG353, RG503, RG552, RG 55G1, RG ARC, RG35XX 2024, RG35XX Plus, +37 - spruceOS, SD card Miyoo A30, Miyoo Flip, Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip, +5 - UnofficialOS, SD card R35S, R36S, RG351M, RG351P, RG351V, RG353M, RG353P, RG353V, RG503, +5 + Allium, SD card Miyoo Mini, Miyoo Mini Plus, +1 + AmberELEC, SD card RG351, RG552 + Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX + ArchR, SD card R36S, R33S, R36S Soysauce, +9 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, +4 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go + JELOS, SD card RG351M, RG351V, RG353M, RG353P, +30 + KNULLI, SD card RG ARC-S, RG28XX, RG34XX, RG35XX, +18 + KOLOS, SD card RG353P, RG353M, RG353V, RG353VS, RG503, +3 + Koriki, SD card Miyoo Mini + MinUI, SD card RG28XX, RG34XX, RG34XX SP, RG35XX, +19 + MiyooCFW, SD card BittBoy, Pocket Go, PowKiddy Q90, +4 + muOS, SD card RG35XX H, RG35XX Plus, RG35XX SP, +8 + MyMinUI, SD card RG35XX, Miyoo Mini, Miyoo Mini Flip, +18 + NextUI, SD card TrimUI Brick, TrimUI Smart Pro + Onion, SD card Miyoo Mini, Miyoo Mini Plus + plumOS, SD card MagicX XU20 V32 + RetroOZ, SD card Odroid Go Super, Odroid Go Advance, +2 + ROCKNIX, SD card RG351, RG353, RG503, RG552, RG 55G1, +40 + spruceOS, SD card Miyoo A30, Miyoo Flip, Miyoo Mini, +7 + UnofficialOS, SD card R35S, R36S, RG351M, RG351P, RG351V, +9 A/Enter Choose B/Esc Back / Filter ? Help diff --git a/tests/fixtures/tui/platforms-80x24.txt b/tests/fixtures/tui/platforms-80x24.txt index 3aa5e9ded..7fb53a4c8 100644 --- a/tests/fixtures/tui/platforms-80x24.txt +++ b/tests/fixtures/tui/platforms-80x24.txt @@ -12,13 +12,13 @@ BizHawk MiSTer FPGA RetroPie - Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip - AmberELEC, SD card RG351, RG552 - Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX - ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, +8 - ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, +2 - CrossMix-OS, SD card TrimUI Smart Pro - dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 - EmuELEC, SD card Odroid Go + Allium, SD card Miyoo Mini, Miyoo Mini Plus, +1 + AmberELEC, SD card RG351, RG552 + Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX + ArchR, SD card R36S, R33S, R36S Soysauce, +9 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, +4 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go 1-19 of 34 A/Enter Choose B/Esc Back / Filter ? Help diff --git a/tests/fixtures/tui/result-40x12.txt b/tests/fixtures/tui/result-40x12.txt index 568b9dce0..b9050871d 100644 --- a/tests/fixtures/tui/result-40x12.txt +++ b/tests/fixtures/tui/result-40x12.txt @@ -8,5 +8,5 @@ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 To put them back: python3 install.py - 1-8 of 17 + 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 45e55c93a..6f1adffc0 100644 --- a/tests/fixtures/tui/result-40x15.txt +++ b/tests/fixtures/tui/result-40x15.txt @@ -11,5 +11,5 @@ --restore /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - 1-11 of 17 + 1-11 of 16 A Exit ^v Scroll diff --git a/tests/fixtures/tui/route-evmapy.txt b/tests/fixtures/tui/route-evmapy.txt index 89b845d3a..db9c01545 100644 --- a/tests/fixtures/tui/route-evmapy.txt +++ b/tests/fixtures/tui/route-evmapy.txt @@ -96,14 +96,14 @@ BizHawk MiSTer FPGA RetroPie - Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip - AmberELEC, SD card RG351, RG552 - Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX - ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, +8 - ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, +2 - CrossMix-OS, SD card TrimUI Smart Pro - dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 - EmuELEC, SD card Odroid Go + Allium, SD card Miyoo Mini, Miyoo Mini Plus, +1 + AmberELEC, SD card RG351, RG552 + Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX + ArchR, SD card R36S, R33S, R36S Soysauce, +9 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, +4 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go 1-19 of 34 A Choose B Back === folder 40x12 diff --git a/tests/fixtures/tui/route-gptokeyb.txt b/tests/fixtures/tui/route-gptokeyb.txt index 7ae640cbc..5f9ba3a01 100644 --- a/tests/fixtures/tui/route-gptokeyb.txt +++ b/tests/fixtures/tui/route-gptokeyb.txt @@ -96,14 +96,14 @@ BizHawk MiSTer FPGA RetroPie - Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip - AmberELEC, SD card RG351, RG552 - Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX - ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, +8 - ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, +2 - CrossMix-OS, SD card TrimUI Smart Pro - dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 - EmuELEC, SD card Odroid Go + Allium, SD card Miyoo Mini, Miyoo Mini Plus, +1 + AmberELEC, SD card RG351, RG552 + Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX + ArchR, SD card R36S, R33S, R36S Soysauce, +9 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, +4 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go 1-19 of 34 A Choose B Back === folder 40x12 diff --git a/tests/fixtures/tui/route-gptokeyb_muxterm.txt b/tests/fixtures/tui/route-gptokeyb_muxterm.txt index 5a8efe5a0..8e7658a13 100644 --- a/tests/fixtures/tui/route-gptokeyb_muxterm.txt +++ b/tests/fixtures/tui/route-gptokeyb_muxterm.txt @@ -96,14 +96,14 @@ BizHawk MiSTer FPGA RetroPie - Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip - AmberELEC, SD card RG351, RG552 - Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX - ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, +8 - ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, +2 - CrossMix-OS, SD card TrimUI Smart Pro - dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 - EmuELEC, SD card Odroid Go + Allium, SD card Miyoo Mini, Miyoo Mini Plus, +1 + AmberELEC, SD card RG351, RG552 + Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX + ArchR, SD card R36S, R33S, R36S Soysauce, +9 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, +4 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go 1-19 of 34 A Choose B Back === folder 40x12 diff --git a/tests/fixtures/tui/route-keys.txt b/tests/fixtures/tui/route-keys.txt index e26ac816f..885c857ab 100644 --- a/tests/fixtures/tui/route-keys.txt +++ b/tests/fixtures/tui/route-keys.txt @@ -96,14 +96,14 @@ BizHawk MiSTer FPGA RetroPie - Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip - AmberELEC, SD card RG351, RG552 - Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX - ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, +8 - ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, +2 - CrossMix-OS, SD card TrimUI Smart Pro - dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 - EmuELEC, SD card Odroid Go + Allium, SD card Miyoo Mini, Miyoo Mini Plus, +1 + AmberELEC, SD card RG351, RG552 + Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX + ArchR, SD card R36S, R33S, R36S Soysauce, +9 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, +4 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go 1-19 of 34 Enter Choose Esc Back / Filter ? Help === folder 40x12 @@ -927,7 +927,7 @@ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 To put them back: python3 install.py - 1-8 of 17 + 1-8 of 16 Enter Exit ^v Scroll === result 40x15 RetroBIOS: Finished with errors @@ -943,7 +943,7 @@ --restore /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - 1-11 of 17 + 1-11 of 16 Enter Exit ^v Scroll === result 80x24 RetroBIOS: Finished with errors diff --git a/tests/fixtures/tui/route-miyoo.txt b/tests/fixtures/tui/route-miyoo.txt index 77041c30d..89e4c8bef 100644 --- a/tests/fixtures/tui/route-miyoo.txt +++ b/tests/fixtures/tui/route-miyoo.txt @@ -96,14 +96,14 @@ BizHawk MiSTer FPGA RetroPie - Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip - AmberELEC, SD card RG351, RG552 - Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX - ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, +8 - ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, +2 - CrossMix-OS, SD card TrimUI Smart Pro - dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 - EmuELEC, SD card Odroid Go + Allium, SD card Miyoo Mini, Miyoo Mini Plus, +1 + AmberELEC, SD card RG351, RG552 + Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX + ArchR, SD card R36S, R33S, R36S Soysauce, +9 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, +4 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go 1-19 of 34 A Choose R2 Back === folder 40x12 diff --git a/tests/fixtures/tui/route-muxterm.txt b/tests/fixtures/tui/route-muxterm.txt index 1e8af124d..64db33553 100644 --- a/tests/fixtures/tui/route-muxterm.txt +++ b/tests/fixtures/tui/route-muxterm.txt @@ -96,14 +96,14 @@ BizHawk MiSTer FPGA RetroPie - Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip - AmberELEC, SD card RG351, RG552 - Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX - ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, +8 - ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, +2 - CrossMix-OS, SD card TrimUI Smart Pro - dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 - EmuELEC, SD card Odroid Go + Allium, SD card Miyoo Mini, Miyoo Mini Plus, +1 + AmberELEC, SD card RG351, RG552 + Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX + ArchR, SD card R36S, R33S, R36S Soysauce, +9 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, +4 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go 1-19 of 34 Start Choose B Back === folder 40x12 diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index e6bf0a222..415df4240 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -102,18 +102,19 @@ class InstallerOutputExample(unittest.TestCase): menu = re.search(r"RetroArch: (\d+) files, ([\d.]+ [KMG]B)[.,]([^`]*)", text) self.assertEqual(menu.groups()[:2], (count, size)) self.assertSplitAddsUp(menu.group(3), count, size) - games = re.search(r"g\) leave out the (\d+) game data files \(([\d.]+ [KMG]B)\)", + games = re.search(r"g\) Game data: kept \((\d+) files, ([\d.]+ [KMG]B)\)", menu.group(3)) self.assertIsNotNone(games, "the menu offers to leave the game data out") - split = re.search(r"(\d+) BIOS files \(([\d.]+ [KMG]B)\) and (\d+) game data files " - r"\(([\d.]+ [KMG]B)\)", menu.group(3)) + split = re.search(r"(\d+) BIOS files \(([\d.]+ [KMG]B)\)(?:,| and) (\d+) game data " + r"files \(([\d.]+ [KMG]B)\)", menu.group(3)) self.assertEqual(games.groups(), split.groups()[2:]) def assertSplitAddsUp(self, text: str, count: str, size: str) -> None: """The run counted 8069 files where its menu counted 3259 BIOS files and 5089 game data files: the parts add up to the total they divide, the sizes within the rounding of their three figures.""" - split = re.search(r"(\d+) BIOS files \(([\d.]+) ([KMG]B)\) and (\d+) game data " + # Data files are a third kind, counted beside the two (tui_audit_12 P2-3). + split = re.search(r"(\d+) BIOS files \(([\d.]+) ([KMG]B)\)(?:,| and) (\d+) game data " r"files \(([\d.]+) ([KMG]B)\)", text) self.assertIsNotNone(split, "a categorized list says its BIOS files and game data") bios, bios_size, bios_unit, games, game_size, game_unit = split.groups() diff --git a/tests/test_handheld_launchers.py b/tests/test_handheld_launchers.py index 66db45cf8..d0d0cf8e4 100644 --- a/tests/test_handheld_launchers.py +++ b/tests/test_handheld_launchers.py @@ -510,6 +510,9 @@ if os.environ.get("FAKE_IDLE_ERROR"): # RETROBIOS_IDLE_EXIT on an error screen: the idle line, then the error. print("RetroBIOS: No button pressed, closing.", file=sys.stderr) print("Error: " + os.environ["FAKE_IDLE_ERROR"], file=sys.stderr) +if os.environ.get("FAKE_QUIT"): + # q or Quit on the screen: the farewell on stderr, code 2. + print("RetroBIOS: quit. Nothing was installed.", file=sys.stderr) if os.environ.get("FAKE_ERROR"): print("Error: " + os.environ["FAKE_ERROR"], file=sys.stderr) sys.exit(int(os.environ.get("FAKE_CODE", "0"))) @@ -959,6 +962,27 @@ class ThePortMasterPort(unittest.TestCase): self.assertIn("Error: the folder is read-only", device.screen) self.assertIn("log.txt", device.screen) + def test_a_quit_chosen_shows_no_message(self): + """code_audit_19 P3-1: a quit chosen exits 2, below 128, and the + first line of tui.err went into a message box waiting for A.""" + device = self.device() + proc = device.run(["bash", self.script], FAKE_QUIT="1", FAKE_CODE="2", + RB_MESSAGE_WAIT="300") + self.assertEqual(proc.returncode, 2) + self.assertLess(device.elapsed, 15) + self.assertNotIn("Nothing was installed", device.screen) + self.assertNotIn("Press A", device.screen) + device.assert_console_restored() + + def test_the_quit_line_is_the_one_install_py_writes(self): + state = install.TuiState(os_type="linux", home=Path.home()) + state.quit_asked, state.exit_code = True, install.QUIT_CODE + self.assertTrue(install.tui_quit_chosen(state)) + line = install.tui_farewell(state)[0] + tui = (REPO_ROOT / "ports" / "shared" / "tui.txt").read_text(encoding="utf-8") + self.assertIn(f'= "{line}" ]', tui) + self.assertIn(f'print("{line}", file=sys.stderr)', FAKE_INSTALL) + def test_offline_installs_from_a_pack_on_the_card(self): device = self.device(FAKE_CURL="7") roms = device.root / "roms" diff --git a/tests/test_install.py b/tests/test_install.py index 42fb10a39..0d3062fc2 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -1790,7 +1790,7 @@ class TestDetectedPlatformPrompt(unittest.TestCase): def test_q_quits_and_the_menu_says_so(self): result, out = self._answer("q") self.assertIsInstance(result, SystemExit) - self.assertEqual(result.code, 130) + self.assertEqual(result.code, install.QUIT_CODE) self.assertIn("q) quit", out) def test_a_wrong_answer_names_the_choices(self): @@ -1859,7 +1859,7 @@ class TestManualPlatformPrompt(unittest.TestCase): with unittest.mock.patch("builtins.input", lambda _prompt="": "q"): with self.assertRaises(SystemExit) as raised: install._prompt_manual_platform("linux") - self.assertEqual(raised.exception.code, 130) + self.assertEqual(raised.exception.code, install.QUIT_CODE) def test_quotes_around_a_typed_folder_are_dropped(self): """Explorer's "Copy as path" quotes the folder: the files went into a diff --git a/tests/test_install_selection.py b/tests/test_install_selection.py index 882b196b0..ec313ad02 100644 --- a/tests/test_install_selection.py +++ b/tests/test_install_selection.py @@ -721,9 +721,10 @@ class TheOneLinerNarrows(unittest.TestCase): self.assertFalse((repo.root / '"').exists()) self.assertEqual(len([p for p in card.rglob("*") if p.is_file()]), 5) - def test_q_at_every_menu_stops_on_130(self): + def test_q_at_every_menu_stops_on_the_quit_code(self): """`install.sh && retroarch` started RetroArch after a q, which left - with 0 where Ctrl+C and Ctrl+D leave with 130.""" + with 0. A quit chosen ends on 2, nothing installed, where Ctrl+C and + Ctrl+D leave with 130 (code_audit_19 P3-1).""" files = [{"dest": "a.bin", "size": 1, "systems": ["nintendo-gba"]}] menus = ( lambda: install._choose_platform("Platform:"), @@ -736,7 +737,7 @@ class TheOneLinerNarrows(unittest.TestCase): mock.patch("sys.stdout", new_callable=lambda: open(os.devnull, "w")), \ self.assertRaises(SystemExit) as stop: menu() - self.assertEqual(stop.exception.code, 130) + self.assertEqual(stop.exception.code, install.QUIT_CODE) @unittest.skipIf(os.name == "nt", "a pseudo-terminal is a POSIX one") def test_ctrl_d_at_the_first_question_has_nothing_to_carry_on(self): diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index b53c1bf31..048df69fa 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -550,10 +550,10 @@ class Flow(unittest.TestCase): press(state, "esc") self.assertEqual(state.screen, "setup") press(state, "esc", "enter") - self.assertEqual(state.exit_code, 130) + self.assertEqual(state.exit_code, install.QUIT_CODE) state = checked_state() self.assertEqual(press(state, "q"), []) - self.assertEqual(state.exit_code, 130) + self.assertEqual(state.exit_code, install.QUIT_CODE) def test_ctrl_c_stops_an_install_once_then_kills(self): state = checked_state() @@ -1063,8 +1063,12 @@ class Views(unittest.TestCase): state.cols, state.rows = cols, rows lines = install.tui_view(state, cols, rows) for line in lines: + # The bar's key names faint, its actions not + # (tui_audit_12 P3-3): the rest as before. self.assertEqual(install.tui_paint(str(line), line.style, False), - install._STYLES.get(line.style, "") + line) + install.painted(line, install._bar_spans( + line, "strong")) if line.style == "hint" + else install._STYLES.get(line.style, "") + line) stripped = [install.unpainted(painted.replace("^[", "\x1b")).rstrip() for painted in screens[name].split("\n")] golden = (GOLDEN / f"{name}-{cols}x{rows}.txt").read_text(encoding="utf-8") @@ -1079,7 +1083,7 @@ class Views(unittest.TestCase): for role, word in (("accent", "RetroBIOS"), ("strong+success", "4 files installed"), ("strong+success", "Done"), ("failure", "1 failed"), ("failure", "failed:"), ("strong+warning", "3 not fetched"), - ("strong+warning", "Stopped"), ("muted", " "), + ("strong+warning", "Stopped"), ("strong", "/home/user/.config/retroarch/system")): with self.subTest(role=role, word=word): self.assertIn(install._sgr(role) + word, drawn) @@ -1099,11 +1103,11 @@ class Views(unittest.TestCase): """The first entry's key in the accent, never a word: "Writing to the card..." and "Any key closes this help" name no key.""" for text in (" Writing to the card...", " Any key closes this help"): - self.assertEqual(install.tui_paint(text, "hint", True), - install._sgr("muted") + text + "\x1b[0m") + self.assertEqual(install.tui_paint(text, "hint", True), text) painted = install.tui_paint(" A/Enter Exit B/Esc Back", "hint", True) - self.assertTrue(painted.startswith(install._sgr("muted") + " " - + install._sgr("accent") + "A/Enter"), painted) + # The actions plain, the other keys faint (tui_audit_12 P3-3). + self.assertEqual(painted, " " + install._sgr("accent") + "A/Enter\x1b[0m Exit " + + install._sgr("muted") + "B/Esc\x1b[0m Back") def test_the_hint_bar_names_the_primary_action_first(self): for name, state in golden_states().items(): @@ -2657,7 +2661,7 @@ class AuditRoundThree(unittest.TestCase): state.no_game_data = True label = next(row.label for row in install._option_rows(state) if row.action == "game_data") - self.assertEqual(label, "Game data: left out, firmware only") + self.assertEqual(label, "Game data: left out") def test_p3_the_plan_says_which_config_named_the_folder(self): config = HOME / ".config/retroarch/retroarch.cfg" @@ -2815,7 +2819,7 @@ class IdleExit(unittest.TestCase): """0 read as "everything asked for is installed" with nothing done.""" state = fresh_state() state.stack = ["setup"] - self.assertEqual(self.idle_close(state), 130) + self.assertEqual(self.idle_close(state), install.QUIT_CODE) def idle_close(self, state: install.TuiState) -> int: """run_tui on state, no key ever pressed, closed by the idle timer.""" @@ -2915,7 +2919,11 @@ class TheSaveFolderHasARow(unittest.TestCase): state = self.state() rows = install._option_rows(state) [row] = [row for row in rows if row.action == "savedir"] - self.assertEqual((row.label, row.right), ("Save folder: not known", "--save-dir")) + # Named as line mode names it, from the same rules (tui_audit_12 P3-15). + place = install.save_directory_for(state.dest, state.manifest, None) + self.assertEqual((row.label, row.right), + (f"Save folder: {install._short_path(place.directory, HOME)}", + "--save-dir")) saves = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) self.addCleanup(shutil.rmtree, saves, True) state.stack = ["setup", "plan", "options"] @@ -3931,6 +3939,9 @@ class TheSaveFolderLineFollowsTheChoice(unittest.TestCase): "folder": "/home/user/MySaves/"} LINE = ("1 file read from RetroArch's save directory goes to a folder per core under: " "~/MySaves") + # The result spells every folder in full (tui_audit_12 P3-7). + RESULT = ("1 file read from RetroArch's save directory goes to a folder per core under: " + "/home/user/MySaves") def said(self, lines) -> str: return " ".join(" ".join(lines).split()) @@ -3964,7 +3975,7 @@ class TheSaveFolderLineFollowsTheChoice(unittest.TestCase): def test_kept_the_confirm_screen_and_the_result_name_it(self): state = self.run_to_result(confirm_saves=True, install_saves=True) self.assertIn(self.LINE, self.confirm) - self.assertIn(self.LINE, self.said(install.tui_outcome(state)[1])) + self.assertIn(self.RESULT, self.said(install.tui_outcome(state)[1])) @unittest.skipIf(os.name == "nt" or os.geteuid() == 0, @@ -4744,7 +4755,9 @@ class EndToEnd(unittest.TestCase): "folder per core under:\n ~/MySaves", screen) term.send("\r") screen = term.wait("6 files installed.") - self.assertIn("folder per core under:\n ~/MySaves", screen) + # Spelled in full on the result, as its other folders. + joined = "".join(line.strip() for line in screen.splitlines()) + self.assertIn("folder per core under:" + str(saves), joined) term.send("\r") self.assertEqual(term.close(), 0) results.append(written(repo, saves)) @@ -4789,7 +4802,7 @@ class EndToEnd(unittest.TestCase): self.assertIn("> Install", screen) term.send("\r") screen = term.wait("installed.") - self.assertIn("Left out as game data: 1 file.", screen) + self.assertIn("1 game data file left out.", screen) term.send("\r") self.assertEqual(term.close(), 0) self.assertEqual(repo.installed(), ["gba_bios.bin", "mgba/extra.bin", @@ -4804,7 +4817,7 @@ class EndToEnd(unittest.TestCase): self.assertIn("missing", screen) self.assertIn("scph5501.bin", screen) term.send("\x1b", "\x1b", "q") - self.assertEqual(term.close(), 130) + self.assertEqual(term.close(), install.QUIT_CODE) self.assertEqual(repo.installed(), []) self.assertIn("Nothing was installed.", term.after()) @@ -4916,7 +4929,7 @@ class EndToEnd(unittest.TestCase): term._pump(0.05) self.assertIn(b"\x1b[?1049h", term.raw[mark:]) term.send("q") - self.assertEqual(term.close(), 130) + self.assertEqual(term.close(), install.QUIT_CODE) def test_closing_the_window_ends_with_its_signal(self): """The terminal gone, the farewell has nowhere to go: the code is @@ -4976,7 +4989,7 @@ class EndToEnd(unittest.TestCase): self.env[install.TUI_IDLE_ENV] = "1" term = self.start() term.wait("Where do you play?") - self.assertEqual(term.close(), 130) + self.assertEqual(term.close(), install.QUIT_CODE) self.assertIn("No button pressed, closing.", term.after()) # A key pressed, the screen stays: the person is there. term = self.start() @@ -4987,7 +5000,7 @@ class EndToEnd(unittest.TestCase): term._pump(0.05) self.assertEqual(os.waitpid(term.pid, os.WNOHANG), (0, 0)) term.send("q") - self.assertEqual(term.close(), 130) + self.assertEqual(term.close(), install.QUIT_CODE) def test_no_key_on_a_screen_reached_by_itself_closes_it(self): """Given --platform, as the offline launcher route is, the check @@ -4997,7 +5010,7 @@ class EndToEnd(unittest.TestCase): self.env[install.TUI_IDLE_ENV] = "1" term = self.start("--platform", "retroarch", "--dest", str(repo.dest)) term.wait("> Install") - self.assertEqual(term.close(), 130) + self.assertEqual(term.close(), install.QUIT_CODE) self.assertIn("No button pressed, closing.", term.after()) self.assertEqual(repo.installed(), []) @@ -5014,7 +5027,7 @@ class EndToEnd(unittest.TestCase): screen = term.wait(bar) self.assertEqual(screen.splitlines()[-1].strip(), bar) term.send("q") - self.assertEqual(term.close(), 130) + self.assertEqual(term.close(), install.QUIT_CODE) for name in env: self.env.pop(name) @@ -5031,7 +5044,7 @@ class EndToEnd(unittest.TestCase): term.resize(40, 12) term.wait("Where do you play?") term.send("q") - self.assertEqual(term.close(), 130) + self.assertEqual(term.close(), install.QUIT_CODE) def test_plain_mode_keeps_the_line_questions(self): self.repo() @@ -5111,7 +5124,7 @@ class EndToEnd(unittest.TestCase): at = screen.splitlines()[0].index("RetroBIOS") self.assertEqual(title[at].fg, "magenta" if name == "colour" else "default") term.send("q") - self.assertEqual(term.close(), 130) + self.assertEqual(term.close(), install.QUIT_CODE) colours = set() for params in re.findall(rb"\x1b\[([0-9;]*)m", term.raw): colours |= {int(code) for code in params.split(b";") if code} & { @@ -5419,7 +5432,7 @@ class TheTerminalComesBack(unittest.TestCase): term.send("q") term.wait("PROMPT$") self.typed(term, "exit") - self.assertEqual(term.close(), 130) + self.assertEqual(term.close(), install.QUIT_CODE) self.assertGivenBack(term) def test_ctrl_z_with_no_shell_to_resume_it_is_ignored(self): @@ -5440,7 +5453,7 @@ class TheTerminalComesBack(unittest.TestCase): pid, status = os.waitpid(term.pid, os.WNOHANG | os.WUNTRACED) self.assertEqual(pid, 0, "stopped") term.send("q") - self.assertEqual(term.close(), 130) + self.assertEqual(term.close(), install.QUIT_CODE) self.assertGivenBack(term) def test_ctrl_z_needs_a_shell_that_can_resume(self): @@ -7443,13 +7456,14 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, args = dict(manifest=listing, dest=self.here, declared_only=False, dry_run=True) text, asked = self.line_run(_args(**args), ["g", ""]) self.assertIn("1 BIOS file (3 B) and 1 game data file (30 B).", text) - self.assertIn("g) leave out the 1 game data file (30 B), game data: files a game or " - "a game engine reads, of no use to the other cores", text) + self.assertIn("g) Game data: kept (1 file, 30 B): leave it out", text) + self.assertIn("Game data: files a game or a game engine reads, of no use to the " + "other cores.", text) after = text[text.index("Counted again"):] self.assertIn("RetroArch: 1 file, 3 B.", after) - self.assertIn("g) keep the 1 game data file", after) + self.assertIn("g) Game data: left out (1 file, 30 B): keep it", after) self.assertIn("--no-game-data", after) - self.assertIn("Left out as game data", after) + self.assertIn("1 game data file left out (--no-game-data)", after) self.assertNotIn("doom1.wad would", after) def test_the_plan_counts_game_data_apart_and_one_press_leaves_it_out(self): @@ -7460,11 +7474,12 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, entry["category"] = "game_data" state.manifest = manifest text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) - self.assertIn("3 BIOS files (1.0 MB) and 1 game data file (4 B). Game data: files a " - "game or a game engine reads, of no use to the other cores.", text) + self.assertIn("3 BIOS files (1.0 MB) and 1 game data file (4 B).", text) + self.assertIn("Game data: files a game or a game engine reads, of no use to the " + "other cores.", text) rows = install.tui_rows(state) row = next(row for row in rows if row.action == "gamedata") - self.assertIn("kept, leave out", row.label) + self.assertEqual(row.label, "Game data: kept") state.focus["plan"] = rows.index(row) effects = install.tui_update(state, Key("enter")) self.assertTrue(state.no_game_data) @@ -7528,7 +7543,7 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, # everywhere or nowhere: here, everywhere. self.assertIn("RetroArch: 4 files, 123 B.\n 1 BIOS file (3 B) and 3 game data files " "(120 B).", text) - self.assertIn("g) leave out the 3 game data files (120 B)", text) + self.assertIn("g) Game data: kept (3 files, 120 B): leave it out", text) self.assertIn(" 4 files (123 B)\n 1 BIOS file (3 B) and 3 game data files (120 B).", text) self.assertIn("0/4 present", text) @@ -7541,13 +7556,13 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, after = text[text.index("Counted again"):] 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) leave out", after) + self.assertNotIn("g) Game data", after) text, _asked = self.line_run(_args(**args), ["c", "1", "g", ""]) after = text[text.index("Counted again"):] - self.assertIn("g) leave out the 3 game data files (120 B)", after) + self.assertIn("g) Game data: kept (3 files, 120 B): leave it out", after) last = after[after.rindex("Counted again"):] - self.assertIn("g) keep the 3 game data files (120 B)", last) - self.assertIn("Left out as game data (--no-game-data): 4 files, 1 of them not " + self.assertIn("g) Game data: left out (3 files, 120 B): keep it", last) + self.assertIn("4 game data files left out (--no-game-data), 1 of them not " "installable", text) def test_the_parts_add_up_on_every_committed_list(self): @@ -7587,6 +7602,7 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, state.files = [*state.files, { "event": "file", "platform": "retroarch", "dest": "PCSX/EXTRA.BIN", "systems": ["sony-playstation"], "size": 4, "state": "missing"}] + state.checked_dests.add("PCSX/EXTRA.BIN") state.check = {**state.check, "missing": state.check["missing"] + 1, "files": state.check["files"] + 1, "bytes_needed": state.check["bytes_needed"] + 4} @@ -7594,7 +7610,7 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, self.assertIn("5 files to download", text) self.assertIn("3 BIOS files (1.0 MB) and 2 game data files (8 B).", text) row = next(row for row in install.tui_rows(state) if row.action == "gamedata") - self.assertIn("Game data (2 files, 8 B)", row.label) + self.assertEqual((row.label, row.right), ("Game data: kept", "2 files, 8 B")) # code_audit_18 P3-5, tui_audit_11 P3-10 and P3-21: line mode wording. @@ -7626,7 +7642,8 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, args = _args(manifest=listing, dest=self.here, declared_only=False, offline=True) setattr(args, "from", [old]) text, _asked = self.line_run(args, [""]) - self.assertIn(f"Copying 1 file copied from {old}", text) + self.assertIn(f"Copying 1 file (3 B) from {old}...", text) + self.assertNotIn("copied from", text[text.index("\nCopying "):]) self.assertNotIn("found on this machine (", text) def test_the_save_lines_name_the_frontend_both_times(self): @@ -7718,7 +7735,7 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, self.assertRaises(SystemExit): install._install(_args(from_pack=[pack], declared_only=False), io.StringIO()) text = out.getvalue() - self.assertEqual(menus, [130]) + self.assertEqual(menus, [install.QUIT_CODE]) self.assertIn("RetroArch was not found here.", text) self.assertIn(f"BIOS folder: {self.here}", text) self.assertIn("Enter) install everything", text) @@ -8047,7 +8064,7 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, mock.patch.object(install, "_ask", side_effect=["q"]), \ self.assertRaises(SystemExit) as stop: install._ask_from_folder([], onion / "BIOS") - self.assertEqual(stop.exception.code, 130) + self.assertEqual(stop.exception.code, install.QUIT_CODE) self.assertIn(f"1) {install.card_line(stick)}", out.getvalue()) with mock.patch.object(install, "_mounted_cards", return_value=[stick, onion]), \ contextlib.redirect_stdout(io.StringIO()) as out, \ @@ -8205,6 +8222,204 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, _text, asked = self.line_run(seen[0], [], found=pc) self.assertEqual(asked, []) + # code_audit_19 and tui_audit_12: line mode, and the screen beside it. + + def copy_folder(self) -> tuple[Path, Path]: + listing, _files = self.game_list() + old = self.root / "OLD" + (old / "doom").mkdir(parents=True) + (old / "gba_bios.bin").write_bytes(b"gba") + (old / "doom" / "doom1.wad").write_bytes(b"wad" * 10) + return listing, old + + def test_the_copy_line_says_copying_once(self): + """code_audit_19 P3-4, tui_audit_12 P3-13: "Copying 22 files copied + from ...", its size read as the folder's.""" + listing, old = self.copy_folder() + args = _args(manifest=listing, dest=self.here, declared_only=False, no_input=True, + offline=True) + setattr(args, "from", [old]) + text, _asked = self.line_run(args, []) + self.assertIn(f"Copying 2 files (33 B) from {old}...", text) + self.assertNotIn("files copied from", text[text.index("\nCopying "):]) + + def test_narrowed_is_said_only_when_the_list_shrank(self): + """tui_audit_12 P3-13: Enter for every system said "Narrowed 4 to 4".""" + args = dict(manifest=self.counted_list(), dest=self.here, declared_only=False, + dry_run=True) + text, _asked = self.line_run(_args(**args), ["c", "", ""]) + self.assertNotRegex(text, r"Narrowed (\d+) to \1 files") + text, _asked = self.line_run(_args(**args), ["c", "1", ""]) + self.assertIn("Narrowed 4 to 3 files", text) + + def test_the_dry_run_names_systems_by_their_titles(self): + """tui_audit_12 P3-13: --dry-run listed microsoft-msx where the list + and the screen say Microsoft MSX.""" + files = [self.entry("msx/msx.rom", b"msx", ["microsoft-msx"]), + self.entry("gba_bios.bin", b"gba", ["nintendo-gba"])] + listing = self.listing("retroarch", files, system_titles={ + "microsoft-msx": "Microsoft MSX", "nintendo-gba": "Nintendo Game Boy Advance"}) + args = _args(manifest=listing, dest=self.here, declared_only=False, dry_run=True, + no_input=True) + text, _asked = self.line_run(args, []) + self.assertIn(" Microsoft MSX 1 file, 3 B", text) + self.assertNotIn(" microsoft-msx 1 file", text) + manifest = json.loads(listing.read_text(encoding="utf-8")) + state = install.TuiState(os_type="linux", home=HOME) + state.manifest = manifest + self.assertEqual(install._tui_system_label({"system": "microsoft-msx"}, + install._system_titles(state)), + "Microsoft MSX") + + def test_the_largest_systems_tail_counts_groups(self): + """code_audit_19 P3-3: "... and 78 more systems" under a plan of 65.""" + files = [{"dest": f"{n}.bin", "size": n + 1, "systems": [f"s{n}"]} for n in range(12)] + files.append({"dest": "shared.bin", "size": 1, "systems": ["s0", "s1"]}) + out = io.StringIO() + with mock.patch.object(install.shutil, "get_terminal_size", + return_value=os.terminal_size((80, 10))), \ + contextlib.redirect_stdout(out): + install._print_plan(files, Path("dest"), install.Terminal(io.StringIO())) + self.assertIn("12 systems in 13 groups, the largest first", out.getvalue()) + self.assertIn("... and 3 more groups:", out.getvalue()) + # The same tail under --check, for the files the collection lacks. + absent = [{"dest": f"{n}.bin", "systems": [f"s{n}", "shared"]} for n in range(10)] + lines = install._needed_lines(absent, "Absent") + self.assertIn(" ... and 2 more groups (--check --verbose lists them all)", lines) + + def test_a_command_given_back_keeps_offline_in_both_modes(self): + """code_audit_19 P2-2, tui_audit_12 P3-13: with the variable alone, + line mode's Same choice ended with --offline and the screen's not.""" + args = dict(manifest=self.counted_list(), dest=self.here, declared_only=False, + dry_run=True) + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + text, _asked = self.line_run(_args(**args), ["c", "1", ""]) + line = next(line for line in text.splitlines() if "Same choice" in line) + self.assertIn(" --offline", line) + state = checked_state() + self.assertIn(" --offline", install.tui_command(state)) + + def test_q_quits_on_the_quit_code_in_both_modes(self): + """code_audit_19 P3-1.""" + args = dict(manifest=self.counted_list(), dest=self.here, declared_only=False) + install.LOCAL.reset() + with mock.patch.object(install, "_ask", return_value="q"), \ + contextlib.redirect_stdout(io.StringIO()), \ + self.assertRaises(SystemExit) as stop: + install._prompt_custom_selection("retroarch", [], []) + self.assertEqual(stop.exception.code, 2) + self.assertEqual(install.QUIT_CODE, 2) + state = checked_state() + install.tui_update(state, Key("char", "q")) + self.assertEqual(state.exit_code, 2) + del args + + def test_a_card_in_the_reader_is_offered_not_asked_for(self): + """code_audit_19 P3-7: line mode numbered the card and said to mount + it, the screen said none was mounted.""" + card = self.root / "media" / "KNULLI2" + (card / "system").mkdir(parents=True) + (card / "system" / "batocera.conf").write_text("system.hostname=RG40XXH\n") + with mock.patch.object(install, "_mounted_cards", return_value=[card]), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()), \ + mock.patch.object(install, "_ask", side_effect=["q"]), \ + contextlib.redirect_stdout(io.StringIO()) as out, \ + self.assertRaises(SystemExit): + install.card_install_dest("knulli", "knulli", interactive=True) + text = out.getvalue() + self.assertIn("Type 1 if it is the KNULLI card, or type its top folder.", text) + self.assertNotIn("Mount the card", text) + self.assertIn(f"1) {card.name} (card or drive, {card})", text) + state = install.TuiState(os_type="linux", home=HOME) + state.platform = "knulli" + state.card_bound = install.card_bound("knulli", "knulli") + with mock.patch.object(install, "_mounted_cards", return_value=[card]), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()): + install._open_folder_screen(state, None) + rows = install.tui_rows(state) + text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + self.assertEqual(rows[state.focus["folder"]].value, card) + self.assertNotIn("is mounted", text) + + def test_a_list_and_nothing_found_stops_a_run_without_questions(self): + """tui_audit_12 P3-12: --yes with a list filled this computer's + default folder, where the page says a run that needs an answer stops.""" + for flag in ({"yes": True}, {}): + with self.subTest(flag=flag): + code, text, events = self.unasked([], [], manifest=self.list, **flag) + self.assertEqual(code, 1, text) + self.assertNotIn("using default path", text) + self.assertIn("Name its folder with --dest", text) + self.assertEqual([(keys["stage"], keys["reason"]) for kind, keys in events + if kind == "error"], [("detect", "not_found")]) + chosen, text, _events = self.unasked([], [], platform="retroarch", yes=True) + self.assertEqual(chosen, [("retroarch", install._default_dest("linux", "retroarch"))]) + + def test_a_terminal_the_screen_cannot_use_is_named(self): + """tui_audit_12 P3-14: TERM unset or dumb gave line questions without + a word, where a small window said why.""" + reason = install.line_mode_reason + self.assertEqual(reason("TERM is dumb or not set", {}), + "TERM is not set: questions come line by line.") + self.assertEqual(reason("TERM is dumb or not set", {"TERM": "dumb"}), + "TERM=dumb: questions come line by line.") + self.assertEqual(reason("TERM=vt52 cannot move the cursor", {}), + "TERM=vt52 cannot move the cursor: questions come line by line.") + self.assertEqual(reason("the terminal is 39x12, smaller than 40x12", {}), + "The window is 39x12: questions come line by line (40x12 for the " + "full screen).") + self.assertEqual(reason("the terminal is 0x0, smaller than 40x12", {}), "") + self.assertEqual(reason("--check runs without questions", {}), "") + self.assertEqual(reason("the input is not a terminal", {}), "") + + def test_game_data_has_one_wording_and_one_decision(self): + """tui_audit_12 P3-4, P3-5: five wordings, "No game data" read as a + list holding none, and --no-game-data given offered it back on the + screen alone.""" + listing, _files = self.game_list() + args = dict(manifest=listing, dest=self.here, declared_only=False, dry_run=True) + text, _asked = self.line_run(_args(**args), ["g", ""]) + self.assertIn("g) Game data: kept (1 file, 30 B): leave it out", text) + after = text[text.index("Counted again"):] + self.assertIn("1 BIOS file (3 B). 1 game data file left out.", after) + self.assertIn("1 game data file left out (--no-game-data)", after) + text, _asked = self.line_run(_args(no_game_data=True, **args), [""]) + self.assertNotIn("g) ", text) + state = checked_state() + state.manifest = {**state.manifest, "files": [ + *state.manifest["files"], {"dest": "doom/doom1.wad", "size": 30, + "category": "game_data", "systems": ["doom"]}]} + state.no_game_data = state.cli_no_game_data = True + self.assertFalse(any(row.action == "gamedata" for row in install.tui_rows(state))) + + def test_three_kinds_are_named_in_both_modes(self): + """tui_audit_12 P2-3: "data files" beside game data files, never + said what they were.""" + listing, _files = self.game_list() + manifest = json.loads(listing.read_text(encoding="utf-8")) + manifest["data_directories"] = [{"ref": "dolphin-sys", "systems": ["nintendo-gba"], + "files": {"a": {"size": 5}, "b": {"size": 5}}}] + out = io.StringIO() + with contextlib.redirect_stdout(out): + install._print_menu_head("retroarch", manifest["files"], [], None, "", + manifest["data_directories"], None, None, None, manifest, + False) + self.assertIn("1 BIOS file (3 B), 1 game data file (30 B) and 2 data files (10 B).", + out.getvalue()) + self.assertIn("Data files: the files an emulator reads from a data folder of its own", + out.getvalue()) + + def test_the_wsl_folder_question_offers_both_sides(self): + """tui_audit_12 P3-11, line mode's half.""" + with mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "_ask", side_effect=["q"]), \ + contextlib.redirect_stdout(io.StringIO()) as out, \ + self.assertRaises(SystemExit): + install.ask_platform_folder("wsl", "retroarch") + label, folder = install.other_side("wsl", "retroarch")[0] + self.assertIn(f"1) {label}", out.getvalue()) + self.assertEqual(out.getvalue().count(install.shown_path(folder)), 1) + class AuditSeventeenAndTen(unittest.TestCase): """code_audit_17 and tui_audit_10: what the screen, the packs, the @@ -8425,10 +8640,13 @@ class AuditSeventeenAndTen(unittest.TestCase): install._open_from_browser(state) rows = install.tui_rows(state) self.assertEqual(rows[state.focus["browse"]].value, old) - self.assertIn("D: (Onion card)", [row.label for row in rows]) - state.focus["browse"] = next(i for i, row in enumerate(rows) - if row.value == dest_card) - press(state, "enter") + # Not offered at all, as line mode's Copy from leaves it out + # (tui_audit_12 P3-16). + self.assertNotIn("D: (Onion card)", [row.label for row in rows]) + self.assertIn("E: (card or drive)", [row.label for row in rows]) + # Reached through its folders all the same, it is refused. + install._browse_to(state, dest_card) + state.focus["browse"] = 0 press(state, "enter") self.assertEqual(state.screen, "browse") self.assertIn("That is the card being installed to", state.status) @@ -8569,8 +8787,8 @@ class AuditSeventeenAndTen(unittest.TestCase): self.assertIn("f) copy from a folder or an old card", menu) # The owner's request of 2026-10-27: game data counted apart, g. # test_doc_pages checks that the figures add up. - self.assertRegex(menu, r"\d+ BIOS files \([\d.]+ [KMG]B\) and \d+ game data files") - self.assertRegex(menu, r"g\) leave out the \d+ game data files") + self.assertRegex(menu, r"\d+ BIOS files \([\d.]+ [KMG]B\)(,| and) \d+ game data files") + self.assertRegex(menu, r"g\) Game data: kept \(\d+ files") env = page[page.index("## Environment overrides"):] self.assertIn("`TERMINFO`", env) self.assertIn("`TERMINFO_DIRS`", env) @@ -8958,7 +9176,7 @@ class AuditSixteenAndNineLineMode(unittest.TestCase): contextlib.redirect_stdout(io.StringIO()) as out, \ self.assertRaises(SystemExit) as stop: install.ask_platform_folder("linux", "batocera") - self.assertEqual(stop.exception.code, 130) + self.assertEqual(stop.exception.code, install.QUIT_CODE) self.assertIn("q to quit", asked[0]) self.assertIn("Type a folder, or q to quit.", out.getvalue()) self.assertFalse((Path.cwd() / "q").exists()) @@ -8983,7 +9201,7 @@ class AuditSixteenAndNineLineMode(unittest.TestCase): contextlib.redirect_stderr(io.StringIO()), \ self.assertRaises(SystemExit) as stop: install.card_install_dest("rocknix", "rocknix", interactive=True) - self.assertEqual(stop.exception.code, 130) + self.assertEqual(stop.exception.code, install.QUIT_CODE) self.assertEqual(asked, ["Card root, Enter to look again, q to quit: "]) def test_install_for_takes_enter_and_offers_another_setup(self): @@ -9609,7 +9827,7 @@ class AuditEighteenAndEleven(_CardFixtures, unittest.TestCase): """P3-19: q reported "stopped", the word of an interrupted run.""" state = checked_state() press(state, "q") - self.assertEqual(state.exit_code, 130) + self.assertEqual(state.exit_code, install.QUIT_CODE) self.assertEqual(install._closing_lines(state), ["RetroBIOS: quit. Nothing was installed."]) other = checked_state() @@ -9874,11 +10092,14 @@ class MouseEnabled(unittest.TestCase): def test_the_text_to_copy_keeps_the_terminal_selection(self): states = golden_states() - for name in ("result", "command", "path"): + for name in ("command", "path"): with self.subTest(screen=name): state = states[name] self.assertFalse(install.tui_wants_mouse(state, drawn(state, 80, 24))) - for name in ("setup", "platforms", "plan", "systems", "options", "error", "quit"): + # The result takes a tap on its Exit: its text is printed again in + # the terminal once it closes, selected there as anywhere. + for name in ("setup", "platforms", "plan", "systems", "options", "error", "quit", + "result"): with self.subTest(screen=name): state = states[name] self.assertTrue(install.tui_wants_mouse(state, drawn(state, 80, 24))) @@ -9938,14 +10159,27 @@ class MouseOnEveryScreen(unittest.TestCase): self.assertEqual(lines[line].style == "focus", index == focus) # The whole width, the text's end or not. effects = tap(state, cols - 1, line) - if index == focus: + if row_list[index].action in install.TUI_TICK_ROWS: + # A tick is ticked as Space ticks it, focus + # moved there first. + keyed.focus[keyed.screen] = index self.assertEqual(effects, - install.tui_update(keyed, Key("enter"))) - self.assertEqual(state.stack, keyed.stack) - else: - self.assertEqual(effects, []) - self.assertEqual(state.focus[state.screen], index) - self.assertEqual(state.stack, base.stack) + install.tui_update(keyed, Key("char", " "))) + self.assertEqual((state.stack, state.ticked), + (keyed.stack, keyed.ticked)) + continue + # A choice is focused by the first tap, even the + # row the screen opened on. + self.assertEqual(effects, []) + self.assertEqual(state.focus[state.screen], index) + self.assertEqual(state.stack, base.stack) + drawn(state, cols, rows) + again = next(at for at, _first, _end, mark in state.hits.targets + if mark == target) + keyed.focus[keyed.screen] = index + self.assertEqual(tap(state, cols - 1, again), + install.tui_update(keyed, Key("enter"))) + self.assertEqual(state.stack, keyed.stack) elif target[0] == "key": label = str(lines[line])[first:end].split(" ", 1)[0] self.assertIn(label, install._BAR_CLICKS) @@ -10163,7 +10397,7 @@ class MouseOnAPty(unittest.TestCase): # The bar's ? Help, pressed by a click. bar = term.screen.display[-1] self.click(term, bar.index("? Help") + 1, len(term.screen.display) - 1) - term.wait("Any key closes this help") + term.wait("Any key or a tap closes this help") self.click(term, 10, 5) term.wait("Which setup?") # Legacy reports move the focus the same way. @@ -10172,25 +10406,10 @@ class MouseOnAPty(unittest.TestCase): term.settle() self.assertTrue(term.screen.buffer[row][3].reverse, term.text()) term.send("q") - self.assertEqual(term.close(), 130) + self.assertEqual(term.close(), install.QUIT_CODE) raw = term.raw self.assertGreater(raw.rfind(b"\x1b[?1000l"), raw.rfind(b"\x1b[?1000h")) - def test_the_result_gives_the_mouse_back(self): - self.repo() - term = self.start() - term.wait("Where do you play?") - term.send("\r") - term.wait("files to download") - self.click(term, 20, self.line_of(term, "> Install")) - term.wait("files installed.") - term.settle() - mark = term.raw.rfind(b"files installed") - self.assertGreater(term.raw.rfind(b"\x1b[?1000l"), term.raw.rfind(b"\x1b[?1000h")) - self.assertNotIn(install._MOUSE_ON.encode(), term.raw[mark:]) - term.send("\r") - self.assertEqual(term.close(), 0) - def test_no_clicks_asked_when_turned_off(self): for name, value in ((install.TUI_MOUSE_ENV, "1"), (install.TUI_PAD_ENV, "gptokeyb")): with self.subTest(env=name): @@ -10235,5 +10454,616 @@ class MouseOnAPty(unittest.TestCase): raw.rfind(b"\x1b[?" + mode + b"h")) +def _painted_roles(text: str, style: str = "", colour: bool = True) -> list[tuple[str, str]]: + """(characters, SGR parameters) of each run tui_paint draws for text, + the parameters "" where the terminal's own colours are used.""" + out = install.tui_paint(text, style, colour) + runs: list[tuple[str, str]] = [] + current = "" + for part in re.split(r"(\x1b\[[0-9;]*m)", out): + if part.startswith("\x1b["): + codes = part[2:-1] + current = "" if codes in ("0", "") else codes[2:] if codes.startswith("0;") else codes + elif part: + runs.append((part, current)) + return runs + + +def _role_of(text: str, word: str, style: str = "", colour: bool = True) -> str: + """The SGR parameters word is drawn in, inside text.""" + at = text.index(word) + seen = 0 + for chars, codes in _painted_roles(text, style, colour): + if seen <= at < seen + len(chars): + return codes + seen += len(chars) + raise AssertionError(f"{word!r} not drawn in {text!r}") + + +def _game_state() -> install.TuiState: + """The plan of checked_state, its PlayStation extra marked game data and + read from the save directory, the check's events fed in.""" + state = checked_state() + manifest = json.loads(json.dumps(state.manifest)) + for entry in manifest["files"]: + if entry["dest"] == "pcsx/extra.bin": + entry.update(category="game_data", root="save") + manifest["save_directory"] = {"folder": "saves", "by_core": True} + state.manifest = manifest + state.saves = {"event": "saves", "files": 1, "folder": "/home/user/.config/retroarch/saves"} + return state + + +class AuditNineteenAndTwelve(_CardFixtures, unittest.TestCase): + """code_audit_19 and tui_audit_12: what the screens answer, line mode's + parity being in LineModeAndScreenDecideAlike.""" + + def setUp(self): + (REPO_ROOT / "tmp").mkdir(exist_ok=True) + self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, self.root, True) + install.LOCAL.reset() + self.addCleanup(install.LOCAL.reset) + for patcher in (mock.patch.dict(install._DETECTED_OS, {}), + mock.patch.object(install, "_mount_points", return_value=frozenset())): + patcher.start() + self.addCleanup(patcher.stop) + + # Root cause 1: a tap follows the row's kind. + + def systems_state(self) -> install.TuiState: + state = checked_state() + state.mouse = True + rows = install.tui_rows(state) + state.focus["plan"] = next(i for i, row in enumerate(rows) if row.action == "systems") + install.tui_update(state, Key("enter")) + self.assertEqual(state.screen, "systems") + return state + + @staticmethod + def line_of(state: install.TuiState, action: str, value: object = None) -> int: + return next(at for at, _first, _end, mark in state.hits.targets + if mark[0] == "row" and mark[2][0] == action + and (value is None or mark[2][1] == value)) + + def test_a_tap_on_a_system_ticks_it_and_never_continues(self): + """tui_audit_12 P1-1: two taps on a system opened Confirm, and the + next tap installed every file, the system meant to be left out with + them.""" + for cols, rows in ((40, 12), (80, 24)): + with self.subTest(size=f"{cols}x{rows}"): + state = self.systems_state() + before = set(state.ticked) + drawn(state, cols, rows) + line = self.line_of(state, "system", "sony-playstation") + effects = tap(state, 10, line) + self.assertEqual(effects, []) + self.assertNotIn("sony-playstation", state.ticked) + drawn(state, cols, rows) + effects = tap(state, 10, self.line_of(state, "system", "sony-playstation")) + self.assertEqual((effects, state.screen), ([], "systems")) + self.assertEqual(state.ticked, before) + drawn(state, cols, rows) + effects = tap(state, 10, self.line_of(state, "system", "nintendo-gba")) + self.assertEqual((effects, state.screen), ([], "systems")) + self.assertNotIn("nintendo-gba", state.ticked) + + def test_the_row_a_screen_opens_on_takes_two_taps(self): + """Confirm opens with Install in focus: one tap there installed.""" + state = checked_state() + state.mouse = True + state.stack.append("confirm") + state.dry = dict(state.check) + drawn(state, 80, 24) + line = self.line_of(state, "install") + self.assertEqual(tap(state, 5, line), []) + self.assertEqual(state.screen, "confirm") + drawn(state, 80, 24) + self.assertEqual(tap(state, 5, line), [("run", "install")]) + # A key between the taps: the next tap focuses again. + state = checked_state() + state.stack.append("confirm") + state.dry = dict(state.check) + drawn(state, 80, 24) + tap(state, 5, line) + install.tui_update(state, Key("down")) + install.tui_update(state, Key("up")) + drawn(state, 80, 24) + self.assertEqual(tap(state, 5, line), []) + + def test_the_help_and_the_bar_say_how_to_tap(self): + state = self.systems_state() + bar = str(install.tui_view(state, 80, 24)[-1]) + self.assertIn("X/Space/tap Tick", bar) + state.help = True + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertIn("Tap Tick or untick a row, as Space", text) + self.assertIn("Any key or a tap closes this help", text) + plan = checked_state() + plan.mouse, plan.help = True, True + text = " ".join(" ".join(install.tui_view(plan, 80, 24)).split()) + self.assertIn("Tap Choose a row, tap it again to open it", text) + # The tick entry of the bar is pressed by a tap too. + state.help = False + lines = drawn(state, 80, 24) + bar_line = str(lines[-1]) + state.focus["systems"] = next(i for i, row in enumerate(install.tui_rows(state)) + if row.action == "system") + drawn(state, 80, 24) + ticked = set(state.ticked) + tap(state, bar_line.index("X/Space/tap"), len(lines) - 1) + self.assertNotEqual(state.ticked, ticked) + # No mouse, no word of a tap. + plain = checked_state() + plain.help = True + self.assertNotIn("Tap", " ".join(install.tui_view(plain, 80, 24))) + + def test_the_result_is_left_by_a_tap(self): + """tui_audit_12 P3-9: the result gave the mouse back, and on Termux + leaving it took the keyboard. A tap on its Exit closes it, and its + commands are printed again in the terminal.""" + state = _installed_state(SYSTEM_DIR) + lines = drawn(state, 80, 24) + self.assertTrue(install.tui_wants_mouse(state, lines)) + bar = str(lines[-1]) + effects = tap(state, bar.index("Exit"), len(lines) - 1) + self.assertEqual((effects, state.exit_code), ([], 0)) + farewell = "\n".join(install.tui_farewell(state)) + self.assertIn("Same choice without questions:", farewell) + self.assertIn("python3 install.py --platform retroarch", farewell) + + # Root cause 2: one narrowed list drives every count. + + def test_the_game_data_row_and_the_save_line_follow_the_choice(self): + """tui_audit_12 P2-1: with only the GBA ticked, the plan said 1 game + data file and a save file of the PlayStation.""" + state = _game_state() + text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + self.assertIn("Game data: kept", text) + self.assertIn("1 file read from RetroArch's save directory", text) + state.ticked = {"nintendo-gba"} + text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + self.assertNotIn("Game data:", text) + self.assertNotIn("save directory", text) + self.assertIn("About 1 file, 16.0 KB, for 1 of 3 systems", text) + # Toggled, the row counts the same game data, left out. + state = _game_state() + row = next(row for row in install.tui_rows(state) if row.action == "gamedata") + self.assertEqual((row.label, row.right), ("Game data: kept", "1 file, 4 B")) + state.no_game_data = True + row = next(row for row in install.tui_rows(state) if row.action == "gamedata") + self.assertEqual((row.label, row.right), ("Game data: left out", "1 file, 4 B")) + text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + self.assertIn("1 game data file left out.", text) + self.assertNotIn("No game data", text) + + def test_the_game_data_row_keeps_its_state_at_40_columns(self): + """tui_audit_12 P2-2: "kept," then "left o" at 40x12.""" + state = _game_state() + state.manifest["files"] += [ + {"dest": f"game/{index}.dat", "size": 50 * 1024 ** 2, "category": "game_data", + "systems": ["sony-playstation"], "cores": None} for index in range(50)] + for left in (False, True): + state.no_game_data = left + 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" + self.assertRegex(line, rf"^ Game data: {word} +51(, | files, )2\.4 GB$") + + def test_data_files_are_named_apart(self): + """tui_audit_12 P2-3: data files beside BIOS files and game data, the + parts adding up to the count above, each kind said once.""" + state = _game_state() + state.check = {**state.check, "data_missing": 7, "data_bytes": 2048} + text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + self.assertIn("3 BIOS files (1.0 MB), 1 game data file (4 B) and 7 data files " + "(2.0 KB).", text) + self.assertIn("Game data: files a game or a game engine reads, of no use to the " + "other cores. Data files: the files an emulator reads from a data " + "folder of its own, such as Dolphin's Sys and PPSSPP's assets.", text) + + def test_confirm_counts_the_kinds_apart(self): + """tui_audit_12, extra criteria: the plan split BIOS files and game + data, Confirm did not.""" + state = _game_state() + state.stack.append("confirm") + state.dry = dict(state.check) + state.dry_files = list(state.files) + text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + self.assertIn("3 BIOS files (1.0 MB) and 1 game data file (4 B).", text) + + def test_no_game_data_given_has_no_row(self): + """tui_audit_12 P3-5: --no-game-data given, the screen offered the + game data back, line mode did not.""" + state = _game_state() + state.no_game_data = state.cli_no_game_data = True + self.assertFalse(any(row.action == "gamedata" for row in install.tui_rows(state))) + options = install._option_rows(state) + self.assertIn("Game data: left out", [row.label for row in options]) + + def test_the_40_column_plan_keeps_its_folder(self): + """tui_audit_12 P3-10: at 40x12 and 40x15 the game data definition + pushed the BIOS folder off the plan.""" + state = _game_state() + for rows in (12, 15): + with self.subTest(rows=rows): + text = "\n".join(install.tui_view(state, 40, rows)) + self.assertIn("BIOS folder:", text) + + # Root cause 3: the version note. + + def test_the_version_note_is_on_the_plan_the_result_and_the_farewell(self): + """code_audit_19 P2-1, tui_audit_12 P2-4: line mode said it, the + screen dropped the version event.""" + said = ("This list was built for RetroBat 8.3.1, this machine runs 8.2.0.1. " + "RetroBat's own BIOS check may report some files as wrong though they work.") + state = fresh_state() + state.stack = ["setup"] + with mock.patch.object(install.Path, "exists", return_value=True): + install.tui_update(state, Key("enter")) + install.tui_receive(state, ("manifest", "retroarch", MANIFEST)) + for event in [{"event": "version", "platform": "retroarch", "list_version": "8.3.1", + "installed_version": "8.2.0.1", "message": said}, + *FILES, *SYSTEMS, CHECK]: + install.tui_receive(state, ("event", state.run_id, event)) + with mock.patch.object(install, "_tui_folder_facts", return_value={ + "free": 50 * 1024 ** 3, "writable": True, "cores": []}): + install.tui_receive(state, ("ended", state.run_id, 1, [])) + text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + self.assertIn(said, text) + result = _installed_state(SYSTEM_DIR) + result.version = state.version + self.assertIn(said, install.tui_outcome(result)[1]) + self.assertIn(said, install.tui_farewell(result)) + plain = checked_state() + self.assertNotIn("This list was built", " ".join(install.tui_view(plain, 120, 40))) + + # Root cause 4: the commands the screen prints. + + def test_a_command_shown_keeps_offline_from_the_variable(self): + """code_audit_19 P2-2: sudo resets the environment, and the root + command the screen gave fetched what the offline run left out.""" + state = checked_state({"free": 50 * 1024 ** 3, "writable": False, "cores": []}) + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}), \ + mock.patch.object(install, "_on_read_only_mount", return_value=False): + text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + self.assertRegex(text, r"sudo python3 install\.py --platform retroarch .*--offline") + self.assertTrue(install.tui_command(state).endswith("--offline")) + argv = install.tui_argv(state, "check", "python3", "install.py") + self.assertLessEqual(argv.count("--offline"), 1) + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "--offline"}): + self.assertEqual(install.tui_command(state).count("--offline"), 1) + + # Root cause 5: a chosen quit. + + def test_a_chosen_quit_exits_2_and_ctrl_c_130(self): + """code_audit_19 P3-1: q and Quit ended on 130, Ctrl-C's code.""" + for keys, code in ((["q"], 2), (["esc", "enter"], 2), (["ctrl-c"], 130), + (["ctrl-d"], 130)): + with self.subTest(keys=keys): + state = fresh_state() + state.stack = ["setup"] + for key in keys: + install.tui_update(state, Key("char", key) if len(key) == 1 else Key(key)) + self.assertEqual(state.exit_code, code) + state = checked_state() + rows = install.tui_rows(state) + state.focus["plan"] = next(i for i, row in enumerate(rows) if row.action == "quit") + install.tui_update(state, Key("enter")) + self.assertEqual(state.exit_code, 2) + self.assertTrue(install.tui_quit_chosen(state)) + self.assertEqual(install.tui_farewell(state), ["RetroBIOS: quit. Nothing was installed."]) + idle = fresh_state() + self.assertEqual(install.tui_idle_code(idle), 2) + + # Root cause 6: paths and commands kept whole. + + def test_a_wrapped_command_still_keeps_the_mouse_off(self): + """code_audit_19 P3-5: the one-liner's URL wrapped in its middle, the + launcher was not found in the joined text and the mouse stayed on.""" + state = checked_state({"free": 50 * 1024 ** 3, "writable": False, "cores": []}) + for bootstrap in ("sh", ""): + for cols in (40, 60, 80): + with self.subTest(bootstrap=bootstrap, cols=cols), \ + mock.patch.dict(os.environ, {"RETROBIOS_BOOTSTRAP": bootstrap}), \ + mock.patch.object(install, "_on_read_only_mount", return_value=False): + lines = drawn(state, cols, 30) + self.assertFalse(install.tui_wants_mouse(state, lines)) + if bootstrap: + url = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh" + words = [word for line in lines for word in str(line).split()] + self.assertIn(url, words) + + def test_a_path_one_cell_too_wide_for_its_indent_loses_it(self): + """tui_audit_12 P3-7: "syste" / "m", folded by the terminal.""" + path = "/home/moi/abcdefghijklmnopqrstuvw/.config/retroarch/system" + width = len(path) + 1 + lines = install._wrap(f"BIOS folder: {path}", width, whole="text") + self.assertEqual(lines, ["BIOS folder:", path]) + command = install._wrap(f" python3 install.py --dest {path}", width + 1, + whole="command") + self.assertIn(f" {path}", command) + self.assertTrue(all(len(line) <= width + 1 for line in command), command) + + def test_the_result_spells_each_folder_one_way(self): + """tui_audit_12 P3-7: the save folder as ~/ beside full paths.""" + state = _installed_state(SYSTEM_DIR) + state.done_saves = {"event": "saves", "files": 2, + "folder": "/home/user/.config/retroarch/saves/"} + _title, lines = install.tui_outcome(state) + self.assertIn("/home/user/.config/retroarch/saves", lines) + self.assertFalse(any(line.startswith("~") for line in lines)) + + def test_minui_names_the_folder_of_its_plan(self): + """tui_audit_12 P3-6: plan Bios, result the card's root.""" + card = self.root / "MINUI" + state = _installed_state(card) + state.platform = "minui" + state.manifest = {"manifest_version": 2, "platform": "minui", + "files": [{"dest": "Bios/MGBA/gba_bios.bin", "size": 3}, + {"dest": "Bios/PS/scph5501.bin", "size": 3}]} + _title, lines = install.tui_outcome(state) + self.assertIn(f"BIOS folder: {card / 'Bios'}", lines) + state.stack = ["checking"] + self.assertIn(str(card / "Bios"), " ".join(install.tui_view(state, 120, 40))) + + # Root cause 7: colour roles read the words. + + def test_each_painted_span_of_the_plan_has_its_role(self): + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + title = "3 files need the internet, which RETROBIOS_INSTALL_OFFLINE=1 does not use" + self.assertEqual(_role_of(title, "=1", "title")[-2:], "1") + self.assertNotIn("35", _role_of(title, "1 does", "title")) + self.assertIn("35", _role_of(title, "3 files", "title")) + first = checked_state() + first.check = {**first.check, "present": 0, "wrong_hash": 0, "missing": 4} + line = next(line for line in install._plan_lines(first.check, first.dest, first) + if str(line).startswith("0 in place")) + self.assertEqual(_role_of(str(line), "4 missing", line.style), "") + later = checked_state() + line = next(line for line in install._plan_lines(later.check, later.dest, later) + if str(line).startswith("1 in place")) + self.assertEqual(_role_of(str(line), "3 missing", getattr(line, "style", "")), "33") + option = "BIOS folder: ~/.config/retroarch/system --dest" + self.assertEqual(_role_of(option, "~/.config"), "1") + self.assertEqual(_role_of(option, "--dest"), "") + + def test_each_painted_span_of_the_result_has_its_role(self): + self.assertEqual(_role_of("6 files copied from OLD, 16 still missing.", + "6 files copied", "title"), "1;32") + self.assertEqual(_role_of("6 files copied from OLD, 16 still missing.", + "16 still missing", "title"), "1;33") + self.assertEqual(_role_of("6 game data files left out.", "left out"), "") + self.assertEqual(_role_of("23 files installed.", "23 files installed", "title"), + "1;32") + + def test_nothing_a_beginner_looks_for_is_faint(self): + """tui_audit_12 P3-3: the filter row and Back, Quit on the bar.""" + state = install.TuiState(os_type="linux", home=HOME) + state.stack = ["platforms"] + self.assertEqual(install._filter_row(state).style, "") + bar = " A/Enter Choose B/Esc Quit / Filter ? Help" + for colour in (True, False): + with self.subTest(colour=colour): + self.assertEqual(_role_of(bar, "Quit", "hint", colour), "") + self.assertEqual(_role_of(bar, "Choose", "hint", colour), "") + self.assertEqual(_role_of(bar, "B/Esc", "hint", colour), "2") + + # Every other item. + + def test_a_legacy_report_past_column_161_is_read_from_the_bytes(self): + """code_audit_19 P3-2: column 163, row 137 typed [M#é, and a press + followed by q lost the q.""" + press = b"\x1b[M" + bytes([32, 32 + 163, 32 + 137]) + release = b"\x1b[M" + bytes([35, 32 + 163, 32 + 137]) + for data, want in ((press + release, [install.TuiMouse(0, 163, 137), + install.TuiMouse(3, 163, 137, True)]), + (press + b"q", [install.TuiMouse(0, 163, 137), Key("char", "q")])): + for cut in range(1, len(data) + 1): + with self.subTest(data=data, cut=cut): + reader, decoder = install.TerminalInput(), install.KeyDecoder() + keys = decoder.feed(reader.decode(data[:cut])) + keys += decoder.feed(reader.decode(data[cut:])) + decoder.flush() + self.assertEqual(keys, want) + + def test_the_setup_list_aligns_its_devices(self): + """tui_audit_12 P3-1: three right edges at 120 columns, devices + starting anywhere from column 20 to 70 at 80.""" + state = install.TuiState(os_type="linux", home=HOME) + state.stack = ["platforms"] + with mock.patch.object(install, "_mounted_cards", return_value=[]): + for cols, rows in ((80, 60), (120, 60)): + with self.subTest(cols=cols): + lines = [str(line) for line in install.tui_view(state, cols, rows)] + starts = {line.index(" ", line.index(", SD card") + 9) + 1 + + len(line[line.index(", SD card") + 9:]) + - len(line[line.index(", SD card") + 9:].lstrip()) + for line in lines if ", SD card " in line} + self.assertEqual(len(starts), 1, lines) + self.assertTrue(all(len(line.rstrip()) <= 80 for line in lines)) + + def test_the_copy_browser_never_offers_the_card_installed_to(self): + """tui_audit_12 P3-16.""" + onion = self.root / "media" / "ONION" + make_card(onion, "Onion") + old = self.root / "media" / "OLD" + old.mkdir(parents=True) + state = checked_state() + state.dest = onion / "BIOS" + with mock.patch.object(install, "_mounted_cards", return_value=[old, onion]): + install._open_from_browser(state) + values = [row.value for row in install.tui_rows(state) if row.action == "dir"] + self.assertIn(old, values) + self.assertNotIn(onion, values) + + def test_a_card_two_systems_fit_is_offered_as_either(self): + """tui_audit_12 P2-5: the first screen said nothing was found, and + with a KNULLI list "No KNULLI card is mounted" beside it.""" + card = self.renamed()["renamed, no about.info"] + with mock.patch.object(install, "_mounted_cards", return_value=[card]), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]): + state, _effects = install.tui_start(_args(), "linux") + self.assertEqual(state.screen, "setup") + rows = install.tui_rows(state) + self.assertEqual([row.value for row in rows if row.action == "doubt"], + [("knulli", card), ("batocera", card)]) + text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + self.assertNotIn("Nothing was found here", text) + self.assertIn("could be a KNULLI card or a Batocera SHARE drive", text) + install.tui_update(state, Key("enter")) + self.assertEqual((state.platform, state.dest), ("knulli", card / "bios")) + folder = install.TuiState(os_type="linux", home=HOME) + folder.platform, folder.card_bound = "knulli", install.card_bound("knulli", + "knulli") + install._open_folder_screen(folder, None) + rows = install.tui_rows(folder) + text = " ".join(" ".join(install.tui_view(folder, 120, 40)).split()) + self.assertEqual(rows[folder.focus["folder"]].value, card) + self.assertNotIn("No KNULLI card is mounted", text) + self.assertNotIn("Look again", text) + + def test_the_wsl_folder_question_offers_the_windows_side(self): + """tui_audit_12 P3-11: under WSL only the Linux home was offered.""" + sides = install.other_side("wsl", "retroarch") + self.assertEqual(len(sides), 1) + self.assertTrue(sides[0][0].startswith("Windows RetroArch: ")) + self.assertEqual(install.other_side("linux", "retroarch"), []) + state = install.TuiState(os_type="wsl", home=HOME) + state.platform, state.stack = "retroarch", ["folder"] + state.folder_default, state.folder_kind = SYSTEM_DIR, "usual" + with mock.patch.object(install, "_mounted_cards", return_value=[]): + rows = install.tui_rows(state) + self.assertIn(sides[0][1], [row.value for row in rows if row.action == "drive"]) + + def test_named_by_sits_under_its_folder(self): + """tui_audit_12 P3-15: the offline remedy and the narrowing came + between the folder and what named it.""" + cfg = self.root / "home" / ".config" / "retroarch" + cfg.mkdir(parents=True) + (cfg / "retroarch.cfg").write_text(f'system_directory = "{self.root / "sys"}"\n') + state = checked_state() + state.dest = self.root / "sys" + state.cli_systems = ["sony-playstation"] + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}), \ + mock.patch.object(install, "_retroarch_config_at", + return_value=(str(cfg / "retroarch.cfg"), cfg)): + lines = [str(line).strip() for line in install.tui_view(state, 120, 40)] + folder = next(i for i, line in enumerate(lines) if line.startswith("BIOS folder:")) + named = next(i for i, line in enumerate(lines) if line.startswith("Named by")) + # Nothing between the folder, wrapped or not, and what named it. + self.assertTrue(all(line.startswith("/") for line in lines[folder + 1:named]), lines) + self.assertLess(named, next(i for i, line in enumerate(lines) + if line.startswith(("For ", "Copy them")))) + + def test_options_name_a_save_folder_the_setup_knows(self): + """tui_audit_12 P3-15: Save folder: not known, beside a RetroArch + whose save folder the same machine names.""" + state = checked_state() + state.manifest = {**state.manifest, "save_directory": {"folder": "saves", + "by_core": True}} + state.saves = None + place = install.SavePlace(Path("/home/user/.config/retroarch/saves"), True, True) + with mock.patch.object(install, "save_directory_for", return_value=place): + label = next(row.label for row in install._option_rows(state) + if row.action == "savedir") + self.assertEqual(label, "Save folder: ~/.config/retroarch/saves") + + def test_the_result_counts_what_it_replaced_and_its_kinds(self): + """tui_audit_12 P3-8: the plan said 2 wrong copies are replaced, the + result counted none, and the old card's result split no kind.""" + state = _installed_state(SYSTEM_DIR) + state.summary = {**state.summary, "kept": [{"folder": "/x", "files": 2, "bytes": 3}]} + _title, lines = install.tui_outcome(state) + self.assertEqual(lines[0], "5 files installed, 2 of them replaced.") + old = _installed_state(SYSTEM_DIR) + old.finished = [("scph5500.bin", True, ""), ("gba_bios.bin", True, "")] + old.local_dests = {"scph5500.bin": ("/media/OLD", ""), "gba_bios.bin": ("/media/OLD", "")} + old.summary = {**old.summary, "installed": 2, "pending": 3} + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + _title, lines = install.tui_outcome(old) + self.assertIn("Installed: 2 BIOS files.", lines) + + def test_the_wiki_says_where_clicks_are_off(self): + """code_audit_19 P3-6: the page promised the mouse on Windows.""" + page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8") + text = " ".join(page.split()) + self.assertIn("Clicks are off in the Windows console the PowerShell command runs in", + text) + for term in ("`dumb`", "`unknown`", "`linux`", "`vt`", "`cons`", "`ansi`"): + self.assertIn(term, text) + self.assertIn("| `RETROBIOS_NO_MOUSE` | Set to any value but `0` or an empty one", text) + self.assertEqual(install.tui_mouse_blocker( + MouseEnabled.Tty(), MouseEnabled.Tty(), + {"TERM": "xterm", install.TUI_MOUSE_ENV: ""}), "") + first = (REPO_ROOT / "wiki" / "first-install.md").read_text(encoding="utf-8") + self.assertIn("On a touch screen, tap a row twice to open it, once to tick a box.", + " ".join(first.split())) + + def test_the_exit_codes_name_a_chosen_quit(self): + page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8") + two = next(line for line in page.splitlines() if line.startswith("| `2` |")) + self.assertIn("a quit chosen before the install", two) + self.assertIn("`RETROBIOS_IDLE_EXIT`", two) + last = next(line for line in page.splitlines() if line.startswith("| `130` |")) + self.assertNotIn("A quit chosen", last) + + +class TapsOnAPty(unittest.TestCase): + """tui_audit_12 P1-1 and P3-9 on the real install.py --tui.""" + + repo = EndToEnd.repo + start = EndToEnd.start + line_of = staticmethod(MouseOnAPty.line_of) + click = MouseOnAPty.click + + def test_taps_on_systems_tick_and_write_nothing(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") + bar = term.settle().splitlines()[-1] + self.assertIn("X/Space/tap Tick", bar) + row = self.line_of(term, "sony-playstation") + self.click(term, 20, row) + self.assertIn("[ ] sony-playstation", term.settle()) + self.click(term, 20, row) + self.assertIn("[x] sony-playstation", term.settle()) + self.click(term, 20, self.line_of(term, "nintendo-gba")) + screen = term.settle() + self.assertIn("[ ] nintendo-gba", screen) + self.assertIn("RetroBIOS: Choose systems", screen) + self.assertEqual(repo.installed(), []) + term.send("q") + self.assertEqual(term.close(), install.QUIT_CODE) + self.assertEqual(repo.installed(), []) + + def test_the_result_is_left_by_a_tap_on_exit(self): + self.repo() + term = self.start() + term.wait("Where do you play?") + term.send("\r") + term.wait("files to download") + row = self.line_of(term, "> Install") + self.click(term, 20, row) + term.settle() + self.click(term, 20, row) + term.wait("files installed.") + term.settle() + mark = term.raw.rfind(b"files installed") + self.assertGreater(term.raw.rfind(b"\x1b[?1000h"), term.raw.rfind(b"\x1b[?1000l")) + bar = term.screen.display[-1] + self.click(term, bar.index("Exit"), len(term.screen.display) - 1) + self.assertEqual(term.close(), 0) + self.assertIn("Same choice without questions:", term.after()) + self.assertGreater(term.raw.rfind(b"\x1b[?1000l"), mark) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_no_game_data.py b/tests/test_no_game_data.py index 79f2d6b73..1c6fe6ddb 100644 --- a/tests/test_no_game_data.py +++ b/tests/test_no_game_data.py @@ -289,7 +289,7 @@ class InstallerLeavesGameDataOut(unittest.TestCase): self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) self.assertTrue((self.bios / "a.bin").exists()) self.assertFalse((self.bios / "game.wad").exists()) - self.assertIn("Left out as game data (--no-game-data): 2 files, 1 of them not installable: " + self.assertIn("2 game data files left out (--no-game-data), 1 of them not installable: " "game.wad, level.dat", proc.stdout) @@ -300,7 +300,7 @@ class InstallerLeavesGameDataOut(unittest.TestCase): with self.subTest(mode=mode): proc = self.run_installer("--no-game-data", mode) self.assertIn( - "Left out as game data (--no-game-data): 2 files, 1 of them not installable: " + "2 game data files left out (--no-game-data), 1 of them not installable: " "game.wad, level.dat", proc.stdout, ) @@ -313,7 +313,7 @@ class InstallerLeavesGameDataOut(unittest.TestCase): proc = self.run_installer() self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) self.assertTrue((self.bios / "game.wad").exists()) - self.assertNotIn("Left out as game data", proc.stdout) + self.assertNotIn("game data files left out", proc.stdout) def test_events_name_what_was_left_out(self): self.serve() diff --git a/wiki/first-install.md b/wiki/first-install.md index 29fac6118..a342c1021 100644 --- a/wiki/first-install.md +++ b/wiki/first-install.md @@ -26,7 +26,7 @@ irm https://raw.githubusercontent.com/Abdess/retrobios/main/install.ps1 | iex -Open **Terminal**. It is in Applications, then Utilities. +Open **Terminal**, in Applications, then Utilities. ```sh title="Terminal" curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh @@ -53,7 +53,7 @@ curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | -Install **Termux**, a terminal app. Run these three lines in it. +Install **Termux**, a terminal app, and run these lines. ```sh title="Terminal" pkg install python @@ -84,11 +84,11 @@ It opens a menu: **Another setup or folder**. Press / and type the device (Miyoo Mini), or browse. -Arrows move, Enter chooses, Esc goes back, as A and B on a handheld. +Arrows move, Enter chooses, Esc goes back (A and B on a handheld). On a +touch screen, tap a row twice to open it, once to tick a box. ## 3. Install -It counts missing files and free space. Press Enter on **Install**. **Choose systems** keeps the consoles you play. @@ -138,13 +138,12 @@ says how to restore them. ### Nothing was found here. -Choose your setup in the list, then its folder. Where the screen offers +Choose your setup, then its folder. Where the screen offers **Another setup or folder**, choose it first. ### Error: Python 3.8 or newer is required. -The message shows the command that installs Python. Run it, then run the -installer again. +Run the command the message shows, then the installer again. RetroPie 4.8 has Python 3.7.3, and its update finds nothing newer. Run it on a computer, with RetroPie's `bios` share open, `\\RETROPIE\bios` from Windows: diff --git a/wiki/installer.md b/wiki/installer.md index 19a72b5ac..d229bde5f 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -238,21 +238,29 @@ started it, the one thing that can resume it: started from a terminal app's `-e`, a desktop entry or a launcher's `sh -c`, the key is ignored and the bar says why. -A mouse or a touch screen works too, beside the keys and buttons. A click or -a tap on a row moves the cursor there, and a second one on the row under the -cursor does what Enter does: one row of a terminal is far smaller than a -finger. The entries of the bottom line press their key, 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 installer asks the terminal for -clicks only on a terminal that reports them (`TERM` not `linux`, `dumb` or -unset), never on a handheld route (`RETROBIOS_PAD`) or with -`RETROBIOS_NO_MOUSE=1`, and gives them back on every way out, a stop with -Ctrl+Z included. On the result, 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. Elsewhere, hold Shift while -selecting. `--plain` never asks for clicks. Clicks and taps were checked on +A mouse or a touch screen works too, beside the keys and buttons, in a +terminal that reports clicks: those of Linux, macOS, WSL and Termux. A click +or a tap on a box (a system, a core, a region) ticks or unticks it, as Space +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. + +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`, +`linux` or a name starting with `vt`, `cons` or `ansi`, on a handheld route +(`RETROBIOS_PAD`), and with `RETROBIOS_NO_MOUSE` set. The installer gives +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 on Android 17. macOS Terminal and iTerm2 are not checked yet, nor the key they use to select over the installer. @@ -654,32 +662,42 @@ the plan screen of the [full-screen interface](#the-full-screen-interface) offers **Install**, **Choose systems** and **Options**. With `--plain`, or where the full screen cannot open (a terminal smaller than 40x12, `TERM=dumb`), the same choice comes as questions after detection. A RetroArch run with `--plain` -printed this on 2026-10-27: +printed this on 2026-10-28, `g` then pressed: ``` RetroArch: 8358 files, 6.6 GB, and 2636 data files (38.5 MB). - 3269 BIOS files (4.1 GB) and 5089 game data files (2.5 GB). + 3269 BIOS files (4.1 GB), 5089 game data files (2.5 GB) and 2636 data files (38.5 MB). + Game data: files a game or a game engine reads, of no use to the other cores. Data files: the files an emulator reads from a data folder of its own, such as Dolphin's Sys and PPSSPP's assets. BIOS folder: /home/me/.config/retroarch/system 106 files read from RetroArch's save directory go to a folder per core under /home/me/.config/retroarch/saves Enter) install everything - i) only what the installed cores read: 87 files, 43.8 MB c) choose systems, cores and regions f) copy from a folder or an old card - g) leave out the 5089 game data files (2.5 GB), game data: files a game or a game engine reads, of no use to the other cores + g) Game data: kept (5089 files, 2.5 GB): leave it out q) quit +> + Counted again for your choices. Nothing is written before Enter. + +RetroArch: 3269 files, 4.1 GB, and 2636 data files (38.5 MB). + 3269 BIOS files (4.1 GB) and 2636 data files (38.5 MB). 5089 game data files left out. ``` -A list that marks its game data counts it apart from the BIOS files, on the -plan, in this menu and on the result (`Installed: 3269 BIOS files, 5089 game -data files and 2636 data files.`), the parts adding up to the total. `g` leaves -the game data of the choice out, as `--no-game-data` does, and counts again -(`g` then keeps it). The plan screen has the same choice as a row of its own, -**Game data (5089 files, 2.5 GB): kept, leave out**, one press toggling it, -and Options keeps its **Game data** row too. +A list that marks its game data counts three kinds of file apart, on the plan, +in this menu and on the result (`Installed: 3269 BIOS files, 5089 game data +files and 2636 data files.`), the parts adding up to the total: BIOS files, +game data, and the data files of an emulator's own data folder (Dolphin's +`Sys`, PPSSPP's assets). What the last two are is said once, under the counts. +`g` leaves the game data of the choice out, as `--no-game-data` does, and +counts again (`g` then keeps it). The plan screen has the same choice as a row +of its own, **Game data: kept** with its count, one press toggling it to +**Game data: left out**, and Options keeps its **Game data** row too. Its +count follows the systems chosen, as `g` counts them, and a choice that holds +no game data has no row. Given `--no-game-data`, neither the menu nor the plan +offers the game data back. -`i` appears for RetroArch, Lakka and RetroPie when cores are found installed, -and the line above is a RetroArch with Beetle PSX HW, Snes9x, mGBA, Genesis Plus GX -and FCEUmm. +`i) only what the installed cores read: 87 files, 43.8 MB` appears for +RetroArch, Lakka and RetroPie when cores are found installed, here a RetroArch +with Beetle PSX HW, Snes9x, mGBA, Genesis Plus GX and FCEUmm. It keeps what those cores read and drops the systems none of them emulates, the rule `--installed-cores` applies. @@ -740,7 +758,7 @@ lists, a game engine's own data. The manifest marks each of these files and names what it left out with its category (ten names, `--verbose` for all): ```text - Left out as game data (--no-game-data): 2436 files: ports/doom/doom1.wad, ports/doom2/doom2.wad, ... + 2436 game data files left out (--no-game-data): ports/doom/doom1.wad, ports/doom2/doom2.wad, ... ``` It is the cut `generate_pack.py --no-game-data` makes, and a test holds the @@ -830,7 +848,7 @@ what the options name, everything by default. | `--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` | | `--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, two setups found) stops on what to name instead | +| `--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 | | `--quiet`, `-q` | Print only failures and the final summary. Implies `--no-input` | | `--events` | Write the run as JSON events on standard output, one object per line, and the usual text on standard error. Implies `--no-input`. See [Machine-readable events](#machine-readable-events) | | `--from FOLDER` | Copy the BIOS files a folder holds (an old SD card, a backup, a ZIP) instead of downloading them, each one recognised by its hash and copied, never moved. Repeat for several folders. See [Copy from an old card](#copy-from-an-old-card) | @@ -971,7 +989,10 @@ with its root password, and a card whose password was changed is named by nothing, never by another system. A drive whose files fit two systems is named by neither: the first screen and `--plain` say which systems it could be and the two commands that name it, and a `--dest` named with one of them takes it -as that system's, with a note. +as that system's, with a note. The full screen lists it under each system it +could be ("KNULLI2: if it is a KNULLI card"), and with a file list for one of +them it is that system's card, offered first, never "no card is mounted". +`--plain` numbers it the same way ("Type 1 if it is the KNULLI card"). The full screen lists such a card on its first screen ("muOS SD card (E:\)"), and a SHARE drive the same way ("Batocera SHARE drive (SHARE)"): its root, typed @@ -995,7 +1016,10 @@ Under WSL both sides are searched. The Windows one is reached through its `/mnt/c/...`, and the Windows variables the rules use (`USERPROFILE`, `APPDATA`, `LOCALAPPDATA`, `ProgramFiles`) are read through `cmd.exe` when WSL does not pass them. A forced `--platform retrobat` falls back to -`/mnt/c/RetroBat/bios`. +`/mnt/c/RetroBat/bios`. With nothing found, the folder question offers both +usual folders of a setup that has one on each side: RetroArch's in this Linux +(`~/.config/retroarch/system`, under Enter) and the Windows one +(`C:\RetroArch-Win64\system`), each named by its side. | Evidence | Platform | BIOS directory | |----------|----------|----------------| @@ -1289,7 +1313,7 @@ Each launcher also sets `RETROBIOS_IDLE_EXIT` to 300: with no button pressed for 5 minutes since it opened, and no check or install running, the interface closes by itself. A pad the system has no mapping for sends nothing, and would otherwise leave the console stuck on the interface with no way out. It closes -with `RetroBIOS: No button pressed, closing.` and exit status 130, as a quit +with `RetroBIOS: No button pressed, closing.` and exit status 2, as a quit before the install. Closed on an error or a result screen, it prints that screen's words after the line and exits with its code (1 for `No internet: offline mode`), so the launcher can tell an error from a close with nothing to @@ -1536,8 +1560,8 @@ fields, the installer refuses to narrow rather than install everything. |------|---------| | `0` | everything asked for is installed or already up to date | | `1` | a failure: the one-liner could not download or verify `install.py`, or found no Python 3.8 (nothing was run), an unusable base URL, an unknown platform or target, `--installed-cores` with no core found, a manifest that fails validation, a fetch that never succeeded, no platform to install for in a non-interactive run, some files failed or were left out by `--offline` while others installed, `--check` found files to fetch, a `--from-pack` pack that cannot be read, lacks a part or is for another platform, a `--from` folder that does not exist or cannot be read, a `--dest` that is not the card of the system named (another system's card, a folder inside one, a drive with none of its files where its card always has them, or a folder that does not exist), `--restore` could not put a file back, or `--add-app` could not put every file of the app on the card | -| `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), or `--offline` left every file out, the drive has too little free space for the download (checked before any folder is created), or the question about a folder that does not exist, named with `--dest` or typed at the folder question, was answered no. Also an option the installer refused before any download: an unknown option, `--jobs` outside 1 to 32, `--target` with `--every-board` or `--from-pack`, `--target` or `--list-targets` with `--offline` (or `RETROBIOS_INSTALL_OFFLINE` set), which read a core list from the network, `--add-app` with `--check`, `--dry-run`, a listing, `--from-pack` or `--offline`. The message then starts with `install.py: error:`. Also `--add-app` for a folder on no card that takes the app, said in an `Error:` line and an `app` event before anything is written. With `--restore`, a folder that is not a dated folder inside `retrobios-backup` | -| `130` | stopped with Ctrl+C, or with `q`, Ctrl+C or Ctrl+D at a question, or the full screen closed with no button pressed (`RETROBIOS_IDLE_EXIT`) before an install. A quit chosen (`q`, **Quit**) is code `130` too, and the terminal keeps `RetroBIOS: quit. Nothing was installed.` where a stop keeps `stopped`. Run the same command again to carry on | +| `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), or `--offline` left every file out, the drive has too little free space for the download (checked before any folder is created), or the question about a folder that does not exist, named with `--dest` or typed at the folder question, was answered no. Also an option the installer refused before any download: an unknown option, `--jobs` outside 1 to 32, `--target` with `--every-board` or `--from-pack`, `--target` or `--list-targets` with `--offline` (or `RETROBIOS_INSTALL_OFFLINE` set), which read a core list from the network, `--add-app` with `--check`, `--dry-run`, a listing, `--from-pack` or `--offline`. The message then starts with `install.py: error:`. Also `--add-app` for a folder on no card that takes the app, said in an `Error:` line and an `app` event before anything is written. With `--restore`, a folder that is not a dated folder inside `retrobios-backup`. Also a quit chosen before the install, as `gh` ends a cancelled prompt: `q` at a question, `q` or **Quit** on the full screen, which then keeps `RetroBIOS: quit. Nothing was installed.`, and the full screen closed with no button pressed (`RETROBIOS_IDLE_EXIT`). No code but `0` closes a Windows Terminal tab started on the installer itself, so such a tab stays open on a quit | +| `130` | stopped with Ctrl+C, or with Ctrl+C or Ctrl+D at a question, the terminal keeping `RetroBIOS: stopped. Nothing was installed.` before an install. Run the same command again to carry on | | `141` | the output was piped to a reader that closed early (`--list-platforms \| head`) | @@ -1641,8 +1665,8 @@ RetroArch folder that holds only `scph5501.bin`, in the order written: | `SSL_CERT_FILE`, `SSL_CERT_DIR` | The trusted certificates to check servers with, for a Python that finds none of its own | | `RETROBIOS_PLAIN`, `ACCESSIBLE` | Set to anything but `0`: the questions come line by line, as with `--plain`, instead of the full-screen interface | | `RETROBIOS_PAD` | The buttons a handheld launcher's mapper sends to the full screen (`gptokeyb`, `gptokeyb_muxterm`, `muxterm`, `evmapy`, `miyoo`), so its hints name them. See [On a handheld](#on-a-handheld-the-retrobios-port) | -| `RETROBIOS_IDLE_EXIT` | Seconds the full screen waits for a first button, counted while nothing runs. With none pressed, it closes with exit status 130, or with the code of the error or result screen it closed on | -| `RETROBIOS_NO_MOUSE` | Set to anything but `0`: the full screen never asks the terminal for clicks, and the mouse selects text everywhere as it does outside the installer | +| `RETROBIOS_IDLE_EXIT` | Seconds the full screen waits for a first button, counted while nothing runs. With none pressed, it closes with exit status 2, as a quit, or with the code of the error or result screen it closed on | +| `RETROBIOS_NO_MOUSE` | Set to any value but `0` or an empty one: the full screen never asks the terminal for clicks, and the mouse selects text everywhere as it does outside the installer | | `HTTPS_PROXY`, `NO_PROXY` | Honoured for every download through the standard library's default proxy handling: one names the proxy, the other the hosts to reach directly | ## When something goes wrong