#!/usr/bin/env python3 """Does the EXTRAS submenu remember its cursor across leave -> re-enter? sylpheed-port's contract-check asserts that EXTRAS does NOT persist. Nothing measured that: the corpus has EXTRAS' initial focus from a SINGLE entry, and Ⓑ-from-a-submenu restoring the PARENT's focus 4/4 -- neither says what a submenu's own cursor does on re-entry. The main menu was measured to PERSIST, so the question is live in both directions, and their `kind: "measured"` label on `initial_focus: ptbtn11` turns on it. 🔴 RUN 1 (2026-08-30) WAS VOID AND ITS FAILURES SHAPE THIS FILE: * it navigated to OPTIONS believing it was EXTRAS -- the focus reader used design-space rows against x11grab frames (menu-focus-reader-offset.txt); * its screen detector could not tell the main menu from a submenu, because both sit inside the glyph 250..420 window (main menu 327, OPTIONS 317); * its control checked only that the ring MOVED, which a wrong origin passes. So this run: absolute row checks against a measured calibration, screen identity against a REFERENCE FRAME captured in this same run, and raw ring ROWS compared inside the submenu -- no submenu geometry is assumed or needed. extras_focus_persistence.py LOG OUTDIR [wait_s] """ import os, re, subprocess, sys, time import numpy as np from PIL import Image sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from ring_row import ring_row, main_menu_item, NAMES, ROW0, SPACING LOG, OUT = sys.argv[1], sys.argv[2] WAIT = float(sys.argv[3]) if len(sys.argv) > 3 else 600 W, H = 1280, 720 PAD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pad.py") T0 = time.time() def deliveries(vk): pat = re.compile((r"RE-INPUT\] XamInputGetKeystrokeEx -> user=\d+ vk=%s flags=0001" % vk).encode()) try: return len(pat.findall(open(LOG, "rb").read())) except FileNotFoundError: return 0 def press(btn, vk, tries=5): for k in range(tries): before = deliveries(vk) subprocess.run([sys.executable, PAD, "tap", btn, "0.5"], check=False) for _ in range(20): time.sleep(0.25) if deliveries(vk) > before: print(f"[{time.time()-T0:7.1f}s] {btn} delivered", flush=True) return True print(f"[{time.time()-T0:7.1f}s] 🔴 {btn} NEVER delivered", flush=True) return False def _open(): return subprocess.Popen( ["ffmpeg", "-loglevel", "error", "-f", "x11grab", "-draw_mouse", "0", "-video_size", f"{W}x{H}", "-i", ":98", "-r", "4", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], stdout=subprocess.PIPE, bufsize=W * H * 3 * 2) def fresh(): q = _open(); a = None for _ in range(3): b = q.stdout.read(W * H * 3) if len(b) == W * H * 3: a = np.frombuffer(b, np.uint8).reshape(H, W, 3).astype(int) q.kill() return a def img(a): return Image.fromarray(a.astype(np.uint8)) def differs(a, b): return float((np.abs(a - b).max(axis=2) > 24).mean()) def glyph(a): r, g, bl = a[:, :, 0], a[:, :, 1], a[:, :, 2] return int(((g > 130) & (g - r > 45) & (g - bl > 45)).sum()) def wait_until(pred, what, limit=60): t = time.time() while time.time() - t < limit: a = fresh() if a is not None and pred(a): print(f"[{time.time()-T0:7.1f}s] {what}", flush=True) return a print(f"[{time.time()-T0:7.1f}s] 🔴 TIMEOUT waiting for {what}", flush=True) return None os.makedirs(OUT, exist_ok=True) # ---- 1. main menu: reference frame + calibrated focus ------------------------ MAIN = wait_until(lambda a: main_menu_item(ring_row(img(a))) is not None and 250 <= glyph(a) <= 420, "MAIN MENU reference captured", 120) if MAIN is None: sys.exit("never identified the main menu") img(MAIN).save(f"{OUT}/0-main-ref.png") f = main_menu_item(ring_row(img(MAIN))) print(f" focus = {NAMES[f]} (ring y {ring_row(img(MAIN))})", flush=True) # ---- 2. walk to EXTRAS, checking the ABSOLUTE row after every press ---------- for step in range((4 - f) % 5): if not press("DOWN", "5811"): sys.exit("a DOWN was never delivered") time.sleep(1.2) a = fresh(); y = ring_row(img(a)); i = main_menu_item(y) want = (f + step + 1) % 5 print(f" step {step+1}: ring y {y} -> {NAMES[i] if i is not None else '??'} " f"(want {NAMES[want]})", flush=True) if i != want: sys.exit(f"🔴 CONTROL FAILED: after {step+1} DOWN the ring is not on {NAMES[want]}") print("✅ on EXTRAS, verified by absolute row after every press", flush=True) # ---- 3. enter EXTRAS -- and prove we LEFT the main menu ---------------------- if not press("A", "5800"): sys.exit("A never delivered") E1 = wait_until(lambda a: differs(a, MAIN) > 0.20, "left the main menu", 40) if E1 is None: sys.exit("A did not change the screen") time.sleep(3.0) E1 = fresh(); img(E1).save(f"{OUT}/E1.png") y1 = ring_row(img(E1)) print(f"[{time.time()-T0:7.1f}s] E1 in the submenu: ring y = {y1}, " f"glyph {glyph(E1)}, {100*differs(E1, MAIN):.1f}% from main", flush=True) # ---- 4. move the cursor, control on an ABSOLUTE change ---------------------- if not press("DOWN", "5811"): sys.exit("a DOWN was never delivered inside the submenu") time.sleep(2.5) E2 = fresh(); img(E2).save(f"{OUT}/E2.png") y2 = ring_row(img(E2)) print(f"[{time.time()-T0:7.1f}s] E2 after 1 DOWN: ring y = {y2}", flush=True) if y1 is None or y2 is None: sys.exit("🔴 CONTROL FAILED: no ring found in the submenu — this reader does not work here") if abs(y2 - y1) < 20: sys.exit(f"🔴 CONTROL FAILED: the ring did not move ({y1} -> {y2})") print(f"✅ CONTROL PASSED: the ring moved {y1} -> {y2} ({abs(y2-y1):.1f} px)", flush=True) # ---- 5. leave, prove we are back on the main menu, re-enter ----------------- if not press("B", "5801"): sys.exit("B never delivered") back = wait_until(lambda a: differs(a, MAIN) < 0.15, "back on the MAIN MENU (vs reference)", 60) if back is None: sys.exit("🔴 B did not return to the main menu — refusing to read E3") if not press("A", "5800"): sys.exit("A never delivered on re-entry") E3 = wait_until(lambda a: differs(a, MAIN) > 0.20, "left the main menu again", 40) if E3 is None: sys.exit("re-entry did not change the screen") time.sleep(3.0) E3 = fresh(); img(E3).save(f"{OUT}/E3.png") y3 = ring_row(img(E3)) same_screen = differs(E3, E1) < 0.15 print(f"[{time.time()-T0:7.1f}s] E3 on re-entry: ring y = {y3}, " f"{100*differs(E3, E1):.1f}% from E1 — same screen: {same_screen}", flush=True) # ---- 6. decide ------------------------------------------------------------ print(f"\n E1 (opened on) ring y = {y1}") print(f" E2 (left it on) ring y = {y2}") print(f" E3 (re-entered) ring y = {y3}") if not same_screen: print("\n=> VOID: re-entry is not the same screen; nothing measured") elif y3 is None: print("\n=> VOID: no ring on re-entry") elif abs(y3 - y2) < 20 and abs(y3 - y1) >= 20: print("\n=> EXTRAS PERSISTS: re-entry is where I left the cursor") elif abs(y3 - y1) < 20 and abs(y3 - y2) >= 20: print("\n=> EXTRAS RESETS: re-entry is where it first opened") else: print(f"\n=> UNDECIDED: |E3-E1|={abs(y3-y1):.1f} |E3-E2|={abs(y3-y2):.1f}") print("EXTRAS FOCUS RUN DONE", flush=True)