chore: merge pad-aware interface hints

This commit is contained in:
Abdessamad Derraz committed 2026-10-23 19:43:54 +02:00
commit a8fef3fbbd
10 files changed
+6462 -54

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@
$ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "ea2777500114b796bb4a23ea9e0dd0a15bc4499bb1289e6e426943beece48865"
$defaultInstallSha256 = "e2cc5f073a8226fe26b0513ebd25f00de6f3f613d8c9851473c2d30b53356f48"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+274 -43
View File
@@ -9675,6 +9675,9 @@ class TuiState:
def __init__(self, os_type: str = "linux", home: Path | None = None) -> None:
self.os_type = os_type
self.home = home or Path("/home/user")
# The buttons the bar names: a pad route (TUI_PAD_ROUTES), "keys"
# for a keyboard alone, "both" where the code cannot tell.
self.pad: str = "both"
self.stack: list = ["setup"]
self.focus: dict = {}
self.filters: dict = {}
@@ -9778,6 +9781,36 @@ def plain_requested(args: argparse.Namespace, env: Mapping[str, str] | None = No
return bool(args.plain) or _env_on(env, "RETROBIOS_PLAIN") or _env_on(env, "ACCESSIBLE")
# Each handheld launcher names the buttons that reach the interface
# (ports/shared/tui.txt exports RB_PAD as this), so the bar names those
# buttons and no others.
TUI_PAD_ENV = "RETROBIOS_PAD"
TUI_PAD_ROUTES = ("gptokeyb", "muxterm", "evmapy", "miyoo")
# Set by Steam for what it starts, and by gamescope: a controller may be
# all there is, mapped by Steam Input to keys nobody here can name.
_STEAM_MARKERS = ("SteamGamepadUI", "SteamDeck", "SteamGameId", "GAMESCOPE_WAYLAND_DISPLAY")
def tui_pad_route(os_type: str, env: Mapping[str, str] | None = None) -> str:
"""The keys the hints name: a launcher's pad route, "keys" at a desktop,
"both" (button and key) where nothing says which one is held."""
env = os.environ if env is None else env
named = env.get(TUI_PAD_ENV, "").strip().lower()
if named:
return named if named in TUI_PAD_ROUTES else "both"
if any(env.get(name) for name in _STEAM_MARKERS):
return "both"
if os_type in ("windows", "darwin", "wsl"):
return "keys"
# A Linux console is where a handheld draws: a desktop or a remote
# shell is a keyboard's.
if os_type == "linux" and env.get("TERM") != "linux" and any(
env.get(name) for name in ("DISPLAY", "WAYLAND_DISPLAY", "SSH_CONNECTION",
"SSH_TTY")):
return "keys"
return "both"
def _terminal_size(stream: TextIO) -> tuple[int, int]:
try:
size = os.get_terminal_size(stream.fileno())
@@ -9939,7 +9972,10 @@ def _filter_row(state: TuiState) -> TuiRow:
return TuiRow("filter", f"Filter: {text}_")
if text:
return TuiRow("filter", f"Filter: {text}")
return TuiRow("filter", "Filter (/ to type)", style="dim")
# A pad has no / and no letters: muxterm's own keyboard types.
how = {"muxterm": "Select shows a keyboard", "gptokeyb": "with a keyboard",
"evmapy": "with a keyboard", "miyoo": "with a keyboard"}.get(state.pad, "/ to type")
return TuiRow("filter", f"Filter ({how})", style="dim")
def _system_titles(state: TuiState) -> dict:
@@ -10696,6 +10732,8 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]:
return _path_key(state, key)
if state.editing:
return _filter_key(state, key)
if state.pad == "miyoo":
key = _miyoo_key(state, key)
char = key.text if key.name == "char" else ""
if key.name == "f1" or char == "?":
state.help = True
@@ -10777,6 +10815,29 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]:
return []
# A Miyoo's L1 and R1 are the keys E and T (codes 18 and 20).
_MIYOO_PAGES = {"e": "pgup", "t": "pgdn"}
def _miyoo_key(state: TuiState, key: TuiKey) -> TuiKey:
"""A Miyoo's buttons as the keys they stand for, outside a text field:
its A is Space, the button that chooses, so it acts as Enter but where
it ticks a system or a core."""
if key.name != "char":
return key
if key.text in _MIYOO_PAGES:
return TuiKey(_MIYOO_PAGES[key.text])
if key.text != " ":
return key
if state.screen in _CHECKLISTS:
rows = tui_rows(state)
row = rows[max(0, min(state.focus.get(state.screen, 0), len(rows) - 1))] \
if rows else None
if row is not None and row.action in ("system", "core"):
return key
return TuiKey("enter")
# Rows Space leaves alone: it is X (tick) on PortMaster and Y on muxterm, a
# key pressed for something else, and these end the run or write.
_SPACE_INERT = ("install", "close", "quit")
@@ -10891,6 +10952,55 @@ _SHORT_KEYS = (("A/Enter ", "A "), ("B/Esc ", "B "), ("X/Space ", "X "), ("Y/o "
("Left/Right ", "<> "), ("Up/Down ", "^v "))
# The key of a hint as each route names it, None for a key it lacks. The
# bar is written in the "both" form ("A/Enter Install"), the route's own
# names replace it: a PC user read "A/Enter", a muOS player was told a
# Start+Select that muxterm does not have.
_ROUTE_KEYS: dict[str, dict[str, str | None]] = {
"keys": {"A/Enter": "Enter", "B/Esc": "Esc", "X/Space": "Space", "Y/o": "o"},
# retrobios.gptk: A Enter, B Esc, X Space, Y Tab, L1 and R1 a page,
# Start Enter, Select Esc, Start+Select the kill.
"gptokeyb": {"A/Enter": "A", "B/Esc": "B", "X/Space": "X", "Y/o": "Y", "?": None,
"/": None, "Ctrl-C": "Start+Select", "Enter": "A", "Esc": "B",
"Ctrl-U": None},
# RetroBIOS.sh.keys: the same buttons, and no combo that ends the run.
"evmapy": {"A/Enter": "A", "B/Esc": "B", "X/Space": "X", "Y/o": "Y", "?": None,
"/": None, "Ctrl-C": None, "Enter": "A", "Esc": "B", "Ctrl-U": None},
# muxterm's own keys: Start Enter, B Backspace, Y Space, Menu closes the
# terminal. A, X, L1 and R1 send nothing, Select shows its keyboard.
"muxterm": {"A/Enter": "Start", "B/Esc": "B", "X/Space": "Y", "Y/o": None, "?": None,
"/": None, "Ctrl-C": "Menu", "Enter": "Start", "Esc": None,
"Ctrl-U": None},
# Buttons that are keys already: A Space, Start Enter, L2 Tab, R2
# Backspace. B is Left Ctrl, a modifier: alone it sends no byte to a
# terminal (the console's keyboard driver only records its state), so
# the interface never sees it, nor X, Y and Select (Shift, Alt, Ctrl).
"miyoo": {"A/Enter": "A", "B/Esc": "R2", "X/Space": "A", "Y/o": "L2", "?": None,
"/": None, "Ctrl-C": None, "Enter": "Start", "Esc": None, "Ctrl-U": None},
}
def _route_hint(state: TuiState, parts: tuple) -> tuple:
"""The bar's entries named as the run's input names them."""
names = _ROUTE_KEYS.get(state.pad)
if names is None or state.help:
return parts
named = []
for part in parts:
key, sep, what = part.partition(" ")
if key in names:
# On a Miyoo A is Space, which ticks in a checklist: Start continues.
mine = ("Start" if state.pad == "miyoo" and key == "A/Enter"
and state.screen in _CHECKLISTS else names[key])
if mine is None:
continue
key = mine
named.append(key + sep + what)
if state.pad == "muxterm" and state.screen == "path":
named.append("Select Keyboard")
return tuple(named)
def _hint(width: int, *parts: str) -> str:
"""The keys that matter now, short names first given up, then whole
entries from the right."""
@@ -11407,6 +11517,17 @@ def _bar(share: float, width: int) -> str:
return f"[{'#' * filled}{'-' * (room - filled)}] {int(share * 100):3d}%"
# What stops an install a second time, and the first time, per route.
_STOP_KEYS = {
"both": ("B or Ctrl-C", "B, Ctrl-C or Start+Select"),
"keys": ("Esc or Ctrl-C", "Esc or Ctrl-C"),
"gptokeyb": ("B", "B or Start+Select"),
"evmapy": ("B", "B"),
"muxterm": ("B", "B"),
"miyoo": ("R2", "R2"),
}
def _view_installing(state: TuiState, width: int, height: int) -> tuple:
lines: list = []
if state.download is None:
@@ -11429,10 +11550,10 @@ def _view_installing(state: TuiState, width: int, height: int) -> tuple:
lines.append(f"{files_done} of {_plural(state.download['files'], 'file')}")
if files_done >= state.download["files"] and state.summary is None:
lines.append("Finishing...")
lines.append(_styled("Stopping, finishing the files in flight. Press B or Ctrl-C "
stops = _STOP_KEYS.get(state.pad, _STOP_KEYS["both"])
lines.append(_styled(f"Stopping, finishing the files in flight. Press {stops[0]} "
"again to stop now." if state.stopping
else "B, Ctrl-C or Start+Select stops. What is done stays done.",
"dim"))
else f"{stops[1]} stops. What is done stays done.", "dim"))
room = height - len(lines) - 1
failed = [item for item in reversed(state.finished) if not item[1]]
good = [item for item in reversed(state.finished) if item[1]]
@@ -11637,21 +11758,68 @@ _TUI_VIEWS: dict[str, Callable[[TuiState, int, int], tuple]] = {
}
# The help's names for each action, per route: the buttons first, then the
# keys a keyboard plugged in also has. None for an action the route lacks.
_HELP_KEYS: dict[str, dict[str, str | None]] = {
"both": {"move": "Up/Down, k/j", "page": "PgUp/PgDn, L1/R1", "ok": "A/Enter, Start",
"tick": "X, Right/Left", "options": "Y, o, Tab", "back": "B/Esc, Backspace",
"stop": "B/Esc, Ctrl-C", "kill": "Start+Select", "enter": "Enter",
"esc": "Esc"},
"keys": {"move": "Up/Down, k/j", "page": "PgUp/PgDn", "ok": "Enter",
"tick": "Space, Right/Left", "options": "o, Tab", "back": "Esc, Backspace",
"stop": "Esc, Ctrl-C", "kill": None, "enter": "Enter", "esc": "Esc"},
"gptokeyb": {"move": "D-pad, Up/Down", "page": "L1/R1, PgUp/PgDn",
"ok": "A, Start, Enter", "tick": "X, Space, Right/Left",
"options": "Y, Tab, o", "back": "B, Select, Esc", "stop": "B, Esc, Ctrl-C",
"kill": "Select, then Start", "enter": "A, Enter", "esc": "B, Esc"},
"evmapy": {"move": "D-pad, Up/Down", "page": "L1/R1, PgUp/PgDn", "ok": "A, Start, Enter",
"tick": "X, Space, Right/Left", "options": "Y, Tab, o",
"back": "B, Select, Esc", "stop": "B, Esc, Ctrl-C", "kill": None,
"enter": "A, Enter", "esc": "B, Esc"},
"muxterm": {"move": "D-pad, Up/Down", "page": "PgUp/PgDn", "ok": "Start, Enter",
"tick": "Y, Space, Right/Left", "options": "o, Tab",
"back": "B, Esc, Backspace", "stop": "B, Esc, Ctrl-C", "kill": None,
"enter": "Start, Enter", "esc": "Esc"},
"miyoo": {"move": "D-pad, Up/Down", "page": "L1/R1, PgUp/PgDn", "ok": "A, Start, Enter",
"tick": "A, Space, Right/Left", "options": "L2, Tab, o",
"back": "R2, Esc, Backspace", "stop": "R2, Esc, Ctrl-C", "kill": None,
"enter": "Start, Enter", "esc": "Esc"},
}
def _route_extras(state: TuiState) -> list[tuple[str, str]]:
"""What a route's own buttons do that no key of the table says."""
if state.pad == "muxterm":
return [("Select", "Keyboard on or off"), ("Menu", "Close the terminal")]
if state.pad == "miyoo":
return [("B", "Sends no key here")]
return []
def _help_lines(state: TuiState) -> list[tuple[str, str]]:
screen = state.screen
names = _HELP_KEYS.get(state.pad, _HELP_KEYS["both"])
# gptokeyb's kill: Start pressed first also chooses the row in focus.
kill = ([(names["kill"], "Quit at once")]
if state.pad == "gptokeyb" else [])
if screen == "installing":
return [("B/Esc, Ctrl-C", "Stop: what is done stays done"),
("Start+Select", "Stop"), ("?, F1", "This help")]
return [(names["stop"], "Stop: what is done stays done"),
*([(names["kill"], "Stop")] if names["kill"] else []),
*_route_extras(state), ("?, F1", "This help")]
if screen == "path":
return [("Enter", "Use this folder"), ("Backspace", "Delete a character"),
("Ctrl-U", "Clear"), ("Esc", "Back"), ("Ctrl-C", "Quit")]
keys = [("Up/Down, k/j", "Move")]
return [(names["enter"], "Use this folder"), ("Backspace", "Delete a character"),
("Ctrl-U", "Clear"), (names["esc"], "Back"), ("Ctrl-C", "Quit"),
*_route_extras(state)]
keys = [(names["move"], "Move")]
if len(tui_rows(state)) > 9 or screen in _PAGERS:
keys += [("PgUp/PgDn, L1/R1", "Page"), ("Home/End, g/G", "First, last")]
keys += [(names["page"], "Page"), ("Home/End, g/G", "First, last")]
primary = _primary(state) or _TUI_VIEWS[screen](state, 80, 24)[2][0]
keys.append(("A/Enter, Start", primary.split(" ", 1)[-1]))
ok = names["ok"]
if state.pad == "miyoo" and screen in _CHECKLISTS:
ok = names["enter"]
keys.append((ok, primary.split(" ", 1)[-1]))
if screen in _CHECKLISTS:
keys.append(("X, Right/Left", "Tick, untick"))
keys.append((names["tick"], "Tick, untick"))
if screen == "systems":
keys.append(("a, n", "All, none"))
if screen in ("platforms", "systems", "cores"):
@@ -11661,9 +11829,10 @@ def _help_lines(state: TuiState) -> list[tuple[str, str]]:
if screen == "options":
keys.append(("Left/Right", "Change a value"))
if screen in ("plan", "systems", "confirm"):
keys.append(("Y, o, Tab", "Options"))
keys.append((names["options"], "Options"))
back = "Quit" if len(state.stack) == 1 else "Back"
keys += [("B/Esc, Backspace", back), ("q, Ctrl-C", "Quit"), ("?, F1", "This help")]
keys += [(names["back"], back), ("q, Ctrl-C", "Quit"), *kill, *_route_extras(state),
("?, F1", "This help")]
return keys
@@ -11705,6 +11874,7 @@ def tui_view(state: TuiState, cols: int, rows: int) -> list[str]:
primary = _primary(state) if not state.help and hint[0].startswith("A/Enter") else None
if primary:
hint = (primary, *hint[1:])
hint = _route_hint(state, hint)
lines = [_styled(fit(f" RetroBIOS: {title}", cols), "title"), _styled("")]
lines += [_styled(fit(" " + line, cols), line.style) for line in body[:height]]
lines += [_styled("")] * (rows - 1 - len(lines))
@@ -11771,6 +11941,7 @@ def tui_start(
) -> tuple[TuiState, list[tuple]]:
"""The first screen, from what is found here and what the command named."""
state = TuiState(os_type=os_type, home=Path.home())
state.pad = tui_pad_route(os_type)
state.cli_systems = list(args.system or [])
state.regions = resolve_regions(args.region) if args.region else []
state.cli_regions = list(state.regions)
@@ -11986,6 +12157,7 @@ class _TuiTerminal:
self.ended = False
self.heard = 0.0
self.frame: list[tuple[str, str]] = []
self.entered = False
def enter(self) -> None:
if os.name != "nt":
@@ -12002,13 +12174,18 @@ class _TuiTerminal:
mode[6][termios.VMIN] = 1
mode[6][termios.VTIME] = 0
termios.tcsetattr(self.fd, termios.TCSANOW, mode)
self.entered = True
self.write(_SCREEN_ON)
def leave(self) -> None:
"""The main screen and the mode found, the mode even when the write
fails: a closed window answers EIO."""
try:
self.write(_SCREEN_OFF)
# Never entered (stopped while it started): the main screen and
# the cursor are as they were, nothing is written over them.
if getattr(self, "entered", True):
self.entered = False
self.write(_SCREEN_OFF)
finally:
if self.saved is not None:
import termios # noqa: PLC0415
@@ -12173,24 +12350,31 @@ def _farewell_lines(state: TuiState) -> list[str]:
def run_tui(args: argparse.Namespace) -> int:
"""The full-screen interface, until it closes: the exit code it ends with."""
os_type = _INVOCATION.os_type or detect_os()
if os_type == "wsl":
_enter_wsl()
# The --from folders are read by each check, not here: indexing a large
# card held the first frame back. Only whether each can be read is asked.
folders = getattr(args, "from") or []
setattr(args, "from", None)
flags = {"interrupt": False, "resized": False, "suspend": False, "resume": False,
"term": 0}
handlers: dict[int, object] = {}
def on_stop(signum: int, _frame: object) -> None:
flags["term"] = flags["term"] or signum
# Before the start, whose detection and pack opening take seconds on a
# handheld: a Start+Select then ended on the default action, without a
# word or the terminal put back.
for name in ("SIGTERM", "SIGHUP"):
number = getattr(signal, name, None)
if number is not None:
handlers[number] = signal.signal(number, on_stop)
try:
_open_local_sources(args)
finally:
setattr(args, "from", folders)
for folder in folders:
problem = from_folder_problem(str(folder))
if problem is not None:
print(f"Error: {folder} {problem[1]}: --from names the folder to copy BIOS "
"files from.", file=sys.stderr)
return 1
state, effects = tui_start(args, os_type)
started = _tui_startup(args)
except BaseException:
_restore_handlers(handlers)
raise
if started is None:
_restore_handlers(handlers)
return 1
state, effects = started
if flags["term"]:
state.exit_code = 128 + flags["term"]
work = Path(tempfile.mkdtemp(prefix=TUI_WORK_PREFIX))
(work / TUI_OWNER).write_text(str(os.getpid()), encoding="utf-8")
_prune_work_folders(work.parent, time.time())
@@ -12198,8 +12382,6 @@ def run_tui(args: argparse.Namespace) -> int:
inbox: queue.Queue = queue.Queue()
current: list[_EngineRun] = []
written: dict[str, Path] = {}
flags = {"interrupt": False, "resized": False, "suspend": False, "resume": False,
"term": 0}
# Start+Select on a handheld is gptokeyb's pkill: SIGTERM, then SIGKILL
# 3 s later. The install gets 2 s to say what it wrote.
terminating = {"signal": 0, "deadline": 0.0}
@@ -12207,7 +12389,6 @@ def run_tui(args: argparse.Namespace) -> int:
terminal = _TuiTerminal(sys.stdin, sys.stdout)
real_out, real_err = sys.stdout, sys.stderr
buffer = io.StringIO()
handlers: dict[int, object] = {}
def perform(todo: list[tuple], from_key: bool = True) -> None:
for effect in todo:
@@ -12251,9 +12432,6 @@ def run_tui(args: argparse.Namespace) -> int:
def on_resize(_signum: int, _frame: object) -> None:
flags["resized"] = True
def on_stop(signum: int, _frame: object) -> None:
flags["term"] = flags["term"] or signum
def on_suspend(_signum: int, _frame: object) -> None:
flags["suspend"] = True
@@ -12262,14 +12440,15 @@ def run_tui(args: argparse.Namespace) -> int:
try:
for name, handler in (("SIGINT", on_interrupt), ("SIGWINCH", on_resize),
("SIGTERM", on_stop), ("SIGHUP", on_stop),
("SIGTSTP", on_suspend), ("SIGCONT", on_resume)):
number = getattr(signal, name, None)
if number is not None:
handlers[number] = signal.signal(number, handler)
sys.stdout = sys.stderr = buffer
terminal.enter()
perform(effects)
if state.exit_code is None:
# Stopped while it started: the screen never opens.
terminal.enter()
perform(effects)
while state.exit_code is None:
if flags["suspend"]:
# Stopped from outside (kill -TSTP): the shell gets its
@@ -12349,12 +12528,64 @@ def run_tui(args: argparse.Namespace) -> int:
try:
for line in lines:
print(line, file=stream)
stream.flush()
except OSError:
# The terminal is gone: the exit code is all that still reaches anyone.
return state.exit_code if state.exit_code is not None else 1
# The terminal is gone (a closed window answers EIO, a closed pipe
# EPIPE): the exit code is all that still reaches anyone, and the
# flush at exit would replace it with 120.
_drop_closed_terminal()
return state.exit_code if state.exit_code is not None else 1
def _tui_startup(args: argparse.Namespace) -> tuple[TuiState, list[tuple]] | None:
"""The local sources opened and the first screen chosen, None when a
--from folder cannot be read (said on stderr)."""
os_type = _INVOCATION.os_type or detect_os()
if os_type == "wsl":
_enter_wsl()
# The --from folders are read by each check, not here: indexing a large
# card held the first frame back. Only whether each can be read is asked.
folders = getattr(args, "from") or []
setattr(args, "from", None)
try:
_open_local_sources(args)
finally:
setattr(args, "from", folders)
for folder in folders:
problem = from_folder_problem(str(folder))
if problem is not None:
print(f"Error: {folder} {problem[1]}: --from names the folder to copy BIOS "
"files from.", file=sys.stderr)
return None
return tui_start(args, os_type)
def _restore_handlers(handlers: dict) -> None:
for number, previous in handlers.items():
signal.signal(number, previous)
def _drop_closed_terminal() -> None:
"""Point stdout and stderr at nothing once the terminal is gone, so what
they still hold is flushed somewhere at exit."""
try:
null = os.open(os.devnull, os.O_WRONLY)
except OSError:
# No null device to point them at: the flush at exit fails as before.
return
try:
for stream in (sys.stdout, sys.stderr):
try:
fd = stream.fileno()
except (ValueError, AttributeError, io.UnsupportedOperation):
# A stream with no descriptor (a test's StringIO) has no
# terminal behind it to lose.
continue
os.dup2(null, fd)
finally:
os.close(null)
def _tui_cleanup(
terminal: _TuiTerminal, runs: list[_EngineRun], handlers: dict, state: TuiState,
) -> list[str]:
+1 -1
View File
@@ -18,7 +18,7 @@ esac
TEMP_INSTALLER=""
TEMP_DIRECTORY=""
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
DEFAULT_INSTALL_SHA256="ea2777500114b796bb4a23ea9e0dd0a15bc4499bb1289e6e426943beece48865"
DEFAULT_INSTALL_SHA256="e2cc5f073a8226fe26b0513ebd25f00de6f3f613d8c9851473c2d30b53356f48"
MAX_INSTALLER_BYTES=2097152
cleanup() {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1134
View File
File diff suppressed because it is too large. Load diff
+1188
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+289 -5
View File
@@ -56,6 +56,8 @@ except ImportError:
GOLDEN = REPO_ROOT / "tests" / "fixtures" / "tui"
SIZES = ((40, 12), (40, 15), (80, 24), (120, 40))
# RETROBIOS_PAD's values, and "keys" for a desktop that sets none.
ROUTES = ("keys", "gptokeyb", "evmapy", "muxterm", "miyoo")
UP, DOWN, RIGHT, LEFT = "\x1b[A", "\x1b[B", "\x1b[C", "\x1b[D"
Key = install.TuiKey
@@ -940,6 +942,31 @@ class Views(unittest.TestCase):
path.write_text(text, encoding="utf-8")
self.assertEqual(text, path.read_text(encoding="utf-8"))
def test_goldens_per_route(self):
"""Each input route at the handheld sizes and 80x24, kept only where a
screen reads differently from the both-names form."""
update = os.environ.get("RETROBIOS_UPDATE_GOLDENS") == "1"
for route in ROUTES:
sections = []
for name, state in golden_states().items():
state.pad = route
for cols, rows in SIZES[:3]:
with self.subTest(route=route, 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.assertLessEqual(install.cell_width(line), cols, line)
self.assertLess(install.cell_width(lines[-1]), cols)
text = "\n".join(line.rstrip() for line in lines) + "\n"
both = (GOLDEN / f"{name}-{cols}x{rows}.txt").read_text(encoding="utf-8")
if text != both:
sections.append(f"=== {name} {cols}x{rows}\n{text}")
path = GOLDEN / f"route-{route}.txt"
if update:
path.write_text("".join(sections), encoding="utf-8")
with self.subTest(route=route):
self.assertEqual("".join(sections), path.read_text(encoding="utf-8"))
def test_the_hint_bar_names_the_primary_action_first(self):
for name, state in golden_states().items():
with self.subTest(screen=name):
@@ -954,6 +981,163 @@ class Views(unittest.TestCase):
self.assertTrue(focused[0].lstrip().startswith("> RetroArch"))
def _bar_keys(line: str) -> set:
"""The key each entry of a hint bar names: its first word."""
return {part.split(" ", 1)[0] for part in line.strip().split(" ") if part}
# The keys each route has, as the bar may name them. "Any" opens "Any key
# closes this help", <> and ^v are the narrow forms of Left/Right, Up/Down.
ROUTE_KEYS = {
"keys": {"Enter", "Esc", "Space", "o", "?", "/", "Ctrl-C", "Ctrl-U"},
"gptokeyb": {"A", "B", "X", "Y", "Start+Select"},
"evmapy": {"A", "B", "X", "Y"},
"muxterm": {"Start", "B", "Y", "Menu", "Select"},
"miyoo": {"A", "Start", "R2", "L2"},
}
EVERY_ROUTE_HAS = {"Left/Right", "Up/Down", "<>", "^v", "Any"}
class PadRoutes(unittest.TestCase):
"""The bar names the buttons the launcher maps, and a desktop's keys."""
def test_the_route_from_the_environment(self):
route = install.tui_pad_route
for value in install.TUI_PAD_ROUTES:
self.assertEqual(route("linux", {"RETROBIOS_PAD": value, "DISPLAY": ":0"}), value)
self.assertEqual(route("linux", {"RETROBIOS_PAD": " MiYoo "}), "miyoo")
self.assertEqual(route("linux", {"RETROBIOS_PAD": "dualshock"}), "both")
for os_type in ("windows", "darwin", "wsl"):
self.assertEqual(route(os_type, {}), "keys", os_type)
for env in ({"DISPLAY": ":0", "TERM": "xterm-256color"}, {"WAYLAND_DISPLAY": "w-0"},
{"SSH_CONNECTION": "10.0.0.2 5 10.0.0.3 22"}, {"SSH_TTY": "/dev/pts/1"}):
self.assertEqual(route("linux", env), "keys", env)
# SteamOS game mode and anything Steam starts: Steam Input may be
# all there is. A console with nothing named, or Android: unknown.
for os_type, env in (("linux", {"SteamGamepadUI": "1", "DISPLAY": ":0"}),
("linux", {"GAMESCOPE_WAYLAND_DISPLAY": "gamescope-0",
"WAYLAND_DISPLAY": "w-0"}),
("windows", {"SteamGameId": "123"}),
("linux", {"SteamDeck": "1", "DISPLAY": ":0"}),
("linux", {"TERM": "linux", "SSH_TTY": "/dev/pts/0"}),
("linux", {"TERM": "linux"}), ("linux", {}),
("android", {"DISPLAY": ":0"})):
self.assertEqual(route(os_type, env), "both", env)
def test_the_launchers_values_are_the_routes(self):
tui = (REPO_ROOT / "ports" / "shared" / "tui.txt").read_text(encoding="utf-8")
named = set(re.findall(r'RB_PAD="(\w+)"', tui))
for launcher in REPO_ROOT.glob("ports/*/*.sh"):
named |= set(re.findall(r'RB_PAD="(\w+)"', launcher.read_text(encoding="utf-8")))
self.assertLessEqual(named, set(install.TUI_PAD_ROUTES))
self.assertEqual(set(ROUTES) - {"keys"}, set(install.TUI_PAD_ROUTES))
def test_the_plan_bar_of_each_route(self):
expected = {
"both": "A/Enter Install B/Esc Back Y/o Options ? Help",
"keys": "Enter Install Esc Back o Options ? Help",
"gptokeyb": "A Install B Back Y Options",
"evmapy": "A Install B Back Y Options",
"muxterm": "Start Install B Back",
"miyoo": "A Install R2 Back L2 Options",
}
for route, bar in expected.items():
with self.subTest(route=route):
state = checked_state()
state.pad = route
self.assertEqual(install.tui_view(state, 80, 24)[-1].strip(), bar)
def test_no_bar_names_a_key_its_route_lacks(self):
for route, have in ROUTE_KEYS.items():
for name, state in golden_states().items():
state.pad = route
for cols, rows in SIZES:
with self.subTest(route=route, screen=name, size=f"{cols}x{rows}"):
state.cols, state.rows = cols, rows
bar = install.tui_view(state, cols, rows)[-1]
self.assertLessEqual(_bar_keys(bar) - EVERY_ROUTE_HAS, have, bar)
def test_the_install_says_what_stops_it_there(self):
expected = {
"both": "B, Ctrl-C or Start+Select stops.", "keys": "Esc or Ctrl-C stops.",
"gptokeyb": "B or Start+Select stops.", "evmapy": "B stops.",
"muxterm": "B stops.", "miyoo": "R2 stops.",
}
for route, words in expected.items():
with self.subTest(route=route):
state = golden_states()["installing"]
state.pad = route
text = "\n".join(install.tui_view(state, 80, 24))
self.assertIn(f"{words} What is done stays done.", text)
state.stopping = True
again = {"both": "B or Ctrl-C", "keys": "Esc or Ctrl-C", "miyoo": "R2"}
self.assertIn(f"Press {again.get(route, 'B')} again to stop now.",
" ".join(" ".join(install.tui_view(state, 80, 24)).split()))
def test_the_help_lists_the_buttons_and_the_keys(self):
def help_text(route: str, screen: str = "plan") -> str:
state = golden_states()[screen]
state.pad, state.help = route, True
return "\n".join(line.rstrip() for line in install.tui_view(state, 80, 24))
gptk = help_text("gptokeyb")
for words in ("D-pad, Up/Down", "A, Start, Enter", "B, Select, Esc", "Y, Tab, o",
"Select, then Start", "Quit at once", "q, Ctrl-C"):
self.assertIn(words, gptk)
self.assertNotIn("Start+Select", help_text("evmapy"))
keys = help_text("keys")
for words in ("Enter", "Esc, Backspace", "o, Tab"):
self.assertIn(words, keys)
for button in ("A/Enter", "B/Esc", "L1", "Start", "Y,"):
self.assertNotIn(button, keys)
muxterm = help_text("muxterm")
for words in ("Start, Enter", "B, Esc, Backspace", "Keyboard on or off",
"Close the terminal"):
self.assertIn(words, muxterm)
self.assertNotIn("Start+Select", muxterm)
miyoo = help_text("miyoo", "systems")
for words in ("Start, Enter", "A, Space, Right/Left", "R2, Esc, Backspace",
"L2, Tab, o", "Sends no key here"):
self.assertIn(words, miyoo)
# The both-names form is the one it was.
self.assertEqual(help_text("both"), (GOLDEN / "help-80x24.txt").read_text().rstrip("\n"))
def test_a_miyoo_a_chooses_and_ticks(self):
"""A is Space on a Miyoo: it chooses where Enter does, ticks where X
would, and L1 and R1 (E and T) page."""
state = checked_state()
state.pad = "miyoo"
self.assertEqual(press(state, " "), [("run", "install")])
state = checked_state()
state.pad = "miyoo"
state.stack.append("systems")
rows = install.tui_rows(state)
first = next(i for i, row in enumerate(rows) if row.action == "system")
state.focus["systems"] = first
before = set(state.ticked)
press(state, " ")
self.assertNotEqual(state.ticked, before)
state.focus["systems"] = next(i for i, row in enumerate(rows) if row.action == "all")
press(state, " ")
self.assertEqual(state.ticked, {row["system"] for row in state.systems})
state.focus["systems"] = first
press(state, "t")
self.assertGreater(state.focus["systems"], first)
press(state, "e")
self.assertEqual(state.focus["systems"], 0)
# In a text field the letters are letters.
state.editing = "systems"
press(state, "t")
self.assertEqual(state.filters["systems"], "t")
def test_space_stays_x_on_the_other_routes(self):
for route in ("both", "keys", "gptokeyb", "evmapy", "muxterm"):
with self.subTest(route=route):
state = checked_state()
state.pad = route
self.assertEqual(press(state, " "), [("run", "confirm")])
class Rendering(unittest.TestCase):
def test_only_changed_lines_are_written(self):
out = io.StringIO()
@@ -1234,8 +1418,13 @@ class CleanupSurvivesABrokenTerminal(unittest.TestCase):
self.addCleanup(os.close, slave)
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0))
before = termios.tcgetattr(slave)
handlers = {n: signal.getsignal(n) for n in (signal.SIGINT, signal.SIGTERM)}
tty = self.Tty(slave)
handlers = {n: signal.getsignal(n) for n in (signal.SIGINT, signal.SIGTERM,
signal.SIGHUP)}
# Keys come in on the terminal itself, the screen goes out on a
# descriptor of its own, as fd 0 and fd 1 are.
tty, screen_fd = self.Tty(slave), os.dup(slave)
self.addCleanup(os.close, screen_fd)
out = self.Tty(screen_fd)
real_draw = install._TuiTerminal.draw
def breaking_draw(terminal, lines, cols):
@@ -1251,11 +1440,14 @@ class CleanupSurvivesABrokenTerminal(unittest.TestCase):
mock.patch.object(install, "tui_start", return_value=(fresh_state(), [])), \
mock.patch.object(install, "_open_local_sources"), \
mock.patch.object(install, "detect_os", return_value="linux"), \
mock.patch.object(sys, "stdin", tty), mock.patch.object(sys, "stdout", tty), \
mock.patch.object(sys, "stderr", tty):
mock.patch.object(sys, "stdin", tty), mock.patch.object(sys, "stdout", out), \
mock.patch.object(sys, "stderr", out):
code = install.run_tui(_args())
self.assertIs(sys.stdout, tty)
self.assertIs(sys.stdout, out)
self.assertEqual(code, 1)
# The farewell found no terminal: what still flushes at exit goes to
# the null device, and the exit code stays the one returned.
self.assertEqual(os.fstat(screen_fd).st_rdev, os.stat(os.devnull).st_rdev)
# The crash keeps its log, marked kept, and nothing else of the run.
self.assertEqual(sorted(path.name for path in work.iterdir()),
["error.log", "kept", "owner"])
@@ -1608,6 +1800,50 @@ class WorkFolders(unittest.TestCase):
work, out = self.run_to(130)
self.assertFalse(work.exists())
def test_a_stop_while_it_starts_ends_on_the_farewell(self):
"""Start+Select during the detection: the signal is the interface's,
never the default action that ended Python without a word."""
import signal
for number in (signal.SIGTERM, signal.SIGHUP):
with self.subTest(signal=number):
heard = []
def recorder(signum, _frame, heard=heard):
heard.append(signum)
previous = signal.signal(number, recorder)
self.addCleanup(signal.signal, number, previous)
interrupt = signal.getsignal(signal.SIGINT)
def starting(*_args, **_kwargs):
os.kill(os.getpid(), number)
return fresh_state(), [("fetch", "retroarch")]
entered = []
WorkFolders.Terminal.state_to = lambda _t: None
out = io.StringIO()
work = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
self.addCleanup(shutil.rmtree, work, True)
with mock.patch.object(install, "_TuiTerminal", self.Terminal), \
mock.patch.object(install.tempfile, "mkdtemp", return_value=str(work)), \
mock.patch.object(self.Terminal, "enter",
lambda _t: entered.append(True)), \
mock.patch.object(install, "tui_start", side_effect=starting), \
mock.patch.object(install, "_open_local_sources"), \
mock.patch.object(install, "_tui_fetch") as fetch, \
mock.patch.object(install, "detect_os", return_value="linux"), \
mock.patch.object(sys, "stdout", out), \
mock.patch.object(sys, "stderr", out):
code = install.run_tui(_args())
self.assertEqual(code, 128 + number)
self.assertEqual(heard, [])
self.assertEqual(entered, [])
fetch.assert_not_called()
self.assertEqual(out.getvalue(), "RetroBIOS: stopped. Nothing was installed.\n")
self.assertIs(signal.getsignal(number), recorder)
self.assertIs(signal.getsignal(signal.SIGINT), interrupt)
def found(dest: str, folder: str, reason: str = "") -> dict:
return {"event": "found", "platform": "retroarch", "dest": dest, "folder": folder,
@@ -2402,6 +2638,11 @@ class EndToEnd(unittest.TestCase):
self.env = {**repo.env(), "HOME": str(home), "TERM": "xterm",
"RETROBIOS_OS": "linux", "XDG_CONFIG_HOME": str(home / ".config")}
self.env.pop("NO_COLOR", None)
# Whatever the shell running the tests has, the input is not known:
# the bar names both the button and the key. Each test names its own.
for name in ("DISPLAY", "WAYLAND_DISPLAY", "SSH_CONNECTION", "SSH_TTY",
install.TUI_PAD_ENV, *install._STEAM_MARKERS):
self.env.pop(name, None)
return repo
def start(self, *args: str, cols: int = 80, rows: int = 24) -> Pty:
@@ -2592,6 +2833,49 @@ class EndToEnd(unittest.TestCase):
term.send("q")
self.assertEqual(term.close(), 130)
def test_closing_the_window_ends_with_its_signal(self):
"""The terminal gone, the farewell has nowhere to go: the code is
129 (SIGHUP), not 120 from the flush failing at exit."""
self.repo()
for screen_name, keys_first in (("Where do you play?", ()),
("files to download", ("\r",))):
with self.subTest(screen=screen_name):
term = self.start()
term.wait("Where do you play?")
term.send(*keys_first)
term.wait(screen_name)
os.close(term.fd)
end = time.monotonic() + 20
status = None
while time.monotonic() < end:
pid, status = os.waitpid(term.pid, os.WNOHANG)
if pid:
break
time.sleep(0.05)
else:
os.kill(term.pid, 9)
self.fail("still running once its window closed")
term.status = os.WEXITSTATUS(status)
self.assertTrue(os.WIFEXITED(status))
self.assertEqual(term.status, 129)
def test_a_desktop_names_its_keys_and_a_pad_its_buttons(self):
self.repo()
for env, bar in (({"DISPLAY": ":0"}, "Enter Continue Esc Quit ? Help"),
({"RETROBIOS_PAD": "gptokeyb", "DISPLAY": ":0"},
"A Continue B Quit"),
({"RETROBIOS_PAD": "muxterm"}, "Start Continue B Quit")):
with self.subTest(env=env):
self.env.update(env)
term = self.start(cols=40, rows=12)
term.wait("Where do you play?")
screen = term.wait(bar)
self.assertEqual(screen.splitlines()[-1].strip(), bar)
term.send("q")
self.assertEqual(term.close(), 130)
for name in env:
self.env.pop(name)
def test_resize_redraws(self):
self.repo()
term = self.start(cols=40, rows=15)
+11 -4
View File
@@ -169,8 +169,12 @@ install starts.
| Quit | `q`, Ctrl+C | B on the first screen |
The bottom line of each screen names what A does on the row under the cursor,
with both spellings (`A/Enter`, `B/Esc`), since A and B swap places between
controller layouts. A narrow screen shortens them to `A`, `B` and `X`. On the
in the names of what is held. On a computer it names the keys (`Enter Install`,
`Esc Back`). Started by a handheld launcher, it names the buttons of that
launcher (`A Install`, `B Back`, see `RETROBIOS_PAD` below), and `?` shows
both the buttons and the keys. Where neither is known, in SteamOS game mode or
on a console no launcher named, it gives both spellings (`A/Enter`, `B/Esc`),
and a narrow screen shortens them to `A`, `B` and `X`. On the
regions screen, Left and Right move the region under the cursor up and down the
order of preference. During the download, a stop waits for the files in
progress, and a second one stops at once. Start+Select on a handheld ends the
@@ -1072,9 +1076,12 @@ Each launcher tells the interface which buttons reach it, in
| `gptokeyb` | gptokeyb or gptokeyb2 with `retrobios.gptk`, the table above | the PortMaster port, the Tools entry, the muOS app with PortMaster |
| `muxterm` | muxterm's own keys: D-pad, Start as Enter, B as Backspace, Y as Space | the muOS app without PortMaster |
| `evmapy` | `RetroBIOS.sh.keys`, the same buttons as the table | the Batocera and Knulli port |
| `miyoo` | buttons that are already keys, A as Space and B as Left Ctrl | no launcher yet |
| `miyoo` | buttons that are already keys: A as Space, Start as Enter, L1 and R1 as E and T, L2 as Tab, R2 as Backspace | no launcher yet |
A launcher with no mapper at all (a keyboard) leaves it unset.
A launcher with no mapper at all (a keyboard) leaves it unset. On a Miyoo, B,
X, Y and Select are Left Ctrl, Shift, Alt and Right Ctrl: pressed alone, a
modifier sends nothing to a terminal, so the interface goes back with R2
instead, and the bottom line says so.
**Without Wi-Fi.** On a computer, download the BIOS pack that serves your
system (every part, for a pack in parts): RetroArch's on muOS, ArkOS,