diff options
author | Jan Arve Sæther <jan-arve.saether@qt.io> | 2021-04-27 16:23:26 +0200 |
---|---|---|
committer | Jan Arve Sæther <jan-arve.saether@qt.io> | 2021-06-04 10:17:28 +0200 |
commit | 3f4088256c7712cbc757dd4d8835a3d4b272b4ee (patch) | |
tree | 612e4f6d152ea0e517e50e76dd68bedb57d36e43 | |
parent | cb20d7bcf8313fa3d39e07b37a7c9252fd28cde0 (diff) |
Add support for hyperlinks in Text items
Task-number: QTBUG-67878
Change-Id: I1e990a5db8f0cf78e5cdcec7359e5aabffe42e3d
Reviewed-by: Volker Hilsheimer <volker.hilsheimer@qt.io>
-rw-r--r-- | src/quick/accessible/qaccessiblequickitem.cpp | 280 | ||||
-rw-r--r-- | src/quick/accessible/qaccessiblequickitem_p.h | 4 | ||||
-rw-r--r-- | src/quick/items/qquickaccessibleattached.cpp | 11 | ||||
-rw-r--r-- | src/quick/items/qquickaccessibleattached_p.h | 2 | ||||
-rw-r--r-- | src/quick/items/qquicktext.cpp | 32 | ||||
-rw-r--r-- | src/quick/items/qquicktext_p_p.h | 12 | ||||
-rw-r--r-- | tests/auto/quick/qquickaccessible/data/text.qml | 10 | ||||
-rw-r--r-- | tests/auto/quick/qquickaccessible/tst_qquickaccessible.cpp | 27 |
8 files changed, 369 insertions, 9 deletions
diff --git a/src/quick/accessible/qaccessiblequickitem.cpp b/src/quick/accessible/qaccessiblequickitem.cpp index 4bb756a018..5e3a1108d6 100644 --- a/src/quick/accessible/qaccessiblequickitem.cpp +++ b/src/quick/accessible/qaccessiblequickitem.cpp @@ -43,6 +43,8 @@ #include "QtQuick/private/qquickitem_p.h" #include "QtQuick/private/qquicktext_p.h" +#include <private/qquicktext_p_p.h> + #include "QtQuick/private/qquicktextinput_p.h" #include "QtQuick/private/qquickaccessibleattached_p.h" #include "QtQuick/qquicktextdocument.h" @@ -51,6 +53,194 @@ QT_BEGIN_NAMESPACE #if QT_CONFIG(accessibility) +class QAccessibleHyperlink : public QAccessibleInterface, public QAccessibleHyperlinkInterface { +public: + QAccessibleHyperlink(QQuickItem *parentTextItem, int linkIndex); + + // check for valid pointers + bool isValid() const override; + QObject *object() const override; + QWindow *window() const override; + + // navigation, hierarchy + QAccessibleInterface *parent() const override; + QAccessibleInterface *child(int index) const override; + int childCount() const override; + int indexOfChild(const QAccessibleInterface *iface) const override; + QAccessibleInterface *childAt(int x, int y) const override; + + // properties and state + QString text(QAccessible::Text) const override; + void setText(QAccessible::Text, const QString &text) override; + QRect rect() const override; + QAccessible::Role role() const override; + QAccessible::State state() const override; + + void *interface_cast(QAccessible::InterfaceType t) override; + + // QAccessibleHyperlinkInterface + QString anchor() const override + { + const QVector<QQuickTextPrivate::LinkDesc> links = QQuickTextPrivate::get(textItem())->getLinks(); + if (linkIndex < links.count()) + return links.at(linkIndex).m_anchor; + return QString(); + } + + QString anchorTarget() const override + { + const QVector<QQuickTextPrivate::LinkDesc> links = QQuickTextPrivate::get(textItem())->getLinks(); + if (linkIndex < links.count()) + return links.at(linkIndex).m_anchorTarget; + return QString(); + } + + int startIndex() const override + { + const QVector<QQuickTextPrivate::LinkDesc> links = QQuickTextPrivate::get(textItem())->getLinks(); + if (linkIndex < links.count()) + return links.at(linkIndex).m_startIndex; + return -1; + } + + int endIndex() const override + { + const QVector<QQuickTextPrivate::LinkDesc> links = QQuickTextPrivate::get(textItem())->getLinks(); + if (linkIndex < links.count()) + return links.at(linkIndex).m_endIndex; + return -1; + } + +private: + QQuickText *textItem() const { return qobject_cast<QQuickText*>(parentTextItem); } + QQuickItem *parentTextItem; + const int linkIndex; + + friend class QAccessibleQuickItem; +}; + + +QAccessibleHyperlink::QAccessibleHyperlink(QQuickItem *parentTextItem, int linkIndex) + : parentTextItem(parentTextItem), + linkIndex(linkIndex) +{ +} + + +bool QAccessibleHyperlink::isValid() const +{ + return textItem(); +} + + +QObject *QAccessibleHyperlink::object() const +{ + return nullptr; +} + + +QWindow *QAccessibleHyperlink::window() const +{ + return textItem()->window(); +} + + +/*! \reimp */ +QRect QAccessibleHyperlink::rect() const +{ + const QVector<QQuickTextPrivate::LinkDesc> links = QQuickTextPrivate::get(textItem())->getLinks(); + if (linkIndex < links.count()) { + const QPoint tl = itemScreenRect(textItem()).topLeft(); + return links.at(linkIndex).rect.translated(tl); + } + return QRect(); +} + +/*! \reimp */ +QAccessibleInterface *QAccessibleHyperlink::childAt(int, int) const +{ + return nullptr; +} + +/*! \reimp */ +QAccessibleInterface *QAccessibleHyperlink::parent() const +{ + return QAccessible::queryAccessibleInterface(textItem()); +} + +/*! \reimp */ +QAccessibleInterface *QAccessibleHyperlink::child(int) const +{ + return nullptr; +} + +/*! \reimp */ +int QAccessibleHyperlink::childCount() const +{ + return 0; +} + +/*! \reimp */ +int QAccessibleHyperlink::indexOfChild(const QAccessibleInterface *) const +{ + return -1; +} + +/*! \reimp */ +QAccessible::State QAccessibleHyperlink::state() const +{ + QAccessible::State s; + s.selectable = true; + s.focusable = true; + s.selectableText = true; + s.selected = false; + return s; +} + +/*! \reimp */ +QAccessible::Role QAccessibleHyperlink::role() const +{ + return QAccessible::Link; +} + +/*! \reimp */ +QString QAccessibleHyperlink::text(QAccessible::Text t) const +{ + // AT servers have different behaviors: + // Wordpad on windows have this behavior: + // * Name returns the anchor target (URL) + // * Value returns the anchor target (URL) + + // Other AT servers (e.g. MS Edge on Windows) does what seems to be more sensible: + // * Name returns the anchor name + // * Value returns the anchor target (URL) + if (t == QAccessible::Name) + return anchor(); + if (t == QAccessible::Value) + return anchorTarget(); + return QString(); +} + +/*! \reimp */ +void QAccessibleHyperlink::setText(QAccessible::Text, const QString &) +{ + +} + +/*! \reimp */ +void *QAccessibleHyperlink::interface_cast(QAccessible::InterfaceType t) +{ + if (t == QAccessible::HyperlinkInterface) + return static_cast<QAccessibleHyperlinkInterface*>(this); + return nullptr; +} + + +/*! + * \internal + * \brief QAccessibleQuickItem::QAccessibleQuickItem + * \param item + */ QAccessibleQuickItem::QAccessibleQuickItem(QQuickItem *item) : QAccessibleObject(item), m_doc(textDocument()) { @@ -75,7 +265,13 @@ QWindow *QAccessibleQuickItem::window() const int QAccessibleQuickItem::childCount() const { - return childItems().count(); + // see comment in QAccessibleQuickItem::child() as to why we do this + int cc = 0; + if (QQuickText *textItem = qobject_cast<QQuickText*>(item())) { + cc = QQuickTextPrivate::get(textItem)->getLinks().count(); + } + cc += childItems().count(); + return cc; } QRect QAccessibleQuickItem::rect() const @@ -109,6 +305,18 @@ QAccessibleInterface *QAccessibleQuickItem::childAt(int x, int y) const return nullptr; } + // special case for text interfaces + if (QQuickText *textItem = qobject_cast<QQuickText*>(item())) { + const auto hyperLinkChildCount = QQuickTextPrivate::get(textItem)->getLinks().count(); + for (auto i = 0; i < hyperLinkChildCount; i++) { + QAccessibleInterface *iface = child(i); + if (iface->rect().contains(x,y)) { + return iface; + } + } + } + + // general item hit test const QList<QQuickItem*> kids = accessibleUnignoredChildren(item(), true); for (int i = kids.count() - 1; i >= 0; --i) { QAccessibleInterface *childIface = QAccessible::queryAccessibleInterface(kids.at(i)); @@ -146,19 +354,73 @@ QAccessibleInterface *QAccessibleQuickItem::parent() const QAccessibleInterface *QAccessibleQuickItem::child(int index) const { - QList<QQuickItem *> children = childItems(); + /* Text with hyperlinks will have dedicated children interfaces representing each hyperlink. + + For the pathological case when a Text node has hyperlinks in its text *and* accessible + quick items as children, we put the hyperlink a11y interfaces as the first children, then + the other interfaces follows the hyperlink children (as siblings). - if (index < 0 || index >= children.count()) + For example, suppose you have two links in the text and an image as a child of the text, + it will have the following a11y hierarchy: + + [a11y:TextInterface] + | + +- [a11y:HyperlinkInterface] + +- [a11y:HyperlinkInterface] + +- [a11y:ImageInterface] + + Having this order (as opposed to having hyperlink interfaces last) will at least + ensure that the child id of hyperlink children is not altered when child is added/removed + to the text item and marked accessible. + In addition, hyperlink interfaces as children should be the common case, so it is preferred + to explore those first when iterating. + */ + if (index < 0) return nullptr; - QQuickItem *child = children.at(index); - return QAccessible::queryAccessibleInterface(child); + + if (QQuickText *textItem = qobject_cast<QQuickText*>(item())) { + const int hyperLinkChildCount = QQuickTextPrivate::get(textItem)->getLinks().count(); + if (index < hyperLinkChildCount) { + auto it = m_childToId.constFind(index); + if (it != m_childToId.constEnd()) + return QAccessible::accessibleInterface(it.value()); + + QAccessibleHyperlink *iface = new QAccessibleHyperlink(item(), index); + QAccessible::Id id = QAccessible::registerAccessibleInterface(iface); + m_childToId.insert(index, id); + return iface; + } + index -= hyperLinkChildCount; + } + + QList<QQuickItem *> children = childItems(); + if (index < children.count()) { + QQuickItem *child = children.at(index); + return QAccessible::queryAccessibleInterface(child); + } + return nullptr; } int QAccessibleQuickItem::indexOfChild(const QAccessibleInterface *iface) const { + int hyperLinkChildCount = 0; + if (QQuickText *textItem = qobject_cast<QQuickText*>(item())) { + hyperLinkChildCount = QQuickTextPrivate::get(textItem)->getLinks().count(); + if (QAccessibleHyperlinkInterface *hyperLinkIface = const_cast<QAccessibleInterface *>(iface)->hyperlinkInterface()) { + // ### assumes that there is only one subclass implementing QAccessibleHyperlinkInterface + // Alternatively, we could simply iterate with child() and do a linear search for it + QAccessibleHyperlink *hyperLink = static_cast<QAccessibleHyperlink*>(hyperLinkIface); + if (hyperLink->textItem() == static_cast<QQuickText*>(item())) { + return hyperLink->linkIndex; + } + } + } QList<QQuickItem*> kids = childItems(); - return kids.indexOf(static_cast<QQuickItem*>(iface->object())); + int idx = kids.indexOf(static_cast<QQuickItem*>(iface->object())); + if (idx >= 0) + idx += hyperLinkChildCount; + return idx; } static void unignoredChildren(QQuickItem *item, QList<QQuickItem *> *items, bool paintOrder) @@ -426,9 +688,11 @@ void *QAccessibleQuickItem::interface_cast(QAccessible::InterfaceType t) r == QAccessible::ScrollBar)) return static_cast<QAccessibleValueInterface*>(this); - if (t == QAccessible::TextInterface && - (r == QAccessible::EditableText)) + if (t == QAccessible::TextInterface) { + if (r == QAccessible::EditableText || + r == QAccessible::StaticText) return static_cast<QAccessibleTextInterface*>(this); + } return QAccessibleObject::interface_cast(t); } diff --git a/src/quick/accessible/qaccessiblequickitem_p.h b/src/quick/accessible/qaccessiblequickitem_p.h index ccc9280ba0..8ed8c0d260 100644 --- a/src/quick/accessible/qaccessiblequickitem_p.h +++ b/src/quick/accessible/qaccessiblequickitem_p.h @@ -137,7 +137,11 @@ protected: void *interface_cast(QAccessible::InterfaceType t) override; private: + // for Text nodes: QTextDocument *m_doc; + typedef QHash<int, QAccessible::Id> ChildCache; + mutable ChildCache m_childToId; + }; QRect itemScreenRect(QQuickItem *item); diff --git a/src/quick/items/qquickaccessibleattached.cpp b/src/quick/items/qquickaccessibleattached.cpp index 67d17c98e5..0b593c0089 100644 --- a/src/quick/items/qquickaccessibleattached.cpp +++ b/src/quick/items/qquickaccessibleattached.cpp @@ -499,6 +499,17 @@ void QQuickAccessibleAttached::availableActions(QStringList *actions) const actions->append(QAccessibleActionInterface::nextPageAction()); } +QString QQuickAccessibleAttached::stripHtml(const QString &html) +{ +#ifndef QT_NO_TEXTHTMLPARSER + QTextDocument doc; + doc.setHtml(html); + return doc.toPlainText(); +#else + return html; +#endif +} + QT_END_NAMESPACE #include "moc_qquickaccessibleattached_p.cpp" diff --git a/src/quick/items/qquickaccessibleattached_p.h b/src/quick/items/qquickaccessibleattached_p.h index 69c097344a..6d7d46bfd4 100644 --- a/src/quick/items/qquickaccessibleattached_p.h +++ b/src/quick/items/qquickaccessibleattached_p.h @@ -189,6 +189,8 @@ public: bool doAction(const QString &actionName); void availableActions(QStringList *actions) const; + Q_INVOKABLE static QString stripHtml(const QString &html); + public Q_SLOTS: void valueChanged() { QAccessibleValueChangeEvent ev(parent(), parent()->property("value")); diff --git a/src/quick/items/qquicktext.cpp b/src/quick/items/qquicktext.cpp index a18ab824e2..379fef7722 100644 --- a/src/quick/items/qquicktext.cpp +++ b/src/quick/items/qquicktext.cpp @@ -2873,6 +2873,38 @@ bool QQuickTextPrivate::isLinkHoveredConnected() IS_SIGNAL_CONNECTED(q, QQuickText, linkHovered, (const QString &)); } +static void getLinks_helper(const QTextLayout *layout, QVector<QQuickTextPrivate::LinkDesc> *links) +{ + for (const QTextLayout::FormatRange &formatRange : layout->formats()) { + if (formatRange.format.isAnchor()) { + const int start = formatRange.start; + const int len = formatRange.length; + QTextLine line = layout->lineForTextPosition(start); + QRectF r; + r.setTop(line.y()); + r.setLeft(line.cursorToX(start, QTextLine::Leading)); + r.setHeight(line.height()); + r.setRight(line.cursorToX(start + len, QTextLine::Trailing)); + // ### anchorNames() is empty?! Not sure why this doesn't work + // QString anchorName = formatRange.format.anchorNames().value(0); //### pick the first? + // Therefore, we resort to QString::mid() + QString anchorName = layout->text().mid(start, len); + const QString anchorHref = formatRange.format.anchorHref(); + if (anchorName.isEmpty()) + anchorName = anchorHref; + links->append( { anchorName, anchorHref, start, start + len, r.toRect()} ); + } + } +} + +QVector<QQuickTextPrivate::LinkDesc> QQuickTextPrivate::getLinks() const +{ + QVector<QQuickTextPrivate::LinkDesc> links; + getLinks_helper(&layout, &links); + return links; +} + + /*! \qmlsignal QtQuick::Text::linkHovered(string link) \since 5.2 diff --git a/src/quick/items/qquicktext_p_p.h b/src/quick/items/qquicktext_p_p.h index da06c7e632..7d8744671a 100644 --- a/src/quick/items/qquicktext_p_p.h +++ b/src/quick/items/qquicktext_p_p.h @@ -199,6 +199,18 @@ public: void setupCustomLineGeometry(QTextLine &line, qreal &height, int fullLayoutTextLength, int lineOffset = 0); bool isLinkActivatedConnected(); bool isLinkHoveredConnected(); + QStringList links() const; + + struct LinkDesc { + QString m_anchor; + QString m_anchorTarget; + int m_startIndex; + int m_endIndex; + QRect rect; + }; + + QVector<LinkDesc> getLinks() const; + static QString anchorAt(const QTextLayout *layout, const QPointF &mousePos); QString anchorAt(const QPointF &pos) const; diff --git a/tests/auto/quick/qquickaccessible/data/text.qml b/tests/auto/quick/qquickaccessible/data/text.qml index 6daeacfd81..4de461f2e1 100644 --- a/tests/auto/quick/qquickaccessible/data/text.qml +++ b/tests/auto/quick/qquickaccessible/data/text.qml @@ -57,4 +57,14 @@ Item { Accessible.description: "description" } + Text { + x: 100 + y: 200 + width: 100 + height: 40 + text : "<p>Rich text with links:</p><a href=\"https://qt.io\">Website</a> or <a href=\"https://qt.io/blog\">blog</a>" + Accessible.name: Accessible.stripHtml(text) + Accessible.description: "Rich text with two hyperlinks" + } + } diff --git a/tests/auto/quick/qquickaccessible/tst_qquickaccessible.cpp b/tests/auto/quick/qquickaccessible/tst_qquickaccessible.cpp index 4270ff958f..edaaed967a 100644 --- a/tests/auto/quick/qquickaccessible/tst_qquickaccessible.cpp +++ b/tests/auto/quick/qquickaccessible/tst_qquickaccessible.cpp @@ -363,7 +363,7 @@ void tst_QQuickAccessible::basicPropertiesTest() QAccessibleInterface *item = iface->child(0); QVERIFY(item); - QCOMPARE(item->childCount(), 5); + QCOMPARE(item->childCount(), 6); QCOMPARE(item->rect().size(), QSize(400, 400)); QCOMPARE(item->role(), QAccessible::Client); QCOMPARE(iface->indexOfChild(item), 0); @@ -448,6 +448,31 @@ void tst_QQuickAccessible::basicPropertiesTest() QCOMPARE(text3->role(), QAccessible::StaticText); QVERIFY(text3->state().readOnly); + // Text "Rich text" + QAccessibleInterface *richText = item->child(5); + QVERIFY(text3); + QCOMPARE(richText->childCount(), 2); + QCOMPARE(richText->text(QAccessible::Name), QLatin1String("Rich text with links:\nWebsite or blog")); + QCOMPARE(richText->role(), QAccessible::StaticText); + QCOMPARE(item->indexOfChild(richText), 5); + QVERIFY(!richText->state().editable); + QVERIFY(!richText->state().readOnly); + + // Check for hyperlink child nodes + for (int i = 0; i < richText->childCount(); ++i) { + static const char *linkUrls[2][2] = { + {"Website", "https://qt.io"}, + {"blog", "https://qt.io/blog"} + }; + QAccessibleInterface *link1 = richText->child(i); + QVERIFY(link1); + QCOMPARE(link1->role(), QAccessible::Link); + QAccessibleHyperlinkInterface *link = link1->hyperlinkInterface(); + QVERIFY(link); + QCOMPARE(link->anchor(), QLatin1String(linkUrls[i][0])); + QCOMPARE(link->anchorTarget(), QLatin1String(linkUrls[i][1])); + } + // see if implicit changes back attached->setRole(QAccessible::EditableText); QEXPECT_FAIL("", "EditableText does not implicitly set readOnly to false", Continue); |