From a08b2cf7709b8003cb92f885e25acf76ef618622 Mon Sep 17 00:00:00 2001 From: leaf-agent <318509791+leaf-agent@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:37:43 +0000 Subject: [PATCH] Repaint the key line when a drag crosses into a selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Escape rung reads the live selection, but nothing repainted the key line inside a pointer press: `selectionchange` returns early while `pointerSelecting` stands, so the word the line showed was whatever the frame the press itself scheduled happened to catch. Idle, that frame landed after the drag had run and the line said "unselect"; loaded, it landed first and the line went on promising "let go" — the wrong key — until the two-second heartbeat repainted it. The handler now repaints when the answer crosses, which is once per press in each direction. The bar still waits for the release; only the line follows the drag, and a drag growing a standing selection paints nothing, so no whole `paintHere` lands inside every frame of one. `hold_selection` gains `frame_the_press`, which lets that frame land before the drag begins, and the label-press test states it — the ordering CI gives every drag and an idle machine gives almost none, and the ordering under which the line's only route to the word is the selection. --- .../leaf/assets/runtime/composing/surface.js | 18 ++++++++++++++++++ tests/render_harness.py | 12 +++++++++++- tests/test_render_navigation.py | 7 +++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/skills/leaf/assets/runtime/composing/surface.js b/skills/leaf/assets/runtime/composing/surface.js index c3ac2aa36..35b79219d 100644 --- a/skills/leaf/assets/runtime/composing/surface.js +++ b/skills/leaf/assets/runtime/composing/surface.js @@ -402,6 +402,16 @@ export function createSelectionSurface({ let selectionPressPoint = null; let actionPress = false; let targetActivation = false; + // Whether the page's own words stood selected when the key line was last painted for + // this press. The bar waits for the release; the Escape rung cannot, because from the + // first glyph a drag takes, Escape clears the selection rather than letting go of the + // control the reader is standing on, and until now nothing repainted the line inside a + // press — the word only became true when the frame the press itself scheduled happened + // to land after the drag had moved, and stayed a lie for a whole heartbeat when it + // landed before. Only the crossing is painted: a drag growing a selection that already + // stands says the same word, and repainting the chrome on every move of a drag would + // put a whole `paintHere` inside every frame of one. + let selectionStood = false; const rememberPointerSelection = () => { const selection = pageSelection(); const anchor = selection ? selectionAnchor(selection) : null; @@ -418,6 +428,9 @@ export function createSelectionSurface({ selectionDragged = false; selectionRangeDuringPress = null; selectionPressPoint = pointerSelecting ? { x: ev.clientX, y: ev.clientY } : null; + // Read here, ahead of the browser's own collapse, so the first crossing this press + // makes is measured against what the line already says rather than against nothing. + selectionStood = Boolean(pageSelection()); const selection = pointerSelecting ? pageSelection() : null; if (selection) { const range = pageRange(selection); @@ -451,6 +464,11 @@ export function createSelectionSurface({ if (pointerSelecting) { selectionChangedDuringPress = true; rememberPointerSelection(); + const stands = Boolean(pageSelection()); + if (stands !== selectionStood) { + selectionStood = stands; + paintHere(); + } return; } if (actionPress || targetActivation || takesLetters(document.activeElement)) return; diff --git a/tests/render_harness.py b/tests/render_harness.py index 8233851a2..16736a64c 100644 --- a/tests/render_harness.py +++ b/tests/render_harness.py @@ -1451,9 +1451,17 @@ def resized(page, width, height): page.evaluate(ONE_FRAME) -def hold_selection(page, start, end, steps=8): +def hold_selection(page, start, end, steps=8, frame_the_press=False): """Drag a selection without releasing, pressing on a whole pixel. + `frame_the_press` lets the frame the press itself schedules land before the drag + begins, which is the ordering a loaded machine gives every drag and an idle one + gives almost none. Without it a surface repainted inside the press is read as + following the drag whenever the round trips outrun the frame, and as stale + whenever they do not — a coin toss written as an assertion. State it wherever the + read is of something the press repaints, so the drag is the only thing the read + can be about. + A fractional start point loses the selection outright wherever it and its own floor fall either side of a glyph's caret boundary: the drag runs, the mouseup lands, and `getSelection()` comes back empty. It reads as the widget under the @@ -1468,6 +1476,8 @@ def hold_selection(page, start, end, steps=8): moves the selection a character.""" page.mouse.move(math.floor(start[0]), math.floor(start[1])) page.mouse.down() + if frame_the_press: + page.evaluate(RENDERED) page.mouse.move(end[0], end[1], steps=steps) diff --git a/tests/test_render_navigation.py b/tests/test_render_navigation.py index de730cd85..3d527cf11 100644 --- a/tests/test_render_navigation.py +++ b/tests/test_render_navigation.py @@ -3838,11 +3838,18 @@ def test_a_label_press_keeps_the_controls_keyboard_standing(browser, serve): expect(control).to_be_focused() expect(page.locator("#frame-question-decision[data-lf-decision]")).to_have_count(1) + # The press has its own frame and the drag comes after it, so the line's only route + # to the word is the selection the drag makes: the reader is taking words out of a + # label, and from the first glyph Escape clears that selection rather than letting + # go of the control. Framed the other way round the press's frame painted the line + # after the drag had already run, and the word arrived whether or not anything + # followed the selection. hold_selection( page, (bounds["x"] + 2, middle[1]), (bounds["x"] + bounds["width"] - 2, middle[1]), steps=10, + frame_the_press=True, ) assert "after state" in page.evaluate("() => getSelection().toString()") assert "unselect" in key_line(page)