feat: pick one system in a few presses

A long systems list opens on None. A there unticks every system and
moves to the first one, and A on a system with nothing ticked installs
that one alone, so Enter still continues. Inside muxterm, which keeps
PgUp and PgDn for its scrollback, Left and Right on the D-pad page.

One system of 66 at 40x15 took 17 presses with a pad, 57 in muxterm;
it now takes 12 on both. The pyyaml check of the device lists skips
where pyyaml is not installed.
This commit is contained in:
Abdessamad Derraz committed 2026-10-26 22:20:29 +01:00
1 parent 57811270e3
commit e919d8478e
7 files changed
+185 -33

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@
$ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "a564b0582012d8851acea1cfc973101785be4a56d79aacb75bcf50691139c5a3"
$defaultInstallSha256 = "b13b2ef42634a371e0fa8c55be5182e8fb04c09d7c456816e300150f507d8697"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+32 -6
View File
@@ -11685,6 +11685,9 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]:
return _open_from_browser(state)
if action in ("misplaced", "unreadable"):
return _push(state, action)
if action == "systems" and len(state.systems) > TUI_LONG_LIST:
# On None: picking one system of 66 began with Up, Up, A.
return _push(state, action, 1)
# On the first system, so A continues and the D-pad ticks.
return _push(state, action, 3 if action == "systems" else 0)
if screen == "systems":
@@ -11692,7 +11695,14 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]:
state.ticked = {row["system"] for row in state.systems}
elif action == "none":
state.ticked = set()
# On the first system: the next A takes the one in focus alone.
state.focus["systems"] = next(
(i for i, item in enumerate(tui_rows(state)) if item.action == "system"),
state.focus.get("systems", 0))
else:
if action == "system" and not state.ticked:
# Nothing ticked: A takes the system in focus, and only it.
state.ticked = {row.value}
return _begin_install(state)
return []
if screen == "options":
@@ -12046,6 +12056,12 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]:
return _filter_key(state, key)
if state.pad == "miyoo":
key = _miyoo_key(state, key)
if (state.pad in _NO_PAGE_PADS and key.name in ("left", "right")
and screen not in ("regions", "options")):
# muxterm keeps PgUp and PgDn for its scrollback: the D-pad's Left
# and Right page instead (X or Y still ticks). One system of 66 took
# about 60 Down presses.
key = TuiKey("pgup" if key.name == "left" else "pgdn")
char = key.text if key.name == "char" else ""
if key.name == "f1" or char == "?":
state.help = True
@@ -12129,6 +12145,10 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]:
return []
# The routes inside muxterm, where no button sends PgUp or PgDn.
_NO_PAGE_PADS = ("muxterm", "gptokeyb_muxterm")
# A Miyoo's L1 and R1 are the keys E and T (codes 18 and 20).
_MIYOO_PAGES = {"e": "pgup", "t": "pgdn"}
@@ -12422,6 +12442,9 @@ def _scrolled(lines: list[TuiLine], first: int, height: int, lead: int = 0) -> l
# The rows a screen keeps for its list under pinned lines.
TUI_PINNED_ROWS = 4
# A list longer than this opens its systems on None, and its help names a
# page key.
TUI_LONG_LIST = 9
def _compose(
@@ -13243,9 +13266,9 @@ _HELP_KEYS: dict[str, dict[str, str | None]] = {
"back": "B, Select, Esc", "stop": "B, Esc, Ctrl-C", "kill": None,
"enter": "A, Enter", "esc": "B, Esc"},
# muxterm keeps PgUp and PgDn for its own scrollback, and its L1 and R1
# send nothing: no key pages a list there.
"muxterm": {"move": "D-pad, Up/Down", "page": None, "ok": "Start, Enter",
"tick": "Y, Space, Right/Left", "options": "o, Tab",
# send nothing: the D-pad's Left and Right page a list there.
"muxterm": {"move": "D-pad, Up/Down", "page": "D-pad Left/Right", "ok": "Start, Enter",
"tick": "Y, Space", "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",
@@ -13254,8 +13277,9 @@ _HELP_KEYS: dict[str, dict[str, str | None]] = {
"enter": "Start, Enter", "esc": "Esc"},
}
# muxterm keeps PgUp and PgDn for its scrollback even when gptokeyb2 owns the
# pad, and the launcher sends nothing on L1 and R1 there.
_HELP_KEYS["gptokeyb_muxterm"] = {**_HELP_KEYS["gptokeyb"], "page": None}
# pad, and the launcher sends nothing on L1 and R1 there: Left and Right page.
_HELP_KEYS["gptokeyb_muxterm"] = {**_HELP_KEYS["gptokeyb"], "page": "D-pad Left/Right",
"tick": "X, Space"}
def _route_extras(state: TuiState) -> list[tuple[str, str]]:
@@ -13282,7 +13306,7 @@ def _help_lines(state: TuiState) -> list[tuple[str, str]]:
("Ctrl-U", "Clear"), (names["esc"], "Back"), ("Ctrl-C", "Quit"),
*_route_extras(state)]
keys = [(names["move"], "Move")]
if len(tui_rows(state)) > 9 or screen in _PAGERS:
if len(tui_rows(state)) > TUI_LONG_LIST or screen in _PAGERS:
keys += [*([(names["page"], "Page")] if names["page"] else []),
("Home/End, g/G", "First, last")]
primary = _primary(state) or _TUI_VIEWS[screen](state, 80, 24)[2][0]
@@ -13315,6 +13339,8 @@ def _primary(state: TuiState) -> str | None:
return None
row = rows[max(0, min(state.focus.get(state.screen, 0), len(rows) - 1))]
verb = _ROW_VERBS.get(row.action)
if state.screen == "systems" and row.action == "system" and not state.ticked:
verb = "Only this system"
return f"A/Enter {verb}" if verb else None
+1 -1
View File
@@ -18,7 +18,7 @@ esac
TEMP_INSTALLER=""
TEMP_DIRECTORY=""
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
DEFAULT_INSTALL_SHA256="a564b0582012d8851acea1cfc973101785be4a56d79aacb75bcf50691139c5a3"
DEFAULT_INSTALL_SHA256="b13b2ef42634a371e0fa8c55be5182e8fb04c09d7c456816e300150f507d8697"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+10 -10
View File
@@ -27,8 +27,8 @@
{
"dest": "roms/ports/retrobios/install.py",
"repo_path": "install.py",
"size": 622687,
"sha256": "a564b0582012d8851acea1cfc973101785be4a56d79aacb75bcf50691139c5a3",
"size": 624131,
"sha256": "b13b2ef42634a371e0fa8c55be5182e8fb04c09d7c456816e300150f507d8697",
"executable": false
},
{
@@ -71,22 +71,22 @@
"muos": {
"archive": {
"dest": "ARCHIVE/RetroBIOS.muxapp",
"size": 673341,
"sha256": "8ffa4bc6e3e16745012c4cebdfebb7070cdcbd63191e0ef0edb11556338698ad"
"size": 674834,
"sha256": "b7d51126f63aeff0d7ca790d887556619aeb566b7133fe66ef79f08a7f639929"
},
"members": [
{
"name": "RetroBIOS/README.md",
"repo_path": "ports/muos/README.md",
"size": 1127,
"sha256": "c0cfafad5fae8e6764b263b14a06195b267f66c4fe042333941ae6bcc6dab265",
"size": 1176,
"sha256": "26b5da84f22ce3c652f16ed8335be11d99e3bfb8657eec7caa100cf505f68497",
"executable": false
},
{
"name": "RetroBIOS/install.py",
"repo_path": "install.py",
"size": 622687,
"sha256": "a564b0582012d8851acea1cfc973101785be4a56d79aacb75bcf50691139c5a3",
"size": 624131,
"sha256": "b13b2ef42634a371e0fa8c55be5182e8fb04c09d7c456816e300150f507d8697",
"executable": false
},
{
@@ -159,8 +159,8 @@
{
"dest": ".config/modules/retrobios/install.py",
"repo_path": "install.py",
"size": 622687,
"sha256": "a564b0582012d8851acea1cfc973101785be4a56d79aacb75bcf50691139c5a3",
"size": 624131,
"sha256": "b13b2ef42634a371e0fa8c55be5182e8fb04c09d7c456816e300150f507d8697",
"executable": false
},
{
+2 -1
View File
@@ -11,7 +11,8 @@ It runs in muxterm, muOS's terminal. With PortMaster installed, gptokeyb2
turns every button into a key and muxterm is told to leave the pad alone, so
each button is sent once: D-pad to move, A or Start to choose, B or Select to
go back, X to tick a system, Y for the options. L1 and R1 do nothing in
muxterm, which keeps Page Up and Page Down for its own scrollback. Hold
muxterm, which keeps Page Up and Page Down for its own scrollback: Left and
Right on the D-pad page a list instead. Hold
Select, then press Start to quit. Without PortMaster, muxterm reads the pad
itself (Y then ticks a system) and Menu closes it.
+133 -9
View File
@@ -444,13 +444,12 @@ class Flow(unittest.TestCase):
self.assertEqual(install.tui_rows(state)[state.focus["systems"]].action, "system")
press(state, "n")
self.assertEqual(state.ticked, set())
self.assertEqual(press(state, "enter"), [])
self.assertIn("No system is ticked", state.status)
focus = next(i for i, row in enumerate(install.tui_rows(state))
if row.value == "nintendo-gba")
state.focus["systems"] = focus
press(state, "right")
# Nothing ticked: Enter takes the system in focus alone.
self.assertEqual(press(state, "enter"), [("run", "confirm")])
self.assertEqual(state.ticked, {"nintendo-gba"})
self.assertEqual(state.screen, "confirm")
self.assertIn("--system=nintendo-gba", install.tui_argv(state, "confirm", "py", "i.py"))
self.assertIn("--dry-run", install.tui_argv(state, "confirm", "py", "i.py"))
@@ -1127,19 +1126,20 @@ class PadRoutes(unittest.TestCase):
self.assertIn(words, muxterm)
self.assertNotIn("Start+Select", muxterm)
# gptokeyb2 owns the pad inside muxterm, which keeps PgUp and PgDn
# for its scrollback: the gptokeyb buttons, and no page key.
# for its scrollback: the gptokeyb buttons, Left and Right to page.
in_muxterm = help_text("gptokeyb_muxterm", "preview")
for words in ("A, Start, Enter", "Select, then Start"):
self.assertIn(words, in_muxterm)
self.assertNotIn("L1/R1", in_muxterm)
self.assertIn("L1/R1", help_text("gptokeyb", "preview"))
# muxterm keeps PgUp and PgDn for its scrollback, and L1 and R1
# send nothing: no paging is offered there, on a list or a pager.
# send nothing: the D-pad's Left and Right page there instead.
for screen in ("systems", "result", "plan"):
with self.subTest(screen=screen):
text = help_text("muxterm", screen)
for words in ("L1", "R1", "PgUp", "PgDn", "Page"):
for words in ("L1", "R1", "PgUp", "PgDn"):
self.assertNotIn(words, text)
self.assertIn("D-pad Left/Right Page", help_text("muxterm", "result"))
miyoo = help_text("miyoo", "systems")
for words in ("Start, Enter", "A, Space, Right/Left", "R2, Esc, Backspace",
"L2, Tab, o", "Sends no key here"):
@@ -2879,7 +2879,10 @@ class TheSetupFilterKnowsDevices(unittest.TestCase):
self.assertIn("muOS", {row.value: row for row in self.rows("")}["retroarch"].right)
def test_the_handheld_lists_are_the_guides(self):
import yaml
try:
import yaml
except ImportError:
self.skipTest("pyyaml is not installed")
guides = yaml.safe_load((REPO_ROOT / "platforms" / "_guides.yml").read_text())
for system in guides["handheld_guide"]["systems"]:
@@ -2889,6 +2892,126 @@ class TheSetupFilterKnowsDevices(unittest.TestCase):
tuple(str(name) for name in system["devices"]))
class OneSystemInFewPresses(unittest.TestCase):
"""tui_audit_4 P3-10.11: one system with a controller took 15 presses on
the fixture list, about 60 on muxterm, which has no page key."""
# The buttons each route sends, as the keys the interface reads.
PADS = {
"gptokeyb": {"A": "enter", "X": " ", "Start": "enter", "R1": "pgdn"},
"muxterm": {"A": "enter", "X": " ", "Start": "enter", "R1": "right"},
}
def long_list(self, pad: str) -> install.TuiState:
state = checked_state()
state.pad = pad
state.cols, state.rows = 40, 15
state.systems = [{"event": "system", "platform": "retroarch", "system": f"sys-{n:02d}",
"name": f"System {n:02d}", "files": 1, "present": 0, "missing": 1,
"wrong_hash": 0, "bytes_needed": 10} for n in range(66)]
state.ticked = {row["system"] for row in state.systems}
state.focus["plan"] = next(i for i, row in enumerate(install.tui_rows(state))
if row.action == "install")
return state
def press_button(self, state, pad: str, button: str) -> list:
name = self.PADS[pad].get(button, button)
return install.tui_update(state, Key("char", name) if len(name) == 1 else Key(name))
def go_to(self, state, pad: str, target: str, paging: bool) -> int:
"""Presses from the focus to the target system, as a person does."""
count = 0
while True:
rows = install.tui_rows(state)
focus = state.focus.get("systems", 0)
index = next(i for i, row in enumerate(rows) if row.value == target)
if index == focus:
return count
page = max(1, state.rows - 6)
button = "R1" if paging and index - focus >= page else "down"
self.press_button(state, pad, button)
count += 1
def presses(self, pad: str, fast: bool) -> int:
"""From the plan to the run that installs sys-45 alone, with the
setup's A before it."""
state = self.long_list(pad)
count = 1
for button in ("down", "A"):
self.press_button(state, pad, button)
count += 1
self.assertEqual(state.screen, "systems")
paging = pad != "muxterm" or fast
if fast:
self.assertEqual(install.tui_rows(state)[state.focus["systems"]].action, "none")
self.press_button(state, pad, "A")
count += 1
count += self.go_to(state, pad, "sys-45", paging)
effects = self.press_button(state, pad, "A")
count += 1
else:
state.focus["systems"] = 3
for button in ("up", "up", "A"):
self.press_button(state, pad, button)
count += 1
count += self.go_to(state, pad, "sys-45", paging)
self.press_button(state, pad, "X")
effects = self.press_button(state, pad, "Start")
count += 2
self.assertEqual(state.ticked, {"sys-45"})
self.assertEqual(effects, [("run", "confirm")])
# Install on the confirm screen, then Exit on the result.
return count + 2
def test_presses_before_and_after(self):
# The audit's path, A Down A Up Up A R1 x5 X Start A A. Before the
# fast path it took 17 presses here, 57 on muxterm (None kept the
# focus, and muxterm had no page key).
self.assertEqual(self.presses("gptokeyb", fast=False), 15)
self.assertEqual(self.presses("gptokeyb", fast=True), 12)
self.assertEqual(self.presses("muxterm", fast=False), 55)
self.assertEqual(self.presses("muxterm", fast=True), 12)
def test_a_on_a_system_with_none_ticked_takes_it_alone(self):
state = self.long_list("keys")
state.stack.append("systems")
state.ticked = set()
state.focus["systems"] = 5
text = install.tui_view(state, 80, 24)[-1]
self.assertIn("Enter Only this system", text)
focused = install.tui_rows(state)[5].value
self.assertEqual(press(state, "enter"), [("run", "confirm")])
self.assertEqual(state.ticked, {focused})
def test_enter_still_continues_with_the_ticks_as_they_are(self):
state = self.long_list("keys")
state.stack.append("systems")
state.focus["systems"] = 5
self.assertEqual(press(state, "enter"), [("run", "install")])
self.assertEqual(len(state.ticked), 66)
def test_left_and_right_page_inside_muxterm_only(self):
for pad in ("muxterm", "gptokeyb_muxterm", "gptokeyb"):
with self.subTest(pad=pad):
state = self.long_list(pad)
state.stack.append("systems")
state.focus["systems"] = 3
press(state, "right")
paged = pad != "gptokeyb"
self.assertEqual(state.focus["systems"] > 4, paged)
self.assertEqual(len(state.ticked), 66)
state.help = True
help_text = " ".join(install.tui_view(state, 80, 24))
self.assertEqual("D-pad Left/Right" in help_text, paged)
def test_a_short_list_opens_on_its_first_system(self):
state = checked_state()
state.focus["plan"] = next(i for i, row in enumerate(install.tui_rows(state))
if row.action == "systems")
press(state, "enter")
self.assertEqual(install.tui_rows(state)[state.focus["systems"]].action, "system")
class DataFilesHaveASize(unittest.TestCase):
"""tui_audit_4 P3-10.8: "8179 files to download, 6.6 GB, and 2636 data
files", the data files with no size."""
@@ -3896,8 +4019,9 @@ class EndToEnd(unittest.TestCase):
term.wait("> Install")
term.send(DOWN, "\r")
term.wait("Choose systems")
# D-pad and A only: up to None, A, down to the second system, Right.
term.send(UP, UP, "\r", DOWN, DOWN, DOWN, RIGHT)
# D-pad and A only: up to None, A (on the first system), down to the
# second system, Right.
term.send(UP, UP, "\r", DOWN, RIGHT)
screen = term.wait("1 of 2 systems")
self.assertIn("> [x] sony-playstation", screen)
term.send("\r")
+6 -5
View File
@@ -131,7 +131,8 @@ how many files are missing, how much they weigh, how many are in place and how
much space is free, before anything is written.
The other choices sit one press away. **Choose systems** ticks the consoles you
play, with the total to download under the list. **Options** sets the
play, with the total to download under the list. A long list opens on **None**:
A there unticks every system, and A on a system then installs that one alone. **Options** sets the
preferred regions, game data, the installed cores only, the cores, the parallel
downloads and the folder. Each row names the option it sets, and **Show the
command line** gives the same choice as one command for a script. **Preview the
@@ -1072,8 +1073,8 @@ Start pressed first also chooses the line in focus: hold Select first. A
running install is then stopped the way B stops it, and the last screen says
what it wrote. On muOS without PortMaster, muxterm reads the pad itself: Y
ticks a system, Select shows its keyboard, and Menu quits. L1 and R1 do not
page there: muxterm keeps PgUp and PgDn for its own scrollback, so a list
moves with the D-pad.
page there: muxterm keeps PgUp and PgDn for its own scrollback, so Left and
Right on the D-pad page a list instead, as they do with gptokeyb in muxterm.
Each launcher also sets `RETROBIOS_IDLE_EXIT` to 300: with no button pressed
for 5 minutes since it opened, and no check or install running, the interface
@@ -1087,8 +1088,8 @@ Each launcher tells the interface which buttons reach it, in
| Value | Input | Set by |
|-------|-------|--------|
| `gptokeyb` | gptokeyb or gptokeyb2 with `retrobios.gptk`, the table above | the PortMaster port and the Tools entry |
| `gptokeyb_muxterm` | the same, inside muxterm, which keeps L1 and R1 for its scrollback: no button pages | 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 |
| `gptokeyb_muxterm` | the same, inside muxterm, which keeps L1 and R1 for its scrollback: Left and Right page | the muOS app with PortMaster |
| `muxterm` | muxterm's own keys: D-pad (Left and Right page), Start as Enter, B as Backspace, Y as Space | the muOS app without PortMaster |
| `evmapy` | `RetroBIOS.sh.keys`, the same buttons as the table | the Batocera and Knulli port |
| `miyoo` | buttons that are already keys: A as Space, Start as Enter, L1 and R1 as E and T, L2 as Tab, R2 as Backspace | no launcher yet |