Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion plugins/leaf/skills/leaf/assets/leaf.js
Original file line number Diff line number Diff line change
Expand Up @@ -10016,9 +10016,26 @@ function renderVersions(state) {
showNews(latestChip, behind);
if (behind) latestChip.textContent = `New version available → open v${latestVersion}`;
}
/** The reader's hand on a widget, in the layer's own word: a drag the log has not taken
* yet. The class is half of `unaccountedGesture` below, so taking it up or putting it
* down moves core's `z` row — a row no widget declares, and therefore the one no widget
* would think to repaint. Both edges of a pointer drag went unpainted for exactly that
* reason, and on a quiet board the line went on offering `undo` for as long as the
* reader held the card, over a press the dispatcher was already refusing. So the paint
* is owed here, where the class is written, rather than by whoever remembers.
*
* Coalesced to a frame like every paint, which is what lets it stand for everything else
* the same gesture moved: the widget's own rows where the grab is a press on an
* already-focused grip and no focus event fires, and a send the drop states after this
* returns — so a drop that sends still reads as a gesture the log has not taken.
*/
export const dragging = (el, on) => {
el.classList.toggle("lf-dragging", on);
paintKeys();
};
// A gesture of the reader's that the page has not accounted for in a log read, asked of
// the layer's own signals rather than of any widget by name: a drag wears .lf-dragging
// (the module sets it), every unresolved browser event is in the outbox, and an undo
// (dragging, above), every unresolved browser event is in the outbox, and an undo
// in flight is its own — it is tracked separately because the walk itself cannot be
// offered again while its event is being answered.
// Two questions want the answer,
Expand Down
15 changes: 8 additions & 7 deletions plugins/leaf/skills/leaf/bundled/widgets/lf-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
keys,
labelOf,
saying,
paintKeys,
dragging,
motion,
pageScroller,
PRESS,
Expand Down Expand Up @@ -237,9 +237,8 @@ customElements.define(
const cards = this.#cards(from);
const index = cards.indexOf(card);
this.#grabbed = { card, grip, from, index };
this.classList.add("lf-dragging");
dragging(this, true);
card.classList.add("lf-lift");
paintKeys(); // a grab is a press on an already-focused grip, so no focus event fires
// Where the card starts, in the idiom every arrow step announces — a reader about to
// move it needs the position the moves count from.
announce(
Expand Down Expand Up @@ -296,8 +295,7 @@ customElements.define(
#release() {
this.#grabbed.card.classList.remove("lf-lift");
this.#grabbed = null;
this.classList.remove("lf-dragging");
paintKeys();
dragging(this, false);
}

// The one writer of "card X sits at index i among column C's cards": arrow steps,
Expand Down Expand Up @@ -375,10 +373,13 @@ customElements.define(
this.#release();
} else this.#cancel();
}
this.classList.add("lf-dragging");
dragging(this, true);
},
onEnd: (evt) => {
this.classList.remove("lf-dragging");
// Ahead of the branches below, because the one that returns early sends
// nothing: a card dropped where it was picked up puts the hand down with
// nothing following it to say so.
dragging(this, false);
const sup = this.#superseded;
this.#superseded = null;
// The *draggable* indexes, which count cards; Sortable's plain
Expand Down
72 changes: 72 additions & 0 deletions tests/test_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,22 @@ def _traffic(page):
return page.lf_traffic


def _painted_line(page):
"""The key line as the gesture just made left it.

`paintHere` coalesces to an animation frame, so a read taken straight after the
state moves reads the frame before the paint. Consuming the frame is what makes
the read once rather than a poll: the line repaints on its own often enough — a
version poll, a focus move, news arriving — that an assertion re-asking through
`expect` reports whichever later paint the page happened to make, and passes on a
gesture that painted nothing at all.
"""
page.evaluate(
"() => new Promise(done => requestAnimationFrame(() => requestAnimationFrame(done)))"
)
return page.locator(".lf-keyline").inner_text()


def _until(page, fact, wanted):
"""Block until `fact` holds of the page's traffic.

Expand Down Expand Up @@ -15305,6 +15321,62 @@ def hold(route):
page.close()


def test_a_pointer_drag_stops_the_line_offering_the_press_it_refuses(browser, serve):
"""`.lf-dragging` is half of the `z` liveness the runtime declares, and a pointer
drag is a whole gesture rather than a frame: the focus paint lands on the
mousedown, `fallbackTolerance` fires the drag's start after it, and on a quiet
board nothing repaints between the pick-up and the drop. So unpainted, the line
goes on offering `undo` for as long as the reader holds the card, over a press the
dispatcher is already refusing. The drop is the same gap read backwards: a card
put down where it was picked up takes the class off and returns before #send, so
there is no send downstream to paint in its place."""
page, errors = open_page(browser, serve(BOARD_PAGE))
grip = page.locator("#card-heater .lf-grip")
grip.focus()
for key in ["Enter", "ArrowRight", "Enter"]:
page.keyboard.press(key)
round_trip(page)
# The move carries a FLIP, and a box measured across it is a box the card has
# already left: the press lands on the grip at that instant and the pointer is
# somewhere else by the mousemove after it, so the drag never starts and the
# failure arrives as the assertion below timing out on a page nobody dragged.
page.wait_for_function(
"() => document.getElementById('card-heater').getAnimations().length === 0"
)
expect(page.locator(".lf-keyline")).to_contain_text("undo")
sent = _traffic(page).sends

# The card the keyboard move left the grip focused on, so the mousedown lands on
# an already-focused control and fires no focusin — the paint under test is the
# only one that could clear the offer. Held inside the column it now has to
# itself, so the drop reorders nothing and onEnd returns before #send.
box = grip.bounding_box()
start = (
math.floor(box["x"] + box["width"] / 2),
math.floor(box["y"] + box["height"] / 2),
)
page.mouse.move(*start)
page.mouse.down()
page.mouse.move(start[0], start[1] + 24, steps=8) # past fallbackTolerance
page.wait_for_selector("lf-board.lf-dragging") # the gesture is live in the page
# Read once, on the frame the paint coalesces to, rather than through `expect`:
# a poll two seconds out repaints the line whatever this drag did, so an
# assertion that re-asks passes on the poll and says nothing about the edge.
assert "undo" not in _painted_line(page), (
"the line offered a press the dispatcher refuses for the length of a drag"
)

page.mouse.up()
assert page.locator("lf-board.lf-dragging").count() == 0
assert "undo" in _painted_line(page), (
"the drop that sent nothing left the line refusing a press that is live"
)
assert _traffic(page).sends == sent, "the drop that moved nothing sent a move"
expect(page.locator("#col-done #card-heater")).to_have_count(1)
assert errors == []
page.close()


def test_an_action_response_accounts_for_its_gesture_without_a_follow_up_poll(
browser, serve
):
Expand Down
Loading