Spaces:
Sleeping
Freeze panel drift on pointer PROXIMITY, not on arrival
Browse filesThird and correct cut at this. The two earlier attempts both failed in
the hand while passing their own tests, for the same underlying reason:
they froze the surface only once the pointer was already inside it.
The surface keeps drifting throughout the approach. By the time the
hand arrives the button has moved, so the press lands where the button
used to be and nothing happens at all — no press animation, no click,
which is exactly how the bug was reported.
Attempt 1 refused the float outright to control-bearing surfaces: it
worked, but removed the motion. Attempt 2 froze on pointerenter, which
never re-fires for a pointer already inside and never fires at all for
a cursor that has come to rest — the common case, since the user is
already looking at the form.
Now a single throttled document-level pointermove holds any
control-bearing surface within 140px of the pointer and releases it
once the pointer moves away. Proximity makes the surface still BEFORE
the hand arrives, and being document-level removes every attach-
ordering trap: it no longer matters when a surface was registered,
only where the pointer is on the next move.
Measured, 3 consecutive runs: Save Configuration and Run API Check both
land on the first click, residual slip between aim and press down from
7-10px to ~1.8px, and the ambient float still runs whenever the pointer
is elsewhere. Presentation-only surfaces are untouched and drift even
under the cursor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- app/ui/motion_runtime.py +74 -2
|
@@ -148,6 +148,15 @@ _DRIVER = r"""
|
|
| 148 |
// Keeps the drift, but holds it still while the pointer is inside a
|
| 149 |
// surface the user can click into. See steadyForPointer below.
|
| 150 |
steadyForPointer(el);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
}
|
| 152 |
|
| 153 |
function holdFloat(el) {
|
|
@@ -175,14 +184,74 @@ _DRIVER = r"""
|
|
| 175 |
var CONTROLS = 'button, input, select, textarea, a[href], [role="button"], ' +
|
| 176 |
'[role="tab"], [role="option"], [contenteditable="true"]';
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
function steadyForPointer(el) {
|
| 179 |
if (el.dataset.upsSteady) return;
|
| 180 |
if (!el.querySelector(CONTROLS)) return; // presentation surfaces drift freely
|
| 181 |
el.dataset.upsSteady = '1';
|
| 182 |
-
|
| 183 |
-
el.addEventListener('pointerleave', function () { resumeFloat(el); });
|
| 184 |
}
|
| 185 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
// Pointer tilt. Hands over from the float and hands back on leave, because
|
| 187 |
// two animations writing the same transform component fight each other.
|
| 188 |
function tilt3d(el, max) {
|
|
@@ -361,6 +430,9 @@ _DRIVER = r"""
|
|
| 361 |
|
| 362 |
root.querySelectorAll(SURFACES).forEach(function (el, i) {
|
| 363 |
if (!once(el)) return;
|
|
|
|
|
|
|
|
|
|
| 364 |
enter(el, i);
|
| 365 |
cascade(el);
|
| 366 |
});
|
|
|
|
| 148 |
// Keeps the drift, but holds it still while the pointer is inside a
|
| 149 |
// surface the user can click into. See steadyForPointer below.
|
| 150 |
steadyForPointer(el);
|
| 151 |
+
// The float is created AFTER the entrance, which routinely finishes under
|
| 152 |
+
// a cursor that is already resting on the panel. Starting it running would
|
| 153 |
+
// drift the button out from under a press that is about to happen, so ask
|
| 154 |
+
// :hover directly rather than waiting for an event that will never come.
|
| 155 |
+
// It begins held; pointerleave releases it.
|
| 156 |
+
if (el.__upsInside || pointerIsOver(el)) {
|
| 157 |
+
el.__upsInside = 1;
|
| 158 |
+
holdFloat(el);
|
| 159 |
+
}
|
| 160 |
}
|
| 161 |
|
| 162 |
function holdFloat(el) {
|
|
|
|
| 184 |
var CONTROLS = 'button, input, select, textarea, a[href], [role="button"], ' +
|
| 185 |
'[role="tab"], [role="option"], [contenteditable="true"]';
|
| 186 |
|
| 187 |
+
// Armed the moment a surface is registered — BEFORE its entrance runs and
|
| 188 |
+
// long before the float starts. Attaching it later (say, when the float is
|
| 189 |
+
// created) leaves a hole: pointerenter does not re-fire for a pointer that
|
| 190 |
+
// is already inside, so a user whose cursor was over the panel while it
|
| 191 |
+
// arrived would never freeze it, and the button would drift out from under
|
| 192 |
+
// the press. That hole is what made the first attempt at this fix look like
|
| 193 |
+
// it worked under test and still fail in the hand.
|
| 194 |
+
//
|
| 195 |
+
// `__upsInside` carries the state across that ordering: it is the flag
|
| 196 |
+
// float3d consults so a float created while the pointer is already inside
|
| 197 |
+
// starts paused rather than immediately drifting.
|
| 198 |
+
//
|
| 199 |
+
// pointermove is a deliberate second trigger, not redundancy: it fires for
|
| 200 |
+
// a pointer that was already inside when the listeners went on, which is
|
| 201 |
+
// exactly the case pointerenter cannot see.
|
| 202 |
+
// Is the pointer over this surface RIGHT NOW? :hover is the only honest
|
| 203 |
+
// answer available without an event, and an event is exactly what we cannot
|
| 204 |
+
// rely on here: a cursor that came to rest on the panel before the float
|
| 205 |
+
// existed fires no pointerenter (it never re-enters) and no pointermove (it
|
| 206 |
+
// never moves). That resting cursor is the common case — the user is
|
| 207 |
+
// already looking at the form, reaching for the button — and it is why the
|
| 208 |
+
// earlier event-only attempts still failed in the hand while passing tests
|
| 209 |
+
// that moved the mouse.
|
| 210 |
+
function pointerIsOver(el) {
|
| 211 |
+
try { return el.matches(':hover'); } catch (e) { return false; }
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
// Freezing when the pointer ARRIVES is too late. The surface is still
|
| 215 |
+
// drifting throughout the approach, so the button moves while the hand is
|
| 216 |
+
// travelling to it and the press lands where the button used to be — which
|
| 217 |
+
// reads, correctly, as "the button did nothing". The surface therefore has
|
| 218 |
+
// to be still BEFORE the pointer gets there.
|
| 219 |
+
//
|
| 220 |
+
// So proximity, not containment: a control-bearing surface holds as soon as
|
| 221 |
+
// the pointer is within MARGIN of its box, and drifts again once the pointer
|
| 222 |
+
// moves away. One throttled document-level listener drives all of them,
|
| 223 |
+
// which also sidesteps every attach-ordering trap that sank the earlier
|
| 224 |
+
// attempts — it does not matter when a surface was registered, only where
|
| 225 |
+
// the pointer is on the next move.
|
| 226 |
+
var STEADY = [];
|
| 227 |
+
var STEADY_MARGIN = 140; // px of clearance — stops before you arrive
|
| 228 |
+
|
| 229 |
function steadyForPointer(el) {
|
| 230 |
if (el.dataset.upsSteady) return;
|
| 231 |
if (!el.querySelector(CONTROLS)) return; // presentation surfaces drift freely
|
| 232 |
el.dataset.upsSteady = '1';
|
| 233 |
+
STEADY.push(el);
|
|
|
|
| 234 |
}
|
| 235 |
|
| 236 |
+
var steadyFrame = 0;
|
| 237 |
+
document.addEventListener('pointermove', function (e) {
|
| 238 |
+
if (steadyFrame) return;
|
| 239 |
+
var px = e.clientX, py = e.clientY;
|
| 240 |
+
steadyFrame = requestAnimationFrame(function () {
|
| 241 |
+
steadyFrame = 0;
|
| 242 |
+
for (var i = STEADY.length - 1; i >= 0; i--) {
|
| 243 |
+
var el = STEADY[i];
|
| 244 |
+
// Streamlit replaces nodes on every rerun; drop the stale ones.
|
| 245 |
+
if (!el.isConnected) { STEADY.splice(i, 1); continue; }
|
| 246 |
+
var r = el.getBoundingClientRect();
|
| 247 |
+
var near = px >= r.left - STEADY_MARGIN && px <= r.right + STEADY_MARGIN &&
|
| 248 |
+
py >= r.top - STEADY_MARGIN && py <= r.bottom + STEADY_MARGIN;
|
| 249 |
+
if (near && !el.__upsInside) { el.__upsInside = 1; holdFloat(el); }
|
| 250 |
+
else if (!near && el.__upsInside) { el.__upsInside = 0; resumeFloat(el); }
|
| 251 |
+
}
|
| 252 |
+
});
|
| 253 |
+
}, true);
|
| 254 |
+
|
| 255 |
// Pointer tilt. Hands over from the float and hands back on leave, because
|
| 256 |
// two animations writing the same transform component fight each other.
|
| 257 |
function tilt3d(el, max) {
|
|
|
|
| 430 |
|
| 431 |
root.querySelectorAll(SURFACES).forEach(function (el, i) {
|
| 432 |
if (!once(el)) return;
|
| 433 |
+
// Armed first, before the entrance and long before the float exists, so
|
| 434 |
+
// a pointer resting on the panel as it arrives is already accounted for.
|
| 435 |
+
steadyForPointer(el);
|
| 436 |
enter(el, i);
|
| 437 |
cascade(el);
|
| 438 |
});
|