fix: settle new screens before taking a tap

A press within 400 ms of a new screen belongs to a double tap on the
screen before and is dropped. A second tap is asked only of entries that
write files with nothing after them. The game data toggle keeps hidden
ticks, a choice in place offers no Install, and one total per plan.
This commit is contained in:
Abdessamad Derraz committed 2026-10-28 11:39:18 +01:00
1 parent 23c1f3121f
commit 2e870d0692
6 files changed
+595 -144

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 = "d6d3483bdaf5c54fae595a6469525015323defb7a4f9f22adbda80029fa3ee54"
$defaultInstallSha256 = "3f450bbd97735e20ce25bd4a7d89e94994d6162edaec108a63f8861452143d2c"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+244 -104
View File
@@ -6128,13 +6128,15 @@ def _report_outcome(
say("To check this later, run the same command with --check.")
def _offline_remedy(folders: list[str] | None = None) -> str:
def _offline_remedy(folders: list[str] | None = None, named: bool = False) -> str:
"""Where files --offline does not fetch can still come from: the
folders --from named (folders, on the screen) are not suggested again.
One wording for line mode, a run without questions, --check and the
screen."""
screen. named: the line before already says what the folders lack."""
labels = (folders if folders is not None
else [index.label for index in LOCAL.folders])
if labels and named:
return f"Run the installer again with a pack (--from-pack) or {offline_undo()}."
if labels:
named = ", ".join(screen_text(shown_path(label)) for label in labels)
return (f"They are not in {named}: run the installer again with a pack "
@@ -6171,7 +6173,7 @@ DATA_FILES_WORDS = ("data files: the files an emulator reads from a data folder
# The definitions in a few words, for a help with no room for the sentences.
_SHORT_DEFINITIONS = (
("Game data:", "Game data: what a game reads, of no use to other cores."),
("Data files:", "Data files: what an emulator reads from its own data folder."),
("Data files:", "Data files: an emulator's own data folder."),
)
@@ -6319,12 +6321,17 @@ def _print_plan(
trees = [(entry, needed) for entry, needed in trees if needed]
tree_counts = tally(meta for _entry, needed in trees for meta in needed.values())
tree_files = tree_counts.files
parts = []
if to_download:
parts.append(f"{_plural(counts.files, 'file')} ({format_size(counts.size)})")
if trees:
parts.append(f"{_plural(tree_files, 'data file')} ({format_size(tree_counts.size)}, "
f"extracted from {_plural(len(trees), 'archive')})")
# One count for the plan, the screen's (code_audit_21 P3-8): "8252
# files, and 2636 data files" read as two kinds of one thing. The kinds
# follow, adding up to it.
total = counts.files + tree_files
parts = ([f"{_plural(total, 'file')} ({format_size(counts.size + tree_counts.size)})"]
if total else [])
split = parts_words(counts if (manifest or {}).get("categorized")
else tally({"size": e.get("size")} for e in to_download),
tree_files, tree_counts.size)
if split and trees:
split += f", the data files extracted from {_plural(len(trees), 'archive')}"
# A pack is copied from, not downloaded: "Would download 23 files" was
# said of a run that makes no network request.
verb = "copy" if LOCAL.pack is not None else "download"
@@ -6335,13 +6342,15 @@ def _print_plan(
# --offline fetches none of them: "Would download" promised a
# transfer the run never makes, where the screen says "need the
# internet".
one = len(to_download) + tree_files == 1
one = total == 1
print(f"\n{' and '.join(parts)} {'needs' if one else 'need'} the internet, which "
f"{offline_named()} does not use. Run {offline_undo()}, and they would go "
f"to {shown_path(bios_path)}:")
elif parts:
source = " from the pack" if LOCAL.pack is not None else ""
print(f"\nWould {verb} {' and '.join(parts)}{source} to {shown_path(bios_path)}:")
if parts and split:
print(f" {split}.")
for line in copied:
print(line)
if not parts:
@@ -7803,11 +7812,18 @@ def _prompt_custom_selection(
# choice that holds none.
games = tally(menu_choice(files, omitted, installed, current, False)[0]
if cut is not None else [])
kept = menu_choice(files, omitted, installed, current, cutting)[0]
# A choice that keeps no file says so in the headline, as the screen
# does, and offers no Enter and no command: both installed nothing
# (code_audit_21 P3-6).
emptied = (emptied_words([system_title(name_, manifest) for name_ in current[0]],
cutting)
if isinstance(current, tuple) and not kept else "")
_print_menu_head(plat, shown, shown_omitted, installed, name, trees, bios_path,
current, object() if toggled else given, manifest, narrowed,
menu_choice(files, omitted, installed, current, cutting),
games.games if cutting else 0)
if (current is not given or toggled) and command is not None:
games.games if cutting else 0, emptied)
if (current is not given or toggled) and command is not None and not emptied:
print(f" Same choice without questions: {screen_text(command(current))}")
offline = _offline_only()
if offline:
@@ -7820,7 +7836,9 @@ def _prompt_custom_selection(
# Offline with nothing to copy from, nothing can be installed but
# what is in place already.
whole = "everything" if current is None else "these"
if dry_run:
if emptied:
pass
elif dry_run:
print(f" Enter) list {whole}, nothing is written")
elif offline and LOCAL.folders:
print(" Enter) install what is found in "
@@ -7846,22 +7864,14 @@ def _prompt_custom_selection(
print(f" g) {game_data_state(cut[0], games)}: "
f"{'keep it' if cut[0] else 'leave it out'}")
print(" q) quit")
keys = ["", "q", *(["c"] if choosable else []),
keys = [*([] if emptied else [""]), "q", *(["c"] if choosable else []),
*(["i"] if installed is not None and current != INSTALLED_ONLY else []),
*(["f"] if copying else []), *(["g"] if games.games else [])]
others = [key for key in keys if key not in ("", "q")]
answer = _ask("> ").lower()
while answer not in keys:
print(f" Type {', '.join(['Enter', *keys[2:]])} or q.")
print(f" Type {', '.join([*([] if emptied else ['Enter']), *others])} or q.")
answer = _ask("> ").lower()
kept = menu_choice(files, omitted, installed, current, cutting)[0]
if answer == "" and isinstance(current, tuple) and not kept:
# A choice that keeps no file stops here, as the screen offers
# no Install for it: Enter answered "All files up to date"
# (tui_audit_13 P3-11).
names = [system_title(name, manifest) for name in current[0]]
print(f" {emptied_words(names, cutting)} Type "
f"{', '.join(keys[2:])} or q.")
continue
if answer == "":
return current
if answer == "q":
@@ -7882,7 +7892,7 @@ def _print_menu_head(
installed: tuple[list[dict], list[dict]] | None, name: str, trees: list[dict],
bios_path: Path | None, current: object, given: object, manifest: dict | None,
narrowed: bool, chosen: tuple[list[dict], list[dict]] | None = None,
cut_games: int = 0,
cut_games: int = 0, emptied: str = "",
) -> None:
"""The plan above the line menu: what the choice holds, its folder, what
stands in its way and where the save files go, as the screen's plan.
@@ -7917,9 +7927,16 @@ def _print_menu_head(
if current is not given:
print("\n Counted again for your choices. Nothing is written before Enter.")
counts = tally(shown)
print(f"\n{name or display_name(plat)}: {sized_words(counts)}"
+ (f", and {_plural(data, 'data file')} ({format_size(data_size)})" if data else "")
+ named + ".")
if emptied:
# The screen's headline for a choice that keeps no file.
print(f"\n{name or display_name(plat)}: {emptied}")
if bios_path is not None:
print(f" BIOS folder: {shown_path(shown_bios_folder(bios_path, files))}")
return
# One total, data files in it, the kinds on the line under it adding up
# to it, as the screen's headline (code_audit_21 P3-8).
print(f"\n{name or display_name(plat)}: {_plural(counts.files + data, 'file')}, "
f"{format_size(counts.size + data_size)}" + named + ".")
categorized = bool((manifest or {}).get("categorized"))
kinds = counts if categorized else tally({"size": e.get("size")} for e in shown)
parts = parts_words(kinds, data, data_size, cut_games)
@@ -12607,20 +12624,25 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
# The count every later line of the platform divides: the files
# checked, planned, offered and installed are these.
run_counts = tally(files)
# Whether the list was narrowed: the data trees kept are then those
# of the files kept. Computed once, for the head and the run, which
# must count the same trees (code_audit_21 P3-10).
narrowed = bool(
target or args.system or args.core or args.region or args.installed_cores
or args.declared_only or len(files) + game_data_cut + len(unplaced)
!= len(manifest.get("files", [])) + len(variants if case_kept else [])
)
data_dirs = select_data_directories(manifest, files, narrowed)
# The kinds as the plan and the menu head say them (parts_words),
# the data files of the folders kept among them: this head said
# BIOS files and game data beside a plan of three kinds, neither
# defined (code_audit_20 P3-5).
head_trees = select_data_directories(manifest, files, bool(
target or args.system or args.core or args.region or args.installed_cores
or args.declared_only or len(files) + game_data_cut + len(unplaced)
!= len(manifest.get("files", [])) + len(variants if case_kept else [])))
head_data = sum(len(entry.get("files") or {}) for entry in head_trees)
head_bytes = sum(meta.get("size", 0) for entry in head_trees
# defined (code_audit_20 P3-5). One total above them, the data files
# in it (code_audit_21 P3-8).
head_data = sum(len(entry.get("files") or {}) for entry in data_dirs)
head_bytes = sum(meta.get("size", 0) for entry in data_dirs
for meta in (entry.get("files") or {}).values())
print(f" {_plural(run_counts.files, 'file')} ({format_size(run_counts.size)})"
+ (f", and {_plural(head_data, 'data file')} ({format_size(head_bytes)})"
if head_data else ""))
print(f" {_plural(run_counts.files + head_data, 'file')} "
f"({format_size(run_counts.size + head_bytes)})")
categorized_list = bool(manifest.get("categorized"))
kinds = run_counts if categorized_list else tally(
{"size": entry.get("size")} for entry in files)
@@ -12719,12 +12741,6 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
accepted=core_choices,
)
narrowed = bool(
target or args.system or args.core or args.region or args.installed_cores
or args.declared_only or len(files) + game_data_cut + len(unplaced)
!= len(manifest.get("files", [])) + len(variants if case_kept else [])
)
data_dirs = select_data_directories(manifest, files, narrowed)
data_states = [
(entry, data_directory_state(entry, bios_path)) for entry in data_dirs
] if args.check or EVENTS.on else []
@@ -13544,7 +13560,11 @@ def _bar_spans(text: str, first: str) -> list[tuple[int, int, str]]:
spans = []
for number, entry in enumerate(re.finditer(r"\S+(?: \S+)*", text)):
key = entry.group().split(" ", 1)[0]
if key in _bar_keys() and " " in entry.group():
if entry.group().endswith(TUI_ARMED):
# Armed by a first tap: the whole entry stands out, a plain
# ": tap again" looked like no change at all (tui_audit_14 P3-2).
spans.append((entry.start(), entry.end(), first))
elif key in _bar_keys() and " " in entry.group():
spans.append((entry.start(), entry.start() + len(key),
first if number == 0 else "muted"))
return spans
@@ -13713,6 +13733,10 @@ class TuiState:
# those of them that hold nothing under its options.
self.ticked_names: dict[str, str] = {}
self.emptied: list[str] = []
# Systems ticked that the last check did not list, game data left
# out: still the choice, ticked again by the check that lists them
# (tui_audit_14 P2-2), as line mode's g keeps its choice.
self.hidden_ticks: set[str] = set()
# Choose systems asked on a plan --system narrowed: the systems are
# opened once the check of every system is in.
self.widen: bool = False
@@ -14573,6 +14597,10 @@ def tui_rows(state: TuiState) -> list[TuiRow]:
if screen == "confirm":
if state.running or state.dry is None:
return []
if not _needed(state.dry) and not state.dry.get("data_missing"):
# Counted again, every file is in place: Install wrote nothing
# and said Done (code_audit_21 P3-4).
return [TuiRow("close", "Close"), TuiRow("back", "Back")]
if _offline_only() and not _dry_copies(state):
# The plan's rule: Install would only fail every file.
return [TuiRow("back", "Back")]
@@ -14600,9 +14628,27 @@ def _emptied_words(state: TuiState) -> str:
return emptied_words(state.emptied, state.no_game_data)
def _choice_in_place(state: TuiState) -> str:
"""The plan's headline when every file of the systems ticked is in
place, while the check counted the whole list: "NEC PC Engine is in
place", Install offered nothing (code_audit_21 P3-4). "" otherwise."""
if not state.systems or not state.ticked or not _tui_estimating(state):
return ""
chosen = [row for row in state.systems if row["system"] in state.ticked]
if not chosen or any(_needed(row) for row in chosen) or any(
event["state"] != "present" for event in _ticked_files(state)):
return ""
if _plan_data(state, state.check or {})[0]:
return ""
titles = _system_titles(state)
names = [_tui_system_label(row, titles) for row in chosen]
subject = (", ".join(names[:-1]) + " and " + names[-1]) if len(names) > 1 else names[0]
return f"{subject} {'are' if len(names) > 1 else 'is'} in place: nothing is left to install."
def _plan_rows(state: TuiState) -> list[TuiRow]:
check = state.check or {}
if state.emptied and state.systems and not state.ticked:
if (state.emptied and state.systems and not state.ticked) or _choice_in_place(state):
# The choice emptied by an option: Choose systems first, never an
# Install that would refuse or take the whole list.
rows = [TuiRow("systems", "Choose systems")]
@@ -14692,16 +14738,20 @@ def _tui_estimating(state: TuiState) -> bool:
and not _offline_only())
def _choice_entries(state: TuiState) -> list[dict]:
def _choice_entries(state: TuiState, hidden: bool = False) -> list[dict]:
"""The list's files the choice made keeps, game data included: the
systems ticked (or named with --system), the cores and the regions, as
line mode's menu_choice narrows them. The game data row, the save line
and the data files count these: over the whole list, a plan of one
system said 5089 game data files and 2.5 GB nothing would install."""
system said 5089 game data files and 2.5 GB nothing would install.
hidden adds the systems ticked that game data left out hides
(hidden_ticks), whose files the game data row brings back."""
manifest = state.manifest or {}
systems = [value for flag, value in tui_flags(state) if flag == "--system" and value]
if state.emptied and not state.ticked:
systems = sorted(state.ticked_names)
elif hidden and systems:
systems = sorted({*systems, *state.hidden_ticks})
# The second spellings the check installs, as line mode adds them where
# the folder keeps case.
variants = [entry for entry in manifest.get("case_variants") or []
@@ -14734,7 +14784,7 @@ def _game_data_row(state: TuiState) -> TuiRow | None:
has none, as line mode offers no g then: one decision in both modes."""
if not _tui_categories(state) or state.cli_no_game_data:
return None
games = tally(entry for entry in _choice_entries(state)
games = tally(entry for entry in _choice_entries(state, hidden=True)
if entry.get("category") == GAME_DATA)
if not games.games:
return None
@@ -14960,10 +15010,14 @@ def _keep_choice(state: TuiState) -> None:
state.keep_ticked = set(state.ticked_names)
return
needed = {row["system"] for row in state.systems if _needed(row)}
state.keep_ticked = None if needed <= state.ticked else set(state.ticked)
state.keep_ticked = (None if needed <= state.ticked and not state.hidden_ticks
else state.ticked | state.hidden_ticks)
titles = _system_titles(state)
state.ticked_names = {row["system"]: _tui_system_label(row, titles)
for row in state.systems if row["system"] in state.ticked}
state.ticked_names = {
**{name: label for name, label in state.ticked_names.items()
if name in state.hidden_ticks},
**{row["system"]: _tui_system_label(row, titles)
for row in state.systems if row["system"] in state.ticked}}
def tui_quit(state: TuiState, code: int | None = None, asked: bool = True) -> list[tuple]:
@@ -15685,6 +15739,9 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]:
if action == "install":
state.stack.pop()
return _install_now(state)
if action == "close":
state.exit_code = 0
return []
return _back(state)
if screen == "error":
if action == "retry":
@@ -16528,6 +16585,14 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]:
# Lines one notch of the wheel moves.
TUI_WHEEL_LINES = 3
# Seconds after a screen is drawn during which a press is taken for the
# second tap of a double tap made on the screen before, and dropped. A
# double click is two presses at most 400 ms (GTK, GNOME) to 500 ms
# (Windows, macOS) apart, and the new screen is drawn on the first release,
# about 100 ms after the first press: 0.4 s after the draw covers both. A
# deliberate tap comes later, a reader taking longer than that to find it.
TUI_TAP_SETTLE = 0.4
def tui_mouse(state: TuiState, event: TuiMouse) -> list[tuple]:
"""What a click, a tap or the wheel does, read against the frame on
@@ -16563,11 +16628,13 @@ def tui_mouse(state: TuiState, event: TuiMouse) -> list[tuple]:
return []
button = event.button & 3
# A tap is a press and a release on one target of one frame: a release
# on a frame drawn after its press is dropped, so the second tap of a
# double tap that changed the screen does nothing on the new one.
# on a frame drawn after its press is dropped, and so is a press within
# TUI_TAP_SETTLE of a new screen, so the second tap of a double tap that
# changed the screen does nothing on the new one (tui_audit_14 P2-1).
seen_on = (hits.frame, state.screen, len(state.stack), state.help, target)
if not event.release:
state.pressed = seen_on if button == 0 else None
settled = state.now - hits.shown >= TUI_TAP_SETTLE
state.pressed = seen_on if button == 0 and settled else None
return []
pressed, state.pressed = state.pressed, None
if button not in (0, 3) or pressed != seen_on:
@@ -16614,26 +16681,34 @@ TUI_TICK_ROWS = ("system", "core", "region")
def _bar_commits(state: TuiState, key: TuiKey) -> bool:
"""Whether a bar entry's key installs, quits or goes on to the last look
before an install: those take two taps, as a row does."""
if key.name == "ctrl-c":
return True
"""Whether a bar entry's key writes files with no screen after it to
look first: the plan's Install when it installs at once, and Put it on
the card. Those take a second tap. Anything else, Quit, Close, Continue
and Install on Confirm (itself the look before the install) included,
acts on one: TUI_TAP_SETTLE already keeps a double tap from reaching
the next screen."""
if key.name != "enter":
return False
rows = tui_rows(state)
if not rows:
return False
row = rows[max(0, min(state.focus.get(state.screen, 0), len(rows) - 1))]
return row.action in ("install", "quit", "close") or (
state.screen == "systems" and row.action == "system")
if state.screen == "plan" and row.action == "install":
# Counted again first when the options changed: Confirm comes next.
return bool(state.ticked or not state.systems) and \
tui_flags(state) == state.checked_flags
return state.screen == "result" and row.action == "addapp"
def _bar_tap(state: TuiState, key: TuiKey) -> list[tuple]:
"""A tap on a bar entry. One that installs, quits or opens Confirm is
drawn armed by the first tap and pressed by the second on the same
screen: the first entry keeps its cell from screen to screen, and a
double tap installed from the plan's bar, or quit from the Checking
screen with Ctrl-C's code (tui_audit_13 P2-1)."""
"""A tap on a bar entry. One that writes files (_bar_commits) is drawn
armed by the first tap and pressed by the second on the same screen."""
if key.name == "ctrl-c":
if state.run == "install" and state.running:
return tui_interrupt(state)
# A quit chosen with a finger is a quit chosen: QUIT_CODE, never
# the code of an interrupted run.
return tui_quit(state)
if not _bar_commits(state, key):
return tui_update(state, key)
armed = ("bar", state.screen, len(state.stack), key)
@@ -16641,12 +16716,6 @@ def _bar_tap(state: TuiState, key: TuiKey) -> list[tuple]:
state.tapped = armed
return []
state.tapped = None
if key.name == "ctrl-c":
if state.run == "install" and state.running:
return tui_interrupt(state)
# A quit chosen with a finger is a quit chosen: QUIT_CODE, never
# the code of an interrupted run.
return tui_quit(state)
return tui_update(state, key)
@@ -16655,12 +16724,17 @@ def _armed_hint(state: TuiState, parts: tuple) -> tuple:
presses it."""
tapped = state.tapped
if (state.help or not tapped or tapped[0] != "bar" or tapped[1] != state.screen
or tapped[2] != len(state.stack)):
or tapped[2] != len(state.stack) or not _bar_commits(state, tapped[3])):
return parts
return tuple(f"{part}: tap again" if _bar_click(state, part.split(" ", 1)[0]) == tapped[3]
return tuple(f"{part}: {TUI_ARMED}" if _bar_click(state, part.split(" ", 1)[0]) == tapped[3]
and " " in part else part for part in parts)
# What an armed bar entry adds to its name, drawn in the accent: a mouse
# clicks and a finger taps, and the screen cannot tell which it was.
TUI_ARMED = "click or tap again"
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."""
@@ -16842,8 +16916,11 @@ def tui_receive(state: TuiState, message: tuple) -> list[tuple]:
return _fail(state, tail, code)
state.ticked = {row["system"] for row in state.systems if _needed(row)}
state.emptied = []
state.hidden_ticks = set()
if state.keep_ticked is not None:
state.ticked = state.keep_ticked & {row["system"] for row in state.systems}
listed = {row["system"] for row in state.systems}
state.ticked = state.keep_ticked & listed
state.hidden_ticks = state.keep_ticked - listed
if state.keep_ticked and not state.ticked:
# Every system chosen holds nothing under these options:
# said, never taken as every system (tui_audit_13 P1-1).
@@ -17299,13 +17376,16 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list:
data_bytes = check.get("data_bytes", 0) if data else 0
if elsewhere:
# The folder by its last name: the whole path pushed the BIOS
# folder off a 40x15 screen. The Options row spells it out.
# folder off a 40x15 screen. The Options row spells it out. The
# data files are among those to fetch: one count (code_audit_21
# P3-8).
where = ", ".join(f"{len(events)} found in {_last_name(folder)}"
for folder, events in elsewhere.items())
headline = (f"{where}, {fetch} need the internet ({offline_named()})"
if fetch and offline
else f"{where}, {fetch} to download ({format_size(fetch_bytes)})"
if fetch else f"{where}, nothing to download")
rest = fetch + data
headline = (f"{where}, {rest} need the internet ({offline_named()})"
if rest and offline
else f"{where}, {rest} to download ({format_size(fetch_bytes + data_bytes)})"
if rest else f"{where}, nothing to download")
elif need and fetch and offline:
both = fetch + data > 1
headline = (f"{_plural(fetch + data, 'file')} {'need' if both else 'needs'} the "
@@ -17324,22 +17404,28 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list:
elif need and fetch:
headline = (f"{_plural(fetch + data, 'file')} to download, "
f"{format_size(fetch_bytes + data_bytes)}")
elif need and data:
copied_bytes = sum(sizes[dest_] for dest_ in copied)
headline = (f"{_plural(len(copied) + data, 'file')} to install, "
f"{format_size(copied_bytes + data_bytes)}")
elif need:
headline = f"{_plural(len(copied), 'file')} to copy from this machine"
# The data trees' size comes from their own event: "2636 data files"
# beside "6.6 GB" said nothing of what they weigh.
data_size = (f" ({format_size(check['data_bytes'])})" if data and "data_bytes" in check
else "")
if need and data and (elsewhere or not fetch):
headline += f", and {_plural(data, 'data file')}{data_size}"
if not need:
headline = (f"{_plural(data, 'data file')} to fetch{data_size}" if data
else "Everything is in place")
elif lacking and not fetch and not copied:
headline = pack_lacks_words(len(lacking), len(lacking))
wrong = check["wrong_hash"]
# Where some were found, the plan names the rest by kind ("Not in old:
# 10 BIOS files and 6 game data files.", _view_plan), and the remedy
# follows it: "They" sat under a split of all 22 (tui_audit_14 P3-7).
offline_lines = ([_offline_remedy([_last_name(str(folder))
for folder in state.from_folders])]
for folder in state.from_folders],
named=bool(elsewhere) and not dry)]
if offline and fetch else [])
# The folder comes second: on a 40x12 screen it is what is left once
# the rows are drawn, and nothing is written before it is seen.
@@ -17360,7 +17446,10 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list:
# Said again where it is the BIOS folder: the browser said it, and the
# plan named ~ alone.
lines += filter(None, [_no_setup_reads(state, dest)])
counts = (f"{check['present']} in place, {check['missing']} missing, "
# The data files missing among the missing: the headline counts them,
# and "10888 need the internet" sat over "8252 missing" (tui_audit_14
# P3-7).
counts = (f"{check['present']} in place, {check['missing'] + data} missing, "
f"{wrong} wrong {'copy' if wrong == 1 else 'copies'}")
# A first install's missing files are its normal state, not a warning.
lines.append(_styled(counts, "quiet") if not check["present"] and not wrong else counts)
@@ -17750,19 +17839,24 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple:
if state.emptied and state.systems and not state.ticked:
lines = [_styled(_emptied_words(state), "title"), *lines[1:2]]
estimating = False
elif _choice_in_place(state):
lines = [_styled(_choice_in_place(state), "title"), *lines[1:2]]
choice = []
estimating = False
elif estimating:
# The headline counts the systems ticked, as the list just did: at
# 40x12 the line below it was dropped, and the plan showed the
# total of all of them. Their data files too, as line mode's head.
chosen = _ticked_files(state)
more = (f", and {_plural(data, 'data file')} ({format_size(data_size or 0)})"
if data else "")
lines[0] = _styled(f"About {_plural(len(chosen), 'file')}, "
f"{format_size(sum(e['size'] for e in chosen))}{more}, for "
f"{len(state.ticked)} of {len(state.systems)} systems", "title")
# One total, the data files in it, the kinds under the folder
# adding up to it (code_audit_21 P3-8).
lines[0] = _styled(f"About {_plural(len(chosen) + data, 'file')}, "
f"{format_size(sum(e['size'] for e in chosen) + (data_size or 0))}"
f", for {len(state.ticked)} of {len(state.systems)} systems", "title")
# The in place and missing counts are the whole list's: under an
# estimate for some systems they read as its own.
whole = (f"{check['present']} in place, {check['missing']} missing, "
whole = (f"{check['present']} in place, "
f"{check['missing'] + check.get('data_missing', 0)} missing, "
f"{check['wrong_hash']} wrong ")
lines = [line for line in lines if not str(line).startswith(whole)]
# Under the folder and what named it, in this order: the card's note,
@@ -17782,8 +17876,23 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple:
# estimates them, those to install otherwise. Under the folder, never
# above it: inserted second, it pushed the folder off a 40x12 plan
# (tui_audit_12 P3-10).
parts, defined = _kinds_lines(state, _ticked_files(state) if estimating else [
event for event in state.files if event["state"] != "present"], data, data_size)
kinds_of = (_ticked_files(state) if estimating else
[event for event in state.files if event["state"] != "present"])
found = (_tui_found_elsewhere(state) if not estimating and _offline_only()
and state.check is check else {})
if found:
# Some were found in a folder: the kinds split those the headline
# says need the internet, named as such (tui_audit_14 P3-7).
took = {event["dest"] for events in found.values() for event in events}
kinds_of = [event for event in kinds_of if event["dest"] not in took]
parts, defined = _kinds_lines(state, kinds_of, data, data_size, whole=bool(found))
if _choice_in_place(state) or (state.emptied and state.systems and not state.ticked):
# Nothing of the choice is left to install: a split of the whole
# list under its headline counted files it does not hold.
parts = defined = ""
if found and parts:
parts = (f"Not in {', '.join(_last_name(str(folder)) for folder in found)}: "
f"{parts}")
lines[at:at] = [*([state.card_note] if state.card_note
and state.card_note != state.status else []),
*([f"{said[0].upper()}{said[1:]}."] if said else []), *choice,
@@ -17852,10 +17961,10 @@ def _plan_data(state: TuiState, check: dict) -> tuple[int, int | None]:
def _kinds_lines(state: TuiState, events: list[dict], data: int,
data_size: int | None) -> tuple[str, str]:
data_size: int | None, whole: bool = False) -> tuple[str, str]:
"""The plan's files by kind (parts_words) and what each kind is: the
same words as line mode's head, game data left out counted over the
choice."""
choice. whole: the kinds are said even when there is one."""
categories = _tui_categories(state)
categorized = bool(categories)
if state.no_game_data:
@@ -17867,6 +17976,8 @@ def _kinds_lines(state: TuiState, events: list[dict], data: int,
if categorized and state.no_game_data:
left = sum(1 for entry in _choice_entries(state) if entry.get("category") == GAME_DATA)
parts = parts_words(counts, data, data_size, left)
if whole and not parts and counts.files:
parts = f"{_plural(counts.files, 'file')} ({format_size(counts.size)})"
# Game data named as missing is defined too, on its own line: the
# definition was spliced into that sentence (tui_audit_13 P3-5).
missing = any(entry.get("category") == GAME_DATA for entry in _unavailable(state)[0])
@@ -18084,7 +18195,8 @@ def _view_confirm(state: TuiState, width: int, height: int) -> tuple:
# Back alone: one Back on the bar, and no word of an Install. Titled
# for what it is: "Confirm" over nothing to confirm.
return ("Nothing to install", _compose(state, width, height, lines, tui_rows(state)),
("A/Enter Back", "? Help"))
("A/Enter Back", "B/Esc Back", "? Help")
if tui_rows(state)[0].action == "close" else ("A/Enter Back", "? Help"))
return ("Confirm", _compose(state, width, height, lines, tui_rows(state)),
("A/Enter Install", "B/Esc Back", "? Help"))
@@ -18473,10 +18585,15 @@ def _view_error(state: TuiState, width: int, height: int) -> tuple:
def _view_quit(state: TuiState, width: int, height: int) -> tuple:
rows = tui_rows(state)
focus = max(0, min(state.focus.get("quit", 0), len(rows) - 1))
# On Stay, A says Stay: a second "B/Esc Stay" beside it named one choice
# twice (tui_audit_14 P3-11).
hint = (("A/Enter Choose",) if rows[focus].action == "stay"
else ("A/Enter Choose", "B/Esc Stay"))
return ("Quit?",
_compose(state, width, height, ["Quit without installing? Nothing was written."],
tui_rows(state)),
("A/Enter Choose", "B/Esc Stay"))
rows), hint)
_TUI_VIEWS: dict[str, Callable[[TuiState, int, int], tuple]] = {
@@ -18570,7 +18687,14 @@ def _tap_help(state: TuiState) -> list[tuple[str, str]]:
return [("Tap", "A bottom line entry presses its key"),
*([("Shift+drag", "Select text to copy")] if state.screen == "result"
else [])]
return [("Tap", "Choose a row, tap it again to open it")]
# Said at every size, 40x12 included: Install below waits for a second
# tap, and the help is where a reader looks when nothing happened.
armed = [row.action for row in tui_rows(state) if row.action in ("install", "addapp")]
if state.screen == "plan" and "install" in armed:
return [("Tap", "A row or Install: click or tap twice")]
if state.screen == "result" and armed:
return [("Tap", "A row or Put it on the card: tap twice")]
return [("Tap", "A row: tap twice to open it")]
def _help_key(state: TuiState) -> list[tuple[str, str]]:
@@ -18627,19 +18751,27 @@ def _view_help(state: TuiState, width: int, height: int) -> tuple:
# Cut from its end at 40x12, the help lost Quit and This help with no
# sign: what a list's keys also say elsewhere goes first.
for spare in ("First, last", "All, none", "extra", "Filter by setup or device name",
"Filter", "Options", "Choose a row, tap it again to open it", "This help",
"definitions"):
"Filter", "Options", "This help", "gap", "definitions"):
if (len(drawn(keys)) + len(extra) + len(defined)
+ len(_text_lines(more, width)) <= height):
break
if spare == "extra":
extra = []
elif spare == "gap":
# The blank line before the definitions, before a definition
# is shortened: the Tap line that says Install takes a second
# tap is kept at every size (tui_audit_14 P3-4).
defined = defined[1:] if defined[:1] == [""] else defined
elif spare == "definitions":
# The short form at 40x12, whole: the long one was cut in the
# middle of its second sentence.
short = " ".join(brief for long_form, brief in _SHORT_DEFINITIONS
if long_form in words)
defined = ["", *textwrap.wrap(short, width)] if short else []
short = [brief for long_form, brief in _SHORT_DEFINITIONS if long_form in words]
room = height - len(drawn(keys)) - len(extra) - len(_text_lines(more, width))
# Still too long: a definition left out whole, never cut in a
# sentence.
while len(short) > 1 and len(textwrap.wrap(" ".join(short), width)) > room:
short.pop()
defined = textwrap.wrap(" ".join(short), width) if short else []
else:
keys = [entry for entry in keys if entry[1] != spare]
return ("Keys", _compose(state, width, height, drawn(keys) + defined + extra + more, []),
@@ -18713,6 +18845,9 @@ class TuiHits(NamedTuple):
# whose targets differ from the one before it is another frame.
depth: int = 0
frame: int = 0
# When this screen was first drawn (state.now): a press sooner than
# TUI_TAP_SETTLE after it belongs to a tap made on the screen before.
shown: float = 0.0
def at(self, col: int, line: int) -> tuple | None:
return next((target for row, first, end, target in self.targets
@@ -18777,8 +18912,13 @@ def tui_hits(state: TuiState, lines: list[str]) -> TuiHits:
rows, scroll, len(state.stack))
before = state.hits
same = before is not None and before[:7] == hits[:7]
# Another screen, its help or its size: what a finger aimed at was
# elsewhere, and the settle time starts again.
moved = before is None or (before.screen, before.help, before.size, before.depth) != (
hits.screen, hits.help, hits.size, hits.depth)
return hits._replace(frame=before.frame if same else
(before.frame + 1 if before is not None else 0))
(before.frame + 1 if before is not None else 0),
shown=state.now if moved else before.shown)
def _setup_label(plat: str, path: Path) -> str:
+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="d6d3483bdaf5c54fae595a6469525015323defb7a4f9f22adbda80029fa3ee54"
DEFAULT_INSTALL_SHA256="3f450bbd97735e20ce25bd4a7d89e94994d6162edaec108a63f8861452143d2c"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+14 -14
View File
@@ -27,8 +27,8 @@
{
"dest": "roms/ports/retrobios/install.py",
"repo_path": "install.py",
"size": 910839,
"sha256": "d6d3483bdaf5c54fae595a6469525015323defb7a4f9f22adbda80029fa3ee54",
"size": 919079,
"sha256": "3f450bbd97735e20ce25bd4a7d89e94994d6162edaec108a63f8861452143d2c",
"executable": false
},
{
@@ -62,8 +62,8 @@
{
"dest": "roms/ports/retrobios/tui.txt",
"repo_path": "ports/shared/tui.txt",
"size": 35599,
"sha256": "1925428b1bb42384980a3336fa95051c173600367c6d15a4656bae08e6a6473c",
"size": 35984,
"sha256": "7291b11eef5b5c2df8d2befab10614a2d515bffdf96dc208529b5dee9e6df0f1",
"executable": false
}
]
@@ -71,8 +71,8 @@
"muos": {
"archive": {
"dest": "ARCHIVE/RetroBIOS.muxapp",
"size": 963631,
"sha256": "e82035ecc39b3a62898ebf7273c717c04880d2011ade0c09488924209796ab2a"
"size": 972256,
"sha256": "23a6a602e3478929b2d3ad01e600ff9a72343dc764cae2ea29dbcd59c790c685"
},
"members": [
{
@@ -92,8 +92,8 @@
{
"name": "RetroBIOS/install.py",
"repo_path": "install.py",
"size": 910839,
"sha256": "d6d3483bdaf5c54fae595a6469525015323defb7a4f9f22adbda80029fa3ee54",
"size": 919079,
"sha256": "3f450bbd97735e20ce25bd4a7d89e94994d6162edaec108a63f8861452143d2c",
"executable": false
},
{
@@ -141,8 +141,8 @@
{
"name": "RetroBIOS/tui.txt",
"repo_path": "ports/shared/tui.txt",
"size": 35599,
"sha256": "1925428b1bb42384980a3336fa95051c173600367c6d15a4656bae08e6a6473c",
"size": 35984,
"sha256": "7291b11eef5b5c2df8d2befab10614a2d515bffdf96dc208529b5dee9e6df0f1",
"executable": false
}
]
@@ -166,8 +166,8 @@
{
"dest": ".config/modules/retrobios/install.py",
"repo_path": "install.py",
"size": 910839,
"sha256": "d6d3483bdaf5c54fae595a6469525015323defb7a4f9f22adbda80029fa3ee54",
"size": 919079,
"sha256": "3f450bbd97735e20ce25bd4a7d89e94994d6162edaec108a63f8861452143d2c",
"executable": false
},
{
@@ -208,8 +208,8 @@
{
"dest": ".config/modules/retrobios/tui.txt",
"repo_path": "ports/shared/tui.txt",
"size": 35599,
"sha256": "1925428b1bb42384980a3336fa95051c173600367c6d15a4656bae08e6a6473c",
"size": 35984,
"sha256": "7291b11eef5b5c2df8d2befab10614a2d515bffdf96dc208529b5dee9e6df0f1",
"executable": false
}
]
+325 -20
View File
@@ -3820,7 +3820,9 @@ class LineModeOffline(unittest.TestCase):
with mock.patch.object(install, "_ask", return_value=""), \
contextlib.redirect_stdout(io.StringIO()) as out:
install._prompt_custom_selection("retroarch", files, [], data_trees=trees)
self.assertIn("RetroArch: 1 file, 1.0 KB, and 2 data files (4.0 KB).", out.getvalue())
# One total, the kinds under it adding up to it (code_audit_21 P3-8).
self.assertIn("RetroArch: 3 files, 5.0 KB.\n 1 BIOS file (1.0 KB) and 2 data files "
"(4.0 KB).", out.getvalue())
def test_one_save_file_goes(self):
files = [{"dest": "keys.txt", "root": "save", "size": 1}]
@@ -6638,7 +6640,11 @@ class AuditSevenScreens(unittest.TestCase):
state.found = [found("gba_bios.bin", str(self.root / "from1"))]
with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}):
text = " ".join(" ".join(install.tui_view(state, 100, 40)).split())
self.assertIn("They are not in from1", text)
# The rest named by kind as not in it, the remedy after
# (tui_audit_14 P3-7).
self.assertIn("Not in from1: ", text)
self.assertIn("Run the installer again with a pack (--from-pack)", text)
self.assertNotIn("They are not in", text)
self.assertNotIn("Copy them from your old card", text)
def test_the_card_browser_focuses_the_card_asked_for(self):
@@ -10024,8 +10030,16 @@ def drawn(state: install.TuiState, cols: int, rows: int) -> list:
return lines
def settled(state: install.TuiState) -> None:
"""The clock moved on past TUI_TAP_SETTLE since the screen was drawn."""
if state.hits is not None:
state.now = max(state.now, state.hits.shown + install.TUI_TAP_SETTLE)
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."""
"""Press and release on one cell, columns and lines counted from 0, made
once the screen settled (TUI_TAP_SETTLE): a reader's deliberate tap."""
settled(state)
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))
@@ -10226,13 +10240,13 @@ class MouseOnEveryScreen(unittest.TestCase):
self.assertIn(label, install._BAR_CLICKS)
effects = tap(state, first, line)
if install._bar_commits(base, target[1]):
# Install, Quit and Continue into Confirm: the
# first tap arms the entry, drawn so, the
# Install on the plan and Put it on the card:
# the first tap arms the entry, drawn so, the
# second on the same entry presses it.
self.assertEqual(effects, [])
self.assertEqual(state.stack, base.stack)
again = drawn(state, cols, rows)
self.assertIn("tap again", str(again[-1]))
self.assertIn(install.TUI_ARMED, str(again[-1]))
at = next(first for row, first, _end, mark in state.hits.targets
if mark == target)
effects = tap(state, at, line)
@@ -10258,7 +10272,9 @@ class MouseOnEveryScreen(unittest.TestCase):
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")
settled(state)
install.tui_update(state, install.TuiMouse(0, 5, row + 1))
self.assertIsNotNone(state.pressed)
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")
@@ -10275,6 +10291,7 @@ class MouseOnEveryScreen(unittest.TestCase):
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()
settled(state)
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])
@@ -10431,6 +10448,9 @@ class MouseOnAPty(unittest.TestCase):
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:
# A deliberate tap, after the screen settled: a press sooner is the
# second of a double tap on the screen before.
time.sleep(install.TUI_TAP_SETTLE)
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):
@@ -10454,6 +10474,7 @@ class MouseOnAPty(unittest.TestCase):
term.wait("Which setup?")
# Legacy reports move the focus the same way.
row = self.line_of(term, " > ") + 2
time.sleep(install.TUI_TAP_SETTLE)
term.send(x10(20, row + 1), x10(20, row + 1, 3))
term.settle()
self.assertTrue(term.screen.buffer[row][3].reverse, term.text())
@@ -10634,7 +10655,7 @@ class AuditNineteenAndTwelve(_CardFixtures, unittest.TestCase):
plan = checked_state()
plan.mouse, plan.help = True, True
text = " ".join(" ".join(install.tui_view(plan, 80, 24)).split())
self.assertIn("Tap Choose a row, tap it again to open it", text)
self.assertIn("Tap A row or Install: click or tap twice", text)
# The tick entry of the bar is pressed by a tap too.
state.help = False
lines = drawn(state, 80, 24)
@@ -11451,7 +11472,7 @@ class AuditTwentyAndThirteen(unittest.TestCase):
state.check = {**state.check, "data_missing": 7, "data_bytes": 2048}
text = " ".join(" ".join(install.tui_view(state, 40, 12)).split())
self.assertIn("Game data: what a game reads, of no use to other cores. Data files: "
"what an emulator reads from its own data folder.", text)
"an emulator's own data folder.", text)
text = " ".join(" ".join(install.tui_view(state, 40, 15)).split())
self.assertIn("such as Dolphin's Sys and PPSSPP's assets.", text)
@@ -11710,7 +11731,9 @@ class TapsSeenBeforeTheyAct(unittest.TestCase):
row = next(i for i, line in enumerate(lines)
if getattr(line, "mark", None) and line.mark[0] == "row"
and line.mark[2][0] == "systems")
settled(state)
install.tui_update(state, install.TuiMouse(0, 5, row + 1))
self.assertIsNotNone(state.pressed)
# The screen changes under the finger before it comes up.
state.stack.append("options")
drawn(state, 80, 24)
@@ -11728,7 +11751,7 @@ class TapsSeenBeforeTheyAct(unittest.TestCase):
self.assertEqual(tap(state, 3, bar), [])
self.assertEqual(state.screen, "plan")
armed = drawn(state, 80, 24)
self.assertIn("A/Enter Install: tap again", str(armed[-1]))
self.assertIn(f"A/Enter Install: {install.TUI_ARMED}", str(armed[-1]))
self.assertEqual(tap(state, 3, bar), [("run", "install")])
def test_a_tapped_quit_quits_with_the_quit_code(self):
@@ -11739,10 +11762,8 @@ class TapsSeenBeforeTheyAct(unittest.TestCase):
lines = drawn(state, 80, 24)
self.assertFalse(str(lines[-1]).strip().startswith("Ctrl-C"))
at = str(lines[-1]).index("Ctrl-C")
tap(state, at, len(lines) - 1)
self.assertIsNone(state.exit_code)
drawn(state, 80, 24)
at = str(install.tui_view(state, 80, 24)[-1]).index("Ctrl-C")
# One tap: quitting writes nothing, and the settle time keeps a
# double tap on the screen before from reaching it.
tap(state, at, len(lines) - 1)
self.assertEqual(state.exit_code, install.QUIT_CODE)
@@ -11769,6 +11790,168 @@ class TapsSeenBeforeTheyAct(unittest.TestCase):
self.assertIn("as A", " ".join(install.tui_view(state, 80, 24)))
class DoubleTapsAcrossScreens(unittest.TestCase):
"""tui_audit_14 P2-1: the second tap of a double tap acted on the screen
the first one opened, at every gap measured (0, 80, 150 and 250 ms).
A press within TUI_TAP_SETTLE of a new screen is dropped, and a slow
deliberate second tap still acts."""
GAPS = (0.0, 0.08, 0.15, 0.25)
@staticmethod
def bar_cell(lines: list) -> tuple[int, int]:
return len(str(lines[-1])) - len(str(lines[-1]).lstrip()), len(lines) - 1
def double_tap(self, state: install.TuiState, gap: float) -> list:
"""Two taps gap seconds apart on the bar's first entry, the screen
drawn after each event as the loop draws it."""
lines = drawn(state, 80, 24)
col, row = self.bar_cell(lines)
state.now = state.hits.shown + 2.0
effects = []
for release in (False, True):
effects += install.tui_update(state, install.TuiMouse(0, col + 1, row + 1, release))
first = state.now
drawn(state, 80, 24)
state.now = first + gap
for release in (False, True):
effects += install.tui_update(state, install.TuiMouse(0, col + 1, row + 1, release))
drawn(state, 80, 24)
return effects
def one_tap(self, state: install.TuiState) -> list:
lines = drawn(state, 80, 24)
col, row = self.bar_cell(lines)
return tap(state, col, row)
def plan_on(self, action: str) -> install.TuiState:
state = checked_state()
state.mouse = True
rows = install.tui_rows(state)
state.focus["plan"] = next(i for i, row in enumerate(rows) if row.action == action)
return state
def both(self, make, gap: float) -> tuple:
once = make()
one = self.one_tap(once)
twice = make()
return one, once, self.double_tap(twice, gap), twice
def test_continue_on_the_first_screen(self):
def make() -> install.TuiState:
state = fresh_state()
state.mouse = True
return state
for gap in self.GAPS:
with self.subTest(gap=gap):
with mock.patch.object(install.Path, "exists", return_value=True):
one, once, two, twice = self.both(make, gap)
self.assertNotEqual(once.stack, ["setup"])
self.assertEqual(two, one)
self.assertEqual(twice.stack, once.stack)
def test_open_on_the_plan_keeps_the_systems_ticked(self):
for gap in self.GAPS:
with self.subTest(gap=gap):
one, once, two, twice = self.both(lambda: self.plan_on("systems"), gap)
self.assertEqual(once.screen, "systems")
self.assertEqual((twice.stack, twice.ticked), (once.stack, once.ticked))
self.assertEqual(two, one)
def test_choose_on_copy_from(self):
for gap in self.GAPS:
with self.subTest(gap=gap):
one, once, two, twice = self.both(lambda: self.plan_on("fromcard"), gap)
self.assertEqual(once.screen, "browse")
self.assertEqual((twice.stack, twice.browse_dir),
(once.stack, once.browse_dir))
self.assertEqual(two, one)
def test_a_slow_second_tap_acts(self):
once = self.plan_on("systems")
self.double_tap(once, 0.0)
slow = self.plan_on("systems")
self.double_tap(slow, install.TUI_TAP_SETTLE + 0.2)
self.assertEqual(once.screen, "systems")
# On Choose systems the first entry goes on to Confirm: the slow
# tap pressed it.
self.assertEqual(slow.screen, "confirm")
def test_a_release_whose_press_came_before_the_frame_does_nothing(self):
state = self.plan_on("systems")
lines = drawn(state, 80, 24)
col, row = self.bar_cell(lines)
state.now = state.hits.shown + 2.0
install.tui_update(state, install.TuiMouse(0, col + 1, row + 1))
# Another screen drawn between the press and its release.
state.stack.append("options")
drawn(state, 80, 24)
state.now += 1.0
self.assertEqual(install.tui_update(state, install.TuiMouse(0, col + 1, row + 1, True)),
[])
self.assertEqual(state.screen, "options")
def test_the_settle_time_is_documented(self):
page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
self.assertIn(f"{round(install.TUI_TAP_SETTLE * 1000)} ms", page)
class TwoTapsOnlyWhereFilesAreWritten(unittest.TestCase):
"""tui_audit_14 P3-2 and P3-4: with the settle time in place, a second
tap is asked only of an entry that writes files with nothing to look at
after it, drawn in the accent and said in the help at every size."""
def test_install_on_the_plan_arms_in_the_accent(self):
state = checked_state()
state.mouse = True
lines = drawn(state, 80, 24)
self.assertEqual(tap(state, 2, len(lines) - 1), [])
armed = str(drawn(state, 80, 24)[-1])
entry = f"A/Enter Install: {install.TUI_ARMED}"
self.assertIn(entry, armed)
painted = install.tui_paint(armed, "hint", True)
self.assertIn(f"{install.COLOUR_ROLES['accent']}m{entry}\x1b[0m", painted)
self.assertEqual(tap(state, 2, len(lines) - 1), [("run", "install")])
def test_quit_takes_two_activations(self):
state = fresh_state()
state.mouse = True
lines = drawn(state, 80, 24)
tap(state, str(lines[-1]).index("B/Esc"), len(lines) - 1)
self.assertEqual(state.screen, "quit")
state.focus["quit"] = next(i for i, row in enumerate(install.tui_rows(state))
if row.action == "quit")
lines = drawn(state, 80, 24)
tap(state, 2, len(lines) - 1)
self.assertEqual(state.exit_code, install.QUIT_CODE)
def test_install_from_choose_systems_takes_two_activations(self):
state = checked_state()
state.mouse = True
rows = install.tui_rows(state)
state.focus["plan"] = next(i for i, row in enumerate(rows) if row.action == "systems")
install.tui_update(state, Key("enter"))
self.assertEqual(state.screen, "systems")
lines = drawn(state, 80, 24)
self.assertEqual(tap(state, 2, len(lines) - 1), [("run", "confirm")])
self.assertEqual(state.screen, "confirm")
install.tui_receive(state, ("event", state.run_id, CHECK))
install.tui_receive(state, ("ended", state.run_id, 1, []))
lines = drawn(state, 80, 24)
self.assertIn("A/Enter Install", str(lines[-1]))
self.assertEqual(tap(state, 2, len(lines) - 1), [("run", "install")])
def test_the_help_says_it_at_every_size(self):
for cols, rows in ((40, 12), (60, 20), (80, 24)):
with self.subTest(size=(cols, rows)):
state = checked_state()
state.mouse = True
state.help = True
text = " ".join(" ".join(str(line).split())
for line in install.tui_view(state, cols, rows))
self.assertIn("A row or Install: click or tap twice", text)
@unittest.skipUnless(pyte and pty and hasattr(pty, "fork"),
"screen tests need pyte (pip install pyte==0.8.2) and a pty")
class TapsOnAPtySeeFirst(unittest.TestCase):
@@ -11791,7 +11974,7 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
term.wait("> Install")
term.settle()
self.click(term, *self.bar_entry(term, "Install"))
self.assertIn("tap again", term.settle().splitlines()[-1])
self.assertIn(install.TUI_ARMED, term.settle().splitlines()[-1])
self.assertEqual(repo.installed(), [])
self.click(term, *self.bar_entry(term, "Install"))
term.wait("files installed")
@@ -11816,6 +11999,30 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
term.send("\r")
self.assertIn(term.close(), (install.QUIT_CODE, 0))
def test_a_double_tap_on_continue_stays_on_what_it_opened(self):
# tui_audit_14 P2-1: at 0, 80, 150 and 250 ms the second tap pressed
# B/Esc Back on the Checking screen, back to the first screen.
for gap in (0.0, 0.08, 0.15, 0.25):
with self.subTest(gap=gap):
repo = self.repo()
term = self.start()
term.wait("Where do you play?")
term.settle()
time.sleep(install.TUI_TAP_SETTLE)
col, line = self.bar_entry(term, "Continue")
press = sgr(col + 1, line + 1)
release = sgr(col + 1, line + 1, release=True)
os.write(term.fd, (press + release).encode())
time.sleep(gap)
os.write(term.fd, (press + release).encode())
screen = term.settle(0.8)
self.assertNotIn("Where do you play?", screen)
self.assertNotIn("Quit?", screen)
term.wait("> Install")
self.assertEqual(repo.installed(), [])
term.send("q")
term.close()
def test_continue_then_install_in_one_cell_stops_on_confirm(self):
repo = self.repo()
term = self.start()
@@ -11832,17 +12039,18 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
term.settle()
col, line = self.bar_entry(term, "Continue")
report = sgr(col + 1, line + 1) + sgr(col + 1, line + 1, release=True)
# A double tap: the first arms Continue, the second opens Confirm.
# A double tap: the first opens Confirm, the second lands on its
# Install inside the settle time and does nothing (tui_audit_14 P2-1).
time.sleep(install.TUI_TAP_SETTLE)
term.send(report + report)
term.wait("Counted again for your choices")
term.settle()
self.assertEqual(repo.installed(), [])
# Install in the same cell: one tap arms it, nothing is written.
term.send(report)
screen = term.settle(0.6)
self.assertIn("RetroBIOS: Confirm", screen)
self.assertIn("Install: tap again", screen.splitlines()[-1])
self.assertEqual(repo.installed(), [])
# A tap made later installs: Confirm is the look before the install.
term.send(report)
term.wait("files installed")
self.assertNotEqual(repo.installed(), [])
term.send("q")
term.close()
@@ -11911,5 +12119,102 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
term.close()
class AuditTwentyOneAndFourteen(unittest.TestCase):
"""code_audit_21 and tui_audit_14: one choice state in both modes, the
cards found first, packs that hold nothing, and words that fit."""
setUp = LineModeAndScreenDecideAlike.setUp
line_run = LineModeAndScreenDecideAlike.line_run
listing = LineModeAndScreenDecideAlike.listing
entry = staticmethod(LineModeAndScreenDecideAlike.entry)
receive = staticmethod(AuditTwentyAndThirteen.receive)
game_manifest = AuditTwentyAndThirteen.game_manifest
game_events = AuditTwentyAndThirteen.game_events
game_plan = AuditTwentyAndThirteen.game_plan
@staticmethod
def screen(state: install.TuiState, cols: int = 80, rows: int = 24) -> str:
return " ".join(" ".join(str(line).split())
for line in install.tui_view(state, cols, rows))
def toggle_game_data(self, state: install.TuiState) -> None:
rows = install.tui_rows(state)
state.focus["plan"] = next(index for index, row in enumerate(rows)
if row.action == "gamedata")
self.assertEqual(install.tui_update(state, Key("enter")), [("run", "check")])
self.receive(state, self.game_events(self.game_manifest(),
left_out=state.no_game_data))
def test_the_game_data_toggle_never_unticks_a_system_for_good(self):
"""tui_audit_14 P2-2: DS and Dreamcast ticked, game data left out
then kept: Dreamcast was gone, and so was the row to turn it back."""
state = self.game_plan()
state.ticked = {"nintendo-ds", "sega-dreamcast"}
self.toggle_game_data(state)
self.assertEqual(state.ticked, {"nintendo-ds"})
self.assertIn("for 1 of 2 systems", self.screen(state))
row = next(row for row in install.tui_rows(state) if row.action == "gamedata")
self.assertEqual((row.label, row.right), ("Game data: left out", "2 files, 2.0 KB"))
self.toggle_game_data(state)
self.assertEqual(state.ticked, {"nintendo-ds", "sega-dreamcast"})
self.assertIn("for 2 of 3 systems", self.screen(state))
# Line mode, the same choice: c, the two systems, g twice.
manifest = self.game_manifest()
listing = self.listing("retroarch", [
self.entry(entry["dest"], b"x" * entry["size"], entry["systems"],
**({"category": entry["category"]} if "category" in entry else {}))
for entry in manifest["files"]], categorized=True)
text, _asked = self.line_run(_args(manifest=listing, dest=self.here,
declared_only=False, dry_run=True),
["c", "1,2", "g", "g"])
self.assertIn("for 1 of 2 systems", text)
self.assertIn("for 2 of 3 systems", text.rsplit("for 1 of 2 systems", 1)[1])
def game_listing(self) -> Path:
manifest = self.game_manifest()
return self.listing("retroarch", [
self.entry(entry["dest"], b"x" * entry["size"], entry["systems"],
**({"category": entry["category"]} if "category" in entry else {}))
for entry in manifest["files"]], categorized=True)
def test_line_mode_offers_no_enter_for_an_emptied_choice(self):
"""code_audit_21 P3-6: Dreamcast alone, game data left out: line mode
said "0 files, 0 B, for 0 of 2 systems", offered Enter and a command
that installs nothing, then refused the Enter."""
text, asked = self.line_run(_args(manifest=self.game_listing(), dest=self.here,
declared_only=False),
["c", "2", "g", ""])
menu = text.rsplit("Counted again for your choices", 1)[1]
self.assertIn("RetroArch: sega-dreamcast holds only game data: nothing is left to "
"install.", menu)
self.assertNotIn("0 files, 0 B", menu)
self.assertNotIn("Enter)", menu)
self.assertNotIn("Same choice without questions", menu)
# Enter is not an answer here: asked again, never refused after.
self.assertIn("Type c, f, g or q.", menu)
self.assertEqual(asked[-1], "> ")
def test_a_kept_choice_in_place_offers_no_install(self):
"""code_audit_21 P3-4: one system ticked, its files in place, the
plan said "About 0 files" over Install, and Confirm "Everything is
in place" over Install too."""
state = checked_state()
state.ticked = {"nintendo-famicom-disk"}
text = self.screen(state)
self.assertIn("(ファミコン) is in place: nothing is left to install.", text)
self.assertNotIn("About 0 files", text)
actions = [row.action for row in install.tui_rows(state)]
self.assertNotIn("install", actions)
self.assertEqual(actions[0], "systems")
# Confirm counted nothing to install: Close, never Install.
state.stack.append("confirm")
state.dry = {**CHECK, "present": 1, "missing": 0, "wrong_hash": 0,
"bytes_needed": 0, "files": 1}
state.dry_files = []
self.assertEqual([row.action for row in install.tui_rows(state)], ["close", "back"])
install.tui_update(state, Key("enter"))
self.assertEqual(state.exit_code, 0)
if __name__ == "__main__":
unittest.main()
+10 -4
View File
@@ -265,15 +265,21 @@ does. On any other row it moves the cursor there, and a second one on that
row does what Enter does: one row of a terminal is far smaller than a finger,
and the row a screen opens on (**Install** on Confirm) is never chosen by one
tap. The entries of the bottom line press their key, **Exit** on the result
too. One that installs, quits or goes on to Confirm takes two taps as a row
does: the first adds `tap again` to it, the second presses it. A row of
too. One that writes files with no screen after it to look first, **Install**
on the plan and **Put it on the card**, takes two taps: the first adds
`click or tap again` to it in the accent colour, the second presses it.
**Install** on Confirm, which is that look, Quit, Continue and Close take one,
so installing or quitting never takes more than two. A row of
Preferred regions stays where it is when it is ticked, its number giving the
order, which the list shows sorted the next time it opens. 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,
on the screen drawn when it went down: the second tap of a double tap that
changes the screen does nothing on the new one. The right and middle buttons
on the screen drawn when it went down. A new screen takes no press for its
first 400 ms: a double click is two presses at most 400 ms (GNOME) to 500 ms
(Windows, macOS) apart and the new screen is drawn on the first release, so
the second tap of a double tap that changes the screen does nothing on the
new one, and a tap made later acts. The right and middle buttons
do nothing. The help and the bottom line of a list of boxes say how a tap acts.
Clicks are off in the Windows console the PowerShell command runs in, which