|
1 | 1 | import { dispatchCommand, type CommandFlags } from '../../core/dispatch.ts'; |
2 | 2 | import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; |
3 | | -import { attachRefs, centerOfRect, findNodeByRef, normalizeRef, type RawSnapshotNode } from '../../utils/snapshot.ts'; |
| 3 | +import { |
| 4 | + attachRefs, |
| 5 | + centerOfRect, |
| 6 | + findNodeByRef, |
| 7 | + normalizeRef, |
| 8 | + type RawSnapshotNode, |
| 9 | + type Rect, |
| 10 | +} from '../../utils/snapshot.ts'; |
4 | 11 | import type { DaemonCommandContext } from '../context.ts'; |
5 | 12 | import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; |
6 | 13 | import { SessionStore } from '../session-store.ts'; |
@@ -548,6 +555,94 @@ export async function handleInteractionCommands(params: { |
548 | 555 | return { ok: true, data: { predicate, pass: true, selector: resolved.selector.raw } }; |
549 | 556 | } |
550 | 557 |
|
| 558 | + if (command === 'scrollintoview') { |
| 559 | + const session = sessionStore.get(sessionName); |
| 560 | + if (!session) { |
| 561 | + return { |
| 562 | + ok: false, |
| 563 | + error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' }, |
| 564 | + }; |
| 565 | + } |
| 566 | + const targetInput = req.positionals?.[0] ?? ''; |
| 567 | + if (!targetInput.startsWith('@')) { |
| 568 | + return null; |
| 569 | + } |
| 570 | + const invalidRefFlagsResponse = refSnapshotFlagGuardResponse('scrollintoview', req.flags); |
| 571 | + if (invalidRefFlagsResponse) return invalidRefFlagsResponse; |
| 572 | + if (!session.snapshot) { |
| 573 | + return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } }; |
| 574 | + } |
| 575 | + const ref = normalizeRef(targetInput); |
| 576 | + if (!ref) { |
| 577 | + return { |
| 578 | + ok: false, |
| 579 | + error: { code: 'INVALID_ARGS', message: 'scrollintoview requires a ref like @e2' }, |
| 580 | + }; |
| 581 | + } |
| 582 | + let node = findNodeByRef(session.snapshot.nodes, ref); |
| 583 | + if (!node?.rect && req.positionals && req.positionals.length > 1) { |
| 584 | + const fallbackLabel = req.positionals.slice(1).join(' ').trim(); |
| 585 | + if (fallbackLabel.length > 0) { |
| 586 | + node = findNodeByLabel(session.snapshot.nodes, fallbackLabel); |
| 587 | + } |
| 588 | + } |
| 589 | + if (!node?.rect) { |
| 590 | + return { |
| 591 | + ok: false, |
| 592 | + error: { code: 'COMMAND_FAILED', message: `Ref ${targetInput} not found or has no bounds` }, |
| 593 | + }; |
| 594 | + } |
| 595 | + const viewportRect = resolveViewportRect(session.snapshot.nodes, node.rect); |
| 596 | + const plan = buildScrollIntoViewPlan(node.rect, viewportRect); |
| 597 | + const refLabel = resolveRefLabel(node, session.snapshot.nodes); |
| 598 | + const selectorChain = buildSelectorChainForNode(node, session.device.platform, { action: 'get' }); |
| 599 | + if (!plan) { |
| 600 | + sessionStore.recordAction(session, { |
| 601 | + command, |
| 602 | + positionals: req.positionals ?? [], |
| 603 | + flags: req.flags ?? {}, |
| 604 | + result: { ref, attempts: 0, alreadyVisible: true, strategy: 'ref-geometry', refLabel, selectorChain }, |
| 605 | + }); |
| 606 | + return { ok: true, data: { ref, attempts: 0, alreadyVisible: true, strategy: 'ref-geometry' } }; |
| 607 | + } |
| 608 | + const data = await dispatch( |
| 609 | + session.device, |
| 610 | + 'swipe', |
| 611 | + [String(plan.x), String(plan.startY), String(plan.x), String(plan.endY), '60'], |
| 612 | + req.flags?.out, |
| 613 | + { |
| 614 | + ...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), |
| 615 | + count: plan.count, |
| 616 | + pauseMs: 0, |
| 617 | + pattern: 'one-way', |
| 618 | + }, |
| 619 | + ); |
| 620 | + sessionStore.recordAction(session, { |
| 621 | + command, |
| 622 | + positionals: req.positionals ?? [], |
| 623 | + flags: req.flags ?? {}, |
| 624 | + result: { |
| 625 | + ...(data ?? {}), |
| 626 | + ref, |
| 627 | + attempts: plan.count, |
| 628 | + direction: plan.direction, |
| 629 | + strategy: 'ref-geometry', |
| 630 | + refLabel, |
| 631 | + selectorChain, |
| 632 | + }, |
| 633 | + }); |
| 634 | + return { |
| 635 | + ok: true, |
| 636 | + data: { |
| 637 | + ...(data ?? {}), |
| 638 | + ref, |
| 639 | + attempts: plan.count, |
| 640 | + direction: plan.direction, |
| 641 | + strategy: 'ref-geometry', |
| 642 | + }, |
| 643 | + }; |
| 644 | + } |
| 645 | + |
551 | 646 | return null; |
552 | 647 | } |
553 | 648 |
|
@@ -593,7 +688,7 @@ const REF_UNSUPPORTED_FLAG_MAP: ReadonlyArray<[keyof CommandFlags, string]> = [ |
593 | 688 | ]; |
594 | 689 |
|
595 | 690 | function refSnapshotFlagGuardResponse( |
596 | | - command: 'press' | 'fill' | 'get', |
| 691 | + command: 'press' | 'fill' | 'get' | 'scrollintoview', |
597 | 692 | flags: CommandFlags | undefined, |
598 | 693 | ): DaemonResponse | null { |
599 | 694 | const unsupported = unsupportedRefSnapshotFlags(flags); |
@@ -623,3 +718,100 @@ export function unsupportedRefSnapshotFlags(flags: CommandFlags | undefined): st |
623 | 718 | } |
624 | 719 | return unsupported; |
625 | 720 | } |
| 721 | + |
| 722 | +function resolveViewportRect(nodes: RawSnapshotNode[], targetRect: Rect): Rect { |
| 723 | + const targetCenter = centerOfRect(targetRect); |
| 724 | + const rectNodes = nodes.filter((node) => hasValidRect(node.rect)); |
| 725 | + const viewportNodes = rectNodes.filter((node) => { |
| 726 | + const type = (node.type ?? '').toLowerCase(); |
| 727 | + return type.includes('application') || type.includes('window'); |
| 728 | + }); |
| 729 | + |
| 730 | + const containingViewport = pickLargestRect( |
| 731 | + viewportNodes |
| 732 | + .map((node) => node.rect as Rect) |
| 733 | + .filter((rect) => containsPoint(rect, targetCenter.x, targetCenter.y)), |
| 734 | + ); |
| 735 | + if (containingViewport) return containingViewport; |
| 736 | + |
| 737 | + const viewportFallback = pickLargestRect(viewportNodes.map((node) => node.rect as Rect)); |
| 738 | + if (viewportFallback) return viewportFallback; |
| 739 | + |
| 740 | + const genericContaining = pickLargestRect( |
| 741 | + rectNodes |
| 742 | + .map((node) => node.rect as Rect) |
| 743 | + .filter((rect) => containsPoint(rect, targetCenter.x, targetCenter.y)), |
| 744 | + ); |
| 745 | + if (genericContaining) return genericContaining; |
| 746 | + |
| 747 | + return targetRect; |
| 748 | +} |
| 749 | + |
| 750 | +function buildScrollIntoViewPlan( |
| 751 | + targetRect: Rect, |
| 752 | + viewportRect: Rect, |
| 753 | +): { x: number; startY: number; endY: number; count: number; direction: 'up' | 'down' } | null { |
| 754 | + const viewportHeight = Math.max(1, viewportRect.height); |
| 755 | + const viewportTop = viewportRect.y; |
| 756 | + const viewportBottom = viewportRect.y + viewportHeight; |
| 757 | + const safeTop = viewportTop + viewportHeight * 0.25; |
| 758 | + const safeBottom = viewportBottom - viewportHeight * 0.25; |
| 759 | + const targetCenterY = targetRect.y + targetRect.height / 2; |
| 760 | + |
| 761 | + if (targetCenterY >= safeTop && targetCenterY <= safeBottom) { |
| 762 | + return null; |
| 763 | + } |
| 764 | + |
| 765 | + const x = Math.round(viewportRect.x + viewportRect.width / 2); |
| 766 | + const dragUpStartY = Math.round(viewportTop + viewportHeight * 0.78); |
| 767 | + const dragUpEndY = Math.round(viewportTop + viewportHeight * 0.22); |
| 768 | + const dragDownStartY = dragUpEndY; |
| 769 | + const dragDownEndY = dragUpStartY; |
| 770 | + const swipeStepPx = Math.max(1, Math.abs(dragUpStartY - dragUpEndY) * 0.9); |
| 771 | + |
| 772 | + if (targetCenterY > safeBottom) { |
| 773 | + const delta = targetCenterY - safeBottom; |
| 774 | + return { |
| 775 | + x, |
| 776 | + startY: dragUpStartY, |
| 777 | + endY: dragUpEndY, |
| 778 | + count: clampInt(Math.ceil(delta / swipeStepPx), 1, 50), |
| 779 | + direction: 'down', |
| 780 | + }; |
| 781 | + } |
| 782 | + |
| 783 | + const delta = safeTop - targetCenterY; |
| 784 | + return { |
| 785 | + x, |
| 786 | + startY: dragDownStartY, |
| 787 | + endY: dragDownEndY, |
| 788 | + count: clampInt(Math.ceil(delta / swipeStepPx), 1, 50), |
| 789 | + direction: 'up', |
| 790 | + }; |
| 791 | +} |
| 792 | + |
| 793 | +function hasValidRect(rect: Rect | undefined): rect is Rect { |
| 794 | + if (!rect) return false; |
| 795 | + return Number.isFinite(rect.x) && Number.isFinite(rect.y) && Number.isFinite(rect.width) && Number.isFinite(rect.height); |
| 796 | +} |
| 797 | + |
| 798 | +function containsPoint(rect: Rect, x: number, y: number): boolean { |
| 799 | + return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height; |
| 800 | +} |
| 801 | + |
| 802 | +function pickLargestRect(rects: Rect[]): Rect | null { |
| 803 | + let best: Rect | null = null; |
| 804 | + let bestArea = -1; |
| 805 | + for (const rect of rects) { |
| 806 | + const area = rect.width * rect.height; |
| 807 | + if (area > bestArea) { |
| 808 | + best = rect; |
| 809 | + bestArea = area; |
| 810 | + } |
| 811 | + } |
| 812 | + return best; |
| 813 | +} |
| 814 | + |
| 815 | +function clampInt(value: number, min: number, max: number): number { |
| 816 | + return Math.min(max, Math.max(min, Math.round(value))); |
| 817 | +} |
0 commit comments