From 919b9e013bd6c6303b26c0ca129007fcbe66827c Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 18 Oct 2026 12:39:26 -0400 Subject: [PATCH] Fixes for mumble check/unchecked items and for menus that were not reading properly. --- README.md | 1 + docs/testing/qt-control-states.md | 109 +++++++++ internal/app/braille_presentation.go | 2 +- internal/app/braille_presentation_test.go | 3 + internal/app/control_events_test.go | 20 +- internal/app/event_accessibility.go | 1 + internal/app/qt_controls_native_test.go | 220 ++++++++++++++++++ internal/atspi/adapter_capture.c | 2 + .../atspi/adapter_event_callback_native.go | 26 ++- internal/atspi/adapter_menu_states.c | 48 ++++ internal/atspi/adapter_native.h | 5 + internal/atspi/adapter_snapshot_native.go | 1 + internal/atspi/model.go | 16 ++ internal/presentation/control_values.go | 2 +- internal/presentation/control_values_test.go | 6 + internal/presentation/presentation.go | 2 +- internal/presentation/sound_cues.go | 2 +- internal/presentation/sound_cues_test.go | 4 + tests/native_qt_controls.py | 111 +++++++++ 19 files changed, 574 insertions(+), 7 deletions(-) create mode 100644 docs/testing/qt-control-states.md create mode 100644 internal/app/qt_controls_native_test.go create mode 100644 internal/atspi/adapter_menu_states.c create mode 100644 tests/native_qt_controls.py diff --git a/README.md b/README.md index e6c2032..562d379 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/testing/qt-control-states.md b/docs/testing/qt-control-states.md new file mode 100644 index 0000000..a0a8a25 --- /dev/null +++ b/docs/testing/qt-control-states.md @@ -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). diff --git a/internal/app/braille_presentation.go b/internal/app/braille_presentation.go index 195feee..63c2f56 100644 --- a/internal/app/braille_presentation.go +++ b/internal/app/braille_presentation.go @@ -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" { diff --git a/internal/app/braille_presentation_test.go b/internal/app/braille_presentation_test.go index 127e2f3..cbadb6e 100644 --- a/internal/app/braille_presentation_test.go +++ b/internal/app/braille_presentation_test.go @@ -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}, " 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}, diff --git a/internal/app/control_events_test.go b/internal/app/control_events_test.go index 009ccf9..cceabd5 100644 --- a/internal/app/control_events_test.go +++ b/internal/app/control_events_test.go @@ -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 { diff --git a/internal/app/event_accessibility.go b/internal/app/event_accessibility.go index e1c5463..f64a6a0 100644 --- a/internal/app/event_accessibility.go +++ b/internal/app/event_accessibility.go @@ -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, diff --git a/internal/app/qt_controls_native_test.go b/internal/app/qt_controls_native_test.go new file mode 100644 index 0000000..a7ed8b9 --- /dev/null +++ b/internal/app/qt_controls_native_test.go @@ -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", " " + } 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", " " + } + waitSpeech(want) + waitBraille(indicator + probe.name + " check box") + } + } +} diff --git a/internal/atspi/adapter_capture.c b/internal/atspi/adapter_capture.c index 33b5f65..60711d2 100644 --- a/internal/atspi/adapter_capture.c +++ b/internal/atspi/adapter_capture.c @@ -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); diff --git a/internal/atspi/adapter_event_callback_native.go b/internal/atspi/adapter_event_callback_native.go index d04bbe2..09c347e 100644 --- a/internal/atspi/adapter_event_callback_native.go +++ b/internal/atspi/adapter_event_callback_native.go @@ -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" || diff --git a/internal/atspi/adapter_menu_states.c b/internal/atspi/adapter_menu_states.c new file mode 100644 index 0000000..daa3a20 --- /dev/null +++ b/internal/atspi/adapter_menu_states.c @@ -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); + } +} diff --git a/internal/atspi/adapter_native.h b/internal/atspi/adapter_native.h index 615467b..51961e2 100644 --- a/internal/atspi/adapter_native.h +++ b/internal/atspi/adapter_native.h @@ -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; diff --git a/internal/atspi/adapter_snapshot_native.go b/internal/atspi/adapter_snapshot_native.go index 144a1fa..4ea0aed 100644 --- a/internal/atspi/adapter_snapshot_native.go +++ b/internal/atspi/adapter_snapshot_native.go @@ -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 diff --git a/internal/atspi/model.go b/internal/atspi/model.go index 40abfac..1f2c09d 100644 --- a/internal/atspi/model.go +++ b/internal/atspi/model.go @@ -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 diff --git a/internal/presentation/control_values.go b/internal/presentation/control_values.go index 319f13b..c2fb542 100644 --- a/internal/presentation/control_values.go +++ b/internal/presentation/control_values.go @@ -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: diff --git a/internal/presentation/control_values_test.go b/internal/presentation/control_values_test.go index 162cc12..0a219f5 100644 --- a/internal/presentation/control_values_test.go +++ b/internal/presentation/control_values_test.go @@ -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"}, diff --git a/internal/presentation/presentation.go b/internal/presentation/presentation.go index d85dd99..88a6d95 100644 --- a/internal/presentation/presentation.go +++ b/internal/presentation/presentation.go @@ -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" } diff --git a/internal/presentation/sound_cues.go b/internal/presentation/sound_cues.go index db85fb1..3411ef7 100644 --- a/internal/presentation/sound_cues.go +++ b/internal/presentation/sound_cues.go @@ -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": diff --git a/internal/presentation/sound_cues_test.go b/internal/presentation/sound_cues_test.go index c54ead9..220c845 100644 --- a/internal/presentation/sound_cues_test.go +++ b/internal/presentation/sound_cues_test.go @@ -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}, diff --git a/tests/native_qt_controls.py b/tests/native_qt_controls.py new file mode 100644 index 0000000..25a26d8 --- /dev/null +++ b/tests/native_qt_controls.py @@ -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)