Better handling of some qt and gtk applications. Used Audacious for testing.

This commit is contained in:
Storm Dragon committed 2026-11-01 21:37:01 -05:00
1 parent 300f4a7504
commit bbe931335f
15 files changed
+507 -16

No files matched your search

+1
View File
@@ -15,6 +15,7 @@ Cthulhu is a Linux screen reader written in Go.
- [MATE window switcher](docs/testing/window-switcher.md): Marco selection announcements, isolated checks, and live verification.
- [MATE desktop](docs/testing/mate-desktop.md): Orca comparison, panel menus and icon labels, regression checks, and live verification.
- [Qt control states](docs/testing/qt-control-states.md): menu checkability, native regressions, and the Qt 5 provider limitation.
- [Audacious GUI](docs/testing/audacious.md): isolated Qt/GTK menu and playlist checks, row-focus fixes, and live verification.
- [Tentacle reader settings API](docs/tentacle-reader-settings.md): contributed preferences pages, quick settings, speech overrides, and verification.
- [Tentacle updates](docs/testing/tentacle-updates.md): package recovery, version comparison, and update verification.
+4 -2
View File
@@ -40,8 +40,10 @@ 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
directions and after reopening the context menu. It also checks Left and Escape
from nested menus, including providers which report the return only by hiding
the submenu; see [Audacious GUI checks](audacious.md). 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
+156
View File
@@ -0,0 +1,156 @@
// 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 TestNativeAudacious(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_AUDACIOUS") == "" {
t.Skip("requires private Audacious GUI session")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
adapter := atspi.New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 256)}
frames := make(chan braille.Frame, 256)
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler),
outputQueue{speech: queue, braille: frameRecorder{frames: frames}},
adapter, nil, nil, 0, nil, nil, nil, nil)
}()
defer func() { cancel(); <-done }()
keys := func(chord string) {
t.Helper()
t.Logf("KEY %s", chord)
if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", chord).CombinedOutput(); err != nil {
t.Fatalf("keys: %v %s", err, out)
}
}
wait := func(wantSpeech, wantBraille string) {
t.Helper()
spoken, displayed := wantSpeech == "", wantBraille == ""
timer := time.NewTimer(2 * time.Second)
defer timer.Stop()
for {
select {
case request := <-queue.requests:
t.Logf("speech %s: %q", request.Cause, request.Text)
if request.Cause == atspi.CaretEvent && strings.Contains(request.Text, "password text") {
t.Fatalf("unfocused proxy password initialization was spoken: %q", request.Text)
}
if strings.Contains(request.Text, ".wav") || strings.Contains(request.Text, "/tmp/") {
t.Fatalf("hidden playlist columns were spoken: %q", request.Text)
}
spoken = spoken || strings.Contains(request.Text, wantSpeech)
case frame := <-frames:
t.Logf("Braille %q", frame.Text)
displayed = displayed || strings.Contains(frame.Text, wantBraille)
case <-timer.C:
if spoken && displayed {
return
}
out, _ := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/native_audacious.py", "--diagnose").CombinedOutput()
t.Logf("native selection: %s", out)
t.Fatalf("missing output speech=%q (%v) Braille=%q (%v)", wantSpeech, spoken, wantBraille, displayed)
case <-ctx.Done():
t.Fatal(ctx.Err())
}
if spoken && displayed {
timer.Reset(80 * time.Millisecond)
}
}
}
step := func(chord, speech, braille string) {
t.Helper()
keys(chord)
wait(speech, braille)
}
// Queueing fixtures never starts playback. Select a row explicitly before
// testing return from menus: an initially unselected table has no row.
step("alt+f", "Open Files ..., menu item", "Open Files ...")
if os.Getenv("CTHULHU_NATIVE_AUDACIOUS") == "-Q" {
step("Down", "Open Folder ..., menu item", "Open Folder ...")
step("Down", "Open URL ..., menu item", "Open URL ...")
step("Up", "Open Folder ..., menu item", "Open Folder ...")
} else {
step("Down", "Open URL ..., menu item", "Open URL ...")
step("Up", "Open Files ..., menu item", "Open Files ...")
}
step("Right", "Play, menu item", "Play")
for _, name := range []string{"Pause", "Stop", "Previous", "Next", "Previous Album", "Next Album", "Repeat"} {
step("Down", name+", ", name)
}
step("Return", "Alpha, Example Artist, Example Album, 0:01", "Alpha table cell")
step("Down", "Beta, Example Artist, Example Album, 0:01", "Beta table cell")
step("Up", "Alpha, Example Artist, Example Album, 0:01", "Alpha table cell")
step("alt+p", "Play, menu item", "Play")
for _, name := range []string{"Pause", "Stop", "Previous", "Next", "Previous Album", "Next Album"} {
step("Down", name+", ", name)
}
step("Down", "Repeat, check menu item, checked", "<x> Repeat check menu item")
step("Return", "Alpha, Example Artist, Example Album, 0:01", "Alpha table cell")
step("alt+p", "Play, menu item", "Play")
step("Right", "Play/Resume, menu item", "Play/Resume")
step("Down", "Refresh, menu item", "Refresh")
if os.Getenv("CTHULHU_NATIVE_AUDACIOUS") == "-Q" {
step("Down", "Find ..., menu item", "Find ...")
}
step("Down", "Sort, menu", "Sort")
step("Right", "By Track Number, menu item", "By Track Number")
step("Down", "By Title, menu item", "By Title")
step("Up", "By Track Number, menu item", "By Track Number")
step("Left", "Sort, menu", "Sort")
if os.Getenv("CTHULHU_NATIVE_AUDACIOUS") == "-Q" {
step("Escape", "Playlist, menu item", "Playlist")
}
step("Escape", "Alpha, Example Artist, Example Album, 0:01", "Alpha table cell")
step("ctrl+p", "Playlist Manager", "New Playlist table cell")
step("Tab", "New, button", "New button")
step("shift+Tab", "New Playlist, 3", "New Playlist table cell")
step("Escape", "Alpha, Example Artist, Example Album, 0:01", "Alpha table cell")
step("alt+f", "Open Files ..., menu item", "Open Files ...")
fileCommands := []string{"Open URL ...", "Add Files ...", "Add URL ...", "Search Library", "About ...", "Settings ..."}
if os.Getenv("CTHULHU_NATIVE_AUDACIOUS") == "-Q" {
fileCommands = []string{"Open Folder ...", "Open URL ...", "Add Files ...", "Add Folder ...", "Add URL ...", "Search Library", "About ...", "Settings ..."}
}
for _, name := range fileCommands {
step("Down", name+", menu item", name)
}
if os.Getenv("CTHULHU_NATIVE_AUDACIOUS") == "-Q" {
step("Return", "Audacious Settings", "Restart in")
step("Tab", "Qt Interface, combo box", "Qt Interface combo box")
step("Tab", "Native, combo box", "Native combo box")
step("shift+Tab", "Qt Interface, combo box", "Qt Interface combo box")
} else {
step("Return", "Audacious Settings", "Appearance")
step("Tab", "Restart in Qt mode, button", "Restart in Qt mode button")
step("Tab", "GTK Interface, combo box", "GTK Interface combo box")
step("shift+Tab", "Restart in Qt mode, button", "Restart in Qt mode button")
}
step("Escape", "Alpha, Example Artist, Example Album, 0:01", "Alpha table cell")
// Qt opens this popup without an active item; Down chooses the first.
keys("Menu")
step("Down", "Song Info ..., menu item", "Song Info ...")
step("Down", "Queue/Unqueue, menu item", "Queue/Unqueue")
step("Up", "Song Info ..., menu item", "Song Info ...")
step("Escape", "Alpha, Example Artist, Example Album, 0:01", "Alpha table cell")
}
+1 -1
View File
@@ -203,7 +203,7 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
activity = browser.RecentInput()
}
intent := activity.Intent
if event.Type == atspi.CaretEvent && event.Source.EditableRoot.Valid() && state.focusedID.Valid() &&
if event.Type == atspi.CaretEvent && (event.Source.EditableRoot.Valid() || event.Source.Role == "password text") && state.focusedID.Valid() &&
!belongsToFocusedEditor(event.Source, state.focusedID) {
logger.Debug("skipped caret outside focused editor", "role", event.Source.Role)
return
+51
View File
@@ -0,0 +1,51 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"log/slog"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
func TestUnfocusedPasswordCaretDuringSettingsCreation(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
speech := &fakeSpeechQueue{spoken: make(chan string, 8)}
done := make(chan struct{})
go func() {
defer close(done)
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
}()
defer func() { cancel(); <-done }()
focus := func(path, name, role string) {
source := atspi.NewSnapshot(path, name, role, "", "")
source.BusName = ":1.42"
adapter.events <- atspi.NewEvent(atspi.FocusEvent, source)
}
focus("/restart", "Restart interface", "button")
password := atspi.NewSnapshot("/proxy-password", "", "password text", "", "")
password.BusName = ":1.42"
adapter.events <- atspi.NewEvent(atspi.CaretEvent, password)
focus("/interface", "Interface", "combo box")
// A real focus event still announces the password field. The following
// button is an event-loop barrier, so stray caret speech cannot pass later.
focus("/proxy-password", "Proxy password", "password text")
focus("/close", "Close", "button")
for _, want := range []string{"Restart interface, button", "Interface, combo box", "Proxy password, password text", "Close, button"} {
select {
case got := <-speech.spoken:
if got != want {
t.Fatalf("speech = %q, want %q", got, want)
}
case <-ctx.Done():
t.Fatalf("missing speech %q", want)
}
}
}
+29
View File
@@ -173,6 +173,35 @@ func TestNativeQtControlStates(t *testing.T) {
}
keys("Escape")
}
// Qt can report only SHOWING=false for a closing submenu, even though
// its parent action regains FOCUSED state. Restore that actual parent.
for _, closeKey := range []string{"Left", "Escape"} {
keys("F7")
waitEvent("Ordinary action", atspi.FocusedStateEvent, false)
waitSpeech("Ordinary action, menu item")
keys("Down")
waitEvent("Nested actions", atspi.FocusedStateEvent, false)
waitSpeech("Nested actions, menu item")
keys("Right")
waitEvent("Nested command", atspi.FocusedStateEvent, false)
waitSpeech("Nested command, menu item")
keys(closeKey)
// The normalized event preserves its native showing-event origin.
for {
select {
case event := <-observed:
if event.Source.Name == "Nested actions" && isFocusGainEvent(event) {
goto returned
}
case <-ctx.Done():
t.Fatal("missing focus return from submenu")
}
}
returned:
waitSpeech("Nested actions, menu item")
waitBraille("Nested actions")
keys("Escape")
}
if version == "6" {
// When the provider exports CHECKABLE, withdrawing that capability is
// authoritative and must retire the prior evidence.
+17 -3
View File
@@ -65,7 +65,7 @@ gchar *cthulhu_table_context(AtspiAccessible *source, gint *rows, gint *columns,
AtspiAccessible *tableAccessible = NULL;
gint row = -1;
gint column = -1;
if (atspi_accessible_get_role(source, NULL) == ATSPI_ROLE_TABLE) {
if (cthulhu_is_table_container(source)) {
tableAccessible = g_object_ref(source);
} else if (atspi_accessible_get_role(source, NULL) == ATSPI_ROLE_TABLE_CELL) {
AtspiTableCell *cell = atspi_accessible_get_table_cell(source);
@@ -76,8 +76,7 @@ gchar *cthulhu_table_context(AtspiAccessible *source, gint *rows, gint *columns,
}
if (tableAccessible == NULL) {
AtspiAccessible *parent = atspi_accessible_get_parent(source, NULL);
if (parent != NULL && (atspi_accessible_get_role(parent, NULL) == ATSPI_ROLE_TABLE ||
cthulhu_is_gtk_list_table(parent))) {
if (parent != NULL && cthulhu_is_table_container(parent)) {
tableAccessible = parent;
gint index = atspi_accessible_get_index_in_parent(source, NULL);
AtspiTable *parentTable = atspi_accessible_get_table_iface(parent);
@@ -125,6 +124,21 @@ gchar *cthulhu_table_context(AtspiAccessible *source, gint *rows, gint *columns,
for (gint column = 0; column < *columns; column++) {
AtspiAccessible *item = atspi_table_get_accessible_at(table, row, column, NULL);
if (item == NULL) continue;
// Qt keeps hidden columns in the Table interface. As in Orca's
// get_showing_cells_in_same_row, GUI full-row reading includes
// only showing cells. Document/spreadsheet capture keeps its
// existing policy. Query fresh state after column visibility changes.
if (*guiTable) {
atspi_accessible_clear_cache_single(item);
AtspiStateSet *states = atspi_accessible_get_state_set(item);
gboolean showing = states != NULL &&
atspi_state_set_contains(states, ATSPI_STATE_SHOWING);
if (states != NULL) g_object_unref(states);
if (!showing) {
g_object_unref(item);
continue;
}
}
gchar *name = atspi_accessible_get_name(item, NULL);
if ((name == NULL || *name == '\0') && remaining != 0 &&
atspi_accessible_get_role(item, NULL) == ATSPI_ROLE_TABLE_CELL) {
@@ -52,13 +52,13 @@ func selectNativeEventTarget(event *C.AtspiEvent, eventType string) (*C.AtspiAcc
// A table's first selected child is its active child only when the
// selection is unique. Multi-selection changes use their own events.
targetRole := C.atspi_accessible_get_role(target, nil)
listTable := targetRole == C.ATSPI_ROLE_LIST && C.cthulhu_is_gtk_list_table(target) != 0
tableContainer := C.cthulhu_is_table_container(target) != 0
// GTK notebooks report container focus before the active tab when an
// X11 key grab ends. Resolve that container to its selected tab, as
// with menus, so ordinary duplicate-focus handling preserves review.
if targetRole == C.ATSPI_ROLE_MENU || targetRole == C.ATSPI_ROLE_PAGE_TAB_LIST || targetRole == C.ATSPI_ROLE_TABLE || listTable {
if targetRole == C.ATSPI_ROLE_MENU || targetRole == C.ATSPI_ROLE_PAGE_TAB_LIST || tableContainer {
var child *C.AtspiAccessible
if targetRole == C.ATSPI_ROLE_TABLE || listTable {
if tableContainer {
child = C.cthulhu_table_selected_child(target)
} else {
child = C.cthulhu_selected_child(target)
@@ -93,7 +93,7 @@ func selectNativeEventTarget(event *C.AtspiEvent, eventType string) (*C.AtspiAcc
// child list. Tab creation/removal makes that list stale.
C.atspi_accessible_clear_cache_single(event.source)
}
if C.atspi_accessible_get_role(event.source, nil) == C.ATSPI_ROLE_TABLE || C.cthulhu_is_gtk_list_table(event.source) != 0 {
if C.cthulhu_is_table_container(event.source) != 0 {
target = C.cthulhu_table_selected_child(event.source)
} else {
target = C.cthulhu_selected_child(event.source)
@@ -433,6 +433,13 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
adapter.forgetWindowDocuments(originID)
}
invalidated := adapter.applyTreeInvalidation(eventType, event)
returnedMenuItem := adapter.returned_menu_item(event, eventType, originID)
if returnedMenuItem != nil {
defer C.g_object_unref(C.gpointer(returnedMenuItem))
// Keep the original showing event and popup origin in diagnostics.
// Only fresh focus on its parent supplies this normalized focus event.
eventType, invalidated = FocusEvent, false
}
if selection := adapter.window_switcher_event(event, eventType); selection != nil {
adapter.enqueueSecureEvent(*selection, secureEpoch)
return
@@ -500,8 +507,12 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
return
}
snapshotStarted := time.Now()
target := adapter.libreOfficeFocusTarget(event, eventType)
ownedTarget := target != nil
target := returnedMenuItem
ownedTarget := false
if target == nil {
target = adapter.libreOfficeFocusTarget(event, eventType)
ownedTarget = target != nil
}
if target == nil {
target, ownedTarget = selectNativeEventTarget(event, eventType)
}
+49 -3
View File
@@ -157,20 +157,66 @@ gboolean cthulhu_is_gtk_list_table(AtspiAccessible *source) {
return matches;
}
// Qt item views use TREE even for flat, multi-column playlists. Require the
// Table interface before applying row-selection policy to a tree.
gboolean cthulhu_is_table_container(AtspiAccessible *source) {
AtspiRole role = atspi_accessible_get_role(source, NULL);
if (role == ATSPI_ROLE_TABLE) return TRUE;
if (role == ATSPI_ROLE_LIST) return cthulhu_is_gtk_list_table(source);
if (role != ATSPI_ROLE_TREE && role != ATSPI_ROLE_TREE_TABLE) return FALSE;
AtspiTable *table = atspi_accessible_get_table_iface(source);
if (table == NULL) return FALSE;
g_object_unref(table);
return TRUE;
}
// Selection counts cells, so a single GTK row with two columns already has
// two selected children. Query the table's selected rows before treating its
// first selected cell as the focus target.
AtspiAccessible *cthulhu_table_selected_child(AtspiAccessible *source) {
// A menu/dialog can return focus without refreshing the cached child list.
atspi_accessible_clear_cache_single(source);
AtspiTable *table = atspi_accessible_get_table_iface(source);
if (table == NULL) return cthulhu_selected_child(source);
GError *error = NULL;
gint count = atspi_table_get_n_selected_rows(table, &error);
g_object_unref(table);
gboolean ambiguous = error == NULL && count > 1;
if (error != NULL) {
g_error_free(error);
g_clear_error(&error);
}
return ambiguous ? NULL : cthulhu_selected_child(source);
AtspiAccessible *child = ambiguous ? NULL : cthulhu_selected_child(source);
// Qt selects every cell in a row, including an unnamed status column.
// Prefer the provider's focused cell in that same uniquely selected row
// so returning from a menu restores a useful Braille/review target too.
// Keep the existing target when no such evidence is available; never
// search other rows or guess a focus target in a multiple selection.
gint columns = atspi_table_get_n_columns(table, NULL);
if (!ambiguous && count == 1 && columns > 0 && columns <= 32) {
GArray *rows = atspi_table_get_selected_rows(table, NULL);
if (rows != NULL && rows->len == 1) {
gint row = g_array_index(rows, gint, 0);
for (gint column = 0; column < columns; column++) {
AtspiAccessible *item = atspi_table_get_accessible_at(table, row, column, NULL);
if (item == NULL) continue;
atspi_accessible_clear_cache_single(item);
AtspiStateSet *states = atspi_accessible_get_state_set(item);
gboolean focused = states != NULL &&
atspi_state_set_contains(states, ATSPI_STATE_FOCUSED) &&
atspi_state_set_contains(states, ATSPI_STATE_SHOWING) &&
!atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT);
if (states != NULL) g_object_unref(states);
if (focused) {
if (child != NULL) g_object_unref(child);
child = item;
break;
}
g_object_unref(item);
}
}
if (rows != NULL) g_array_unref(rows);
}
g_object_unref(table);
return child;
}
// Resolve the focused descendant at a window-activation boundary. Providers
+27
View File
@@ -10,6 +10,33 @@
static const gchar *menu_checkability_key = "cthulhu-menu-checkability";
enum { CHECKABILITY_INFERRED = 1, CHECKABILITY_EXPLICIT = 2 };
// Qt can hide a submenu on Left/Escape without emitting focus-gained for
// its parent action. Require fresh provider focus and visibility on that
// exact parent; closing an entire menu or switching away grants no focus.
AtspiAccessible *cthulhu_returned_menu_item(AtspiAccessible *menu) {
atspi_accessible_clear_cache_single(menu);
AtspiStateSet *states = atspi_accessible_get_state_set(menu);
gboolean hidden = states != NULL &&
!atspi_state_set_contains(states, ATSPI_STATE_SHOWING) &&
!atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT);
if (states != NULL) g_object_unref(states);
if (!hidden) return NULL;
AtspiAccessible *parent = atspi_accessible_get_parent(menu, NULL);
if (parent == NULL) return NULL;
atspi_accessible_clear_cache_single(parent);
AtspiRole role = atspi_accessible_get_role(parent, NULL);
states = atspi_accessible_get_state_set(parent);
gboolean focused = states != NULL &&
atspi_state_set_contains(states, ATSPI_STATE_FOCUSED) &&
atspi_state_set_contains(states, ATSPI_STATE_SHOWING) &&
!atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT);
if (states != NULL) g_object_unref(states);
if (focused && (role == ATSPI_ROLE_MENU_ITEM || role == ATSPI_ROLE_CHECK_MENU_ITEM ||
role == ATSPI_ROLE_RADIO_MENU_ITEM)) return parent;
g_object_unref(parent);
return NULL;
}
void cthulhu_forget_menu_checkability(AtspiAccessible *source) {
if (source != NULL)
g_object_set_data(G_OBJECT(source), menu_checkability_key, NULL);
@@ -0,0 +1,39 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
/*
#include "adapter_native.h"
*/
import "C"
// A hiding submenu can return focus only from its most recently focused
// direct item. Queries and the returned reference belong to the GLib owner;
// the callback releases the reference after the ordinary foreground gates.
func (adapter *Adapter) returned_menu_item(event *C.AtspiEvent, eventType string, origin ObjectID) *C.AtspiAccessible {
if eventType != ShowingStateEvent || event.detail1 != 0 || len(adapter.nativeFocus) == 0 {
return nil
}
role := C.atspi_accessible_get_role(event.source, nil)
if role != C.ATSPI_ROLE_MENU && role != C.ATSPI_ROLE_POPUP_MENU {
return nil
}
current := adapter.nativeFocus[len(adapter.nativeFocus)-1].source
switch C.atspi_accessible_get_role(current, nil) {
case C.ATSPI_ROLE_MENU_ITEM, C.ATSPI_ROLE_CHECK_MENU_ITEM, C.ATSPI_ROLE_RADIO_MENU_ITEM:
default:
return nil
}
parent := C.atspi_accessible_get_parent(current, nil)
if parent == nil {
return nil
}
match := nativeObjectID(parent) == origin
C.g_object_unref(C.gpointer(parent))
if !match {
return nil
}
return C.cthulhu_returned_menu_item(event.source)
}
+2
View File
@@ -123,6 +123,8 @@ AtspiAccessible *cthulhu_event_active_descendant(AtspiEvent *event);
AtspiAccessible *cthulhu_selected_child(AtspiAccessible *source);
AtspiAccessible *cthulhu_table_selected_child(AtspiAccessible *source);
gboolean cthulhu_is_gtk_list_table(AtspiAccessible *source);
gboolean cthulhu_is_table_container(AtspiAccessible *source);
AtspiAccessible *cthulhu_returned_menu_item(AtspiAccessible *menu);
void cthulhu_refresh_frame_children(AtspiAccessible *window);
AtspiAccessible *cthulhu_focused_descendant(AtspiAccessible *window);
AtspiAccessible *cthulhu_showing_web_document(AtspiAccessible *window);
+1 -1
View File
@@ -19,7 +19,7 @@ xterm-focus-handoff,src/cthulhu/input_event_manager.py,input,Suspend Cthulhu whi
focus-speech,src/cthulhu/scripts/default.py,speech,Speak the focused accessible object including common control states and range values,Storm Dragon,internal/atspi internal/presentation internal/app,implemented,go test ./internal/atspi ./internal/presentation ./internal/app -count=1,Maintainer accepted post-change YAD value and LibreOffice focus value and menu behavior on physical Xorg on 2026-09-12. The combined log had no accessibility-event speech or sound queue overflow; 599 debug diagnostics were dropped during a LibreOffice state-event burst.,,Maintainer confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md.,,Focus presentation includes common states and range values. Focused value changes speak only the value; non-focused state and value events are filtered; menu selection and table-cell verbosity are reduced. Relations position context background progress updates complete role-specific formatting and diagnostics saturation acceptance remain pending. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md. September 25 review: window-owned title tracking suppresses repeated name events and preserves empty-to-restored title changes; event-loop regression passes. Combo selection follow-up: focus/name/value event regressions verify selection-only speech without repeated combo cues and restore the cue on re-entry; focused app/presentation race checks pass. Current general native focus listening is accepted by the maintainer on 2026-10-22.
terminal-caret-character,src/cthulhu/scripts/default.py,terminal,Speak the character at the caret,Storm Dragon,internal/app,implemented,go test ./internal/atspi ./internal/presentation ./internal/app -count=1,Maintainer accepted terminal caret behavior in the launcher's LXTerminal test application on Xorg during the Stage 1 physical checklist on 2026-09-11.,,Maintainer confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md.,,Uses AT-SPI character granularity with line fallback debounces caret movement and suppresses unchanged path-and-announcement combinations; the maintainer confirms thorough native and terminal live testing on 2026-10-22.
terminal-text-change-echo,src/cthulhu/scripts/terminal/script.py,terminal,Speak sanitized inserted or deleted text from terminals and editable text controls,Storm Dragon,internal/app,implemented,go test ./internal/atspi ./internal/presentation ./internal/app -count=1,"Maintainer accepted terminal insertion in LXTerminal on Xorg on 2026-09-11, accepted the post-change LibreOffice Writer typing behavior on physical Xorg on 2026-09-12, and confirmed YAD close-and-prompt coalescing on physical Xorg on 2026-09-13. Physical acceptance of terminal deletion-redraw coalescing is pending.",,Maintainer confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md.,,"Text changes are limited to terminals and editable text or paragraph roles. A change suppresses the next caret event from the same object so typing does not replay the line. Same-terminal insertion within the event-coalescing window suppresses a redundant interrupting terminal-focus presentation. A terminal deletion waits 50 ms and is suppressed only when the same terminal inserts replacement text at the same AT-SPI offset; standalone and different-offset deletions remain presentable. Debug speech delivery records retain the final utterance with cause and policy metadata; normal logging omits speech content. Broader application coverage remains incomplete. Focusable writable browser editors also own their editable paragraph events, preventing rejected insertions from replaying the growing line through caret speech. Private Chromium coverage and live acceptance steps are in docs/testing/browser-editor.md; original-page ChatGPT listening and physical Braille acceptance remain pending."
managed-descendant-presentation,src/cthulhu/scripts/default.py,focus,Resolve focused container selection and active-descendant changes to the managed child,Storm Dragon,internal/atspi internal/app,implemented,go test ./internal/atspi ./internal/app -count=1; python3 tests/native_x11.py; python3 tests/native_atspi.py --table-only,The isolated GTK/Xvfb/D-Bus selection fixture and physical I38 run-dialog completion selection speech passed on 2026-09-12 without regressing X11 pass-through command consumption release ordering lock states lifecycle or grab cleanup.,,Maintainer confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md.,,The generic adapter registers both selection and active-descendant events resolves their managed child preserves the originating container path and filters events unless the container or child owns focus. Duplicate target presentations are suppressed. No I38-specific workaround was added. Broader GTK and Qt managed-descendant coverage remains pending. October 17: GTK controls with list role and Table interface retain selected-cell focus and table ownership without normalizing ordinary lists or choosing among multiple selected rows. Private native focus and recorded-speech tests cover first Down and genuine Shift+Tab exit; activation descriptions are not repeated on first row selection. Current physical listening and Braille acceptance remain pending. See docs/testing/gui-list-tables.md.
managed-descendant-presentation,src/cthulhu/scripts/default.py,focus,Resolve focused container selection and active-descendant changes to the managed child,Storm Dragon,internal/atspi internal/app,implemented,go test ./internal/atspi ./internal/app -count=1; python3 tests/native_x11.py; python3 tests/native_atspi.py --table-only,The isolated GTK/Xvfb/D-Bus selection fixture and physical I38 run-dialog completion selection speech passed on 2026-09-12 without regressing X11 pass-through command consumption release ordering lock states lifecycle or grab cleanup.,,Maintainer confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md.,,The generic adapter registers both selection and active-descendant events resolves their managed child preserves the originating container path and filters events unless the container or child owns focus. Duplicate target presentations are suppressed. No I38-specific workaround was added. Broader GTK and Qt managed-descendant coverage remains pending. October 17: GTK controls with list role and Table interface retain selected-cell focus and table ownership without normalizing ordinary lists or choosing among multiple selected rows. Private native focus and recorded-speech tests cover first Down and genuine Shift+Tab exit; activation descriptions are not repeated on first row selection. Current physical listening and Braille acceptance remain pending. See docs/testing/gui-list-tables.md. November 1: real Audacious Qt and GTK fixtures cover table-capable trees; visible GUI row columns; submenu and playlist focus return; context menus; and Settings navigation. See docs/testing/audacious.md. Audible speech and physical Braille acceptance remain pending.
ssip-speak-cancel,src/cthulhu/speechdispatcherfactory.py,speech,Send speech and cancellation through SSIP,Storm Dragon,internal/speech,in-progress,go test ./internal/speech -count=1,Maintainer accepted audible non-consuming Control/Shift interruption in the Stage 1 physical Xorg checklist on 2026-09-11.,,Maintainer heard lifecycle and terminal speech plus Control/Shift interruption during the accepted Stage 1 physical Xorg checklist on 2026-09-11.,,Protocol replies 230 and 225 were confirmed against the local service; broader backend and X11 coverage remains pending.
web-structural-navigation,src/cthulhu/scripts/web/script.py,web,Move by headings and other document structure,Storm Dragon,internal/input internal/review internal/app,implemented,go test -race ./... -count=1; python3 tests/native_browser.py,Private Brave/Xvfb structural and mode/chrome sequences pass on 2026-09-20.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md.,,Structural navigation follows native caret movement. Echo suppression ends on subsequent input. Failed same-object context capture retries. Delayed document carets cannot override editable focus and stale results cannot overwrite unresolved unrelated focus. All 27 browse symbols pass through after routing is disabled. Automatic structural focus is configurable globally and per application on 2026-10-28; defaults off and preserves sticky modes. Private GTK Apply/reload and Chromium native typing checks pass. Other Python structural settings remain pending. New option live listening and physical Braille acceptance remain pending; see docs/testing/structural-focus.md. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md.
web-document-recapture,src/cthulhu/ax_object.py,web,Recover heading navigation after browser document replacement,Storm Dragon,internal/atspi internal/review internal/app,implemented,go test -race ./... -count=1; python3 tests/native_browser.py,Private Brave/Xvfb passes immediate H after loading a new page and after activating its return link on 2026-09-20.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md.,,Native caches refresh on recapture. Load completion retains the new root and resets browser context and pending reading. Worker page epochs retire queued and in-flight results across reused paths. Native reload and mode/chrome tests remain covered. Installed Go preview and Brave Origin are not verified. On 2026-09-21 semantic invalidation was scoped to captured objects so browser chrome and other applications cannot discard the document. Focused race tests and the full private Brave suite pass; heading/link jumps assert capture reuse. Large-page latency and logged stale-object failures still require physical retesting. September 22 log regressions: native invalid text-end recovery and bounded embedded text expansion; web name-event suppression; background load preserves verified chrome focus. Focused race tests and private Brave proofs pass. See docs/testing/chromium-browser.md for reference behavior and remaining physical gates. September 23 ownership correction rejects loads outside the current application or known window and after owner loss; accepted loads update the focus owner for correct defunct recovery. Five event-loop regressions and app race tests pass; physical acceptance remains pending. September 24 same-geometry window recovery now requires fresh active/showing/non-iconified state before foreground correlation; private recovery regression fails on the earlier preview and passes on the rebuilt one. Address changes outside the owned field are suppressed; popup-associated typing remains supported. September 25 review: recovery now checks the fresh tree when text/geometry recapture discovers a removed focus; deterministic regression and focused race checks pass.
1 id python_source subsystem behavior owner go_destination status automated_evidence x11_evidence wayland_evidence speech_evidence braille_evidence notes
19 focus-speech src/cthulhu/scripts/default.py speech Speak the focused accessible object including common control states and range values Storm Dragon internal/atspi internal/presentation internal/app implemented go test ./internal/atspi ./internal/presentation ./internal/app -count=1 Maintainer accepted post-change YAD value and LibreOffice focus value and menu behavior on physical Xorg on 2026-09-12. The combined log had no accessibility-event speech or sound queue overflow; 599 debug diagnostics were dropped during a LibreOffice state-event burst. Maintainer confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md. Focus presentation includes common states and range values. Focused value changes speak only the value; non-focused state and value events are filtered; menu selection and table-cell verbosity are reduced. Relations position context background progress updates complete role-specific formatting and diagnostics saturation acceptance remain pending. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md. September 25 review: window-owned title tracking suppresses repeated name events and preserves empty-to-restored title changes; event-loop regression passes. Combo selection follow-up: focus/name/value event regressions verify selection-only speech without repeated combo cues and restore the cue on re-entry; focused app/presentation race checks pass. Current general native focus listening is accepted by the maintainer on 2026-10-22.
20 terminal-caret-character src/cthulhu/scripts/default.py terminal Speak the character at the caret Storm Dragon internal/app implemented go test ./internal/atspi ./internal/presentation ./internal/app -count=1 Maintainer accepted terminal caret behavior in the launcher's LXTerminal test application on Xorg during the Stage 1 physical checklist on 2026-09-11. Maintainer confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md. Uses AT-SPI character granularity with line fallback debounces caret movement and suppresses unchanged path-and-announcement combinations; the maintainer confirms thorough native and terminal live testing on 2026-10-22.
21 terminal-text-change-echo src/cthulhu/scripts/terminal/script.py terminal Speak sanitized inserted or deleted text from terminals and editable text controls Storm Dragon internal/app implemented go test ./internal/atspi ./internal/presentation ./internal/app -count=1 Maintainer accepted terminal insertion in LXTerminal on Xorg on 2026-09-11, accepted the post-change LibreOffice Writer typing behavior on physical Xorg on 2026-09-12, and confirmed YAD close-and-prompt coalescing on physical Xorg on 2026-09-13. Physical acceptance of terminal deletion-redraw coalescing is pending. Maintainer confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md. Text changes are limited to terminals and editable text or paragraph roles. A change suppresses the next caret event from the same object so typing does not replay the line. Same-terminal insertion within the event-coalescing window suppresses a redundant interrupting terminal-focus presentation. A terminal deletion waits 50 ms and is suppressed only when the same terminal inserts replacement text at the same AT-SPI offset; standalone and different-offset deletions remain presentable. Debug speech delivery records retain the final utterance with cause and policy metadata; normal logging omits speech content. Broader application coverage remains incomplete. Focusable writable browser editors also own their editable paragraph events, preventing rejected insertions from replaying the growing line through caret speech. Private Chromium coverage and live acceptance steps are in docs/testing/browser-editor.md; original-page ChatGPT listening and physical Braille acceptance remain pending.
22 managed-descendant-presentation src/cthulhu/scripts/default.py focus Resolve focused container selection and active-descendant changes to the managed child Storm Dragon internal/atspi internal/app implemented go test ./internal/atspi ./internal/app -count=1; python3 tests/native_x11.py; python3 tests/native_atspi.py --table-only The isolated GTK/Xvfb/D-Bus selection fixture and physical I38 run-dialog completion selection speech passed on 2026-09-12 without regressing X11 pass-through command consumption release ordering lock states lifecycle or grab cleanup. Maintainer confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md. The generic adapter registers both selection and active-descendant events resolves their managed child preserves the originating container path and filters events unless the container or child owns focus. Duplicate target presentations are suppressed. No I38-specific workaround was added. Broader GTK and Qt managed-descendant coverage remains pending. October 17: GTK controls with list role and Table interface retain selected-cell focus and table ownership without normalizing ordinary lists or choosing among multiple selected rows. Private native focus and recorded-speech tests cover first Down and genuine Shift+Tab exit; activation descriptions are not repeated on first row selection. Current physical listening and Braille acceptance remain pending. See docs/testing/gui-list-tables.md. The generic adapter registers both selection and active-descendant events resolves their managed child preserves the originating container path and filters events unless the container or child owns focus. Duplicate target presentations are suppressed. No I38-specific workaround was added. Broader GTK and Qt managed-descendant coverage remains pending. October 17: GTK controls with list role and Table interface retain selected-cell focus and table ownership without normalizing ordinary lists or choosing among multiple selected rows. Private native focus and recorded-speech tests cover first Down and genuine Shift+Tab exit; activation descriptions are not repeated on first row selection. Current physical listening and Braille acceptance remain pending. See docs/testing/gui-list-tables.md. November 1: real Audacious Qt and GTK fixtures cover table-capable trees; visible GUI row columns; submenu and playlist focus return; context menus; and Settings navigation. See docs/testing/audacious.md. Audible speech and physical Braille acceptance remain pending.
23 ssip-speak-cancel src/cthulhu/speechdispatcherfactory.py speech Send speech and cancellation through SSIP Storm Dragon internal/speech in-progress go test ./internal/speech -count=1 Maintainer accepted audible non-consuming Control/Shift interruption in the Stage 1 physical Xorg checklist on 2026-09-11. Maintainer heard lifecycle and terminal speech plus Control/Shift interruption during the accepted Stage 1 physical Xorg checklist on 2026-09-11. Protocol replies 230 and 225 were confirmed against the local service; broader backend and X11 coverage remains pending.
24 web-structural-navigation src/cthulhu/scripts/web/script.py web Move by headings and other document structure Storm Dragon internal/input internal/review internal/app implemented go test -race ./... -count=1; python3 tests/native_browser.py Private Brave/Xvfb structural and mode/chrome sequences pass on 2026-09-20. Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md. Structural navigation follows native caret movement. Echo suppression ends on subsequent input. Failed same-object context capture retries. Delayed document carets cannot override editable focus and stale results cannot overwrite unresolved unrelated focus. All 27 browse symbols pass through after routing is disabled. Automatic structural focus is configurable globally and per application on 2026-10-28; defaults off and preserves sticky modes. Private GTK Apply/reload and Chromium native typing checks pass. Other Python structural settings remain pending. New option live listening and physical Braille acceptance remain pending; see docs/testing/structural-focus.md. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md.
25 web-document-recapture src/cthulhu/ax_object.py web Recover heading navigation after browser document replacement Storm Dragon internal/atspi internal/review internal/app implemented go test -race ./... -count=1; python3 tests/native_browser.py Private Brave/Xvfb passes immediate H after loading a new page and after activating its return link on 2026-09-20. Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md. Native caches refresh on recapture. Load completion retains the new root and resets browser context and pending reading. Worker page epochs retire queued and in-flight results across reused paths. Native reload and mode/chrome tests remain covered. Installed Go preview and Brave Origin are not verified. On 2026-09-21 semantic invalidation was scoped to captured objects so browser chrome and other applications cannot discard the document. Focused race tests and the full private Brave suite pass; heading/link jumps assert capture reuse. Large-page latency and logged stale-object failures still require physical retesting. September 22 log regressions: native invalid text-end recovery and bounded embedded text expansion; web name-event suppression; background load preserves verified chrome focus. Focused race tests and private Brave proofs pass. See docs/testing/chromium-browser.md for reference behavior and remaining physical gates. September 23 ownership correction rejects loads outside the current application or known window and after owner loss; accepted loads update the focus owner for correct defunct recovery. Five event-loop regressions and app race tests pass; physical acceptance remains pending. September 24 same-geometry window recovery now requires fresh active/showing/non-iconified state before foreground correlation; private recovery regression fails on the earlier preview and passes on the rebuilt one. Address changes outside the owned field are suppressed; popup-associated typing remains supported. September 25 review: recovery now checks the fresh tree when text/geometry recapture discovers a removed focus; deterministic regression and focused race checks pass.
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Storm Dragon
# Authors: Storm Dragon, Angus Kola
# SPDX-License-Identifier: GPL-3.0-or-later
"""Exercise real Audacious GUIs without the desktop player's session or files."""
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import wave
sys.dont_write_bytecode = True
from native_test_session import run_private_session
from native_x11 import wait_for
def run_inside():
root = Path(__file__).resolve().parents[1]
temporary = Path(os.environ['XDG_CONFIG_HOME'])
configPath = temporary / 'i3.conf'
configPath.write_text('font pango:monospace 10\nfocus_follows_mouse no\nipc-socket ' +
str(temporary / 'i3.sock') + '\n', encoding='utf-8')
manager = subprocess.Popen(['i3', '-c', str(configPath)], stdout=subprocess.DEVNULL)
process = None
try:
wait_for(lambda: subprocess.run(['i3-msg', '-t', 'get_version'],
capture_output=True).returncode == 0, 'private i3 missing')
tracks = []
trackDirectory = temporary / 'Example Artist' / 'Example Album'
trackDirectory.mkdir(parents=True)
for name in ('Alpha', 'Beta', 'Gamma'):
path = trackDirectory / (name + '.wav')
with wave.open(str(path), 'wb') as track:
track.setparams((1, 2, 8000, 0, 'NONE', 'not compressed'))
track.writeframes(b'\0\0' * 8000)
tracks.append(str(path))
process = subprocess.Popen(['audacious', '-2', os.environ['CTHULHU_NATIVE_AUDACIOUS'], '-e', *tracks])
def find_window():
result = subprocess.run(['xdotool', 'search', '--onlyvisible', '--name', '^Audacious \\(2\\)$'],
capture_output=True, text=True)
return result.stdout.strip().splitlines()[0] if result.stdout.strip() else None
wait_for(find_window, 'Audacious window missing')
import pyatspi
def find_playlist(node):
if node.getRoleName() in ('tree', 'table'):
table = node.queryTable()
if table.nRows == 3:
return table
for child in node:
result = find_playlist(child)
if result is not None:
return result
return None
playlist = wait_for(lambda: find_playlist(pyatspi.Registry.getDesktop(0)),
'Audacious playlist missing')
assert playlist.addRowSelection(0), 'could not select first fixture row'
if os.environ['CTHULHU_NATIVE_AUDACIOUS'] == '-G':
assert playlist.getAccessibleAt(0, 0).queryComponent().grabFocus(), 'could not focus first fixture cell'
# Selecting through AT-SPI does not initialize Audacious's GTK
# keyboard cursor. The first Down establishes its first row.
subprocess.run(['xdotool', 'key', '--clearmodifiers', 'Down'], check=True)
wait_for(lambda: playlist.getSelectedRows() == [0], 'GTK cursor did not select first row')
subprocess.run(['go', 'test', './internal/app', '-run', '^TestNativeAudacious$',
'-count=1', '-v', '-timeout=90s'], cwd=root, check=True, timeout=120)
finally:
if process is not None:
process.terminate()
process.wait(timeout=5)
manager.terminate()
manager.wait(timeout=5)
if __name__ == '__main__':
if '--diagnose' in sys.argv:
import pyatspi
def describe(node, depth=0):
if depth > 8:
return
if node.getRoleName() in ('tree', 'table', 'frame', 'table cell'):
print(node.getRoleName(), repr(node.name), node.getState().getStates(), flush=True)
if node.getRoleName() in ('tree', 'table'):
table = node.queryTable()
print('ROWS', table.getSelectedRows(), flush=True)
for row in range(table.nRows):
cell = table.getAccessibleAt(row, 0)
print('CELL', repr(cell.name), cell.getState().getStates(), flush=True)
for child in node:
describe(child, depth+1)
for app in pyatspi.Registry.getDesktop(0):
if app.name.lower() == 'audacious':
describe(app)
elif '--inside' in sys.argv:
run_inside()
else:
moduleCache = subprocess.check_output(['go', 'env', 'GOMODCACHE'], text=True).strip()
for mode in ('-Q', '-G'):
with tempfile.TemporaryDirectory(prefix='cthulhu-audacious-') as temporary:
environment = dict(os.environ, XDG_CONFIG_HOME=temporary,
XDG_CACHE_HOME=temporary, XDG_DATA_HOME=temporary, XDG_STATE_HOME=temporary,
GOMODCACHE=moduleCache, GOCACHE='/tmp/cthulhu-go-cache',
GOPROXY='off', QT_QPA_PLATFORM='xcb',
QT_LINUX_ACCESSIBILITY_ALWAYS_ON='1', NO_AT_BRIDGE='0',
CTHULHU_NATIVE_AUDACIOUS=mode)
environment['I3SOCK'] = str(Path(temporary) / 'i3.sock')
run_private_session(['xvfb-run', '-a', 'dbus-run-session', '--',
sys.executable, __file__, '--inside'], env=environment, check=True)
+2
View File
@@ -58,6 +58,8 @@ def fixture(version):
option = menu.addAction('Optional feature')
option.setCheckable(True)
ordinary = menu.addAction('Ordinary action')
nestedMenu = menu.addMenu('Nested actions')
nestedMenu.addAction('Nested command')
contextMenu = QtWidgets.QMenu(window)
contextOption = contextMenu.addAction('Context option')
contextOption.setCheckable(True)