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
@@ -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},
|
||||
|
||||
Reference in new issue
Block a user