diff --git a/docs/testing/background-events.md b/docs/testing/background-events.md index 5317b44..6d3ff6b 100644 --- a/docs/testing/background-events.md +++ b/docs/testing/background-events.md @@ -5,8 +5,10 @@ selected descendants or capturing their text, values, attributes and geometry. The native adapter owns this decision on the GLib thread. It uses the existing foreground window and epoch; there is no additional window ownership cache. -Events from another provider can be rejected using copied bus identities and -libatspi's cached role and application name, without querying that provider. +Events from another provider first resolve their containing window: GTK socket +children can belong to the foreground host while using a different provider +bus. Unrelated providers are then rejected using the resolved window and copied +bus identities, before any content queries. Events from another window on the same provider undergo the existing window correlation before content capture. Lifecycle invalidation still runs, but a background provider no longer clears another provider's cached document lines. @@ -16,8 +18,9 @@ their existing handling. Announcement events are explicit requests to speak and also bypass the filter; see [announcements](announcements.md). Native alerts, notification daemons and supported Steam toasts retain their notification paths. Menu popups retain existing foreground checks. Verified active/showing `mate-panel` menu windows may pass -across provider boundaries, including over an empty desktop; ordinary panel -controls and hidden menus remain filtered. See [MATE desktop](mate-desktop.md). +across provider boundaries, including over an empty desktop. Embedded panel +controls use their host's normal X11 correlation; unrelated panel windows and +hidden menus remain filtered. See [MATE desktop](mate-desktop.md). Same-provider autocomplete relations and tooltip candidates continue through the existing event-loop ownership checks; they do not acquire focus merely by passing the early filter. diff --git a/docs/testing/mate-desktop.md b/docs/testing/mate-desktop.md index 454cc86..98b32d6 100644 --- a/docs/testing/mate-desktop.md +++ b/docs/testing/mate-desktop.md @@ -18,6 +18,7 @@ Reviewed the local `orca/` checkout at revision `a8a851800`: | Marco window cycling | `script_manager.py`, `scripts/switcher/script.py`, `scripts/switcher/script_utilities.py` | Already implemented; see [window switcher checks](window-switcher.md). | | MATE notification daemon | `script_manager.py`, `scripts/apps/notification-daemon/script.py` | Already routed through notification capture, speech, Braille and history; added an explicit MATE daemon regression. | | Unnamed panel icons | `generator.py:_generate_accessible_name` | Adapted the containing panel's name/description when the icon supplies neither. Own names and descriptions retain precedence. | +| Embedded panel applets | `script_utilities.py:frame_and_dialog`, `script_utilities.py:top_level_object`, `scripts/default.py:_on_focused_changed` | Follow the accessible parent chain through a GTK plug/socket to its host window before normal foreground validation. | Orca has no dedicated `mate-panel` script in this checkout. Its current app script catalog also contains no Caja, Pluma, Atril, Engrampa or Eye of MATE @@ -39,10 +40,32 @@ hidden menu items retain normal filtering. Native actions and review use the same window validation. The correlated X11 application is preserved; no popup cache, native references, timer or extra window owner is introduced. +## Embedded applet ownership + +The newer `kendell/mate.log`, recorded with revision `702894ee`, shows a +different failure: Bottom Panel controls are accepted, but focused icons from +other provider buses are rejected as outside the X11 foreground. The reader +announces the host's generic "panel" while discarding the embedded icon. + +A real GTK socket/plug reproduction exposes the same structure. The applet +publishes its own frame, whose accessible parent chain crosses the socket's +provider and reaches the panel frame. Window resolution now follows that chain +to the host instead of stopping at the plug frame. The background filter also +resolves this window before rejecting a different provider bus. The source +object retains its own bus, path and process identity for focus and actions. + +This is shared window ancestry handling: it requires no MATE process-name +exception. Normal X11 process, state and geometry checks still apply to the +host. Same-provider nested frames retain their existing nearest-window +behavior. An unembedded or detached applet cannot borrow the host's identity, +and a background host cannot admit applet focus. Parent links beyond the plug +frame are refreshed on the GLib owner; no embedding cache is retained. + ## Automated checks ```sh python3 tests/native_test_session.py env CTHULHU_NATIVE_PANEL_MENU=1 CTHULHU_NATIVE_WINDOW_SWITCHER=1 CTHULHU_NATIVE_BACKGROUND_EVENTS=1 go test -race ./internal/atspi ./internal/app -run '^TestNative(MatePanelMenu|MateWindowSwitcherOutsideForeground|BackgroundEventFiltering)' -count=1 -p=1 -timeout=90s -v +python3 tests/native_test_session.py env CTHULHU_NATIVE_PANEL_APPLET=1 go test -race ./internal/atspi ./internal/app -run '^TestNativeEmbeddedPanelApplet' -count=1 -p=1 -timeout=90s -v go test ./internal/app -run '^TestNotificationProvidersAndHistoryMatchReferenceBehavior$' -count=1 ``` @@ -55,6 +78,14 @@ inherited labels, own-name/description precedence and unrelated-container rejection. The event-loop check verifies queued speech and Braille frames for navigation, dismissal, editor return and reopening. It emits no audible speech. +The socket/plug fixture uses separate host, applet and editor processes. It +checks the shared containing-window identity, preserved applet identity, icon +label inheritance, Tab/Shift+Tab within the applet, host controls on either +side, and editor restoration. It also checks a non-MATE host, background applet +focus, an unrelated applet claiming the MATE application name, and stale focus +after socket destruction. The event-loop check verifies speech requests and +Braille frames through two panel/editor round trips. + The existing background flood regression checks that ordinary same-provider and other-provider updates still cause zero content queries. The Marco regression checks switcher insertions and showing events while an editor @@ -87,6 +118,9 @@ MATE session. After starting the candidate through the normal launcher: typing and ordinary Tab navigation. Check terminal-reader handoff if used. 3. Use Control+Alt+Tab to reach the panel and navigate its applets. Confirm applet icons use supplied labels without repeating unrelated container text. + Traverse both directions through launchers, clock, notification area and + configured external applets. Open and dismiss applet controls, then return + to the previous application. Background applet updates must stay silent. 4. Cycle windows with Alt+Tab and Shift+Alt+Tab, then cancel with Escape. Check a MATE notification and its notification-history entry. 5. Return to browser heading navigation and address-bar suggestions, and open diff --git a/internal/app/panel_applet_native_test.go b/internal/app/panel_applet_native_test.go new file mode 100644 index 0000000..8031980 --- /dev/null +++ b/internal/app/panel_applet_native_test.go @@ -0,0 +1,115 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "bufio" + "context" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" +) + +func TestNativeEmbeddedPanelAppletPresentation(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_PANEL_APPLET") != "1" { + t.Skip("requires private Xorg/AT-SPI session") + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + adapter := atspi.New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + start := func(args ...string) (io.WriteCloser, string, string) { + t.Helper() + command := exec.CommandContext(ctx, "/usr/bin/python3", append([]string{"../../tests/gtk_panel_applet.py"}, args...)...) + input, err := command.StdinPipe() + if err != nil { + t.Fatal(err) + } + output, err := command.StdoutPipe() + if err != nil { + t.Fatal(err) + } + command.Stderr = os.Stderr + if err := command.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { input.Close(); command.Process.Kill(); command.Wait() }) + line, err := bufio.NewReader(output).ReadString('\n') + fields := strings.Fields(line) + if err != nil || len(fields) != 3 || fields[0] != "ready" { + t.Fatalf("fixture readiness: %q, %v", line, err) + } + return input, fields[1], fields[2] + } + host, xid, socketID := start("panel") + start("applet", socketID) + editor, editorXID, _ := start("editor") + key := func(args ...string) { + t.Helper() + if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { + t.Fatalf("xdotool: %s, %v", output, err) + } + } + speech := &fakeSpeechQueue{spoken: make(chan string, 128)} + frames := make(chan braille.Frame, 128) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), + outputQueue{speech: speech, braille: browserBrailleProbe{frames: frames}}, + adapter, nil, nil, 0, nil, nil, nil, nil) + }() + defer func() { cancel(); <-done }() + wait := func(name string) { + t.Helper() + spoken, displayed := false, false + var messages []string + for !spoken || !displayed { + select { + case text := <-speech.spoken: + messages = append(messages, text) + spoken = spoken || strings.Contains(text, name) + case frame := <-frames: + displayed = displayed || strings.Contains(frame.Text, name) + case <-ctx.Done(): + t.Fatalf("missing presentation %q: speech=%v braille=%v messages=%q", name, spoken, displayed, messages) + } + } + } + for iteration := 0; iteration < 2; iteration++ { + key("windowfocus", "--sync", xid) + fmt.Fprintln(host, "first") + wait("Before applet") + key("key", "Tab") + wait("Network applet") + key("key", "Tab") + wait("Applet settings") + key("key", "shift+Tab") + wait("Network applet") + key("key", "Tab") + wait("Applet settings") + key("key", "Tab") + wait("After applet") + for _, name := range []string{"Applet settings", "Network applet", "Before applet"} { + key("key", "shift+Tab") + wait(name) + } + key("windowfocus", "--sync", editorXID) + text := fmt.Sprintf("Returned to editor %d", iteration) + fmt.Fprintln(editor, "edit "+text) + wait(text) + } +} diff --git a/internal/atspi/adapter_event_filter_native.go b/internal/atspi/adapter_event_filter_native.go index 064d7f9..95b9b83 100644 --- a/internal/atspi/adapter_event_filter_native.go +++ b/internal/atspi/adapter_event_filter_native.go @@ -32,7 +32,7 @@ func (adapter *Adapter) filterNativeBackgroundEvent(event *C.AtspiEvent, eventTy if C.cthulhu_may_be_background_notification(event.source) != 0 { return false } - if (foreground.Client.Window == 0 || adapter.foregroundWindow.Valid() && origin.BusName != adapter.foregroundWindow.BusName) && + if foreground.Client.Window == 0 && adapter.applicationProcess(event.source) != "mate-panel" { return true } @@ -43,6 +43,14 @@ func (adapter *Adapter) filterNativeBackgroundEvent(event *C.AtspiEvent, eventTy return false } defer C.g_object_unref(C.gpointer(window)) + // An embedded GTK applet has its own provider bus but belongs to the + // socket host's window. Resolve that ancestry before the cross-bus fast + // rejection; the host still has to pass normal X11 foreground checks. + windowID := nativeObjectID(window) + if adapter.foregroundWindow.Valid() && origin.BusName != adapter.foregroundWindow.BusName && + windowID.BusName != adapter.foregroundWindow.BusName && adapter.applicationProcess(event.source) != "mate-panel" { + return true + } focusedMenuItem := false if eventType == FocusEvent || eventType == FocusedStateEvent && event.detail1 != 0 { switch C.atspi_accessible_get_role(event.source, nil) { @@ -50,7 +58,7 @@ func (adapter *Adapter) filterNativeBackgroundEvent(event *C.AtspiEvent, eventTy focusedMenuItem = true } } - if adapter.matchForeground(window, nativeObjectID(window), nil, focusedMenuItem) { + if adapter.matchForeground(window, windowID, nil, focusedMenuItem) { return false } if origin.BusName == adapter.foregroundWindow.BusName { diff --git a/internal/atspi/adapter_events.c b/internal/atspi/adapter_events.c index 9e12577..7f4b341 100644 --- a/internal/atspi/adapter_events.c +++ b/internal/atspi/adapter_events.c @@ -609,37 +609,68 @@ gboolean cthulhu_may_be_background_notification(AtspiAccessible *source) { // Return the containing top-level window. An embedded web dialog is part of // its frame; a standalone dialog directly below the application owns itself. +// GTK plugs also publish a frame, but its parent chain crosses the socket's +// provider before reaching the host window. Keep that host as the owner while +// retaining the source object's provider identity. Same-provider nested frames +// retain their existing nearest-window behavior. AtspiAccessible *cthulhu_window_ancestor(AtspiAccessible *source) { if (source == NULL) return NULL; AtspiAccessible *current = g_object_ref(source); + AtspiAccessible *window = NULL; for (gint depth = 0; current != NULL && depth < 256; depth++) { GError *error = NULL; AtspiRole role = atspi_accessible_get_role(current, &error); if (error != NULL) { g_clear_error(&error); g_object_unref(current); + if (window != NULL) g_object_unref(window); return NULL; } - if (role == ATSPI_ROLE_FRAME || role == ATSPI_ROLE_WINDOW) { - return current; + if (role == ATSPI_ROLE_APPLICATION) { + g_object_unref(current); + return window; + } + if ((role == ATSPI_ROLE_FRAME || role == ATSPI_ROLE_WINDOW) && + (window == NULL || g_strcmp0(cthulhu_accessible_bus_name(window), + cthulhu_accessible_bus_name(current)) != 0)) { + if (window != NULL) g_object_unref(window); + window = g_object_ref(current); } AtspiAccessible *parent = atspi_accessible_get_parent(current, &error); + // Only a frame with parents below the application can be a plug. + // Refresh that chain to reject detached sockets, without invalidating + // every ordinary top-level window for every accessibility event. + if (window != NULL && parent != NULL && error == NULL && + atspi_accessible_get_role(parent, NULL) != ATSPI_ROLE_APPLICATION) { + g_object_unref(parent); + atspi_accessible_clear_cache_single(current); + parent = atspi_accessible_get_parent(current, &error); + } // Qt can expose a top-level QWidget as a filler rather than a frame. // Python's Qt utilities use the application-child boundary here. if (parent != NULL && error == NULL && role != ATSPI_ROLE_APPLICATION && atspi_accessible_get_role(parent, NULL) == ATSPI_ROLE_APPLICATION) { g_object_unref(parent); - return current; + if (window == NULL || g_strcmp0(cthulhu_accessible_bus_name(window), + cthulhu_accessible_bus_name(current)) != 0) { + if (window != NULL) g_object_unref(window); + return current; + } + g_object_unref(current); + return window; } g_object_unref(current); if (error != NULL) { g_clear_error(&error); if (parent != NULL) g_object_unref(parent); + if (window != NULL) g_object_unref(window); return NULL; } current = parent; } if (current != NULL) g_object_unref(current); + if (current == NULL) return window; + if (window != NULL) g_object_unref(window); return NULL; } diff --git a/internal/atspi/panel_applet_native_test.go b/internal/atspi/panel_applet_native_test.go new file mode 100644 index 0000000..8735f29 --- /dev/null +++ b/internal/atspi/panel_applet_native_test.go @@ -0,0 +1,159 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func TestNativeEmbeddedPanelApplet(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_PANEL_APPLET") != "1" { + t.Skip("requires private Xorg/AT-SPI session") + } + // Embedding belongs to the window ancestry contract, not a process-name + // exception. Both MATE and an ordinary GTK host must retain ownership. + for _, hostName := range []string{"panel", "other-host"} { + t.Run(hostName, func(t *testing.T) { check_native_embedded_applet(t, hostName) }) + } +} + +func check_native_embedded_applet(t *testing.T, hostName string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + start := func(args ...string) (io.WriteCloser, string, string) { + t.Helper() + command := exec.CommandContext(ctx, "/usr/bin/python3", append([]string{"../../tests/gtk_panel_applet.py"}, args...)...) + input, err := command.StdinPipe() + if err != nil { + t.Fatal(err) + } + output, err := command.StdoutPipe() + if err != nil { + t.Fatal(err) + } + command.Stderr = os.Stderr + if err := command.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { input.Close(); command.Process.Kill(); command.Wait() }) + line, err := bufio.NewReader(output).ReadString('\n') + fields := strings.Fields(line) + if err != nil || len(fields) != 3 || fields[0] != "ready" { + t.Fatalf("fixture readiness: %q, %v", line, err) + } + return input, fields[1], fields[2] + } + host, xid, socketID := start(hostName) + applet, _, _ := start("applet", socketID) + editor, editorXID, _ := start("editor") + wait := func(predicate func(Event) bool) Event { + t.Helper() + var last Event + for { + select { + case event := <-adapter.Events(): + last = event + if predicate(event) { + return event + } + case <-ctx.Done(): + t.Fatalf("native event missing; filtered=%d last=%s %q window=%+v mismatch=%v", adapter.backgroundEventsFiltered.Load(), last.Type, last.Source.Name, last.Window, last.ForegroundMismatch) + } + } + } + key := func(args ...string) { + t.Helper() + if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { + t.Fatalf("xdotool %v: %s, %v", args, output, err) + } + } + key("windowfocus", "--sync", xid) + fmt.Fprintln(host, "first") + owned := wait(func(event Event) bool { + return event.Source.Name == "Before applet" && event.Source.Focused && !event.ForegroundMismatch + }) + key("key", "Tab") + icon := wait(func(event Event) bool { return event.Source.Role == "icon" && event.Source.Focused }) + if icon.ForegroundMismatch || icon.Window != owned.Window || icon.Source.BusName == owned.Source.BusName || icon.Foreground != owned.Foreground { + t.Fatalf("embedded icon lost host ownership: host=%+v icon=%+v source=%+v mismatch=%v foreground=%+v want=%+v", owned.Window, icon.Window, icon.Source.ID(), icon.ForegroundMismatch, icon.Foreground, owned.Foreground) + } + if icon.Source.Name != "Network applet" || icon.Source.ApplicationProcess != "applet" { + t.Fatalf("embedded icon lost its label or provider identity: %+v", icon) + } + key("key", "Tab") + wait(func(event Event) bool { + return event.Source.Name == "Applet settings" && event.Source.Focused && event.Window == owned.Window && !event.ForegroundMismatch + }) + key("key", "shift+Tab") + wait(func(event Event) bool { + return event.Source.Role == "icon" && event.Source.Focused && !event.ForegroundMismatch + }) + key("key", "Tab") + wait(func(event Event) bool { + return event.Source.Name == "Applet settings" && event.Source.Focused && !event.ForegroundMismatch + }) + key("key", "Tab") + wait(func(event Event) bool { + return event.Source.Name == "After applet" && event.Source.Focused && !event.ForegroundMismatch + }) + for _, name := range []string{"Applet settings", "Network applet", "Before applet"} { + key("key", "shift+Tab") + wait(func(event Event) bool { + return event.Source.Name == name && event.Source.Focused && !event.ForegroundMismatch + }) + } + key("windowfocus", "--sync", editorXID) + fmt.Fprintln(editor, "edit Foreground restored") + wait(func(event Event) bool { return event.Payload == "Foreground restored" && !event.ForegroundMismatch }) + fmt.Fprintln(applet, "stale") + wait(func(event Event) bool { + if event.Source.Role == "icon" && !event.ForegroundMismatch { + t.Fatalf("background applet stole foreground: %+v", event) + } + return event.Payload == "Applet barrier" + }) + // A separately published applet window cannot borrow the host's identity. + standalone, standaloneXID, _ := start("standalone") + key("windowfocus", "--sync", standaloneXID) + fmt.Fprintln(standalone, "first") + wait(func(event Event) bool { + return event.Source.Role == "icon" && event.Source.Focused && !event.ForegroundMismatch + }) + key("windowfocus", "--sync", xid) + fmt.Fprintln(host, "first") + wait(func(event Event) bool { + return event.Source.Name == "Before applet" && event.Source.Focused && !event.ForegroundMismatch + }) + fmt.Fprintln(standalone, "stale") + wait(func(event Event) bool { + if event.Source.Role == "icon" && !event.ForegroundMismatch { + t.Fatalf("unembedded applet borrowed host ownership: %+v", event) + } + return event.Payload == "Applet barrier" + }) + fmt.Fprintln(host, "detach") + wait(func(event Event) bool { return event.Payload == "Detached host barrier" }) + fmt.Fprintln(applet, "stale") + wait(func(event Event) bool { + if event.Source.Role == "icon" && !event.ForegroundMismatch { + t.Fatalf("detached applet retained host ownership: %+v", event) + } + return event.Payload == "Applet barrier" + }) +} diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index 531d8d7..8629b46 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -1,4 +1,5 @@ id,python_source,subsystem,behavior,owner,go_destination,status,automated_evidence,x11_evidence,wayland_evidence,speech_evidence,braille_evidence,notes +embedded-panel-applet,Orca src/orca/script_utilities.py:frame_and_dialog;top_level_object;src/orca/scripts/default.py:_on_focused_changed,focus,Follow embedded GTK applet focus through the socket host window while preserving the source provider,,internal/atspi internal/app,in-progress,Private native regression reproduces rejection before the fix; socket and plug tests cover MATE and ordinary hosts; see docs/testing/mate-desktop.md,Separate host/applet/editor providers cover icon labels; Tab and Shift+Tab; editor return; background and unrelated providers; socket detachment.,,Queued speech verified; real MATE listening pending.,Native event-loop frames verified; physical Braille acceptance pending.,Refresh parent links through embedded frames before normal X11 host validation. No process-name bypass or retained embedding state. Human review and live panel traversal required. submission-clear-deletion,src/cthulhu/scripts/default.py:onTextDeleted,speech,Avoid announcing an entry clear after plain Enter before the submitted chat message,,internal/app/submitted_text.go;internal/input/intent.go,in-progress,Submission replay fails against the original presenter and passes with the fix; editing regression matrix and focused race checks pass.,Private GTK submission and selected-text replacement checks use native snapshots and the real X11 input listener.,,Speech requests verified; live Pidgin listening pending.,Braille update path retained; physical display acceptance pending.,Requires focused text or entry plus fresh plain Enter and known empty line at offset zero. Selection replacement and modified Enter retain existing feedback. No Thunderbird or word-echo changes. See docs/testing/messaging.md. mate-panel-menu,Orca src/orca/scripts/default.py;src/orca/ax_utilities.py;src/orca/generator.py,focus,Read active MATE panel menus across X11 application boundaries and label unnamed panel icons,,internal/atspi internal/app,in-progress,Private native and event-loop regressions; see docs/testing/mate-desktop.md,Separate GTK providers cover editor and empty desktop menus; submenus; reverse navigation; Escape; hidden/impostor rejection; icon label precedence.,,Queued presentation verified; actual Fedora MATE listening pending.,Native event-loop frames verified; physical display acceptance pending.,Fresh active/showing native menu structure and verified mate-panel process only. Preserves X11 correlation without retained popup state. Existing Marco and notification paths retained. Human review and live MATE acceptance required. mate-window-switcher,Orca src/orca/scripts/switcher/script.py;src/orca/scripts/switcher/script_utilities.py,focus,Present Marco switcher selections while the old application retains X11 foreground,,internal/atspi internal/app,in-progress,go test ./internal/app -run '^TestMateWindowSwitcher' -count=1; see docs/testing/window-switcher.md,Private GTK provider checks repeated insertions and showing events outside foreground without taking ownership.,,Actual Marco listening and physical key acceptance pending.,Transient flash requests tested; physical display acceptance pending.,Verified Marco process and application plus visible status-bar container only. Preserves application focus and review. No name-signal duplication or general background bypass. Original log lacks later cycling insertions; real cycling and Escape remain live gates. diff --git a/tests/gtk_panel_applet.py b/tests/gtk_panel_applet.py new file mode 100644 index 0000000..128186d --- /dev/null +++ b/tests/gtk_panel_applet.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Storm Dragon +# Authors: Storm Dragon, Angus Kola +# SPDX-License-Identifier: GPL-3.0-or-later +"""Real GTK socket/plug embedding on separate providers for private tests.""" +import ctypes +import os +import sys + +import gi + +gi.require_version("Gtk", "3.0") +gi.require_version("Atk", "1.0") +gi.require_version("GdkX11", "3.0") +from gi.repository import Atk, GdkX11, GLib, Gtk + + +mode = sys.argv[1] +processName = "mate-panel" if mode == "panel" else "applet" if mode == "standalone" else mode +GLib.set_prgname(processName) +if ctypes.CDLL(None).prctl(15, processName.encode(), 0, 0, 0) != 0: + raise RuntimeError("cannot set fixture process name") +window = Gtk.Plug.new(int(sys.argv[2])) if mode == "applet" else Gtk.Window() +if mode == "applet": + window.connect("delete-event", lambda *_args: True) +if mode == "standalone": + Atk.get_root().set_name("mate-panel") +window.set_title("Embedded applet fixture " + mode) +window.set_default_size(400, 120) +box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) +window.add(box) +socket = None +if mode in ("applet", "standalone"): + box.get_accessible().set_role(Atk.Role.PANEL) + box.get_accessible().set_name("Network applet") + first = Gtk.Button(label="Network") + first.get_accessible().set_role(Atk.Role.ICON) + first.get_accessible().set_name("") + second = Gtk.Button(label="Applet settings") + box.add(first) + box.add(second) +elif mode == "editor": + first = Gtk.Entry() + first.get_accessible().set_name("Editor entry") + box.add(first) +else: + first = Gtk.Button(label="Before applet") + second = Gtk.Button(label="After applet") + socket = Gtk.Socket() + box.add(first) + box.add(socket) + box.add(second) +barrier = Gtk.Label() +barrier.get_accessible().set_role(Atk.Role.ALERT) +box.add(barrier) +window.show_all() +pendingInput = "" + + +def receive_command(_source, _condition): + global pendingInput + content = os.read(sys.stdin.fileno(), 4096) + if not content: + Gtk.main_quit() + return False + pendingInput += content.decode() + while "\n" in pendingInput: + command, pendingInput = pendingInput.split("\n", 1) + if command == "first": + first.grab_focus() + elif command == "second": + second.grab_focus() + elif command == "stale": + first.get_accessible().notify_state_change(Atk.StateType.FOCUSED, True) + barrier.set_text("") + barrier.set_text("Applet barrier") + elif command == "detach": + socket.destroy() + barrier.set_text("Detached host barrier") + elif command.startswith("edit "): + first.set_text(command.removeprefix("edit ")) + first.grab_focus() + elif command == "quit": + Gtk.main_quit() + return False + return True + + +GLib.io_add_watch(sys.stdin, GLib.IO_IN | GLib.IO_HUP, receive_command) +GLib.idle_add(lambda: print( + "ready", GdkX11.X11Window.get_xid(window.get_window()), + socket.get_id() if socket is not None else 0, flush=True) or False) +Gtk.main() diff --git a/tests/gtk_window_switcher.py b/tests/gtk_window_switcher.py index 07c2d4d..281d946 100644 --- a/tests/gtk_window_switcher.py +++ b/tests/gtk_window_switcher.py @@ -24,6 +24,9 @@ if providerName == "marco": raise RuntimeError("cannot set fixture process name") window = Gtk.Window(title="Switcher fixture " + providerName) +# Label updates must not resize the foreground window while the test compares +# its full X11 snapshot to check that the switcher has not taken ownership. +window.set_default_size(400, 100) if providerName == "marco": window.set_accept_focus(False) window.set_focus_on_map(False)