From 29aa2bbd4c62bb532fc0c09854e22b89fc0e68e1 Mon Sep 17 00:00:00 2001 From: Vadim Zeitlin Date: Thu, 1 Oct 2026 02:36:56 +0200 Subject: [PATCH] Add accessibility support for wxStatusBar in wxGTK and wxOSX Only wxMSW uses the native wxStatusBar which is recognized by screen readers, while wxGTK and wxOSX use the generic version which is just a window and so was not recognized by them as being what it is at all. Add generic (but private for now) SetAccessibleElements() function implemented for both of them and use it from wxStatusBarGeneric to let Orca/VoiceOver know about the status bar fields and, in wxGTK, about its role. Assisted-by: Claude Opus 5.5 Co-authored-by: Quin Gillespie --- include/wx/private/access.h | 68 ++++++ src/generic/statusbr.cpp | 43 ++++ src/gtk/win_gtk.cpp | 406 ++++++++++++++++++++++++++++++++++++ src/osx/cocoa/window.mm | 70 +++++++ 4 files changed, 587 insertions(+) create mode 100644 include/wx/private/access.h diff --git a/include/wx/private/access.h b/include/wx/private/access.h new file mode 100644 index 0000000000..c381c3e134 --- /dev/null +++ b/include/wx/private/access.h @@ -0,0 +1,68 @@ +/////////////////////////////////////////////////////////////////////////////// +// Name: wx/private/access.h +// Purpose: Private accessibility helpers used by the generic controls. +// Author: Vadim Zeitlin +// Created: 2026-09-30 +// Copyright: (c) 2026 Vadim Zeitlin +// Licence: wxWindows licence +/////////////////////////////////////////////////////////////////////////////// + +#ifndef _WX_PRIVATE_ACCESS_H_ +#define _WX_PRIVATE_ACCESS_H_ + +#include "wx/gdicmn.h" +#include "wx/string.h" + +#include + +class WXDLLIMPEXP_FWD_CORE wxWindow; + +namespace wxPrivate +{ + +// Part of a custom drawn window, which is not a window itself, but should +// still be visible to the accessibility clients, e.g. a status bar field. +struct AccessibleElement +{ + AccessibleElement(const wxString& label_, const wxRect& rect_) + : label(label_), rect(rect_) + { + } + + // The text read by the screen readers. + wxString label; + + // Rectangle occupied by the element in the client coordinates of the + // window it belongs to. + wxRect rect; +}; + +using AccessibleElements = std::vector; + +// Let the accessibility clients see the given elements as the children of this +// window, using the "static text" role. +// +// Calling this function again replaces the elements set by the previous call, +// but does nothing if they didn't change, so it's fine to call it whenever the +// elements might have changed. Passing an empty vector removes them. +// +// This function is currently implemented for wxOSX and wxGTK3 only and does +// nothing elsewhere. +#if defined(__WXOSX_COCOA__) || (defined(__WXGTK3__) && !defined(__WXGTK4__)) + +WXDLLIMPEXP_CORE void +SetAccessibleElements(wxWindow* win, const AccessibleElements& elements); + +#else // !wxOSX && !wxGTK3 + +inline void +SetAccessibleElements(wxWindow* WXUNUSED(win), + const AccessibleElements& WXUNUSED(elements)) +{ +} + +#endif // wxOSX || wxGTK3 + +} // namespace wxPrivate + +#endif // _WX_PRIVATE_ACCESS_H_ diff --git a/src/generic/statusbr.cpp b/src/generic/statusbr.cpp index 3e970c4198..9a88102639 100644 --- a/src/generic/statusbr.cpp +++ b/src/generic/statusbr.cpp @@ -23,6 +23,8 @@ #include "wx/control.h" #endif +#include "wx/private/access.h" + #ifdef __WXGTK__ #include "wx/gtk/private.h" #endif @@ -44,6 +46,37 @@ // Margin between the field text and the field rect #define wxFIELD_TEXT_MARGIN 2 +// ---------------------------------------------------------------------------- +// helpers +// ---------------------------------------------------------------------------- + +namespace +{ + +// Let the screen readers read the fields, which are not windows and so are +// invisible to them otherwise. +// +// This must be called whenever either the text or the position of the fields +// changes. +void UpdateAccessibleFields(wxStatusBarGeneric* statbar) +{ + wxPrivate::AccessibleElements elements; + + const int count = statbar->GetFieldsCount(); + for ( int i = 0; i < count; ++i ) + { + wxRect rect; + if ( !statbar->GetFieldRect(i, rect) ) + continue; + + elements.emplace_back(statbar->GetStatusText(i), rect); + } + + wxPrivate::SetAccessibleElements(statbar, elements); +} + +} // anonymous namespace + // ---------------------------------------------------------------------------- // GTK+ signal handler // ---------------------------------------------------------------------------- @@ -123,6 +156,12 @@ bool wxStatusBarGeneric::Create(wxWindow *parent, SetFieldsCount(1); +#if defined(__WXGTK3__) && !defined(__WXGTK4__) + // Let the screen readers know that this is a status bar, e.g. Orca has a + // command for reading it. + atk_object_set_role(gtk_widget_get_accessible(m_widget), ATK_ROLE_STATUSBAR); +#endif + #if defined( __WXGTK__ ) #if GTK_CHECK_VERSION(2,12,0) if (HasFlag(wxSTB_SHOW_TIPS) && wx_is_at_least_gtk2(12)) @@ -160,6 +199,8 @@ void wxStatusBarGeneric::DoUpdateStatusText(int number) Refresh(true, &rect); + UpdateAccessibleFields(this); + // it's common to show some text in the status bar before starting a // relatively lengthy operation, ensure that the text is shown to the // user immediately and not after the lengthy operation end @@ -193,6 +234,8 @@ void wxStatusBarGeneric::DoUpdateFieldWidths() // recompute the cache of the field widths if the status bar width has changed m_widthsAbs = CalculateAbsWidths(width); + + UpdateAccessibleFields(this); } bool wxStatusBarGeneric::ShowsSizeGrip() const diff --git a/src/gtk/win_gtk.cpp b/src/gtk/win_gtk.cpp index 3459377714..94adb68ca3 100644 --- a/src/gtk/win_gtk.cpp +++ b/src/gtk/win_gtk.cpp @@ -8,9 +8,23 @@ #include "wx/wxprec.h" +#ifndef WX_PRECOMP + #include "wx/window.h" +#endif + +#include "wx/private/access.h" + #include "wx/gtk/private.h" #include "wx/gtk/private/win_gtk.h" +// We use GTK accessibility classes, which are only public since GTK 3.8, to +// implement wxPrivate::SetAccessibleElements(). +#if GTK_CHECK_VERSION(3,8,0) && !defined(__WXGTK4__) + #define wxHAS_GTK_ACCESSIBLE + + #include +#endif + /* wxPizza is a custom GTK+ widget derived from GtkFixed. A custom widget is needed to adapt GTK+ to wxWidgets needs in 3 areas: scrolling, window @@ -45,6 +59,394 @@ enum { }; #endif +#ifdef wxHAS_GTK_ACCESSIBLE + +// ---------------------------------------------------------------------------- +// Accessibility support +// ---------------------------------------------------------------------------- + +/* + wxPizza uses its own accessible type, which is just GtkContainerAccessible + unless wxPrivate::SetAccessibleElements() is called, in which case it also + reports the elements passed to it as its children, after the real ones. + + Each of these elements is represented by a wxPizzaAccessibleElement object. +*/ + +namespace +{ + +// Register a new static type with the name based on the given one but unique: +// this is needed in case several copies of wxWidgets are loaded into the same +// process, see also wxPizza::type(). +GType RegisterUniqueType(GType parent, const char* baseName, const GTypeInfo& info) +{ + const char* name = baseName; + char buf[64]; + for (unsigned i = 0; g_type_from_name(name); i++) + { + g_snprintf(buf, sizeof(buf), "%s%u", baseName, i); + name = buf; + } + + return g_type_register_static(parent, name, &info, GTypeFlags(0)); +} + +} // anonymous namespace + +extern "C" { + +struct wxPizzaAccessibleElement +{ + AtkObject parent; + + // The container, which owns us, may be null if it has been destroyed or + // if this element has been removed from it. + AtkObject* container; + + // Rectangle in the client coordinates of the container window. + GdkRectangle rect; +}; + +struct wxPizzaAccessibleElementClass +{ + AtkObjectClass parent; +}; + +struct wxPizzaAccessible +{ + GtkContainerAccessible parent; + + // Array of wxPizzaAccessibleElement objects, all of which we own. + GPtrArray* elements; +}; + +struct wxPizzaAccessibleClass +{ + GtkContainerAccessibleClass parent; +}; + +static AtkObjectClass* element_parent_class; +static AtkObjectClass* accessible_parent_class; + +static GType wxPizzaAccessibleElement_get_type(); +static GType wxPizzaAccessible_get_type(); + +#define WX_PIZZA_ACCESSIBLE_ELEMENT(obj) \ + G_TYPE_CHECK_INSTANCE_CAST(obj, wxPizzaAccessibleElement_get_type(), wxPizzaAccessibleElement) +#define WX_PIZZA_ACCESSIBLE(obj) \ + G_TYPE_CHECK_INSTANCE_CAST(obj, wxPizzaAccessible_get_type(), wxPizzaAccessible) +#define WX_IS_PIZZA_ACCESSIBLE(obj) \ + G_TYPE_CHECK_INSTANCE_TYPE(obj, wxPizzaAccessible_get_type()) + +static AtkObject* element_get_parent(AtkObject* obj) +{ + return WX_PIZZA_ACCESSIBLE_ELEMENT(obj)->container; +} + +static int element_get_index_in_parent(AtkObject* obj) +{ + AtkObject* const container = WX_PIZZA_ACCESSIBLE_ELEMENT(obj)->container; + if ( !container ) + return -1; + + const GPtrArray* const elements = WX_PIZZA_ACCESSIBLE(container)->elements; + for ( guint n = 0; n < elements->len; n++ ) + { + if ( g_ptr_array_index(elements, n) == obj ) + return accessible_parent_class->get_n_children(container) + int(n); + } + + return -1; +} + +static AtkStateSet* element_ref_state_set(AtkObject* obj) +{ + AtkStateSet* const states = element_parent_class->ref_state_set(obj); + + AtkObject* const container = WX_PIZZA_ACCESSIBLE_ELEMENT(obj)->container; + GtkWidget* const widget = container + ? gtk_accessible_get_widget(GTK_ACCESSIBLE(container)) + : nullptr; + if ( !widget ) + { + atk_state_set_add_state(states, ATK_STATE_DEFUNCT); + return states; + } + + atk_state_set_add_state(states, ATK_STATE_ENABLED); + atk_state_set_add_state(states, ATK_STATE_SENSITIVE); + + if ( gtk_widget_get_visible(widget) ) + { + atk_state_set_add_state(states, ATK_STATE_VISIBLE); + + if ( gtk_widget_get_mapped(widget) ) + atk_state_set_add_state(states, ATK_STATE_SHOWING); + } + + return states; +} + +static void element_get_extents(AtkComponent* component, + int* x, int* y, int* width, int* height, + AtkCoordType coord_type) +{ + const wxPizzaAccessibleElement* const + element = WX_PIZZA_ACCESSIBLE_ELEMENT(component); + + *width = element->rect.width; + *height = element->rect.height; + + GtkWidget* const widget = element->container + ? gtk_accessible_get_widget(GTK_ACCESSIBLE(element->container)) + : nullptr; + if ( !widget || !gtk_widget_is_drawable(widget) ) + { + *x = + *y = G_MININT; + return; + } + + *x = element->rect.x; + *y = element->rect.y; + + // The window of wxPizza corresponds to the client area of wxWindow, so + // its origin is the origin of the client coordinates. + int xOrigin = 0, + yOrigin = 0; + switch ( coord_type ) + { + case ATK_XY_SCREEN: + case ATK_XY_WINDOW: + { + GdkWindow* const window = gtk_widget_get_window(widget); + gdk_window_get_origin(window, &xOrigin, &yOrigin); + + if ( coord_type == ATK_XY_WINDOW ) + { + int xTLW, yTLW; + gdk_window_get_origin(gdk_window_get_toplevel(window), + &xTLW, &yTLW); + xOrigin -= xTLW; + yOrigin -= yTLW; + } + } + break; + + default: + // This must be ATK_XY_PARENT, only available since ATK 2.30, and + // our coordinates are already relative to the parent. + break; + } + + *x += xOrigin; + *y += yOrigin; +} + +static void element_component_init(void* g_iface, void*) +{ + AtkComponentIface* const iface = static_cast(g_iface); + iface->get_extents = element_get_extents; +} + +static void element_class_init(void* g_class, void*) +{ + AtkObjectClass* const klass = ATK_OBJECT_CLASS(g_class); + klass->get_parent = element_get_parent; + klass->get_index_in_parent = element_get_index_in_parent; + klass->ref_state_set = element_ref_state_set; + + element_parent_class = ATK_OBJECT_CLASS(g_type_class_peek_parent(g_class)); +} + +static gint accessible_get_n_children(AtkObject* obj) +{ + return accessible_parent_class->get_n_children(obj) + + int(WX_PIZZA_ACCESSIBLE(obj)->elements->len); +} + +static AtkObject* accessible_ref_child(AtkObject* obj, gint i) +{ + const gint numReal = accessible_parent_class->get_n_children(obj); + if ( i < numReal ) + return accessible_parent_class->ref_child(obj, i); + + const GPtrArray* const elements = WX_PIZZA_ACCESSIBLE(obj)->elements; + const guint n = guint(i - numReal); + if ( n >= elements->len ) + return nullptr; + + return ATK_OBJECT(g_object_ref(g_ptr_array_index(elements, n))); +} + +static void accessible_finalize(GObject* obj) +{ + GPtrArray* const elements = WX_PIZZA_ACCESSIBLE(obj)->elements; + for ( guint n = 0; n < elements->len; n++ ) + { + wxPizzaAccessibleElement* const element = + WX_PIZZA_ACCESSIBLE_ELEMENT(g_ptr_array_index(elements, n)); + + // The accessibility clients may still hold references to the element, + // so it can outlive us: ensure it doesn't use a dangling pointer. + element->container = nullptr; + g_object_unref(element); + } + + g_ptr_array_free(elements, TRUE); + + G_OBJECT_CLASS(accessible_parent_class)->finalize(obj); +} + +static void accessible_init(GTypeInstance* instance, void*) +{ + WX_PIZZA_ACCESSIBLE(instance)->elements = g_ptr_array_new(); +} + +static void accessible_class_init(void* g_class, void*) +{ + G_OBJECT_CLASS(g_class)->finalize = accessible_finalize; + + AtkObjectClass* const klass = ATK_OBJECT_CLASS(g_class); + klass->get_n_children = accessible_get_n_children; + klass->ref_child = accessible_ref_child; + + accessible_parent_class = ATK_OBJECT_CLASS(g_type_class_peek_parent(g_class)); +} + +static GType wxPizzaAccessibleElement_get_type() +{ + static GType type; + if (type == 0) + { + const GTypeInfo info = { + sizeof(wxPizzaAccessibleElementClass), + nullptr, nullptr, + element_class_init, + nullptr, nullptr, + sizeof(wxPizzaAccessibleElement), 0, + nullptr, nullptr + }; + type = RegisterUniqueType(ATK_TYPE_OBJECT, "wxPizzaAccessibleElement", info); + + const GInterfaceInfo component_info = { + element_component_init, nullptr, nullptr + }; + g_type_add_interface_static(type, ATK_TYPE_COMPONENT, &component_info); + } + return type; +} + +static GType wxPizzaAccessible_get_type() +{ + static GType type; + if (type == 0) + { + const GTypeInfo info = { + sizeof(wxPizzaAccessibleClass), + nullptr, nullptr, + accessible_class_init, + nullptr, nullptr, + sizeof(wxPizzaAccessible), 0, + accessible_init, + nullptr + }; + type = RegisterUniqueType(GTK_TYPE_CONTAINER_ACCESSIBLE, "wxPizzaAccessible", info); + } + return type; +} + +} // extern "C" + +void +wxPrivate::SetAccessibleElements(wxWindow* win, const AccessibleElements& elements) +{ + GtkWidget* const widget = win->m_wxwindow; + if ( !widget ) + return; + + AtkObject* const obj = gtk_widget_get_accessible(widget); + if ( !WX_IS_PIZZA_ACCESSIBLE(obj) ) + return; + + GPtrArray* const current = WX_PIZZA_ACCESSIBLE(obj)->elements; + const guint numReal = guint(accessible_parent_class->get_n_children(obj)); + const guint numNew = guint(elements.size()); + + // Update the existing elements in place rather than replacing them, as + // this preserves the position of the screen reader in them. + for ( guint n = 0; n < current->len && n < numNew; n++ ) + { + wxPizzaAccessibleElement* const element = + WX_PIZZA_ACCESSIBLE_ELEMENT(g_ptr_array_index(current, n)); + + const wxRect& rect = elements[n].rect; + element->rect.x = rect.x; + element->rect.y = rect.y; + element->rect.width = rect.width; + element->rect.height = rect.height; + + // Don't generate a notification if the text didn't change. + const wxString& label = elements[n].label; + const char* const name = atk_object_get_name(ATK_OBJECT(element)); + if ( !name || wxString::FromUTF8(name) != label ) + atk_object_set_name(ATK_OBJECT(element), label.utf8_str()); + } + + // Remove the extra elements, if any, starting from the end. + while ( current->len > numNew ) + { + const guint n = current->len - 1; + + wxPizzaAccessibleElement* const element = + WX_PIZZA_ACCESSIBLE_ELEMENT(g_ptr_array_index(current, n)); + g_ptr_array_remove_index(current, n); + + element->container = nullptr; + g_object_notify(G_OBJECT(element), "accessible-parent"); + g_signal_emit_by_name(obj, "children-changed::remove", + numReal + n, element, nullptr); + + g_object_unref(element); + } + + // And add the new ones. + for ( guint n = current->len; n < numNew; n++ ) + { + wxPizzaAccessibleElement* const element = WX_PIZZA_ACCESSIBLE_ELEMENT( + g_object_new(wxPizzaAccessibleElement_get_type(), nullptr)); + + element->container = obj; + + const wxRect& rect = elements[n].rect; + element->rect.x = rect.x; + element->rect.y = rect.y; + element->rect.width = rect.width; + element->rect.height = rect.height; + + atk_object_set_role(ATK_OBJECT(element), ATK_ROLE_LABEL); + atk_object_set_name(ATK_OBJECT(element), elements[n].label.utf8_str()); + + g_ptr_array_add(current, element); + + g_object_notify(G_OBJECT(element), "accessible-parent"); + g_signal_emit_by_name(obj, "children-changed::add", + numReal + n, element, nullptr); + } +} + +#elif defined(__WXGTK3__) && !defined(__WXGTK4__) + +// Provide a dummy version if we can't implement it. +void +wxPrivate::SetAccessibleElements(wxWindow* WXUNUSED(win), + const AccessibleElements& WXUNUSED(elements)) +{ +} + +#endif // wxHAS_GTK_ACCESSIBLE/wxGTK3 without it + extern "C" { struct wxPizzaClass @@ -317,6 +719,10 @@ static void class_init(void* g_class, void*) g_cclosure_user_marshal_VOID__OBJECT_OBJECT, G_TYPE_NONE, 2, GTK_TYPE_ADJUSTMENT, GTK_TYPE_ADJUSTMENT); #endif +#ifdef wxHAS_GTK_ACCESSIBLE + gtk_widget_class_set_accessible_type(widget_class, wxPizzaAccessible_get_type()); +#endif + parent_class = GTK_WIDGET_CLASS(g_type_class_peek_parent(g_class)); } diff --git a/src/osx/cocoa/window.mm b/src/osx/cocoa/window.mm index d4ba631adf..9bb366bcc3 100644 --- a/src/osx/cocoa/window.mm +++ b/src/osx/cocoa/window.mm @@ -25,6 +25,7 @@ #include "wx/osx/private/datatransfer.h" #endif +#include "wx/private/access.h" #include "wx/private/bmpbndl.h" #include "wx/evtloop.h" @@ -4391,6 +4392,75 @@ void wxWidgetCocoaImpl::DoNotifyFocusEvent(bool receivedFocus, wxWidgetImpl* oth } } +void +wxPrivate::SetAccessibleElements(wxWindow* win, const AccessibleElements& elements) +{ + NSView* const view = win->GetHandle(); + if ( !view ) + return; + + // Check if anything has changed since the last call, as replacing the + // elements would make VoiceOver lose its position in them. + NSArray* const current = [view accessibilityChildren]; + if ( current.count == elements.size() ) + { + bool changed = false; + for ( size_t n = 0; n < elements.size(); ++n ) + { + id const child = current[n]; + if ( ![child isKindOfClass:[NSAccessibilityElement class]] ) + { + changed = true; + break; + } + + NSAccessibilityElement* const element = child; + const wxString label = wxCFStringRef::AsString([element accessibilityLabel]); + const NSRect frame = wxToNSRect(view, elements[n].rect); + if ( label != elements[n].label || + !NSEqualRects([element accessibilityFrameInParentSpace], frame) ) + { + changed = true; + break; + } + } + + if ( !changed ) + return; + } + + if ( elements.empty() ) + { + [view setAccessibilityChildren:nil]; + return; + } + + NSMutableArray* const children = + [NSMutableArray arrayWithCapacity:elements.size()]; + + for ( const auto& e : elements ) + { + NSAccessibilityElement* const element = + [NSAccessibilityElement + accessibilityElementWithRole:NSAccessibilityStaticTextRole + frame:NSZeroRect + label:wxCFStringRef(e.label).AsNSString() + parent:view]; + + // Use the frame relative to the parent view and not the screen frame + // for the element to remain at the correct position if the window + // moves. + [element setAccessibilityFrameInParentSpace:wxToNSRect(view, e.rect)]; + + [children addObject:element]; + } + + // The container itself must be visible to the accessibility clients for + // them to reach its children. + [view setAccessibilityRole:NSAccessibilityGroupRole]; + [view setAccessibilityChildren:children]; +} + void wxWidgetCocoaImpl::SetCursor(const wxCursor& cursor) { if ( !wxIsBusy() )