mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-05 17:43:54 -06:00
chore: merge pad-aware interface hints
This commit is contained in:
commit
a8fef3fbbd
10 files changed
+6462
-54
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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() {
|
||||||
|
|||||||
Vendored
+1188
File diff suppressed because it is too large.
Load diff
+1188
File diff suppressed because it is too large.
Load diff
Vendored
+1134
File diff suppressed because it is too large.
Load diff
Vendored
+1188
File diff suppressed because it is too large.
Load diff
Vendored
+1188
File diff suppressed because it is too large.
Load diff
+289
-5
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user