diff --git a/ui/abstract_button.h b/ui/abstract_button.h index 2eea6dfc..66816ac1 100644 --- a/ui/abstract_button.h +++ b/ui/abstract_button.h @@ -69,9 +69,17 @@ class AbstractButton : public RpWidget { [[nodiscard]] bool isListItem() const { return _listItem; } + void setIsPageTab(bool value) { + _pageTab = value; + } + [[nodiscard]] bool isPageTab() const { + return _pageTab; + } QAccessible::Role accessibilityRole() override { - return _listItem + return _pageTab + ? QAccessible::PageTab + : _listItem ? QAccessible::ListItem : _menuButton ? QAccessible::ButtonMenu @@ -132,6 +140,7 @@ class AbstractButton : public RpWidget { bool _triggerOnPress : 1 = false; bool _menuButton : 1 = false; bool _listItem : 1 = false; + bool _pageTab : 1 = false; Fn _clickedCallback; diff --git a/ui/accessible/ui_accessible_widget.cpp b/ui/accessible/ui_accessible_widget.cpp index aa538f74..f102bcf1 100644 --- a/ui/accessible/ui_accessible_widget.cpp +++ b/ui/accessible/ui_accessible_widget.cpp @@ -99,6 +99,10 @@ void *Widget::interface_cast(QAccessible::InterfaceType type) { && rp()->accessibilityOrientation().has_value()) { return static_cast(this); } + if (type == QAccessible::ValueInterface + && rp()->accessibilityValueRange().has_value()) { + return static_cast(this); + } return QAccessibleWidget::interface_cast(type); } @@ -211,6 +215,24 @@ QAccessibleInterface* Widget::focusChild() const { ++ReentrancyDepth; struct Guard { ~Guard() { --ReentrancyDepth; } } guard; + // A container with painted children keeps its own browse position on + // them, so a child reporting focus wins over the selected item below: + // the container holds real keyboard focus the whole time, and forwarding + // to the selection would announce the current item when the screen + // reader asked for a different, merely browsed one. + const auto count = rp()->accessibilityChildCount(); + if (count >= 0 && widget()->hasFocus()) { + // Iterate through children to find focused one (Qt standard approach). + for (int i = 0; i < count; ++i) { + if (const auto iface = rp()->accessibilityChildInterface(i)) { + const auto s = iface->state(); + if (s.focused || s.active) { + return iface; + } + } + } + } + // A selection list forwards accessible focus to its current (selected) item, // so focusing the container lands the screen reader on a navigable item // rather than the inert container. Only while the container itself holds @@ -226,26 +248,11 @@ QAccessibleInterface* Widget::focusChild() const { // Only handle focus child for widgets with custom accessibility children. // For other widgets (containers, scroll areas), delegate to Qt immediately. - const auto count = rp()->accessibilityChildCount(); if (count < 0) { // No custom children - let Qt handle it normally. return QAccessibleWidget::focusChild(); } - if (!widget()->hasFocus()) { - return nullptr; - } - - // Iterate through children to find focused one (Qt standard approach). - for (int i = 0; i < count; ++i) { - if (const auto iface = rp()->accessibilityChildInterface(i)) { - const auto s = iface->state(); - if (s.focused || s.active) { - return iface; - } - } - } - // Has custom children but none focused - return null. return nullptr; } @@ -289,10 +296,22 @@ void Widget::doAction(const QString &actionName) { }); } -// Selection. A selection item is a child with the ListItem role reporting -// selected = active; the selected one resolves independently of focus. Plain -// buttons among the children are excluded, and a locked folder reports -// selectable = false, so it is never claimed as a successful selection. +// Selection. A selection item is a child with the ListItem (or PageTab, for +// a strip of tabs, or RadioButton, for a group of exclusive options) role +// reporting selected = active; the selected one resolves independently of +// focus. Plain buttons among the children are excluded, and a locked folder +// reports selectable = false, so it is never claimed as a successful +// selection. + +namespace { + +[[nodiscard]] bool IsSelectionItemRole(QAccessible::Role role) { + return (role == QAccessible::ListItem) + || (role == QAccessible::PageTab) + || (role == QAccessible::RadioButton); +} + +} // namespace int Widget::selectedItemCount() const { return int(selectedItems().size()); @@ -304,7 +323,7 @@ QList Widget::selectedItems() const { for (auto i = 0; i != count; ++i) { const auto item = child(i); if (item - && item->role() == QAccessible::ListItem + && IsSelectionItemRole(item->role()) && item->state().selected) { result.append(item); } @@ -320,7 +339,7 @@ QAccessibleInterface *Widget::selectedItem(int selectionIndex) const { bool Widget::isSelected(QAccessibleInterface *childItem) const { return childItem && indexOfChild(childItem) >= 0 - && childItem->role() == QAccessible::ListItem + && IsSelectionItemRole(childItem->role()) && childItem->state().selected; } @@ -331,7 +350,7 @@ bool Widget::select(QAccessibleInterface *childItem) { // item only implements pressAction, so invoke that rather than toggleAction. if (!childItem || indexOfChild(childItem) < 0 - || childItem->role() != QAccessible::ListItem + || !IsSelectionItemRole(childItem->role()) || childItem->state().disabled || !childItem->state().selectable) { return false; @@ -378,4 +397,32 @@ QVariant Widget::attributeValue(QAccessible::Attribute key) const { return QVariant(); } +// Value. Reports the numeric range of a slider-like widget. The setter may be +// invoked by the platform on a background thread, so the widget must hop to +// the main thread itself before touching any state. + +QVariant Widget::currentValue() const { + const auto range = rp()->accessibilityValueRange(); + return range ? QVariant(range->current) : QVariant(); +} + +void Widget::setCurrentValue(const QVariant &value) { + rp()->accessibilitySetValue(value.toDouble()); +} + +QVariant Widget::maximumValue() const { + const auto range = rp()->accessibilityValueRange(); + return range ? QVariant(range->maximum) : QVariant(); +} + +QVariant Widget::minimumValue() const { + const auto range = rp()->accessibilityValueRange(); + return range ? QVariant(range->minimum) : QVariant(); +} + +QVariant Widget::minimumStepSize() const { + const auto range = rp()->accessibilityValueRange(); + return range ? QVariant(range->step) : QVariant(); +} + } // namespace Ui::Accessible diff --git a/ui/accessible/ui_accessible_widget.h b/ui/accessible/ui_accessible_widget.h index a7de7b43..cba499c2 100644 --- a/ui/accessible/ui_accessible_widget.h +++ b/ui/accessible/ui_accessible_widget.h @@ -17,7 +17,8 @@ namespace Ui::Accessible { class Widget : public QAccessibleWidget , public QAccessibleSelectionInterface - , public QAccessibleAttributesInterface { + , public QAccessibleAttributesInterface + , public QAccessibleValueInterface { public: explicit Widget(not_null widget); @@ -66,6 +67,15 @@ class Widget QList attributeKeys() const override; QVariant attributeValue(QAccessible::Attribute key) const override; + // Value. Exposed (via interface_cast) only when the widget reports an + // accessibilityValueRange(), so UI Automation maps it to the RangeValue + // pattern and a screen reader reads and changes the numeric value. + QVariant currentValue() const override; + void setCurrentValue(const QVariant &value) override; + QVariant maximumValue() const override; + QVariant minimumValue() const override; + QVariant minimumStepSize() const override; + }; } // namespace Ui::Accessible diff --git a/ui/rp_widget.cpp b/ui/rp_widget.cpp index 8d05fbab..d3f4c6ef 100644 --- a/ui/rp_widget.cpp +++ b/ui/rp_widget.cpp @@ -457,6 +457,12 @@ void RpWidget::accessibilityChildStateChanged( QAccessible::updateAccessibility(&event); } +void RpWidget::accessibilityChildSelectionChanged(int index) { + auto event = QAccessibleEvent(this, QAccessible::SelectionAdd); + event.setChild(index); + QAccessible::updateAccessibility(&event); +} + void RpWidget::accessibilityChildFocused(int index) { QAccessibleEvent event(this, QAccessible::Focus); event.setChild(index); @@ -515,10 +521,19 @@ QString RpWidget::accessibilityValue() const { } void RpWidget::accessibilityValueChanged() { + // A string value routes to the textual Value pattern change, which is + // the one a screen reader announces and reads the value back from. QAccessibleValueChangeEvent event(this, accessibilityValue()); QAccessible::updateAccessibility(&event); } +std::optional RpWidget::accessibilityValueRange() const { + return std::nullopt; +} + +void RpWidget::accessibilitySetValue(double value) { +} + QStringList RpWidget::accessibilityActionNames() { return QStringList(); } diff --git a/ui/rp_widget.h b/ui/rp_widget.h index 1d10351e..d0fdc4b0 100644 --- a/ui/rp_widget.h +++ b/ui/rp_widget.h @@ -393,6 +393,14 @@ struct AccessibilityState { void writeTo(QAccessible::State &state); }; +// Numeric value of a control that picks a point on a range (e.g. a slider). +struct AccessibilityValueRange { + double current = 0.; + double minimum = 0.; + double maximum = 0.; + double step = 1.; +}; + class RpWidget : public RpWidgetBase { // The Q_OBJECT meta info is used for qobject_cast above! Q_OBJECT @@ -426,6 +434,14 @@ class RpWidget : public RpWidgetBase { void accessibilityStateChanged(AccessibilityState changes); [[nodiscard]] virtual QString accessibilityValue() const; void accessibilityValueChanged(); + + // Numeric range value of a slider-like control. nullopt (the default) + // means the widget exposes no range; a widget returning one gets + // QAccessibleValueInterface, which the platform maps to a range value + // pattern so a screen reader can read and change the number. + [[nodiscard]] virtual std::optional accessibilityValueRange() const; + // The platform may deliver the new value on a background thread. + virtual void accessibilitySetValue(double value); [[nodiscard]] virtual QStringList accessibilityActionNames(); virtual void accessibilityDoAction(const QString &name); [[nodiscard]] virtual int accessibilityChildCount() const; @@ -457,6 +473,16 @@ class RpWidget : public RpWidgetBase { void accessibilityChildValueChanged(int index); [[nodiscard]] virtual QAccessible::State accessibilityChildState(int index) const; void accessibilityChildStateChanged(int index, AccessibilityState changes); + + // Announces that a child was selected or deselected. A state change does + // not cover it: the Windows bridge looks at the checked flag there and + // ignores the selected one. Both directions raise the same event, the one + // that bridge forwards (SelectionRemove reaches no one) - a screen reader + // reads the new value off the child and announces the difference, so it + // only needs telling that the selection changed. SideBarButton does the + // same for the folders that are real widgets. + void accessibilityChildSelectionChanged(int index); + [[nodiscard]] virtual QAccessible::Role accessibilityChildRole() const; [[nodiscard]] virtual QRect accessibilityChildRect(int index) const; [[nodiscard]] virtual int accessibilityChildColumnCount(int row) const; diff --git a/ui/widgets/popup_menu.cpp b/ui/widgets/popup_menu.cpp index 59bbdcae..6d9728aa 100644 --- a/ui/widgets/popup_menu.cpp +++ b/ui/widgets/popup_menu.cpp @@ -22,6 +22,7 @@ #include "ui/ui_utility.h" #include +#include #include #include #include @@ -1503,4 +1504,19 @@ PopupMenu::~PopupMenu() { } } +QPoint ContextMenuPosition( + not_null anchor, + not_null e) { + return ContextMenuPosition(anchor, e, anchor->rect()); +} + +QPoint ContextMenuPosition( + not_null anchor, + not_null e, + QRect rect) { + return (e->reason() == QContextMenuEvent::Keyboard) + ? anchor->mapToGlobal(rect.center()) + : QCursor::pos(); +} + } // namespace Ui diff --git a/ui/widgets/popup_menu.h b/ui/widgets/popup_menu.h index 7aeafbb1..03cd4bee 100644 --- a/ui/widgets/popup_menu.h +++ b/ui/widgets/popup_menu.h @@ -296,4 +296,19 @@ class PopupMenu : public RpWidget { }; +// Where to show a context menu for the given event: at the mouse cursor for +// a mouse-invoked one, on the anchor for a keyboard-invoked one - the mouse +// may sit nowhere near the control then (or outside the window altogether), +// and the position Qt puts into the keyboard event is no help either, it is +// synthesized from the input method rect, which plain controls leave empty. +// The rect is in the anchor's coordinates and defaults to its whole area - +// pass one to anchor on a painted element inside the anchor widget. +[[nodiscard]] QPoint ContextMenuPosition( + not_null anchor, + not_null e); +[[nodiscard]] QPoint ContextMenuPosition( + not_null anchor, + not_null e, + QRect rect); + } // namespace Ui diff --git a/ui/widgets/side_bar_button.cpp b/ui/widgets/side_bar_button.cpp index 31a47540..76339720 100644 --- a/ui/widgets/side_bar_button.cpp +++ b/ui/widgets/side_bar_button.cpp @@ -53,7 +53,7 @@ void SideBarButton::setActive(bool active) { return; } _active = active; - if (isListItem()) { + if (isPageTab()) { // Announce selection via a dedicated selection event (the Windows // bridge maps these to UIA_SelectionItem_ElementSelected); a generic // state-change event is ignored for the selected state. @@ -66,12 +66,12 @@ void SideBarButton::setActive(bool active) { } AccessibilityState SideBarButton::accessibilityState() const { - // Merge the base state so plain buttons keep reporting `pressed`. A list - // item exposes the active one as selected - persistently, independent of + // Merge the base state so plain buttons keep reporting `pressed`. A tab + // exposes the active one as selected - persistently, independent of // keyboard focus. A locked (premium) folder stays a focusable, invokable - // list item but can never become current, so it isn't selectable. + // tab but can never become current, so it isn't selectable. auto state = RippleButton::accessibilityState(); - if (isListItem()) { + if (isPageTab()) { state.selectable = !_lock.locked; state.selected = _active; }