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" $ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "ea2777500114b796bb4a23ea9e0dd0a15bc4499bb1289e6e426943beece48865" $defaultInstallSha256 = "e2cc5f073a8226fe26b0513ebd25f00de6f3f613d8c9851473c2d30b53356f48"
$maximumInstallerBytes = 2MB $maximumInstallerBytes = 2MB
$installerExit = 0 $installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } $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: def __init__(self, os_type: str = "linux", home: Path | None = None) -> None:
self.os_type = os_type self.os_type = os_type
self.home = home or Path("/home/user") 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.stack: list = ["setup"]
self.focus: dict = {} self.focus: dict = {}
self.filters: 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") 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]: def _terminal_size(stream: TextIO) -> tuple[int, int]:
try: try:
size = os.get_terminal_size(stream.fileno()) size = os.get_terminal_size(stream.fileno())
@@ -9939,7 +9972,10 @@ def _filter_row(state: TuiState) -> TuiRow:
return TuiRow("filter", f"Filter: {text}_") return TuiRow("filter", f"Filter: {text}_")
if text: if text:
return TuiRow("filter", f"Filter: {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: def _system_titles(state: TuiState) -> dict:
@@ -10696,6 +10732,8 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]:
return _path_key(state, key) return _path_key(state, key)
if state.editing: if state.editing:
return _filter_key(state, key) return _filter_key(state, key)
if state.pad == "miyoo":
key = _miyoo_key(state, key)
char = key.text if key.name == "char" else "" char = key.text if key.name == "char" else ""
if key.name == "f1" or char == "?": if key.name == "f1" or char == "?":
state.help = True state.help = True
@@ -10777,6 +10815,29 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]:
return [] 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 # 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. # key pressed for something else, and these end the run or write.
_SPACE_INERT = ("install", "close", "quit") _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 ")) ("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: def _hint(width: int, *parts: str) -> str:
"""The keys that matter now, short names first given up, then whole """The keys that matter now, short names first given up, then whole
entries from the right.""" entries from the right."""
@@ -11407,6 +11517,17 @@ def _bar(share: float, width: int) -> str:
return f"[{'#' * filled}{'-' * (room - filled)}] {int(share * 100):3d}%" 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: def _view_installing(state: TuiState, width: int, height: int) -> tuple:
lines: list = [] lines: list = []
if state.download is None: 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')}") lines.append(f"{files_done} of {_plural(state.download['files'], 'file')}")
if files_done >= state.download["files"] and state.summary is None: if files_done >= state.download["files"] and state.summary is None:
lines.append("Finishing...") 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 "again to stop now." if state.stopping
else "B, Ctrl-C or Start+Select stops. What is done stays done.", else f"{stops[1]} stops. What is done stays done.", "dim"))
"dim"))
room = height - len(lines) - 1 room = height - len(lines) - 1
failed = [item for item in reversed(state.finished) if not item[1]] failed = [item for item in reversed(state.finished) if not item[1]]
good = [item for item in reversed(state.finished) if 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]]: def _help_lines(state: TuiState) -> list[tuple[str, str]]:
screen = state.screen 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": if screen == "installing":
return [("B/Esc, Ctrl-C", "Stop: what is done stays done"), return [(names["stop"], "Stop: what is done stays done"),
("Start+Select", "Stop"), ("?, F1", "This help")] *([(names["kill"], "Stop")] if names["kill"] else []),
*_route_extras(state), ("?, F1", "This help")]
if screen == "path": if screen == "path":
return [("Enter", "Use this folder"), ("Backspace", "Delete a character"), return [(names["enter"], "Use this folder"), ("Backspace", "Delete a character"),
("Ctrl-U", "Clear"), ("Esc", "Back"), ("Ctrl-C", "Quit")] ("Ctrl-U", "Clear"), (names["esc"], "Back"), ("Ctrl-C", "Quit"),
keys = [("Up/Down, k/j", "Move")] *_route_extras(state)]
keys = [(names["move"], "Move")]
if len(tui_rows(state)) > 9 or screen in _PAGERS: 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] 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: if screen in _CHECKLISTS:
keys.append(("X, Right/Left", "Tick, untick")) keys.append((names["tick"], "Tick, untick"))
if screen == "systems": if screen == "systems":
keys.append(("a, n", "All, none")) keys.append(("a, n", "All, none"))
if screen in ("platforms", "systems", "cores"): if screen in ("platforms", "systems", "cores"):
@@ -11661,9 +11829,10 @@ def _help_lines(state: TuiState) -> list[tuple[str, str]]:
if screen == "options": if screen == "options":
keys.append(("Left/Right", "Change a value")) keys.append(("Left/Right", "Change a value"))
if screen in ("plan", "systems", "confirm"): 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" 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 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 primary = _primary(state) if not state.help and hint[0].startswith("A/Enter") else None
if primary: if primary:
hint = (primary, *hint[1:]) hint = (primary, *hint[1:])
hint = _route_hint(state, hint)
lines = [_styled(fit(f" RetroBIOS: {title}", cols), "title"), _styled("")] lines = [_styled(fit(f" RetroBIOS: {title}", cols), "title"), _styled("")]
lines += [_styled(fit(" " + line, cols), line.style) for line in body[:height]] lines += [_styled(fit(" " + line, cols), line.style) for line in body[:height]]
lines += [_styled("")] * (rows - 1 - len(lines)) lines += [_styled("")] * (rows - 1 - len(lines))
@@ -11771,6 +11941,7 @@ def tui_start(
) -> tuple[TuiState, list[tuple]]: ) -> tuple[TuiState, list[tuple]]:
"""The first screen, from what is found here and what the command named.""" """The first screen, from what is found here and what the command named."""
state = TuiState(os_type=os_type, home=Path.home()) state = TuiState(os_type=os_type, home=Path.home())
state.pad = tui_pad_route(os_type)
state.cli_systems = list(args.system or []) state.cli_systems = list(args.system or [])
state.regions = resolve_regions(args.region) if args.region else [] state.regions = resolve_regions(args.region) if args.region else []
state.cli_regions = list(state.regions) state.cli_regions = list(state.regions)
@@ -11986,6 +12157,7 @@ class _TuiTerminal:
self.ended = False self.ended = False
self.heard = 0.0 self.heard = 0.0
self.frame: list[tuple[str, str]] = [] self.frame: list[tuple[str, str]] = []
self.entered = False
def enter(self) -> None: def enter(self) -> None:
if os.name != "nt": if os.name != "nt":
@@ -12002,13 +12174,18 @@ class _TuiTerminal:
mode[6][termios.VMIN] = 1 mode[6][termios.VMIN] = 1
mode[6][termios.VTIME] = 0 mode[6][termios.VTIME] = 0
termios.tcsetattr(self.fd, termios.TCSANOW, mode) termios.tcsetattr(self.fd, termios.TCSANOW, mode)
self.entered = True
self.write(_SCREEN_ON) self.write(_SCREEN_ON)
def leave(self) -> None: def leave(self) -> None:
"""The main screen and the mode found, the mode even when the write """The main screen and the mode found, the mode even when the write
fails: a closed window answers EIO.""" fails: a closed window answers EIO."""
try: 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: finally:
if self.saved is not None: if self.saved is not None:
import termios # noqa: PLC0415 import termios # noqa: PLC0415
@@ -12173,24 +12350,31 @@ def _farewell_lines(state: TuiState) -> list[str]:
def run_tui(args: argparse.Namespace) -> int: def run_tui(args: argparse.Namespace) -> int:
"""The full-screen interface, until it closes: the exit code it ends with.""" """The full-screen interface, until it closes: the exit code it ends with."""
os_type = _INVOCATION.os_type or detect_os() flags = {"interrupt": False, "resized": False, "suspend": False, "resume": False,
if os_type == "wsl": "term": 0}
_enter_wsl() handlers: dict[int, object] = {}
# 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. def on_stop(signum: int, _frame: object) -> None:
folders = getattr(args, "from") or [] flags["term"] = flags["term"] or signum
setattr(args, "from", None)
# 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: try:
_open_local_sources(args) started = _tui_startup(args)
finally: except BaseException:
setattr(args, "from", folders) _restore_handlers(handlers)
for folder in folders: raise
problem = from_folder_problem(str(folder)) if started is None:
if problem is not None: _restore_handlers(handlers)
print(f"Error: {folder} {problem[1]}: --from names the folder to copy BIOS " return 1
"files from.", file=sys.stderr) state, effects = started
return 1 if flags["term"]:
state, effects = tui_start(args, os_type) state.exit_code = 128 + flags["term"]
work = Path(tempfile.mkdtemp(prefix=TUI_WORK_PREFIX)) work = Path(tempfile.mkdtemp(prefix=TUI_WORK_PREFIX))
(work / TUI_OWNER).write_text(str(os.getpid()), encoding="utf-8") (work / TUI_OWNER).write_text(str(os.getpid()), encoding="utf-8")
_prune_work_folders(work.parent, time.time()) _prune_work_folders(work.parent, time.time())
@@ -12198,8 +12382,6 @@ def run_tui(args: argparse.Namespace) -> int:
inbox: queue.Queue = queue.Queue() inbox: queue.Queue = queue.Queue()
current: list[_EngineRun] = [] current: list[_EngineRun] = []
written: dict[str, Path] = {} 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 # 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. # 3 s later. The install gets 2 s to say what it wrote.
terminating = {"signal": 0, "deadline": 0.0} terminating = {"signal": 0, "deadline": 0.0}
@@ -12207,7 +12389,6 @@ def run_tui(args: argparse.Namespace) -> int:
terminal = _TuiTerminal(sys.stdin, sys.stdout) terminal = _TuiTerminal(sys.stdin, sys.stdout)
real_out, real_err = sys.stdout, sys.stderr real_out, real_err = sys.stdout, sys.stderr
buffer = io.StringIO() buffer = io.StringIO()
handlers: dict[int, object] = {}
def perform(todo: list[tuple], from_key: bool = True) -> None: def perform(todo: list[tuple], from_key: bool = True) -> None:
for effect in todo: for effect in todo:
@@ -12251,9 +12432,6 @@ def run_tui(args: argparse.Namespace) -> int:
def on_resize(_signum: int, _frame: object) -> None: def on_resize(_signum: int, _frame: object) -> None:
flags["resized"] = True 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: def on_suspend(_signum: int, _frame: object) -> None:
flags["suspend"] = True flags["suspend"] = True
@@ -12262,14 +12440,15 @@ def run_tui(args: argparse.Namespace) -> int:
try: try:
for name, handler in (("SIGINT", on_interrupt), ("SIGWINCH", on_resize), for name, handler in (("SIGINT", on_interrupt), ("SIGWINCH", on_resize),
("SIGTERM", on_stop), ("SIGHUP", on_stop),
("SIGTSTP", on_suspend), ("SIGCONT", on_resume)): ("SIGTSTP", on_suspend), ("SIGCONT", on_resume)):
number = getattr(signal, name, None) number = getattr(signal, name, None)
if number is not None: if number is not None:
handlers[number] = signal.signal(number, handler) handlers[number] = signal.signal(number, handler)
sys.stdout = sys.stderr = buffer sys.stdout = sys.stderr = buffer
terminal.enter() if state.exit_code is None:
perform(effects) # Stopped while it started: the screen never opens.
terminal.enter()
perform(effects)
while state.exit_code is None: while state.exit_code is None:
if flags["suspend"]: if flags["suspend"]:
# Stopped from outside (kill -TSTP): the shell gets its # Stopped from outside (kill -TSTP): the shell gets its
@@ -12349,12 +12528,64 @@ def run_tui(args: argparse.Namespace) -> int:
try: try:
for line in lines: for line in lines:
print(line, file=stream) print(line, file=stream)
stream.flush()
except OSError: except OSError:
# The terminal is gone: the exit code is all that still reaches anyone. # The terminal is gone (a closed window answers EIO, a closed pipe
return state.exit_code if state.exit_code is not None else 1 # 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 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( def _tui_cleanup(
terminal: _TuiTerminal, runs: list[_EngineRun], handlers: dict, state: TuiState, terminal: _TuiTerminal, runs: list[_EngineRun], handlers: dict, state: TuiState,
) -> list[str]: ) -> list[str]:
+1 -1
View File
@@ -18,7 +18,7 @@ esac
TEMP_INSTALLER="" TEMP_INSTALLER=""
TEMP_DIRECTORY="" TEMP_DIRECTORY=""
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
DEFAULT_INSTALL_SHA256="ea2777500114b796bb4a23ea9e0dd0a15bc4499bb1289e6e426943beece48865" DEFAULT_INSTALL_SHA256="e2cc5f073a8226fe26b0513ebd25f00de6f3f613d8c9851473c2d30b53356f48"
MAX_INSTALLER_BYTES=2097152 MAX_INSTALLER_BYTES=2097152
cleanup() { 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" GOLDEN = REPO_ROOT / "tests" / "fixtures" / "tui"
SIZES = ((40, 12), (40, 15), (80, 24), (120, 40)) 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" UP, DOWN, RIGHT, LEFT = "\x1b[A", "\x1b[B", "\x1b[C", "\x1b[D"
Key = install.TuiKey Key = install.TuiKey
@@ -940,6 +942,31 @@ class Views(unittest.TestCase):
path.write_text(text, encoding="utf-8") path.write_text(text, encoding="utf-8")
self.assertEqual(text, path.read_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): def test_the_hint_bar_names_the_primary_action_first(self):
for name, state in golden_states().items(): for name, state in golden_states().items():
with self.subTest(screen=name): with self.subTest(screen=name):
@@ -954,6 +981,163 @@ class Views(unittest.TestCase):
self.assertTrue(focused[0].lstrip().startswith("> RetroArch")) 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): class Rendering(unittest.TestCase):
def test_only_changed_lines_are_written(self): def test_only_changed_lines_are_written(self):
out = io.StringIO() out = io.StringIO()
@@ -1234,8 +1418,13 @@ class CleanupSurvivesABrokenTerminal(unittest.TestCase):
self.addCleanup(os.close, slave) self.addCleanup(os.close, slave)
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0)) fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0))
before = termios.tcgetattr(slave) before = termios.tcgetattr(slave)
handlers = {n: signal.getsignal(n) for n in (signal.SIGINT, signal.SIGTERM)} handlers = {n: signal.getsignal(n) for n in (signal.SIGINT, signal.SIGTERM,
tty = self.Tty(slave) 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 real_draw = install._TuiTerminal.draw
def breaking_draw(terminal, lines, cols): 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, "tui_start", return_value=(fresh_state(), [])), \
mock.patch.object(install, "_open_local_sources"), \ mock.patch.object(install, "_open_local_sources"), \
mock.patch.object(install, "detect_os", return_value="linux"), \ 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, "stdin", tty), mock.patch.object(sys, "stdout", out), \
mock.patch.object(sys, "stderr", tty): mock.patch.object(sys, "stderr", out):
code = install.run_tui(_args()) code = install.run_tui(_args())
self.assertIs(sys.stdout, tty) self.assertIs(sys.stdout, out)
self.assertEqual(code, 1) 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. # The crash keeps its log, marked kept, and nothing else of the run.
self.assertEqual(sorted(path.name for path in work.iterdir()), self.assertEqual(sorted(path.name for path in work.iterdir()),
["error.log", "kept", "owner"]) ["error.log", "kept", "owner"])
@@ -1608,6 +1800,50 @@ class WorkFolders(unittest.TestCase):
work, out = self.run_to(130) work, out = self.run_to(130)
self.assertFalse(work.exists()) 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: def found(dest: str, folder: str, reason: str = "") -> dict:
return {"event": "found", "platform": "retroarch", "dest": dest, "folder": folder, 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", self.env = {**repo.env(), "HOME": str(home), "TERM": "xterm",
"RETROBIOS_OS": "linux", "XDG_CONFIG_HOME": str(home / ".config")} "RETROBIOS_OS": "linux", "XDG_CONFIG_HOME": str(home / ".config")}
self.env.pop("NO_COLOR", None) 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 return repo
def start(self, *args: str, cols: int = 80, rows: int = 24) -> Pty: def start(self, *args: str, cols: int = 80, rows: int = 24) -> Pty:
@@ -2592,6 +2833,49 @@ class EndToEnd(unittest.TestCase):
term.send("q") term.send("q")
self.assertEqual(term.close(), 130) 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): def test_resize_redraws(self):
self.repo() self.repo()
term = self.start(cols=40, rows=15) 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 | | 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, 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 in the names of what is held. On a computer it names the keys (`Enter Install`,
controller layouts. A narrow screen shortens them to `A`, `B` and `X`. On the `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 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 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 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 | | `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 | | `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 | | `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 **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, system (every part, for a pack in parts): RetroArch's on muOS, ArkOS,