Fix GTK 4 foreground matching and menu labels

This commit is contained in:
Chase Cichorz authored and Angus Kola committed 2026-11-07 13:40:23 -05:00
1 parent d0c977281d
commit 49eeae89f4
8 files changed
+254 -9

No files matched your search

+10 -5
View File
@@ -58,9 +58,11 @@ gint cthulhu_document_action_priority(AtspiAccessible *source, const gchar *name
return 0;
}
// Some toolkits expose an edit field's label only as a relation, leaving its
// accessible name empty. Follow direct targets only, with a shared query and
// output budget; never infer a label from neighbouring controls or field text.
// Some toolkits expose an edit field's or menu item's label only as a
// relation, leaving its accessible name empty. GTK 4 popover menu items point
// at an internal label whose role is invalid, so read that target's text too.
// Follow direct targets only, with a shared query and output budget; never
// infer a label from neighbouring controls or field text.
static gchar *cthulhu_text_control_label(AtspiAccessible *source) {
GArray *relations = atspi_accessible_get_relation_set(source, NULL);
if (relations == NULL) return NULL;
@@ -79,7 +81,8 @@ static gchar *cthulhu_text_control_label(AtspiAccessible *source) {
if (role != ATSPI_ROLE_PASSWORD_TEXT) {
text = atspi_accessible_get_name(target, NULL);
if ((text == NULL || *text == '\0') &&
(role == ATSPI_ROLE_LABEL || role == ATSPI_ROLE_STATIC)) {
(role == ATSPI_ROLE_LABEL || role == ATSPI_ROLE_STATIC ||
role == ATSPI_ROLE_INVALID)) {
g_free(text);
AtspiText *labelText = atspi_accessible_get_text(target);
if (labelText != NULL) {
@@ -504,7 +507,9 @@ cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean capt
}
if (!snapshot.defunct && (snapshot.name == NULL || *snapshot.name == '\0') &&
(g_strcmp0(snapshot.role, "text") == 0 || g_strcmp0(snapshot.role, "entry") == 0 ||
g_strcmp0(snapshot.role, "password text") == 0)) {
g_strcmp0(snapshot.role, "password text") == 0 || g_strcmp0(snapshot.role, "menu item") == 0 ||
g_strcmp0(snapshot.role, "check menu item") == 0 ||
g_strcmp0(snapshot.role, "radio menu item") == 0)) {
g_free(snapshot.name);
snapshot.name = cthulhu_text_control_label(source);
}
@@ -70,6 +70,8 @@ func TestNativeEditFieldLabels(t *testing.T) {
{"named", "Explicit name"},
{"unlabelled", ""},
{"protected", "Password"},
{"invalid-label", "Fallback label"},
{"password-target", ""},
} {
t.Run(test.id, func(t *testing.T) {
snapshot, ok := fields[test.id]
+11 -1
View File
@@ -49,7 +49,17 @@ func foregroundWindowMatches(client xorg.Foreground, pid int, bounds Rectangle)
frame := Rectangle{X: int32(client.FrameX), Y: int32(client.FrameY), Width: int32(client.FrameWidth), Height: int32(client.FrameHeight)}
return bounds == inner || bounds == outer ||
frame.Valid() && bounds == frame ||
scaledWindowMatches(bounds, inner) || scaledWindowMatches(bounds, outer) || scaledWindowMatches(bounds, frame)
scaledWindowMatches(bounds, inner) || scaledWindowMatches(bounds, outer) || scaledWindowMatches(bounds, frame) ||
unpositionedWindowMatches(client, pid, bounds, inner)
}
// Some GTK 4 X11 frames report screen extents with a 0,0 origin wherever
// the window really is. Accept such a frame only when both sides name the
// same process and the size equals the X11 client
// exactly; a dialog or sibling of a different size still cannot claim it.
func unpositionedWindowMatches(client xorg.Foreground, pid int, bounds, inner Rectangle) bool {
return client.PID > 0 && pid == client.PID && bounds.X == 0 && bounds.Y == 0 &&
bounds.Width == inner.Width && bounds.Height == inner.Height
}
// Some Chromium windows report AT-SPI extents in logical pixels while X11
+30
View File
@@ -36,6 +36,36 @@ func TestForegroundWindowCorrelation(t *testing.T) {
}
}
func TestForegroundUnpositionedGTK4Correlation(t *testing.T) {
// GTK 4 on X11 (seen on i3) reports its frame at 0,0 with the client's size.
client := xorg.Foreground{Known: true, Window: 35651592, PID: 215379,
X: 700, Y: 449, Width: 520, Height: 322, OuterX: 698, OuterY: 427, OuterWidth: 524, OuterHeight: 346,
FrameX: 698, FrameY: 427, FrameWidth: 524, FrameHeight: 346}
for _, test := range []struct {
name string
client xorg.Foreground
pid int
bounds Rectangle
want bool
}{
{"gtk4 frame at origin", client, 215379, Rectangle{0, 0, 520, 322}, true},
{"other process", client, 215380, Rectangle{0, 0, 520, 322}, false},
{"unknown accessible process", client, 0, Rectangle{0, 0, 520, 322}, false},
{"unknown X11 process", xorg.Foreground{Known: true, Window: 35651592,
X: 700, Y: 449, Width: 520, Height: 322}, 215379, Rectangle{0, 0, 520, 322}, false},
{"same process different size", client, 215379, Rectangle{0, 0, 400, 300}, false},
{"same process scaled size", client, 215379, Rectangle{0, 0, 260, 161}, false},
{"same process outer size at origin", client, 215379, Rectangle{0, 0, 524, 346}, false},
{"same size elsewhere", client, 215379, Rectangle{10, 0, 520, 322}, false},
} {
t.Run(test.name, func(t *testing.T) {
if got := foregroundWindowMatches(test.client, test.pid, test.bounds); got != test.want {
t.Fatalf("match = %v, want %v", got, test.want)
}
})
}
}
func TestForegroundWindowCorrelationSteamBigPictureScale(t *testing.T) {
client := xorg.Foreground{Known: true, Window: 83886130, PID: 111, X: 0, Y: 0, Width: 1920, Height: 1080,
OuterX: 0, OuterY: 0, OuterWidth: 1920, OuterHeight: 1080}
@@ -0,0 +1,85 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi_test
import (
"context"
"os"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/presentation"
)
// GTK 4 popover menu items leave Name empty and point LABELLED_BY at an
// internal label whose role is invalid; only its text holds the item's label.
func TestNativeGTK4MenuItemLabels(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_GTK4_MENU_LABELS") != "1" {
t.Skip("requires isolated GTK 4 menu label fixture")
}
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
adapter := atspi.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.Error(err)
}
}()
tree, err := adapter.CaptureDesktop(ctx)
if err != nil {
t.Fatal(err)
}
root, err := tree.Root()
if err != nil {
t.Fatal(err)
}
var items []atspi.Snapshot
var walk func(atspi.ObjectReference)
walk = func(ref atspi.ObjectReference) {
snapshot, err := tree.Resolve(ref)
if err != nil {
t.Fatal(err)
}
if snapshot.Role == "menu item" || snapshot.Role == "check menu item" || snapshot.Role == "radio menu item" {
items = append(items, snapshot)
}
child, ok, err := tree.FirstChild(ref)
if err != nil {
t.Fatal(err)
}
for ok {
walk(child)
child, ok, err = tree.NextSibling(child)
if err != nil {
t.Fatal(err)
}
}
}
walk(root)
for _, want := range []string{"New Message", "Add Account…", "Group into Conversations", "All Messages"} {
t.Run(want, func(t *testing.T) {
for _, item := range items {
if item.Name == want {
if spoken := presentation.FocusText(item); !strings.Contains(spoken, want) {
t.Errorf("speech %q omits label %q", spoken, want)
}
return
}
}
names := make([]string, 0, len(items))
for _, item := range items {
names = append(names, item.Role+": "+item.Name)
}
t.Errorf("no menu item named %q; captured %q", want, names)
})
}
}