Fixes for mumble check/unchecked items and for menus that were not reading properly.

This commit is contained in:
Storm Dragon committed 2026-10-18 12:39:26 -04:00
1 parent 3db88abddb
commit 919b9e013b
19 files changed
+574 -7

No files matched your search

+1
View File
@@ -8,6 +8,7 @@ Cthulhu is a Linux screen reader written in Go.
- [Progress reporting](PROGRESS_BARS.md): web progress bars, Steam shader processing, and verification.
- [Steam keyboard focus](STEAM_FOCUS.md): Xorg workspace focus recovery and verification.
- [Flatpak foreground accessibility](docs/testing/flatpak-foreground.md): process identity and proxy matching, tests, and live acceptance.
- [Qt control states](docs/testing/qt-control-states.md): menu checkability, native regressions, and the Qt 5 provider limitation.
- [Wayland keyboard access](WAYLAND_SUPPORT.md): shared protocol support, limitations, and verification.
AI-assisted contributions are welcome. Contributors remain responsible for human review and testing before submission; see the [full policy](CONTRIBUTING.md#ai-assisted-contributions).
+109
View File
@@ -0,0 +1,109 @@
# Qt control state reporting
Cthulhu preserves AT-SPI checkability independently of the native role.
An ordinary menu-item role with CHECKABLE, CHECKED or INDETERMINATE is
presented as a check menu item for speech, state sounds and Braille. A checked
state-change event also establishes checkability when its new value is false.
When a provider omits CHECKABLE, Cthulhu remembers that evidence on the native
accessible object, so a subsequent unchecked value still has check-menu
semantics. This applies to all applications without matching names or labels.
An ordinary menu command without that evidence keeps its existing presentation.
Qt menu actions can change state without sending a checked-state event.
Cthulhu refreshes their native cache on focus so reopening a menu reads the
current state instead of a previously cached value.
Actions shared between a menu bar and a context menu can keep their original
AT-SPI parent. Qt can emit focus-gained for the shared accessible while a state
query reports it unfocused because its original menu is hidden. Cthulhu uses
that explicit focus event for menu items, including check and radio menu
items, before applying the existing foreground checks. The event must name
the captured accessible itself; redirected descendants and defunct items do
not inherit this evidence. No focus evidence is retained beyond the event.
Checkability evidence belongs to the AT-SPI accessible on the GLib owner
thread. No extra accessible references are retained. It ends with that object's
native lifetime, is cleared on defunct/name-change events or a changed role,
and respects withdrawal of an explicitly exported CHECKABLE state. Replacement
actions, including ones with the same label, do not inherit the evidence.
## Automated checks
Run the generic Qt 5 (PyQt5) and Qt 6 (PySide6) fixture with:
```sh
/usr/bin/python3 tests/native_qt_controls.py
```
This uses private Xvfb, D-Bus and temporary configuration directories. It checks
queued speech and Braille frames through Cthulhu's event loop for menu-bar and
context-menu actions, checking and unchecking ordinary checkboxes and checkable
buttons, an ordinary menu command, same-label action replacement, and explicit
capability removal. It also traverses shared and context-only actions in both
directions and after reopening the context menu. It requires both Python Qt bindings,
xdotool and the native build dependencies. It does not replace the active
reader or establish audible speech or physical Braille acceptance.
Existing package checks cover mixed states, state sounds, speech-only and
sound-only modes, and repeated focus after checking and unchecking. The
existing browser menu test checks ordinary and compound menu navigation:
```sh
go test ./internal/atspi ./internal/presentation ./internal/app -count=1
CTHULHU_NATIVE_BROWSER_TESTS=menu /usr/bin/python3 tests/native_browser.py
```
## Providers which omit checkability
The tested Mumble 1.5.939 / Qt 5 installation exports Mute Self and Deafen Self
as ordinary menu items, with CHECKED only while checked and no CHECKABLE state.
Its toolbar equivalents use the check-box role. Qt 5's upstream
[AT-SPI state mapping](https://github.com/qt/qtbase/blob/5.15/src/platformsupport/linuxaccessibility/constant_mappings.cpp)
omits `state.checkable`, although
[menu accessibility](https://github.com/qt/qtbase/blob/5.15/src/widgets/accessible/qaccessiblemenu.cpp)
sets that field internally. Qt 6's
[mapping](https://github.com/qt/qtbase/blob/6.12/src/gui/accessible/linux/qspi_constant_mappings.cpp)
exports it.
No toolkit patch is required for Cthulhu to retain observed checkability.
The Qt 5 native fixture verifies the sequence unchecked, checked, unchecked
for both menu-bar and context-menu actions. After checking establishes the
capability, unchecking and reopening announces `not checked`, with the
corresponding Braille indicator. Native checked-state events establish the same
capability even when the first observed transition is to false.
The Python reference's `generator._generateCheckedStateIfCheckable` and Orca's
`generator._generate_state_checked_if_checkable` also treat CHECKED as sufficient
evidence even without CHECKABLE. Their menu generators do not assign an
unchecked state to an ordinary menu action without evidence.
On first encountering an unchecked menu action with neither the correct role
nor CHECKABLE nor a checked-state event, its semantics are unknown. Cthulhu
keeps ordinary menu presentation until evidence arrives, rather than
announcing every menu command as unchecked. Retained evidence cannot detect
an unreported capability removal by a provider that never exports CHECKABLE;
object destruction, role changes and name changes still retire it.
## Live acceptance
After reviewing the diff, rebuild and restart the intended Cthulhu binary:
1. In Mumble's Self menu, navigate to Mute Self and Deafen Self. Toggle each,
reopen the menu, and confirm both checked and not-checked states once the
capability is established. Restore the original settings.
2. On a server authorized for testing, open another user's context menu and
exercise Local Mute in both states. Reopen the menu after each change.
Restore the original local mute setting; do not change server mute.
3. Test the toolbar mute/deafen controls and ordinary settings checkboxes in
both states. Confirm Tab and Shift+Tab still reach adjacent controls.
4. Navigate ordinary Mumble and browser menu commands. Confirm no invented
checked state, duplicate announcements, or lost keyboard focus.
5. Listen to actual speech and check a physical Braille display when available.
6. Open your own user context menu. Arrow through every enabled item in both
directions, including ordinary commands and checkable items, then close and
reopen it and repeat. Confirm every stop reads once. Also test the menu-bar
equivalents, Escape back to the user list, and switching away from Mumble
while the popup is open; background menu events must remain silent.
Do not submit before human review and live testing, as required by
[the contribution policy](../../CONTRIBUTING.md#ai-assisted-contributions).
+1 -1
View File
@@ -163,7 +163,7 @@ func braille_object_frame(s atspi.Snapshot, options config.BrailleOptions, confi
if frame, handled := libreOfficeBrailleFrame(s, options); handled {
return frame
}
name, role := strings.TrimSpace(s.Name), s.Role
name, role := strings.TrimSpace(s.Name), s.ControlRole()
if role == "terminal" || brailleTextRole(role) && s.TextCaretKnown {
label := name
if role == "terminal" || role == "heading" || role == "static" {
@@ -161,6 +161,9 @@ func TestBraillePythonControlFormats(t *testing.T) {
{"toggle", atspi.Snapshot{Name: "Bold", Role: "toggle button", Checked: true}, "&=y Bold toggle button", 0},
{"button", atspi.Snapshot{Name: "More", Role: "push button", Expandable: true}, "More collapsed push button", 0},
{"menu", atspi.Snapshot{Name: "Save", Role: "menu item", SensitiveKnown: true}, "Save grayed", 0},
{"unchecked generic menu", atspi.Snapshot{Name: "Updates", Role: "menu item", Checkable: true}, "< > Updates check menu item", 0},
{"checked generic menu", atspi.Snapshot{Name: "Updates", Role: "menu item", Checked: true}, "<x> Updates check menu item", 0},
{"mixed generic menu", atspi.Snapshot{Name: "Updates", Role: "menu item", Indeterminate: true}, "<-> Updates check menu item", 0},
{"combo", atspi.Snapshot{Name: "Color", Role: "combo box", ValueText: "Blue"}, "Color Blue combo box", 6},
{"slider", atspi.Snapshot{Name: "Volume", Role: "slider", ValueKnown: true, CurrentValue: 1.25, MinimumIncrement: 0.01}, "Volume 1.25 slider", 0},
{"progress", atspi.Snapshot{Name: "Copy", Role: "progress bar", ValueKnown: true, CurrentValue: 25, MaximumValue: 100}, "Copy 25% progress bar", 0},
+18 -2
View File
@@ -18,7 +18,7 @@ import (
)
func TestRepeatedControlFocusAfterStateChange(t *testing.T) {
for _, role := range []string{"radio button", "check box", "toggle button"} {
for _, role := range []string{"radio button", "check box", "toggle button", "check menu item", "menu item"} {
for _, mode := range []string{"speech_only", "sound_and_speech", "sound_only"} {
t.Run(role+"/"+mode, func(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 16), requests: make(chan speechRequest, 16)}
@@ -39,6 +39,7 @@ func TestRepeatedControlFocusAfterStateChange(t *testing.T) {
return true
}
control := atspi.Snapshot{BusName: ":1.7", Path: "/option", Role: role, Name: "Some", RadioGroupName: "Punctuation Level"}
control.Checkable = role == "menu item"
focus := atspi.NewEvent(atspi.FocusedStateEvent, control)
focus.Detail1 = 1
present(focus)
@@ -54,7 +55,7 @@ func TestRepeatedControlFocusAfterStateChange(t *testing.T) {
t.Fatalf("state sound = %+v", changed)
}
} else {
want := map[string]string{"radio button": "selected", "check box": "checked", "toggle button": "pressed"}[role]
want := map[string]string{"radio button": "selected", "check box": "checked", "toggle button": "pressed", "check menu item": "checked", "menu item": "checked"}[role]
if changed.Text != want {
t.Fatalf("state speech = %q, want %q", changed.Text, want)
}
@@ -67,6 +68,21 @@ func TestRepeatedControlFocusAfterStateChange(t *testing.T) {
if frame := <-frames; frame.Text != checkedFrame.Text || !state.current.Checked || !state.brailleSnapshot.Checked {
t.Fatalf("repeated focus lost the checked state: %+v", frame)
}
control.Checked = false
present(atspi.NewEvent("object:state-changed:checked", control))
changed = <-queue.requests
if mode == "sound_only" {
if changed.Text != "" || len(changed.Sound) != 1 {
t.Fatalf("unchecked state sound = %+v", changed)
}
} else {
want := map[string]string{"radio button": "not selected", "check box": "not checked", "toggle button": "not pressed", "check menu item": "not checked", "menu item": "not checked"}[role]
if changed.Text != want {
t.Fatalf("unchecked state speech = %q, want %q", changed.Text, want)
}
}
<-frames
focus.Source = control
// New semantics at the same identity still need an announcement.
focus.Source.Name = "Most"
if !present(focus) || len(queue.requests) != 1 {
+1
View File
@@ -90,6 +90,7 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
"window_recovered", event.WindowRecovered,
"focus_outside_document", event.FocusOutsideDocument.Path,
"role", event.Source.Role,
"focused", event.Source.Focused,
"browser_tab", event.Source.BrowserTab,
"selected", event.Source.Selected,
"radio_group_name", event.Source.RadioGroupName,
+220
View File
@@ -0,0 +1,220 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"log/slog"
"os"
"os/exec"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/braille"
)
func TestNativeQtControlStates(t *testing.T) {
version := os.Getenv("CTHULHU_NATIVE_QT_CONTROLS")
if version != "5" && version != "6" {
t.Skip("requires private Qt control fixture")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
native := atspi.New()
if err := native.Start(ctx); err != nil {
t.Fatal(err)
}
defer native.Stop(context.Background())
adapter := &menuRecordingAdapter{Adapter: native, events: make(chan atspi.Event, 256)}
observed := make(chan atspi.Event, 256)
pumpDone := make(chan struct{})
go func() {
defer close(pumpDone)
for {
select {
case event := <-native.Events():
select {
case observed <- event:
case <-ctx.Done():
return
}
select {
case adapter.events <- event:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}()
queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 256)}
frames := make(chan braille.Frame, 256)
done := make(chan struct{})
go func() {
defer close(done)
output := outputQueue{speech: queue, braille: frameRecorder{frames: frames}}
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), output, adapter, nil, nil, 0,
nil, nil, nil, nil)
}()
defer func() { cancel(); <-done; <-pumpDone }()
keys := func(chord string) {
t.Helper()
if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", chord).CombinedOutput(); err != nil {
t.Fatalf("keys: %v %s", err, out)
}
}
waitEvent := func(name, eventType string, checked bool) atspi.Event {
t.Helper()
for {
select {
case event := <-observed:
if event.Source.Name == name {
t.Logf("event: %s detail=%d checked=%v checkable=%v focused=%v window=%s", event.Type, event.Detail1, event.Source.Checked, event.Source.Checkable, event.Source.Focused, event.Window.Path)
}
if event.Source.Name == name && event.Type == eventType && event.Source.Checked == checked &&
(eventType != atspi.FocusedStateEvent || event.Detail1 == 1) {
return event
}
case <-ctx.Done():
t.Fatalf("missing %s event for %s checked=%v", eventType, name, checked)
}
}
}
waitSpeech := func(want string) {
t.Helper()
timer := time.NewTimer(2 * time.Second)
defer timer.Stop()
for {
select {
case request := <-queue.requests:
if request.Text == want || strings.HasSuffix(request.Text, ", "+want) {
t.Logf("speech: %q", request.Text)
return
}
case <-timer.C:
t.Fatalf("missing speech %q", want)
}
}
}
waitBraille := func(want string) {
t.Helper()
timer := time.NewTimer(2 * time.Second)
defer timer.Stop()
for {
select {
case frame := <-frames:
if frame.Text == want || strings.HasSuffix(frame.Text, " "+want) {
t.Logf("Braille: %q", frame.Text)
return
}
case <-timer.C:
t.Fatalf("missing Braille %q", want)
}
}
}
// Qt 5 omits STATE_CHECKABLE. Checked-state evidence must survive the
// next uncheck without assigning a state to unrelated menu commands.
for _, probe := range []struct{ key, name string }{{"F6", "Optional feature"}, {"F8", "Context option"}} {
for step, checked := range []bool{false, true, false} {
keys(probe.key)
event := waitEvent(probe.name, atspi.FocusedStateEvent, checked)
if event.Source.Checkable != (version == "6" || step > 0) {
t.Fatalf("Qt %s checkable metadata = %v", version, event.Source.Checkable)
}
role, suffix, indicator := "check menu item", ", not checked", "< > "
if checked {
suffix, indicator = ", checked", "<x> "
} else if version == "5" && step == 0 {
role, suffix, indicator = "menu item", "", ""
}
waitSpeech(probe.name + ", " + role + suffix)
brailleText := indicator + probe.name
if role == "check menu item" {
brailleText += " check menu item"
}
waitBraille(brailleText)
if step < 2 {
keys("Return")
} else {
keys("Escape")
}
}
}
keys("F7")
waitEvent("Ordinary action", atspi.FocusedStateEvent, false)
waitSpeech("Ordinary action, menu item")
waitBraille("Ordinary action")
keys("Escape")
// Traverse between context-only entries and actions shared with the
// menu bar, in both directions and after reopening the popup.
for range 2 {
keys("F8")
waitEvent("Context option", atspi.FocusedStateEvent, false)
waitSpeech("Context option, check menu item, not checked")
for _, step := range []struct{ key, name string }{
{"Down", "Ordinary action"}, {"Down", "Context command"},
{"Down", "Optional feature"}, {"Up", "Context command"},
{"Up", "Ordinary action"}, {"Up", "Context option"},
} {
keys(step.key)
waitEvent(step.name, atspi.FocusedStateEvent, false)
wantSpeech, wantBraille := step.name+", menu item", step.name
if step.name == "Optional feature" || step.name == "Context option" {
wantSpeech = step.name + ", check menu item, not checked"
wantBraille = "< > " + step.name + " check menu item"
}
waitSpeech(wantSpeech)
waitBraille(wantBraille)
}
keys("Escape")
}
if version == "6" {
// When the provider exports CHECKABLE, withdrawing that capability is
// authoritative and must retire the prior evidence.
keys("F12")
keys("F6")
event := waitEvent("Optional feature", atspi.FocusedStateEvent, false)
if event.Source.Checkable {
t.Fatal("explicit removal of checkability was ignored")
}
waitSpeech("Optional feature, menu item")
waitBraille("Optional feature")
keys("Escape")
}
// Replacing an action with an ordinary action of the same name must not
// inherit the removed accessible's checkability evidence.
keys("F11")
keys("F6")
event := waitEvent("Optional feature", atspi.FocusedStateEvent, false)
if event.Source.Checkable {
t.Fatal("replacement action inherited checkability")
}
waitSpeech("Optional feature, menu item")
waitBraille("Optional feature")
keys("Escape")
for _, probe := range []struct{ key, name string }{{"F9", "Updates"}, {"F10", "Power"}} {
if probe.key == "F9" {
keys("F10")
} else {
keys("F9")
}
keys(probe.key)
waitEvent(probe.name, atspi.FocusedStateEvent, false)
waitSpeech(probe.name + ", check box, not checked")
for _, checked := range []bool{true, false} {
keys("space")
waitEvent(probe.name, "object:state-changed:checked", checked)
want, indicator := "not checked", "< > "
if checked {
want, indicator = "checked", "<x> "
}
waitSpeech(want)
waitBraille(indicator + probe.name + " check box")
}
}
}
+2
View File
@@ -362,6 +362,7 @@ cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean capt
snapshot.focused = atspi_state_set_contains(states, ATSPI_STATE_FOCUSED);
snapshot.hasPopup = atspi_state_set_contains(states, ATSPI_STATE_HAS_POPUP);
snapshot.sensitiveKnown = TRUE;
snapshot.checkable = atspi_state_set_contains(states, ATSPI_STATE_CHECKABLE);
snapshot.checked = atspi_state_set_contains(states, ATSPI_STATE_CHECKED) || atspi_state_set_contains(states, ATSPI_STATE_PRESSED);
snapshot.defunct = atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT);
snapshot.editable = atspi_state_set_contains(states, ATSPI_STATE_EDITABLE);
@@ -388,6 +389,7 @@ cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean capt
g_error_free(error);
error = NULL;
}
cthulhu_capture_menu_checkability(source, &snapshot);
if (!snapshot.defunct && (g_strcmp0(snapshot.role, "list") == 0 ||
g_strcmp0(snapshot.role, "list box") == 0)) {
snapshot.childCount = atspi_accessible_get_child_count(source, &error);
@@ -268,7 +268,7 @@ func (adapter *Adapter) enrichNativeWindowEvent(event *C.AtspiEvent, target *C.A
}
focusEvent.Foreground = adapter.Foreground()
focusedMenuItem := (eventType == FocusEvent || eventType == FocusedStateEvent) &&
snapshot.Role == "menu item" && snapshot.Focused
(snapshot.Role == "menu item" || snapshot.Role == "check menu item" || snapshot.Role == "radio menu item") && snapshot.Focused
if eventType == WindowActivatedEvent {
focusEvent.ForegroundMismatch = !adapter.matchForeground(window, focusEvent.Window, &focusEvent.ForegroundCheck, false)
} else {
@@ -410,6 +410,9 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
}
eventType := canonicalTextChangeEventType(nativeEventType)
originID := nativeObjectID(event.source)
if eventType == AccessibleNameEvent || eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 {
C.cthulhu_forget_menu_checkability(event.source)
}
adapter.retireEchoFocus(eventType, int(C.cthulhu_event_detail1(event)), originID)
if eventType != DefunctStateEvent || C.cthulhu_event_detail1(event) != 0 {
adapter.retireLibreOfficeHeaders(event.source, eventType, originID)
@@ -417,6 +420,10 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
if adapter.secure.Load() {
return
}
if eventType == "object:state-changed:checked" &&
C.atspi_accessible_get_role(event.source, nil) == C.ATSPI_ROLE_MENU_ITEM {
C.cthulhu_note_menu_checkability(event.source)
}
adapter.applySecureReset()
adapter.refreshForeground()
@@ -457,6 +464,12 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
if target == nil {
return
}
if (eventType == FocusEvent || eventType == FocusedStateEvent) &&
C.atspi_accessible_get_role(target, nil) == C.ATSPI_ROLE_MENU_ITEM {
// Qt menus can change their checked state without a state-change
// event. Reopening the menu must capture the current provider state.
C.atspi_accessible_clear_cache_single(target)
}
if (eventType == SelectedStateEvent || eventType == SelectionChangedEvent) &&
C.atspi_accessible_get_role(target, nil) == C.ATSPI_ROLE_PAGE_TAB {
// Firefox can emit selected-state events from the previous tab;
@@ -470,6 +483,17 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
}
payload := C.cthulhu_event_payload(event)
snapshot, popupFor := adapter.captureNativeEventSnapshot(event, target, eventType, originID)
// Shared Qt actions can report focus through their original, hidden menu
// even while another menu emits focus-gained for that very accessible.
// The explicit event establishes focus; the queried state may disagree.
// Do not transfer this evidence to a redirected descendant or a dead item.
if snapshot.ID() == originID && !snapshot.Defunct &&
(eventType == FocusEvent || eventType == FocusedStateEvent && C.cthulhu_event_detail1(event) == 1) {
switch snapshot.Role {
case "menu item", "check menu item", "radio menu item":
snapshot.Focused = true
}
}
var inlineMenuID ObjectID
if eventType == SelectedStateEvent && C.cthulhu_event_detail1(event) == 1 && !snapshot.Defunct &&
(snapshot.Role == "button" || snapshot.Role == "push button" || snapshot.Role == "toggle button" ||
+48
View File
@@ -0,0 +1,48 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
#include "adapter_native.h"
// Owned by the native accessible, on the AT-SPI GLib thread. No accessible
// references or application labels are retained. Destruction frees the data;
// semantic identity changes and explicit capability removal retire it too.
static const gchar *menu_checkability_key = "cthulhu-menu-checkability";
enum { CHECKABILITY_INFERRED = 1, CHECKABILITY_EXPLICIT = 2 };
void cthulhu_forget_menu_checkability(AtspiAccessible *source) {
if (source != NULL)
g_object_set_data(G_OBJECT(source), menu_checkability_key, NULL);
}
void cthulhu_note_menu_checkability(AtspiAccessible *source) {
if (source != NULL &&
GPOINTER_TO_INT(g_object_get_data(G_OBJECT(source), menu_checkability_key)) != CHECKABILITY_EXPLICIT)
g_object_set_data(G_OBJECT(source), menu_checkability_key,
GINT_TO_POINTER(CHECKABILITY_INFERRED));
}
void cthulhu_capture_menu_checkability(AtspiAccessible *source, cthulhu_snapshot *snapshot) {
if (snapshot->defunct || g_strcmp0(snapshot->role, "menu item") != 0) {
cthulhu_forget_menu_checkability(source);
return;
}
if (!snapshot->statesKnown) return;
gint evidence = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(source), menu_checkability_key));
if (snapshot->checkable) {
g_object_set_data(G_OBJECT(source), menu_checkability_key,
GINT_TO_POINTER(CHECKABILITY_EXPLICIT));
} else if (snapshot->checked || snapshot->indeterminate) {
cthulhu_note_menu_checkability(source);
snapshot->checkable = TRUE;
} else if (evidence == CHECKABILITY_INFERRED) {
// Providers such as Qt 5 omit CHECKABLE in both states. Once CHECKED
// or a checked-state event proves this object's capability, retain it
// when the value becomes false. Absence alone proves nothing.
snapshot->checkable = TRUE;
} else {
// A provider which supplied CHECKABLE can explicitly withdraw it.
cthulhu_forget_menu_checkability(source);
}
}
+5
View File
@@ -29,6 +29,7 @@ typedef struct {
gboolean focusable;
gboolean focused;
gboolean hasPopup;
gboolean checkable;
gboolean checked;
gboolean defunct;
gboolean editable;
@@ -76,6 +77,10 @@ typedef struct {
gboolean referenceGeometryKnown;
} cthulhu_snapshot;
void cthulhu_capture_menu_checkability(AtspiAccessible *source, cthulhu_snapshot *snapshot);
void cthulhu_note_menu_checkability(AtspiAccessible *source);
void cthulhu_forget_menu_checkability(AtspiAccessible *source);
typedef struct {
uintptr_t callback;
} cthulhu_callback_data;
@@ -112,6 +112,7 @@ func snapshotFromNativeWithOptions(source *C.AtspiAccessible, text, caretCharact
snapshot.Focusable = captured.focusable != 0
snapshot.Focused = captured.focused != 0
snapshot.HasPopup = captured.hasPopup != 0
snapshot.Checkable = captured.checkable != 0
snapshot.Checked = captured.checked != 0
snapshot.Defunct = captured.defunct != 0
snapshot.Editable = captured.editable != 0
+16
View File
@@ -83,6 +83,7 @@ type Snapshot struct {
Focused bool
HasPopup bool
HeadingLevel int
Checkable bool
Checked bool
Defunct bool
Editable bool
@@ -144,6 +145,16 @@ type Snapshot struct {
SoundGeometry SoundGeometry
}
// ControlRole includes checkable menu semantics even when a provider exposes
// the ordinary menu-item role. Checked and mixed states also establish this
// capability when the provider omits STATE_CHECKABLE.
func (snapshot Snapshot) ControlRole() string {
if snapshot.Role == "menu item" && (snapshot.Checkable || snapshot.Checked || snapshot.Indeterminate) {
return "check menu item"
}
return snapshot.Role
}
// TextAttributeRun is the native attribute range at the caret, copied without
// retaining an AT-SPI object. Offsets are Unicode character offsets.
type TextAttributeRun struct {
@@ -312,6 +323,11 @@ func NewSnapshotWithCaretCharacter(path, name, role, description, text, caretCha
func NewEvent(eventType string, source Snapshot) Event {
eventType = sanitizeSnapshotField(eventType)
// A checked-state transition proves checkability even when the provider
// omits STATE_CHECKABLE and the new checked value is false.
if eventType == "object:state-changed:checked" && source.Role == "menu item" {
source.Checkable = true
}
event := Event{Type: canonicalTextChangeEventType(eventType), Source: source, OriginPath: source.Path}
if event.Type != eventType {
event.NativeType = eventType
+1 -1
View File
@@ -88,7 +88,7 @@ func controlValue(snapshot atspi.Snapshot) string {
func controlStates(snapshot atspi.Snapshot) []string {
var states []string
switch snapshot.Role {
switch snapshot.ControlRole() {
case "check box", "check menu item":
switch {
case snapshot.Indeterminate:
@@ -39,6 +39,10 @@ func TestFocusTextPresentsControlStates(t *testing.T) {
{name: "collapsed item", snapshot: atspi.Snapshot{Name: "Documents", Role: "tree item", Expandable: true}, want: "Documents, tree item, collapsed"},
{name: "expanded selected item", snapshot: atspi.Snapshot{Name: "Documents", Role: "tree item", Expandable: true, Expanded: true, Selected: true}, want: "Documents, tree item, expanded, selected"},
{name: "selected menu item", snapshot: atspi.Snapshot{Name: "Open", Role: "menu item", Selected: true}, want: "Open, menu item"},
{name: "unchecked generic menu", snapshot: atspi.Snapshot{Name: "Updates", Role: "menu item", Checkable: true}, want: "Updates, check menu item, not checked"},
{name: "checked generic menu without marker", snapshot: atspi.Snapshot{Name: "Updates", Role: "menu item", Checked: true}, want: "Updates, check menu item, checked"},
{name: "mixed generic menu", snapshot: atspi.Snapshot{Name: "Updates", Role: "menu item", Indeterminate: true}, want: "Updates, check menu item, partially checked"},
{name: "ordinary menu", snapshot: atspi.Snapshot{Name: "Open", Role: "menu item"}, want: "Open, menu item"},
{name: "selected table cell", snapshot: atspi.Snapshot{Name: "First post", Role: "table cell", Selected: true}, want: "First post"},
{name: "field requirements", snapshot: atspi.Snapshot{Name: "Account", Role: "text", ReadOnly: true, Required: true, Invalid: true}, want: "Account, text, read only, required, invalid entry"},
{name: "disabled control", snapshot: atspi.Snapshot{Name: "Apply", Role: "push button", SensitiveKnown: true}, want: "Apply, push button, grayed"},
@@ -61,6 +65,8 @@ func TestEventTextPresentsOnlyChangedState(t *testing.T) {
want string
}{
{name: "checked", event: atspi.NewEvent("object:state-changed:checked", atspi.Snapshot{Name: "Updates", Role: "check box", Checked: true}), want: "checked"},
{name: "generic menu checked", event: atspi.NewEvent("object:state-changed:checked", atspi.Snapshot{Name: "Updates", Role: "menu item", Checkable: true, Checked: true}), want: "checked"},
{name: "generic menu unchecked without marker", event: atspi.NewEvent("object:state-changed:checked", atspi.Snapshot{Name: "Updates", Role: "menu item"}), want: "not checked"},
{name: "expanded", event: atspi.NewEvent("object:state-changed:expanded", atspi.Snapshot{Name: "Files", Role: "tree item", Expanded: true}), want: "expanded"},
{name: "menu selection omitted", event: atspi.NewEvent("object:state-changed:selected", atspi.Snapshot{Name: "Open", Role: "menu item", Selected: true}), want: ""},
{name: "tree selection", event: atspi.NewEvent("object:state-changed:selected", atspi.Snapshot{Name: "Files", Role: "tree item", Selected: true}), want: "selected"},
+1 -1
View File
@@ -86,7 +86,7 @@ func FocusSpeechWithOptions(snapshot atspi.Snapshot, options Options) Speech {
}
reading := reading_options(options)
var parts []string
role := snapshot.Role
role := snapshot.ControlRole()
if role == "entry" && snapshot.Autocomplete {
role = "autocomplete"
}
+1 -1
View File
@@ -151,7 +151,7 @@ func stateSoundCue(snapshot atspi.Snapshot, options Options) (sound.Cue, bool) {
"radio menu item": "radiobutton",
"toggle button": "togglebutton",
"switch": "switch",
}[snapshot.Role]
}[snapshot.ControlRole()]
switch {
case stateBase == "":
case snapshot.Indeterminate && stateBase == "checkbox":
+4
View File
@@ -36,6 +36,10 @@ func TestRoleAndStateSoundPolicy(t *testing.T) {
{atspi.Snapshot{Role: "check box", Checked: true}, []string{"checkbox_checked"}},
{atspi.Snapshot{Role: "check box", Indeterminate: true}, []string{"checkbox_mixed"}},
{atspi.Snapshot{Role: "check box"}, []string{"checkbox_unchecked"}},
{atspi.Snapshot{Role: "menu item", Checkable: true}, []string{"checkbox_unchecked"}},
{atspi.Snapshot{Role: "menu item", Checked: true}, []string{"checkbox_checked"}},
{atspi.Snapshot{Role: "menu item", Indeterminate: true}, []string{"checkbox_mixed"}},
{atspi.Snapshot{Role: "menu item"}, nil},
{atspi.Snapshot{Role: "toggle button", Checked: true}, []string{"button", "togglebutton_checked"}},
{atspi.Snapshot{Role: "link", Visited: true}, []string{"link_visited"}},
{atspi.Snapshot{Role: "entry"}, nil},
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Storm Dragon
# Authors: Storm Dragon, Angus Kola
# SPDX-License-Identifier: GPL-3.0-or-later
"""Check Qt menu and button states on a private X11/AT-SPI desktop."""
import os
from pathlib import Path
import subprocess
import sys
import tempfile
sys.dont_write_bytecode = True
from native_x11 import wait_for
def fixture(version):
if version == '5':
from PyQt5 import QtCore, QtWidgets
else:
from PySide6 import QtCore, QtWidgets
class ControlWindow(QtWidgets.QMainWindow):
def keyPressEvent(self, event):
nonlocal option
targets = {QtCore.Qt.Key_F6: option, QtCore.Qt.Key_F7: ordinary,
QtCore.Qt.Key_F8: contextOption}
if event.key() in targets:
target = targets[event.key()]
popup = contextMenu if target is contextOption else menu
popup.popup(self.mapToGlobal(QtCore.QPoint(20, 50)))
popup.setActiveAction(target)
elif event.key() == QtCore.Qt.Key_F9:
checkbox.setFocus()
elif event.key() == QtCore.Qt.Key_F10:
toggle.setFocus()
elif event.key() == QtCore.Qt.Key_F11:
menu.removeAction(option)
option.deleteLater()
option = menu.addAction('Optional feature')
elif event.key() == QtCore.Qt.Key_F12:
option.setCheckable(False)
else:
super().keyPressEvent(event)
application = QtWidgets.QApplication([])
window = ControlWindow()
window.setWindowTitle('Qt control states probe')
content = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(content)
checkbox = QtWidgets.QCheckBox('Updates')
layout.addWidget(checkbox)
toggle = QtWidgets.QPushButton('Power')
toggle.setCheckable(True)
layout.addWidget(toggle)
window.setCentralWidget(content)
menu = window.menuBar().addMenu('&Actions')
option = menu.addAction('Optional feature')
option.setCheckable(True)
ordinary = menu.addAction('Ordinary action')
contextMenu = QtWidgets.QMenu(window)
contextOption = contextMenu.addAction('Context option')
contextOption.setCheckable(True)
# Shared actions can retain their menu-bar accessibility parent even
# while the context menu emits their focus events.
contextMenu.addAction(ordinary)
contextMenu.addAction('Context command')
contextMenu.addAction(option)
window.resize(400, 250)
window.show()
checkbox.setFocus()
application.exec()
def run_inside():
root = Path(__file__).resolve().parents[1]
for version in ('5', '6'):
process = subprocess.Popen([sys.executable, __file__, '--fixture', version])
try:
def find_window():
result = subprocess.run(['xdotool', 'search', '--onlyvisible', '--name',
'^Qt control states probe$'], capture_output=True, text=True)
return result.stdout.strip().splitlines()[0] if result.stdout.strip() else None
windowId = wait_for(find_window, 'Qt fixture window missing')
subprocess.run(['xdotool', 'windowfocus', windowId], check=True)
from native_x11 import set_active_window
set_active_window(int(windowId))
subprocess.run(['go', 'test', './internal/app', '-run', '^TestNativeQtControlStates$',
'-count=1', '-v', '-timeout=45s'], cwd=root, check=True, timeout=60,
env=dict(os.environ, CTHULHU_NATIVE_QT_CONTROLS=version,
GOCACHE='/tmp/cthulhu-cache'))
finally:
process.terminate()
process.wait(timeout=5)
if __name__ == '__main__':
if '--fixture' in sys.argv:
fixture(sys.argv[-1])
elif '--inside' in sys.argv:
run_inside()
else:
with tempfile.TemporaryDirectory(prefix='cthulhu-qt-controls-') as temporary:
environment = dict(os.environ, HOME=temporary, XDG_CONFIG_HOME=temporary,
XDG_CACHE_HOME=temporary, XDG_DATA_HOME=temporary,
GOMODCACHE=subprocess.check_output(['go', 'env', 'GOMODCACHE'], text=True).strip(),
QT_QPA_PLATFORM='xcb', QT_LINUX_ACCESSIBILITY_ALWAYS_ON='1',
NO_AT_BRIDGE='0')
environment.pop('AT_SPI_BUS_ADDRESS', None)
subprocess.run(['xvfb-run', '-a', 'dbus-run-session', '--', sys.executable,
__file__, '--inside'], env=environment, check=True)