diff --git a/install.ps1 b/install.ps1 index 4d00044ce..615575afc 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "825b55c17403287319a0c7d6e118e34d1bbf21cf0e6e66029c6c7eb767d2e0cb" + $defaultInstallSha256 = "7757b36db5beb3cbeb3717e0736db630237e87da5189a14d5092e679b69156af" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 7cbc3f95c..d7b2a6bda 100644 --- a/install.py +++ b/install.py @@ -12809,7 +12809,15 @@ _PASTE_END = "\x1b[201~" # Turned on by the interface, so a pasted path arrives as one piece and its # newline does not submit it half typed. _SCREEN_ON = "\x1b[?1049h\x1b[?25l\x1b[?2004h\x1b[2J" -_SCREEN_OFF = "\x1b[?2004l\x1b[0m\x1b[?25h\x1b[?1049l" +# The mouse modes are turned off on every way out, turned on or not: a +# shell left with them on reads each click as a line of garbage. +_SCREEN_OFF = "\x1b[?1006l\x1b[?1000l\x1b[?2004l\x1b[0m\x1b[?25h\x1b[?1049l" +# Presses and releases (1000), reported in SGR form (1006): a column past +# 223 does not fit the legacy form's byte. Never 1003 (every motion): a +# touch screen has no hover, and nothing here depends on one. +_MOUSE_ON = "\x1b[?1000h\x1b[?1006h" +_MOUSE_OFF = "\x1b[?1006l\x1b[?1000l" +TUI_MOUSE_ENV = "RETROBIOS_NO_MOUSE" # How a line is drawn without colour: the screen's title and a headline # bold, the row in focus reversed, the bar and secondary text dim. _STYLES = {"title": "\x1b[1m", "head": "\x1b[1m", "focus": "\x1b[7m", "dim": "\x1b[2m", @@ -12830,6 +12838,40 @@ class TuiKey(NamedTuple): text: str = "" +class TuiMouse(NamedTuple): + """One mouse report: the button (0 left, 1 middle, 2 right, 3 a legacy + release, 64 and 65 the wheel, plus 4, 8 and 16 for Shift, Meta and + Ctrl), the cell counted from 1, and whether the button went up.""" + + button: int + col: int + row: int + release: bool = False + name: str = "mouse" + text: str = "" + + +def _sgr_mouse(params: str, final: str) -> TuiMouse | None: + """An SGR report: ESC [ < button ; column ; row, M pressed, m released.""" + parts = params[1:].split(";") + if len(parts) != 3 or not all(part.isdigit() for part in parts): + return None + button, col, row = (int(part) for part in parts) + return TuiMouse(button, col, row, final == "m") + + +def _x10_mouse(data: str) -> TuiMouse | None: + """A legacy report: three characters, each 32 plus its value. A + position past 95 is a byte UTF-8 cannot read, and the report is dropped.""" + codes = [ord(char) - 32 for char in data] + if any(not 0 <= code <= 223 for code in codes): + return None + button, col, row = codes + if col < 1 or row < 1: + return None + return TuiMouse(button, col, row, button & 3 == 3 and not button & 64) + + _CSI_KEYS = {"A": "up", "B": "down", "C": "right", "D": "left", "H": "home", "F": "end", "Z": "shift-tab"} _TILDE_KEYS = {"1": "home", "7": "home", "4": "end", "8": "end", "5": "pgup", "6": "pgdn", @@ -12853,7 +12895,9 @@ def _plain_key(char: str) -> TuiKey | None: return TuiKey("char", char) -def _csi_key(params: str, final: str) -> TuiKey | None: +def _csi_key(params: str, final: str) -> TuiKey | TuiMouse | None: + if params.startswith("<"): + return _sgr_mouse(params, final) if final in "Mm" else None if final == "~": if params == "200": return TuiKey("paste-start") @@ -12869,15 +12913,16 @@ def _csi_key(params: str, final: str) -> TuiKey | None: return None -def _escape(buffer: str) -> tuple[int, TuiKey | None]: +def _escape(buffer: str) -> tuple[int, TuiKey | TuiMouse | None]: """The sequence at the start of buffer: the characters it takes and its key (None for one nobody bound), or 0 while more are needed to tell.""" if len(buffer) < 2: return 0, None if buffer[1] == "[": if buffer[2:3] == "M": - # An X10 mouse report: three bytes of position follow. - return (6, None) if len(buffer) >= 6 else (0, None) + # A legacy mouse report: three bytes of button and position + # follow, read as a report and never as keys. + return (6, _x10_mouse(buffer[3:6])) if len(buffer) >= 6 else (0, None) for end in range(2, len(buffer)): code = ord(buffer[end]) if 0x40 <= code <= 0x7E: @@ -12896,7 +12941,8 @@ def _escape(buffer: str) -> tuple[int, TuiKey | None]: class KeyDecoder: """Keys from what a terminal sends, however the reads split it. - Unknown sequences (F13, mouse reports) are dropped, never taken as text. + Unknown sequences (F13) are dropped, never taken as text. A mouse + report, SGR or legacy, comes out as a TuiMouse. """ def __init__(self) -> None: @@ -13093,9 +13139,14 @@ def _wrap(text: str, width: int, indent: str = " ", whole: str = "") -> list[st class TuiLine(str): """A screen line and how it is drawn: "head" (the screen's title), - "title", "focus", "dim", "hint" (the bar), "refusal" or plain.""" + "title", "focus", "dim", "hint" (the bar), "refusal" or plain. + + mark is what a click on the line reaches (tui_hits): ("row", index, + (action, value)) for a row of the list, ("scroll", kind, rows) for the + line saying which lines are in view, ("bar",) for the bar.""" style = "" + mark: tuple | None = None def _styled(text: str, style: str = "") -> TuiLine: @@ -13229,6 +13280,12 @@ class TuiState: self.card_note: str = "" # The rows the last view drew in its list, what a page moves by. self.page_rows: int = 0 + # The targets of the frame on screen (tui_hits), the target a + # button went down on, and per screen the first row of a list the + # wheel scrolled. + self.hits: TuiHits | None = None + self.pressed: tuple | None = None + self.list_top: dict = {} # The mounted cards of that system, as last looked for. self.cards: list | None = None # (width and text, lines) of the last pager wrapped. @@ -13691,6 +13748,52 @@ def tui_blocker( return "" +# TERM values of terminals that report no mouse the xterm way: the Linux +# console (gpm is its own protocol), the DEC VTs and BSD consoles. +_NO_MOUSE_TERMS = ("linux", "vt", "cons", "ansi") + + +def tui_mouse_blocker( + stdin: TextIO | None = None, stdout: TextIO | None = None, + env: Mapping[str, str] | None = None, +) -> str: + """Why the full screen leaves the mouse to the terminal, "" when it + asks for clicks. A pad route has no pointer and its mapper may send + anything a report would be confused with, and RETROBIOS_NO_MOUSE keeps + the terminal's own selection everywhere.""" + env = os.environ if env is None else env + stdin = stdin or sys.stdin + stdout = stdout or sys.stdout + if _env_on(env, TUI_MOUSE_ENV): + return f"{TUI_MOUSE_ENV} is set" + if env.get(TUI_PAD_ENV, "").strip(): + return f"{TUI_PAD_ENV} names a controller route" + if os.name == "nt": + # msvcrt.getwch reads keys, never a console mouse record. + return "a Windows console reports no clicks as keys" + for name, stream in (("input", stdin), ("output", stdout)): + isatty = getattr(stream, "isatty", None) + if not (isatty and isatty()): + return f"the {name} is not a terminal" + term = env.get("TERM", "").strip().lower() + if term in ("", "dumb", "unknown") or term.startswith(_NO_MOUSE_TERMS): + return f"TERM={term} reports no clicks" if term else "TERM is not set" + return "" + + +def tui_wants_mouse(state: TuiState, lines: list[str]) -> bool: + """Whether the frame takes clicks: not where a command or the result is + shown, so the terminal's own selection copies it as it always has, nor + where a path is typed, which a middle or right click pastes into.""" + if state.help: + return True + if state.screen in ("result", "command", "path"): + return False + hint = tui_hint(state) + text = " ".join(" ".join(str(line).split()) for line in lines) + return not any(launcher in text for launcher in (hint.launcher(), hint.launcher(True))) + + def tui_flags(state: TuiState) -> list[tuple[str, str | None]]: """The options that carry the choices made on screen, CLI ones included.""" flags = list(state.passthrough) @@ -15661,6 +15764,8 @@ def _pager_last(state: TuiState, items: list) -> int: def tui_update(state: TuiState, key: TuiKey) -> list[tuple]: """What a key does on the current screen, and the work it starts.""" + if key.name == "mouse": + return tui_mouse(state, key) # type: ignore[arg-type] if key.name in ("ctrl-c", "ctrl-d"): return tui_interrupt(state) if state.cols < TUI_MIN_COLS or state.rows < TUI_MIN_ROWS: @@ -15791,6 +15896,103 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]: return [] +# Lines one notch of the wheel moves. +TUI_WHEEL_LINES = 3 + + +def tui_mouse(state: TuiState, event: TuiMouse) -> list[tuple]: + """What a click, a tap or the wheel does, read against the frame on + screen when it came (state.hits): a frame drawn for another screen, or + a row no longer where it was drawn, does nothing. + + A tap acts when the button goes up, on the target it went down on: a + swipe that ends elsewhere does nothing. On a row it focuses the row, + and on the row in focus it does what Enter does: one terminal row is + far smaller than a finger, and a single tap acting on it would act on + a near miss. On a bar entry it presses that entry's key. The right and + middle buttons do nothing, nor a click with Shift, Meta or Ctrl held, + which the terminal keeps for its own selection and links. + """ + hits = state.hits + if (event.button & 32 or hits is None or hits.screen != state.screen + or hits.help != state.help or hits.size != (state.cols, state.rows) + or state.cols < TUI_MIN_COLS or state.rows < TUI_MIN_ROWS or state.app_running): + state.pressed = None + return [] + target = hits.at(event.col - 1, event.row - 1) + if event.button & 64: + if event.release or event.button & 3 > 1 or state.help: + return [] + return _wheel(state, hits, 1 if event.button & 1 else -1) + if event.button & 28: + state.pressed = None + return [] + button = event.button & 3 + if not event.release: + state.pressed = (state.screen, state.help, target) if button == 0 else None + return [] + pressed, state.pressed = state.pressed, None + if button not in (0, 3) or pressed != (state.screen, state.help, target): + return [] + if state.help: + # Any key closes the help, a tap too. + state.help = False + return [] + if target is None: + return [] + if target[0] == "key": + return tui_update(state, target[1]) + if target[0] == "scroll": + return _scroll_tap(state, target[1]) + rows = tui_rows(state) + index = target[1] + if index >= len(rows) or (rows[index].action, rows[index].value) != target[2]: + return [] + screen = state.screen + if state.editing: + state.editing = "" + elif max(0, min(state.focus.get(screen, 0), len(rows) - 1)) == index: + return tui_update(state, TuiKey("enter")) + state.status = state.status_more = "" + state.focus[screen] = index + return [] + + +def _wheel(state: TuiState, hits: TuiHits, step: int) -> list[tuple]: + """A notch of the wheel: the list, the message or the text moves, and + the focus moves only when it would leave the rows in view.""" + screen = state.screen + lines = step * TUI_WHEEL_LINES + if hits.listing is not None: + first, shown, total = hits.listing + top = max(0, min(first + lines, total - shown)) + state.list_top[screen] = top + rows = tui_rows(state) + focus = max(0, min(state.focus.get(screen, 0), len(rows) - 1)) + state.focus[screen] = max(top, min(focus, top + shown - 1)) + elif hits.scroll == "error": + state.focus["error_text"] = max(0, state.focus.get("error_text", 0) + lines) + elif hits.scroll == "pager": + last = _pager_last(state, _pager_items(state)) + state.focus[screen] = max(0, min(state.focus.get(screen, 0) + lines, last)) + return [] + + +def _scroll_tap(state: TuiState, kind: str) -> list[tuple]: + """A tap on the line saying which lines are in view: the next page, + and from the last one the first.""" + before = (dict(state.focus), dict(state.list_top)) + effects = tui_update(state, TuiKey("pgdn")) + # The view keeps a scroll inside the text: measured, not guessed. + tui_view(state, state.cols, state.rows) + if (dict(state.focus), dict(state.list_top)) != before: + return effects + if kind == "error": + state.focus["error_text"] = 0 + return effects + return effects + tui_update(state, TuiKey("home")) + + # The routes inside muxterm, where no button sends PgUp or PgDn. _NO_PAGE_PADS = ("muxterm", "gptokeyb_muxterm") @@ -16118,6 +16320,12 @@ def _text_lines(items: list, width: int, whole: bool = False) -> list[TuiLine]: lines: list[TuiLine] = [] for item in items: style = getattr(item, "style", "") + if getattr(item, "mark", None) is not None: + # A line saying which lines are in view keeps what a click on it does. + line = _styled(fit(str(item), width), style) + line.mark = item.mark + lines.append(line) + continue if style == "path" and not whole: # One line, cut in its middle: the end of a path tells it apart. lines.append(_styled(middle(str(item), width))) @@ -16176,14 +16384,25 @@ def _compose( return top_lines + _compose(state, width, room, before, rows, after) + footer_lines focus = max(0, min(state.focus.get(state.screen, 0), len(rows) - 1)) if rows else 0 row_lines = [_row_line(row, index == focus, width) for index, row in enumerate(rows)] + for index, (row, line) in enumerate(zip(rows, row_lines)): + line.mark = ("row", index, (row.action, row.value)) # What L1/R1 move by: the rows drawn. Six fewer than the window's height # skipped rows the intro left no room for at 40x12, muOS among them. state.page_rows = max(1, min(len(row_lines), room - 1)) if len(row_lines) >= room: first = focus - (room - 1) // 2 + # Scrolled by the wheel, the list stays where it was put while the + # focus is in view: centred again, the row under the pointer moved. + top = state.list_top.get(state.screen) + if top is not None and top <= focus < top + max(1, room - 1): + first = top + else: + state.list_top.pop(state.screen, None) lead = next((i for i, row in enumerate(rows) if row.action not in ("all", "none", "filter")), 0) body = _scrolled(row_lines, first, room, lead) + if len(row_lines) > room: + body[-1].mark = ("scroll", "list", len(rows)) else: budget = room - len(row_lines) top: list[TuiLine] = [] @@ -16230,7 +16449,9 @@ def _pager( 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")] + where = _styled(f"{start}-{end} of {total - skip}", "dim") + where.mark = ("scroll", "pager", total) + return lines[offset:offset + room] + [where] def _pager_starts(state: TuiState, items: list, width: int) -> list[int]: @@ -17273,6 +17494,7 @@ def _view_error(state: TuiState, width: int, height: int) -> tuple: first = max(0, min(state.focus.get("error_text", 0), len(text) - (room - 1))) state.focus["error_text"] = first text = _scrolled(text, first, room) + text[-1].mark = ("scroll", "error", 0) page = (TUI_ROUTES.get(state.pad, TUI_ROUTES["both"]).help["page"] or "PgUp/PgDn").split(", ")[0] hint += (f"{page} Scroll",) @@ -17449,14 +17671,94 @@ def tui_view(state: TuiState, cols: int, rows: int) -> list[str]: lines = [_styled(fit(f" RetroBIOS: {title}", cols), "head"), _styled("")] # A long line is drawn whole, the terminal folding it into the rows # after it (_text_lines): cut to the width, a copied path lost its end. - lines += [_styled(" " + line if line.style == "long" else fit(" " + line, cols), line.style) - for line in body[:height]] + for line in body[:height]: + drawn = _styled(" " + line if line.style == "long" else fit(" " + line, cols), + line.style) + drawn.mark = line.mark + lines.append(drawn) lines += [_styled("")] * (rows - 1 - len(lines)) # Never the last cell of the screen: a terminal may scroll when it is written. - lines.append(_styled(fit(" " + _hint(width, *hint), cols - 1), "hint")) + bar = _styled(fit(" " + _hint(width, *hint), cols - 1), "hint") + bar.mark = ("bar",) + lines.append(bar) return lines +class TuiHits(NamedTuple): + """What a click reaches on one drawn frame (tui_hits): each target is + (line, first column, column past the last, what it does), lines and + columns counted from 0. listing is (first row shown, rows shown, rows) + for a list taller than its room, scroll "pager" or "error" for a text + that scrolls.""" + + screen: str + help: bool + size: tuple[int, int] + targets: tuple + listing: tuple | None = None + scroll: str = "" + + def at(self, col: int, line: int) -> tuple | None: + return next((target for row, first, end, target in self.targets + if row == line and first <= col < end), None) + + +# The keys the bar's entries name, as a click on the entry presses them. A +# pair of keys (Left/Right, PgUp/PgDn) is no button: which one is meant +# cannot be told. +_BAR_CLICKS = { + "A/Enter": TuiKey("enter"), "A": TuiKey("enter"), "Enter": TuiKey("enter"), + "Start": TuiKey("enter"), "B/Esc": TuiKey("esc"), "B": TuiKey("esc"), + "Esc": TuiKey("esc"), "X/Space": TuiKey("char", " "), "X": TuiKey("char", " "), + "Space": TuiKey("char", " "), "Y/o": TuiKey("char", "o"), "Y": TuiKey("char", "o"), + "o": TuiKey("char", "o"), "/": TuiKey("char", "/"), "Ctrl-U": TuiKey("ctrl-u"), + "Ctrl-C": TuiKey("ctrl-c"), + # F1 and not ?, which the path screen types into the path. + "?": TuiKey("f1"), +} +_BAR_ENTRY = re.compile(r"\S+(?: \S+)*") + + +def _bar_click(state: TuiState, name: str) -> TuiKey | None: + """The key a bar entry's name stands for, on the route the bar names.""" + route = TUI_ROUTES.get(state.pad) + if route is not None and name not in _BAR_CLICKS: + name = next((key for key, mine in route.bar.items() if mine == name), name) + return _BAR_CLICKS.get(name) + + +def tui_hits(state: TuiState, lines: list[str]) -> TuiHits: + """The targets of the frame tui_view drew: a row of the list across + the whole width, its text or not, each entry of the bar that names a + key, and the line saying which lines are in view.""" + cols = max([state.cols, *(cell_width(str(line)) for line in lines)]) + targets: list[tuple] = [] + shown: list[int] = [] + listed = None + scroll = "" + for number, line in enumerate(lines): + mark = getattr(line, "mark", None) + if not mark: + continue + if mark[0] == "row": + targets.append((number, 0, cols, mark)) + shown.append(mark[1]) + elif mark[0] == "scroll": + targets.append((number, 0, cols, mark)) + if mark[1] == "list": + listed = mark[2] + else: + scroll = mark[1] + elif mark[0] == "bar" and not state.help: + for entry in _BAR_ENTRY.finditer(str(line)): + key = _bar_click(state, entry.group().split(" ", 1)[0]) + if key is not None: + targets.append((number, entry.start(), entry.end(), ("key", key))) + rows = (min(shown), len(shown), listed) if listed is not None and shown else None + return TuiHits(state.screen, state.help, (state.cols, state.rows), tuple(targets), + rows, scroll) + + def _setup_label(plat: str, path: Path) -> str: system = detected_os(plat, path) name = display_name(plat) @@ -17854,6 +18156,18 @@ class _TuiTerminal: self.heard = 0.0 self.frame: list[tuple[str, str]] = [] self.entered = False + # Whether clicks may be asked for here (tui_mouse_blocker), and + # whether they are now: off on a screen whose text is copied. + self.mouse_allowed = not tui_mouse_blocker(stdin, stdout) + self.mouse = False + + def set_mouse(self, on: bool) -> None: + """Ask for clicks or give them back to the terminal, written only + when that changes.""" + on = on and getattr(self, "mouse_allowed", False) and self.entered + if on != getattr(self, "mouse", False): + self.mouse = on + self.write(_MOUSE_ON if on else _MOUSE_OFF) def enter(self) -> None: if os.name != "nt": @@ -17881,6 +18195,9 @@ class _TuiTerminal: # the cursor are as they were, nothing is written over them. if getattr(self, "entered", True): self.entered = False + # The mouse modes go with the screen (_SCREEN_OFF), and come + # back with the next frame once entered again. + self.mouse = False self.write(_SCREEN_OFF) finally: if self.saved is not None: @@ -18263,7 +18580,12 @@ def run_tui(args: argparse.Namespace) -> int: state.cols, state.rows = cols, rows terminal.frame = [] state.now = time.monotonic() - terminal.draw(tui_view(state, cols, rows), cols) + lines = tui_view(state, cols, rows) + # A click is read against the frame it was made on. + state.hits = tui_hits(state, lines) + # Before the frame: a frame on screen takes clicks as it shows. + terminal.set_mouse(tui_wants_mouse(state, lines)) + terminal.draw(lines, cols) for key in terminal.keys(TUI_FRAME): if key.name == "ctrl-z": # ISIG is off, so Ctrl-Z comes as a key: it stops what diff --git a/install.sh b/install.sh index a53481c67..629d48f74 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="825b55c17403287319a0c7d6e118e34d1bbf21cf0e6e66029c6c7eb767d2e0cb" +DEFAULT_INSTALL_SHA256="7757b36db5beb3cbeb3717e0736db630237e87da5189a14d5092e679b69156af" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 22a20653b..0f5e5493c 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": 840737, - "sha256": "825b55c17403287319a0c7d6e118e34d1bbf21cf0e6e66029c6c7eb767d2e0cb", + "size": 854609, + "sha256": "7757b36db5beb3cbeb3717e0736db630237e87da5189a14d5092e679b69156af", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 893286, - "sha256": "d463dc4f1203733a0b4932d90a659c03b91701f27a6da7b01becb69f05dab83c" + "size": 907158, + "sha256": "15c404a5dfb8d084caa11843d3969e7f0222f434c9b2320b04a0d89ca7336f21" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 840737, - "sha256": "825b55c17403287319a0c7d6e118e34d1bbf21cf0e6e66029c6c7eb767d2e0cb", + "size": 854609, + "sha256": "7757b36db5beb3cbeb3717e0736db630237e87da5189a14d5092e679b69156af", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 840737, - "sha256": "825b55c17403287319a0c7d6e118e34d1bbf21cf0e6e66029c6c7eb767d2e0cb", + "size": 854609, + "sha256": "7757b36db5beb3cbeb3717e0736db630237e87da5189a14d5092e679b69156af", "executable": false }, { diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 158805065..b53c1bf31 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -144,7 +144,9 @@ class KeyDecoding(unittest.TestCase): for sequence in ("\x1b[25~", "\x1b[1;5A", "\x1b[M !!", "\x1b[<0;10;5M", "\x1b[?1;2c", "\x1bOZ"): with self.subTest(sequence=sequence): - self.assertEqual(self.feed(sequence + "x"), keys("char:x")) + # A mouse report is a report (MouseReports), never typed. + self.assertEqual([key for key in self.feed(sequence + "x") + if key.name != "mouse"], keys("char:x")) def test_an_incomplete_sequence_is_dropped_at_the_timeout(self): self.assertEqual(self.feed("\x1b[1"), []) @@ -1935,6 +1937,9 @@ class WorkFolders(unittest.TestCase): def draw(self, lines, cols) -> None: pass + def set_mouse(self, on: bool) -> None: + pass + def keys(self, timeout): WorkFolders.Terminal.state_to(self) return [Key("enter")] @@ -3891,6 +3896,9 @@ class Crash(unittest.TestCase): def draw(self, lines, cols) -> None: pass + def set_mouse(self, on: bool) -> None: + pass + def keys(self, timeout): raise RuntimeError("a defect") @@ -5311,6 +5319,12 @@ class TheTerminalComesBack(unittest.TestCase): raw = term.raw[since:] self.assertGreater(raw.rfind(b"\x1b[?1049l"), raw.rfind(b"\x1b[?1049h"), raw[-200:]) self.assertGreater(raw.rfind(b"\x1b[?25h"), raw.rfind(b"\x1b[?25l"), raw[-200:]) + # Clicks are given back too: a shell left with them on reads each + # one as a line of escape bytes. + for mode in (b"1000", b"1006"): + self.assertGreater(raw.rfind(b"\x1b[?" + mode + b"l"), + raw.rfind(b"\x1b[?" + mode + b"h"), raw[-200:]) + self.assertNotIn(b"\x1b[?1003h", raw) def assertModesBack(self, modes: list) -> None: fresh = self.fresh_modes() @@ -5323,6 +5337,10 @@ class TheTerminalComesBack(unittest.TestCase): self.repo() term = self.start() term.wait("Where do you play?") + # Every way out below starts with the clicks asked for, written + # right after the frame. + term.settle() + self.assertIn(install._MOUSE_ON.encode(), term.raw) how(term) return term @@ -5395,6 +5413,9 @@ class TheTerminalComesBack(unittest.TestCase): self.assertIn(b"\x1b[?1049h", term.raw[mark:]) term.wait("Where do you play?") self.assertFalse(termios.tcgetattr(term.fd)[3] & termios.ECHO) + # The clicks come back with the screen. + term.settle() + self.assertIn(install._MOUSE_ON.encode(), term.raw[mark:]) term.send("q") term.wait("PROMPT$") self.typed(term, "exit") @@ -9723,5 +9744,496 @@ class TheAndroidResultReadsWhole(unittest.TestCase): self.assertNotIn("Needed to start an emulator", said) +MOUSE_SIZES = ((40, 12), (80, 24), (120, 40)) + + +def sgr(col: int, row: int, button: int = 0, release: bool = False) -> str: + """An SGR mouse report, the cell counted from 1.""" + return f"\x1b[<{button};{col};{row}{'m' if release else 'M'}" + + +def x10(col: int, row: int, button: int = 0) -> str: + """A legacy report: 32 plus each value.""" + return "\x1b[M" + "".join(chr(32 + value) for value in (button, col, row)) + + +def drawn(state: install.TuiState, cols: int, rows: int) -> list: + """The frame as the loop draws it, its targets kept as the loop keeps them.""" + state.cols, state.rows = cols, rows + lines = install.tui_view(state, cols, rows) + state.hits = install.tui_hits(state, lines) + return lines + + +def tap(state: install.TuiState, col: int, row: int, button: int = 0) -> list: + """Press and release on one cell, columns and lines counted from 0.""" + effects = install.tui_update(state, install.TuiMouse(button, col + 1, row + 1)) + return effects + install.tui_update(state, install.TuiMouse(button, col + 1, row + 1, + True)) + + +class MouseReports(unittest.TestCase): + """Mouse reports come out of the decoder as reports, never as keys.""" + + def feed(self, *chunks: str) -> list: + decoder = install.KeyDecoder() + found = [] + for chunk in chunks: + found += decoder.feed(chunk) + return found + decoder.flush() + + def test_sgr_press_release_and_wheel(self): + Mouse = install.TuiMouse + self.assertEqual(self.feed(sgr(10, 5), sgr(10, 5, release=True)), + [Mouse(0, 10, 5), Mouse(0, 10, 5, True)]) + self.assertEqual(self.feed(sgr(3, 4, 64), sgr(3, 4, 65)), + [Mouse(64, 3, 4), Mouse(65, 3, 4)]) + self.assertEqual(self.feed(sgr(1, 1, 2)), [Mouse(2, 1, 1)]) + + def test_sgr_past_the_legacy_limit(self): + self.assertEqual(self.feed(sgr(300, 250)), [install.TuiMouse(0, 300, 250)]) + + def test_sgr_split_across_reads(self): + report = sgr(12, 7) + for cut in range(1, len(report)): + with self.subTest(cut=cut): + decoder = install.KeyDecoder() + found = decoder.feed(report[:cut]) + found += decoder.feed(report[cut:] + "j") + self.assertEqual(found, [install.TuiMouse(0, 12, 7), Key("char", "j")]) + + def test_legacy_reports(self): + Mouse = install.TuiMouse + self.assertEqual(self.feed(x10(5, 6), x10(5, 6, 3), x10(5, 6, 64)), + [Mouse(0, 5, 6), Mouse(3, 5, 6, True), Mouse(64, 5, 6)]) + + def test_legacy_bytes_are_never_keys(self): + """A position past 95 is a byte UTF-8 replaces: the report is + dropped, and none of its bytes is read as a key press.""" + for tail in ("�!!", "!�!", " qj"): + with self.subTest(tail=tail): + found = self.feed("\x1b[M" + tail, "k") + self.assertEqual([key for key in found if key.name != "mouse"], + [Key("char", "k")]) + + def test_a_cut_report_is_dropped_whole(self): + self.assertEqual(self.feed("\x1b[<0;10"), []) + self.assertEqual(self.feed("\x1b[<0;10;1;7M", "j"), [Key("char", "j")]) + + +class MouseEnabled(unittest.TestCase): + """Clicks are asked for only where they can come and are wanted.""" + + class Tty(io.StringIO): + def isatty(self) -> bool: + return True + + def blocker(self, env: dict, interactive: bool = True) -> str: + stream = self.Tty() if interactive else io.StringIO() + return install.tui_mouse_blocker(stream, stream, env) + + def test_an_xterm_like_terminal_takes_clicks(self): + for term in ("xterm", "xterm-256color", "screen-256color", "tmux-256color", + "alacritty", "foot", "xterm-kitty", "rxvt-unicode-256color"): + with self.subTest(term=term): + self.assertEqual(self.blocker({"TERM": term}), "") + + def test_what_keeps_them_off(self): + for env, interactive in ( + ({"TERM": "linux"}, True), ({"TERM": "dumb"}, True), ({}, True), + ({"TERM": "vt100"}, True), ({"TERM": "xterm"}, False), + ({"TERM": "xterm", install.TUI_PAD_ENV: "gptokeyb"}, True), + ({"TERM": "xterm", install.TUI_MOUSE_ENV: "1"}, True)): + with self.subTest(env=env, interactive=interactive): + self.assertNotEqual(self.blocker(env, interactive), "") + self.assertEqual(self.blocker({"TERM": "xterm", install.TUI_MOUSE_ENV: "0"}), "") + + def test_a_windows_console_reads_no_clicks(self): + with mock.patch.object(install.os, "name", "nt"): + self.assertIn("Windows", self.blocker({"TERM": "xterm"})) + + def test_the_terminal_writes_a_change_only(self): + terminal = install._TuiTerminal.__new__(install._TuiTerminal) + written = [] + terminal.write = written.append + terminal.mouse_allowed, terminal.mouse, terminal.entered = True, False, True + terminal.set_mouse(True) + terminal.set_mouse(True) + terminal.set_mouse(False) + self.assertEqual(written, [install._MOUSE_ON, install._MOUSE_OFF]) + terminal.mouse_allowed = False + terminal.set_mouse(True) + self.assertEqual(len(written), 2) + + def test_modes_asked_for(self): + self.assertEqual(install._MOUSE_ON, "\x1b[?1000h\x1b[?1006h") + self.assertIn(install._MOUSE_OFF, install._SCREEN_OFF) + source = (REPO_ROOT / "install.py").read_text(encoding="utf-8") + for mode in ("1002h", "1003h", "1005h", "1015h"): + self.assertNotIn("?" + mode, source) + + def test_the_text_to_copy_keeps_the_terminal_selection(self): + states = golden_states() + for name in ("result", "command", "path"): + with self.subTest(screen=name): + state = states[name] + self.assertFalse(install.tui_wants_mouse(state, drawn(state, 80, 24))) + for name in ("setup", "platforms", "plan", "systems", "options", "error", "quit"): + with self.subTest(screen=name): + state = states[name] + self.assertTrue(install.tui_wants_mouse(state, drawn(state, 80, 24))) + + def test_a_plan_naming_a_command_keeps_the_selection(self): + state = checked_state({"free": 50 * 1024 ** 3, "writable": False, + "cores": ["pcsx_rearmed"]}) + with mock.patch.object(install, "_on_read_only_mount", return_value=False): + lines = drawn(state, 120, 40) + self.assertIn("install.py", " ".join(lines)) + self.assertFalse(install.tui_wants_mouse(state, lines)) + + def test_the_view_adds_no_text(self): + """A frame drawn with its targets is the frame drawn without them.""" + patcher = mock.patch.object(install, "_mounted_cards", return_value=[]) + patcher.start() + self.addCleanup(patcher.stop) + for name, state in golden_states().items(): + for cols, rows in MOUSE_SIZES: + with self.subTest(screen=name, size=f"{cols}x{rows}"): + path = GOLDEN / f"{name}-{cols}x{rows}.txt" + if not path.exists(): + continue + lines = drawn(state, cols, rows) + text = "\n".join(line.rstrip() for line in lines) + "\n" + self.assertEqual(text, path.read_text(encoding="utf-8")) + + +class MouseOnEveryScreen(unittest.TestCase): + """A tap does on each screen what its key does, at each size.""" + + def setUp(self): + patcher = mock.patch.object(install, "_mounted_cards", return_value=[]) + patcher.start() + self.addCleanup(patcher.stop) + + @staticmethod + def twin(state: install.TuiState) -> install.TuiState: + import copy # noqa: PLC0415 + + return copy.deepcopy(state) + + def test_rows_and_bar_buttons(self): + checked = 0 + for name, base in golden_states().items(): + for cols, rows in MOUSE_SIZES: + lines = drawn(base, cols, rows) + hits = base.hits + for line, first, end, target in hits.targets: + with self.subTest(screen=name, size=f"{cols}x{rows}", target=target): + state, keyed = self.twin(base), self.twin(base) + if target[0] == "row": + index = target[1] + row_list = install.tui_rows(state) + focus = max(0, min(state.focus.get(state.screen, 0), + len(row_list) - 1)) + self.assertEqual(lines[line].style == "focus", index == focus) + # The whole width, the text's end or not. + effects = tap(state, cols - 1, line) + if index == focus: + self.assertEqual(effects, + install.tui_update(keyed, Key("enter"))) + self.assertEqual(state.stack, keyed.stack) + else: + self.assertEqual(effects, []) + self.assertEqual(state.focus[state.screen], index) + self.assertEqual(state.stack, base.stack) + elif target[0] == "key": + label = str(lines[line])[first:end].split(" ", 1)[0] + self.assertIn(label, install._BAR_CLICKS) + effects = tap(state, first, line) + self.assertEqual(effects, install.tui_update(keyed, target[1])) + self.assertEqual((state.stack, state.help), + (keyed.stack, keyed.help)) + checked += 1 + self.assertGreater(checked, 200) + + def test_two_taps_activate_a_row(self): + state = fresh_state() + lines = drawn(state, 80, 24) + another = next(index for index, line in enumerate(lines) + if "Choose another" in line or "another" in line.lower()) + self.assertEqual(tap(state, 30, another), []) + self.assertEqual(state.screen, "setup") + drawn(state, 80, 24) + tap(state, 30, another) + self.assertEqual(state.screen, "platforms") + + def test_release_acts_not_press(self): + state = fresh_state() + lines = drawn(state, 80, 24) + row = next(i for i, line in enumerate(lines) if line.style != "focus" + and getattr(line, "mark", None) and line.mark[0] == "row") + install.tui_update(state, install.TuiMouse(0, 5, row + 1)) + self.assertEqual(state.focus.get("setup", 0), 0) + # Released on another row: a swipe, which does nothing. + focused = next(i for i, line in enumerate(lines) if line.style == "focus") + install.tui_update(state, install.TuiMouse(0, 5, focused + 1, True)) + self.assertEqual(state.focus.get("setup", 0), 0) + self.assertIsNone(state.pressed) + # A release with no press before it does nothing either. + install.tui_update(state, install.TuiMouse(0, 5, row + 1, True)) + self.assertEqual(state.focus.get("setup", 0), 0) + + def test_legacy_release_names_no_button(self): + state = fresh_state() + lines = drawn(state, 80, 24) + row = next(i for i, line in enumerate(lines) if line.style != "focus" + and getattr(line, "mark", None) and line.mark[0] == "row") + decoder = install.KeyDecoder() + for event in decoder.feed(x10(5, row + 1) + x10(5, row + 1, 3)): + install.tui_update(state, event) + self.assertEqual(state.focus["setup"], lines[row].mark[1]) + + def test_right_middle_and_modified_clicks_do_nothing(self): + for button in (1, 2, 4, 8, 16, 32): + with self.subTest(button=button): + state = fresh_state() + lines = drawn(state, 80, 24) + row = next(i for i, line in enumerate(lines) if line.style != "focus" + and getattr(line, "mark", None) and line.mark[0] == "row") + self.assertEqual(tap(state, 5, row, button), []) + self.assertEqual(state.focus.get("setup", 0), 0) + + def test_a_click_on_another_frame_does_nothing(self): + """Read against the frame on screen when it came: Enter, typed in + the same read, opened the next screen before the click was read.""" + state = fresh_state() + lines = drawn(state, 80, 24) + row = next(i for i, line in enumerate(lines) if line.style != "focus" + and getattr(line, "mark", None) and line.mark[0] == "row") + install.tui_update(state, Key("char", "?")) + self.assertEqual(tap(state, 5, row), []) + self.assertTrue(state.help) + state = fresh_state() + drawn(state, 80, 24) + state.stack.append("platforms") + self.assertEqual(tap(state, 5, row), []) + self.assertEqual(state.focus.get("platforms", 0), 0) + # The same screen with its rows changed under the frame. + state = fresh_state() + drawn(state, 80, 24) + state.setups = state.setups[1:] + tap(state, 5, row) + self.assertEqual(state.focus.get("setup", 0), 0) + # A frame of another size. + state = fresh_state() + drawn(state, 80, 24) + state.cols = 100 + self.assertEqual(tap(state, 5, row), []) + self.assertEqual(state.focus.get("setup", 0), 0) + + def test_a_tap_closes_the_help(self): + state = golden_states()["help"] + drawn(state, 80, 24) + tap(state, 3, 5) + self.assertFalse(state.help) + + def long_list(self, cols: int = 40, rows: int = 12) -> install.TuiState: + state = fresh_state() + state.stack = ["setup", "platforms"] + drawn(state, cols, rows) + self.assertIsNotNone(state.hits.listing) + return state + + def test_the_wheel_scrolls_a_list_and_keeps_the_focus_in_view(self): + for cols, rows in MOUSE_SIZES[:2]: + with self.subTest(size=f"{cols}x{rows}"): + state = self.long_list(cols, rows) + first, shown, total = state.hits.listing + self.assertEqual(state.focus.get("platforms", 0), 0) + install.tui_update(state, install.TuiMouse(65, 5, 4)) + lines = drawn(state, cols, rows) + newfirst, _shown, _total = state.hits.listing + self.assertEqual(newfirst, first + install.TUI_WHEEL_LINES) + # Out of view, the focus came to the first row in view. + self.assertEqual(state.focus["platforms"], newfirst) + focused = [line.mark[1] for line in lines if line.style == "focus"] + self.assertEqual(focused, [newfirst]) + # Down again: the row in focus is still in view, and stays. + state.focus["platforms"] = newfirst + shown - 1 + drawn(state, cols, rows) + install.tui_update(state, install.TuiMouse(64, 5, 4)) + drawn(state, cols, rows) + self.assertEqual(state.hits.listing[0], first) + self.assertEqual(state.focus["platforms"], first + shown - 1) + # To the end and no further. + for _notch in range(total): + install.tui_update(state, install.TuiMouse(65, 5, 4)) + drawn(state, cols, rows) + self.assertEqual(state.hits.listing[0], total - shown) + # A key afterwards moves the focus from where the wheel put it. + at = state.focus["platforms"] + install.tui_update(state, Key("down")) + self.assertEqual(state.focus["platforms"], at + 1) + + def test_the_wheel_scrolls_a_text(self): + state = golden_states()["preview"] + state.dry_files = [{**FILES[0], "dest": f"file{n}.bin"} for n in range(40)] + drawn(state, 40, 12) + self.assertEqual(state.hits.scroll, "pager") + install.tui_update(state, install.TuiMouse(65, 5, 4)) + self.assertEqual(state.focus["preview"], install.TUI_WHEEL_LINES) + install.tui_update(state, install.TuiMouse(64, 5, 4)) + self.assertEqual(state.focus["preview"], 0) + install.tui_update(state, install.TuiMouse(64, 5, 4)) + self.assertEqual(state.focus["preview"], 0) + + def test_the_wheel_scrolls_a_long_message(self): + state = golden_states()["error"] + state.error = [f"Line {n} of a long message." for n in range(30)] + drawn(state, 40, 12) + self.assertEqual(state.hits.scroll, "error") + focus = state.focus.get("error", 0) + install.tui_update(state, install.TuiMouse(65, 5, 4)) + self.assertEqual(state.focus["error_text"], install.TUI_WHEEL_LINES) + self.assertEqual(state.focus.get("error", 0), focus) + + def test_a_tap_on_the_count_shows_the_next_page(self): + state = self.long_list() + lines = drawn(state, 40, 12) + where = next(i for i, line in enumerate(lines) + if getattr(line, "mark", None) and line.mark[0] == "scroll") + before = state.hits.listing[0] + tap(state, 2, where) + drawn(state, 40, 12) + self.assertGreater(state.hits.listing[0], before) + last = len(install.tui_rows(state)) - 1 + for _page in range(40): + if state.focus["platforms"] == last: + break + tap(state, 2, where) + drawn(state, 40, 12) + self.assertEqual(state.focus["platforms"], last) + # From the last page it goes back to the first. + tap(state, 2, where) + drawn(state, 40, 12) + self.assertEqual((state.focus["platforms"], state.hits.listing[0]), (0, 0)) + + def test_a_tap_on_a_row_ends_the_typing_of_a_filter(self): + state = self.long_list(80, 24) + install.tui_update(state, Key("char", "/")) + install.tui_update(state, Key("char", "r")) + lines = drawn(state, 80, 24) + row = next(i for i, line in enumerate(lines) if line.style != "focus" + and getattr(line, "mark", None) and line.mark[0] == "row" + and line.mark[2][0] == "platform") + tap(state, 5, row) + self.assertEqual(state.editing, "") + self.assertEqual(state.focus["platforms"], lines[row].mark[1]) + self.assertEqual(state.filters["platforms"], "r") + + +@unittest.skipUnless(pyte and pty and hasattr(pty, "fork"), + "screen tests need pyte (pip install pyte==0.8.2) and a pty") +class MouseOnAPty(unittest.TestCase): + """The real install.py --tui, clicked through a pseudo-terminal.""" + + repo = EndToEnd.repo + start = EndToEnd.start + + @staticmethod + def line_of(term: Pty, needle: str) -> int: + return next(index for index, line in enumerate(term.screen.display) if needle in line) + + def click(self, term: Pty, col: int, row: int) -> None: + term.send(sgr(col + 1, row + 1), sgr(col + 1, row + 1, release=True)) + + def test_taps_choose_a_row_and_press_the_bar(self): + self.repo() + term = self.start() + term.wait("Where do you play?") + term.settle() + self.assertIn(install._MOUSE_ON.encode(), term.raw) + row = self.line_of(term, "Another setup") + self.click(term, 60, row) + term.settle() + self.assertTrue(term.screen.buffer[row][3].reverse, term.text()) + self.assertIn("Where do you play?", term.text()) + self.click(term, 60, row) + term.wait("Which setup?") + # The bar's ? Help, pressed by a click. + bar = term.screen.display[-1] + self.click(term, bar.index("? Help") + 1, len(term.screen.display) - 1) + term.wait("Any key closes this help") + self.click(term, 10, 5) + term.wait("Which setup?") + # Legacy reports move the focus the same way. + row = self.line_of(term, " > ") + 2 + term.send(x10(20, row + 1), x10(20, row + 1, 3)) + term.settle() + self.assertTrue(term.screen.buffer[row][3].reverse, term.text()) + term.send("q") + self.assertEqual(term.close(), 130) + raw = term.raw + self.assertGreater(raw.rfind(b"\x1b[?1000l"), raw.rfind(b"\x1b[?1000h")) + + def test_the_result_gives_the_mouse_back(self): + self.repo() + term = self.start() + term.wait("Where do you play?") + term.send("\r") + term.wait("files to download") + self.click(term, 20, self.line_of(term, "> Install")) + term.wait("files installed.") + term.settle() + mark = term.raw.rfind(b"files installed") + self.assertGreater(term.raw.rfind(b"\x1b[?1000l"), term.raw.rfind(b"\x1b[?1000h")) + self.assertNotIn(install._MOUSE_ON.encode(), term.raw[mark:]) + term.send("\r") + self.assertEqual(term.close(), 0) + + def test_no_clicks_asked_when_turned_off(self): + for name, value in ((install.TUI_MOUSE_ENV, "1"), (install.TUI_PAD_ENV, "gptokeyb")): + with self.subTest(env=name): + self.repo() + self.env[name] = value + term = self.start() + term.wait("RetroBIOS") + term.settle() + term.send("\x03") + term.close() + self.assertNotIn(b"\x1b[?1000h", term.raw) + + def test_line_mode_asks_for_no_clicks(self): + self.repo() + term = self.start("--plain") + term.settle(quiet=1.0) + term.send("\x03") + term.close() + self.assertNotIn(b"\x1b[?1000h", term.raw) + self.assertNotIn(b"\x1b[?1006h", term.raw) + + def test_a_crash_gives_the_clicks_back(self): + """The crash path ends in _tui_cleanup, as every other way out.""" + master, slave = os.openpty() + self.addCleanup(os.close, master) + self.addCleanup(os.close, slave) + given = open(slave, encoding="utf-8", closefd=False) # noqa: SIM115 + shown = open(slave, "w", encoding="utf-8", closefd=False) # noqa: SIM115 + self.addCleanup(given.close) + self.addCleanup(shown.close) + terminal = install._TuiTerminal(given, shown) + terminal.mouse_allowed = True + terminal.enter() + terminal.set_mouse(True) + problems = install._tui_cleanup(terminal, [], {}, fresh_state()) + self.assertEqual(problems, []) + raw = b"" + while select.select([master], [], [], 0.2)[0]: + raw += os.read(master, 65536) + for mode in (b"1000", b"1006"): + self.assertGreater(raw.rfind(b"\x1b[?" + mode + b"l"), + raw.rfind(b"\x1b[?" + mode + b"h")) + + if __name__ == "__main__": unittest.main() diff --git a/wiki/installer.md b/wiki/installer.md index e165ef4f1..801d8e77d 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -238,6 +238,22 @@ started it, the one thing that can resume it: started from a terminal app's `-e`, a desktop entry or a launcher's `sh -c`, the key is ignored and the bar says why. +A mouse or a touch screen works too, beside the keys and buttons. A click or +a tap on a row moves the cursor there, and a second one on the row under the +cursor does what Enter does: one row of a terminal is far smaller than a +finger. The entries of the bottom line press their key, the line that says +which rows are in view shows the next page, and the wheel scrolls a list or a +text without moving the cursor unless it would leave the screen. Nothing +happens until the button or the finger comes up, and only where it went down. +The right and middle buttons do nothing. The installer asks the terminal for +clicks only on a terminal that reports them (`TERM` not `linux`, `dumb` or +unset), never on a handheld route (`RETROBIOS_PAD`) or with +`RETROBIOS_NO_MOUSE=1`, and gives them back on every way out, a stop with +Ctrl+Z included. On the result, the command line screen, any screen showing +a command and the screen where a path is typed, the terminal keeps the mouse, +so text is selected, copied and pasted as usual. Elsewhere, hold Shift while selecting (Option in macOS +Terminal and iTerm2). `--plain` never asks for clicks. + - `--tui` asks for the full screen, and names the reason when it cannot open (code 2). - `--plain`, `RETROBIOS_PLAIN=1` or `ACCESSIBLE=1` keep the questions line by @@ -1623,6 +1639,7 @@ RetroArch folder that holds only `scph5501.bin`, in the order written: | `RETROBIOS_PLAIN`, `ACCESSIBLE` | Set to anything but `0`: the questions come line by line, as with `--plain`, instead of the full-screen interface | | `RETROBIOS_PAD` | The buttons a handheld launcher's mapper sends to the full screen (`gptokeyb`, `gptokeyb_muxterm`, `muxterm`, `evmapy`, `miyoo`), so its hints name them. See [On a handheld](#on-a-handheld-the-retrobios-port) | | `RETROBIOS_IDLE_EXIT` | Seconds the full screen waits for a first button, counted while nothing runs. With none pressed, it closes with exit status 130, or with the code of the error or result screen it closed on | +| `RETROBIOS_NO_MOUSE` | Set to anything but `0`: the full screen never asks the terminal for clicks, and the mouse selects text everywhere as it does outside the installer | | `HTTPS_PROXY`, `NO_PROXY` | Honoured for every download through the standard library's default proxy handling: one names the proxy, the other the hosts to reach directly | ## When something goes wrong