Repository navigation
fix(scroll): keep Android directional swipe endpoints inside the resolved scrollable #3746
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import { test } from 'vitest'; | ||
| import assert from 'node:assert/strict'; | ||
| import type { Rect } from '@agent-device/kernel/snapshot'; | ||
| import { buildScrollGesturePlan, type ScrollDirection } from './scroll-gesture.ts'; | ||
|
|
||
| // The iPhone geometry #2714 was reported on: a 402x874 frame, the default 10% edge padding leaving | ||
| // y 87..787 for a swipe. | ||
| const FRAME = { referenceWidth: 402, referenceHeight: 874 }; | ||
| const PADDED_TOP = 87; | ||
| const PADDED_BOTTOM = 787; | ||
| const INSET_CONTAINER: Rect = { x: 18, y: 178, width: 366, height: 662 }; | ||
| const FITTING_CONTAINER: Rect = { x: 18, y: 62, width: 366, height: 729 }; | ||
|
|
||
| function endpointsInside( | ||
| plan: { x1: number; y1: number; x2: number; y2: number }, | ||
| rect: Rect, | ||
| ): boolean { | ||
| const inside = (x: number, y: number) => | ||
| x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height; | ||
| return inside(plan.x1, plan.y1) && inside(plan.x2, plan.y2); | ||
| } | ||
|
|
||
| test.each<ScrollDirection>(['up', 'down'])( | ||
| 'a %s swipe longer than an inset container shrinks to the container and reports both spans', | ||
| (direction) => { | ||
| const unclamped = buildScrollGesturePlan({ direction, amount: 0.75, ...FRAME }); | ||
| const plan = buildScrollGesturePlan({ | ||
| direction, | ||
| amount: 0.75, | ||
| ...FRAME, | ||
| container: INSET_CONTAINER, | ||
| }); | ||
|
|
||
| assert.ok(endpointsInside(plan, INSET_CONTAINER), JSON.stringify(plan)); | ||
| assert.deepEqual( | ||
| [Math.min(plan.y1, plan.y2), Math.max(plan.y1, plan.y2)], | ||
| [INSET_CONTAINER.y, PADDED_BOTTOM], | ||
| ); | ||
| assert.equal(plan.pixels, PADDED_BOTTOM - INSET_CONTAINER.y); | ||
| assert.equal(plan.requestedPixels, unclamped.pixels); | ||
| assert.equal(Math.sign(plan.y2 - plan.y1), Math.sign(unclamped.y2 - unclamped.y1)); | ||
| }, | ||
| ); | ||
|
|
||
| test.each<ScrollDirection>(['up', 'down', 'left', 'right'])( | ||
| 'a %s swipe already inside its container keeps today’s endpoints and distance', | ||
| (direction) => { | ||
| const unclamped = buildScrollGesturePlan({ direction, amount: 0.75, ...FRAME }); | ||
| const plan = buildScrollGesturePlan({ | ||
| direction, | ||
| amount: 0.75, | ||
| ...FRAME, | ||
| container: FITTING_CONTAINER, | ||
| }); | ||
|
|
||
| assert.deepEqual(plan, unclamped); | ||
| assert.equal('requestedPixels' in plan, false); | ||
| }, | ||
| ); | ||
|
|
||
| test('a swipe that fits an offset container moves into it without shortening', () => { | ||
| const lowerContainer: Rect = { x: 0, y: 300, width: 402, height: 574 }; | ||
| const unclamped = buildScrollGesturePlan({ direction: 'down', amount: 0.5, ...FRAME }); | ||
| const plan = buildScrollGesturePlan({ | ||
| direction: 'down', | ||
| amount: 0.5, | ||
| ...FRAME, | ||
| container: lowerContainer, | ||
| }); | ||
|
|
||
| assert.ok(endpointsInside(plan, lowerContainer), JSON.stringify(plan)); | ||
| assert.equal(Math.min(plan.y1, plan.y2), lowerContainer.y); | ||
| assert.equal(Math.abs(plan.y1 - plan.y2), Math.abs(unclamped.y1 - unclamped.y2)); | ||
| assert.equal(plan.pixels, unclamped.pixels); | ||
| assert.equal('requestedPixels' in plan, false); | ||
| }); | ||
|
|
||
| test('a vertical swipe beside a narrow container runs down its centre line', () => { | ||
| const rightColumn: Rect = { x: 250, y: 0, width: 152, height: 874 }; | ||
| const unclamped = buildScrollGesturePlan({ direction: 'up', ...FRAME }); | ||
| const plan = buildScrollGesturePlan({ direction: 'up', ...FRAME, container: rightColumn }); | ||
|
|
||
| assert.equal(plan.x1, rightColumn.x + rightColumn.width / 2); | ||
| assert.equal(plan.x2, plan.x1); | ||
| assert.deepEqual([plan.y1, plan.y2, plan.pixels], [unclamped.y1, unclamped.y2, unclamped.pixels]); | ||
| }); | ||
|
|
||
| test('a container lying wholly in the edge padding leaves the padded swipe as it was', () => { | ||
| const statusBand: Rect = { x: 0, y: 0, width: 402, height: PADDED_TOP - 1 }; | ||
| const unclamped = buildScrollGesturePlan({ direction: 'up', ...FRAME }); | ||
| const plan = buildScrollGesturePlan({ direction: 'up', ...FRAME, container: statusBand }); | ||
|
|
||
| assert.deepEqual(plan, unclamped); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -49,6 +49,12 @@ export type ScrollGestureOptions = { | |
| pixels?: number; | ||
| referenceWidth: number; | ||
| referenceHeight: number; | ||
| /** | ||
| * The scroll container the swipe is meant for, in the reference frame's own zero-origin space. | ||
| * Both endpoints stay inside its intersection with the padded frame (#2714): a span that already | ||
| * fits is unchanged, one that leaves it is moved in, and one longer than it is shortened. | ||
| */ | ||
| container?: Rect; | ||
| }; | ||
|
|
||
| export type ScrollGesturePlan = { | ||
|
|
@@ -60,7 +66,10 @@ export type ScrollGesturePlan = { | |
| referenceWidth: number; | ||
| referenceHeight: number; | ||
| amount?: number; | ||
| /** The span dispatched, which is less than `requestedPixels` when the container shortened it. */ | ||
| pixels: number; | ||
| /** Present only when the container shortened the swipe: the span the padded frame would carry. */ | ||
| requestedPixels?: number; | ||
| }; | ||
|
|
||
| export type SwipePresetGesturePlan = { | ||
|
|
@@ -125,16 +134,65 @@ export function buildScrollGesturePlan(options: ScrollGestureOptions): ScrollGes | |
| pixels: travelPixels, | ||
| }); | ||
|
|
||
| switch (direction) { | ||
| case 'up': | ||
| return buildPlan(centerX, centerY - halfTravel, centerX, centerY + halfTravel); | ||
| case 'down': | ||
| return buildPlan(centerX, centerY + halfTravel, centerX, centerY - halfTravel); | ||
| case 'left': | ||
| return buildPlan(centerX - halfTravel, centerY, centerX + halfTravel, centerY); | ||
| case 'right': | ||
| return buildPlan(centerX + halfTravel, centerY, centerX - halfTravel, centerY); | ||
| } | ||
| const vertical = direction === 'up' || direction === 'down'; | ||
| const unclamped = { | ||
| axisStart: (vertical ? centerY : centerX) - halfTravel, | ||
| axisEnd: (vertical ? centerY : centerX) + halfTravel, | ||
| cross: vertical ? centerX : centerY, | ||
| }; | ||
| const lane = options.container | ||
| ? keepSwipeInContainer(unclamped, options.container, vertical, { | ||
| min: edgePadding, | ||
| max: axisLength - edgePadding, | ||
| }) | ||
| : unclamped; | ||
| const shortened = lane.axisEnd - lane.axisStart < unclamped.axisEnd - unclamped.axisStart; | ||
| const plan = buildPlan( | ||
| vertical ? lane.cross : lane.axisStart, | ||
| vertical ? lane.axisStart : lane.cross, | ||
| vertical ? lane.cross : lane.axisEnd, | ||
| vertical ? lane.axisEnd : lane.cross, | ||
| ); | ||
| const placed = shortened | ||
| ? { ...plan, pixels: lane.axisEnd - lane.axisStart, requestedPixels: travelPixels } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: For odd travel distances, a shortened swipe can report Prompt for AI agents |
||
| : plan; | ||
| // `up`/`left` drag from the low end toward the high end; `down`/`right` drag the other way. | ||
| return direction === 'up' || direction === 'left' | ||
| ? placed | ||
| : { ...placed, x1: placed.x2, y1: placed.y2, x2: placed.x1, y2: placed.y1 }; | ||
| } | ||
|
|
||
| type SwipeLane = { axisStart: number; axisEnd: number; cross: number }; | ||
|
|
||
| /** | ||
| * Moves a centred swipe lane inside the container's intersection with the padded axis, shortening | ||
| * it only when the intersection is shorter than the lane. A container wholly inside the padding | ||
| * leaves nothing to swipe in, so the lane stays as the padded frame planned it. | ||
| */ | ||
| function keepSwipeInContainer( | ||
| lane: SwipeLane, | ||
| container: Rect, | ||
| vertical: boolean, | ||
| padded: { min: number; max: number }, | ||
| ): SwipeLane { | ||
| const containerMin = vertical ? container.y : container.x; | ||
| const containerSize = vertical ? container.height : container.width; | ||
| const bandMin = Math.max(padded.min, Math.round(containerMin)); | ||
| const bandMax = Math.min(padded.max, Math.round(containerMin + containerSize)); | ||
| if (bandMax <= bandMin) return lane; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When the container does not intersect the padded band, this returns the unclamped lane outside the container, so the swipe misses the target scrollable. Either surface that case to the caller or update the JSDoc, which currently promises the endpoints stay inside the intersection. Prompt for AI agents |
||
|
|
||
| const crossMin = vertical ? container.x : container.y; | ||
| const crossSize = vertical ? container.width : container.height; | ||
| const cross = | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The cross-axis fallback can place a container-clamped swipe outside the 10% safe bounds. Intersect the container’s cross-axis range with the corresponding padded frame range before choosing its center (or handle an empty intersection explicitly). Prompt for AI agents |
||
| lane.cross >= crossMin && lane.cross <= crossMin + crossSize | ||
| ? lane.cross | ||
| : Math.round(crossMin + crossSize / 2); | ||
|
|
||
| const length = lane.axisEnd - lane.axisStart; | ||
| if (length >= bandMax - bandMin) return { axisStart: bandMin, axisEnd: bandMax, cross }; | ||
| const shift = | ||
| lane.axisStart < bandMin ? bandMin - lane.axisStart : Math.min(0, bandMax - lane.axisEnd); | ||
| return { axisStart: lane.axisStart + shift, axisEnd: lane.axisEnd + shift, cross }; | ||
| } | ||
|
|
||
| /** | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -164,17 +164,40 @@ export function readScrollSurfaceBaseline( | |||||
| function freezeScrollSurfaceBaseline(session: SessionState): ScrollMovementPlan { | ||||||
| const baseline = readScrollSurfaceBaseline(session.snapshot); | ||||||
| if (!baseline) return { kind: 'unobservable', reason: 'stored-surface-unusable' }; | ||||||
| if (refFrameState(session) !== 'active') { | ||||||
| return { kind: 'unobservable', reason: 'stored-surface-not-current' }; | ||||||
| } | ||||||
| if (isPostGestureStabilizationPending(session)) { | ||||||
| // A gesture nobody has read yet may still be moving this tree, which would bill its motion to | ||||||
| // the scroll being dispatched now. | ||||||
| return { kind: 'unobservable', reason: 'prior-gesture-unsettled' }; | ||||||
| } | ||||||
| const staleness = storedSurfaceStaleness(session); | ||||||
| if (staleness) return { kind: 'unobservable', reason: staleness }; | ||||||
| return { kind: 'observe', baseline }; | ||||||
| } | ||||||
|
|
||||||
| function storedSurfaceStaleness( | ||||||
| session: SessionState, | ||||||
| ): Exclude<ScrollSurfaceBaselineAbsence, 'stored-surface-unusable'> | undefined { | ||||||
| if (refFrameState(session) !== 'active') return 'stored-surface-not-current'; | ||||||
| // A gesture nobody has read yet may still be moving this tree, which would bill its motion to | ||||||
| // the scroll being dispatched now. | ||||||
| if (isPostGestureStabilizationPending(session)) return 'prior-gesture-unsettled'; | ||||||
| return undefined; | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * The scroll container a directional swipe should stay inside (#2714), read before the gesture off | ||||||
| * the same current tree a movement claim would compare against, through the edge verdict's own | ||||||
| * selection. A tree that may no longer be the screen names no container, and neither does a | ||||||
| * horizontal scroll, which the vertical-only edge analyzer cannot place, nor a platform that moves | ||||||
| * content without a synthesized swipe. | ||||||
| */ | ||||||
| export async function readScrollTargetContainer( | ||||||
| device: DeviceInfo, | ||||||
| session: SessionState, | ||||||
| direction: ScrollDirection, | ||||||
| ): Promise<Rect | undefined> { | ||||||
| if (!isMobilePlatform(device)) return undefined; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Prompt for AI agents
Suggested change
|
||||||
| const edge = verticalEdgeFor(direction); | ||||||
| const nodes = session.snapshot?.nodes; | ||||||
| if (!edge || !nodes || nodes.length === 0 || storedSurfaceStaleness(session)) return undefined; | ||||||
| return (await readScrollEdgeState(nodes, edge)).containerRect; | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Observes one directional scroll and returns the movement its response may claim. | ||||||
| * | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3: No test covers a left or right swipe that is actually shortened or moved by a container. The
['up', 'down', 'left', 'right']case only checks an already-fitting container, so the horizontal axis mapping is unverified. Add a horizontal case with a narrow container, such as{ x: 100, y: 0, width: 200, height: 874 }, asserting the x endpoints andpixels/requestedPixels.Prompt for AI agents