Files
OrcaSlicer/scripts/CAD/check-gui-click-edit.py
T
Tommaso BianchiandClaude Opus 5 e5659e0f0f The value field stops being a window, and now takes what is typed
Rebases the in-canvas work onto cad-mainline and finishes it. The field is drawn
by ImGui inside the GL canvas instead of being a borderless top-level wxFrame.

WHY THE FLOATING FRAME COULD NOT BE FIXED. Whether a borderless top-level may hold
the keyboard is the window manager's decision, and it differs per desktop: openbox
grants it, mutter refuses it, macOS denies key status outright. Seven workarounds
fought that and one cost a macOS regression. Drawn inside the canvas there is no
second top-level for anyone to refuse, so the question is never asked. The field is
fed exactly like every other ImGui widget in the app — GLCanvas3D::on_char ->
ImGuiWrapper::update_key_data -> io.AddInputCharacter.

MEASURED, on behemoth: the click-edit ladder holds 28 checks — Line, Rectangle,
Circle, Slot, Polygon, Ellipse, Arc, and click-to-edit on a placed dimension label
— typing with NO click into the field first, committed == typed != prefill every
time, and 27 [UX] imgui_char lines showing the characters arriving.

WHAT WAS ACTUALLY WRONG. Not the field. The belief that "characters never reach the
ImGui InputText" came from the harness: the ladder was delivering keys with
`xdotool type --window` (XSendEvent), which GTK discards, so no build of any kind
could have received them. The new probe in ImGuiWrapper::update_key_data — the one
place ImGui is ever handed a character — is what separated that from a real defect,
and it stays, because a canvas-side probe provably cannot answer the question:
GLCanvas3D::on_char is bound later than any constructor-time probe, wx runs handlers
in reverse bind order, and on_char returns without Skip(), so such a probe is silent
whether or not the key arrived. A day was lost reading that silence as evidence.

Also drops DesignPanel's content-based forwarder and DesignCanvas::inline_type_char.
They were the right rule for a field that could not be focused; with the field
inside the canvas there is nothing to forward, and keeping them would have masked
whether the normal path works.

STILL UNVERIFIED: behaviour under mutter itself. Neither focus-stealing-prevention
WM available here survives long enough to judge — metacity SEGVs ~20s in and xfwm4
dies with BadWindow on SetInputFocus, both before the sketch opens and both
unrelated to this field. The design's claim is structural rather than measured: no
second top-level means no focus to refuse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
2026-09-06 11:10:02 +02:00

620 lines
29 KiB
Python
Executable File

