testsAndMisc/python_pkg/android_ui/_scroll.py
2026-11-02 07:56:57 +01:00

73 lines
2.7 KiB
Python

"""``scroll_to``: bring a widget that is outside the viewport on screen."""
from __future__ import annotations
from abc import ABC, abstractmethod
import time
from python_pkg.android_ui._elements import ElementNotFoundError, UiElement
_SWIPE_MS = "350"
# Swipe through the middle 40% of the screen: far enough to move a page, short
# enough that Flutter does not turn it into a fling that overshoots the target.
_FROM_FRAC = 0.7
_TO_FRAC = 0.3
def _snapshot(elements: list[UiElement]) -> list[tuple[str, tuple[int, ...]]]:
"""Reduce a tree to what changes when a list scrolls."""
return [(e.label, e.bounds) for e in elements]
class ScrollMixin(ABC):
"""The scrolling part of :class:`~python_pkg.android_ui.driver.AndroidUi`."""
_settle: float
@abstractmethod
def _input(self, *args: str) -> str:
"""Supplied by the concrete driver: ``input`` on the driven display."""
@abstractmethod
def dump(self, *, retries: int = 4) -> list[UiElement]:
"""Supplied by the concrete driver: read the current UI tree."""
@abstractmethod
def find(self, query: str, *, exact: bool = False) -> UiElement:
"""Supplied by the concrete driver: the single match, or raise."""
def scroll_to(
self, query: str, *, exact: bool = False, max_swipes: int = 8
) -> UiElement:
"""Swipe up until ``query`` matches exactly one element, then return it.
A widget outside the viewport is not in the accessibility tree, so
``find`` reports it missing. Stops early, naming the query, when a swipe
leaves the screen unchanged (the end of the list) rather than spinning
for ``max_swipes`` swipes. Ambiguity raises, as it does for ``find``.
"""
elements = self.dump()
for _ in range(max_swipes):
if any(e.matches(query, exact=exact) for e in elements):
return self.find(query, exact=exact)
width = max((e.bounds[2] for e in elements), default=0)
height = max((e.bounds[3] for e in elements), default=0)
x = str(width // 2)
self._input(
"swipe",
x,
str(int(height * _FROM_FRAC)),
x,
str(int(height * _TO_FRAC)),
_SWIPE_MS,
)
time.sleep(self._settle)
after = self.dump()
if _snapshot(after) == _snapshot(elements):
break
elements = after
if any(e.matches(query, exact=exact) for e in elements):
return self.find(query, exact=exact)
msg = f"scrolled looking for {query!r} but it never appeared"
raise ElementNotFoundError(msg)