diff --git a/install.ps1 b/install.ps1 index 6d870ac14..93b8437c6 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "749d14547ad7a5504ff6ee4c62aca2ec710839174e149df77759c70d3e4fe951" + $defaultInstallSha256 = "7998082004788f8d97177492ec371d8bdc507c5f132983ae05dc43be82d7e62a" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 325e7efa1..df9bd3ba6 100644 --- a/install.py +++ b/install.py @@ -338,12 +338,100 @@ def _enable_ansi() -> bool: return False +# The roles text is painted with, from the 16 basic colours only: a +# handheld's Linux console has no more. The screens and the line questions +# name a role, never a code. Magenta is the hue that stays legible on black +# and on white in the xterm, VGA, Tango and Campbell palettes, so it is the +# accent, bold. Yellow reads poorly on white: it paints a status word, never +# a sentence. Bold red is bright red on the Linux console, where plain red on +# black is the weakest pair. +COLOUR_ROLES = { + "accent": "1;35", + "success": "32", + "warning": "33", + "failure": "1;31", + "muted": "2", + "strong": "1", + # The accent reversed: the row in focus, its > kept for no colour. + "selected": "7;35", +} + +# The words a status takes its role from, wherever it is said. A count of +# 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"(? list[tuple[int, int, str]]: + """The spans in order, none empty, the longer kept where two overlap.""" + kept: list[tuple[int, int, str]] = [] + for span in sorted(spans, key=lambda span: (span[0], -span[1])): + if span[1] > span[0] and (not kept or span[0] >= kept[-1][1]): + kept.append(span) + return kept + + +def status_spans(text: str) -> list[tuple[int, int, str]]: + """(start, end, role) of the status words in text, in order, apart.""" + return _apart([(match.start(), match.end(), role) for pattern, role in _STATUS_WORDS + for match in pattern.finditer(text)]) + + +def _sgr(role: str) -> str: + """The SGR sequence of a role, or of roles joined by + ("strong+success").""" + return "\033[0;" + ";".join(COLOUR_ROLES[name] for name in role.split("+")) + "m" + + +def painted(text: str, spans: list[tuple[int, int, str]], base: str = "") -> str: + """text with each span in its role and the rest in the base role, a code + written where the role changes and the colours reset at the end. The + characters are those of text, in the same order. A span in "plain" + takes the terminal's own colours.""" + pieces: list[tuple[str, str]] = [] + at = 0 + for start, end, role in spans: + pieces += [(text[at:start], base), (text[start:end], role)] + at = end + pieces.append((text[at:], base)) + out: list[str] = [] + current = "" + for piece, role in pieces: + role = "" if role == "plain" else role + if not piece: + continue + if role != current: + out.append(_sgr(role) if role else "\033[0m") + current = role + out.append(piece) + if current: + out.append("\033[0m") + return "".join(out) + + +_SGR = re.compile(r"\x1b\[[0-9;]*m") + + +def unpainted(text: str) -> str: + """text without its SGR codes.""" + return _SGR.sub("", text) + + class Terminal: """What the output stream can show: a live line, colour, or neither. A pipe or a log gets plain lines, and NO_COLOR and TERM=dumb or unset are honoured, as is a terminal whose description moves no cursor the ANSI way - (vt52). + (vt52), or names fewer than 8 colours (vt100), which keeps the line live + and plain. As no-color.org reads it, an empty NO_COLOR is not set: `NO_COLOR= cmd` turned colour off. FORCE_COLOR or CLICOLOR_FORCE paints a pipe, for a log viewer that shows colour, and never makes the line live. @@ -362,25 +450,30 @@ class Terminal: forced = any(os.environ.get(name, "0") not in ("", "0") for name in ("FORCE_COLOR", "CLICOLOR_FORCE")) self.colour = ( - (self.live or forced) and not os.environ.get("NO_COLOR") and _enable_ansi() + ((self.live and not terminal_few_colours(os.environ)) or forced) + and not os.environ.get("NO_COLOR") and _enable_ansi() ) if not self.colour: self.live = self.live and os.name != "nt" - def paint(self, text: str, code: str) -> str: - return f"\033[{code}m{text}\033[0m" if self.colour else text + def paint(self, text: str, role: str) -> str: + return f"\033[{COLOUR_ROLES[role]}m{text}\033[0m" if self.colour else text + + def status(self, text: str) -> str: + """text with its status words in their roles (status_spans).""" + return painted(text, status_spans(text)) if self.colour else text def ok(self, text: str) -> str: - return self.paint(text, "32") + return self.paint(text, "success") def bad(self, text: str) -> str: - return self.paint(text, "31") + return self.paint(text, "failure") def dim(self, text: str) -> str: - return self.paint(text, "2") + return self.paint(text, "muted") def bold(self, text: str) -> str: - return self.paint(text, "1") + return self.paint(text, "strong") def format_duration(seconds: float) -> str: @@ -5616,6 +5709,17 @@ NETWORK_FAILURE_ADVICE = ( f" More help: {TROUBLESHOOTING_PAGE}") +def _status_print(term: Terminal | None) -> Callable[..., None]: + """print, with the status words of what it prints in their roles when + term paints (Terminal.status).""" + if term is None or not term.colour: + return print + + def say(*parts: object, **kwargs: Any) -> None: + print(*(term.status(str(part)) for part in parts), **kwargs) + return say + + def _report_outcome( paths: list[Path], downloaded: int, @@ -5635,6 +5739,7 @@ def _report_outcome( offline: int = 0, kinds: list[int] | None = None, same_choice: str = "", + term: Terminal | None = None, ) -> None: """Close the run on what happened, and leave with a status that says so. kinds is [BIOS files, game data files] installed, said apart. @@ -5645,8 +5750,9 @@ def _report_outcome( the size question: counted with it, 14,696 files missing ended on Done and exit 0 once 4 came from this machine. They end the run as files that failed do, 1 when some were installed and 2 when none were, the - codes the full screen's run gives. + codes the full screen's run gives. term paints the status words. """ + say = _status_print(term) # A file --offline left out is not a failure, whether the run asked or # not: without questions it was "14698 files failed", and "Run the same # command again to retry only those", which cannot fetch them. @@ -5658,22 +5764,22 @@ def _report_outcome( offline += len(left) if transferred and seconds > 0: verb = "copied from the pack" if LOCAL.pack is not None else "downloaded" - print(f"\n{format_size(transferred)} {verb} in " - f"{format_duration(seconds)} ({format_size(int(transferred / seconds))}/s).") + say(f"\n{format_size(transferred)} {verb} in " + f"{format_duration(seconds)} ({format_size(int(transferred / seconds))}/s).") reasons = {reason for _dest, reason in failures or []} if failures and len(failures) > 1 and len(reasons) == 1 and reasons != {""}: # One cause, said once: a read-only folder listed ten of its files # and "... and 266 more" above the advice that fixes them all. - print(f" failed: {_plural(len(failures), 'file')} ({reasons.pop()}), " - f"first {failures[0][0]}") + say(f" failed: {_plural(len(failures), 'file')} ({reasons.pop()}), " + f"first {failures[0][0]}") else: for dest, reason in (failures or [])[:10]: - print(f" failed: {dest}" + (f" ({reason})" if reason else "")) + say(f" failed: {dest}" + (f" ({reason})" if reason else "")) if failures and len(failures) > 10: - print(f" ... and {len(failures) - 10} more") + say(f" ... and {len(failures) - 10} more") if declined and not downloaded and not errors and not interrupted and not offline: - print(f"\nNothing installed: {_plural(declined, 'file')} not downloaded, as answered.") - print(f"{_plural(up_to_date, 'file')} already up to date at {_locations(paths)}.") + say(f"\nNothing installed: {_plural(declined, 'file')} not downloaded, as answered.") + say(f"{_plural(up_to_date, 'file')} already up to date at {_locations(paths)}.") return # Left out for an unknown save directory, said with that reason in # every ending: "the collection does not hold it" blamed the collection. @@ -5684,10 +5790,10 @@ def _report_outcome( f"{unplaced[0].get('frontend') or 'the frontend'}'s save directory left out: " "its place is not known. Name it with --save-dir next time."] if unplaced else []) if interrupted: - print(f"\nStopped. {_plural(downloaded, 'file')} installed.") + say(f"\nStopped. {_plural(downloaded, 'file')} installed.") for line in kept: - print(line) - print("Run the same command again to carry on from here. Nothing is lost.") + say(line) + say("Run the same command again to carry on from here. Nothing is lost.") sys.exit(130) # A standalone copy fails by absolute path, in an emulator's folder @@ -5720,86 +5826,86 @@ def _report_outcome( if offline: remedy = _offline_remedy() if not downloaded and not errors: - print(f"\nNothing installed: {_plural(offline, 'file')} " - f"{'needs' if offline == 1 else 'need'} the internet, which " - f"{offline_named()} does not use.") - print(f" {remedy}") + say(f"\nNothing installed: {_plural(offline, 'file')} " + f"{'needs' if offline == 1 else 'need'} the internet, which " + f"{offline_named()} does not use.") + say(f" {remedy}") for line in kept: - print(line) + say(line) if up_to_date: - print(f"{_plural(up_to_date, 'file')} already up to date at " - f"{_locations(paths)}.") + say(f"{_plural(up_to_date, 'file')} already up to date at " + f"{_locations(paths)}.") sys.exit(2) failed = f", {errors} failed" if errors else "" if downloaded: - print(f"\nFinished with files missing. {_plural(downloaded, 'file')} installed, " - f"{offline} not fetched ({offline_named()}){failed}.") + say(f"\nFinished with files missing. {_plural(downloaded, 'file')} installed, " + f"{offline} not fetched ({offline_named()}){failed}.") else: # Nothing written: "Finished with files missing. 0 files installed" # read as a part done. - print(f"\nNothing installed: {_plural(offline, 'file')} not fetched " - f"({offline_named()}){failed}.") + say(f"\nNothing installed: {_plural(offline, 'file')} not fetched " + f"({offline_named()}){failed}.") if sources: - print(f" {sources}") + say(f" {sources}") # Under the files it is about: after the save-directory line, "They" # read as that one file. - print(f" {remedy}") - print(f"Location: {_locations(paths)}") + say(f" {remedy}") + say(f"Location: {_locations(paths)}") for line in kept + advice: - print(line) + say(line) sys.exit(1 if downloaded else 2) if errors and not downloaded: - print(f"\nNothing was installed. {_plural(errors, 'file')} failed.") + say(f"\nNothing was installed. {_plural(errors, 'file')} failed.") if failures and all(isinstance(r, (NetworkReason, OfflineReason)) for _d, r in failures): # The folder was never the problem: naming it as the place being # written to sent people looking at their disk. - print(f"Destination: {_locations(paths)}") + say(f"Destination: {_locations(paths)}") else: - print(f"Location that was being written to: {_locations(paths)}") + say(f"Location that was being written to: {_locations(paths)}") for line in kept + advice: - print(line) - print("Run the same command again once that is resolved. " - "Files already installed are kept.") + say(line) + say("Run the same command again once that is resolved. " + "Files already installed are kept.") sys.exit(2) if errors: - print(f"\nFinished with errors. {_plural(downloaded, 'file')} installed, " - f"{_plural(errors, 'file')} failed.") + say(f"\nFinished with errors. {_plural(downloaded, 'file')} installed, " + f"{_plural(errors, 'file')} failed.") if sources: - print(f" {sources}") - print(f"Location: {_locations(paths)}") + say(f" {sources}") + say(f"Location: {_locations(paths)}") for line in kept + advice: - print(line) + say(line) if not all(str(reason).startswith(NOT_KEPT) for _dest, reason in failures or []): - print("Run the same command again to retry only those.") + say("Run the same command again to retry only those.") sys.exit(1) - print(f"\nDone. {_plural(downloaded, 'file')} installed, " - f"{_plural(up_to_date, 'file')} already up to date.") + say(f"\nDone. {_plural(downloaded, 'file')} installed, " + f"{_plural(up_to_date, 'file')} already up to date.") if kinds and kinds[1]: - print(f" Installed: {kinds_words(*kinds)}.") + say(f" Installed: {kinds_words(*kinds)}.") if sources: - print(f" {sources}") - print(f"Location: {_locations(paths)}") + say(f" {sources}") + say(f"Location: {_locations(paths)}") for line in kept: - print(line) + say(line) if omitted > len(unplaced): for line in _omission_lines(omitted - len(unplaced), [entry for entry in omitted_entries or [] if entry.get("reason") != "no_save_directory"], Path(paths[0]) if len(paths) == 1 else None): - print(line) + say(line) for line in hints or []: - print(line) + say(line) if same_choice: # After the questions, as the screen's result ends: "the same # command" was one that asked them (tui_audit_11 P3-21). - print(f"Same choice without questions: {screen_text(same_choice)}") - print("To check this later, run that command with --check.") + say(f"Same choice without questions: {screen_text(same_choice)}") + say("To check this later, run that command with --check.") return - print("To check this later, run the same command with --check.") + say("To check this later, run the same command with --check.") def _offline_remedy(folders: list[str] | None = None) -> str: @@ -6513,6 +6619,7 @@ def _install_standalone_folders( _report_outcome( [folder for folder, _entries in plan], downloaded, up_to_date_count, errors, 0, interrupted, failures=failures, transferred=transferred, seconds=seconds, + term=term, ) return True @@ -12440,6 +12547,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: sources=_run_sources(bios_indexes, total_downloaded, total_errors + total_offline), offline=total_offline, + term=term, ) finish_app(args, platforms, app_cards, interactive and not total_declined) @@ -12481,7 +12589,17 @@ _PASTE_END = "\x1b[201~" # newline does not submit it half typed. _SCREEN_ON = "\x1b[?1049h\x1b[?25l\x1b[?2004h\x1b[2J" _SCREEN_OFF = "\x1b[?2004l\x1b[0m\x1b[?25h\x1b[?1049l" -_STYLES = {"title": "\x1b[1m", "focus": "\x1b[7m", "dim": "\x1b[2m"} +# How a line is drawn without colour: the screen's title and a headline +# bold, the row in focus reversed, the bar and secondary text dim. +_STYLES = {"title": "\x1b[1m", "head": "\x1b[1m", "focus": "\x1b[7m", "dim": "\x1b[2m", + "hint": "\x1b[2m"} +# The role each style is drawn in with colour (tui_paint). +_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)?") +_PROGRESS_BAR = re.compile(r"^ *\[(#*)(-*)\]") +_FOLDER_LABEL = "BIOS folder: " class TuiKey(NamedTuple): @@ -12753,7 +12871,8 @@ def _wrap(text: str, width: int, indent: str = " ", whole: str = "") -> list[st class TuiLine(str): - """A screen line and how it is drawn: "title", "focus", "dim" or plain.""" + """A screen line and how it is drawn: "head" (the screen's title), + "title", "focus", "dim", "hint" (the bar), "refusal" or plain.""" style = "" @@ -12764,6 +12883,59 @@ def _styled(text: str, style: str = "") -> TuiLine: return line +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", "?", "/"} + 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) + return keys + + +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. + + The title names RetroBIOS in the accent, a headline its counts and + sizes, the bar the key it names first, a line its status words, the + plan and the result the BIOS folder in bold. + """ + if not colour: + return _STYLES.get(style, "") + text + base = _COLOUR_BASES.get(style, "") + 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) + if style == "head": + at = text.find("RetroBIOS") + if at >= 0: + spans.append((at, at + len("RetroBIOS"), "accent")) + if style == "title": + spans += [(match.start(), match.end(), "accent") + for match in _KEY_NUMBER.finditer(text)] + lead = text.lstrip(" ") + if lead.startswith(_FOLDER_LABEL): + start = len(text) - len(lead) + len(_FOLDER_LABEL) + spans.append((start, len(text.rstrip(" ")), "strong")) + bar = _PROGRESS_BAR.match(text) + if bar: + spans += [(bar.start(1), bar.end(1), "accent"), (bar.start(2), bar.end(2), "muted")] + # On a bold line a status word stays bold. + spans += [(start, end, f"strong+{role}" if base == "strong" + and not COLOUR_ROLES[role].startswith("1;") else role) + for start, end, role in status_spans(text)] + return painted(text, _apart(spans), base) + + def _short_path(path: Path | None, home: Path) -> str: if path is None: return "" @@ -13103,6 +13275,8 @@ _TERMINFO_DIRS = ("/etc/terminfo", "/lib/terminfo", "/usr/share/terminfo", "/usr/lib/terminfo", "/usr/share/lib/terminfo") # cursor_address, string capability 10 of term(5). _TERMINFO_CUP = 10 +# max_colors, numeric capability 13. +_TERMINFO_COLORS = 13 def _terminfo_folders(env: Mapping[str, str]) -> list[Path]: @@ -13114,19 +13288,47 @@ def _terminfo_folders(env: Mapping[str, str]) -> list[Path]: return folders + [Path(folder) for folder in _TERMINFO_DIRS] +def _terminfo_header(data: bytes) -> tuple[int, int, int, int, int] | None: + """Of a compiled term(5) entry: the width of a number, the counts of + names bytes, booleans, numbers and strings. None when data is none.""" + if len(data) < 12: + return None + # The legacy format stores numbers in 2 bytes, the extended one in 4. + width = {0o432: 2, 0o1036: 4}.get(int.from_bytes(data[0:2], "little", signed=True)) + if width is None: + return None + names, bools, numbers, strings = (int.from_bytes(data[at:at + 2], "little", signed=True) + for at in (2, 4, 6, 8)) + return width, names, bools, numbers, strings + + +def terminfo_number(data: bytes, index: int) -> int | None: + """Numeric capability index of a compiled term(5) entry, -1 when the + entry lacks it, None when data is no compiled entry.""" + header = _terminfo_header(data) + if header is None: + return None + width, names, bools, numbers, _strings = header + if index >= numbers: + return -1 + start = 12 + names + bools + start += start % 2 + index * width + if start + width > len(data): + return None + value = int.from_bytes(data[start:start + width], "little", signed=True) + return value if value >= 0 else -1 + + def terminfo_string(data: bytes, index: int) -> str | None: """String capability index of a compiled term(5) entry, "" when the entry lacks it, None when data is no compiled entry.""" def short(at: int) -> int: return int.from_bytes(data[at:at + 2], "little", signed=True) - if len(data) < 12: + header = _terminfo_header(data) + if header is None: return None - # The legacy format stores numbers in 2 bytes, the extended one in 4. - width = {0o432: 2, 0o1036: 4}.get(short(0)) - if width is None: - return None - names, bools, numbers, strings = short(2), short(4), short(6), short(8) + width, names, bools, numbers, strings = header offsets = 12 + names + bools offsets += offsets % 2 + numbers * width if index >= strings: @@ -13141,23 +13343,64 @@ def terminfo_string(data: bytes, index: int) -> str | None: return data[start:end].decode("latin-1") -def terminal_cursor_address(term: str, env: Mapping[str, str]) -> str | None: - """How TERM's description here moves the cursor, "" when it cannot, - None when no description is found: Windows, Android and many handheld - images carry none, and the screen is not held back for it.""" +def _terminfo_data(term: str, env: Mapping[str, str]) -> bytes | None: + """TERM's compiled description, the first found, None when none is: + Windows, Android and many handheld images carry none.""" if not term or "/" in term or term.startswith("."): return None for folder in _terminfo_folders(env): # Linux files xterm under x/, macOS under 78/. for sub in (term[0], f"{ord(term[0]):02x}"): try: - data = (folder / sub / term).read_bytes() + return (folder / sub / term).read_bytes() except (OSError, ValueError): continue - return terminfo_string(data, _TERMINFO_CUP) return None +def terminal_cursor_address(term: str, env: Mapping[str, str]) -> str | None: + """How TERM's description here moves the cursor, "" when it cannot, + None when no description is found, and the screen is not held back + for it.""" + data = _terminfo_data(term, env) + return None if data is None else terminfo_string(data, _TERMINFO_CUP) + + +def terminal_colours(term: str, env: Mapping[str, str]) -> int | None: + """The colours TERM's description here names, 0 when it names none + (vt100), None when no description is found or it cannot be read.""" + data = _terminfo_data(term, env) + if data is None: + return None + count = terminfo_number(data, _TERMINFO_COLORS) + return None if count is None else max(count, 0) + + +def terminal_few_colours(env: Mapping[str, str]) -> str: + """Why TERM's description here leaves colour off, "" when it does not: + no description is no refusal, as for the cursor.""" + if os.name == "nt": + return "" + term = env.get("TERM", "") + count = terminal_colours(term, env) + if count is not None and count < 8: + return f"TERM={term} names {count} colours, fewer than 8" + return "" + + +def colour_blocker(env: Mapping[str, str], stream: TextIO) -> str: + """Why the full-screen interface draws without colour, "" when it paints + with the roles of COLOUR_ROLES. The text is the same either way.""" + if env.get("NO_COLOR"): + return "NO_COLOR is set" + isatty = getattr(stream, "isatty", None) + if not (isatty and isatty()): + return "the output is not a terminal" + if os.name != "nt" and env.get("TERM", "") in ("", "dumb"): + return "TERM is dumb or not set" + return terminal_few_colours(env) + + def terminal_without_ansi(env: Mapping[str, str]) -> str: """Why TERM's description here says the ANSI sequences the screen and the progress line write would print as text, "" when it does not.""" @@ -13865,9 +14108,10 @@ def _start_check(state: TuiState) -> list[tuple]: return [("fetch", state.platform)] -def _closed_status(state: TuiState, closed: tuple[Path, str]) -> str: - return (f"This user cannot open {_short_path(closed[0], state.home)} ({closed[1]}): " - "choose another folder, or give this user access to it.") +def _closed_status(state: TuiState, closed: tuple[Path, str]) -> TuiLine: + folder = _short_path(closed[0], state.home) + return _styled(f"This user cannot open {folder} ({closed[1]}): choose another folder, " + "or give this user access to it.", "refusal") def _choose(state: TuiState, plat: str, dest: Path, proposed: bool = False) -> list[tuple]: @@ -13968,7 +14212,7 @@ def _card_status(state: TuiState, choice: CardChoice, given: bool = False) -> No else: then = "Choose the card's top folder." short = choice.short or choice.refused + "." - state.status = f"{short} {then}" + state.status = _styled(f"{short} {then}", "refusal") state.status_more = f"{choice.refused}." + (f" {choice.detail}." if choice.detail else "") @@ -14389,7 +14633,7 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: chosen = state.browse_dir if action == "use" else row.value refused = copy_source_refusal(chosen, state.dest) if refused: - state.status = refused + state.status = _styled(refused, "refusal") return [] if chosen not in state.from_folders: state.from_folders.append(chosen) @@ -16084,7 +16328,8 @@ def _status_after(state: TuiState) -> list[str]: # Said once: the full form repeated the short one under it. The help # holds it, and a pad, with no button for the help, reads it here. if state.pad in ("both", "keys"): - return [f"{state.status} {'F1' if state.screen == 'path' else '?'} says more."] + return [_styled(f"{state.status} {'F1' if state.screen == 'path' else '?'} says more.", + getattr(state.status, "style", ""))] return [state.status, state.status_more] @@ -16283,10 +16528,10 @@ def _view_systems(state: TuiState, width: int, height: int) -> tuple: and cell_width(f"> {row.label} {row.value} {row.right}") + 2 <= width else row for row in rows] chosen = _ticked_files(state) - footer = [f"{len(state.ticked)} of {len(state.systems)} systems: " - f"{_plural(len(chosen), 'file')}, " - f"{format_size(sum(e['size'] for e in chosen))}" - + (" before your options" if _counts_predate_options(state) else "")] + total = (f"{len(state.ticked)} of {len(state.systems)} systems: " + f"{_plural(len(chosen), 'file')}, {format_size(sum(e['size'] for e in chosen))}" + + (" before your options" if _counts_predate_options(state) else "")) + footer = [_styled(total, "title")] focused = rows[min(state.focus.get("systems", 0), len(rows) - 1)] drawn = str(_row_line(focused, True, width)) if focused.action == "system" and not ( @@ -16749,6 +16994,9 @@ def tui_keeps_log(state: TuiState) -> bool: def _view_result(state: TuiState, width: int, height: int) -> tuple: title, lines = tui_outcome(state) + # The outcome in bold, its counts in their roles: it led a list of + # lines drawn alike. + lines[0] = _styled(lines[0], "title") rows = tui_rows(state) if rows: return (title, _compose(state, width, height, lines, rows), @@ -16944,14 +17192,14 @@ def tui_view(state: TuiState, cols: int, rows: int) -> list[str]: # keys that end it, not Tick and Help. hint = ("Enter Done", "Esc Clear") hint = _route_hint(state, _back_hint(state, hint)) - lines = [_styled(fit(f" RetroBIOS: {title}", cols), "title"), _styled("")] + 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. lines += [_styled(" " + line if line.style == "long" else fit(" " + line, cols), line.style) for line in body[:height]] lines += [_styled("")] * (rows - 1 - len(lines)) # Never the last cell of the screen: a terminal may scroll when it is written. - lines.append(_styled(fit(" " + _hint(width, *hint), cols - 1), "dim")) + lines.append(_styled(fit(" " + _hint(width, *hint), cols - 1), "hint")) return lines @@ -17345,6 +17593,9 @@ class _TuiTerminal: # Python turns a C locale into UTF-8 for itself, not for the # terminal: a console font there has no CJK, often no accents. self.ascii = not utf8 or locale_name in ("C", "POSIX") + # The same text without colour: NO_COLOR, a dumb or unset TERM, a + # description naming fewer than 8 colours, an output no terminal. + self.colour = not colour_blocker(os.environ, stdout) and _enable_ansi() self.ended = False self.heard = 0.0 self.frame: list[tuple[str, str]] = [] @@ -17410,7 +17661,8 @@ class _TuiTerminal: text = ascii_text(text) if style == "focus": text = _pad(text, min(cols, TUI_MAX_WIDTH) - (index == len(frame) - 1)) - out.append(f"\x1b[{index + 1};1H{_STYLES.get(style, '')}{text}\x1b[0m\x1b[K") + out.append(f"\x1b[{index + 1};1H" + f"{tui_paint(text, style, getattr(self, 'colour', False))}\x1b[0m\x1b[K") self.frame = frame if out: self.write("".join(out)) diff --git a/install.sh b/install.sh index 34c845946..c38d90bc4 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="749d14547ad7a5504ff6ee4c62aca2ec710839174e149df77759c70d3e4fe951" +DEFAULT_INSTALL_SHA256="7998082004788f8d97177492ec371d8bdc507c5f132983ae05dc43be82d7e62a" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 77a7edb56..6ad02257f 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": 818600, - "sha256": "749d14547ad7a5504ff6ee4c62aca2ec710839174e149df77759c70d3e4fe951", + "size": 829229, + "sha256": "7998082004788f8d97177492ec371d8bdc507c5f132983ae05dc43be82d7e62a", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 871149, - "sha256": "cbca9dd0e3d7de656965dbff79512991856a03853c0e771da97e5e95d7d31f49" + "size": 881778, + "sha256": "7e5cc8505ce9c810888baf62260b7ac2ff1cfcbce2e13c44b99d15458969f38a" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 818600, - "sha256": "749d14547ad7a5504ff6ee4c62aca2ec710839174e149df77759c70d3e4fe951", + "size": 829229, + "sha256": "7998082004788f8d97177492ec371d8bdc507c5f132983ae05dc43be82d7e62a", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 818600, - "sha256": "749d14547ad7a5504ff6ee4c62aca2ec710839174e149df77759c70d3e4fe951", + "size": 829229, + "sha256": "7998082004788f8d97177492ec371d8bdc507c5f132983ae05dc43be82d7e62a", "executable": false }, { diff --git a/tests/fixtures/tui/colour-120x40.txt b/tests/fixtures/tui/colour-120x40.txt new file mode 100644 index 000000000..7a89fbf7f --- /dev/null +++ b/tests/fixtures/tui/colour-120x40.txt @@ -0,0 +1,902 @@ +=== setup +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Where do you play?^[[0m + + Found on this machine: + +^[[0;7;35m > RetroArch^[[0m + KNULLI (Batocera files) + Another setup or folder + + BIOS folder: ^[[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/Enter^[[0m Continue^[[0;2m B/Esc Quit ? Help^[[0m +=== 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 +^[[0;7;35m > RetroArch^[[0m + Batocera + Recalbox + RetroBat + EmuDeck + Lakka + RetroDECK + RomM + 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 +=== folder +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which folder?^[[0m + + RetroArch usually reads its BIOS files from: + /home/user/.config/retroarch/system + +^[[0;7;35m > Use this folder^[[0m + Browse for a folder + Type a path + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Use this folder^[[0;2m B/Esc Back ? Help^[[0m +=== folder-card +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Where is the SD card?^[[0m + + NextUI keeps its BIOS files at the root of its SD card. Choose the card's top folder, the one + holding .system. + No NextUI card is mounted. Insert it, then choose Look again. + +^[[0;7;35m > Browse for a folder^[[0m + Type a path + Look again + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Browse^[[0;2m B/Esc Back ? Help^[[0m +=== browse +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Browse for a folder^[[0m + + /home/user/.config/retroarch + +^[[0;7;35m > Use this folder^[[0m + .. + /media/user/SDCARD (NextUI card) + cores/ + system/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Use this folder^[[0;2m B/Esc Back ? Help^[[0m +=== path +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Type a path^[[0m + + The folder for RetroArch: +^[[0;7;35m > ~/bios_^[[0m + + Ctrl-U clears. Quotes are removed, ~ and variables are expanded. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mEnter^[[0m Use this folder^[[0;2m Esc Back Ctrl-U Clear^[[0m +=== create +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Create the folder?^[[0m + + /home/user/bios/new does not exist. + The nearest folder that does: /home/user + +^[[0;7;35m > Create it and continue^[[0m + Choose another folder + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Create^[[0;2m B/Esc Quit^[[0m +=== checking +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Checking^[[0m + + RetroArch: ~/.config/retroarch/system + + File list received. + Reading the files already in the folder... 0:04 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mCtrl-C^[[0m Quit^[[0;2m B/Esc Quit^[[0m +=== plan +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m + +^[[0;1m ^[[0;1;35m4^[[0;1m files to download, ^[[0;1;35m1.0 MB^[[0m + BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m + ^[[0;32m1 in place^[[0m, ^[[0;33m3 missing^[[0m, ^[[0;33m1 wrong copy^[[0m + The ^[[0;33m1 wrong copy^[[0m is replaced, the old one kept in a backup folder. + Free space: 50.0 GB + +^[[0;7;35m > Install^[[0m + Choose systems + Copy the BIOS files from your old card or a folder + Options + Quit + + + + + + + + + + + + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Install^[[0;2m B/Esc Back Y/o Options ? Help^[[0m +=== plan-in-place +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m + +^[[0;1m ^[[0;1;32mEverything is in place^[[0m + BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m + ^[[0;32m5 in place^[[0m, 0 missing, 0 wrong copies + Free space: 50.0 GB + +^[[0;7;35m > Close^[[0m + Options + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Close^[[0;2m B/Esc Back Y/o Options ? Help^[[0m +=== systems +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Choose systems^[[0m + + All + None +^[[0;2m Filter (/ to type)^[[0m +^[[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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +^[[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 +=== options +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Options^[[0m + + Each row is an option of the command line, named on the right. + +^[[0;7;35m > Preferred regions: North America, Japan --region^[[0m + Game data: kept --no-game-data + Installed cores only: no (1 found) --installed-cores + Cores: all --core + Parallel downloads: 8 --jobs + BIOS folder: ^[[0;1m~/.config/retroarch/system --dest^[[0m + Copy from: none --from + Preview the file list --dry-run + Show the command line + + + + + + + + + + + + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Open^[[0;2m B/Esc Back Left/Right Set ? Help^[[0m +=== regions +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preferred regions^[[0m + + Tick the regions to keep, best first. Where a system has one BIOS per region, the best one found + is installed. + +^[[0;7;35m > [1] North America^[[0m + [ ] Japan + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Tick^[[0;2m B/Esc Done Left/Right Order ? Help^[[0m +=== cores +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Cores^[[0m + + Tick the cores you use. With none ticked, every core's files are kept. + +^[[0;7;35m > Filter (/ to type)^[[0m + [ ] mesen + [x] pcsx_rearmed + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Filter^[[0;2m B/Esc Back X/Space Tick ? Help^[[0m +=== preview +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preview^[[0m + + 4 files to download, 1.0 MB. Nothing is written. + + missing 512.0 KB scph5500.bin + wrong copy 512.0 KB scph5501.bin + missing 4 B pcsx/extra.bin + missing 16.0 KB gba_bios.bin + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Back +=== command +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Command line^[[0m + + The same choice without questions: + + python3 install.py --platform retroarch --dest /home/user/.config/retroarch/system --system + nintendo-gba + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +^[[0;2m ^[[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 + Free space: 50.0 GB + +^[[0;7;35m > Install^[[0m + Back + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Install^[[0;2m B/Esc Back ? Help^[[0m +=== installing +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Installing^[[0m + + [^[[0;1;35m##############################################^[[0;2m---------------------------------------------^[[0m] 50% + 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 + + ^[[0;1;31mFAILED^[[0m scph5500.bin (HTTP 404) +^[[0;32m ok^[[0m gba_bios.bin + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mB/Esc^[[0m Stop^[[0;2m ? Help^[[0m +=== 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: ^[[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 --restore + /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 + Same choice without questions: + python3 install.py --platform retroarch --dest /home/user/.config/retroarch/system + Details: /tmp/retrobios-x/install.log + + + + + + + + + + + + + + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Exit +=== error +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;31mSomething went wrong^[[0m + + Could not fetch the file list for retroarch after 3 attempts: [Errno -2] Name or service not + known + Address: https://raw.githubusercontent.com/Abdess/retrobios/main/install/retroarch.json + +^[[0;7;35m > Choose another setup or folder^[[0m + Quit + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Choose^[[0;2m B/Esc Back^[[0m +=== quit +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Quit?^[[0m + + Quit without installing? Nothing was written. + +^[[0;7;35m > Quit^[[0m + Stay + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Quit^[[0;2m B/Esc Stay^[[0m +=== help +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Keys^[[0m + + Up/Down, k/j Move + A/Enter, Start Install + Y, o, Tab Options + B/Esc, Backspace Back + q, Ctrl-C Quit + ?, F1 This help + + BIOS folder: ^[[0;1m/home/user/.config/retroarch/system^[[0m + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +^[[0;2m Any key closes this help^[[0m diff --git a/tests/fixtures/tui/colour-40x12.txt b/tests/fixtures/tui/colour-40x12.txt new file mode 100644 index 000000000..396127fd0 --- /dev/null +++ b/tests/fixtures/tui/colour-40x12.txt @@ -0,0 +1,286 @@ +=== setup +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Where do you play?^[[0m + + Found on this machine: + +^[[0;7;35m > RetroArch^[[0m + KNULLI (Batocera files) + Another setup or folder + + BIOS folder: + /home/user/.config/retroarch/system + ES-DE found: its BIOS files go to each +^[[0;2m ^[[0;1;35mA^[[0m Continue^[[0;2m B Quit ? Help^[[0m +=== platforms +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which setup?^[[0m + +^[[0;2m Filter: setup or device (/)^[[0m +^[[0;7;35m > RetroArch^[[0m + Batocera + Recalbox + RetroBat + EmuDeck + Lakka + RetroDECK +^[[0;2m 1-7 of 34^[[0m +^[[0;2m ^[[0;1;35mA^[[0m Choose^[[0;2m B Back / Filter ? Help^[[0m +=== folder +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which folder?^[[0m + + RetroArch usually reads its BIOS files + from: + /home/user/.config/retroarch/system + +^[[0;7;35m > Use this folder^[[0m + Browse for a folder + Type a path + + +^[[0;2m ^[[0;1;35mA^[[0m Use this folder^[[0;2m B Back ? Help^[[0m +=== folder-card +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Where is the SD card?^[[0m + + NextUI keeps its BIOS files at the + root of its SD card. Choose the + card's top folder, the one holding + .system. + No NextUI card is mounted. Insert it, + then choose Look again. +^[[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 +=== browse +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Browse for a folder^[[0m + + /home/user/.config/retroarch + +^[[0;7;35m > Use this folder^[[0m + .. + /media/user/SDCARD (NextUI card) + cores/ + system/ + + +^[[0;2m ^[[0;1;35mA^[[0m Use this folder^[[0;2m B Back ? Help^[[0m +=== path +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Type a path^[[0m + + The folder for RetroArch: +^[[0;7;35m > ~/bios_^[[0m + + Ctrl-U clears. Quotes are removed, ~ + and variables are expanded. + + + + +^[[0;2m ^[[0;1;35mEnter^[[0m Use this folder^[[0;2m Esc Back^[[0m +=== create +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Create the folder?^[[0m + + /home/user/bios/new does not exist. + The nearest folder that does: + /home/user + +^[[0;7;35m > Create it and continue^[[0m + Choose another folder + + + +^[[0;2m ^[[0;1;35mA^[[0m Create^[[0;2m B Quit^[[0m +=== checking +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Checking^[[0m + + RetroArch: ~/.config/retroarch/system + + File list received. + Reading the files already in the + folder... 0:04 + + + + +^[[0;2m ^[[0;1;35mCtrl-C^[[0m Quit^[[0;2m B Quit^[[0m +=== plan +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m + +^[[0;1m ^[[0;1;35m4^[[0;1m files to download, ^[[0;1;35m1.0 MB^[[0m + BIOS folder: ^[[0;1m~/.co.../retroarch/system^[[0m + ^[[0;32m1 in place^[[0m, ^[[0;33m3 missing^[[0m, ^[[0;33m1 wrong copy^[[0m + ^[[0;33m1 wrong copy^[[0m: backed up, replaced. +^[[0;7;35m > Install^[[0m + Choose systems + 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 +=== plan-in-place +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m + +^[[0;1m ^[[0;1;32mEverything is in place^[[0m + BIOS folder: ^[[0;1m~/.co.../retroarch/system^[[0m + ^[[0;32m5 in place^[[0m, 0 missing, 0 wrong copies + Free space: 50.0 GB + +^[[0;7;35m > Close^[[0m + Options + + +^[[0;2m ^[[0;1;35mA^[[0m Close^[[0;2m B Back Y Options ? Help^[[0m +=== systems +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Choose systems^[[0m + + All + None +^[[0;2m Filter (/ to type)^[[0m +^[[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 +=== options +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Options^[[0m + +^[[0;7;35m > Preferred regions: North America, Ja^[[0m + Game data: kept + Installed cores only: no (1 found) + Cores: all + Parallel downloads: 8 + BIOS folder: ^[[0;1m~/.config...ch/system^[[0m + 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 +=== regions +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preferred regions^[[0m + + Tick the regions to keep, best first. + Where a system has one BIOS per + region, the best one found is + installed. + +^[[0;7;35m > [1] North America^[[0m + [ ] Japan + + +^[[0;2m ^[[0;1;35mA^[[0m Tick^[[0;2m B Done <> Order ? Help^[[0m +=== cores +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Cores^[[0m + + Tick the cores you use. With none + ticked, every core's files are kept. + +^[[0;7;35m > Filter (/ to type)^[[0m + [ ] mesen + [x] pcsx_rearmed + + + +^[[0;2m ^[[0;1;35mA^[[0m Filter^[[0;2m B Back X Tick ? Help^[[0m +=== preview +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preview^[[0m + + 4 files to download, 1.0 MB. Nothing + is written. + + missing 512.0 KB scph5500.bin + wrong copy 512.0 KB scph5501.bin + missing 4 B pcsx/extra.bin + missing 16.0 KB gba_bios.bin + + +^[[0;2m ^[[0;1;35mA^[[0m Back +=== command +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Command line^[[0m + + The same choice without questions: + + python3 install.py --platform + retroarch --dest + /home/user/.config/retroarch/system + --system nintendo-gba + + + +^[[0;2m ^[[0;1;35mA^[[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~/.co.../retroarch/system^[[0m + 0 in place, ^[[0;33m1 missing^[[0m, 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 +=== installing +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Installing^[[0m + + [^[[0;1;35m###############^[[0;2m----------------^[[0m] 50% + 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 + + ^[[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 +=== 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;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 +=== error +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;31mSomething went wrong^[[0m + + Could not fetch the file list for + retroarch after 3 attempts: [Errno + -2] Name or service not known + Address: + https://raw.githubusercontent.com/ +^[[0;2m 1-5 of 7^[[0m + +^[[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 +=== quit +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Quit?^[[0m + + Quit without installing? Nothing was + written. + +^[[0;7;35m > Quit^[[0m + Stay + + + + +^[[0;2m ^[[0;1;35mA^[[0m Quit^[[0;2m B Stay^[[0m +=== help +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Keys^[[0m + + Up/Down, k/j Move + A/Enter, Start Install + Y, o, Tab Options + B/Esc, Backspace Back + q, Ctrl-C Quit + ?, F1 This help + + BIOS folder: + /home/user/.config/retroarch/system +^[[0;2m Any key closes this help^[[0m diff --git a/tests/fixtures/tui/colour-40x15.txt b/tests/fixtures/tui/colour-40x15.txt new file mode 100644 index 000000000..05721261e --- /dev/null +++ b/tests/fixtures/tui/colour-40x15.txt @@ -0,0 +1,352 @@ +=== setup +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Where do you play?^[[0m + + Found on this machine: + +^[[0;7;35m > RetroArch^[[0m + KNULLI (Batocera files) + Another setup or folder + + BIOS folder: + /home/user/.config/retroarch/system + 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 +=== platforms +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which setup?^[[0m + +^[[0;2m Filter: setup or device (/)^[[0m +^[[0;7;35m > RetroArch^[[0m + Batocera + Recalbox + RetroBat + EmuDeck + Lakka + RetroDECK + RomM + BizHawk + MiSTer FPGA +^[[0;2m 1-10 of 34^[[0m +^[[0;2m ^[[0;1;35mA^[[0m Choose^[[0;2m B Back / Filter ? Help^[[0m +=== folder +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which folder?^[[0m + + RetroArch usually reads its BIOS files + from: + /home/user/.config/retroarch/system + +^[[0;7;35m > Use this folder^[[0m + Browse for a folder + Type a path + + + + + +^[[0;2m ^[[0;1;35mA^[[0m Use this folder^[[0;2m B Back ? Help^[[0m +=== folder-card +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Where is the SD card?^[[0m + + NextUI keeps its BIOS files at the + root of its SD card. Choose the + card's top folder, the one holding + .system. + No NextUI card is mounted. Insert it, + then choose Look again. + +^[[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 +=== browse +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Browse for a folder^[[0m + + /home/user/.config/retroarch + +^[[0;7;35m > Use this folder^[[0m + .. + /media/user/SDCARD (NextUI card) + cores/ + system/ + + + + + +^[[0;2m ^[[0;1;35mA^[[0m Use this folder^[[0;2m B Back ? Help^[[0m +=== path +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Type a path^[[0m + + The folder for RetroArch: +^[[0;7;35m > ~/bios_^[[0m + + Ctrl-U clears. Quotes are removed, ~ + and variables are expanded. + + + + + + + +^[[0;2m ^[[0;1;35mEnter^[[0m Use this folder^[[0;2m Esc Back^[[0m +=== create +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Create the folder?^[[0m + + /home/user/bios/new does not exist. + The nearest folder that does: + /home/user + +^[[0;7;35m > Create it and continue^[[0m + Choose another folder + + + + + + +^[[0;2m ^[[0;1;35mA^[[0m Create^[[0;2m B Quit^[[0m +=== checking +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Checking^[[0m + + RetroArch: ~/.config/retroarch/system + + File list received. + Reading the files already in the + folder... 0:04 + + + + + + + +^[[0;2m ^[[0;1;35mCtrl-C^[[0m Quit^[[0;2m B Quit^[[0m +=== plan +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m + +^[[0;1m ^[[0;1;35m4^[[0;1m files to download, ^[[0;1;35m1.0 MB^[[0m + BIOS folder: ^[[0;1m~/.co.../retroarch/system^[[0m + ^[[0;32m1 in place^[[0m, ^[[0;33m3 missing^[[0m, ^[[0;33m1 wrong copy^[[0m + ^[[0;33m1 wrong copy^[[0m: backed up, replaced. + Free space: 50.0 GB + +^[[0;7;35m > Install^[[0m + Choose systems + 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 +=== plan-in-place +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m + +^[[0;1m ^[[0;1;32mEverything is in place^[[0m + BIOS folder: ^[[0;1m~/.co.../retroarch/system^[[0m + ^[[0;32m5 in place^[[0m, 0 missing, 0 wrong copies + Free space: 50.0 GB + +^[[0;7;35m > Close^[[0m + Options + + + + + +^[[0;2m ^[[0;1;35mA^[[0m Close^[[0;2m B Back Y Options ? Help^[[0m +=== systems +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Choose systems^[[0m + + All + None +^[[0;2m Filter (/ to type)^[[0m +^[[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 +=== options +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Options^[[0m + +^[[0;7;35m > Preferred regions: North America, Ja^[[0m + Game data: kept + Installed cores only: no (1 found) + Cores: all + Parallel downloads: 8 + BIOS folder: ^[[0;1m~/.config...ch/system^[[0m + Copy from: none + Preview the file list + Show the command line + + Sets --region + +^[[0;2m ^[[0;1;35mA^[[0m Open^[[0;2m B Back <> Set ? Help^[[0m +=== regions +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preferred regions^[[0m + + Tick the regions to keep, best first. + Where a system has one BIOS per + region, the best one found is + installed. + +^[[0;7;35m > [1] North America^[[0m + [ ] Japan + + + + + +^[[0;2m ^[[0;1;35mA^[[0m Tick^[[0;2m B Done <> Order ? Help^[[0m +=== cores +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Cores^[[0m + + Tick the cores you use. With none + ticked, every core's files are kept. + +^[[0;7;35m > Filter (/ to type)^[[0m + [ ] mesen + [x] pcsx_rearmed + + + + + + +^[[0;2m ^[[0;1;35mA^[[0m Filter^[[0;2m B Back X Tick ? Help^[[0m +=== preview +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preview^[[0m + + 4 files to download, 1.0 MB. Nothing + is written. + + missing 512.0 KB scph5500.bin + wrong copy 512.0 KB scph5501.bin + missing 4 B pcsx/extra.bin + missing 16.0 KB gba_bios.bin + + + + + +^[[0;2m ^[[0;1;35mA^[[0m Back +=== command +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Command line^[[0m + + The same choice without questions: + + python3 install.py --platform + retroarch --dest + /home/user/.config/retroarch/system + --system nintendo-gba + + + + + + +^[[0;2m ^[[0;1;35mA^[[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~/.co.../retroarch/system^[[0m + 0 in place, ^[[0;33m1 missing^[[0m, 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 +=== installing +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Installing^[[0m + + [^[[0;1;35m###############^[[0;2m----------------^[[0m] 50% + 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 + + ^[[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 +=== 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;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 + --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 +=== error +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;31mSomething went wrong^[[0m + + Could not fetch the file list for + retroarch after 3 attempts: [Errno + -2] Name or service not known + Address: + https://raw.githubusercontent.com/ + Abdess/retrobios/main/install/ + retroarch.json + +^[[0;7;35m > Choose another setup or folder^[[0m + Quit + + +^[[0;2m ^[[0;1;35mA^[[0m Choose^[[0;2m B Back^[[0m +=== quit +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Quit?^[[0m + + Quit without installing? Nothing was + written. + +^[[0;7;35m > Quit^[[0m + Stay + + + + + + + +^[[0;2m ^[[0;1;35mA^[[0m Quit^[[0;2m B Stay^[[0m +=== help +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Keys^[[0m + + Up/Down, k/j Move + A/Enter, Start Install + Y, o, Tab Options + B/Esc, Backspace Back + q, Ctrl-C Quit + ?, F1 This help + + BIOS folder: + /home/user/.config/retroarch/system + + + +^[[0;2m Any key closes this help^[[0m diff --git a/tests/fixtures/tui/colour-80x24.txt b/tests/fixtures/tui/colour-80x24.txt new file mode 100644 index 000000000..fd8f432c9 --- /dev/null +++ b/tests/fixtures/tui/colour-80x24.txt @@ -0,0 +1,550 @@ +=== setup +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Where do you play?^[[0m + + Found on this machine: + +^[[0;7;35m > RetroArch^[[0m + KNULLI (Batocera files) + Another setup or folder + + BIOS folder: ^[[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/Enter^[[0m Continue^[[0;2m B/Esc Quit ? Help^[[0m +=== platforms +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which setup?^[[0m + +^[[0;2m Filter: setup or device (/)^[[0m +^[[0;7;35m > RetroArch^[[0m + Batocera + Recalbox + RetroBat + EmuDeck + Lakka + RetroDECK + RomM + 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 +^[[0;2m 1-19 of 34^[[0m +^[[0;2m ^[[0;1;35mA/Enter^[[0m Choose^[[0;2m B/Esc Back / Filter ? Help^[[0m +=== folder +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which folder?^[[0m + + RetroArch usually reads its BIOS files from: + /home/user/.config/retroarch/system + +^[[0;7;35m > Use this folder^[[0m + Browse for a folder + Type a path + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Use this folder^[[0;2m B/Esc Back ? Help^[[0m +=== folder-card +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Where is the SD card?^[[0m + + NextUI keeps its BIOS files at the root of its SD card. Choose the card's top + folder, the one holding .system. + No NextUI card is mounted. Insert it, then choose Look again. + +^[[0;7;35m > Browse for a folder^[[0m + Type a path + Look again + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Browse^[[0;2m B/Esc Back ? Help^[[0m +=== browse +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Browse for a folder^[[0m + + /home/user/.config/retroarch + +^[[0;7;35m > Use this folder^[[0m + .. + /media/user/SDCARD (NextUI card) + cores/ + system/ + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Use this folder^[[0;2m B/Esc Back ? Help^[[0m +=== path +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Type a path^[[0m + + The folder for RetroArch: +^[[0;7;35m > ~/bios_^[[0m + + Ctrl-U clears. Quotes are removed, ~ and variables are expanded. + + + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mEnter^[[0m Use this folder^[[0;2m Esc Back Ctrl-U Clear^[[0m +=== create +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Create the folder?^[[0m + + /home/user/bios/new does not exist. + The nearest folder that does: /home/user + +^[[0;7;35m > Create it and continue^[[0m + Choose another folder + + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Create^[[0;2m B/Esc Quit^[[0m +=== checking +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Checking^[[0m + + RetroArch: ~/.config/retroarch/system + + File list received. + Reading the files already in the folder... 0:04 + + + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mCtrl-C^[[0m Quit^[[0;2m B/Esc Quit^[[0m +=== plan +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m + +^[[0;1m ^[[0;1;35m4^[[0;1m files to download, ^[[0;1;35m1.0 MB^[[0m + BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m + ^[[0;32m1 in place^[[0m, ^[[0;33m3 missing^[[0m, ^[[0;33m1 wrong copy^[[0m + The ^[[0;33m1 wrong copy^[[0m is replaced, the old one kept in a backup folder. + Free space: 50.0 GB + +^[[0;7;35m > Install^[[0m + Choose systems + Copy the BIOS files from your old card or a folder + Options + Quit + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Install^[[0;2m B/Esc Back Y/o Options ? Help^[[0m +=== plan-in-place +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m + +^[[0;1m ^[[0;1;32mEverything is in place^[[0m + BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m + ^[[0;32m5 in place^[[0m, 0 missing, 0 wrong copies + Free space: 50.0 GB + +^[[0;7;35m > Close^[[0m + Options + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Close^[[0;2m B/Esc Back Y/o Options ? Help^[[0m +=== systems +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Choose systems^[[0m + + All + None +^[[0;2m Filter (/ to type)^[[0m +^[[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 + + + + + + + + + + + + + + +^[[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 +=== options +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Options^[[0m + + Each row is an option of the command line, named on the right. + +^[[0;7;35m > Preferred regions: North America, Japan --region^[[0m + Game data: kept --no-game-data + Installed cores only: no (1 found) --installed-cores + Cores: all --core + Parallel downloads: 8 --jobs + BIOS folder: ^[[0;1m~/.config/retroarch/system --dest^[[0m + Copy from: none --from + Preview the file list --dry-run + Show the command line + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Open^[[0;2m B/Esc Back Left/Right Set ? Help^[[0m +=== regions +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preferred regions^[[0m + + Tick the regions to keep, best first. Where a system has one BIOS per region, + the best one found is installed. + +^[[0;7;35m > [1] North America^[[0m + [ ] Japan + + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Tick^[[0;2m B/Esc Done Left/Right Order ? Help^[[0m +=== cores +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Cores^[[0m + + Tick the cores you use. With none ticked, every core's files are kept. + +^[[0;7;35m > Filter (/ to type)^[[0m + [ ] mesen + [x] pcsx_rearmed + + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Filter^[[0;2m B/Esc Back X/Space Tick ? Help^[[0m +=== preview +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preview^[[0m + + 4 files to download, 1.0 MB. Nothing is written. + + missing 512.0 KB scph5500.bin + wrong copy 512.0 KB scph5501.bin + missing 4 B pcsx/extra.bin + missing 16.0 KB gba_bios.bin + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Back +=== command +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Command line^[[0m + + The same choice without questions: + + python3 install.py --platform retroarch --dest + /home/user/.config/retroarch/system --system nintendo-gba + + + + + + + + + + + + + + + + + +^[[0;2m ^[[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 + Free space: 50.0 GB + +^[[0;7;35m > Install^[[0m + Back + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Install^[[0;2m B/Esc Back ? Help^[[0m +=== installing +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Installing^[[0m + + [^[[0;1;35m####################################^[[0;2m-----------------------------------^[[0m] 50% + 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 + + ^[[0;1;31mFAILED^[[0m scph5500.bin (HTTP 404) +^[[0;32m ok^[[0m gba_bios.bin + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mB/Esc^[[0m Stop^[[0;2m ? Help^[[0m +=== 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: ^[[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 --restore + /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 + Same choice without questions: + python3 install.py --platform retroarch --dest + /home/user/.config/retroarch/system + Details: /tmp/retrobios-x/install.log + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Exit +=== error +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;31mSomething went wrong^[[0m + + Could not fetch the file list for retroarch after 3 attempts: [Errno -2] + Name or service not known + Address: https://raw.githubusercontent.com/Abdess/retrobios/main/install/ + retroarch.json + +^[[0;7;35m > Choose another setup or folder^[[0m + Quit + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Choose^[[0;2m B/Esc Back^[[0m +=== quit +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Quit?^[[0m + + Quit without installing? Nothing was written. + +^[[0;7;35m > Quit^[[0m + Stay + + + + + + + + + + + + + + + + + +^[[0;2m ^[[0;1;35mA/Enter^[[0m Quit^[[0;2m B/Esc Stay^[[0m +=== help +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Keys^[[0m + + Up/Down, k/j Move + A/Enter, Start Install + Y, o, Tab Options + B/Esc, Backspace Back + q, Ctrl-C Quit + ?, F1 This help + + BIOS folder: ^[[0;1m/home/user/.config/retroarch/system^[[0m + + + + + + + + + + + + + +^[[0;2m Any key closes this help^[[0m diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index 36f496351..bbcf0fb0e 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -918,6 +918,37 @@ class ColourFollowsNoColorOrg(unittest.TestCase): self.assertFalse(term.live) +class TheEndOfARunPaintsItsStatusWords(unittest.TestCase): + """Line mode ends on the words the full screen paints, in the same + roles, and on the same text without colour.""" + + def _end(self, colour: bool, *counts: int, **given) -> str: + term = install.Terminal(io.StringIO()) + term.colour = colour + out = io.StringIO() + with contextlib.redirect_stdout(out), contextlib.suppress(SystemExit): + install._report_outcome([Path("bios")], *counts, term=term, **given) + return out.getvalue() + + def test_the_same_words_with_and_without_colour(self): + for counts, given, painted in ( + ((3, 1, 1, 0, False), {"failures": [("a.bin", "HTTP 404")]}, + (("failure", "Finished with errors"), ("success", "3 files installed"), + ("failure", "1 file failed"), ("failure", "failed:"))), + ((2, 1, 0, 0, False), {}, + (("success", "Done"), ("success", "2 files installed"), + ("success", "1 file already up to date"))), + ((1, 0, 0, 0, False), {"offline": 2}, + (("warning", "Finished with files missing"), ("warning", "2 not fetched")))): + with self.subTest(counts=counts): + plain, coloured = self._end(False, *counts, **given), \ + self._end(True, *counts, **given) + self.assertNotIn("\x1b", plain) + self.assertEqual(install.unpainted(coloured), plain) + for role, word in painted: + self.assertIn(install._sgr(role) + word, coloured) + + class SmallWordings(unittest.TestCase): def test_check_asks_which_to_check(self): """--check with several platforms asked "Install for:", and diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index e9ff1c2bf..caf6f68fb 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -951,6 +951,29 @@ def golden_states() -> dict: return states +def result_states() -> dict: + """The result of a run that installed everything, of one with a file + failed, of one --offline left files out of, and of one stopped.""" + def ended(code: int, **summary) -> install.TuiState: + state = checked_state() + state.stack.append("result") + state.code = code + state.download = {"event": "download", "files": 4, "bytes": 1064964} + state.summary = {"event": "summary", "mode": "install", "installed": 4, + "up_to_date": 1, "pending": 0, "failed": [], "skipped": [], + "left_out": [], "kept": [], "interrupted": False, **summary} + return state + + return { + "done": ended(0), + "partial": golden_states()["result"], + "offline": ended(1, installed=1, pending=3, failed=[ + {"dest": name, "reason": install.OFFLINE_WORDS} + for name in ("scph5500.bin", "scph5501.bin", "pcsx/extra.bin")]), + "stopped": ended(130, installed=2, interrupted=True), + } + + class Views(unittest.TestCase): """Every screen as text, at the sizes a handheld and a desktop show.""" @@ -1008,6 +1031,78 @@ class Views(unittest.TestCase): with self.subTest(route=route): self.assertEqual("".join(sections), path.read_text(encoding="utf-8")) + def test_colour_goldens(self): + """Every screen as the terminal gets it with colour, the escape + codes kept (ESC written ^[), one file per size.""" + update = os.environ.get("RETROBIOS_UPDATE_GOLDENS") == "1" + for cols, rows in SIZES: + sections = [] + for name, state in golden_states().items(): + state.cols, state.rows = cols, rows + painted = [install.tui_paint(str(line), line.style, True) + for line in install.tui_view(state, cols, rows)] + sections.append(f"=== {name}\n" + "\n".join(painted).replace("\x1b", "^[") + + "\n") + path = GOLDEN / f"colour-{cols}x{rows}.txt" + if update: + path.write_text("".join(sections), encoding="utf-8") + with self.subTest(size=f"{cols}x{rows}"): + self.assertEqual("".join(sections), path.read_text(encoding="utf-8")) + + def test_colour_adds_codes_and_never_text(self): + """With colour off a line is drawn as before, its style alone, and + with colour on, stripped of its codes, it is the same text: the + golden screen, character for character.""" + for cols, rows in SIZES: + colour = (GOLDEN / f"colour-{cols}x{rows}.txt").read_text(encoding="utf-8") + screens = dict(re.findall(r"=== (\S+)\n(.*?)(?=\n=== |\n\Z)", colour, re.S)) + for name, state in golden_states().items(): + with self.subTest(screen=name, size=f"{cols}x{rows}"): + state.cols, state.rows = cols, rows + lines = install.tui_view(state, cols, rows) + for line in lines: + self.assertEqual(install.tui_paint(str(line), line.style, False), + 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") + self.assertEqual("\n".join(stripped) + "\n", golden) + + def test_each_role_paints_a_status_word_on_the_result_screens(self): + """Done, a part done, files left offline, a stop: between them the + result screens use every role, each on a word that says it.""" + drawn = "\n".join(install.tui_paint(str(line), line.style, True) + for state in result_states().values() + for line in install.tui_view(state, 80, 24)) + 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", "/home/user/.config/retroarch/system")): + with self.subTest(role=role, word=word): + self.assertIn(install._sgr(role) + word, drawn) + # "2 of 4 installed" is no count of files installed. + self.assertNotIn(install._sgr("strong+success") + "4 installed", drawn) + + def test_the_status_words_take_their_role_and_a_zero_none(self): + spans = install.status_spans("1 in place, 0 missing, 3 missing, 1 wrong copy, 2 failed") + self.assertEqual([("1 in place", "success"), ("3 missing", "warning"), + ("1 wrong copy", "warning"), ("2 failed", "failure")], + [("1 in place, 0 missing, 3 missing, 1 wrong copy, 2 failed"[a:b], r) + for a, b, r in spans]) + self.assertEqual(install.status_spans("0 wrong copies, 0 missing"), []) + self.assertEqual(install.status_spans("python3 install.py --platform retroarch"), []) + + def test_the_bar_paints_a_key_only(self): + """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") + 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) + def test_the_hint_bar_names_the_primary_action_first(self): for name, state in golden_states().items(): with self.subTest(screen=name): @@ -3668,7 +3763,8 @@ class LineModeOffline(unittest.TestCase): term.wait("q) quit") term.send("\r") code = term.close() - out = term.raw.decode("utf-8", "replace") + # Read as words: a terminal gets the status words painted. + out = install.unpainted(term.raw.decode("utf-8", "replace")) self.assertIn("--offline: only the files found on this machine are installed, and no " "--from names a folder to copy them from.", out) # tui_audit_7 P3-4: nothing to copy from, nothing to install. @@ -4988,52 +5084,77 @@ class EndToEnd(unittest.TestCase): def test_no_color_draws_no_colour(self): """The screen marks the row in focus with > as well as reverse video, - and draws no colour at all: NO_COLOR has nothing left to remove.""" + so NO_COLOR, or a terminal of fewer than 8 colours, takes the colour + away and leaves the same screen.""" self.repo() - self.env["NO_COLOR"] = "1" - term = self.start() - screen = term.wait("Where do you play?") - self.assertIn("> RetroArch", screen) - term.send("q") - self.assertEqual(term.close(), 130) - for params in re.findall(rb"\x1b\[([0-9;]*)m", term.raw): - codes = {int(code) for code in params.split(b";") if code} - self.assertFalse(codes & {*range(30, 50), *range(90, 108)}, params) + screens = {} + for name, env in (("colour", {}), ("no_color", {"NO_COLOR": "1"}), + ("mono", {"TERM": "rbtest-mono", + "TERMINFO": str(_terminfo_folder(self))})): + with self.subTest(env=name): + self.env.pop("NO_COLOR", None) + self.env.update({"TERM": "xterm", **env}) + term = self.start() + screen = term.wait("Where do you play?") + self.assertIn("> RetroArch", screen) + screens[name] = screen + # pyte reads the colours drawn: RetroBIOS in the accent. + title = term.screen.buffer[0] + 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) + 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} & { + *range(30, 50), *range(90, 108)} + self.assertEqual(bool(colours), name == "colour", colours) + self.assertEqual(screens["colour"], screens["no_color"]) + self.assertEqual(screens["colour"], screens["mono"]) -def _terminfo_entry(name: str, cup: str | None, extended: bool = False) -> bytes: +def _terminfo_entry(name: str, cup: str | None, extended: bool = False, + colours: int | None = 8) -> bytes: """A compiled term(5) entry holding the strings up to cursor_address, - only that one set.""" + only that one set, and the numbers up to max_colors when colours is + given, columns alone when it is not.""" def short(value: int) -> bytes: return value.to_bytes(2, "little", signed=True) + width = 4 if extended else 2 names = name.encode() + b"|test terminal\0" table = (cup.encode("latin-1") + b"\0") if cup is not None else b"" offsets = [-1] * (install._TERMINFO_CUP + 1) if cup is not None: offsets[install._TERMINFO_CUP] = 0 + numbers = [80] + ([-1] * (install._TERMINFO_COLORS - 1) + [colours] + if colours is not None else []) body = names + (b"\0" if len(names) % 2 else b"") - # One number, columns, in the width of the format. - body += (80).to_bytes(4 if extended else 2, "little") + body += b"".join(number.to_bytes(width, "little", signed=True) for number in numbers) body += b"".join(short(offset) for offset in offsets) + table header = b"".join(short(value) for value in ( - 0o1036 if extended else 0o432, len(names), 0, 1, len(offsets), len(table))) + 0o1036 if extended else 0o432, len(names), 0, len(numbers), len(offsets), + len(table))) return header + body def _terminfo_folder(test: unittest.TestCase) -> Path: - """A $TERMINFO with a glass terminal, a VT52 and two ANSI terminals, - one in the extended format.""" + """A $TERMINFO with a glass terminal, a VT52, two ANSI terminals of 8 + colours, one in the extended format, and two ANSI terminals without + colour: one naming 2, one naming none (vt100).""" (REPO_ROOT / "tmp").mkdir(exist_ok=True) folder = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) test.addCleanup(shutil.rmtree, folder, True) - for name, cup, extended in ( - ("rbtest-glass", None, False), - ("rbtest-vt52", "\x1bY%p1%' '%+%c%p2%' '%+%c", False), - ("rbtest-ansi", "\x1b[%i%p1%d;%p2%dH", False), - ("rbtest-ansi2", "\x1b[%i%p1%d;%p2%dH", True)): + ansi = "\x1b[%i%p1%d;%p2%dH" + for name, cup, extended, colours in ( + ("rbtest-glass", None, False, None), + ("rbtest-vt52", "\x1bY%p1%' '%+%c%p2%' '%+%c", False, None), + ("rbtest-ansi", ansi, False, 8), + ("rbtest-ansi2", ansi, True, 256), + ("rbtest-two", ansi, True, 2), + ("rbtest-mono", ansi, False, None)): (folder / "r").mkdir(exist_ok=True) - (folder / "r" / name).write_bytes(_terminfo_entry(name, cup, extended)) + (folder / "r" / name).write_bytes(_terminfo_entry(name, cup, extended, colours)) return folder @@ -5102,6 +5223,55 @@ class TerminalsTheScreenCannotDraw(unittest.TestCase): self.assertEqual((shown.live, shown.colour), (live, live)) self.assertEqual(shown.ok("ok") == "ok", not live) + def test_the_colours_are_read(self): + self.assertEqual(install.terminal_colours("rbtest-ansi", self.env), 8) + self.assertEqual(install.terminal_colours("rbtest-ansi2", self.env), 256) + self.assertEqual(install.terminal_colours("rbtest-two", self.env), 2) + self.assertEqual(install.terminal_colours("rbtest-mono", self.env), 0) + self.assertIsNone(install.terminal_colours("rbtest-none", self.env)) + for data in (b"", b"\x00" * 12, _terminfo_entry("x", None, colours=8)[:40]): + with self.subTest(data=data): + self.assertIsNone(install.terminfo_number(data, install._TERMINFO_COLORS)) + + def test_fewer_than_8_colours_draw_the_line_live_and_plain(self): + """A vt100 moves the cursor the ANSI way and has no colour: the + progress line still redraws, its words unpainted.""" + for term, colour in (("rbtest-ansi", True), ("rbtest-ansi2", True), + ("rbtest-two", False), ("rbtest-mono", False), + ("rbtest-none", True)): + with self.subTest(term=term), mock.patch.dict( + os.environ, {**self.env, "TERM": term}), \ + mock.patch.object(install, "_enable_ansi", return_value=True): + os.environ.pop("NO_COLOR", None) + shown = install.Terminal(self.tty) + self.assertEqual((shown.live, shown.colour), (True, colour)) + self.assertEqual(shown.status("1 failed") != "1 failed", colour) + + def test_the_screen_draws_without_colour_for_each_reason(self): + """NO_COLOR, TERM dumb or unset, fewer than 8 colours, an output no + terminal: each turns the colour off, and says which.""" + for env, said in (({"TERM": "rbtest-ansi"}, ""), + ({"TERM": "rbtest-none"}, ""), + ({"TERM": "rbtest-ansi", "NO_COLOR": "1"}, "NO_COLOR is set"), + ({"TERM": "rbtest-ansi", "NO_COLOR": ""}, ""), + ({"TERM": "dumb"}, "TERM is dumb or not set"), + ({}, "TERM is dumb or not set"), + ({"TERM": "rbtest-two"}, + "TERM=rbtest-two names 2 colours, fewer than 8"), + ({"TERM": "rbtest-mono"}, + "TERM=rbtest-mono names 0 colours, fewer than 8")): + with self.subTest(env=env): + self.assertEqual(install.colour_blocker({**self.env, **env}, self.tty), said) + self.assertEqual(install.colour_blocker({**self.env, "TERM": "rbtest-ansi"}, + io.StringIO()), + "the output is not a terminal") + with mock.patch.dict(os.environ, {**self.env, "TERM": "rbtest-mono"}), \ + mock.patch.object(install, "_enable_ansi", return_value=True): + self.assertFalse(install._TuiTerminal(self.tty, self.tty).colour) + os.environ["TERM"] = "rbtest-ansi" + os.environ.pop("NO_COLOR", None) + self.assertTrue(install._TuiTerminal(self.tty, self.tty).colour) + def test_an_unset_term_is_a_dumb_one(self): with mock.patch.dict(os.environ, {"FORCE_COLOR": ""}), \ mock.patch.object(install, "_enable_ansi", return_value=True): diff --git a/wiki/installer.md b/wiki/installer.md index f5ac09f70..59f70c1d4 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -220,6 +220,15 @@ given on the command line fill the screens in advance and are kept as they are. menu shows it the same way, Enter keeping what `--system` named and `c` choosing anew. +Colour is added to words, never put in their place. RetroBIOS in the title, the row in focus +and the key the bottom line names first are magenta, files installed or in place +green, files missing or left out yellow, failures and a refused folder red, and +secondary text dim. Only the 16 basic colours are used, the ones a handheld's +console has. `NO_COLOR` set to any value but an empty one turns colour off, as do +`TERM=dumb` or no `TERM`, a terminal whose description names fewer than 8 colours +and an output that is not a terminal. The text stays the same. The line-by-line +questions paint the end of a run the same way. + However the screen ends (Enter on the result, `q`, Ctrl+C, Ctrl+D, SIGTERM, a hangup), the terminal is given back as the screen found it: the cursor shown, the main screen back, echo and line editing on. Ctrl+Z, or SIGTSTP from @@ -1584,7 +1593,7 @@ RetroArch folder that holds only `scph5501.bin`, in the order written: | `RETROBIOS_INSTALL_URL` | Where the bootstrap fetches `install.py`. HTTPS only | | `RETROBIOS_INSTALL_SHA256` | The SHA-256 the bootstrap requires of that installer, 64 hex characters. When either of these two is set and the fingerprint does not match, the error names the variable instead of suggesting a retry | | `RETROBIOS_BOOTSTRAP` | Set by `install.sh` (`sh`) and `install.ps1` (`ps1`) for the temporary installer they run, so that its hints give back the one-line command rather than `python3 install.py` | -| `NO_COLOR` | Plain text, no colour, even at a terminal, when set to any value but an empty one. `TERM=dumb`, or no `TERM` outside Windows, also turns the live progress line and the colour off, as does a terminal whose description moves no cursor the ANSI way (`TERM=vt52`). The full screen draws no colour at all: the row in focus is marked with `>` as well as reverse video, and every state is written in words | +| `NO_COLOR` | Plain text, no colour, even at a terminal, when set to any value but an empty one. `TERM=dumb`, or no `TERM` outside Windows, also turns the live progress line and the colour off, as does a terminal whose description moves no cursor the ANSI way (`TERM=vt52`). A description naming fewer than 8 colours keeps the live line and drops the colour. The full screen then draws the same text without colour: the row in focus is marked with `>` as well as reverse video, and every state is written in words | | `TERM`, `TERMINFO`, `TERMINFO_DIRS` | Decide whether the full screen opens. `TERM` unset or `dumb` (outside Windows) gives the questions line by line. Otherwise the terminal's description is read where ncurses reads it: `TERMINFO`, then `~/.terminfo`, then each folder of `TERMINFO_DIRS`, then the system's own. A description whose cursor moves are not the ANSI ones the screen writes (`TERM=vt52`) gives the questions too, and `--tui` there stops with the reason | | `FORCE_COLOR`, `CLICOLOR_FORCE` | Colour even when the output is a pipe or a file, set to any value but `0` or an empty one. `NO_COLOR` wins over both, and the progress line stays one line per file | | `SSL_CERT_FILE`, `SSL_CERT_DIR` | The trusted certificates to check servers with, for a Python that finds none of its own |