#!/usr/bin/env python3
"""The click-edit contract: a value field that opens must accept what is TYPED into it.
WHY THIS EXISTS SEPARATELY FROM check-gui-sketching.py. That ladder draws geometry and grades the
result, and to make its values land it calls focus_field() — one synthetic click INTO the field
before typing. Its own docstring says why:
WITHOUT THIS THE TYPED VALUE IS SILENTLY DISCARDED. The field is shown and raised but the
window manager does not give it the keyboard, so xdotool's digits go to the canvas and Return
commits the value the field opened with — the pre-filled as-drawn number.
That click is a workaround for a defect, and a suite that performs it can never see the defect
again. A user cannot be told to click the field first; when they do not, they get the as-drawn
number and report "the label value is not editable". So this ladder types IMMEDIATELY after the
field opens, exactly as a person does, and fails if the prefill is what gets committed.
WHAT IT GRADES. The app emits one line per event under SNAPORCA_UXTRACE=1:
[UX] open title=Length prefill=154.76
[UX] commit title=Length typed=80 value=80.0000
[UX] refused title=Length typed=8O
[UX] cancel title=Length
For every field the driver opens it asserts: a commit arrived, what the field received is what we
typed, the parsed value equals it, and it differs from the prefill. The last clause is the one
that matters — a field that is on screen but deaf commits its prefill, and every other signal
(the field is visible, a constraint is created, the solve succeeds) looks perfectly healthy.
scripts/CAD/check-gui-click-edit.py --display :10 --bin build/src/Release/orca-slicer
With --attach it drives an already-running app instead of launching one; the app must have been
started with SNAPORCA_UXTRACE=1 and its stderr redirected to --trace.
Exit 0 = every field took what was typed.
"""
import argparse, json, os, re, shutil, signal, socket, subprocess, sys, tempfile, time
AP = argparse.ArgumentParser()
AP.add_argument("--display", default=os.environ.get("DISPLAY", ":10"))
AP.add_argument("--bin", default="build/src/Release/orca-slicer")
AP.add_argument("--datadir", default="")
AP.add_argument("--trace", default="")
AP.add_argument("--sock", default="/tmp/mcp-uxcheck.sock",
help="the app's MCP socket: the oracle for whether a sketch is really open")
AP.add_argument("--attach", action="store_true", help="drive a running app; do not launch one")
AP.add_argument("--keep", action="store_true", help="leave the app running afterwards")
AP.add_argument("--no-defocus", action="store_true",
help="do NOT take focus off the field before typing (weakens the gate; see below)")
AP.add_argument("--seed-from", default=os.path.expanduser("~/.config/OrcaCAD/OrcaSlicer.conf"),
help="an existing OrcaSlicer.conf to copy presets/settings from")
A = AP.parse_args()
DISP = A.display
TRACE = A.trace or os.path.join(tempfile.gettempdir(), "ux-click-edit.log")
_fail = 0
_checks = 0
_n = 0
def call(method, **params):
"""One MCP request over the app's unix socket. The socket is the only witness that cannot
lie about sketch state: the keytrace says a key ARRIVED, a screenshot says something is on
screen, and neither distinguishes an open sketch from sketch mode with the plane offer up."""
global _n
_n += 1
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(30)
s.connect(A.sock)
s.sendall((json.dumps({"jsonrpc": "2.0", "id": _n, "method": method,
"params": params}) + "\n").encode())
buf = b""
while b"\n" not in buf:
d = s.recv(65536)
if not d:
break
buf += d
r = json.loads(buf.decode().strip())
if "error" in r:
raise RuntimeError(f"{method}: {r['error']['message']}")
return r["result"]
def try_call(method, **params):
try:
return call(method, **params)
except Exception:
return None
def sh(cmd):
# bash -c, NOT -lc: a login shell sources the profile on every xdotool call, and this driver
# makes hundreds. On a GNOME box that meant im-config running per call, thousands of journal
# lines, and a window poll slow enough to time out before the app had finished starting.
return subprocess.run(["bash", "-c", cmd], capture_output=True, text=True).stdout
def xdo(args):
sh(f"DISPLAY={DISP} xdotool {args}")
def key(k, pause=0.35, window=None):
xdo(f"key {'--window ' + str(window) + ' ' if window else ''}{k}")
time.sleep(pause)
def typ(s, pause=0.35, window=None):
# --clearmodifiers so a modifier left down by an earlier synthetic key cannot turn digits
# into something else; --delay 60 because ImGui reads one character per frame.
#
# `window` targets a specific window with XSendEvent instead of following the input focus.
# That is the whole gate: see type_into_open_field.
tgt = f"--window {window} " if window else ""
xdo(f"type {tgt}--clearmodifiers --delay 60 -- '{s}'")
time.sleep(pause)
def die(msg):
print(f"FATAL {msg}", file=sys.stderr)
sys.exit(2)
# ---------------------------------------------------------------- the app
_proc = None
def seed_datadir(datadir):
"""The Design tab does not exist unless enable_cad_feature is on, and it needs a RESTART.
A fresh datadir has it off, so a driver that just points the app at an empty directory gets
Prepare/Preview/Device/Project, no Design tab, and every rung fails for a reason that has
nothing to do with what is being tested. Seed the flag before the first launch.
"""
os.makedirs(datadir, exist_ok=True)
conf = os.path.join(datadir, "OrcaSlicer.conf")
data = {}
if os.path.exists(A.seed_from):
try:
with open(A.seed_from) as f:
data = json.load(f)
except Exception:
data = {}
app = data.setdefault("app", {})
app["enable_cad_feature"] = True
# Deterministic starting state for the rungs that follow: the bed drawn, loops welded as the
# ~90% case expects. A ladder whose result depends on the developer's own preferences is not
# a gate.
app["auto_close_sketch_loops"] = True
with open(conf, "w") as f:
json.dump(data, f, indent=1)
for sub in ("user", "system", "presets", "vendor"):
src = os.path.join(os.path.dirname(A.seed_from), sub)
dst = os.path.join(datadir, sub)
if os.path.isdir(src) and not os.path.exists(dst):
shutil.copytree(src, dst)
def launch():
global _proc
datadir = A.datadir or os.path.join(tempfile.gettempdir(), "orcacad-uxcheck")
seed_datadir(datadir)
env = dict(os.environ)
# WAYLAND_DISPLAY MUST GO, and GDK_BACKEND must say x11. GTK prefers Wayland whenever
# WAYLAND_DISPLAY is set and ignores DISPLAY entirely, so a driver launched from a systemd
# user unit (which inherits it) started the app on the DESKTOP session instead of the rig:
# the process was alive, `xdotool search` on the rig display found nothing, and the window
# was sitting on the user's own screen. Silent, and it drives a stray app at someone's face.
env.pop("WAYLAND_DISPLAY", None)
env.update(DISPLAY=DISP, GDK_BACKEND="x11", SNAPORCA_UXTRACE="1",
LIBGL_ALWAYS_SOFTWARE="1", GALLIUM_DRIVER="llvmpipe",
# The rig's Xvfb has no input-method daemon, and a dead ibus context makes a
# GtkEntry drop every character while the app looks fine. It cannot affect the
# in-canvas field (ImGui needs no IM) but the app has other text fields, and a
# display full of IBUS warnings has cost a whole misdiagnosis before.
GTK_IM_MODULE="gtk-im-context-simple", XMODIFIERS="@im=none",
# The key tracer is this driver's only positive signal that a keystroke reached
# the Design panel at all. Without it "the field never opened" is indistinguishable
# from "we never got into sketch mode", and the first run of this ladder reported
# seven product failures that were really one driver racing a still-loading app.
SNAPORCA_KEYTRACE="1", SNAPORCA_MCP=A.sock,
SSL_CERT_FILE="/etc/ssl/certs/ca-certificates.crt",
WEBKIT_DISABLE_DMABUF_RENDERER="1", WEBKIT_DISABLE_COMPOSITING_MODE="1")
if os.path.exists(A.sock):
os.unlink(A.sock) # a stale socket from a dead run answers nothing, slowly
log = open(TRACE, "wb")
_proc = subprocess.Popen([A.bin, "--datadir", datadir], env=env,
stdout=subprocess.DEVNULL, stderr=log)
for _ in range(120):
if win_id():
return
time.sleep(1)
die("the app never showed a window on " + DISP)
def window_pid(w):
"""_NET_WM_PID for a window, or 0. The property is how we tell a live app from its ghost."""
out = sh(f"DISPLAY={DISP} xprop -id {w} _NET_WM_PID 2>/dev/null")
m = re.search(r"= *(\d+)", out)
return int(m.group(1)) if m else 0
def pid_alive(pid):
return pid > 0 and os.path.isdir(f"/proc/{pid}")
def win_id():
"""The main window: OURS if we launched it, otherwise the biggest LIVE top-level.
Two rules here, each paid for.
By PID, not by size, whenever we launched the app. An X window outlives its client if the
connection is not torn down cleanly, and a killed OrcaSlicer can leave a full-screen ghost
mapped on the display. It answers geometry queries exactly like the real thing, it wins "the
biggest window" every time, and every synthetic keystroke sent to it goes nowhere. That is
indistinguishable, from the driver's side, from an app that ignores the keyboard — which is
the very defect this ladder exists to measure. One run reported the entire contract broken
while the real app sat beside the ghost, untouched.
Never by title: a saved project renames the main window.
"""
if _proc is not None:
for w in sh(f"DISPLAY={DISP} xdotool search --pid {_proc.pid} --onlyvisible --name '.'").split():
g = dict(l.split("=", 1) for l in
sh(f"DISPLAY={DISP} xdotool getwindowgeometry --shell {w}").strip().splitlines()
if "=" in l)
if "WIDTH" in g and int(g["WIDTH"]) * int(g["HEIGHT"]) > 400 * 400:
return (w, int(g["X"]), int(g["Y"]), int(g["WIDTH"]), int(g["HEIGHT"]))
return None
best = None
for w in sh(f"DISPLAY={DISP} xdotool search --onlyvisible --name '.'").split():
g = dict(l.split("=", 1) for l in
sh(f"DISPLAY={DISP} xdotool getwindowgeometry --shell {w}").strip().splitlines()
if "=" in l)
if "WIDTH" not in g:
continue
if not pid_alive(window_pid(w)): # a ghost: no client is behind it any more
continue
a = int(g["WIDTH"]) * int(g["HEIGHT"])
if a > 400 * 400 and (best is None or a > best[0]):
best = (a, w, int(g["X"]), int(g["Y"]), int(g["WIDTH"]), int(g["HEIGHT"]))
return best[1:] if best else None
_win = None
def win():
global _win
if _win is None:
w = win_id()
if w is None:
die("no app window on " + DISP)
sh(f"DISPLAY={DISP} xdotool windowactivate --sync {w[0]}")
sh(f"DISPLAY={DISP} xdotool windowsize {w[0]} 1920 1080")
sh(f"DISPLAY={DISP} xdotool windowmove {w[0]} 0 0")
time.sleep(1.0)
_win = (w[0], 0, 0, 1920, 1080)
return _win
def click(px, py, pause=0.5, btn=1):
_, X, Y, _, _ = win()
xdo(f"mousemove {X+int(px)} {Y+int(py)} click --delay 120 {btn}")
time.sleep(pause)
def visible_windows():
"""(id, name, w, h) for every MAPPED top-level, main window included.
`--onlyvisible` is what makes this usable. Without it xdotool also returns the app's unmapped
helper windows — a 10x10 and a 200x200 that exist for the whole session — and a caller that
tries to reason about "extra windows" from that list is reasoning about furniture.
"""
out = []
for w in sh(f"DISPLAY={DISP} xdotool search --onlyvisible --name '.'").split():
g = dict(l.split("=", 1) for l in
sh(f"DISPLAY={DISP} xdotool getwindowgeometry --shell {w}").strip().splitlines()
if "=" in l)
if "WIDTH" not in g:
continue
if not pid_alive(window_pid(w)): # see win_id(): a ghost cannot be closed, only ignored
continue
n = sh(f"DISPLAY={DISP} xdotool getwindowname {w}").strip()
out.append((w, n, int(g["WIDTH"]), int(g["HEIGHT"])))
return out
def dismiss_modals(timeout=30):
"""Close every modal over the main window, and PROVE none is left.
This is the rung that decides whether any of the others mean anything. A fresh datadir opens
"Bambu Network Plug-in Required" — 440x259, centred at 742,450 — which sits exactly on top of
the point every drawing gesture in TOOLS starts from. The whole ladder then reports eleven
product failures, all of them the driver clicking a dialog.
The old version pressed Escape and moved on. This dialog ignores Escape, so it "dismissed"
nothing and said so to no one; the run that found this was red for a reason that had nothing
to do with the contract under test. Escape is still tried first because it is the gentlest
thing that works on the wizard, then WM_DELETE_WINDOW, and then the function asserts what it
was supposed to have achieved instead of assuming it.
"""
# NEVER run without knowing which window to spare. The first version took `keep = main[0] if
# main else None`, so a win_id() that raced the app's mapping made keep None and every visible
# window a modal — this function then sent WM_DELETE to the app's own main window. The app
# survived as a process, printed "GdkWindow unexpectedly destroyed", and answered nothing
# afterwards; the ladder reported "no sketch opened" for 180s. Losing the main window is not a
# state to recover from silently.
deadline = time.time() + timeout
keep = None
while keep is None and time.time() < deadline:
main = win_id()
keep = main[0] if main else None
if keep is None:
time.sleep(0.5)
if keep is None:
die(f"no main window to protect after {timeout}s — refusing to close anything")
scr = sh(f"DISPLAY={DISP} xdotool getdisplaygeometry").split()
full = int(scr[0]) * int(scr[1]) if len(scr) == 2 else 1920 * 1080
while time.time() < deadline:
# A modal is small. Anything covering half the screen is the app, whatever id win_id()
# happened to return this instant — a second belt on the rule above, because the cost of
# being wrong here is an app that looks alive and answers nothing.
extra = [x for x in visible_windows() if x[0] != keep and x[2] * x[3] < full * 0.5]
if not extra:
return
for (w, n, _, _) in extra:
sh(f"DISPLAY={DISP} xdotool windowactivate {w}")
time.sleep(0.4)
key("Escape", 0.4)
if any(x[0] == w for x in visible_windows()):
sh(f"DISPLAY={DISP} xdotool windowclose {w}")
time.sleep(0.6)
time.sleep(0.5)
left = [f"{n!r} ({w}x{h})" for (i, n, w, h) in visible_windows()
if i != keep and w * h < full * 0.5]
die("a modal is still covering the canvas after " + str(timeout) + "s: " + ", ".join(left) +
" — every drawing gesture would land in it, so nothing below this line could be trusted")
def dismiss_first_run():
dismiss_modals()
# ---------------------------------------------------------------- the trace
def trace_lines():
try:
with open(TRACE, "r", errors="replace") as f:
return [l.strip() for l in f if l.startswith("[UX] ")]
except OSError:
return []
def trace_mark():
return len(trace_lines())
def parse(line):
m = re.match(r"\[UX\] (\w+) title=(.*?) (.*)$", line)
if not m:
return None
ev, title, rest = m.group(1), m.group(2), m.group(3)
kv = dict(re.findall(r"(\w+)=(\S*)", rest))
return ev, title, kv
# ---------------------------------------------------------------- grading
def check(cond, what):
global _fail, _checks
_checks += 1
if cond:
print(f" ok {what}")
else:
print(f" FAIL {what}", file=sys.stderr)
_fail += 1
def type_into_open_field(value, mark):
"""Type `value` into whatever field is open, WITHOUT clicking it first, and grade the pair.
No click: the click is the workaround this ladder exists to refuse. If the field cannot take
the keyboard on its own, `typed` will be the prefill and this fails — which is the report.
"""
# POLL for the field. It opens from a CallAfter that runs after a re-solve, so on llvmpipe it
# is simply not there yet when a fast driver looks — and "no field opened" is the same message
# whether the product never opened one or the driver asked too early. Wait, then decide.
opens = []
deadline = time.time() + 8.0
while time.time() < deadline:
opens = [e for e in (parse(l) for l in trace_lines()[mark:]) if e and e[0] == "open"]
if opens:
break
time.sleep(0.25)
if not opens:
check(False, f"a value field opened (nothing did; cannot type {value})")
return mark
title = opens[-1][1]
prefill = opens[-1][2].get("prefill", "")
m2 = trace_mark()
# TYPE NORMALLY. NOTHING TO DEFOCUS ANY MORE.
#
# The value field is drawn INSIDE the GL canvas by ImGui, so it is not a window: there is no
# second toplevel for a window manager to grant or refuse the keyboard, and the keystrokes go
# to the app's one window exactly as a person's would. That is the entire point of the design
# — the WM has no say — and it is why this ladder no longer tries to manufacture the failing
# condition.
#
# When the field WAS a floating wxFrame, this spot held two attempts to reproduce
# "field open, keyboard elsewhere", and both are recorded here so neither is tried again:
# - XSetInputFocus onto the main window (`xdotool windowfocus`): the field's own re-focus
# CallAfter wins the race every time; four retries all lost, and the ladder passed twice
# against a binary with the fix compiled out.
# - XSendEvent at the main window (`xdotool type --window`): GTK discards synthetic key
# events, so NEITHER build received anything and every run was red regardless of the code.
# A run that used the second of those is what produced "the app never saw a digit" — a
# property of xdotool, not of the product.
#
# For the in-canvas field the honest gate is simply: type, and see whether the value the app
# commits is the value that was typed.
diag = sh(f"DISPLAY={DISP} xdotool getwindowfocus").strip()
typ(str(value), 0.4)
key("Return", 0.9)
after, commits, refused, commit_at = [], [], [], None
deadline = time.time() + 5.0
while time.time() < deadline:
after = [parse(l) for l in trace_lines()[m2:]]
commits = [(i, e) for i, e in enumerate(after) if e and e[0] == "commit"]
refused = [e for e in after if e and e[0] == "refused"]
if commits:
commit_at = m2 + commits[-1][0]
commits = [e for _, e in commits]
if commits or refused:
break
time.sleep(0.25)
if refused and not commits:
check(False, f"{title}: field REFUSED {value!r} (typed={refused[-1][2].get('typed')!r})")
key("Escape", 0.5)
return trace_mark()
if not commits:
check(False, f"{title}: typed {value} but nothing committed — the field took no keys")
key("Escape", 0.5)
return trace_mark()
typed = commits[-1][2].get("typed", "")
got = commits[-1][2].get("value", "")
check(typed == str(value),
f"{title}: field received what was typed (typed={typed!r} wanted={value!r}"
f"{' <-- it committed its PREFILL, so it never got the keyboard' if typed == prefill else ''})")
# A value that will not parse is a FAILED CHECK, never an exception. An unguarded float()
# here met a locale-formatted "61,0000" and took the whole run down immediately after the
# first check in the ladder's history had passed — the seven rungs below it were never tried
# and the report read as a total failure.
try:
ok_val = abs(float(got) - float(value)) < 1e-6
except (TypeError, ValueError):
ok_val = False
check(ok_val, f"{title}: committed value is {got!r} (wanted {value})")
check(str(value) != prefill, f"{title}: the test value differs from the prefill {prefill!r}")
# RESUME JUST AFTER THE COMMIT, not at the end of the trace. A queued chain opens its next
# field from the commit callback, so by the time trace_mark() is read here that "open" line
# is already written — and the next call, searching only after this mark, never sees it. The
# rectangle's Height, the slot's Radius and the label reopen all failed as "nothing did"
# while the trace plainly showed the field open and waiting.
return (commit_at + 1) if commit_at is not None else trace_mark()
# ---------------------------------------------------------------- the ladder
def enter_sketch(timeout=180):
"""Open a real sketch on a real plane, and PROVE it with the socket before drawing anything.
THE SEQUENCE MATTERS AND IT IS NOT OBVIOUS. Shift+S enters sketch MODE and pops the plane
offer; the offer must be dismissed; and the plane itself is chosen by clicking it in the
viewport BEFORE Shift+S. check-gui-sketching.py has always done all four steps. This ladder
did two of them — Design tab, then Shift+S — and went straight to the tool letters.
That intermediate state is the trap. `is_sketching` reads 1, every tool key is accepted and
traced, and not one click draws anything, because there is no plane under them. The ladder
then reports eleven product failures, all of them "a value field opened (nothing did)", and
every one is the driver's. Two whole runs were spent on it.
So the gate is the ORACLE, not the keytrace: sketch_describe answers only when a sketch is
genuinely open. Waiting on a mode flag is what allowed the wrong state to pass for the right
one in the first place.
"""
deadline = time.time() + timeout
while time.time() < deadline:
click(132, 53) # Design tab
time.sleep(2.0)
dismiss_modals()
click(*PLANE_PX) # pick the plane IN THE VIEWPORT — before Shift+S
key("shift+s", 1.0)
key("Escape", 0.5) # entering sketch mode pops the offer; dismiss it
key("p", 0.6) # any sketch tool starts the session on that plane
if try_call("sketch_describe") is not None:
# NO Escape here. Every rung already opens with one to drop whatever tool the last
# one left armed, and Escape in the Design tab walks a LIFO: first press drops the
# armed tool, second LEAVES THE SKETCH. Pressing it here made that second press the
# rung's own, so the ladder exited the sketch before drawing anything and then
# reported all eleven checks failed with "nothing opened" — the tools were arming
# into an empty Feature-mode document.
return
die("no sketch opened after plane click + Shift+S within "
f"{timeout}s — sketch_describe never answered on {A.sock} (trace {TRACE})")
# tool key, the clicks that draw it, and one distinct value per queued field. The values are
# deliberately nothing like the as-drawn size, so a committed prefill cannot coincide with them.
# Where the plane label sits in the viewport before a sketch is open. Same constant the gesture
# ladder uses; it is a label on the 3D view, not a widget, so it moves only if the camera does.
PLANE_PX = (913, 359)
# Every coordinate below stays inside 1000..1400 x 500..760 — the box check-gui-sketching.py's
# calibration probes land four Points in, i.e. the region PROVEN to be live canvas on a 1920x1080
# window. Earlier values started at x=950, which is left of that box and also, on a fresh datadir,
# underneath the "Bambu Network Plug-in Required" modal.
TOOLS = [
("L", "Line", [(1030, 540), (1360, 540)], [61]),
("R", "Rectangle", [(1030, 540), (1360, 730)], [62, 43]),
("C", "Circle", [(1180, 620), (1330, 620)], [64]),
("S", "Slot", [(1030, 580), (1300, 580), (1300, 640)], [66]),
("G", "Polygon", [(1180, 620), (1320, 620)], [67]),
("E", "Ellipse", [(1180, 620), (1370, 620), (1180, 720)], [68]),
("A", "Arc", [(1040, 660), (1340, 660), (1190, 560)], [69]),
]
def rung_tool(k, name, clicks, values):
print(f" {name}")
key("Escape", 0.6) # back to Select, whatever the last tool left armed
# Every rung re-establishes that a sketch is STILL open. One stray Escape too many leaves it,
# and from then on every tool arms into a Feature-mode document that cannot open a value
# field — which the checks below report as eleven independent product failures.
if try_call("sketch_describe") is None:
die(f"{name}: the sketch is no longer open before this rung — an earlier rung left it")
key(k, 0.8)
# MARK BEFORE THE CLICKS, not after. The field is opened from a CallAfter scheduled by the
# render that follows the last click, so it can already be open by the time a mark taken
# afterwards is read — and type_into_open_field, which only looks at events AFTER its mark,
# then finds none and reports "a value field opened (nothing did)" for a field that is on
# screen, open, and waiting. That message accused the product of the exact defect the ladder
# exists to detect, from a bug in the ladder's own bookkeeping.
mark = trace_mark()
for (x, y) in clicks:
click(x, y)
for v in values:
mark = type_into_open_field(v, mark)
def rung_label_click():
"""The user's own report: click an existing dimension label and type a new value into it.
KEEP THE SHAPE AS DRAWN. An earlier version committed 55 and 47 into the queued chain first,
which resized the rectangle — and then clicked the pixel where the label had been before the
resize. It missed, every time, and reported the reopen broken. The shape's on-screen position
is only predictable if nothing has moved it, so Escape the chain instead: the rectangle stays
exactly between the two corners we clicked.
FIND THE LABEL, do not assume its offset. A dimension label is drawn beside its edge at an
offset that depends on zoom and text metrics, so a single hardcoded pixel is a guess that
silently becomes wrong. Walk a short band across the top edge instead and stop at the first
click that opens a field; if none of them does, that is a real failure and it says so.
"""
print(" label click-to-edit")
key("Escape", 0.6) # Select mode
key("R", 0.8)
click(1020, 530)
click(1350, 740)
time.sleep(1.5)
key("Escape", 0.8) # keep as drawn: abandon the queued value chain
time.sleep(0.8)
key("Escape", 0.6) # back to Select so a click picks rather than draws
mid_x, top_y = (1020 + 1350) // 2, 530
candidates = [(mid_x, top_y + dy) for dy in (-26, -20, -14, -8, 0, 8, 14)]
for (cx, cy) in candidates:
mark = trace_mark()
click(cx, cy)
deadline = time.time() + 2.0
while time.time() < deadline:
if [e for e in (parse(l) for l in trace_lines()[mark:]) if e and e[0] == "open"]:
check(True, f"clicking a dimension label reopened its value field (at {cx},{cy})")
type_into_open_field(71, mark)
return
time.sleep(0.2)
check(False, "clicking a dimension label reopened its value field "
f"(tried {len(candidates)} points across the top edge at x={mid_x})")
def main():
if not A.attach:
if not os.path.exists(A.bin):
die(f"no binary at {A.bin}")
open(TRACE, "w").close()
launch()
dismiss_first_run()
win()
print(f"click-edit ladder on {DISP}, trace {TRACE}")
enter_sketch()
for (k, name, clicks, values) in TOOLS:
rung_tool(k, name, clicks, values)
rung_label_click()
print()
if _fail:
print(f"CLICK-EDIT LADDER FAILED — {_fail} of {_checks} checks", file=sys.stderr)
else:
print(f"CLICK-EDIT LADDER HELD — {_checks} checks")
if _proc is not None and not A.keep:
_proc.send_signal(signal.SIGTERM)
try:
_proc.wait(20)
except subprocess.TimeoutExpired:
_proc.kill()
return 1 if _fail else 0
if __name__ == "__main__":
sys.exit(main())