Sketch value fields: content-based key arbiter + the gate that can judge it

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
This commit is contained in:
Tommaso Bianchi
2026-09-06 10:35:22 +02:00
co-authored by Claude Opus 5
parent af4bbe0217
commit 9134299233
9 changed files with 946 additions and 2 deletions
+5
View File
@@ -1339,6 +1339,11 @@ bool DesignCanvas::inline_has_focus() const
return m_inline_editor && m_inline_editor->has_focus();
}
bool DesignCanvas::inline_type_char(int key)
{
return m_inline_editor && m_inline_editor->type_char(key);
}
void DesignCanvas::inline_commit()
{
if (m_inline_editor) m_inline_editor->commit();
+2
View File
@@ -244,6 +244,8 @@ public:
void delete_selected_sketch_entities();
bool inline_busy() const; // a sketch value field is open (guard keys)
bool inline_has_focus() const; // the field itself holds keyboard focus
// Hand one character to the open value field, bypassing focus. See DesignPanel's arbiter.
bool inline_type_char(int key);
void inline_commit(); // accept the typed value (Enter/Tab)
void inline_cancel(); // discard the typed value (Esc)
// The layered Esc: abandon the points of the gesture in progress, else drop the armed tool
+19
View File
@@ -4205,6 +4205,25 @@ DesignPanel::DesignPanel(wxWindow* parent)
m_viewport->inline_commit();
return;
}
// THE ARBITER. Route by what the key IS, not by who the window manager focused.
//
// This is FreeCAD's rule, from Sketcher's DrawSketchKeyboardManager::
// detectKeyboardEventHandlingMode: a digit, a sign, a decimal separator or a
// Backspace/Delete is unambiguously meant for the number the user is entering; a
// letter is unambiguously a tool shortcut; Enter/Tab hand control back to the view.
// FreeCAD never queries focus anywhere in that decision, and that is precisely why
// its sketcher behaves the same on every desktop.
//
// Ours asked "who has focus?" instead — a question whose answer is the window
// manager's opinion. openbox grants this borderless top-level the keyboard, mutter
// refuses it, so the same binary took typed values on one machine and silently
// committed the pre-filled as-drawn number on another. Seven workarounds fought that
// and one of them cost a macOS regression. The question was wrong, not the answers.
//
// The has_focus() guard above keeps this from double-typing where the toolkit DID
// give the field the keyboard: there the field's own binding will get the key too.
if (!ctrl && m_viewport->inline_type_char(key))
return;
// Esc is NOT special-cased here any more: escape() routes it, and the open field is
// exactly what CadLevel::Transient means, so it closes the field and stops there.
}
+17 -2
View File
@@ -1383,10 +1383,22 @@ std::string DesignSketchTool::dimtype_title(DimType k) const {
// Draw-then-edit dispatcher: mirror the Select-mode quote-click logic, but target the
// freshly-drawn selection's PRIMARY value and use the tentative (clean-cancel) path for
// scalar quotes. Runs after render_live_quotes, so the live-quote state is populated.
// Why a draw-then-edit chain did not start. Four early returns can swallow it, and from outside
// they are indistinguishable: the shape appears, no field opens, and nothing says which guard
// fired. check-gui-click-edit.py reports that as "a value field opened (nothing did)" for every
// tool at once, which reads like a total product failure and is not necessarily one.
static void trace_autoedit(const char* why, size_t n)
{
if (!std::getenv("SNAPORCA_UXTRACE")) return;
fprintf(stderr, "[UX] autoedit %s steps=%zu\n", why, n);
fflush(stderr);
}
void DesignSketchTool::open_primary_autoedit()
{
if (!on_inline_edit || m_awaiting_length) return; // no host, or a field is already open
if (!m_active) return; // session ended before the deferred tick
if (!on_inline_edit) { trace_autoedit("skip: no on_inline_edit host", 0); return; }
if (m_awaiting_length) { trace_autoedit("skip: a field is already open", 0); return; }
if (!m_active) { trace_autoedit("skip: session ended before the deferred tick", 0); return; }
// Build ONE ordered list of edit steps covering EVERY characteristic dimension of the
// freshly-drawn shape — scalar quotes (constraint-based) AND geometric editors — so every
@@ -1494,6 +1506,8 @@ void DesignSketchTool::open_primary_autoedit()
[this, fi](double v){ set_rect_angle(fi, v); }, span(fi), "Angle" });
}
trace_autoedit(m_autoedit_dims.empty() ? "built NO steps (no live quote matched)" : "opening",
m_autoedit_dims.size());
if (!m_autoedit_dims.empty()) {
m_autoedit_dim_idx = 0;
open_next_autoedit_dim();
@@ -8857,6 +8871,7 @@ void DesignSketchTool::render(GLCanvas3D& canvas)
// next render_live_quotes(), so the deferred open still sees this frame's values.
if (m_autoedit_pending) {
m_autoedit_pending = false;
trace_autoedit("pending -> deferring open", 0);
wxGetApp().CallAfter([this] { open_primary_autoedit(); });
}
if (is_edit_op_mode())
+80
View File
@@ -90,6 +90,23 @@ constexpr bool keep_mapped_between_fields =
false;
#endif
// The click-edit contract, made observable. scripts/CAD/check-gui-click-edit.py grades a build
// on these four lines and nothing else, because they are the only place the distinction it cares
// about is visible: a field that is on screen but deaf commits its PREFILL, and every other
// signal — the field drew, a constraint appeared, the solve succeeded — looks perfectly healthy
// either way. `typed` is what the control actually held when Enter arrived; `prefill` is what
// open() put there. typed == prefill on a commit means the keyboard never reached the field.
//
// stderr, one line, no buffering, only under SNAPORCA_UXTRACE: this is a test surface, not
// logging, and it must cost nothing in a normal run.
void trace_ux(const char* event, const std::string& title, const std::string& kv)
{
if (!std::getenv("SNAPORCA_UXTRACE")) return;
fprintf(stderr, "[UX] %s title=%s%s%s\n", event, title.c_str(),
kv.empty() ? "" : " ", kv.c_str());
fflush(stderr);
}
void trace_inline_focus(wxFrame* frame, const std::string& title)
{
if (!std::getenv("SNAPORCA_KEYTRACE")) return;
@@ -214,6 +231,8 @@ void SketchInlineEditor::open(const wxPoint& screen_px, double value,
m_ctrl->SetFocus();
m_ctrl->SelectAll();
m_open = true;
m_prefill = m_ctrl->GetValue();
trace_ux("open", title, "prefill=" + std::string(m_prefill.utf8_str()));
trace_inline_focus(m_frame, title);
// Re-assert on the next tick too: the GL canvas can reclaim focus while it finishes
// handling the click/render that opened us, so a single immediate SetFocus may be stolen.
@@ -235,6 +254,8 @@ void SketchInlineEditor::do_commit()
// Silence here read as a freeze: Enter did nothing, the text re-selected itself, and
// nothing on screen said the value had been refused or what would be accepted. Every
// other CAD names the problem in place; so do we.
trace_ux("refused", std::string(m_title_text.utf8_str()),
"typed=" + std::string(m_ctrl->GetValue().utf8_str()));
flag_invalid(m_ctrl->GetValue().Strip(wxString::both).IsEmpty()
? _L("Enter a number")
: _L("Not a number"));
@@ -242,6 +263,17 @@ void SketchInlineEditor::do_commit()
m_ctrl->SelectAll();
return;
}
{
// LOCALE-INVARIANT on purpose. printf honours the app's locale, which on an Italian
// desktop makes this "61,0000" — and the ladder that reads it does float(), which raises
// on a comma and takes the whole run down one check after the first success. A machine
// surface must not change shape with the user's regional settings.
char buf[64];
snprintf(buf, sizeof(buf), "%.4f", v);
for (char* c = buf; *c; ++c) if (*c == ',') *c = '.';
trace_ux("commit", std::string(m_title_text.utf8_str()),
"typed=" + std::string(m_ctrl->GetValue().utf8_str()) + " value=" + buf);
}
auto cb = m_commit;
m_open = false; // logically closed; whether it stays MAPPED is per-toolkit
m_commit = nullptr;
@@ -262,6 +294,53 @@ void SketchInlineEditor::do_commit()
});
}
// Deliver one character into the field without the window manager's permission.
//
// This is the whole content-based-routing idea in one function: the caller has already decided,
// from the KEY ITSELF, that this keystroke belongs to a number field, so the field takes it —
// whether or not any window manager saw fit to give it focus. FreeCAD's sketcher works exactly
// this way and never asks who is focused.
bool SketchInlineEditor::type_char(int key)
{
if (!m_open || m_ctrl == nullptr) return false;
if (key == WXK_BACK || key == WXK_DELETE) {
long from = 0, to = 0;
m_ctrl->GetSelection(&from, &to);
if (from != to) {
m_ctrl->Remove(from, to);
} else {
const long ip = m_ctrl->GetInsertionPoint();
if (key == WXK_BACK) { if (ip > 0) m_ctrl->Remove(ip - 1, ip); }
else { if (ip < m_ctrl->GetLastPosition()) m_ctrl->Remove(ip, ip + 1); }
}
clear_invalid();
return true;
}
// The numeric keypad reports its own key codes, and a keypad is exactly what someone typing
// dimensions all day uses.
int ch = key;
if (key >= WXK_NUMPAD0 && key <= WXK_NUMPAD9) ch = '0' + (key - WXK_NUMPAD0);
else if (key == WXK_NUMPAD_DECIMAL) ch = '.';
else if (key == WXK_NUMPAD_SUBTRACT) ch = '-';
const bool numeric = (ch >= '0' && ch <= '9') || ch == '-' || ch == '+' || ch == '.' || ch == ',';
if (!numeric) return false;
// A decimal COMMA is normalised to a point on the way in: this field feeds a CAD kernel and
// the rest of the file already promises a point whatever the locale (see fmt_value).
if (ch == ',') ch = '.';
// WriteText replaces the current selection — and open() left the whole prefill selected, so
// the FIRST character typed replaces the as-drawn value and the rest append. That is the
// behaviour a person expects from a pre-selected field, obtained for free rather than
// reimplemented.
m_ctrl->WriteText(wxString(wxUniChar(ch)));
clear_invalid();
return true;
}
void SketchInlineEditor::cancel()
{
if (m_open) do_cancel();
@@ -345,6 +424,7 @@ void SketchInlineEditor::clear_invalid()
void SketchInlineEditor::do_cancel()
{
if (!m_open) return;
trace_ux("cancel", std::string(m_title_text.utf8_str()), "");
auto cb = m_cancel;
close();
if (cb) cb();
+16
View File
@@ -49,8 +49,23 @@ private:
public:
// True when the field itself holds keyboard focus. Callers use this to decide whether the
// field will handle a key on its own or needs it forwarded — see DesignPanel's CHAR_HOOK.
//
// NOTE what this is NOT for any more: deciding whether the field may receive a character.
// Whether a borderless top-level window is granted focus is the window manager's call and
// differs per desktop — openbox grants it, mutter refuses it — so a routing rule built on
// this question gives a different product on every machine. Routing is now by CONTENT
// (DesignPanel's arbiter); this stays only to avoid forwarding a key the field is already
// going to get for itself, which would type it twice.
bool has_focus() const { return m_ctrl != nullptr && wxWindow::FindFocus() == m_ctrl; }
// Deliver one character into the field programmatically, bypassing focus entirely.
// `key` is a wx key code: a printable character is inserted, WXK_BACK/WXK_DELETE edit.
// Returns true if the field consumed it. Modelled on FreeCAD, whose sketcher decides where a
// key belongs from the key itself and never queries focus:
// DrawSketchKeyboardManager::detectKeyboardEventHandlingMode routes digits, '-', '.', ','
// and Backspace/Delete to the on-view parameter and everything else to the view.
bool type_char(int key);
private:
void do_commit();
void do_cancel();
@@ -70,6 +85,7 @@ private:
bool m_open{false};
bool m_closing{false};
wxString m_title_text; // the real title, restored after an error message
wxString m_prefill; // what open() put in the field; see trace_ux
};
}} // namespace Slic3r::GUI