mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-01 00:27:20 -05:00
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:
1 parent
979fc03445
commit
72287e3310
6 files changed
+871
-23
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user