Fixes for mumble check/unchecked items and for menus that were not reading properly.
This commit is contained in:
1 parent
3db88abddb
commit
919b9e013b
19 files changed
+574
-7
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user