Fix for repeating in some instances, e.g. pidgin.
This commit is contained in:
1 parent
4d29f2bb23
commit
b232c1eebb
20 files changed
+921
-7
No files matched your search
@@ -129,6 +129,12 @@ func (presenter *eventPresenter) speak(event atspi.Event, capturedIntent ...inpu
|
||||
state.typingText = event.Source.Text
|
||||
}
|
||||
}
|
||||
if state.submitted_text_clear(event, recentInput(inputSource)) {
|
||||
// Focus, caret suppression and Braille were already updated by the
|
||||
// event handler. Only omit speech for the entry's submission clear.
|
||||
logger.Debug("skipped deletion feedback after submitting text", "role", event.Source.Role)
|
||||
return
|
||||
}
|
||||
if event.Type == atspi.CaretEvent && (intent == input.WordIntent || intent == input.LineIntent) && event.Source.Misspelled &&
|
||||
settings.Reading.SpeakMisspelledIndicator && !settings.Reading.OnlySpeakDisplayedText {
|
||||
spoken = presentation.SpeechWithOptions([]string{strings.TrimSuffix(text, ", misspelled"), "misspelled"}, table.options)
|
||||
|
||||
@@ -21,6 +21,14 @@ func TestNotificationProvidersAndHistoryMatchReferenceBehavior(t *testing.T) {
|
||||
expectedSpeech []string
|
||||
expectedHistory string
|
||||
}{
|
||||
{
|
||||
name: "MATE notification daemon labels",
|
||||
event: notificationSnapshot("mate-notification-daemon", atspi.WindowCreateEvent, atspi.NewSnapshot(
|
||||
"/notification", "", "frame", "", "Mail arrived New message",
|
||||
)),
|
||||
expectedSpeech: []string{"Notification Mail arrived New message"},
|
||||
expectedHistory: "Notification Mail arrived New message",
|
||||
},
|
||||
{
|
||||
name: "xfce notification daemon labels",
|
||||
event: notificationSnapshot("xfce4-notifyd", atspi.WindowCreateEvent, atspi.NewSnapshot(
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// 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 TestNativeMatePanelMenuPresentation(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_NATIVE_PANEL_MENU") != "1" {
|
||||
t.Skip("requires private Xorg/AT-SPI session")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
adapter := atspi.New()
|
||||
if err := adapter.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adapter.Stop(context.Background())
|
||||
start := func(name string) (io.WriteCloser, string) {
|
||||
t.Helper()
|
||||
command := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/gtk_panel_menu.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]
|
||||
}
|
||||
panel, _ := start("mate-panel")
|
||||
editor, xid := 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)
|
||||
}
|
||||
}
|
||||
key("windowfocus", "--sync", xid)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(editor, "edit Foreground ready")
|
||||
wait("Foreground ready")
|
||||
for iteration := 0; iteration < 2; iteration++ {
|
||||
fmt.Fprintln(panel, "popup")
|
||||
wait("menu")
|
||||
// The initial bare menu may have no label. Navigation must acquire
|
||||
// its first item, including after dismissal and reopening.
|
||||
for _, step := range []struct{ key, name string }{
|
||||
{"Down", "First application"}, {"Down", "Utilities"}, {"Right", "Example utility"},
|
||||
{"Left", "Utilities"}, {"Down", "Last application"},
|
||||
} {
|
||||
key("key", step.key)
|
||||
wait(step.name)
|
||||
}
|
||||
key("key", "Escape")
|
||||
text := fmt.Sprintf("Returned to editor %d", iteration)
|
||||
fmt.Fprintln(editor, "edit "+text)
|
||||
wait(text)
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
)
|
||||
|
||||
// A submission can clear an entry before its message appears in a transcript.
|
||||
// Do not mistake that clear for an editing command. Unknown input/caret data,
|
||||
// other controls and selected-text replacement keep their existing feedback.
|
||||
func (state *eventLoopState) submitted_text_clear(event atspi.Event, activity input.Activity) bool {
|
||||
source := event.Source
|
||||
if event.Type != atspi.TextDeletedEvent || event.Payload == "" || event.Detail1 != 0 ||
|
||||
!activity.PlainEnter || activity.Sequence == 0 || !atspi.IsEchoEditField(source) ||
|
||||
(source.Role != "text" && source.Role != "entry") || source.Autocomplete || source.LiveRegion ||
|
||||
source.ID() != state.focusedID || !source.TextCaretKnown || source.BrailleCaret != 0 ||
|
||||
source.BrailleLineStart != 0 || source.Text != "" || source.TextBeforeCaret != "" || source.BrailleLine != "" ||
|
||||
source.SelectionEnd > source.SelectionStart {
|
||||
return false
|
||||
}
|
||||
if state.selection.id == source.ID() && state.selection.end > state.selection.start {
|
||||
return false
|
||||
}
|
||||
// Some providers report selection removal before the deletion itself.
|
||||
return state.selectionCaretID != source.ID() || state.selectionCaretSequence != activity.Sequence
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
|
||||
)
|
||||
|
||||
func TestNativeSubmissionClearAndSelectedReplacement(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_NATIVE_SUBMISSION_ECHO") != "1" {
|
||||
t.Skip("requires native_test_session.py private GTK session")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 25*time.Second)
|
||||
defer cancel()
|
||||
adapter := atspi.New()
|
||||
if err := adapter.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adapter.Stop(context.Background())
|
||||
listener := xorg.New()
|
||||
if err := listener.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Stop(context.Background())
|
||||
previousBus := ""
|
||||
for _, mode := range []string{"submit", "replace"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
fixture := exec.CommandContext(ctx, "/usr/bin/python3", filepath.Join("..", "..", "tests", "gtk_submit_echo.py"), mode)
|
||||
output, err := fixture.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixture.Stderr = os.Stderr
|
||||
if err := fixture.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { fixture.Process.Kill(); fixture.Wait() }()
|
||||
if line, err := bufio.NewReader(output).ReadString('\n'); err != nil || line != "READY\n" {
|
||||
t.Fatalf("GTK fixture readiness: %q %v", line, err)
|
||||
}
|
||||
keys := func(args ...string) {
|
||||
t.Helper()
|
||||
if out, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil {
|
||||
t.Fatalf("xdotool: %v: %s", err, out)
|
||||
}
|
||||
}
|
||||
keys("search", "--sync", "--onlyvisible", "--pid", strconv.Itoa(fixture.Process.Pid), "--name", "^Submission echo probe$", "windowfocus", "--sync")
|
||||
var focus atspi.Event
|
||||
for focus.Source.Name != "Message input" || focus.Source.BusName == previousBus || !isFocusGainEvent(focus) {
|
||||
select {
|
||||
case focus = <-adapter.Events():
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
}
|
||||
previousBus = focus.Source.BusName
|
||||
keys("key", "--clearmodifiers", "Return")
|
||||
inputLoop:
|
||||
for {
|
||||
select {
|
||||
case key := <-listener.Events():
|
||||
if key.Pressed && key.Name == "Return" {
|
||||
break inputLoop
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case event := <-adapter.Events():
|
||||
if event.Source.ID() != focus.Source.ID() || event.Type != atspi.TextDeletedEvent {
|
||||
continue
|
||||
}
|
||||
if !listener.RecentInput().PlainEnter {
|
||||
t.Fatal("native X11 Enter activity missing")
|
||||
}
|
||||
got := submitted_text_speech(t, listener, focus, event)
|
||||
if mode == "submit" && len(got) != 1 {
|
||||
t.Fatalf("native submission echoed deletion: %q; snapshot=%+v", got, event.Source)
|
||||
}
|
||||
if mode == "replace" && (len(got) != 2 || got[1] != "A sample message.") {
|
||||
t.Fatalf("native selected replacement lost feedback: %q", got)
|
||||
}
|
||||
t.Logf("native %s: %q", mode, got)
|
||||
return
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
)
|
||||
|
||||
func TestSubmittedMessageIsAnnouncedOnce(t *testing.T) {
|
||||
for _, key := range []string{"Return", "KP_Enter"} {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
entry := ownerEvent(atspi.FocusEvent, ":1.42", "/window", "/entry")
|
||||
entry.Source.Role, entry.Source.ApplicationProcess = "text", "pidgin"
|
||||
entry.Source.Editable, entry.Source.Focused, entry.Source.TextCaretKnown = true, true, true
|
||||
deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, entry.Source, "A sample message.")
|
||||
deletion.Window = entry.Window
|
||||
message := pidginMessageEvent("Username: A sample message.")
|
||||
tracker := &input.IntentTracker{}
|
||||
tracker.Observe(input.NewKeyEvent(0, true, key))
|
||||
tracker.Observe(input.NewKeyEvent(0, false, key))
|
||||
got := submitted_text_speech(t, tracker, entry, deletion,
|
||||
atspi.NewEvent(atspi.CaretEvent, entry.Source), message)
|
||||
want := []string{"text", "Username: A sample message."}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("submission speech = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmissionClearPreservesEditingFeedback(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
keys []string
|
||||
selected bool
|
||||
removeFirst bool
|
||||
insert string
|
||||
remainingText string
|
||||
caretKnown bool
|
||||
want []string
|
||||
}{
|
||||
{name: "plain Enter clear", keys: []string{"Return"}, caretKnown: true},
|
||||
{name: "Backspace", keys: []string{"BackSpace"}, caretKnown: true, want: []string{"old"}},
|
||||
{name: "Delete", keys: []string{"Delete"}, caretKnown: true, want: []string{"old"}},
|
||||
{name: "word deletion", keys: []string{"Control_L", "BackSpace"}, caretKnown: true, want: []string{"old"}},
|
||||
{name: "cut", keys: []string{"Control_L", "x"}, caretKnown: true, want: []string{"cut"}},
|
||||
{name: "paste replacement", keys: []string{"Control_L", "v"}, insert: "new", caretKnown: true, want: []string{"old", "pasted from clipboard"}},
|
||||
{name: "undo", keys: []string{"Control_L", "z"}, caretKnown: true, want: []string{"old"}},
|
||||
{name: "redo", keys: []string{"Control_L", "Shift_L", "z"}, caretKnown: true, want: []string{"old"}},
|
||||
{name: "typing replaces selection", keys: []string{"x"}, selected: true, insert: "x", caretKnown: true, want: []string{"old", "x"}},
|
||||
{name: "Enter replaces selection", keys: []string{"Return"}, selected: true, insert: "\n", caretKnown: true, want: []string{"old"}},
|
||||
{name: "selection removal precedes replacement", keys: []string{"Return"}, selected: true, removeFirst: true, caretKnown: true, want: []string{"selection removed", "old"}},
|
||||
{name: "Shift Enter", keys: []string{"Shift_L", "Return"}, caretKnown: true, want: []string{"old"}},
|
||||
{name: "unknown caret", keys: []string{"Return"}, want: []string{"old"}},
|
||||
{name: "no keyboard evidence", caretKnown: true, want: []string{"old"}},
|
||||
{name: "Enter leaves text", keys: []string{"Return"}, caretKnown: true, remainingText: "remaining", want: []string{"old"}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
tracker := &input.IntentTracker{}
|
||||
for _, key := range test.keys {
|
||||
tracker.Observe(input.NewKeyEvent(0, true, key))
|
||||
}
|
||||
entry := atspi.Snapshot{BusName: ":1.42", Path: "/entry", Role: "entry", Editable: true, Focused: true, TextCaretKnown: test.caretKnown}
|
||||
if test.selected {
|
||||
entry.SelectionStart, entry.SelectionEnd, entry.SelectionText = 0, 3, "old"
|
||||
}
|
||||
events := []atspi.Event{atspi.NewEvent(atspi.FocusEvent, entry)}
|
||||
entry.SelectionStart, entry.SelectionEnd, entry.SelectionText = 0, 0, ""
|
||||
entry.Text = test.remainingText
|
||||
if test.removeFirst {
|
||||
events = append(events, atspi.NewEvent(atspi.TextSelectionChangedEvent, entry))
|
||||
}
|
||||
events = append(events, atspi.NewEventWithPayload(atspi.TextDeletedEvent, entry, "old"))
|
||||
if test.insert != "" {
|
||||
events = append(events, atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, test.insert))
|
||||
}
|
||||
got := submitted_text_speech(t, tracker, events...)
|
||||
// Initial focus also presents an existing selection.
|
||||
got = got[1:]
|
||||
if !slices.Equal(got, test.want) {
|
||||
t.Fatalf("editing speech = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func submitted_text_speech(t *testing.T, inputSource any, events ...atspi.Event) []string {
|
||||
t.Helper()
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, len(events))}
|
||||
for _, event := range events {
|
||||
adapter.events <- event
|
||||
}
|
||||
close(adapter.events)
|
||||
queue := &remoteSpeechQueue{}
|
||||
if err := processEventsWithInput(context.Background(), slog.New(slog.DiscardHandler), queue, adapter,
|
||||
nil, nil, 0, nil, inputSource, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
texts := make([]string, 0, len(queue.requests))
|
||||
for _, request := range queue.requests {
|
||||
texts = append(texts, request.Text)
|
||||
}
|
||||
return texts
|
||||
}
|
||||
@@ -462,7 +462,24 @@ cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean capt
|
||||
}
|
||||
snapshot.description = atspi_accessible_get_description(source, &error);
|
||||
if (error != NULL) {
|
||||
g_error_free(error);
|
||||
g_clear_error(&error);
|
||||
}
|
||||
// Orca uses the containing panel's label for otherwise unlabeled icons.
|
||||
// Keep an icon's own name/description and never borrow from other roles.
|
||||
if (!snapshot.defunct && g_strcmp0(snapshot.role, "icon") == 0 &&
|
||||
(snapshot.name == NULL || *snapshot.name == '\0') &&
|
||||
(snapshot.description == NULL || *snapshot.description == '\0')) {
|
||||
AtspiAccessible *parent = atspi_accessible_get_parent(source, &error);
|
||||
if (error == NULL && parent != NULL && atspi_accessible_get_role(parent, NULL) == ATSPI_ROLE_PANEL) {
|
||||
g_free(snapshot.name);
|
||||
snapshot.name = atspi_accessible_get_name(parent, NULL);
|
||||
if (snapshot.name == NULL || *snapshot.name == '\0') {
|
||||
g_free(snapshot.name);
|
||||
snapshot.name = atspi_accessible_get_description(parent, NULL);
|
||||
}
|
||||
}
|
||||
if (parent != NULL) g_object_unref(parent);
|
||||
g_clear_error(&error);
|
||||
}
|
||||
if (captureDocumentMetadata) {
|
||||
cthulhu_capture_document_metrics(source, &snapshot);
|
||||
|
||||
@@ -27,7 +27,8 @@ 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.foregroundWindow.Valid() && origin.BusName != adapter.foregroundWindow.BusName) &&
|
||||
adapter.applicationProcess(event.source) != "mate-panel" {
|
||||
return true
|
||||
}
|
||||
window := C.cthulhu_window_ancestor(event.source)
|
||||
|
||||
@@ -504,8 +504,10 @@ gboolean cthulhu_active_menu_window(AtspiAccessible *window) {
|
||||
AtspiStateSet *states = atspi_accessible_get_state_set(window);
|
||||
gboolean active = states != NULL && atspi_state_set_contains(states, ATSPI_STATE_ACTIVE);
|
||||
gboolean showing = states != NULL && atspi_state_set_contains(states, ATSPI_STATE_SHOWING);
|
||||
gboolean unavailable = states == NULL || atspi_state_set_contains(states, ATSPI_STATE_ICONIFIED) ||
|
||||
atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT);
|
||||
if (states != NULL) g_object_unref(states);
|
||||
if (!active || !showing || atspi_accessible_get_child_count(window, NULL) != 1) return FALSE;
|
||||
if (!active || !showing || unavailable || atspi_accessible_get_child_count(window, NULL) != 1) return FALSE;
|
||||
AtspiAccessible *child = atspi_accessible_get_child_at_index(window, 0, NULL);
|
||||
if (child == NULL) return FALSE;
|
||||
AtspiRole childRole = atspi_accessible_get_role(child, NULL);
|
||||
|
||||
@@ -71,7 +71,7 @@ func (adapter *Adapter) matchForeground(window *C.AtspiAccessible, id ObjectID,
|
||||
if !state.Known {
|
||||
return true
|
||||
}
|
||||
if state.Window == 0 || window == nil {
|
||||
if window == nil {
|
||||
if check != nil {
|
||||
check.Reason = "no X11 foreground window or accessible window"
|
||||
}
|
||||
@@ -82,6 +82,18 @@ func (adapter *Adapter) matchForeground(window *C.AtspiAccessible, id ObjectID,
|
||||
check.AccessiblePID = pid
|
||||
}
|
||||
pid = adapter.foregroundProcessID(pid)
|
||||
// Desktop panel menus can grab the keyboard while X11 still names the
|
||||
// previous application. Orca accepts their active, showing menu window.
|
||||
// Keep the correlated X11 owner so dismissal can return to that client.
|
||||
if adapter.is_panel_menu(window) {
|
||||
return true
|
||||
}
|
||||
if state.Window == 0 {
|
||||
if check != nil {
|
||||
check.Reason = "no X11 foreground window or accessible window"
|
||||
}
|
||||
return false
|
||||
}
|
||||
if adapter.foregroundWindow.Valid() {
|
||||
if adapter.foregroundWindow == id {
|
||||
return true
|
||||
@@ -165,6 +177,17 @@ func (adapter *Adapter) matchForeground(window *C.AtspiAccessible, id ObjectID,
|
||||
return true
|
||||
}
|
||||
|
||||
// The exception is for a native menu from the panel process, never an
|
||||
// arbitrary panel control or a provider claiming the panel's accessible name.
|
||||
// Query fresh window state on the GLib owner; no popup state is retained.
|
||||
func (adapter *Adapter) is_panel_menu(window *C.AtspiAccessible) bool {
|
||||
if adapter.applicationProcess(window) != "mate-panel" {
|
||||
return false
|
||||
}
|
||||
C.atspi_accessible_clear_cache_single(window)
|
||||
return C.cthulhu_active_menu_window(window) != 0
|
||||
}
|
||||
|
||||
func (adapter *Adapter) replacesForegroundWindow(window *C.AtspiAccessible, id ObjectID, foregroundPID, pid int) bool {
|
||||
if foregroundPID <= 0 || pid != foregroundPID || id.BusName != adapter.foregroundWindow.BusName {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// 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 TestNativeMatePanelMenuOutsideForeground(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_NATIVE_PANEL_MENU") != "1" {
|
||||
t.Skip("requires private Xorg/AT-SPI session")
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name, processName string
|
||||
empty bool
|
||||
}{
|
||||
{"over editor", "mate-panel", false},
|
||||
{"empty desktop", "mate-panel", true},
|
||||
{"impostor", "impostor", false},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) { check_native_panel_menu(t, test.processName, test.empty) })
|
||||
}
|
||||
}
|
||||
|
||||
func check_native_panel_menu(t *testing.T, processName string, empty bool) {
|
||||
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(name string) (io.WriteCloser, string) {
|
||||
t.Helper()
|
||||
command := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/gtk_panel_menu.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]
|
||||
}
|
||||
panel, _ := start(processName)
|
||||
editor, xid := start("editor")
|
||||
tree, err := adapter.CaptureDesktop(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check_native_panel_icons(t, tree)
|
||||
key := func(args ...string) {
|
||||
t.Helper()
|
||||
if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil {
|
||||
t.Fatalf("xdotool: %s, %v", output, err)
|
||||
}
|
||||
}
|
||||
key("windowfocus", "--sync", xid)
|
||||
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: %v; filtered=%d last=%+v", ctx.Err(), adapter.backgroundEventsFiltered.Load(), last)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(editor, "edit Foreground ready")
|
||||
owned := wait(func(event Event) bool { return event.Payload == "Foreground ready" && !event.ForegroundMismatch })
|
||||
if empty {
|
||||
key("windowfocus", "0")
|
||||
for adapter.Foreground().Client.Window != 0 {
|
||||
select {
|
||||
case <-adapter.ForegroundChanges():
|
||||
case <-ctx.Done():
|
||||
t.Fatal("empty desktop foreground missing")
|
||||
}
|
||||
}
|
||||
owned.Foreground = adapter.Foreground()
|
||||
}
|
||||
fmt.Fprintln(panel, "popup")
|
||||
activation := wait(func(event Event) bool {
|
||||
return event.Type == WindowActivatedEvent && event.Source.ApplicationName == "mate-panel"
|
||||
})
|
||||
if processName != "mate-panel" {
|
||||
if !activation.ForegroundMismatch {
|
||||
t.Fatalf("accessible name enabled panel exception for another process: %+v", activation)
|
||||
}
|
||||
key("key", "Escape")
|
||||
return
|
||||
}
|
||||
if activation.ForegroundMismatch || activation.Foreground != owned.Foreground {
|
||||
t.Fatalf("panel popup rejected with unchanged X11 foreground: %+v", activation)
|
||||
}
|
||||
for _, step := range []struct{ key, name string }{
|
||||
{"Down", "First application"}, {"Down", "Utilities"}, {"Right", "Example utility"},
|
||||
{"Left", "Utilities"}, {"Down", "Last application"}, {"Up", "Utilities"},
|
||||
} {
|
||||
key("key", step.key)
|
||||
event := wait(func(event Event) bool {
|
||||
return event.Source.Name == step.name && (event.Type == FocusEvent || event.Type == FocusedStateEvent || event.Type == SelectionChangedEvent)
|
||||
})
|
||||
if event.ForegroundMismatch || event.Foreground != owned.Foreground || event.Source.ApplicationProcess != "mate-panel" {
|
||||
t.Fatalf("panel navigation lost ownership: %+v", event)
|
||||
}
|
||||
}
|
||||
key("key", "Escape")
|
||||
fmt.Fprintln(panel, "ordinary")
|
||||
ordinary := wait(func(event Event) bool {
|
||||
return event.Type == WindowActivatedEvent && event.Source.ApplicationName == "mate-panel"
|
||||
})
|
||||
if !ordinary.ForegroundMismatch {
|
||||
t.Fatalf("ordinary panel window borrowed menu exception: %+v", ordinary)
|
||||
}
|
||||
fmt.Fprintln(panel, "stale")
|
||||
wait(func(event Event) bool {
|
||||
if event.Source.Name == "First application" && (event.Type == FocusEvent || event.Type == FocusedStateEvent) {
|
||||
t.Fatalf("hidden menu item escaped filtering: %+v", event)
|
||||
}
|
||||
return event.Payload == "Stale barrier"
|
||||
})
|
||||
if empty {
|
||||
key("windowfocus", "--sync", xid)
|
||||
}
|
||||
fmt.Fprintln(editor, "edit Foreground restored")
|
||||
restored := wait(func(event Event) bool { return event.Payload == "Foreground restored" && !event.ForegroundMismatch })
|
||||
if restored.Window != owned.Window || !empty && restored.Foreground != owned.Foreground {
|
||||
t.Fatalf("panel dismissal changed editor ownership: %+v", restored)
|
||||
}
|
||||
}
|
||||
|
||||
func check_native_panel_icons(t *testing.T, tree *AccessibleTree) {
|
||||
t.Helper()
|
||||
tree.mu.RLock()
|
||||
defer tree.mu.RUnlock()
|
||||
icons := 0
|
||||
for _, node := range tree.nodes {
|
||||
icon := node.snapshot
|
||||
if icon.Role != "icon" {
|
||||
continue
|
||||
}
|
||||
icons++
|
||||
parent := tree.nodes[node.parent].snapshot
|
||||
want := ""
|
||||
switch parent.Name {
|
||||
case "Network applet":
|
||||
want = parent.Name
|
||||
case "Unused applet name":
|
||||
if icon.Description == "" {
|
||||
want = "Named icon"
|
||||
}
|
||||
}
|
||||
if icon.Name != want {
|
||||
t.Fatalf("panel icon name=%q description=%q parent=%q; want %q", icon.Name, icon.Description, parent.Name, want)
|
||||
}
|
||||
}
|
||||
if icons != 8 {
|
||||
t.Fatalf("captured %d panel icons; want four cases from each provider", icons)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ type Activity struct {
|
||||
// may be waiting for application settings after ordinary input has aged out.
|
||||
TitleInterruptionSequence uint64
|
||||
PlainCharacterArrow bool
|
||||
PlainEnter bool
|
||||
Selecting bool
|
||||
Intent Intent
|
||||
Sequence uint64
|
||||
@@ -55,6 +56,7 @@ type IntentTracker struct {
|
||||
intent Intent
|
||||
clipboard ClipboardAction
|
||||
plainCharacterArrow bool
|
||||
plainEnter bool
|
||||
selecting bool
|
||||
at time.Time
|
||||
}
|
||||
@@ -78,6 +80,7 @@ func (tracker *IntentTracker) Observe(event Event) {
|
||||
tracker.clipboard = NoClipboardAction
|
||||
tracker.selecting = false
|
||||
tracker.plainCharacterArrow = false
|
||||
tracker.plainEnter = false
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -127,6 +130,9 @@ func (tracker *IntentTracker) Observe(event Event) {
|
||||
tracker.plainCharacterArrow = (name == "Left" || name == "Right") && !ctrl && !shift && !alt &&
|
||||
!tracker.held["Super_L"] && !tracker.held["Super_R"] &&
|
||||
!tracker.held["ISO_Level3_Shift"] && !tracker.held["ISO_Level5_Shift"]
|
||||
tracker.plainEnter = (name == "Return" || name == "Enter") && !ctrl && !shift && !alt &&
|
||||
!tracker.held["Super_L"] && !tracker.held["Super_R"] &&
|
||||
!tracker.held["ISO_Level3_Shift"] && !tracker.held["ISO_Level5_Shift"]
|
||||
tracker.selecting = shift && (intent == CharacterIntent || intent == WordIntent || intent == LineIntent)
|
||||
tracker.intent, tracker.at = intent, time.Now()
|
||||
}
|
||||
@@ -141,6 +147,7 @@ func (tracker *IntentTracker) Reset() {
|
||||
tracker.intent, tracker.at = OtherIntent, time.Time{}
|
||||
tracker.clipboard, tracker.selecting = NoClipboardAction, false
|
||||
tracker.plainCharacterArrow = false
|
||||
tracker.plainEnter = false
|
||||
}
|
||||
|
||||
func (tracker *IntentTracker) RecentInput() Activity {
|
||||
@@ -149,6 +156,6 @@ func (tracker *IntentTracker) RecentInput() Activity {
|
||||
if time.Since(tracker.at) > time.Second {
|
||||
return Activity{InputSequence: tracker.sequence, TitleInterruptionSequence: tracker.titleInterruptionSequence}
|
||||
}
|
||||
return Activity{PlainCharacterArrow: tracker.plainCharacterArrow, InputSequence: tracker.sequence, Intent: tracker.intent, Sequence: tracker.sequence, Clipboard: tracker.clipboard, Selecting: tracker.selecting,
|
||||
return Activity{PlainCharacterArrow: tracker.plainCharacterArrow, PlainEnter: tracker.plainEnter, InputSequence: tracker.sequence, Intent: tracker.intent, Sequence: tracker.sequence, Clipboard: tracker.clipboard, Selecting: tracker.selecting,
|
||||
TitleInterruptionSequence: tracker.titleInterruptionSequence}
|
||||
}
|
||||
@@ -70,6 +70,47 @@ func TestIntentTracksClipboardChords(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainEnterActivityLifetime(t *testing.T) {
|
||||
for _, key := range []string{"Return", "KP_Enter"} {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
tracker := &IntentTracker{}
|
||||
tracker.Observe(NewKeyEvent(0, true, key))
|
||||
tracker.Observe(NewKeyEvent(0, false, key))
|
||||
if activity := tracker.RecentInput(); !activity.PlainEnter || activity.Sequence == 0 || activity.Intent != OtherIntent {
|
||||
t.Fatalf("Enter release lost activity: %+v", activity)
|
||||
}
|
||||
tracker.Observe(NewKeyEvent(0, true, "a"))
|
||||
if tracker.RecentInput().PlainEnter {
|
||||
t.Fatal("Enter survived the next key")
|
||||
}
|
||||
tracker.Observe(NewKeyEvent(0, true, key))
|
||||
tracker.at = time.Now().Add(-2 * time.Second)
|
||||
if tracker.RecentInput().PlainEnter {
|
||||
t.Fatal("expired Enter remained active")
|
||||
}
|
||||
tracker.Observe(NewKeyEvent(0, true, key))
|
||||
tracker.Reset()
|
||||
if tracker.RecentInput().PlainEnter {
|
||||
t.Fatal("reset retained Enter")
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, modifier := range []string{"Shift_L", "Control_L", "Alt_L", "Super_L", "ISO_Level3_Shift"} {
|
||||
t.Run(modifier, func(t *testing.T) {
|
||||
tracker := &IntentTracker{}
|
||||
tracker.Observe(NewKeyEvent(0, true, "Return"))
|
||||
tracker.Observe(NewKeyEvent(0, true, modifier))
|
||||
if tracker.RecentInput().PlainEnter {
|
||||
t.Fatal("modifier press retained plain Enter")
|
||||
}
|
||||
tracker.Observe(NewKeyEvent(0, true, "Return"))
|
||||
if tracker.RecentInput().PlainEnter {
|
||||
t.Fatal("modified Enter classified as plain Enter")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectionIntentEndsWithPlainNavigation(t *testing.T) {
|
||||
tracker := &IntentTracker{}
|
||||
tracker.Observe(NewKeyEvent(0, true, "Shift_L"))
|
||||
|
||||
Reference in new issue
Block a user