Announce the initial row in unselected GTK menus
This commit is contained in:
1 parent
090ac9dbeb
commit
ae53f23c63
6 files changed
+190
-3
No files matched your search
@@ -448,3 +448,63 @@ func TestTerminalTextChangeSuppressesPromptReplayButPreservesNavigation(t *testi
|
||||
cancel()
|
||||
<-completed
|
||||
}
|
||||
|
||||
// Replay YAD's stdin-fed menu sequence: initial unselected row, stale active
|
||||
// descendant for that row on Down, then the actual new selection.
|
||||
func TestListPromptInitialRowAndFirstArrow(t *testing.T) {
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 16), requests: make(chan speechRequest, 16)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 16)}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
completed := make(chan struct{})
|
||||
go func() {
|
||||
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
|
||||
close(completed)
|
||||
}()
|
||||
window := atspi.NewObjectID(":1.9", "/menu")
|
||||
list := atspi.NewSnapshot("/menu/list", "", "tree table", "", "")
|
||||
list.BusName = window.BusName
|
||||
activation := atspi.NewEvent(atspi.WindowActivatedEvent, list)
|
||||
activation.Window, activation.WindowTitle, activation.WindowMessage = window, "Action menu", "Please select an action"
|
||||
adapter.events <- activation
|
||||
cell := atspi.NewSnapshot("/menu/play", "Play", "table cell", "", "")
|
||||
cell.BusName, cell.TablePath = window.BusName, list.Path
|
||||
cell.GUITable, cell.TablePositionKnown = true, true
|
||||
cell.TableRowText = "Play"
|
||||
focus := atspi.NewEvent(atspi.FocusedStateEvent, cell)
|
||||
focus.Window, focus.Detail1 = window, 1
|
||||
adapter.events <- focus
|
||||
expect := func(text string, appendSpeech bool) {
|
||||
t.Helper()
|
||||
select {
|
||||
case request := <-speech.requests:
|
||||
if request.Text != text || request.Append != appendSpeech {
|
||||
t.Fatalf("got %q append=%v; want %q append=%v", request.Text, request.Append, text, appendSpeech)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("missing %q", text)
|
||||
}
|
||||
}
|
||||
expect("Action menu, Please select an action", false)
|
||||
expect("Play", true)
|
||||
adapter.events <- focus // Duplicate container focus must not repeat Play.
|
||||
adapter.events <- atspi.NewManagedDescendantEvent(atspi.ActiveDescendantEvent, list.Path, cell)
|
||||
second := cell
|
||||
second.Path, second.Name, second.TableRowText = "/menu/install", "Install", "Install"
|
||||
second.TableRowIndex, second.Selected, second.Focused = 1, true, true
|
||||
adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, second)
|
||||
expect("Install", false)
|
||||
adapter.events <- atspi.NewManagedDescendantEvent(atspi.ActiveDescendantEvent, list.Path, second)
|
||||
cell.Selected, cell.Focused = true, true
|
||||
adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, cell)
|
||||
expect("Play", false)
|
||||
cancel()
|
||||
select {
|
||||
case <-completed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("event loop did not stop")
|
||||
}
|
||||
if len(speech.requests) != 0 {
|
||||
t.Fatal("unexpected duplicate menu speech")
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,8 @@ func selectNativeEventTarget(event *C.AtspiEvent, eventType string) (*C.AtspiAcc
|
||||
ownedTarget = true
|
||||
}
|
||||
// A table's first selected child is its active child only when the
|
||||
// selection is unique. Multi-selection changes use their own events.
|
||||
// selection is unique. An unselected single-column GTK list can
|
||||
// present its initial row without changing selection.
|
||||
targetRole := C.atspi_accessible_get_role(target, nil)
|
||||
tableContainer := C.cthulhu_is_table_container(target) != 0
|
||||
// GTK notebooks report container focus before the active tab when an
|
||||
@@ -59,7 +60,7 @@ func selectNativeEventTarget(event *C.AtspiEvent, eventType string) (*C.AtspiAcc
|
||||
if targetRole == C.ATSPI_ROLE_MENU || targetRole == C.ATSPI_ROLE_PAGE_TAB_LIST || tableContainer {
|
||||
var child *C.AtspiAccessible
|
||||
if tableContainer {
|
||||
child = C.cthulhu_table_selected_child(target)
|
||||
child = C.cthulhu_table_focus_child(target)
|
||||
} else {
|
||||
child = C.cthulhu_selected_child(target)
|
||||
}
|
||||
|
||||
@@ -219,6 +219,44 @@ AtspiAccessible *cthulhu_table_selected_child(AtspiAccessible *source) {
|
||||
return child;
|
||||
}
|
||||
|
||||
// GTK lists populated through stdin can have no selected row at focus time.
|
||||
// Their first navigation starts at row zero. Present that initial row without
|
||||
// selecting it or moving the application's cursor. Selection-change queries
|
||||
// must continue to return only actual selections.
|
||||
AtspiAccessible *cthulhu_table_focus_child(AtspiAccessible *source) {
|
||||
AtspiAccessible *child = cthulhu_table_selected_child(source);
|
||||
if (child != NULL) return child;
|
||||
gchar *toolkit = atspi_accessible_get_toolkit_name(source, NULL);
|
||||
gboolean gtk = g_ascii_strcasecmp(toolkit != NULL ? toolkit : "", "gtk") == 0;
|
||||
g_free(toolkit);
|
||||
if (!gtk) return NULL;
|
||||
AtspiStateSet *states = atspi_accessible_get_state_set(source);
|
||||
gboolean initial = states != NULL &&
|
||||
atspi_state_set_contains(states, ATSPI_STATE_FOCUSED) &&
|
||||
!atspi_state_set_contains(states, ATSPI_STATE_MULTISELECTABLE);
|
||||
if (states != NULL) g_object_unref(states);
|
||||
if (!initial) return NULL;
|
||||
AtspiTable *table = atspi_accessible_get_table_iface(source);
|
||||
if (table == NULL) return NULL;
|
||||
GError *error = NULL;
|
||||
gint selected = atspi_table_get_n_selected_rows(table, &error);
|
||||
if (error == NULL && selected == 0 && atspi_table_get_n_rows(table, NULL) > 0 &&
|
||||
atspi_table_get_n_columns(table, NULL) == 1) {
|
||||
child = atspi_table_get_accessible_at(table, 0, 0, NULL);
|
||||
if (child != NULL) {
|
||||
states = atspi_accessible_get_state_set(child);
|
||||
gboolean showing = 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 (!showing) { g_object_unref(child); child = NULL; }
|
||||
}
|
||||
}
|
||||
g_clear_error(&error);
|
||||
g_object_unref(table);
|
||||
return child;
|
||||
}
|
||||
|
||||
// Resolve the focused descendant at a window-activation boundary. Providers
|
||||
// such as Steam's Chromium UI can activate a frame without emitting a usable
|
||||
// object-focus event. Keep the synchronous callback work bounded so a broken
|
||||
|
||||
@@ -127,6 +127,7 @@ cthulhu_live_properties cthulhu_capture_live_properties(AtspiAccessible *source)
|
||||
AtspiAccessible *cthulhu_event_active_descendant(AtspiEvent *event);
|
||||
AtspiAccessible *cthulhu_selected_child(AtspiAccessible *source);
|
||||
AtspiAccessible *cthulhu_table_selected_child(AtspiAccessible *source);
|
||||
AtspiAccessible *cthulhu_table_focus_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);
|
||||
|
||||
@@ -311,3 +311,90 @@ func TestNativeGTKRadioGroupName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeYADListInitialRow(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_NATIVE_YAD") != "1" {
|
||||
t.Skip("requires private Xvfb/AT-SPI session")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
adapter := New()
|
||||
if err := adapter.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer stopCancel()
|
||||
if err := adapter.Stop(stopCtx); err != nil {
|
||||
t.Errorf("stop adapter: %v", err)
|
||||
}
|
||||
}()
|
||||
command := exec.CommandContext(ctx, "yad", "--list", "--title=Initial menu probe",
|
||||
"--text=Please select an action", "--column=Option", "--no-headers",
|
||||
"--selectable-labels", "--search-column=1", "--height=400", "--width=600")
|
||||
command.Stdin = strings.NewReader("Play\nInstall\n")
|
||||
if err := command.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_ = command.Process.Kill()
|
||||
_ = command.Wait()
|
||||
}()
|
||||
var yadWindow string
|
||||
for yadWindow == "" && ctx.Err() == nil {
|
||||
output, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "^Initial menu probe$").Output()
|
||||
if err == nil {
|
||||
yadWindow = strings.TrimSpace(string(output))
|
||||
}
|
||||
if yadWindow == "" {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
if yadWindow == "" {
|
||||
t.Fatal("YAD list window did not appear")
|
||||
}
|
||||
if output, err := exec.CommandContext(ctx, "xdotool", "windowfocus", strings.Split(yadWindow, "\n")[0]).CombinedOutput(); err != nil {
|
||||
t.Fatalf("focus YAD list window: %v: %s", err, output)
|
||||
}
|
||||
stage := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("menu event missing at navigation stage %d", stage)
|
||||
case event := <-adapter.Events():
|
||||
if event.Source.ApplicationName != "yad" {
|
||||
continue
|
||||
}
|
||||
if stage == 0 {
|
||||
if (event.Type != FocusEvent && event.Type != FocusedStateEvent) || event.Source.Role != "table cell" || event.Source.Name != "Play" {
|
||||
continue
|
||||
}
|
||||
if event.Source.Selected || !event.Source.GUITable || event.Source.TablePath == "" {
|
||||
t.Fatalf("initial row must retain table ownership without selecting it: %#v", event.Source)
|
||||
}
|
||||
} else {
|
||||
want := "Install"
|
||||
if stage == 2 {
|
||||
want = "Play"
|
||||
}
|
||||
if event.Type != SelectionChangedEvent || event.Source.Name != want {
|
||||
continue
|
||||
}
|
||||
if !event.Source.Selected {
|
||||
t.Fatalf("navigation did not select %s", want)
|
||||
}
|
||||
if stage == 2 {
|
||||
return
|
||||
}
|
||||
}
|
||||
key := "Down"
|
||||
if stage == 1 {
|
||||
key = "Up"
|
||||
}
|
||||
if output, err := exec.CommandContext(ctx, "xdotool", "key", key).CombinedOutput(); err != nil {
|
||||
t.Fatalf("navigate: %v: %s", err, output)
|
||||
}
|
||||
stage++
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user