Fix GTK 4 foreground matching and menu labels
This commit is contained in:
1 parent
d0c977281d
commit
49eeae89f4
8 files changed
+254
-9
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user