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

+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()