From 326d3b46eefe0a34f33d9a9efb4ff37e92e9c125 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:08:01 +0200 Subject: [PATCH 1/5] chore: install screen test tools in ci --- .github/workflows/validate.yml | 20 ++++++++++++++++++-- pyproject.toml | 5 +++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 7f0ccb19..bc4d29ec 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -213,9 +213,19 @@ jobs: run: python3.9 -m pip install pyyaml "jsonschema[format-nongpl]==4.25.1" # markdown: the site validation test renders a wiki line to prove the - # heading guard's premise, and skipped on every run without it. + # heading guard's premise, and skipped on every run without it. pyte + # reads the installer's full screen back in its end-to-end tests, + # hypothesis fuzzes its key decoder, and vermin holds install.py to 3.8. - name: Install dependencies - run: pip install pyyaml "jsonschema[format-nongpl]==4.26.0" "markdown==3.10.2" + run: pip install pyyaml "jsonschema[format-nongpl]==4.26.0" "markdown==3.10.2" "pyte==0.8.2" "hypothesis==6.170.8" "vermin==1.9.2" + + # The full screen also runs under the 3.8 floor: the end-to-end tests + # start install.py with the interpreter that runs them. + - name: Install the screen tests' dependency for Python 3.8 + run: python3.8 -m pip install "pyte==0.8.2" + + - name: Hold install.py to Python 3.8 + run: vermin --no-tips -t=3.8- --violations install.py # The site is built as deploy-site.yml builds it, from the lockfile. - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 @@ -257,6 +267,12 @@ jobs: RETROBIOS_OFFLINE: "1" run: python -m unittest discover -s tests -t . -v + - name: Run the full-screen interface tests on Python 3.8 + if: ${{ !cancelled() }} + env: + RETROBIOS_OFFLINE: "1" + run: python3.8 -m unittest tests.test_install_tui -v + # deploy-site.yml runs this after the merge, and a dead link or a stray # heading failed it there with nothing in front of it. Run whatever the # suite returned, so one red step does not hide the other. The release diff --git a/pyproject.toml b/pyproject.toml index 3935e6e0..95e70765 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,11 @@ dependencies = [ dev = [ "jsonschema[format-nongpl]>=4.23", "markdown>=3.7", + # The installer's full-screen tests: pyte reads the screen back, + # hypothesis fuzzes the key decoder, vermin holds install.py to 3.8. + "pyte>=0.8.2", + "hypothesis>=6.100", + "vermin>=1.9", ] # The site is built by Astro Starlight from website/package.json and its # lockfile (npm ci --prefix website): no Python package builds it. From c6fc0a3a61f88e3823d9d4bfba39a66679699aa5 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:23:35 +0200 Subject: [PATCH 2/5] feat: add full-screen installer interface A bare install at a terminal of 40x12 or more opens a full screen that a keyboard or a controller mapped to keys drives: setup, plan, install in three presses, systems, regions, cores, game data, folder browser and a dry-run preview one press away. The installer runs itself as a child with --events for every check and install, the file list fetched once and handed over with --manifest. --tui asks for it, --plain, RETROBIOS_PLAIN and ACCESSIBLE keep the line questions. --- install.ps1 | 2 +- install.py | 2129 ++++++++++++++++++- install.sh | 2 +- tests/fixtures/tui/browse-120x40.txt | 40 + tests/fixtures/tui/browse-40x12.txt | 12 + tests/fixtures/tui/browse-40x15.txt | 15 + tests/fixtures/tui/browse-80x24.txt | 24 + tests/fixtures/tui/checking-120x40.txt | 40 + tests/fixtures/tui/checking-40x12.txt | 12 + tests/fixtures/tui/checking-40x15.txt | 15 + tests/fixtures/tui/checking-80x24.txt | 24 + tests/fixtures/tui/command-120x40.txt | 40 + tests/fixtures/tui/command-40x12.txt | 12 + tests/fixtures/tui/command-40x15.txt | 15 + tests/fixtures/tui/command-80x24.txt | 24 + tests/fixtures/tui/confirm-120x40.txt | 40 + tests/fixtures/tui/confirm-40x12.txt | 12 + tests/fixtures/tui/confirm-40x15.txt | 15 + tests/fixtures/tui/confirm-80x24.txt | 24 + tests/fixtures/tui/cores-120x40.txt | 40 + tests/fixtures/tui/cores-40x12.txt | 12 + tests/fixtures/tui/cores-40x15.txt | 15 + tests/fixtures/tui/cores-80x24.txt | 24 + tests/fixtures/tui/create-120x40.txt | 40 + tests/fixtures/tui/create-40x12.txt | 12 + tests/fixtures/tui/create-40x15.txt | 15 + tests/fixtures/tui/create-80x24.txt | 24 + tests/fixtures/tui/error-120x40.txt | 40 + tests/fixtures/tui/error-40x12.txt | 12 + tests/fixtures/tui/error-40x15.txt | 15 + tests/fixtures/tui/error-80x24.txt | 24 + tests/fixtures/tui/folder-120x40.txt | 40 + tests/fixtures/tui/folder-40x12.txt | 12 + tests/fixtures/tui/folder-40x15.txt | 15 + tests/fixtures/tui/folder-80x24.txt | 24 + tests/fixtures/tui/folder-card-120x40.txt | 40 + tests/fixtures/tui/folder-card-40x12.txt | 12 + tests/fixtures/tui/folder-card-40x15.txt | 15 + tests/fixtures/tui/folder-card-80x24.txt | 24 + tests/fixtures/tui/help-120x40.txt | 40 + tests/fixtures/tui/help-40x12.txt | 12 + tests/fixtures/tui/help-40x15.txt | 15 + tests/fixtures/tui/help-80x24.txt | 24 + tests/fixtures/tui/installing-120x40.txt | 40 + tests/fixtures/tui/installing-40x12.txt | 12 + tests/fixtures/tui/installing-40x15.txt | 15 + tests/fixtures/tui/installing-80x24.txt | 24 + tests/fixtures/tui/options-120x40.txt | 40 + tests/fixtures/tui/options-40x12.txt | 12 + tests/fixtures/tui/options-40x15.txt | 15 + tests/fixtures/tui/options-80x24.txt | 24 + tests/fixtures/tui/path-120x40.txt | 40 + tests/fixtures/tui/path-40x12.txt | 12 + tests/fixtures/tui/path-40x15.txt | 15 + tests/fixtures/tui/path-80x24.txt | 24 + tests/fixtures/tui/plan-120x40.txt | 40 + tests/fixtures/tui/plan-40x12.txt | 12 + tests/fixtures/tui/plan-40x15.txt | 15 + tests/fixtures/tui/plan-80x24.txt | 24 + tests/fixtures/tui/plan-in-place-120x40.txt | 40 + tests/fixtures/tui/plan-in-place-40x12.txt | 12 + tests/fixtures/tui/plan-in-place-40x15.txt | 15 + tests/fixtures/tui/plan-in-place-80x24.txt | 24 + tests/fixtures/tui/platforms-120x40.txt | 40 + tests/fixtures/tui/platforms-40x12.txt | 12 + tests/fixtures/tui/platforms-40x15.txt | 15 + tests/fixtures/tui/platforms-80x24.txt | 24 + tests/fixtures/tui/preview-120x40.txt | 40 + tests/fixtures/tui/preview-40x12.txt | 12 + tests/fixtures/tui/preview-40x15.txt | 15 + tests/fixtures/tui/preview-80x24.txt | 24 + tests/fixtures/tui/quit-120x40.txt | 40 + tests/fixtures/tui/quit-40x12.txt | 12 + tests/fixtures/tui/quit-40x15.txt | 15 + tests/fixtures/tui/quit-80x24.txt | 24 + tests/fixtures/tui/regions-120x40.txt | 40 + tests/fixtures/tui/regions-40x12.txt | 12 + tests/fixtures/tui/regions-40x15.txt | 15 + tests/fixtures/tui/regions-80x24.txt | 24 + tests/fixtures/tui/result-120x40.txt | 40 + tests/fixtures/tui/result-40x12.txt | 12 + tests/fixtures/tui/result-40x15.txt | 15 + tests/fixtures/tui/result-80x24.txt | 24 + tests/fixtures/tui/setup-120x40.txt | 40 + tests/fixtures/tui/setup-40x12.txt | 12 + tests/fixtures/tui/setup-40x15.txt | 15 + tests/fixtures/tui/setup-80x24.txt | 24 + tests/fixtures/tui/systems-120x40.txt | 40 + tests/fixtures/tui/systems-40x12.txt | 12 + tests/fixtures/tui/systems-40x15.txt | 15 + tests/fixtures/tui/systems-80x24.txt | 24 + tests/test_install_tui.py | 1276 +++++++++++ wiki/installer.md | 54 + wiki/testing-guide.md | 9 +- 94 files changed, 5468 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/tui/browse-120x40.txt create mode 100644 tests/fixtures/tui/browse-40x12.txt create mode 100644 tests/fixtures/tui/browse-40x15.txt create mode 100644 tests/fixtures/tui/browse-80x24.txt create mode 100644 tests/fixtures/tui/checking-120x40.txt create mode 100644 tests/fixtures/tui/checking-40x12.txt create mode 100644 tests/fixtures/tui/checking-40x15.txt create mode 100644 tests/fixtures/tui/checking-80x24.txt create mode 100644 tests/fixtures/tui/command-120x40.txt create mode 100644 tests/fixtures/tui/command-40x12.txt create mode 100644 tests/fixtures/tui/command-40x15.txt create mode 100644 tests/fixtures/tui/command-80x24.txt create mode 100644 tests/fixtures/tui/confirm-120x40.txt create mode 100644 tests/fixtures/tui/confirm-40x12.txt create mode 100644 tests/fixtures/tui/confirm-40x15.txt create mode 100644 tests/fixtures/tui/confirm-80x24.txt create mode 100644 tests/fixtures/tui/cores-120x40.txt create mode 100644 tests/fixtures/tui/cores-40x12.txt create mode 100644 tests/fixtures/tui/cores-40x15.txt create mode 100644 tests/fixtures/tui/cores-80x24.txt create mode 100644 tests/fixtures/tui/create-120x40.txt create mode 100644 tests/fixtures/tui/create-40x12.txt create mode 100644 tests/fixtures/tui/create-40x15.txt create mode 100644 tests/fixtures/tui/create-80x24.txt create mode 100644 tests/fixtures/tui/error-120x40.txt create mode 100644 tests/fixtures/tui/error-40x12.txt create mode 100644 tests/fixtures/tui/error-40x15.txt create mode 100644 tests/fixtures/tui/error-80x24.txt create mode 100644 tests/fixtures/tui/folder-120x40.txt create mode 100644 tests/fixtures/tui/folder-40x12.txt create mode 100644 tests/fixtures/tui/folder-40x15.txt create mode 100644 tests/fixtures/tui/folder-80x24.txt create mode 100644 tests/fixtures/tui/folder-card-120x40.txt create mode 100644 tests/fixtures/tui/folder-card-40x12.txt create mode 100644 tests/fixtures/tui/folder-card-40x15.txt create mode 100644 tests/fixtures/tui/folder-card-80x24.txt create mode 100644 tests/fixtures/tui/help-120x40.txt create mode 100644 tests/fixtures/tui/help-40x12.txt create mode 100644 tests/fixtures/tui/help-40x15.txt create mode 100644 tests/fixtures/tui/help-80x24.txt create mode 100644 tests/fixtures/tui/installing-120x40.txt create mode 100644 tests/fixtures/tui/installing-40x12.txt create mode 100644 tests/fixtures/tui/installing-40x15.txt create mode 100644 tests/fixtures/tui/installing-80x24.txt create mode 100644 tests/fixtures/tui/options-120x40.txt create mode 100644 tests/fixtures/tui/options-40x12.txt create mode 100644 tests/fixtures/tui/options-40x15.txt create mode 100644 tests/fixtures/tui/options-80x24.txt create mode 100644 tests/fixtures/tui/path-120x40.txt create mode 100644 tests/fixtures/tui/path-40x12.txt create mode 100644 tests/fixtures/tui/path-40x15.txt create mode 100644 tests/fixtures/tui/path-80x24.txt create mode 100644 tests/fixtures/tui/plan-120x40.txt create mode 100644 tests/fixtures/tui/plan-40x12.txt create mode 100644 tests/fixtures/tui/plan-40x15.txt create mode 100644 tests/fixtures/tui/plan-80x24.txt create mode 100644 tests/fixtures/tui/plan-in-place-120x40.txt create mode 100644 tests/fixtures/tui/plan-in-place-40x12.txt create mode 100644 tests/fixtures/tui/plan-in-place-40x15.txt create mode 100644 tests/fixtures/tui/plan-in-place-80x24.txt create mode 100644 tests/fixtures/tui/platforms-120x40.txt create mode 100644 tests/fixtures/tui/platforms-40x12.txt create mode 100644 tests/fixtures/tui/platforms-40x15.txt create mode 100644 tests/fixtures/tui/platforms-80x24.txt create mode 100644 tests/fixtures/tui/preview-120x40.txt create mode 100644 tests/fixtures/tui/preview-40x12.txt create mode 100644 tests/fixtures/tui/preview-40x15.txt create mode 100644 tests/fixtures/tui/preview-80x24.txt create mode 100644 tests/fixtures/tui/quit-120x40.txt create mode 100644 tests/fixtures/tui/quit-40x12.txt create mode 100644 tests/fixtures/tui/quit-40x15.txt create mode 100644 tests/fixtures/tui/quit-80x24.txt create mode 100644 tests/fixtures/tui/regions-120x40.txt create mode 100644 tests/fixtures/tui/regions-40x12.txt create mode 100644 tests/fixtures/tui/regions-40x15.txt create mode 100644 tests/fixtures/tui/regions-80x24.txt create mode 100644 tests/fixtures/tui/result-120x40.txt create mode 100644 tests/fixtures/tui/result-40x12.txt create mode 100644 tests/fixtures/tui/result-40x15.txt create mode 100644 tests/fixtures/tui/result-80x24.txt create mode 100644 tests/fixtures/tui/setup-120x40.txt create mode 100644 tests/fixtures/tui/setup-40x12.txt create mode 100644 tests/fixtures/tui/setup-40x15.txt create mode 100644 tests/fixtures/tui/setup-80x24.txt create mode 100644 tests/fixtures/tui/systems-120x40.txt create mode 100644 tests/fixtures/tui/systems-40x12.txt create mode 100644 tests/fixtures/tui/systems-40x15.txt create mode 100644 tests/fixtures/tui/systems-80x24.txt create mode 100644 tests/test_install_tui.py diff --git a/install.ps1 b/install.ps1 index 2ab5bf18..04f64092 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "851c1d9e8b2b481adf50d1af2c67d1e44d9b50504fdef3d5b6514332ac4457eb" + $defaultInstallSha256 = "532f0b8d96e0dcf62164daa16837dc21918ced45d46c01e84dd60650dcc73256" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 74807803..f207d986 100644 --- a/install.py +++ b/install.py @@ -15,7 +15,9 @@ Usage: from __future__ import annotations import argparse +import codecs import concurrent.futures +import contextlib import errno import hashlib import http.client @@ -23,9 +25,11 @@ import io import json import os import platform +import queue import re import shlex import shutil +import signal import socket import stat import subprocess @@ -34,13 +38,15 @@ import tarfile import tempfile import threading import time +import traceback +import unicodedata import urllib.error import urllib.parse import urllib.request import xml.etree.ElementTree as ET import zipfile import zlib -from collections.abc import Callable, Iterator +from collections.abc import Callable, Iterator, Mapping from pathlib import Path, PurePosixPath from typing import BinaryIO, NamedTuple, TextIO @@ -6816,6 +6822,20 @@ def main() -> None: help="read the file list from FILE (install/.json) instead " "of downloading it", ) + parser.add_argument( + "--tui", + action="store_true", + help="open the full-screen interface, which a keyboard or a controller " + "drives. It opens by itself for an install at a terminal of 40x12 " + "or more", + ) + parser.add_argument( + "--plain", + action="store_true", + help="ask the questions line by line instead of the full-screen " + "interface, as a screen reader reads them (RETROBIOS_PLAIN=1 or " + "ACCESSIBLE=1 do the same)", + ) parser.add_argument( "--version", action="version", @@ -6844,6 +6864,12 @@ def main() -> None: or args.list_systems or args.list_cores or args.list_emulators ): parser.error("--quiet applies to an install, not to --check, --dry-run or a listing") + if args.tui and args.plain: + parser.error("--tui and --plain ask for two different interfaces") + if args.tui: + reason = tui_blocker(args) + if reason: + parser.error(f"--tui cannot open the full-screen interface here: {reason}") if args.restore is not None: # The folder says where each file goes: nothing else applies. alone = ("platform", "dest", "target", "every_board", "check", "list_platforms", @@ -6870,6 +6896,8 @@ def main() -> None: if args.no_game_data and (args.list_platforms or args.list_targets): parser.error("--no-game-data narrows a file list, not --list-platforms " "or --list-targets") + if args.tui or not (plain_requested(args) or tui_blocker(args)): + sys.exit(run_tui(args)) 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. @@ -7752,6 +7780,2105 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: ) +# Full-screen interface +# +# A bare run at a terminal opens it, and --tui asks for it. It decides and the +# installer acts: every check and install is this file run again as a child, +# with --events, the contract the handheld port reads, and the choices made on +# screen as its options. The line questions stay behind --plain, for screen +# readers and for any terminal this cannot drive. Stdlib only: curses is +# missing from Windows CPython and from some Buildroot images. + +TUI_MIN_COLS = 40 +TUI_MIN_ROWS = 12 +# Text stops growing past this width: on a 200-column window a row and its +# size sat a whole screen apart. +TUI_MAX_WIDTH = 100 +# An arrow key arrives as Esc and two more characters, Esc alone as itself: +# past this wait an Esc with nothing after it is the key. +TUI_ESC_WAIT = 0.04 +TUI_FRAME = 0.1 +_PASTE_START = "\x1b[200~" +_PASTE_END = "\x1b[201~" +# Turned on by the interface, so a pasted path arrives as one piece and its +# 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"} + + +class TuiKey(NamedTuple): + """One key press: a name ("up", "enter"), or "char" or "paste" and the text.""" + + name: str + text: str = "" + + +_CSI_KEYS = {"A": "up", "B": "down", "C": "right", "D": "left", "H": "home", "F": "end", + "Z": "shift-tab"} +_TILDE_KEYS = {"1": "home", "7": "home", "4": "end", "8": "end", "5": "pgup", "6": "pgdn", + "3": "delete", "11": "f1"} +_SS3_KEYS = {"A": "up", "B": "down", "C": "right", "D": "left", "H": "home", "F": "end", + "P": "f1", "M": "enter"} +# msvcrt.getwch gives these after a \x00 or \xe0 prefix. +_WINDOWS_KEYS = {"H": "up", "P": "down", "K": "left", "M": "right", "I": "pgup", + "Q": "pgdn", "G": "home", "O": "end", "S": "delete", ";": "f1"} + + +def _plain_key(char: str) -> TuiKey | None: + """The key one character stands for, None for a control nobody bound.""" + named = {"\r": "enter", "\n": "enter", "\x7f": "backspace", "\x08": "backspace", + "\t": "tab", "\x1b": "esc", "\x03": "ctrl-c", "\x04": "ctrl-d"}.get(char) + if named: + return TuiKey(named) + if char < " " or unicodedata.category(char) in ("Cc", "Cs"): + return None + return TuiKey("char", char) + + +def _csi_key(params: str, final: str) -> TuiKey | None: + if final == "~": + if params == "200": + return TuiKey("paste-start") + name = _TILDE_KEYS.get(params.split(";")[0]) + return TuiKey(name) if name else None + name = _CSI_KEYS.get(final) + if name is None: + return None + if params in ("", "1"): + return TuiKey(name) + if params == "1;2" and name in ("up", "down"): + return TuiKey(f"shift-{name}") + return None + + +def _escape(buffer: str) -> tuple[int, TuiKey | None]: + """The sequence at the start of buffer: the characters it takes and its + key (None for one nobody bound), or 0 while more are needed to tell.""" + if len(buffer) < 2: + return 0, None + if buffer[1] == "[": + if buffer[2:3] == "M": + # An X10 mouse report: three bytes of position follow. + return (6, None) if len(buffer) >= 6 else (0, None) + for end in range(2, len(buffer)): + code = ord(buffer[end]) + if 0x40 <= code <= 0x7E: + return end + 1, _csi_key(buffer[2:end], buffer[end]) + if not 0x20 <= code <= 0x3F: + return 1, TuiKey("esc") + return 0, None + if buffer[1] == "O": + if len(buffer) < 3: + return 0, None + name = _SS3_KEYS.get(buffer[2]) + return 3, TuiKey(name) if name else None + return 1, TuiKey("esc") + + +class KeyDecoder: + """Keys from what a terminal sends, however the reads split it. + + Unknown sequences (F13, mouse reports) are dropped, never taken as text. + """ + + def __init__(self) -> None: + self.buffer = "" + self.paste: str | None = None + + def feed(self, text: str) -> list[TuiKey]: + self.buffer += text + return self._drain(final=False) + + def flush(self) -> list[TuiKey]: + """What is left once the wait for the rest of a sequence is over.""" + return self._drain(final=True) + + @property + def waiting(self) -> bool: + return bool(self.buffer) or self.paste is not None + + def _drain(self, final: bool) -> list[TuiKey]: + keys: list[TuiKey] = [] + while self.buffer or (final and self.paste is not None): + if self.paste is not None: + end = self.buffer.find(_PASTE_END) + if end < 0 and not final: + # The end marker may be cut between two reads. + keep = next((n for n in range(len(_PASTE_END) - 1, 0, -1) + if self.buffer.endswith(_PASTE_END[:n])), 0) + cut = len(self.buffer) - keep + self.paste += self.buffer[:cut] + self.buffer = self.buffer[cut:] + break + skip = len(_PASTE_END) if end >= 0 else 0 + end = end if end >= 0 else len(self.buffer) + text = self.paste + self.buffer[:end] + self.buffer = self.buffer[end + skip:] + self.paste = None + text = "".join(char for char in text if char >= " " and char != "\x7f") + if text: + keys.append(TuiKey("paste", text)) + continue + if self.buffer[0] == "\x1b": + taken, key = _escape(self.buffer) + if not taken: + if final: + if self.buffer == "\x1b": + keys.append(TuiKey("esc")) + self.buffer = "" + break + self.buffer = self.buffer[taken:] + if key is not None and key.name == "paste-start": + self.paste = "" + elif key is not None: + keys.append(key) + continue + key = _plain_key(self.buffer[0]) + self.buffer = self.buffer[1:] + if key is not None: + keys.append(key) + return keys + + +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"): + name = _WINDOWS_KEYS.get(read_next()) + return TuiKey(name) if name else None + return _plain_key(first) + + +def _char_width(char: str) -> int: + if unicodedata.combining(char) or unicodedata.category(char) in ("Mn", "Me", "Cf"): + return 0 + return 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1 + + +def cell_width(text: str) -> int: + """Terminal cells the text takes: a CJK character two, an accent none.""" + return sum(_char_width(char) for char in text) + + +def fit(text: str, width: int) -> str: + """The text cut to width cells, never through a character.""" + kept: list[str] = [] + used = 0 + for char in text: + size = _char_width(char) + if used + size > width: + break + kept.append(char) + used += size + return "".join(kept) + + +def middle(text: str, width: int) -> str: + """A long path cut in its middle, its start and its last folders kept.""" + if cell_width(text) <= width: + return text + if width <= 3: + return fit(text, width) + room = width - 3 + tail = fit(text[::-1], room // 2)[::-1] + return fit(text, room - cell_width(tail)) + "..." + tail + + +def _pad(text: str, width: int) -> str: + return text + " " * max(0, width - cell_width(text)) + + +def _columns(left: str, right: str, width: int) -> str: + """left, and right against the edge when both fit, else left alone.""" + room = width - cell_width(right) - 2 + if not right or room < 8: + return fit(left, width) + return _pad(fit(left, room), room) + " " + right + + +def _split_word(word: str, room: int) -> list[str]: + """A word longer than room, cut after a slash where it can be: a path + read /home/us and er/bios when cut anywhere.""" + pieces = [] + while cell_width(word) > room: + head = fit(word, max(room, 1)) + cut = head.rfind("/") + 1 + if cut <= len(head) // 3: + cut = len(head) + pieces.append(word[:cut]) + word = word[cut:] + return pieces + [word] if word else pieces + + +def _wrap(text: str, width: int, indent: str = " ") -> list[str]: + """Lines of at most width cells, the ones after the first indented.""" + if cell_width(text) <= width: + return [text] + lead = text[:len(text) - len(text.lstrip(" "))] + lines: list[str] = [] + line = "" + for word in text.split(): + for piece in _split_word(word, width - len(lead + indent)): + prefix = lead + indent if lines else lead + longer = f"{line} {piece}" if line else piece + if not line or cell_width(prefix + longer) <= width: + line = longer + else: + lines.append(prefix + line) + line = piece + lines.append((lead + indent if lines else lead) + line) + return lines + + +class TuiLine(str): + """A screen line and how it is drawn: "title", "focus", "dim" or plain.""" + + style = "" + + +def _styled(text: str, style: str = "") -> TuiLine: + line = TuiLine(text) + line.style = style + return line + + +def _short_path(path: Path | None, home: Path) -> str: + if path is None: + return "" + text = str(path) + home_text = str(home) + if home_text not in ("", "/", ".") and (text == home_text + or text.startswith(home_text + os.sep)): + return "~" + text[len(home_text):] + return text + + +def _folded(text: str) -> str: + """Text compared without case or accents, as a filter reads it.""" + decomposed = unicodedata.normalize("NFKD", text) + return "".join(char for char in decomposed if not unicodedata.combining(char)).casefold() + + +class TuiState: + """Everything the screens show. Nothing here touches the terminal.""" + + def __init__(self, os_type: str = "linux", home: Path | None = None) -> None: + self.os_type = os_type + self.home = home or Path("/home/user") + self.stack: list = ["setup"] + self.focus: dict = {} + self.filters: dict = {} + self.editing: str = "" + self.help: bool = False + self.status: str = "" + self.cols: int = 80 + self.rows: int = 24 + self.now: float = 0.0 + # (platform, folder, label) for each setup found here. + self.setups: list = [] + self.notes: list = [] + self.platform: str = "" + self.dest: Path | None = None + # A --dest given without --platform: the list asks whose files go there. + self.dest_given: Path | None = None + self.folder_default: Path | None = None + self.path_text: str = "" + self.browse_dir: Path | None = None + self.browse_rows: list = [] + self.manifest: dict | None = None + self.manifest_for: str = "" + self.manifest_path: Path | None = None + self.manifest_given: bool = False + self.error: list = [] + self.error_code: int = 1 + # The child run in progress: check, confirm, preview or install. + self.run: str = "" + self.running: bool = False + self.run_id: int = 0 + self.started: float = 0.0 + self.checked_flags: list = [] + self.check: dict | None = None + self.files: list = [] + self.systems: list = [] + self.facts: dict = {} + self.dry: dict | None = None + self.dry_files: list = [] + self.dry_summary: dict | None = None + # Choices, each one an option of the command line. + self.ticked: set = set() + self.cli_systems: list = [] + self.regions: list = [] + self.cores: list = [] + self.no_game_data: bool = False + self.installed_only: bool = False + self.jobs: int = 8 + self.passthrough: list = [] + # The install. + self.download: dict | None = None + self.progress: dict | None = None + self.first_sample: tuple | None = None + self.finished: list = [] + self.summary: dict | None = None + self.code: int | None = None + self.stopping: bool = False + self.tail: list = [] + self.log: str = "" + self.exit_code: int | None = None + + @property + def screen(self) -> str: + return self.stack[-1] + + +def _env_on(env: Mapping[str, str], name: str) -> bool: + return env.get(name, "") not in ("", "0") + + +def plain_requested(args: argparse.Namespace, env: Mapping[str, str] | None = None) -> bool: + """Whether the line questions were asked for: --plain, RETROBIOS_PLAIN, ACCESSIBLE.""" + env = os.environ if env is None else env + return bool(args.plain) or _env_on(env, "RETROBIOS_PLAIN") or _env_on(env, "ACCESSIBLE") + + +def _terminal_size(stream: TextIO) -> tuple[int, int]: + try: + size = os.get_terminal_size(stream.fileno()) + except (OSError, ValueError, AttributeError, io.UnsupportedOperation): + return 0, 0 + return size.columns, size.lines + + +def tui_blocker( + args: argparse.Namespace, stdin: TextIO | None = None, stdout: TextIO | None = None, + env: Mapping[str, str] | None = None, +) -> str: + """Why the full-screen interface cannot run here, "" when it can.""" + env = os.environ if env is None else env + stdin = stdin or sys.stdin + stdout = stdout or sys.stdout + for flag, given in ( + ("--check", args.check), ("--dry-run", args.dry_run), + ("--list-platforms", args.list_platforms), ("--list-targets", args.list_targets), + ("--list-systems", args.list_systems), ("--list-cores", args.list_cores), + ("--list-emulators", args.list_emulators), ("--events", args.events), + ("--quiet", args.quiet), ("--no-input", args.no_input), + ("--restore", args.restore is not None), ("--yes", args.yes), + ): + if given: + return f"{flag} runs without questions" + for name, stream in (("input", stdin), ("output", stdout)): + isatty = getattr(stream, "isatty", None) + if not (isatty and isatty()): + return f"the {name} is not a terminal" + if os.name != "nt" and env.get("TERM", "") in ("", "dumb"): + return "TERM is dumb or not set" + # A pseudo-terminal nobody sized reports 0x0: script(1) fed from a pipe + # is one, and the line questions run there as they always have. + cols, rows = _terminal_size(stdout) + if cols < TUI_MIN_COLS or rows < TUI_MIN_ROWS: + return (f"the terminal is {cols}x{rows}, smaller than " + f"{TUI_MIN_COLS}x{TUI_MIN_ROWS}") + if os.name == "nt": + try: + import msvcrt # noqa: F401, PLC0415 + except ImportError: + return "this Python has no msvcrt to read keys" + if not _enable_ansi(): + return "this console does not draw escape sequences (Windows 10 or later does)" + return "" + try: + import termios # noqa: PLC0415 + except ImportError: + return "this Python has no termios to read keys" + try: + termios.tcgetattr(stdin.fileno()) + except (termios.error, OSError, ValueError, AttributeError, + io.UnsupportedOperation) as exc: + return f"single key presses cannot be read here ({exc})" + return "" + + +def tui_flags(state: TuiState) -> list[tuple[str, str | None]]: + """The options that carry the choices made on screen, CLI ones included.""" + flags = list(state.passthrough) + needed = {row["system"] for row in state.systems + if row["missing"] + row["wrong_hash"] > 0} + if state.systems and not needed <= state.ticked: + flags += [("--system", name) for name in sorted(state.ticked)] + else: + flags += [("--system", name) for name in state.cli_systems] + flags += [("--core", name) for name in state.cores] + if state.regions: + flags.append(("--region", ",".join(state.regions))) + if state.no_game_data: + flags.append(("--no-game-data", None)) + if state.installed_only and not any(flag == "--cores-dir" for flag, _v in flags): + flags.append(("--installed-cores", None)) + if state.jobs != 8: + flags.append(("--jobs", str(state.jobs))) + return flags + + +_TUI_MODES = { + "check": ["--check", "--events"], + "confirm": ["--dry-run", "--events"], + "preview": ["--dry-run", "--events"], + "install": ["--events", "--yes"], +} + + +def tui_argv(state: TuiState, run: str, python: str, script: str) -> list[str]: + """The child that does what the screen decided.""" + argv = [python, script, "--platform", state.platform, "--dest", str(state.dest)] + if state.manifest_path is not None: + argv += ["--manifest", str(state.manifest_path)] + for flag, value in tui_flags(state): + argv += [flag] if value is None else [flag, value] + return argv + _TUI_MODES[run] + + +def tui_command(state: TuiState) -> str: + """The same choice as one command, in the form the run was started with.""" + hint = Invocation() + hint.os_type = state.os_type + hint.options = ([("--manifest", str(state.manifest_path))] + if state.manifest_given and state.manifest_path else []) + hint.options += tui_flags(state) + return hint.command(state.platform, state.dest) + +class TuiRow(NamedTuple): + """One row a screen lists: what Enter does on it, and what it shows.""" + + action: str + label: str + right: str = "" + value: object = None + style: str = "" + + +# Screens that scroll text instead of listing rows. +_PAGERS = ("preview", "command", "result") +_CHECKLISTS = ("systems", "cores") + + +def _needed(row: dict) -> int: + return row["missing"] + row["wrong_hash"] + + +def _tui_system_label(row: dict) -> str: + name = row.get("name") or row["system"] + return row["system"] if name == row["system"] else f"{row['system']} ({name})" + + +def _matches(text: str, needle: str) -> bool: + return not needle or _folded(needle) in _folded(text) + + +def _filter_row(state: TuiState) -> TuiRow: + text = state.filters.get(state.screen, "") + if state.editing == state.screen: + return TuiRow("filter", f"Filter: {text}_") + if text: + return TuiRow("filter", f"Filter: {text}") + return TuiRow("filter", "Filter (/ to type)", style="dim") + + +def _sorted_systems(state: TuiState) -> list[dict]: + """Systems with something to download first, the ones in place last.""" + return sorted(state.systems, key=lambda row: (_needed(row) == 0, row["system"])) + + +def tui_choices(state: TuiState) -> dict[str, list[str]]: + """What the file list lets one narrow to, read from the list itself.""" + manifest = state.manifest or {} + return available_choices(manifest.get("files", []), manifest.get("omitted_files", [])) + + +def tui_rows(state: TuiState) -> list[TuiRow]: + """The rows of the current screen, in the order they are shown.""" + screen = state.screen + if screen == "setup": + rows = [TuiRow("setup", label, _short_path(path, state.home), value=index) + for index, (_plat, path, label) in enumerate(state.setups)] + return rows + [TuiRow("another", "Another setup or folder")] + if screen == "platforms": + needle = state.filters.get(screen, "") + return [_filter_row(state)] + [ + TuiRow("platform", display_name(plat), value=plat) + for plat in AVAILABLE_PLATFORMS + if _matches(f"{plat} {display_name(plat)}", needle) + ] + if screen == "folder": + rows = [TuiRow("default", "Use this folder")] if state.folder_default else [] + return rows + [TuiRow("browse", "Browse for a folder"), TuiRow("type", "Type a path")] + if screen == "browse": + return list(state.browse_rows) + if screen == "create": + return [TuiRow("create", "Create it and continue"), + TuiRow("other", "Choose another folder")] + if screen == "plan": + return _plan_rows(state) + if screen == "systems": + return _system_rows(state) + if screen == "options": + return _option_rows(state) + if screen == "regions": + ranked = [TuiRow("region", f"[{rank}] {name}", value=name) + for rank, name in enumerate(state.regions, 1)] + return ranked + [TuiRow("region", f"[ ] {name}", value=name) + for name in tui_choices(state)["regions"] + if name not in state.regions] + if screen == "cores": + needle = state.filters.get(screen, "") + return [_filter_row(state)] + [ + TuiRow("core", f"[{'x' if name in state.cores else ' '}] {name}", value=name) + for name in tui_choices(state)["cores"] if _matches(name, needle) + ] + if screen == "confirm": + if state.running or state.dry is None: + return [] + return [TuiRow("install", "Install"), TuiRow("back", "Back")] + if screen == "error": + rows = [TuiRow("retry", "Try again")] if state.run == "check" else [] + return rows + [TuiRow("back", "Choose another setup or folder" if state.run == "check" + else "Back"), + TuiRow("quit", "Quit")] + if screen == "quit": + return [TuiRow("quit", "Quit"), TuiRow("stay", "Stay")] + return [] + + +def _plan_rows(state: TuiState) -> list[TuiRow]: + check = state.check or {} + if not (_needed(check) if check else 0) and not check.get("data_missing"): + return [TuiRow("close", "Close"), TuiRow("options", "Options")] + size = check.get("bytes_needed", 0) + label = f"Install {format_size(size)}" if size >= CONFIRM_FROM_BYTES else "Install" + rows = [TuiRow("install", label)] + if records_selection((state.manifest or {}).get("files", [])) and len(state.systems) > 1: + rows.append(TuiRow("systems", "Choose systems")) + return rows + [TuiRow("options", "Options"), TuiRow("quit", "Quit")] + + +def _system_rows(state: TuiState) -> list[TuiRow]: + needle = state.filters.get("systems", "") + rows = [TuiRow("all", "All"), TuiRow("none", "None"), _filter_row(state)] + for row in _sorted_systems(state): + label = _tui_system_label(row) + if not _matches(label, needle): + continue + box = "[x]" if row["system"] in state.ticked else "[ ]" + need = _needed(row) + right = (f"{_plural(need, 'file')}, {format_size(row['bytes_needed'])}" + if need else "in place") + rows.append(TuiRow("system", f"{box} {label}", right, row["system"], + "" if need else "dim")) + return rows + + +def _yes(value: bool) -> str: + return "yes" if value else "no" + + +def _option_rows(state: TuiState) -> list[TuiRow]: + manifest = state.manifest or {} + choices = tui_choices(state) + rows = [] + if choices["regions"]: + rows.append(TuiRow("regions", "Preferred regions: " + + (", ".join(state.regions) or "any"), "--region")) + if manifest.get("categorized"): + rows.append(TuiRow("game_data", f"Leave out game data: {_yes(state.no_game_data)}", + "--no-game-data")) + found = len(state.facts.get("cores", [])) + if state.platform in CORE_PLATFORMS and "system_cores" in manifest and found: + rows.append(TuiRow("installed", f"Installed cores only: " + f"{_yes(state.installed_only)} ({found} found)", + "--installed-cores")) + if choices["cores"]: + chosen = f"{len(state.cores)} chosen" if state.cores else "all" + rows.append(TuiRow("cores", f"Cores: {chosen}", "--core")) + rows += [ + TuiRow("jobs", f"Parallel downloads: {state.jobs}", "--jobs"), + TuiRow("folder", f"BIOS folder: {_short_path(state.dest, state.home)}", "--dest"), + TuiRow("preview", "Preview the file list", "--dry-run"), + TuiRow("command", "Show the command line"), + ] + return rows + + +def _push(state: TuiState, screen: str, focus: int = 0) -> list[tuple]: + state.stack.append(screen) + state.focus[screen] = focus + return [] + + +def _replace(state: TuiState, screen: str) -> None: + state.stack[-1] = screen + state.focus[screen] = 0 + + +def _back(state: TuiState) -> list[tuple]: + effects = [("kill",)] if state.running else [] + if state.running: + # What the stopped run still reports belongs to no screen. + state.running = False + state.run_id += 1 + if len(state.stack) == 1: + state.stack.append("quit") + state.focus["quit"] = 0 + return effects + state.stack.pop() + return effects + + +def tui_quit(state: TuiState, code: int = 130) -> list[tuple]: + """Leave now: with the install's own code once it ran, else 130.""" + if state.screen == "result": + code = state.code if state.code is not None else 1 + elif state.screen == "error" and code != 130: + code = state.error_code + state.exit_code = code + return [("kill",)] if state.running else [] + + +def tui_interrupt(state: TuiState) -> list[tuple]: + """Ctrl-C: an install stops and says what it did, anything else ends.""" + if state.run == "install" and state.running: + if state.stopping: + return [("kill",)] + state.stopping = True + return [("stop",)] + return tui_quit(state) + + +def _start_run(state: TuiState, run: str) -> list[tuple]: + state.run = run + state.running = True + state.run_id += 1 + state.started = state.now + if run == "check": + state.checked_flags = tui_flags(state) + state.check, state.files, state.systems = None, [], [] + elif run in ("confirm", "preview"): + state.dry, state.dry_files, state.dry_summary = None, [], None + else: + state.download = state.progress = state.first_sample = None + state.finished, state.summary, state.code, state.stopping = [], None, None, False + return [("run", run)] + + +def _start_check(state: TuiState) -> list[tuple]: + """Check the chosen folder, after fetching the file list once.""" + if "plan" in state.stack: + del state.stack[state.stack.index("plan"):] + while state.stack and state.stack[-1] in ("checking", "error", "create"): + state.stack.pop() + state.stack.append("checking") + state.facts, state.ticked = {}, set() + if state.manifest is not None and state.manifest_for == state.platform: + return _start_run(state, "check") + state.manifest = None + state.run, state.run_id = "check", state.run_id + 1 + state.started = state.now + return [("fetch", state.platform)] + + +def _choose(state: TuiState, plat: str, dest: Path) -> list[tuple]: + state.platform, state.dest = plat, dest + if not dest.exists(): + state.facts = {"nearest": _nearest_folder(dest)} + return _push(state, "create") + return _start_check(state) + + +def _open_folder_screen(state: TuiState, default: Path | None) -> list[tuple]: + state.folder_default = default + return _push(state, "folder") + + +def _mounted_cards() -> list[Path]: + """Mount points of removable media, where a card or a share appears. + + A handheld's card is most often prepared on a PC with a card reader: on + Windows every drive but the system one may be that card. + """ + if os.name == "nt": + system = os.environ.get("SystemDrive", "C:").upper() + return [Path(f"{letter}:\\") for letter in "DEFGHIJKLMNOPQRSTUVWXYZ" + if f"{letter}:" != system and os.path.exists(f"{letter}:\\")] + found: list[Path] = [] + for root in (Path("/media"), Path("/run/media"), Path("/mnt"), Path("/Volumes")): + try: + children = sorted(root.iterdir()) + except OSError: + continue + for child in children: + if child.name in ("wsl", "wslg"): + continue + if os.path.ismount(child): + found.append(child) + elif root.name in ("media", "run") or root == Path("/run/media"): + # /media//