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.
|
- [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.
|
- [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.
|
- [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.
|
- [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).
|
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 {
|
if frame, handled := libreOfficeBrailleFrame(s, options); handled {
|
||||||
return frame
|
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 {
|
if role == "terminal" || brailleTextRole(role) && s.TextCaretKnown {
|
||||||
label := name
|
label := name
|
||||||
if role == "terminal" || role == "heading" || role == "static" {
|
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},
|
{"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},
|
{"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},
|
{"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},
|
{"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},
|
{"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},
|
{"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) {
|
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"} {
|
for _, mode := range []string{"speech_only", "sound_and_speech", "sound_only"} {
|
||||||
t.Run(role+"/"+mode, func(t *testing.T) {
|
t.Run(role+"/"+mode, func(t *testing.T) {
|
||||||
queue := &fakeSpeechQueue{spoken: make(chan string, 16), requests: make(chan speechRequest, 16)}
|
queue := &fakeSpeechQueue{spoken: make(chan string, 16), requests: make(chan speechRequest, 16)}
|
||||||
@@ -39,6 +39,7 @@ func TestRepeatedControlFocusAfterStateChange(t *testing.T) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
control := atspi.Snapshot{BusName: ":1.7", Path: "/option", Role: role, Name: "Some", RadioGroupName: "Punctuation Level"}
|
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 := atspi.NewEvent(atspi.FocusedStateEvent, control)
|
||||||
focus.Detail1 = 1
|
focus.Detail1 = 1
|
||||||
present(focus)
|
present(focus)
|
||||||
@@ -54,7 +55,7 @@ func TestRepeatedControlFocusAfterStateChange(t *testing.T) {
|
|||||||
t.Fatalf("state sound = %+v", changed)
|
t.Fatalf("state sound = %+v", changed)
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
if changed.Text != want {
|
||||||
t.Fatalf("state speech = %q, want %q", 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 {
|
if frame := <-frames; frame.Text != checkedFrame.Text || !state.current.Checked || !state.brailleSnapshot.Checked {
|
||||||
t.Fatalf("repeated focus lost the checked state: %+v", frame)
|
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.
|
// New semantics at the same identity still need an announcement.
|
||||||
focus.Source.Name = "Most"
|
focus.Source.Name = "Most"
|
||||||
if !present(focus) || len(queue.requests) != 1 {
|
if !present(focus) || len(queue.requests) != 1 {
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
|
|||||||
"window_recovered", event.WindowRecovered,
|
"window_recovered", event.WindowRecovered,
|
||||||
"focus_outside_document", event.FocusOutsideDocument.Path,
|
"focus_outside_document", event.FocusOutsideDocument.Path,
|
||||||
"role", event.Source.Role,
|
"role", event.Source.Role,
|
||||||
|
"focused", event.Source.Focused,
|
||||||
"browser_tab", event.Source.BrowserTab,
|
"browser_tab", event.Source.BrowserTab,
|
||||||
"selected", event.Source.Selected,
|
"selected", event.Source.Selected,
|
||||||
"radio_group_name", event.Source.RadioGroupName,
|
"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.focused = atspi_state_set_contains(states, ATSPI_STATE_FOCUSED);
|
||||||
snapshot.hasPopup = atspi_state_set_contains(states, ATSPI_STATE_HAS_POPUP);
|
snapshot.hasPopup = atspi_state_set_contains(states, ATSPI_STATE_HAS_POPUP);
|
||||||
snapshot.sensitiveKnown = TRUE;
|
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.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.defunct = atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT);
|
||||||
snapshot.editable = atspi_state_set_contains(states, ATSPI_STATE_EDITABLE);
|
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);
|
g_error_free(error);
|
||||||
error = NULL;
|
error = NULL;
|
||||||
}
|
}
|
||||||
|
cthulhu_capture_menu_checkability(source, &snapshot);
|
||||||
if (!snapshot.defunct && (g_strcmp0(snapshot.role, "list") == 0 ||
|
if (!snapshot.defunct && (g_strcmp0(snapshot.role, "list") == 0 ||
|
||||||
g_strcmp0(snapshot.role, "list box") == 0)) {
|
g_strcmp0(snapshot.role, "list box") == 0)) {
|
||||||
snapshot.childCount = atspi_accessible_get_child_count(source, &error);
|
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()
|
focusEvent.Foreground = adapter.Foreground()
|
||||||
focusedMenuItem := (eventType == FocusEvent || eventType == FocusedStateEvent) &&
|
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 {
|
if eventType == WindowActivatedEvent {
|
||||||
focusEvent.ForegroundMismatch = !adapter.matchForeground(window, focusEvent.Window, &focusEvent.ForegroundCheck, false)
|
focusEvent.ForegroundMismatch = !adapter.matchForeground(window, focusEvent.Window, &focusEvent.ForegroundCheck, false)
|
||||||
} else {
|
} else {
|
||||||
@@ -410,6 +410,9 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
|
|||||||
}
|
}
|
||||||
eventType := canonicalTextChangeEventType(nativeEventType)
|
eventType := canonicalTextChangeEventType(nativeEventType)
|
||||||
originID := nativeObjectID(event.source)
|
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)
|
adapter.retireEchoFocus(eventType, int(C.cthulhu_event_detail1(event)), originID)
|
||||||
if eventType != DefunctStateEvent || C.cthulhu_event_detail1(event) != 0 {
|
if eventType != DefunctStateEvent || C.cthulhu_event_detail1(event) != 0 {
|
||||||
adapter.retireLibreOfficeHeaders(event.source, eventType, originID)
|
adapter.retireLibreOfficeHeaders(event.source, eventType, originID)
|
||||||
@@ -417,6 +420,10 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
|
|||||||
if adapter.secure.Load() {
|
if adapter.secure.Load() {
|
||||||
return
|
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.applySecureReset()
|
||||||
adapter.refreshForeground()
|
adapter.refreshForeground()
|
||||||
@@ -457,6 +464,12 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
|
|||||||
if target == nil {
|
if target == nil {
|
||||||
return
|
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) &&
|
if (eventType == SelectedStateEvent || eventType == SelectionChangedEvent) &&
|
||||||
C.atspi_accessible_get_role(target, nil) == C.ATSPI_ROLE_PAGE_TAB {
|
C.atspi_accessible_get_role(target, nil) == C.ATSPI_ROLE_PAGE_TAB {
|
||||||
// Firefox can emit selected-state events from the previous 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)
|
payload := C.cthulhu_event_payload(event)
|
||||||
snapshot, popupFor := adapter.captureNativeEventSnapshot(event, target, eventType, originID)
|
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
|
var inlineMenuID ObjectID
|
||||||
if eventType == SelectedStateEvent && C.cthulhu_event_detail1(event) == 1 && !snapshot.Defunct &&
|
if eventType == SelectedStateEvent && C.cthulhu_event_detail1(event) == 1 && !snapshot.Defunct &&
|
||||||
(snapshot.Role == "button" || snapshot.Role == "push button" || snapshot.Role == "toggle button" ||
|
(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 focusable;
|
||||||
gboolean focused;
|
gboolean focused;
|
||||||
gboolean hasPopup;
|
gboolean hasPopup;
|
||||||
|
gboolean checkable;
|
||||||
gboolean checked;
|
gboolean checked;
|
||||||
gboolean defunct;
|
gboolean defunct;
|
||||||
gboolean editable;
|
gboolean editable;
|
||||||
@@ -76,6 +77,10 @@ typedef struct {
|
|||||||
gboolean referenceGeometryKnown;
|
gboolean referenceGeometryKnown;
|
||||||
} cthulhu_snapshot;
|
} 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 {
|
typedef struct {
|
||||||
uintptr_t callback;
|
uintptr_t callback;
|
||||||
} cthulhu_callback_data;
|
} cthulhu_callback_data;
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ func snapshotFromNativeWithOptions(source *C.AtspiAccessible, text, caretCharact
|
|||||||
snapshot.Focusable = captured.focusable != 0
|
snapshot.Focusable = captured.focusable != 0
|
||||||
snapshot.Focused = captured.focused != 0
|
snapshot.Focused = captured.focused != 0
|
||||||
snapshot.HasPopup = captured.hasPopup != 0
|
snapshot.HasPopup = captured.hasPopup != 0
|
||||||
|
snapshot.Checkable = captured.checkable != 0
|
||||||
snapshot.Checked = captured.checked != 0
|
snapshot.Checked = captured.checked != 0
|
||||||
snapshot.Defunct = captured.defunct != 0
|
snapshot.Defunct = captured.defunct != 0
|
||||||
snapshot.Editable = captured.editable != 0
|
snapshot.Editable = captured.editable != 0
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ type Snapshot struct {
|
|||||||
Focused bool
|
Focused bool
|
||||||
HasPopup bool
|
HasPopup bool
|
||||||
HeadingLevel int
|
HeadingLevel int
|
||||||
|
Checkable bool
|
||||||
Checked bool
|
Checked bool
|
||||||
Defunct bool
|
Defunct bool
|
||||||
Editable bool
|
Editable bool
|
||||||
@@ -144,6 +145,16 @@ type Snapshot struct {
|
|||||||
SoundGeometry SoundGeometry
|
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
|
// TextAttributeRun is the native attribute range at the caret, copied without
|
||||||
// retaining an AT-SPI object. Offsets are Unicode character offsets.
|
// retaining an AT-SPI object. Offsets are Unicode character offsets.
|
||||||
type TextAttributeRun struct {
|
type TextAttributeRun struct {
|
||||||
@@ -312,6 +323,11 @@ func NewSnapshotWithCaretCharacter(path, name, role, description, text, caretCha
|
|||||||
|
|
||||||
func NewEvent(eventType string, source Snapshot) Event {
|
func NewEvent(eventType string, source Snapshot) Event {
|
||||||
eventType = sanitizeSnapshotField(eventType)
|
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}
|
event := Event{Type: canonicalTextChangeEventType(eventType), Source: source, OriginPath: source.Path}
|
||||||
if event.Type != eventType {
|
if event.Type != eventType {
|
||||||
event.NativeType = eventType
|
event.NativeType = eventType
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func controlValue(snapshot atspi.Snapshot) string {
|
|||||||
|
|
||||||
func controlStates(snapshot atspi.Snapshot) []string {
|
func controlStates(snapshot atspi.Snapshot) []string {
|
||||||
var states []string
|
var states []string
|
||||||
switch snapshot.Role {
|
switch snapshot.ControlRole() {
|
||||||
case "check box", "check menu item":
|
case "check box", "check menu item":
|
||||||
switch {
|
switch {
|
||||||
case snapshot.Indeterminate:
|
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: "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: "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: "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: "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: "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"},
|
{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
|
want string
|
||||||
}{
|
}{
|
||||||
{name: "checked", event: atspi.NewEvent("object:state-changed:checked", atspi.Snapshot{Name: "Updates", Role: "check box", Checked: true}), want: "checked"},
|
{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: "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: "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"},
|
{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)
|
reading := reading_options(options)
|
||||||
var parts []string
|
var parts []string
|
||||||
role := snapshot.Role
|
role := snapshot.ControlRole()
|
||||||
if role == "entry" && snapshot.Autocomplete {
|
if role == "entry" && snapshot.Autocomplete {
|
||||||
role = "autocomplete"
|
role = "autocomplete"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ func stateSoundCue(snapshot atspi.Snapshot, options Options) (sound.Cue, bool) {
|
|||||||
"radio menu item": "radiobutton",
|
"radio menu item": "radiobutton",
|
||||||
"toggle button": "togglebutton",
|
"toggle button": "togglebutton",
|
||||||
"switch": "switch",
|
"switch": "switch",
|
||||||
}[snapshot.Role]
|
}[snapshot.ControlRole()]
|
||||||
switch {
|
switch {
|
||||||
case stateBase == "":
|
case stateBase == "":
|
||||||
case snapshot.Indeterminate && stateBase == "checkbox":
|
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", Checked: true}, []string{"checkbox_checked"}},
|
||||||
{atspi.Snapshot{Role: "check box", Indeterminate: true}, []string{"checkbox_mixed"}},
|
{atspi.Snapshot{Role: "check box", Indeterminate: true}, []string{"checkbox_mixed"}},
|
||||||
{atspi.Snapshot{Role: "check box"}, []string{"checkbox_unchecked"}},
|
{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: "toggle button", Checked: true}, []string{"button", "togglebutton_checked"}},
|
||||||
{atspi.Snapshot{Role: "link", Visited: true}, []string{"link_visited"}},
|
{atspi.Snapshot{Role: "link", Visited: true}, []string{"link_visited"}},
|
||||||
{atspi.Snapshot{Role: "entry"}, nil},
|
{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