mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-18 22:42:37 +00:00
The reported defect: sketch dimension labels are "not editable" — you draw a
rectangle, its Width field opens, you type, and the as-drawn number is committed
instead. It affects every sketch tool, not just the rounded rectangle.
WHAT THIS ADDS
1. The arbiter (DesignPanel CHAR_HOOK -> DesignCanvas::inline_type_char ->
SketchInlineEditor::type_char). Routes a key by what it IS, not by who the
window manager focused: digits, sign, decimal separator and Backspace/Delete
go to the open value field, Enter/Tab commit, letters stay tool shortcuts.
This is FreeCAD Sketcher's rule (DrawSketchKeyboardManager::
detectKeyboardEventHandlingMode), and the reason its sketcher behaves the same
on every desktop: it never asks who has focus.
2. The [UX] trace (SNAPORCA_UXTRACE) in SketchInlineEditor: open/commit/refused/
cancel, with the prefill and what the control actually held at Enter. It did
not exist — the ladder below was written against a surface no build emitted,
so it could only ever report "nothing opened". typed == prefill on a commit is
the defect's signature and nothing else makes it visible.
3. A draw-then-edit trace in DesignSketchTool: four early returns can swallow the
value-field chain and from outside they are indistinguishable.
4. scripts/CAD/check-gui-click-edit.py — types WITHOUT clicking the field, as a
person does, across Line/Rectangle/Circle/Slot/Polygon/Ellipse/Arc plus label
click-to-edit, and asserts committed == typed != prefill.
5. scripts/CAD/focus-loop.sh — sync/build/assert on behemoth. NOT the orcacad-gui
rig: its image pins deps 216 non-CAD files behind cad-mainline, so today's CAD
sources cannot build there without a deps rebuild.
WHAT IS PROVEN, AND WHAT IS NOT
Green under openbox: 28 checks, every tool, committed == typed != prefill.
But openbox CANNOT adjudicate this bug and the ladder says so in place. There the
field always wins the keyboard, so the same ladder also passes against a binary
with the arbiter compiled out — measured twice. Two ways of removing the keyboard
were tried and both are recorded as dead ends: XSetInputFocus loses to the field's
own re-focus CallAfter, and XSendEvent (xdotool --window) is dropped by GTK, which
made every run red regardless of the code.
Under metacity — same focus-stealing-prevention lineage as the user's mutter — the
mechanism appears in the WM's own log:
Buggy client sent a _NET_ACTIVE_WINDOW message with a timestamp of 0
That is the activation being refused, which is exactly the reported symptom.
present_toplevel() already asks for a server timestamp, so a path is still falling
through to frame->Raise(), which sends time 0. That is the next thing to fix, and
it is tracked; the arbiter alone does not close it. metacity also aborts on this
window (frames.c:1239), so the gate needs a WM that survives before it can return
a verdict.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
637 lines
30 KiB
Python
Executable File
637 lines
30 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()
|
|
# REPRODUCE THE FAILING CONDITION ON PURPOSE, rather than hoping the window manager supplies
|
|
# it. The defect is "the value field is open but does not hold the keyboard", which is what
|
|
# mutter does on the user's GNOME desktop and what openbox — the rig's WM — never does. A
|
|
# ladder that just types on openbox is green at baseline and proves nothing: the gate could
|
|
# not fail, so it could not pass meaningfully either.
|
|
#
|
|
# So take the keyboard AWAY from the field first, deliberately, by activating the main
|
|
# window. That is exactly the state mutter leaves behind, reproduced deterministically on any
|
|
# WM. A build that routes keys by content is unaffected; a build that routes by focus commits
|
|
# its prefill, which is the bug, and the assertion below catches it.
|
|
if not A.no_defocus:
|
|
w, _, _, _, _ = win()
|
|
# windowfocus, NOT windowactivate. `windowactivate` sets _NET_ACTIVE_WINDOW — it ASKS the
|
|
# window manager, and openbox obliges by marking the field's toplevel inactive while
|
|
# leaving the X input focus exactly where it was. Measured: the trace read
|
|
# "active=0 toplevel_focus=0" and every keystroke still reached the field, so the ladder
|
|
# passed against a binary with the arbiter compiled out. A gate that cannot fail cannot
|
|
# pass meaningfully either, and that run nearly shipped as proof.
|
|
#
|
|
# `windowfocus` calls XSetInputFocus, which is what actually decides where the server
|
|
# delivers keys. That reproduces the real defect — field on screen, keyboard elsewhere —
|
|
# on any WM, instead of hoping the local one volunteers it.
|
|
# DELIVER THE KEYS TO THE MAIN WINDOW, not to whatever holds the focus.
|
|
#
|
|
# This is the defect, reproduced exactly and without a focus fight. On the user's GNOME
|
|
# desktop mutter's focus-stealing prevention refuses the borderless field toplevel the
|
|
# keyboard, so the digits are delivered to the Design panel — which is precisely what
|
|
# DesignPanel's CHAR_HOOK comment describes. Only routing by CONTENT can get them from
|
|
# there into the field; a build that routes by focus commits its prefill.
|
|
#
|
|
# Stealing the focus instead does NOT work and must not be reinstated: measured on
|
|
# openbox, the app re-asserts SetFocus from open()'s CallAfter and wins every race — four
|
|
# retries of XSetInputFocus all lost, `xdotool getwindowfocus` came back as the field's
|
|
# own toplevel every time. Two full runs passed against a binary with the arbiter
|
|
# compiled out because of it. Targeting the window sidesteps the question entirely.
|
|
target = None
|
|
if not A.no_defocus:
|
|
target, _, _, _, _ = win()
|
|
typ(str(value), 0.4, window=target)
|
|
key("Return", 0.9, window=target)
|
|
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())
|