Merge branch 'testing' of https://git.stormux.org/storm/cthulhu into testing
This commit is contained in:
commit
c925344c41
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some toolkits expose an edit field's label only as a relation, leaving its
|
// Some toolkits expose an edit field's or menu item's label only as a
|
||||||
// accessible name empty. Follow direct targets only, with a shared query and
|
// relation, leaving its accessible name empty. GTK 4 popover menu items point
|
||||||
// output budget; never infer a label from neighbouring controls or field text.
|
// 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) {
|
static gchar *cthulhu_text_control_label(AtspiAccessible *source) {
|
||||||
GArray *relations = atspi_accessible_get_relation_set(source, NULL);
|
GArray *relations = atspi_accessible_get_relation_set(source, NULL);
|
||||||
if (relations == NULL) return NULL;
|
if (relations == NULL) return NULL;
|
||||||
@@ -79,7 +81,8 @@ static gchar *cthulhu_text_control_label(AtspiAccessible *source) {
|
|||||||
if (role != ATSPI_ROLE_PASSWORD_TEXT) {
|
if (role != ATSPI_ROLE_PASSWORD_TEXT) {
|
||||||
text = atspi_accessible_get_name(target, NULL);
|
text = atspi_accessible_get_name(target, NULL);
|
||||||
if ((text == NULL || *text == '\0') &&
|
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);
|
g_free(text);
|
||||||
AtspiText *labelText = atspi_accessible_get_text(target);
|
AtspiText *labelText = atspi_accessible_get_text(target);
|
||||||
if (labelText != NULL) {
|
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') &&
|
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, "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);
|
g_free(snapshot.name);
|
||||||
snapshot.name = cthulhu_text_control_label(source);
|
snapshot.name = cthulhu_text_control_label(source);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ func TestNativeEditFieldLabels(t *testing.T) {
|
|||||||
{"named", "Explicit name"},
|
{"named", "Explicit name"},
|
||||||
{"unlabelled", ""},
|
{"unlabelled", ""},
|
||||||
{"protected", "Password"},
|
{"protected", "Password"},
|
||||||
|
{"invalid-label", "Fallback label"},
|
||||||
|
{"password-target", ""},
|
||||||
} {
|
} {
|
||||||
t.Run(test.id, func(t *testing.T) {
|
t.Run(test.id, func(t *testing.T) {
|
||||||
snapshot, ok := fields[test.id]
|
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)}
|
frame := Rectangle{X: int32(client.FrameX), Y: int32(client.FrameY), Width: int32(client.FrameWidth), Height: int32(client.FrameHeight)}
|
||||||
return bounds == inner || bounds == outer ||
|
return bounds == inner || bounds == outer ||
|
||||||
frame.Valid() && bounds == frame ||
|
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
|
// 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) {
|
func TestForegroundWindowCorrelationSteamBigPictureScale(t *testing.T) {
|
||||||
client := xorg.Foreground{Known: true, Window: 83886130, PID: 111, X: 0, Y: 0, Width: 1920, Height: 1080,
|
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}
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Copyright (c) 2026 Storm Dragon
|
||||||
|
# Authors: Storm Dragon, Angus Kola
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
"""A GTK 4 popover menu, whose items expose their label only through a relation."""
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gio, GLib, Gtk
|
||||||
|
|
||||||
|
|
||||||
|
def activate(app):
|
||||||
|
window = Gtk.ApplicationWindow(application=app, title="GTK 4 menu label probe")
|
||||||
|
for name in ("compose", "add-account", "grouping"):
|
||||||
|
action = (Gio.SimpleAction.new_stateful(name, None, GLib.Variant.new_boolean(True))
|
||||||
|
if name == "grouping" else Gio.SimpleAction.new(name, None))
|
||||||
|
window.add_action(action)
|
||||||
|
app.set_accels_for_action("win.compose", ["<Control>n"])
|
||||||
|
window.add_action(Gio.SimpleAction.new_stateful("filter", GLib.VariantType.new("s"),
|
||||||
|
GLib.Variant.new_string("all")))
|
||||||
|
menu = Gio.Menu()
|
||||||
|
menu.append("_New Message", "win.compose")
|
||||||
|
menu.append("_Add Account…", "win.add-account")
|
||||||
|
menu.append("Group into _Conversations", "win.grouping")
|
||||||
|
menu.append("_All Messages", "win.filter::all")
|
||||||
|
button = Gtk.MenuButton(label="File", menu_model=menu)
|
||||||
|
window.set_child(button)
|
||||||
|
window.present()
|
||||||
|
GLib.timeout_add(300, lambda: (button.popup(), False)[1])
|
||||||
|
|
||||||
|
|
||||||
|
app = Gtk.Application(application_id="org.stormux.CthulhuGtk4MenuProbe")
|
||||||
|
app.connect("activate", activate)
|
||||||
|
app.run(None)
|
||||||
@@ -31,5 +31,29 @@ field("multiple", ["Account", "identifier"])
|
|||||||
field("named", ["Other label"], name="Explicit name")
|
field("named", ["Other label"], name="Explicit name")
|
||||||
field("unlabelled", [])
|
field("unlabelled", [])
|
||||||
field("protected", ["Password"], password=True)
|
field("protected", ["Password"], password=True)
|
||||||
|
|
||||||
|
# Exercise the role-invalid Text fallback used by GTK 4, and ensure that a
|
||||||
|
# password relation target cannot contribute either its name or its contents.
|
||||||
|
invalid_label = Gtk.Label(label="Fallback label")
|
||||||
|
invalid_label.get_accessible().set_role(Atk.Role.INVALID)
|
||||||
|
invalid_label.get_accessible().set_name("")
|
||||||
|
box.pack_start(invalid_label, False, False, 0)
|
||||||
|
invalid_entry = Gtk.Entry()
|
||||||
|
invalid_entry.get_accessible().set_name("")
|
||||||
|
invalid_entry.get_accessible().set_description("invalid-label")
|
||||||
|
invalid_entry.get_accessible().add_relationship(Atk.RelationType.LABELLED_BY,
|
||||||
|
invalid_label.get_accessible())
|
||||||
|
box.pack_start(invalid_entry, False, False, 0)
|
||||||
|
password_label = Gtk.Entry()
|
||||||
|
password_label.set_visibility(False)
|
||||||
|
password_label.set_text("secret relation value")
|
||||||
|
password_label.get_accessible().set_name("secret relation name")
|
||||||
|
box.pack_start(password_label, False, False, 0)
|
||||||
|
safe_entry = Gtk.Entry()
|
||||||
|
safe_entry.get_accessible().set_name("")
|
||||||
|
safe_entry.get_accessible().set_description("password-target")
|
||||||
|
safe_entry.get_accessible().add_relationship(Atk.RelationType.LABELLED_BY,
|
||||||
|
password_label.get_accessible())
|
||||||
|
box.pack_start(safe_entry, False, False, 0)
|
||||||
window.show_all()
|
window.show_all()
|
||||||
Gtk.main()
|
Gtk.main()
|
||||||
+58
-3
@@ -26,6 +26,9 @@ def run_inside():
|
|||||||
if os.environ.get("CTHULHU_NATIVE_EDIT_LABELS") == "1":
|
if os.environ.get("CTHULHU_NATIVE_EDIT_LABELS") == "1":
|
||||||
run_edit_label_probe()
|
run_edit_label_probe()
|
||||||
return
|
return
|
||||||
|
if os.environ.get("CTHULHU_NATIVE_GTK4_MENU_LABELS") == "1":
|
||||||
|
run_gtk4_menu_label_probe()
|
||||||
|
return
|
||||||
if os.environ.get("CTHULHU_FOREGROUND_PID") == "1":
|
if os.environ.get("CTHULHU_FOREGROUND_PID") == "1":
|
||||||
if os.environ.get("CTHULHU_FOREGROUND_PROXY") == "1":
|
if os.environ.get("CTHULHU_FOREGROUND_PROXY") == "1":
|
||||||
run_proxy_probe()
|
run_proxy_probe()
|
||||||
@@ -58,11 +61,20 @@ def run_inside():
|
|||||||
def run_edit_label_probe():
|
def run_edit_label_probe():
|
||||||
fixture = subprocess.Popen([sys.executable, str(Path(__file__).with_name("gtk_edit_labels.py"))])
|
fixture = subprocess.Popen([sys.executable, str(Path(__file__).with_name("gtk_edit_labels.py"))])
|
||||||
try:
|
try:
|
||||||
import pyatspi
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Atspi", "2.0")
|
||||||
|
from gi.repository import Atspi
|
||||||
|
|
||||||
def find_window():
|
def find_window():
|
||||||
return any(window.name == "Edit label probe"
|
desktop = Atspi.get_desktop(0)
|
||||||
for app in pyatspi.Registry.getDesktop(0) for window in app)
|
for i in range(desktop.get_child_count()):
|
||||||
|
app = desktop.get_child_at_index(i)
|
||||||
|
for j in range(app.get_child_count() if app else 0):
|
||||||
|
window = app.get_child_at_index(j)
|
||||||
|
if window is not None and window.get_name() == "Edit label probe":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
wait_for(find_window, "edit label probe missing")
|
wait_for(find_window, "edit label probe missing")
|
||||||
environment = dict(os.environ, GOCACHE="/tmp/cthulhu-go-cache", GOPROXY="off")
|
environment = dict(os.environ, GOCACHE="/tmp/cthulhu-go-cache", GOPROXY="off")
|
||||||
@@ -76,6 +88,49 @@ def run_edit_label_probe():
|
|||||||
fixture.wait(timeout=3)
|
fixture.wait(timeout=3)
|
||||||
|
|
||||||
|
|
||||||
|
def run_gtk4_menu_label_probe():
|
||||||
|
fixture = subprocess.Popen([sys.executable, str(Path(__file__).with_name("gtk4_menu_labels.py"))],
|
||||||
|
env=dict(os.environ, GTK_A11Y="atspi"))
|
||||||
|
try:
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Atspi", "2.0")
|
||||||
|
from gi.repository import Atspi
|
||||||
|
|
||||||
|
def showing_menu_item(node, depth=0):
|
||||||
|
if node is None or depth > 40:
|
||||||
|
return False
|
||||||
|
if node.get_role_name() in ("menu item", "check menu item", "radio menu item") and \
|
||||||
|
node.get_state_set().contains(Atspi.StateType.SHOWING):
|
||||||
|
return True
|
||||||
|
return any(showing_menu_item(node.get_child_at_index(i), depth + 1)
|
||||||
|
for i in range(node.get_child_count()))
|
||||||
|
|
||||||
|
def menu_open():
|
||||||
|
desktop = Atspi.get_desktop(0)
|
||||||
|
for i in range(desktop.get_child_count()):
|
||||||
|
app = desktop.get_child_at_index(i)
|
||||||
|
for j in range(app.get_child_count() if app else 0):
|
||||||
|
window = app.get_child_at_index(j)
|
||||||
|
if window is not None and window.get_name() == "GTK 4 menu label probe":
|
||||||
|
try:
|
||||||
|
return showing_menu_item(window)
|
||||||
|
except Exception: # noqa: BLE001 - tree still settling
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
wait_for(menu_open, "GTK 4 menu probe missing")
|
||||||
|
environment = dict(os.environ, GOCACHE="/tmp/cthulhu-go-cache", GOPROXY="off")
|
||||||
|
subprocess.run(
|
||||||
|
["go", "test", "./internal/atspi", "-run", "^TestNativeGTK4MenuItemLabels$",
|
||||||
|
"-count=1", "-timeout=25s", "-v"],
|
||||||
|
cwd=Path(__file__).resolve().parents[1], env=environment, check=True,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
fixture.terminate()
|
||||||
|
fixture.wait(timeout=3)
|
||||||
|
|
||||||
|
|
||||||
def run_thunar_probe():
|
def run_thunar_probe():
|
||||||
with tempfile.TemporaryDirectory(prefix="cthulhu-thunar-") as temporary:
|
with tempfile.TemporaryDirectory(prefix="cthulhu-thunar-") as temporary:
|
||||||
home = Path(temporary)
|
home = Path(temporary)
|
||||||
|
|||||||
Reference in new issue
Block a user