diff --git a/ui/accessible/ui_accessible_factory.cpp b/ui/accessible/ui_accessible_factory.cpp index afca84190..cfa597400 100644 --- a/ui/accessible/ui_accessible_factory.cpp +++ b/ui/accessible/ui_accessible_factory.cpp @@ -7,6 +7,7 @@ #include "ui/accessible/ui_accessible_factory.h" #include "ui/rp_widget.h" +#include "ui/widgets/fields/input_field.h" #include "base/screen_reader_state.h" #include @@ -14,8 +15,11 @@ namespace Ui::Accessible { namespace { [[nodiscard]] QAccessibleInterface *Method(const QString&, QObject *object) { - const auto rpWidget = qobject_cast(object); - return rpWidget ? rpWidget->accessibilityCreate() : nullptr; + if (const auto rpWidget = qobject_cast(object)) { + return rpWidget->accessibilityCreate(); + } + // The inner editor of a field is a QTextEdit, not an RpWidget. + return InputField::CreateInnerAccessible(object); } } // namespace diff --git a/ui/widgets/fields/input_field.cpp b/ui/widgets/fields/input_field.cpp index 38c530eca..c4c3f5b3a 100644 --- a/ui/widgets/fields/input_field.cpp +++ b/ui/widgets/fields/input_field.cpp @@ -30,14 +30,18 @@ #include #include +#include #include +#include #include +#include #include #include #include #include #include #include +#include #include #include @@ -1691,6 +1695,422 @@ class InputField::Inner final : public QTextEdit { }; +namespace { + +// The text an emoji object in the document stands for, or nothing for +// another object (a collapsed quote). +[[nodiscard]] QString EmojiTextAt( + not_null document, + int position) { + const auto block = document->findBlock(position); + for (auto i = block.begin(); !i.atEnd(); ++i) { + const auto fragment = i.fragment(); + if (!fragment.isValid() || !fragment.contains(position)) { + continue; + } + const auto format = fragment.charFormat(); + if (format.isImageFormat()) { + const auto name = format.toImageFormat().name(); + if (const auto emoji = Emoji::FromUrl(name)) { + return emoji->text(); + } + } + return format.property(kCustomEmojiText).toString(); + } + return QString(); +} + +// The emoji in the document are objects, a replacement character each, +// while a screen reader wants the emoji's own text there. The text this +// interface exposes has that substitution made, and its offsets are those +// of the exposed text - an emoji of two code units takes two offsets - +// so the caret, the selection and the word boundaries a platform derives +// from the text all line up. Every offset coming in is mapped back to a +// document position and every position going out is mapped to an offset. +// +// Qt's own interface for a QTextEdit lives in a private header, so the +// text interface is implemented here on the public document API, the +// way Qt's does it. +class InnerAccessible final + : public QAccessibleWidget + , public QAccessibleTextInterface + , public QAccessibleEditableTextInterface { +public: + explicit InnerAccessible(not_null edit) + : QAccessibleWidget(edit, QAccessible::EditableText) { + } + + void *interface_cast(QAccessible::InterfaceType t) override { + if (t == QAccessible::TextInterface) { + return static_cast(this); + } else if (t == QAccessible::EditableTextInterface) { + return static_cast(this); + } + return QAccessibleWidget::interface_cast(t); + } + + QAccessible::State state() const override { + auto result = QAccessibleWidget::state(); + result.selectableText = true; + result.multiLine = true; + if (edit()->isReadOnly()) { + result.readOnly = true; + } else { + result.editable = true; + } + return result; + } + + QString text(QAccessible::Text t) const override { + return (t == QAccessible::Value) + ? withEmoji(edit()->toPlainText(), 0) + : QAccessibleWidget::text(t); + } + void setText(QAccessible::Text t, const QString &text) override { + if (t != QAccessible::Value) { + QAccessibleWidget::setText(t, text); + } else if (!edit()->isReadOnly()) { + edit()->setPlainText(text); + } + } + + // QAccessibleTextInterface. + void selection( + int selectionIndex, + int *startOffset, + int *endOffset) const override { + *startOffset = *endOffset = 0; + const auto cursor = edit()->textCursor(); + if (selectionIndex != 0 || !cursor.hasSelection()) { + return; + } + *startOffset = toOffset(cursor.selectionStart()); + *endOffset = toOffset(cursor.selectionEnd()); + } + int selectionCount() const override { + return edit()->textCursor().hasSelection() ? 1 : 0; + } + void addSelection(int startOffset, int endOffset) override { + setSelection(0, startOffset, endOffset); + } + void removeSelection(int selectionIndex) override { + if (selectionIndex != 0) { + return; + } + auto cursor = edit()->textCursor(); + cursor.clearSelection(); + edit()->setTextCursor(cursor); + } + void setSelection( + int selectionIndex, + int startOffset, + int endOffset) override { + if (selectionIndex != 0) { + return; + } + edit()->setTextCursor(cursorForRange( + toPosition(startOffset), + toPosition(endOffset, true))); + } + int cursorPosition() const override { + return toOffset(edit()->textCursor().position()); + } + void setCursorPosition(int position) override { + auto cursor = edit()->textCursor(); + cursor.setPosition(toPosition(position)); + edit()->setTextCursor(cursor); + } + QString text(int startOffset, int endOffset) const override { + return textBetween( + toPosition(startOffset), + toPosition(endOffset, true)); + } + QString textBeforeOffset( + int offset, + QAccessible::TextBoundaryType boundaryType, + int *startOffset, + int *endOffset) const override { + auto cursor = edit()->textCursor(); + cursor.setPosition(toPosition(offset)); + auto boundaries = QAccessible::qAccessibleTextBoundaryHelper( + cursor, + boundaryType); + cursor.setPosition(boundaries.first - 1); + boundaries = QAccessible::qAccessibleTextBoundaryHelper( + cursor, + boundaryType); + return textAt(boundaries, startOffset, endOffset); + } + QString textAfterOffset( + int offset, + QAccessible::TextBoundaryType boundaryType, + int *startOffset, + int *endOffset) const override { + auto cursor = edit()->textCursor(); + cursor.setPosition(toPosition(offset)); + auto boundaries = QAccessible::qAccessibleTextBoundaryHelper( + cursor, + boundaryType); + cursor.setPosition(boundaries.second); + boundaries = QAccessible::qAccessibleTextBoundaryHelper( + cursor, + boundaryType); + return textAt(boundaries, startOffset, endOffset); + } + QString textAtOffset( + int offset, + QAccessible::TextBoundaryType boundaryType, + int *startOffset, + int *endOffset) const override { + auto cursor = edit()->textCursor(); + cursor.setPosition(toPosition(offset)); + const auto boundaries = QAccessible::qAccessibleTextBoundaryHelper( + cursor, + boundaryType); + return textAt(boundaries, startOffset, endOffset); + } + int characterCount() const override { + return toOffset(documentLength()); + } + QRect characterRect(int offset) const override { + const auto position = toPosition(offset); + const auto block = document()->findBlock(position); + if (!block.isValid()) { + return QRect(); + } + const auto layout = block.layout(); + const auto relative = position - block.position(); + const auto line = layout->lineForTextPosition(relative); + if (!line.isValid()) { + return QRect(); + } + auto format = QTextCharFormat(); + auto i = block.begin(); + if (i.atEnd()) { + format = block.charFormat(); + } else { + while (!i.atEnd() && !i.fragment().contains(position)) { + ++i; + } + if (i.atEnd()) { + --i; + } + format = i.fragment().charFormat(); + } + const auto metrics = QFontMetrics(format.font()); + const auto ch = textBetween(position, position + 1); + if (ch.isEmpty()) { + return QRect(); + } + const auto origin = layout->position(); + const auto x = line.cursorToX(relative); + const auto width = metrics.horizontalAdvance(ch); + const auto height = metrics.height(); + auto result = QRect( + int(origin.x() + x), + int(origin.y() + line.y() + line.ascent() + metrics.descent() + - height), + width, + height); + result.moveTo(viewport()->mapToGlobal(result.topLeft())); + result.translate(-scrollBarPosition()); + return result; + } + int offsetAtPoint(const QPoint &point) const override { + const auto local = viewport()->mapFromGlobal(point) + + scrollBarPosition(); + const auto position = document()->documentLayout()->hitTest( + local, + Qt::ExactHit); + return (position < 0) ? position : toOffset(position); + } + void scrollToSubstring(int startIndex, int endIndex) override { + auto cursor = edit()->textCursor(); + cursor.setPosition(toPosition(startIndex)); + auto rect = edit()->cursorRect(cursor); + cursor.setPosition(toPosition(endIndex, true)); + rect.setBottomRight(edit()->cursorRect(cursor).bottomRight()); + const auto bar = edit()->verticalScrollBar(); + const auto height = viewport()->height(); + if (rect.top() < 0) { + bar->setValue(bar->value() + rect.top()); + } else if (rect.bottom() > height) { + bar->setValue(bar->value() + rect.bottom() - height); + } + } + QString attributes( + int offset, + int *startOffset, + int *endOffset) const override { + // No text attributes are reported: the field has one font, and + // a screen reader does not need them to read the message. + *startOffset = 0; + *endOffset = characterCount(); + return QString(); + } + + // QAccessibleEditableTextInterface. + void deleteText(int startOffset, int endOffset) override { + cursorForRange( + toPosition(startOffset), + toPosition(endOffset, true)).removeSelectedText(); + } + void insertText(int offset, const QString &text) override { + auto cursor = edit()->textCursor(); + cursor.setPosition(toPosition(offset)); + cursor.insertText(text); + } + void replaceText( + int startOffset, + int endOffset, + const QString &text) override { + auto cursor = cursorForRange( + toPosition(startOffset), + toPosition(endOffset, true)); + cursor.removeSelectedText(); + cursor.insertText(text); + } + +private: + // An emoji object in the document and the text exposed for it. + struct EmojiSpan { + int position = 0; + int length = 0; + }; + + [[nodiscard]] QTextEdit *edit() const { + return static_cast(widget()); + } + [[nodiscard]] QTextDocument *document() const { + return edit()->document(); + } + [[nodiscard]] QWidget *viewport() const { + return edit()->viewport(); + } + [[nodiscard]] int documentLength() const { + auto cursor = edit()->textCursor(); + cursor.movePosition(QTextCursor::End); + return cursor.position(); + } + [[nodiscard]] QPoint scrollBarPosition() const { + const auto horizontal = edit()->horizontalScrollBar(); + const auto vertical = edit()->verticalScrollBar(); + return QPoint( + horizontal ? horizontal->sliderPosition() : 0, + vertical ? vertical->sliderPosition() : 0); + } + [[nodiscard]] QTextCursor cursorForRange(int start, int end) const { + auto cursor = edit()->textCursor(); + cursor.setPosition(start, QTextCursor::MoveAnchor); + cursor.setPosition(end, QTextCursor::KeepAnchor); + return cursor; + } + + // The emoji objects of the document, in order. + [[nodiscard]] std::vector emojiSpans() const { + auto result = std::vector(); + const auto document = this->document(); + for (auto block = document->begin() + ; block.isValid() + ; block = block.next()) { + for (auto i = block.begin(); !i.atEnd(); ++i) { + const auto fragment = i.fragment(); + if (!fragment.isValid()) { + continue; + } + const auto text = fragment.text(); + for (auto j = 0, size = int(text.size()); j != size; ++j) { + if (text[j] != kObjectReplacementCh) { + continue; + } + const auto position = fragment.position() + j; + const auto length = int( + EmojiTextAt(document, position).size()); + if (length > 1) { + result.push_back({ position, length }); + } + } + } + } + return result; + } + + // The offset in the exposed text of a document position. + [[nodiscard]] int toOffset(int position) const { + auto result = position; + for (const auto &span : emojiSpans()) { + if (span.position >= position) { + break; + } + result += span.length - 1; + } + return result; + } + + // The document position of an offset in the exposed text. An offset + // inside an emoji lands on its start, or past its end when the offset + // closes a range, so that a range never cuts an emoji in two. + [[nodiscard]] int toPosition(int offset, bool rangeEnd = false) const { + auto shift = 0; + for (const auto &span : emojiSpans()) { + const auto start = span.position + shift; + if (offset <= start) { + break; + } else if (offset < start + span.length) { + return rangeEnd ? (span.position + 1) : span.position; + } + shift += span.length - 1; + } + return offset - shift; + } + + // The exposed text between two document positions. + [[nodiscard]] QString textBetween(int start, int end) const { + const auto raw = cursorForRange(start, end) + .selectedText() + .replace(QChar(QChar::ParagraphSeparator), QLatin1Char('\n')); + return withEmoji(raw, start); + } + [[nodiscard]] QString textAt( + QPair boundaries, + int *startOffset, + int *endOffset) const { + *startOffset = toOffset(boundaries.first); + *endOffset = toOffset(boundaries.second); + return textBetween(boundaries.first, boundaries.second); + } + // The document text from a position, each object replaced by the + // text of its emoji. + [[nodiscard]] QString withEmoji(const QString &text, int position) const { + if (!text.contains(kObjectReplacementCh)) { + return text; + } + const auto document = this->document(); + auto result = QString(); + result.reserve(text.size()); + for (auto i = 0, size = int(text.size()); i != size; ++i) { + const auto ch = text[i]; + if (ch != kObjectReplacementCh) { + result.append(ch); + continue; + } + const auto emoji = EmojiTextAt(document, position + i); + result.append(emoji.isEmpty() ? QString(ch) : emoji); + } + return result; + } + +}; + +} // namespace + +QAccessibleInterface *InputField::CreateInnerAccessible(QObject *object) { + const auto edit = qobject_cast(object); + return (edit && dynamic_cast(edit->parentWidget())) + ? new InnerAccessible(edit) + : nullptr; +} + void InsertEmojiAtCursor(QTextCursor cursor, EmojiPtr emoji) { const auto currentFormat = cursor.charFormat(); const auto height = QFontMetrics(currentFormat.font()).height(); diff --git a/ui/widgets/fields/input_field.h b/ui/widgets/fields/input_field.h index 28cc3cf56..1ccd0321b 100644 --- a/ui/widgets/fields/input_field.h +++ b/ui/widgets/fields/input_field.h @@ -23,6 +23,7 @@ class QMenu; class QShortcut; class QTextEdit; +class QAccessibleInterface; class QContextMenuEvent; class Painter; @@ -199,6 +200,14 @@ class InputField : public RpWidget { return _placeholderFull.current(); } + // The accessible interface of the inner editor of a field, for the + // accessibility factory: the emoji in the document are objects, and + // a screen reader reading the text would get a replacement character + // for each - this one gives it the emoji instead. Null for any other + // object. + [[nodiscard]] static QAccessibleInterface *CreateInnerAccessible( + QObject *object); + [[nodiscard]] const style::InputField &st() const { return _st; }