Bug 1758689: Create and maintain a viewport cache for hittesting r=Jamie,emilio

This hittesting approach is borrowed from our Android implementation.
We maintain a cache of ID's for accessibles which are visible in the viewport.
This cache is created with a call to `nsLayoutUtils::GetFramesForArea`, which
returns frames in the viewport, in hittesting order.

In the parent process, we walk through the cache, keeping track of accs whose
bounds contain the hittesting point. Depending on if we're searching for the
direct child, or the deepest child, we walk the entire cache or return the
first match.

Each document (in the content process) maintains a dirty bit, which gets set
any time an acc it contains bundles either a text update, or an update that
affects bounds. We check whether this bit is set in `DidRefresh` after getting
a notification from our post-refresh observer. If that bit is set, we
queue a cache update for the `::Viewport` domain on the current document.

Because this cache depends on the viewport being painted, we include the
`IgnorePaintSuppression` flag in our `GetFramesForArea` call. This ensures
the display lists are built before the page finishes loading.

Differential Revision: https://phabricator.services.mozilla.com/D147225
This commit is contained in:
Morgan Rae Reschenberg
2022-06-13 22:28:36 +00:00
parent 232e79554b
commit b6ecba1f9d
13 changed files with 379 additions and 6 deletions

View File

@@ -26,6 +26,7 @@ class CacheDomain {
static constexpr uint64_t ScrollPosition = ((uint64_t)0x1) << 11;
static constexpr uint64_t Table = ((uint64_t)0x1) << 12;
static constexpr uint64_t Spelling = ((uint64_t)0x1) << 13;
static constexpr uint64_t Viewport = ((uint64_t)0x1) << 14;
static constexpr uint64_t All = ~((uint64_t)0x0);
};

View File

