Possible fix for mate's window switcher.
This commit is contained in:
1 parent
eb5b2038d6
commit
e9a2dc1975
11 files changed
+472
No files matched your search
@@ -10,6 +10,7 @@ Cthulhu is a Linux screen reader written in Go.
|
|||||||
- [Messaging support](docs/testing/messaging.md): Pidgin message review/copy shortcuts, application boundaries, and verification.
|
- [Messaging support](docs/testing/messaging.md): Pidgin message review/copy shortcuts, application boundaries, and verification.
|
||||||
- [Flatpak foreground accessibility](docs/testing/flatpak-foreground.md): process identity and proxy matching, tests, and live acceptance.
|
- [Flatpak foreground accessibility](docs/testing/flatpak-foreground.md): process identity and proxy matching, tests, and live acceptance.
|
||||||
- [Background event filtering](docs/testing/background-events.md): early foreground checks, notification exceptions, and performance verification.
|
- [Background event filtering](docs/testing/background-events.md): early foreground checks, notification exceptions, and performance verification.
|
||||||
|
- [MATE window switcher](docs/testing/window-switcher.md): Marco selection announcements, isolated checks, and live verification.
|
||||||
- [Qt control states](docs/testing/qt-control-states.md): menu checkability, native regressions, and the Qt 5 provider limitation.
|
- [Qt control states](docs/testing/qt-control-states.md): menu checkability, native regressions, and the Qt 5 provider limitation.
|
||||||
- [Tentacle reader settings API](docs/tentacle-reader-settings.md): contributed preferences pages, quick settings, speech overrides, and verification.
|
- [Tentacle reader settings API](docs/tentacle-reader-settings.md): contributed preferences pages, quick settings, speech overrides, and verification.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# MATE window switcher
|
||||||
|
|
||||||
|
Marco exposes the selected window title as a status-bar accessible. While Alt
|
||||||
|
is held, the old application can remain the X11 foreground client and the
|
||||||
|
switcher need not send a focus event. Ordinary foreground and editable-text
|
||||||
|
filters therefore cannot present its selection.
|
||||||
|
|
||||||
|
Following Orca's switcher event contract, Cthulhu reads the container name on
|
||||||
|
text insertion, selection change and showing-state gain. The accompanying
|
||||||
|
accessible-name signal does not repeat the announcement. The native adapter
|
||||||
|
handles this before routine background filtering and selected-child lookup.
|
||||||
|
It requires the `marco` process identity, the Marco application name, a known
|
||||||
|
visible/showing non-defunct status bar and a containing window on the same
|
||||||
|
provider bus. It refreshes only that accessible before reading the selection.
|
||||||
|
|
||||||
|
The application handler presents the title as transient speech and a Braille
|
||||||
|
message. It preserves application/native focus, browser mode and review
|
||||||
|
position, and rejects stale foreground epochs. Terminal handoff permits these
|
||||||
|
window-manager announcements; screen lock and internal UI still block them.
|
||||||
|
Normal key interruption and queued speech policy apply. No switcher state,
|
||||||
|
native references, timers or polling are retained. Other window managers are
|
||||||
|
outside this change.
|
||||||
|
|
||||||
|
## Automated checks
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./internal/app -run '^TestMateWindowSwitcher' -count=1
|
||||||
|
python3 tests/native_test_session.py env CTHULHU_NATIVE_WINDOW_SWITCHER=1 CTHULHU_NATIVE_BACKGROUND_EVENTS=1 go test -race ./internal/atspi -run '^TestNative(MateWindowSwitcherOutsideForeground|BackgroundEventFiltering)$' -count=1 -timeout=60s -v
|
||||||
|
```
|
||||||
|
|
||||||
|
The application regression reproduces the rejected non-focused status event
|
||||||
|
from the reported log, repeated forward/reverse selections, showing and
|
||||||
|
selection events, duplicate name rejection, stale/hidden/dead/impostor
|
||||||
|
rejection, terminal handoff, screen lock and Braille flash requests. A later
|
||||||
|
name change on the original control verifies that focus ownership survives.
|
||||||
|
|
||||||
|
The private GTK fixture publishes a non-focusable status bar with Marco's
|
||||||
|
application and process identity. It verifies that repeated insertions and a
|
||||||
|
showing event survive native background filtering while the editor retains
|
||||||
|
X11 foreground ownership. It models Marco's AT-SPI contract; it does not run
|
||||||
|
the real MATE window manager. The existing background flood test checks both
|
||||||
|
other-provider and same-provider inactive windows and zero content queries.
|
||||||
|
All native checks use a disposable X server and accessibility bus.
|
||||||
|
|
||||||
|
## Live acceptance
|
||||||
|
|
||||||
|
After reviewing the diff and starting the candidate through the normal local
|
||||||
|
launcher when ready:
|
||||||
|
|
||||||
|
1. Open at least three differently titled windows. Hold Alt and press Tab
|
||||||
|
repeatedly. Confirm each selected title is spoken; repeat with Shift+Tab.
|
||||||
|
2. Release Alt and confirm normal reading follows the chosen application.
|
||||||
|
Repeat from a terminal, and check that terminal delegation resumes.
|
||||||
|
3. Open the switcher, cycle through titles and press Escape. Confirm reading
|
||||||
|
returns to the original control and review/navigation still work.
|
||||||
|
4. With a browser open, check heading navigation, address-bar editing and
|
||||||
|
switching between two windows of the same application. Background status
|
||||||
|
updates must stay quiet.
|
||||||
|
5. If using Braille, confirm each selection appears as a transient message
|
||||||
|
and normal application content returns after dismissal. Switcher output
|
||||||
|
must not appear while the screen is locked.
|
||||||
|
|
||||||
|
Physical keys, audible speech, real Marco cycling/cancellation and physical
|
||||||
|
Braille remain live acceptance checks. The original log contains an initial
|
||||||
|
status insertion but no later insertions for its subsequent Tab presses; keep
|
||||||
|
a fresh timed log if actual cycling still fails.
|
||||||
@@ -111,6 +111,9 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
|
|||||||
"selection_end", event.Source.SelectionEnd)
|
"selection_end", event.Source.SelectionEnd)
|
||||||
}
|
}
|
||||||
syncForeground()
|
syncForeground()
|
||||||
|
if handler.handle_window_switcher(event) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if handler.messaging.handle(event) {
|
if handler.messaging.handle(event) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Copyright (c) 2026 Storm Dragon
|
||||||
|
// Authors: Storm Dragon, Angus Kola
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A window-manager switcher can leave the old client as X11 foreground while
|
||||||
|
// Alt is held. Present its selection without replacing that client's focus,
|
||||||
|
// review position, browser mode or native ownership. No session state is kept.
|
||||||
|
func (handler *accessibilityEventHandler) handle_window_switcher(event atspi.Event) bool {
|
||||||
|
if !atspi.IsWindowSwitcherSelection(event) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if handler.focusGate.InternalUIActive() ||
|
||||||
|
handler.foreground != nil && event.Foreground.Epoch != handler.state.foregroundState.Epoch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if title := strings.TrimSpace(event.Source.Name); title != "" {
|
||||||
|
// The system role also flashes the title on Braille through outputQueue.
|
||||||
|
// Normal input interruption and FIFO speech policy still apply.
|
||||||
|
handler.speech.Enqueue(speechRequest{Text: title, Role: "system", Cause: "window-switcher", BypassFocusGate: true})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Copyright (c) 2026 Storm Dragon
|
||||||
|
// Authors: Storm Dragon, Angus Kola
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/braille"
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/config"
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
|
||||||
|
)
|
||||||
|
|
||||||
|
type switcherBrailleRecorder struct{ flashes chan string }
|
||||||
|
|
||||||
|
func (*switcherBrailleRecorder) EnqueueFrame(braille.Frame) {}
|
||||||
|
func (display *switcherBrailleRecorder) Flash(text string, _ config.BrailleOptions) bool {
|
||||||
|
display.flashes <- text
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func mate_switcher_event(eventType, title string, foreground atspi.ForegroundState) atspi.Event {
|
||||||
|
event := ownerEvent(eventType, ":1.43", "/switcher", "/switcher/status")
|
||||||
|
event.Source.ApplicationName, event.Source.ApplicationProcess = "marco", "marco"
|
||||||
|
event.Source.Role, event.Source.Name = "status bar", title
|
||||||
|
event.Source.StatesKnown, event.Source.Visible, event.Source.Showing = true, true, true
|
||||||
|
event.Foreground, event.ForegroundMismatch = foreground, true
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMateWindowSwitcherOutsideForeground(t *testing.T) {
|
||||||
|
adapter := &foregroundAdapter{fakeAdapter: fakeAdapter{events: make(chan atspi.Event, 32)}, changes: make(chan struct{}, 1)}
|
||||||
|
foreground := atspi.ForegroundState{Client: xorg.Foreground{Known: true, Window: 101}, Epoch: 1}
|
||||||
|
adapter.state.Store(&foreground)
|
||||||
|
queue := &fakeSpeechQueue{spoken: make(chan string, 32), requests: make(chan speechRequest, 32)}
|
||||||
|
live := &liveSettings{}
|
||||||
|
live.Store(config.DefaultSettings())
|
||||||
|
display := &switcherBrailleRecorder{flashes: make(chan string, 16)}
|
||||||
|
output := outputQueue{speech: queue, braille: display, live: live}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
_ = processEventsWithSettings(ctx, slog.New(slog.DiscardHandler), output, adapter, nil, nil, 0, live)
|
||||||
|
}()
|
||||||
|
defer func() { cancel(); <-done }()
|
||||||
|
|
||||||
|
focus := ownerEvent(atspi.FocusedStateEvent, ":1.42", "/editor", "/editor/button")
|
||||||
|
focus.Source.Role, focus.Source.Name, focus.Source.Focused = "push button", "Save", true
|
||||||
|
focus.Detail1, focus.Foreground = 1, foreground
|
||||||
|
adapter.events <- focus
|
||||||
|
expectActivationSpeech(t, queue.spoken, "Save, push button")
|
||||||
|
<-queue.requests
|
||||||
|
|
||||||
|
// Replay the log's non-focused status insertion, then repeated forward
|
||||||
|
// and reverse selection updates without an X11 foreground change.
|
||||||
|
for _, title := range []string{"Editor", "Terminal", "Editor"} {
|
||||||
|
adapter.events <- mate_switcher_event(atspi.TextInsertedEvent, title, foreground)
|
||||||
|
expectActivationSpeech(t, queue.spoken, title)
|
||||||
|
request := <-queue.requests
|
||||||
|
if request.Cause != "window-switcher" || request.Role != "system" || !request.BypassFocusGate {
|
||||||
|
t.Fatalf("switcher request = %+v", request)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case got := <-display.flashes:
|
||||||
|
if got != title {
|
||||||
|
t.Fatalf("Braille title = %q; want %q", got, title)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("missing switcher Braille message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showing := mate_switcher_event(atspi.ShowingStateEvent, "Browser", foreground)
|
||||||
|
showing.Detail1 = 1
|
||||||
|
adapter.events <- showing
|
||||||
|
expectActivationSpeech(t, queue.spoken, "Browser")
|
||||||
|
<-queue.requests
|
||||||
|
adapter.events <- mate_switcher_event(atspi.SelectionChangedEvent, "Terminal", foreground)
|
||||||
|
expectActivationSpeech(t, queue.spoken, "Terminal")
|
||||||
|
<-queue.requests
|
||||||
|
|
||||||
|
// Duplicate name signals, hidden/destroyed switchers, stale epochs and
|
||||||
|
// another application's status bar cannot borrow the exception.
|
||||||
|
for _, change := range []func(*atspi.Event){
|
||||||
|
func(e *atspi.Event) { e.Type = atspi.AccessibleNameEvent },
|
||||||
|
func(e *atspi.Event) { e.Type, e.Detail1 = atspi.ShowingStateEvent, 0 },
|
||||||
|
func(e *atspi.Event) { e.Source.Role = "label" },
|
||||||
|
func(e *atspi.Event) { e.Source.StatesKnown = false },
|
||||||
|
func(e *atspi.Event) { e.Source.Visible = false },
|
||||||
|
func(e *atspi.Event) { e.Source.Showing = false },
|
||||||
|
func(e *atspi.Event) { e.Source.Defunct = true },
|
||||||
|
func(e *atspi.Event) { e.Foreground.Epoch-- },
|
||||||
|
func(e *atspi.Event) { e.Source.ApplicationName, e.Source.ApplicationProcess = "editor", "editor" },
|
||||||
|
func(e *atspi.Event) { e.Source.ApplicationProcess = "python3" },
|
||||||
|
func(e *atspi.Event) { e.Window.BusName = ":1.44" },
|
||||||
|
func(e *atspi.Event) { e.Source.Name = " " },
|
||||||
|
} {
|
||||||
|
event := mate_switcher_event(atspi.TextInsertedEvent, "Unwanted output", foreground)
|
||||||
|
change(&event)
|
||||||
|
adapter.events <- event
|
||||||
|
}
|
||||||
|
// A name change on the original control proves that switcher output did
|
||||||
|
// not replace application ownership or the locus of focus. FIFO provides
|
||||||
|
// a barrier for every rejected event above.
|
||||||
|
focus.Type, focus.Source.Name = atspi.AccessibleNameEvent, "Still focused"
|
||||||
|
adapter.events <- focus
|
||||||
|
expectActivationSpeech(t, queue.spoken, "Still focused, push button")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMateWindowSwitcherTerminalHandoffAndLock(t *testing.T) {
|
||||||
|
queue := &fakeSpeechQueue{spoken: make(chan string, 4)}
|
||||||
|
gate := &focusGate{}
|
||||||
|
gate.suspended.Store(true)
|
||||||
|
handler := accessibilityEventHandler{focusGate: gate, speech: gatedSpeech{speech: queue, gate: gate}}
|
||||||
|
event := mate_switcher_event(atspi.TextInsertedEvent, "Editor", atspi.ForegroundState{})
|
||||||
|
if !handler.handle_window_switcher(event) {
|
||||||
|
t.Fatal("switcher selection was not handled")
|
||||||
|
}
|
||||||
|
expectActivationSpeech(t, queue.spoken, "Editor")
|
||||||
|
// The top-level accessibility handler must reject the event before the
|
||||||
|
// special path, even though terminal-handoff output is allowed above.
|
||||||
|
gate.secure.Store(true)
|
||||||
|
handler.handle(event)
|
||||||
|
if len(queue.spoken) != 0 {
|
||||||
|
t.Fatal("switcher selection spoke during screen lock")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -433,6 +433,10 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
|
|||||||
adapter.forgetWindowDocuments(originID)
|
adapter.forgetWindowDocuments(originID)
|
||||||
}
|
}
|
||||||
invalidated := adapter.applyTreeInvalidation(eventType, event)
|
invalidated := adapter.applyTreeInvalidation(eventType, event)
|
||||||
|
if selection := adapter.window_switcher_event(event, eventType); selection != nil {
|
||||||
|
adapter.enqueueSecureEvent(*selection, secureEpoch)
|
||||||
|
return
|
||||||
|
}
|
||||||
if eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 &&
|
if eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 &&
|
||||||
adapter.applicationProcesses[originID.BusName] == "pidgin" {
|
adapter.applicationProcesses[originID.BusName] == "pidgin" {
|
||||||
// Read-only transcripts need not own native focus. Forward their
|
// Read-only transcripts need not own native focus. Forward their
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Copyright (c) 2026 Storm Dragon
|
||||||
|
// Authors: Storm Dragon, Angus Kola
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package atspi
|
||||||
|
|
||||||
|
/*
|
||||||
|
#include "adapter_native.h"
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
// Capture only the verified window manager's visible status container, before
|
||||||
|
// background filtering or selected-child resolution. All native queries run
|
||||||
|
// on the GLib owner; no native references or mutable switcher state survive.
|
||||||
|
func (adapter *Adapter) window_switcher_event(event *C.AtspiEvent, eventType string) *Event {
|
||||||
|
detail1 := int(C.cthulhu_event_detail1(event))
|
||||||
|
if !is_window_switcher_selection_type(eventType, detail1) ||
|
||||||
|
C.atspi_accessible_get_role(event.source, nil) != C.ATSPI_ROLE_STATUS_BAR ||
|
||||||
|
adapter.applicationProcess(event.source) != "marco" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Insertions can precede accessible-name updates. Read the current name
|
||||||
|
// and state instead of announcing the previous selection from the cache.
|
||||||
|
C.atspi_accessible_clear_cache_single(event.source)
|
||||||
|
snapshot := snapshotFromNativeWithCaret(event.source, false, false, false, false)
|
||||||
|
snapshot.ApplicationProcess = "marco"
|
||||||
|
selection := NewEvent(eventType, snapshot)
|
||||||
|
selection.Detail1 = detail1
|
||||||
|
selection.Foreground = adapter.Foreground()
|
||||||
|
selection.ForegroundMismatch = true
|
||||||
|
window := C.cthulhu_window_ancestor(event.source)
|
||||||
|
if window == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
selection.Window = nativeObjectID(window)
|
||||||
|
C.g_object_unref(C.gpointer(window))
|
||||||
|
if !IsWindowSwitcherSelection(selection) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &selection
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Copyright (c) 2026 Storm Dragon
|
||||||
|
// Authors: Storm Dragon, Angus Kola
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package atspi
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
func is_window_switcher_selection_type(eventType string, detail1 int) bool {
|
||||||
|
switch eventType {
|
||||||
|
case TextInsertedEvent, SelectionChangedEvent:
|
||||||
|
return true
|
||||||
|
case ShowingStateEvent:
|
||||||
|
return detail1 != 0
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsWindowSwitcherSelection matches Marco's transient selection container.
|
||||||
|
// Like Orca's switcher script, it uses insert/show/selection events and the
|
||||||
|
// container's name. The following accessible-name signal is not another move.
|
||||||
|
// Process identity and visible state keep ordinary background status updates
|
||||||
|
// out of this exception. This does not grant native or application focus.
|
||||||
|
func IsWindowSwitcherSelection(event Event) bool {
|
||||||
|
source := event.Source
|
||||||
|
return is_window_switcher_selection_type(event.Type, event.Detail1) &&
|
||||||
|
source.ApplicationProcess == "marco" && strings.EqualFold(source.ApplicationName, "marco") &&
|
||||||
|
source.Role == "status bar" && source.StatesKnown && source.Visible && source.Showing && !source.Defunct &&
|
||||||
|
source.ID().Valid() && event.Window.Valid() && event.Window.BusName == source.BusName
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// 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 TestNativeMateWindowSwitcherOutsideForeground(t *testing.T) {
|
||||||
|
if os.Getenv("CTHULHU_NATIVE_WINDOW_SWITCHER") != "1" {
|
||||||
|
t.Skip("requires private Xorg/AT-SPI session")
|
||||||
|
}
|
||||||
|
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(name string) (io.WriteCloser, string) {
|
||||||
|
command := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/gtk_window_switcher.py", name)
|
||||||
|
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) != 2 || fields[0] != "ready" {
|
||||||
|
t.Fatalf("fixture readiness: %q, %v", line, err)
|
||||||
|
}
|
||||||
|
return input, fields[1]
|
||||||
|
}
|
||||||
|
switcher, _ := start("marco")
|
||||||
|
foreground, xid := start("editor")
|
||||||
|
if _, err := adapter.CaptureDesktop(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if output, err := exec.CommandContext(ctx, "xdotool", "windowfocus", "--sync", xid).CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("focus: %s, %v", output, err)
|
||||||
|
}
|
||||||
|
wait := func(eventType, title string, switcher bool) Event {
|
||||||
|
t.Helper()
|
||||||
|
var last Event
|
||||||
|
count := 0
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case event := <-adapter.Events():
|
||||||
|
last = event
|
||||||
|
count++
|
||||||
|
if event.Type == eventType && (event.Source.Name == title || event.Payload == title) {
|
||||||
|
if IsWindowSwitcherSelection(event) != switcher {
|
||||||
|
t.Fatalf("switcher classification: %+v", event)
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatalf("missing %s %q; background-filtered=%d received=%d last=%+v", eventType, title, adapter.backgroundEventsFiltered.Load(), count, last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintln(foreground, "select Foreground ready")
|
||||||
|
owned := wait(TextInsertedEvent, "Foreground ready", false)
|
||||||
|
if owned.ForegroundMismatch || !owned.Foreground.Client.Known {
|
||||||
|
t.Fatalf("fixture has no correlated foreground: %+v", owned)
|
||||||
|
}
|
||||||
|
for _, title := range []string{"Editor", "Terminal", "Editor"} {
|
||||||
|
fmt.Fprintln(switcher, "select "+title)
|
||||||
|
event := wait(TextInsertedEvent, title, true)
|
||||||
|
if !event.ForegroundMismatch || event.Foreground != owned.Foreground || event.Source.Focused {
|
||||||
|
t.Fatalf("switcher acquired foreground/native focus: %+v", event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintln(switcher, "showing")
|
||||||
|
wait(ShowingStateEvent, "Editor", true)
|
||||||
|
// The provider stays separate from the old foreground after the popup
|
||||||
|
// disappears. Ordinary application events still use native correlation.
|
||||||
|
fmt.Fprintln(switcher, "hide")
|
||||||
|
fmt.Fprintln(foreground, "select Foreground retained")
|
||||||
|
retained := wait(TextInsertedEvent, "Foreground retained", false)
|
||||||
|
if retained.ForegroundMismatch || retained.Window != owned.Window {
|
||||||
|
t.Fatalf("switcher changed foreground ownership: %+v", retained)
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+1
@@ -1,4 +1,5 @@
|
|||||||
id,python_source,subsystem,behavior,owner,go_destination,status,automated_evidence,x11_evidence,wayland_evidence,speech_evidence,braille_evidence,notes
|
id,python_source,subsystem,behavior,owner,go_destination,status,automated_evidence,x11_evidence,wayland_evidence,speech_evidence,braille_evidence,notes
|
||||||
|
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.
|
||||||
gtk-alert-message,src/cthulhu/speech_generator.py;src/cthulhu/script_utilities.py,speech,Read a GTK alert message before its first focused button,,internal/atspi internal/app,in-progress,go test ./internal/app ./internal/atspi -count=1; python3 tests/native_alert.py,Private GTK/Xvfb/AT-SPI fixture captured the complete question on 2026-09-26.,,Physical FastSMRW listening pending.,,Alert window creation captures its label text; activation does not interrupt the message; the first button appends and later buttons use ordinary focus speech.
|
gtk-alert-message,src/cthulhu/speech_generator.py;src/cthulhu/script_utilities.py,speech,Read a GTK alert message before its first focused button,,internal/atspi internal/app,in-progress,go test ./internal/app ./internal/atspi -count=1; python3 tests/native_alert.py,Private GTK/Xvfb/AT-SPI fixture captured the complete question on 2026-09-26.,,Physical FastSMRW listening pending.,,Alert window creation captures its label text; activation does not interrupt the message; the first button appends and later buttons use ordinary focus speech.
|
||||||
yad-simple-prompt,src/cthulhu/formatting.py;src/cthulhu/scripts/default.py,speech,Read a simple YAD frame question before its first focused button,,internal/atspi internal/app,implemented,go test ./internal/app ./internal/atspi -count=1; python3 tests/native_alert.py,Private YAD/Xvfb/AT-SPI fixture captured the exact question on frame activation or first button focus. The live 2026-09-27 log showed a third path where activation targets the focused button; an event-loop regression covers its speech order.,,Maintainer confirmed the YAD question was spoken on the updated build on 2026-09-27.,,YAD exposes the prompt as a frame with layout nodes and a label plus buttons. The generic Python frame format appears to omit the label; this Go addition follows the user's explicit example. A strict simple prompt shape limits frame text capture and bounded first-button refresh handles late labels.
|
yad-simple-prompt,src/cthulhu/formatting.py;src/cthulhu/scripts/default.py,speech,Read a simple YAD frame question before its first focused button,,internal/atspi internal/app,implemented,go test ./internal/app ./internal/atspi -count=1; python3 tests/native_alert.py,Private YAD/Xvfb/AT-SPI fixture captured the exact question on frame activation or first button focus. The live 2026-09-27 log showed a third path where activation targets the focused button; an event-loop regression covers its speech order.,,Maintainer confirmed the YAD question was spoken on the updated build on 2026-09-27.,,YAD exposes the prompt as a frame with layout nodes and a label plus buttons. The generic Python frame format appears to omit the label; this Go addition follows the user's explicit example. A strict simple prompt shape limits frame text capture and bounded first-button refresh handles late labels.
|
||||||
radio-group-focus,src/cthulhu/generator.py;src/cthulhu/speech_generator.py,speech,Speak the radio group question with a focused option,,internal/atspi internal/presentation internal/preferences,implemented,go test ./internal/presentation ./internal/atspi ./internal/preferences -count=1; python3 tests/native_alert.py,Private GTK radio group focus captured Punctuation Level.,,Physical preferences speech pending.,,A named panel or labelled-by relation supplies the group question; each focused radio presents that name before its option. The Braille verbosity radios now have a named frame.
|
radio-group-focus,src/cthulhu/generator.py;src/cthulhu/speech_generator.py,speech,Speak the radio group question with a focused option,,internal/atspi internal/presentation internal/preferences,implemented,go test ./internal/presentation ./internal/atspi ./internal/preferences -count=1; python3 tests/native_alert.py,Private GTK radio group focus captured Punctuation Level.,,Physical preferences speech pending.,,A named panel or labelled-by relation supplies the group question; each focused radio presents that name before its option. The Braille verbosity radios now have a named frame.
|
||||||
|
|||||||
|
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Copyright (c) 2026 Storm Dragon
|
||||||
|
# Authors: Storm Dragon, Angus Kola
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
"""A non-focusable status container in a private Marco-shaped GTK provider."""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
providerName = sys.argv[1]
|
||||||
|
GLib.set_prgname(providerName)
|
||||||
|
# Match /proc/PID/comm as well as the AT-SPI name; a name alone must not
|
||||||
|
# enable the window-manager exception.
|
||||||
|
if providerName == "marco":
|
||||||
|
if ctypes.CDLL(None).prctl(15, b"marco", 0, 0, 0) != 0:
|
||||||
|
raise RuntimeError("cannot set fixture process name")
|
||||||
|
|
||||||
|
window = Gtk.Window(title="Switcher fixture " + providerName)
|
||||||
|
if providerName == "marco":
|
||||||
|
window.set_accept_focus(False)
|
||||||
|
window.set_focus_on_map(False)
|
||||||
|
label = Gtk.Label(label="Initial selection")
|
||||||
|
label.get_accessible().set_role(Atk.Role.STATUSBAR)
|
||||||
|
window.add(label)
|
||||||
|
window.show_all()
|
||||||
|
Atk.get_root().set_name(providerName)
|
||||||
|
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.startswith("select "):
|
||||||
|
label.set_text(command.removeprefix("select "))
|
||||||
|
elif command == "showing":
|
||||||
|
label.get_accessible().notify_state_change(Atk.StateType.SHOWING, True)
|
||||||
|
elif command == "hide":
|
||||||
|
label.hide()
|
||||||
|
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()), flush=True) or False)
|
||||||
|
Gtk.main()
|
||||||
Reference in new issue
Block a user