feat: take clicks and taps on the full screen

Modes 1000 and 1006 only, SGR and legacy reports decoded, a hit map built from each drawn frame, action on release. Off on every way out, on the screens whose text is copied, with a pad route or RETROBIOS_NO_MOUSE.
This commit is contained in:
Abdessamad Derraz committed 2026-10-27 19:06:34 +01:00
1 parent 979fc03445
commit 72287e3310
6 files changed
+871 -23

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@
$ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "7998082004788f8d97177492ec371d8bdc507c5f132983ae05dc43be82d7e62a"
$defaultInstallSha256 = "811453011fa28ebe70094aefd7affd59ee4b102da89f23313b93d00392bc0486"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+333 -12
View File
@@ -12588,7 +12588,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",
@@ -12609,6 +12617,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",
@@ -12632,7 +12674,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")
@@ -12648,15 +12692,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:
@@ -12675,7 +12720,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:
@@ -12872,9 +12918,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:
@@ -13008,6 +13059,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.
@@ -13470,6 +13527,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)
@@ -15410,6 +15513,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:
@@ -15540,6 +15645,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")
@@ -15867,6 +16069,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)))
@@ -15925,14 +16133,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] = []
@@ -15979,7 +16198,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]:
@@ -17019,6 +17240,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",)
@@ -17195,14 +17417,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)
@@ -17600,6 +17902,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":
@@ -17627,6 +17941,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:
@@ -18009,7 +18326,11 @@ 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)
terminal.draw(lines, cols)
terminal.set_mouse(tui_wants_mouse(state, lines))
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
+1 -1
View File
@@ -18,7 +18,7 @@ esac
TEMP_INSTALLER=""
TEMP_DIRECTORY=""
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
DEFAULT_INSTALL_SHA256="7998082004788f8d97177492ec371d8bdc507c5f132983ae05dc43be82d7e62a"
DEFAULT_INSTALL_SHA256="811453011fa28ebe70094aefd7affd59ee4b102da89f23313b93d00392bc0486"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+8 -8
View File
@@ -27,8 +27,8 @@
{
"dest": "roms/ports/retrobios/install.py",
"repo_path": "install.py",
"size": 829229,
"sha256": "7998082004788f8d97177492ec371d8bdc507c5f132983ae05dc43be82d7e62a",
"size": 843025,
"sha256": "811453011fa28ebe70094aefd7affd59ee4b102da89f23313b93d00392bc0486",
"executable": false
},
{
@@ -71,8 +71,8 @@
"muos": {
"archive": {
"dest": "ARCHIVE/RetroBIOS.muxapp",
"size": 881778,
"sha256": "7e5cc8505ce9c810888baf62260b7ac2ff1cfcbce2e13c44b99d15458969f38a"
"size": 895574,
"sha256": "3fa9ebfa9e3c53a50d9ea9e0de4adc3b856312397802ebfda61c8d7883261c42"
},
"members": [
{
@@ -92,8 +92,8 @@
{
"name": "RetroBIOS/install.py",
"repo_path": "install.py",
"size": 829229,
"sha256": "7998082004788f8d97177492ec371d8bdc507c5f132983ae05dc43be82d7e62a",
"size": 843025,
"sha256": "811453011fa28ebe70094aefd7affd59ee4b102da89f23313b93d00392bc0486",
"executable": false
},
{
@@ -166,8 +166,8 @@
{
"dest": ".config/modules/retrobios/install.py",
"repo_path": "install.py",
"size": 829229,
"sha256": "7998082004788f8d97177492ec371d8bdc507c5f132983ae05dc43be82d7e62a",
"size": 843025,
"sha256": "811453011fa28ebe70094aefd7affd59ee4b102da89f23313b93d00392bc0486",
"executable": false
},
{
+511 -1
View File
@@ -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,8 @@ 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.
self.assertIn(install._MOUSE_ON.encode(), term.raw)
how(term)
return term
@@ -5395,6 +5411,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")
@@ -9623,5 +9642,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()
+17
View File
@@ -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
@@ -1600,6 +1616,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