diff --git a/install.ps1 b/install.ps1 index 54f214a3a..4e6878ef7 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 } diff --git a/install.py b/install.py index d596a9aca..49fb58619 100644 --- a/install.py +++ b/install.py @@ -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 diff --git a/install.sh b/install.sh index 0e7bfb92f..724dc1704 100755 --- a/install.sh +++ b/install.sh @@ -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() { diff --git a/install/launchers/index.json b/install/launchers/index.json index da7543eca..36d6c3ec4 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -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 }, { diff --git a/ports/muos/README.md b/ports/muos/README.md index 8b2facf76..36096ca10 100644 --- a/ports/muos/README.md +++ b/ports/muos/README.md @@ -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. diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 4759e0e87..3cb296e1c 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -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") diff --git a/wiki/installer.md b/wiki/installer.md index 59b5eae89..7f3e432d6 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -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 |