@@ -83,7 +83,8 @@ NS_IMPL_CYCLE_COLLECTION_UNROOT_NATIVE(NotificationController, Release)
void NotificationController::Shutdown() {
if (mObservingState != eNotObservingRefresh &&
mPresShell->RemoveRefreshObserver(this, FlushType::Display)) {
mPresShell->RemoveRefreshObserver(this, FlushType::Display) &&
mPresShell->RemovePostRefreshObserver(this)) {
mObservingState = eNotObservingRefresh;
}
@@ -453,7 +454,8 @@ void NotificationController::ScheduleProcessing() {
// asynchronously (after style and layout).
if (mObservingState == eNotObservingRefresh) {
if (mPresShell->AddRefreshObserver(this, FlushType::Display,
"Accessibility notifications")) {
"Accessibility notifications") &&
mPresShell->AddPostRefreshObserver(this)) {
mObservingState = eRefreshObserving;
}
}
@@ -981,11 +983,30 @@ void NotificationController::WillRefresh(mozilla::TimeStamp aTime) {
!mFocusEvent && mEvents.IsEmpty() && mTextHash.Count() == 0 &&
mHangingChildDocuments.IsEmpty() &&
mDocument->HasLoadState(DocAccessible::eCompletelyLoaded) &&
mPresShell->RemoveRefreshObserver(this, FlushType::Display)) {
mPresShell->RemoveRefreshObserver(this, FlushType::Display) &&
mPresShell->RemovePostRefreshObserver(this)) {
mObservingState = eNotObservingRefresh;
}
}
void NotificationController::DidRefresh() {
if (IPCAccessibilityActive() && mDocument->IsViewportCacheDirty()) {
// It is now safe to send the viewport cache, because
// we know painting has finished.
RefPtr<AccAttributes> fields = mDocument->BundleFieldsForCache(
CacheDomain::Viewport, CacheUpdateType::Update);
if (fields->Count()) {
nsTArray<CacheData> data(1);
data.AppendElement(CacheData(0, fields));
MOZ_ASSERT(mDocument->IPCDoc());
mDocument->IPCDoc()->SendCache(CacheUpdateType::Update, data, true);
}
mDocument->SetViewportCacheDirty(false);
}
}
void NotificationController::EventMap::PutEvent(AccTreeMutationEvent* aEvent) {
EventType type = GetEventType(aEvent);
uint64_t addr = reinterpret_cast<uintptr_t>(aEvent->GetAccessible());

View File

@@ -91,7 +91,8 @@ class TNotification : public Notification {
* Used to process notifications from core for the document accessible.
*/
class NotificationController final : public EventQueue,
public nsARefreshObserver {
public nsARefreshObserver,
public nsAPostRefreshObserver {
public:
NotificationController(DocAccessible* aDocument, PresShell* aPresShell);
@@ -299,6 +300,7 @@ class NotificationController final : public EventQueue,
// nsARefreshObserver
virtual void WillRefresh(mozilla::TimeStamp aTime) override;
virtual void DidRefresh() override;
/**
* Set and returns a hide event, paired with a show event, for the move.

View File

@@ -90,6 +90,7 @@ DocAccessible::DocAccessible(dom::Document* aDocument,
mDocumentNode(aDocument),
mLoadState(eTreeConstructionPending),
mDocFlags(0),
mViewportCacheDirty(false),
mLoadEventType(0),
mPrevStateBits(0),
mVirtualCursor(nullptr),

View File

@@ -153,6 +153,9 @@ class DocAccessible : public HyperTextAccessibleWrap,
bool IsHidden() const;
bool IsViewportCacheDirty() { return mViewportCacheDirty; }
void SetViewportCacheDirty(bool aDirty) { mViewportCacheDirty = aDirty; }
/**
* Document load states.
*/
@@ -662,7 +665,15 @@ class DocAccessible : public HyperTextAccessibleWrap,
/**
* Bit mask of other states and props.
*/
uint32_t mDocFlags : 28;
uint32_t mDocFlags : 27;
/**
* Tracks whether we have seen changes to this document's content that
* indicate we should re-send the viewport cache we use for hittesting.
* This value is set in `BundleFieldsForCache` and processed in
* `ProcessQueuedCacheUpdates`.
*/
bool mViewportCacheDirty : 1;
/**
* Type of document load event fired after the document is loaded completely.

View File

@@ -3209,6 +3209,57 @@ already_AddRefed<AccAttributes> LocalAccessible::BundleFieldsForCache(
}
}
if (aCacheDomain & CacheDomain::Viewport && IsDoc()) {
// Construct the viewport cache for this document. This cache domain will
// only be requested after we finish painting.
DocAccessible* doc = AsDoc();
PresShell* presShell = doc->PresShellPtr();
if (nsIFrame* rootFrame = presShell->GetRootFrame()) {
nsTArray<nsIFrame*> frames;
nsIScrollableFrame* sf = presShell->GetRootScrollFrameAsScrollable();
nsRect scrollPort = sf ? sf->GetScrollPortRect() : rootFrame->GetRect();
nsLayoutUtils::GetFramesForArea(
RelativeTo{rootFrame}, scrollPort, frames,
{{// We only care about visible content for hittesting.
nsLayoutUtils::FrameForPointOption::OnlyVisible,
// This flag ensures the display lists are built, even if
// the page hasn't finished loading.
nsLayoutUtils::FrameForPointOption::IgnorePaintSuppression,
// Each doc should have its own viewport cache, so we can
// ignore cross-doc content as an optimization.
nsLayoutUtils::FrameForPointOption::IgnoreCrossDoc}});
nsTHashSet<LocalAccessible*> inViewAccs;
nsTArray<uint64_t> viewportCache;
for (nsIFrame* frame : frames) {
nsIContent* content = frame->GetContent();
if (!content) {
continue;
}
LocalAccessible* acc = doc->GetAccessibleOrContainer(content);
if (!acc) {
continue;
}
if (acc->IsTextLeaf() && nsAccUtils::MustPrune(acc->LocalParent())) {
acc = acc->LocalParent();
}
if (inViewAccs.EnsureInserted(acc)) {
viewportCache.AppendElement(
acc->IsDoc() ? 0 : reinterpret_cast<uint64_t>(acc->UniqueID()));
}
}
if (viewportCache.Length()) {
fields->SetAttribute(nsGkAtoms::viewport, std::move(viewportCache));
}
}
}
bool boundsChanged = false;
if (aCacheDomain & CacheDomain::Bounds) {
nsRect newBoundsRect = ParentRelativeBounds();
@@ -3521,6 +3572,12 @@ already_AddRefed<AccAttributes> LocalAccessible::BundleFieldsForCache(
}
}
if ((aCacheDomain & (CacheDomain::Text | CacheDomain::ScrollPosition) ||
boundsChanged) &&
mDoc) {
mDoc->SetViewportCacheDirty(true);
}
return fields.forget();
}

View File

@@ -313,6 +313,81 @@ double RemoteAccessibleBase<Derived>::Step() const {
return UnspecifiedNaN<double>();
}
template <class Derived>
Accessible* RemoteAccessibleBase<Derived>::ChildAtPoint(
int32_t aX, int32_t aY, LocalAccessible::EWhichChildAtPoint aWhichChild) {
RemoteAccessible* lastMatch = nullptr;
// If `this` is a document, use its viewport cache instead of
// the cache of its parent document.
if (DocAccessibleParent* doc = IsDoc() ? AsDoc() : mDoc) {
if (auto maybeViewportCache =
doc->mCachedFields->GetAttribute<nsTArray<uint64_t>>(
nsGkAtoms::viewport)) {
// The retrieved viewport cache contains acc IDs in hittesting order.
// That is, items earlier in the list have z-indexes that are larger than
// those later in the list. If you were to build a tree by z-index, where
// chilren have larger z indices than their parents, iterating this list
// is essentially a postorder tree traversal.
const nsTArray<uint64_t>& viewportCache = *maybeViewportCache;
for (auto id : viewportCache) {
RemoteAccessible* acc = doc->GetAccessible(id);
if (!acc) {
// This can happen if the acc died in between
// pushing the viewport cache and doing this hittest
continue;
}
if (acc == this) {
// Even though we're searching from the doc's cache
// this call shouldn't pass the boundary defined by
// the acc this call originated on. If we hit `this`,
// return our most recent match.
break;
}
if (acc == doc) {
// If we're already in `doc`s viewport cache, and the doc is
// not the acc this call originated on, skip it.
// We have to have `doc` in this list, because we need to support
// calling `doc->ChildAtPoint()`. Without this check, we end up
// calling `doc->ChildAtPoint(...)` below which changes the context of
// this call.
continue;
}
if (acc->Bounds().Contains(aX, aY)) {
if (acc->IsDoc()) {
// If we encounter a doc, search its viewport
// cache. Do this even if we're looking for the
// deepest child, since in that case we should return
// the deepest child in the subdoc.
return acc->ChildAtPoint(aX, aY, aWhichChild);
}
if (aWhichChild == EWhichChildAtPoint::DeepestChild) {
// Because our rects are in hittesting order, the
// first match we encounter is guaranteed to be the
// deepest match.
lastMatch = acc;
break;
}
// We're looking for a DirectChild match. Update our
// `lastMatch` marker as we ascend towards `this`.
lastMatch = acc;
}
}
}
}
if (!lastMatch && Bounds().Contains(aX, aY)) {
return this;
}
return lastMatch;
}
template <class Derived>
Maybe<nsRect> RemoteAccessibleBase<Derived>::RetrieveCachedBounds() const {
MOZ_ASSERT(mCachedFields);

View File

@@ -175,6 +175,10 @@ class RemoteAccessibleBase : public Accessible, public HyperTextAccessibleBase {
virtual double MaxValue() const override;
virtual double Step() const override;
virtual Accessible* ChildAtPoint(
int32_t aX, int32_t aY,
LocalAccessible::EWhichChildAtPoint aWhichChild) override;
virtual LayoutDeviceIntRect Bounds() const override;
nsRect GetBoundsInAppUnits() const;

View File

@@ -198,7 +198,7 @@ double Step() const override;
bool SetCurValue(double aValue);
RemoteAccessible* FocusedChild();
virtual Accessible* ChildAtPoint(
Accessible* ChildAtPoint(
int32_t aX, int32_t aY,
LocalAccessible::EWhichChildAtPoint aWhichChild) override;
LayoutDeviceIntRect Bounds() const override;

View File

@@ -932,6 +932,11 @@ RemoteAccessible* RemoteAccessible::FocusedChild() {
Accessible* RemoteAccessible::ChildAtPoint(
int32_t aX, int32_t aY, LocalAccessible::EWhichChildAtPoint aWhichChild) {
if (StaticPrefs::accessibility_cache_enabled_AtStartup()) {
return RemoteAccessibleBase<RemoteAccessible>::ChildAtPoint(aX, aY,
aWhichChild);
}
RemoteAccessible* target = this;
do {
if (target->IsOuterDoc()) {

View File

@@ -820,6 +820,11 @@ void RemoteAccessible::TakeFocus() const {
Accessible* RemoteAccessible::ChildAtPoint(
int32_t aX, int32_t aY, Accessible::EWhichChildAtPoint aWhichChild) {
if (StaticPrefs::accessibility_cache_enabled_AtStartup()) {
return RemoteAccessibleBase<RemoteAccessible>::ChildAtPoint(aX, aY,
aWhichChild);
}
RefPtr<IAccessible2_2> target = QueryInterface<IAccessible2_2>(this);
if (!target) {
return nullptr;

View File

@@ -72,3 +72,4 @@ skip-if = true # Failing due to incorrect index of test container children on do
[browser_obj_group.js]
skip-if = e10s && os == 'win' # Only supported with cache enabled
[browser_caching_position.js]
[browser_caching_hittest.js]

View File

@@ -0,0 +1,190 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const { Layout } = ChromeUtils.import(
"chrome://mochitests/content/browser/accessible/tests/browser/Layout.jsm"
);
const { CommonUtils } = ChromeUtils.import(
"chrome://mochitests/content/browser/accessible/tests/browser/Common.jsm"
);
function getChildAtPoint(container, x, y, findDeepestChild) {
try {
const child = findDeepestChild
? container.getDeepestChildAtPoint(x, y)
: container.getChildAtPoint(x, y);
info(`Got child with role: ${roleToString(child.role)}`);
return child;
} catch (e) {
// Failed to get child at point.
}
return null;
}
async function testChildAtPoint(dpr, x, y, container, child, grandChild) {
const [containerX, containerY] = Layout.getBounds(container, dpr);
x += containerX;
y += containerY;
await untilCacheIs(
() => getChildAtPoint(container, x, y, false),
child,
`Wrong direct child accessible at the point (${x}, ${y}) of ${CommonUtils.prettyName(
container
)}, sought ${child ? roleToString(child.role) : "unknown"}`
);
await untilCacheIs(
() => getChildAtPoint(container, x, y, true),
grandChild,
`Wrong deepest child accessible at the point (${x}, ${y}) of ${CommonUtils.prettyName(
container
)}, sought ${grandChild ? roleToString(grandChild.role) : "unknown"}`
);
}
async function hitTest(browser, container, child, grandChild) {
const [childX, childY] = await getContentBoundsForDOMElm(
browser,
getAccessibleDOMNodeID(child)
);
const x = childX + 1;
const y = childY + 1;
await untilCacheIs(
() => getChildAtPoint(container, x, y, false),
child,
`Wrong direct child accessible at the point (${x}, ${y}) of ${CommonUtils.prettyName(
container
)}, sought ${child ? roleToString(child.role) : "unknown"}`
);
await untilCacheIs(
() => getChildAtPoint(container, x, y, true),
grandChild,
`Wrong deepest child accessible at the point (${x}, ${y}) of ${CommonUtils.prettyName(
container
)}, sought ${grandChild ? roleToString(grandChild.role) : "unknown"}`
);
}
async function runTests(browser, accDoc) {
await waitForImageMap(browser, accDoc);
const dpr = await getContentDPR(browser);
await testChildAtPoint(
dpr,
3,
3,
findAccessibleChildByID(accDoc, "list"),
findAccessibleChildByID(accDoc, "listitem"),
findAccessibleChildByID(accDoc, "inner").firstChild
);
todo(
false,
"Bug 746974 - children must match on all platforms. On Windows, " +
"ChildAtPoint with eDeepestChild is incorrectly ignoring MustPrune " +
"for the graphic."
);
const txt = findAccessibleChildByID(accDoc, "txt");
await testChildAtPoint(dpr, 1, 1, txt, txt, txt);
info(
"::MustPrune case, point is outside of textbox accessible but is in document."
);
await testChildAtPoint(dpr, -1, -1, txt, null, null);
info("::MustPrune case, point is outside of root accessible.");
await testChildAtPoint(dpr, -10000, -10000, txt, null, null);
info("Not specific case, point is inside of btn accessible.");
const btn = findAccessibleChildByID(accDoc, "btn");
await testChildAtPoint(dpr, 1, 1, btn, btn, btn);
info("Not specific case, point is outside of btn accessible.");
await testChildAtPoint(dpr, -1, -1, btn, null, null);
info(
"Out of flow accessible testing, do not return out of flow accessible " +
"because it's not a child of the accessible even though visually it is."
);
await invokeContentTask(browser, [], () => {
// We have to reimprot CommonUtils in this scope -- eslint thinks this is
// wrong, but if you remove it, things will break.
/* eslint-disable no-shadow */
const { CommonUtils } = ChromeUtils.import(
"chrome://mochitests/content/browser/accessible/tests/browser/Common.jsm"
);
/* eslint-enable no-shadow */
const doc = content.document;
const rectArea = CommonUtils.getNode("area", doc).getBoundingClientRect();
const outOfFlow = CommonUtils.getNode("outofflow", doc);
outOfFlow.style.left = rectArea.left + "px";
outOfFlow.style.top = rectArea.top + "px";
});
const area = findAccessibleChildByID(accDoc, "area");
await testChildAtPoint(dpr, 1, 1, area, area, area);
todo(
false,
"Test image maps. Their children are not in the layout tree. See bug 1772609"
);
// const imgmap = findAccessibleChildByID(accDoc, "imgmap");
// const theLetterA = imgmap.firstChild;
// await hitTest(browser, imgmap, theLetterA, theLetterA);
// await hitTest(
// browser,
// findAccessibleChildByID(accDoc, "container"),
// imgmap,
// theLetterA
// );
info("hit testing for element contained by zero-width element");
const container2Input = findAccessibleChildByID(accDoc, "container2_input");
await hitTest(
browser,
findAccessibleChildByID(accDoc, "container2"),
container2Input,
container2Input
);
}
addAccessibleTask(
`
<div role="list" id="list">
<div role="listitem" id="listitem"><span title="foo" id="inner">inner</span>item</div>
</div>
<span role="button">button1</span><span role="button" id="btn">button2</span>
<span role="textbox">textbox1</span><span role="textbox" id="txt">textbox2</span>
<div id="outofflow" style="width: 10px; height: 10px; position: absolute; left: 0px; top: 0px; background-color: yellow;">
</div>
<div id="area" style="width: 100px; height: 100px; background-color: blue;"></div>
<map name="atoz_map">
<area id="thelettera" href="http://www.bbc.co.uk/radio4/atoz/index.shtml#a"
coords="0,0,15,15" alt="thelettera" shape="rect"/>
</map>
<div id="container">
<img id="imgmap" width="447" height="15" usemap="#atoz_map" src="http://example.com/a11y/accessible/tests/mochitest/letters.gif"/>
</div>
<div id="container2" style="width: 0px">
<input id="container2_input">
</div>
`,
runTests,
{
iframe: true,
remoteIframe: false, // Causes bounds failures for now, see bug 1772861
// Ensure that all hittest elements are in view.
iframeAttrs: { style: "width: 600px; height: 600px;" },
}
);