chore: merge the last installer items

# Conflicts:
#	install.ps1
#	install.py
#	install.sh
#	install/launchers/index.json
This commit is contained in:
Abdessamad Derraz committed 2026-10-26 23:10:30 +01:00
commit dc225acb57
18 files changed
+770 -106

No files matched your search

+313 -64
View File
@@ -15,6 +15,7 @@ Usage:
from __future__ import annotations
import argparse
import bisect
import codecs
import concurrent.futures
import contextlib
@@ -543,6 +544,10 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = {
# kind of its own, since schema 1 never adds a key to the file event.
"declared": ("platform", "files", "present", "missing", "wrong_hash",
"bytes_needed", "dests"),
# Right after declared: the files of the data trees to fetch (check's
# data_missing) and the bytes they write. A kind of its own, since
# schema 1 never adds a key to check.
"data": ("platform", "files", "bytes"),
# A file to install that this machine already holds: misplaced in the
# BIOS folder (folder is its bios_dir, reason says why in words) or in
# a folder named with --from (reason empty). member names the ZIP member
@@ -642,7 +647,7 @@ def _os_name() -> str:
def _emit_check(
plat: str, manifest: dict, bios_path: Path, files: list[dict], up_to_date: list[dict],
mismatched: list[dict], data_missing: int, not_installable: int,
chosen: list[str] | None = None,
chosen: list[str] | None = None, data_bytes: int = 0,
) -> None:
"""One event per file, per system and per platform after a check, then
one for the files of the platform's own list.
@@ -705,6 +710,7 @@ def _emit_check(
bytes_needed=sum(size for _d, size, state in own_states if state != "present"),
dests=[dest for dest, _s, _state in own_states],
)
EVENTS.emit("data", platform=plat, files=data_missing, bytes=data_bytes)
def _declared_state(copy: dict, entry: dict, bios_path: Path) -> str:
@@ -9549,6 +9555,74 @@ def _check_card_root(plat: str, dest: Path) -> None:
f"{display_name(plat)} file list was asked for.")
def card_bound(named: str, plat: str) -> tuple[str, str] | None:
"""(system name, BIOS folder below the card root) when what --platform
named runs from an SD card, else None.
`--platform onion` gets RetroArch's list, and on a computer that list's
default folder is the computer's own RetroArch: the files went there,
which Onion never reads. --platform retroarch names a list, not a card.
"""
if plat in CARD_PLATFORMS:
return display_name(plat), ""
key = _name_key(named)
rule = next((rule for rule in _card_rules()
if key and rule.manifest == plat and key == _name_key(rule.name)), None)
return (rule.name, rule.bios) if rule else None
def mounted_cards_of(system: str, plat: str) -> list[Path]:
"""The mounted cards whose own files say they run system."""
if plat in CARD_PLATFORMS:
return [mount for mount in _mounted_cards() if card_platform(mount) == plat]
return [mount for mount in _mounted_cards()
if any(rule.name == system for rule in _card_matches(mount))]
def card_install_dest(named: str, plat: str, interactive: bool) -> Path:
"""The folder for a card-bound system named without --dest, on a machine
that is not the device.
One mounted card that runs it is taken. At a terminal the card's root is
asked for, never this machine's own folder. Without questions the run
stops on the --dest that names the card.
"""
system, below = card_bound(named, plat) or (display_name(plat), "")
cards = mounted_cards_of(system, plat)
if len(cards) == 1:
dest = cards[0] / below if below else cards[0]
print(f" {system} card found at {cards[0]}: {display_name(plat)} files, in {dest}")
return dest
example = (f"--platform {named.strip() or plat} --dest /media/$USER/<card> "
"(Windows: --dest E:\\)")
if len(cards) > 1:
print(f" {len(cards)} {system} cards are mounted: "
+ ", ".join(str(card) for card in cards) + ".", file=sys.stderr)
if not interactive:
EVENTS.emit("error", stage="detect", reason="not_found", platform=plat,
message=f"{system} keeps its BIOS files on its SD card, and no "
"card was named with --dest")
print(f" {system} keeps its BIOS files on its SD card, not on this machine. "
f"Mount the card and name its root with --dest, for example: {example}",
file=sys.stderr)
sys.exit(1)
print(f" {system} keeps its BIOS files on its SD card, not on this machine. "
"Mount the card, then type its root folder.")
raw = ""
while not raw:
raw = _ask("Card root (Ctrl+C to stop): ")
root = _typed_folder(raw)
if plat in CARD_PLATFORMS:
_check_card_root(plat, root)
return root
folder = card_dest(named, plat, root)[1]
if folder == root and below and not _ends_with(root, below):
# A KNULLI share or a ROCKNIX card read as a list's name: its
# folder below the root, as the device reads it.
return root / below
return folder
def _platform_for_dest(os_type: str, dest: Path, interactive: bool) -> str:
"""The file list for a --dest given without --platform.
@@ -9670,7 +9744,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
elif args.platform:
print("Detecting platform...")
detected = detect_platforms(os_type)
matched = [(n, p) for n, p in detected if n == args.platform]
bound = card_bound(named, args.platform)
# A system that runs from its card is found only on the device:
# Onion named on a PC matched the PC's own RetroArch.
matched = [(n, p) for n, p in detected if n == args.platform
and (bound is None or detected_os(n, p) == bound[0])]
if matched:
platforms = matched
elif listing:
@@ -9678,20 +9756,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
# needed, and asking BizHawk's for one refused a question asked
# before downloading BizHawk.
platforms = [(args.platform, _default_dest(os_type, args.platform) or Path.cwd())]
elif args.platform in CARD_PLATFORMS:
elif bound is not None:
# Its files go on its SD card, which is never this machine's
# own folder: the card's mount point is the user's to name.
EVENTS.emit("error", stage="detect", reason="not_found", platform=args.platform,
message=f"{display_name(args.platform)} keeps its BIOS files on "
"its SD card, and no card was named with --dest")
print(
f" {display_name(args.platform)} keeps its BIOS files on its SD card. "
f"Mount the card and name its root with --dest, for example: "
f"--platform {args.platform} --dest /media/$USER/<card> "
f"(Windows: --dest E:\\)",
file=sys.stderr,
)
sys.exit(1)
# own folder.
platforms = [(args.platform, card_install_dest(named, args.platform,
interactive))]
elif args.platform in PORTABLE_ONLY:
# A portable emulator reads the folder beside its executable:
# any default would be a folder it never opens.
@@ -10163,6 +10232,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
plat_name, manifest, bios_path, files, up_to_date, mismatched,
sum(len(state[1]) for _entry, state in data_states), len(omitted_files),
chosen=selection[0] if selection else None,
data_bytes=sum(meta.get("size", 0) for _entry, state in data_states
for meta in state[1].values()),
)
emit_copies(plat_name, to_download, copies, bios_index, bios_path, files)
emit_unreadable(plat_name, unread)
@@ -10740,6 +10811,11 @@ class TuiState:
# "chosen" (the run's folder, from Change folder) or "closed" (the
# one chosen could not be opened, none is offered).
self.folder_kind: str = "usual"
# (system, BIOS folder below the card root) when the setup asked for
# runs from an SD card: the folder screen offers the mounted cards.
self.card_bound: tuple[str, str] | None = None
# (width and text, lines) of the last pager wrapped.
self.pager_cache: tuple | None = None
self.path_text: str = ""
self.browse_dir: Path | None = None
self.browse_rows: list = []
@@ -11141,13 +11217,20 @@ def tui_rows(state: TuiState) -> list[TuiRow]:
if screen == "platforms":
needle = state.filters.get(screen, "")
systems = _systems_reading()
return [_filter_row(state)] + [
TuiRow("platform", display_name(plat), ", ".join(systems.get(plat, [])), plat)
for plat in AVAILABLE_PLATFORMS
if _matches(" ".join([plat, display_name(plat), *systems.get(plat, [])]), needle)
]
rows = [_filter_row(state)]
for plat in AVAILABLE_PLATFORMS:
named = _setup_matches(plat, systems.get(plat, []), needle)
if named is not None:
# The systems the filter named, those a device typed runs:
# "miyoo" on RetroArch read "muOS, JELOS, KOLOS...".
rows.append(TuiRow("platform", display_name(plat),
", ".join(named or systems.get(plat, [])), plat))
return rows
if screen == "folder":
rows = [TuiRow("default", "Use this folder")] if state.folder_default else []
if _tui_card_bound(state):
rows += [TuiRow("card", f"{_mount_name(mount)} ({_card_what(mount)})", value=mount)
for mount in _mounted_cards()]
return rows + [TuiRow("browse", "Browse for a folder"), TuiRow("type", "Type a path")]
if screen == "browse":
return list(state.browse_rows)
@@ -11529,6 +11612,19 @@ def _mounted_cards() -> list[Path]:
return found
def _tui_card_bound(state: TuiState) -> tuple[str, str] | None:
"""The card the chosen setup runs from, when it runs from one."""
if state.card_bound:
return state.card_bound
return card_bound(state.platform, state.platform) if state.platform in CARD_PLATFORMS else None
def _card_what(mount: Path) -> str:
"""What a mounted card is, as its own files say."""
system = card_system(mount)
return f"{system[2]} card" if system else "card or drive"
def _browse_rows(folder: Path | None, zips: bool = False) -> tuple[list[TuiRow], str]:
"""A directory's rows for the browser, cards first, and what went wrong.
With zips, the ZIP files too: a backup is often one archive."""
@@ -11539,9 +11635,7 @@ def _browse_rows(folder: Path | None, zips: bool = False) -> tuple[list[TuiRow],
rows = [TuiRow("use", "Use this folder"), TuiRow("up", "..", value=folder.parent)]
for card in _mounted_cards():
if card != folder:
system = card_system(card)
what = f"{system[2]} card" if system else "card or drive"
rows.append(TuiRow("dir", f"{card} ({what})", value=card))
rows.append(TuiRow("dir", f"{card} ({_card_what(card)})", value=card))
try:
entries = list(os.scandir(folder))
except OSError as exc:
@@ -11651,7 +11745,8 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]:
if state.dest_given is not None:
return _choose(state, row.value, state.dest_given)
state.platform = row.value
if row.value in CARD_PLATFORMS:
state.card_bound = card_bound(row.value, row.value)
if state.card_bound:
return _open_folder_screen(state, None)
# The folder detected here before the built-in one: the built-in
# default was offered as where RetroArch reads, when the
@@ -11663,6 +11758,9 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]:
if screen == "folder":
if action == "default":
return _choose(state, state.platform, state.folder_default)
if action == "card":
below = (_tui_card_bound(state) or ("", ""))[1]
return _choose(state, state.platform, row.value / below if below else row.value)
if action == "browse":
state.browse_for = "dest"
_push(state, "browse")
@@ -11719,6 +11817,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":
@@ -11726,7 +11827,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":
@@ -11770,6 +11878,94 @@ def _systems_reading() -> dict[str, list[str]]:
return names
# The devices people type in the setup filter instead of the setup they run,
# beside the setups a first-party page says each one runs. Keys are platform
# ids or the system names of _EMBEDDED_RULES, and a device that runs several
# setups is under each. The handheld systems' lists are those of
# platforms/_guides.yml (handheld_guide, each with its devices_source), and a
# test holds the two equal.
_SETUP_DEVICES: dict[str, tuple[str, ...]] = {
"ArkOS": ("RG353M", "RG353V", "RG503", "RGB30", "RK2023", "RG351MP", "RG351V", "R35S",
"RGB10"),
"muOS": ("RG35XX H", "RG35XX Plus", "RG35XX SP", "RG35XX 2024", "RG34XX H", "RG40XX H",
"RG40XX V", "RG28XX H", "RGCUBEXX", "TrimUI Brick", "TrimUI Smart Pro"),
"Onion": ("Miyoo Mini", "Miyoo Mini+"),
"spruceOS": ("Miyoo A30", "Miyoo Flip", "Miyoo Mini", "Miyoo Mini Plus",
"Miyoo Mini Flip", "TrimUI Smart Pro", "TrimUI Brick",
"Anbernic RG35XX Plus", "RG40XX H", "Powkiddy RGB30"),
"Allium": ("Miyoo Mini", "Miyoo Mini Plus", "Miyoo Mini Flip"),
"ArchR": ("R36S", "R33S", "R36S Soysauce", "Odroid Go Advance", "Odroid Go Super",
"RG351V", "RG351M", "GameForce Chi", "MagicX XU10", "Powkiddy RGB10",
"Powkiddy RGB10X", "K36"),
"CrossMix-OS": ("TrimUI Smart Pro",),
"dArkOS": ("Odroid Go Advance", "RGB10", "RG351MP", "G350"),
"KOLOS": ("RG353P", "RG353M", "RG353V", "RG353VS", "RG503", "Powkiddy RK2023", "RGB30",
"RGB10 Max 3"),
"Koriki": ("Miyoo Mini",),
"MiyooCFW": ("BittBoy", "Pocket Go", "PowKiddy Q90", "PowKiddy V90", "PowKiddy Q20 Mini",
"Sup M3", "XYC Q8"),
"plumOS": ("MagicX XU20 V32",),
"RetroOZ": ("OGS", "OGA", "RGB10Max", "RGB10Max2"),
"UnofficialOS": ("R35S", "R36S", "RG351M", "RG351P", "RG351V", "RG353M", "RG353P",
"RG353V", "RG503", "RG552", "RGB30", "RGB20 Pro", "Odroid Go Advance",
"Odroid Go Super"),
# The Android tile of the handheld group (_guides.yml): goretroid.com
# "OS: Android 13" for the Retroid Pocket 5, ayntec.com "Android 15" for
# the AYN Odin 3. The guide runs RetroArch on Steam Deck (run: steamdeck)
# and on Windows, which the ROG Ally runs (rog.asus.com spec,
# "Windows 11 Home").
"retroarch": ("Steam Deck", "ROG Ally", "Android", "Retroid Pocket", "Retroid Pocket 5",
"AYN Odin", "AYN Odin 3"),
"emudeck": ("Steam Deck", "ROG Ally"),
"retrodeck": ("Steam Deck",),
"retrobat": ("ROG Ally",),
"bizhawk": ("ROG Ally",),
# _guides.yml runs_on, each cited there.
"batocera": ("Steam Deck",),
"amberelec": ("Anbernic RG351", "RG552"),
"emuelec": ("Odroid Go",),
"minui": ("Anbernic", "Miyoo", "TrimUI"),
"nextui": ("TrimUI Brick", "TrimUI Smart Pro"),
"myminui": ("Anbernic", "Miyoo"),
# rocknix.org/devices, read 2026-10-26.
"rocknix": ("Anbernic RG351", "RG353", "RG503", "RG552", "RG 55G1", "RG ARC",
"RG35XX 2024", "RG35XX Plus", "RG35XX Pro", "RG35XX H", "RG35XX SP", "RG28XX",
"RG40XX V", "RG40XX H", "RG CubeXX", "RG34XX", "AYANEO Pocket", "Ayn Odin 2",
"Ayn Odin 3", "Ayn Thor", "GameForce Ace", "Odroid Go Advance",
"Odroid Go Super", "Odroid Go Ultra", "MagicX XU Mini M", "Mangmi Air X",
"Powkiddy RGB10", "RGB10X", "RK2023", "RGB20SX", "RGB20 Pro", "RGB30", "X35S",
"X35H", "X55", "XU10", "Retroid Pocket 5", "Retroid Pocket 6",
"Retroid Pocket Flip2", "Retroid Pocket Mini", "Retroid Pocket Nova", "R33S",
"R35S", "R36S", "K36"),
# knulli.org, read 2026-10-26.
"knulli": ("Anbernic RG-Arc-S", "RG28XX", "RG34XX", "RG35XX Original", "RG35XX Plus",
"RG35XX H", "RG35XX 2024", "RG35XX SP", "RG40XX H", "RG40XX V", "RGCubeXX",
"Retroid Pocket 5", "Retroid Pocket Flip 2", "Retroid Pocket Mini",
"Miyoo Flip", "Orange Pi Zero 2w", "Powkiddy RGB30", "X55", "TrimUI Brick",
"TrimUI Smart Pro", "BattleXP G350", "GKD Pixel 2"),
}
def _device_needle(needle: str) -> str:
"""The filter text with a model's short form spelled out: owners write
RP5 for the Retroid Pocket 5, as the store names it."""
short = re.fullmatch(r"\s*rp\s*(\d+)\s*", needle, re.IGNORECASE)
return f"Retroid Pocket {short.group(1)}" if short else needle
def _setup_matches(plat: str, systems: list[str], needle: str) -> list[str] | None:
"""What the filter names for plat: its devices and systems that match,
[] when plat's own name does, None when nothing does. A device counts
for each setup it runs."""
if not needle or _matches(f"{plat} {display_name(plat)}", needle):
return []
needle = _device_needle(needle)
devices = [device for device in _SETUP_DEVICES.get(plat, ()) if _matches(device, needle)]
named = [system for system in systems
if _matches(" ".join([system, *_SETUP_DEVICES.get(system, ())]), needle)]
return devices + named or None
def _platform_focus(state: TuiState, plat: str) -> int:
"""The platform list's row for plat, below its filter row."""
return 1 + AVAILABLE_PLATFORMS.index(plat) if plat in AVAILABLE_PLATFORMS else 1
@@ -11982,18 +12178,11 @@ def _pager_key(state: TuiState, key: TuiKey) -> list[tuple]:
def _pager_last(state: TuiState, items: list) -> int:
"""The first item shown when the end of a pager is in view, found from
the end: only the last screenful is wrapped."""
width = min(state.cols, TUI_MAX_WIDTH) - 2
room = max(1, state.rows - 4)
used, index = 0, len(items)
while index > 0:
size = len(_text_lines([items[index - 1]], width))
if used + size > room:
break
used += size
index -= 1
return index
"""The first line shown when the end of a pager is in view: the view's
own height (tui_view), less the line that counts."""
total = _pager_starts(state, items, min(state.cols, TUI_MAX_WIDTH) - 2)[-1]
height = state.rows - 3
return 0 if total <= height else max(0, total - max(1, height - 1))
def tui_update(state: TuiState, key: TuiKey) -> list[tuple]:
@@ -12016,6 +12205,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
@@ -12099,6 +12294,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"}
@@ -12163,6 +12362,10 @@ def _take_event(state: TuiState, event: dict) -> None:
state.done_saves = event
elif kind == "found" and state.run == "install":
state.local_dests[event["dest"]] = (event["folder"], event["reason"])
elif kind == "data" and state.run == "check" and state.check is not None:
state.check["data_bytes"] = event["bytes"]
elif kind == "data" and state.run in ("confirm", "preview") and state.dry is not None:
state.dry["data_bytes"] = event["bytes"]
elif state.run == "check":
if kind == "check":
state.check = event
@@ -12388,6 +12591,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(
@@ -12433,24 +12639,46 @@ def _compose(
def _pager(
state: TuiState, width: int, height: int, items: list, lead: int = 0,
) -> list[TuiLine]:
"""The items in view, from the first shown on, each wrapped alone: the
8,000 lines of a full preview were wrapped again at every key."""
first = max(0, min(state.focus.get(state.screen, 0), _pager_last(state, items)))
if len(items) <= height and first == 0:
lines = _text_lines(items, width)
if len(lines) <= height:
return lines
"""The lines in view, scrolled line by line, the last one saying which
lines these are. The lead items (a count above a list) are not counted.
It scrolled by item and counted items: at 40x12 the result read
"1-2 of 5" over eight lines, its last paragraph cut, and a paragraph
longer than the screen could never be read to its end. Only the items
in view are wrapped: the 8,000 lines of a full preview were wrapped
again at every key.
"""
starts = _pager_starts(state, items, width)
total = starts[-1]
if total <= height:
return _text_lines(items, width)
room = max(1, height - 1)
first = max(0, min(state.focus.get(state.screen, 0), total - room))
index = bisect.bisect_right(starts, first) - 1
offset = first - starts[index]
lines: list[TuiLine] = []
index = first
while index < len(items) and len(lines) < room:
while index < len(items) and starts[index] < first + room:
lines += _text_lines([items[index]], width)
index += 1
whole = index if len(lines) <= room else index - 1
lines = lines[:room]
start = max(1, first + 1 - lead)
return lines + [_styled(f"{start}-{max(start, whole - lead)} of {len(items) - lead}",
"dim")]
skip = starts[lead] if lead else 0
start = max(1, first + 1 - skip)
end = max(start, first + room - skip)
return lines[offset:offset + room] + [_styled(f"{start}-{end} of {total - skip}", "dim")]
def _pager_starts(state: TuiState, items: list, width: int) -> list[int]:
"""The line each item starts on once wrapped at width, and the total
last, kept while the items stay the same. An item that fits is one line
without being wrapped."""
key = (width, [(str(item), getattr(item, "style", "")) for item in items])
if state.pager_cache is not None and state.pager_cache[0] == key:
return state.pager_cache[1]
starts = [0]
for item in items:
fits = getattr(item, "style", "") == "path" or cell_width(str(item)) <= width
starts.append(starts[-1] + (1 if fits else len(_text_lines([item], width))))
state.pager_cache = (key, starts)
return starts
def _tui_misplaced(state: TuiState) -> list[dict]:
@@ -12521,11 +12749,15 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list:
headline = f"{_plural(fetch, 'file')} to download, {format_size(fetch_bytes)}"
elif need:
headline = f"{_plural(len(copied), 'file')} to copy from this machine"
# The data trees' size comes from their own event: "2636 data files"
# beside "6.6 GB" said nothing of what they weigh.
data_size = (f" ({format_size(check['data_bytes'])})" if data and "data_bytes" in check
else "")
if need:
if data:
headline += f", and {_plural(data, 'data file')}"
headline += f", and {_plural(data, 'data file')}{data_size}"
if not need:
headline = (f"{_plural(data, 'data file')} to fetch" if data
headline = (f"{_plural(data, 'data file')} to fetch{data_size}" if data
else "Everything is in place")
wrong = check["wrong_hash"]
offline_lines = (["Copy them from your old card or a folder, or run the installer "
@@ -12570,7 +12802,9 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list:
f"{'is' if len(misplaced) == 1 else 'are'} in the wrong place, "
f"{'it' if len(misplaced) == 1 else 'they'} will be copied where the "
"emulators read "
f"{'it' if len(misplaced) == 1 else 'them'}.")
f"{'it' if len(misplaced) == 1 else 'them'}, your "
f"{'copy' if len(misplaced) == 1 else 'copies'} left as "
f"{'it is' if len(misplaced) == 1 else 'they are'}.")
if state.facts.get("writable") is False:
if state.os_type == "windows":
lines.append("Or run the installer as an administrator.")
@@ -12654,10 +12888,13 @@ def _view_platforms(state: TuiState, width: int, height: int) -> tuple:
def _view_folder(state: TuiState, width: int, height: int) -> tuple:
name = display_name(state.platform)
if state.platform in CARD_PLATFORMS:
bound = _tui_card_bound(state)
if bound:
title = "Where is the SD card?"
before = [f"{name} keeps its BIOS files on its SD card. Choose the card's "
"root folder, the one holding .system."]
before = [f"{bound[0]} keeps its BIOS files on its SD card. Choose the card's "
"root folder"
+ (", the one holding .system." if state.platform in CARD_PLATFORMS
else ".")]
elif state.folder_default is not None:
title = "Which folder?"
lead = {"found": f"{name} reads its BIOS files from:",
@@ -13119,6 +13356,10 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]:
sources = _tui_sources(state, installed, len(failed))
if sources:
lines.append(sources)
ok = {dest for dest, good, _why in state.finished if good}
if any(reason and dest in ok for dest, (_folder, reason) in state.local_dests.items()):
# A fix copies: the misplaced files are the user's, never moved.
lines.append("Your copies at the wrong place were left as they are.")
lines.append(f"BIOS folder: {state.dest}")
if installed + up_to_date:
lines += _saves_lines(state, state.done_saves)
@@ -13241,9 +13482,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",
@@ -13252,8 +13493,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]]:
@@ -13280,7 +13522,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]
@@ -13319,6 +13561,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
@@ -13465,7 +13709,11 @@ def tui_start(
state.stack = []
return state, _choose(state, plat, Path(args.dest))
if plat:
matched = [setup for setup in state.setups if setup[0] == plat]
bound = card_bound(args.platform, plat)
# A system that runs from its card is its card or the device, never
# this machine's own folder for the same list.
matched = [setup for setup in state.setups if setup[0] == plat
and (bound is None or detected_os(setup[0], setup[1]) == bound[0])]
if len(matched) == 1:
state.stack = []
return state, _choose(state, plat, matched[0][1])
@@ -13473,8 +13721,9 @@ def tui_start(
state.setups = matched
return state, []
state.platform = plat
state.card_bound = bound
state.stack = []
default = None if plat in CARD_PLATFORMS else _default_dest(os_type, plat)
default = None if bound else _default_dest(os_type, plat)
return state, _open_folder_screen(state, default)
if args.dest is not None:
card = card_platform(Path(args.dest))