diff --git a/README.md b/README.md index 71af05f..8a41239 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Cthulhu is a Linux screen reader written in Go. - [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. - [MATE window switcher](docs/testing/window-switcher.md): Marco selection announcements, isolated checks, and live verification. +- [MATE desktop](docs/testing/mate-desktop.md): Orca comparison, panel menus and icon labels, regression checks, and live verification. - [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. diff --git a/docs/testing/background-events.md b/docs/testing/background-events.md index bbc80b6..ed34535 100644 --- a/docs/testing/background-events.md +++ b/docs/testing/background-events.md @@ -14,7 +14,10 @@ background provider no longer clears another provider's cached document lines. Window creation, activation, deactivation, destruction and defunct events keep their existing handling. Native alerts, notification daemons and supported Steam toasts retain their notification paths. Menu popups retain existing -foreground checks. Same-provider autocomplete relations and tooltip candidates +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). +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 new file mode 100644 index 0000000..454cc86 --- /dev/null +++ b/docs/testing/mate-desktop.md @@ -0,0 +1,97 @@ +# MATE desktop accessibility + +The MATE menu report was captured on X11 with Cthulhu revision +`4d29f2bb235a4e0131ea44ddac368a8148bc594f`. At 18:40:45 the active, showing +`mate-panel` popup was rejected because X11 still identified the previous +application as foreground. Routine panel events could then be discarded by +the native background filter before debug logging. This establishes a reader +filtering gap; the log does not establish a Fedora packaging defect. + +## Orca comparison + +Reviewed the local `orca/` checkout at revision `a8a851800`: + +| Behavior | Orca source | Cthulhu handling | +| --- | --- | --- | +| Panel popup activation and selected menu item | `scripts/default.py:_on_window_activated`, `ax_utilities.py:can_be_active_window` | Adapted active/showing menu handling across the panel/application process boundary. | +| GTK menu focus | `scripts/toolkits/gtk/script.py:locus_of_focus_changed` | Existing window ownership and menu focus/selection handling; exercised with nested panel menus. | +| 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. | + +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 +specialization. Those applications use shared toolkit/role behavior; this +comparison does not establish that every workflow in those applications works. + +## Panel menu policy + +The native adapter recognizes the `mate-panel` process, then freshly queries +the popup's state and menu structure on the GLib owner. It requires an active, +showing, non-iconified, non-defunct native menu window. The existing menu +structure check permits a window with one menu child, or an unnamed frame +with one menu-bar child. A panel application name alone grants no exception. + +This check is used both before routine background content capture and during +foreground validation. It also works when X11 has no foreground client, as +when a panel menu opens over an empty desktop. Ordinary panel windows and +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. + +## 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 +go test ./internal/app -run '^TestNotificationProvidersAndHistoryMatchReferenceBehavior$' -count=1 +``` + +The private GTK fixture uses separate panel and editor providers. Native +checks cover menu activation over an editor and an empty desktop, forward and +reverse navigation, a submenu, Escape, editor restoration, rejection of an +ordinary panel window, a late focus signal from a hidden menu, and an unrelated +process claiming the panel's application name. Panel icon captures verify +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 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 +retains foreground ownership. + +Validation on 2026-10-27 passed the full Go suite, the combined native checks +above with the race detector, `go vet ./internal/atspi ./internal/app`, the +parity ledger validator and a candidate build. GTK/Qt traversal and menus, +same-process window recovery, Brave address focus, dialogs, empty workspaces +and shutdown also passed ownership verification using a temporary copy of +`tests/native_ownership.py` with current schema-2 settings and `--append` on +reader launches. The checked-in ownership script still uses legacy settings +and assumes logs survive reader restart. Its restart assertion fails on the +unchanged revision too, despite the expected activation appearing in the log. +That existing harness needs separate maintenance; it is not a product failure +or an unqualified passing check. + +## Human review and live acceptance + +Review the diff before submission, as required by the contribution policy. +The fixtures model the native contracts; they do not run the reporter's Fedora +MATE session. After starting the candidate through the normal launcher: + +1. Open the Applications menu over a browser, a terminal and the desktop. + Navigate forward/backward, enter/leave submenus, and check spoken labels + and Braille if available. Repeat with Places/System menus and panel context + menus offered by the installed MATE configuration. +2. Press Escape and confirm reading resumes in the original application. + Reopen the menu and activate an application. Check its initial control, + 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. +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 + ordinary GTK/Qt menus and dialogs. Background updates must not take over + speech. Panel menu events must remain silent while the screen is locked. + +Audible speech, physical keyboard/Braille behavior, the exact Fedora panel +tree and real MATE application workflows remain live acceptance checks. diff --git a/docs/testing/messaging.md b/docs/testing/messaging.md index f390250..cdcf481 100644 --- a/docs/testing/messaging.md +++ b/docs/testing/messaging.md @@ -9,6 +9,16 @@ received while this reader is running; it does not import an existing transcript The legacy controls for separate conversation histories, room-name prefixes, typing announcements, and message verbosity are not yet ported. +When plain Enter clears the focused message entry, the deleted draft is no +longer spoken before the transcript announces the sent message. This shared +presentation rule requires a freshly observed plain Enter and a known empty +native text line at offset zero. It keeps deletion feedback when text was +selected, including when selection removal arrives before the deletion. +Backspace/Delete, cut/paste, undo/redo, modified Enter, unknown input/caret data, +autocomplete, terminals and document editors retain their existing behavior. +Caret and Braille updates still run before speech is suppressed. This does not +change word echo or Thunderbird foreground handling. + ## Shortcuts Both desktop and laptop layouts use: @@ -69,6 +79,8 @@ GOCACHE=/tmp/cthulhu-cache go test -race ./internal/messaging ./internal/input . python3 tests/native_test_session.py env GOCACHE=/tmp/cthulhu-cache CTHULHU_NATIVE_MESSAGING=1 go test ./internal/atspi -run '^TestNativePidginTranscriptCapture$' -count=1 -v python3 tests/native_test_session.py env GOCACHE=/tmp/cthulhu-cache CTHULHU_NATIVE_MESSAGING=1 go test ./internal/app -run '^TestNativeMessagingCopyPreservesClipboardText$' -count=1 -v python3 tests/native_test_session.py env GOCACHE=/tmp/cthulhu-cache CTHULHU_NATIVE_X11=1 go test ./internal/platform/xorg -run '^TestNativeLearnModeCapturesKeysAndRestoresCommands$' -count=1 -v +go test ./internal/input ./internal/app -run '^(TestPlainEnterActivityLifetime|TestSubmittedMessageIsAnnouncedOnce|TestSubmissionClearPreservesEditingFeedback)$' -count=1 +python3 tests/native_test_session.py env CTHULHU_NATIVE_SUBMISSION_ECHO=1 go test ./internal/app -run '^TestNativeSubmissionClearAndSelectedReplacement$' -count=1 -v ``` Native checks use private X11 and D-Bus sessions. The transcript fixture sets its @@ -77,6 +89,11 @@ client. These checks verify copied metadata, recorded presentation, synthetic keys, and clipboard contents. They do not establish audible speech, physical keyboard behavior, or a connected Braille display. +The submission replay test reproduces both announcements with the original +presenter and only the chat announcement with the fix. The GTK submission +fixture uses the real X11 input listener and native deletion snapshots, and +checks Enter replacing a selection separately. It does not send real messages. + ## Human review and live acceptance Review the diff before submission under the contribution policy. Build a reader @@ -93,7 +110,12 @@ The implementation work does not replace the running reader. multiline content. Confirm the copy response follows a successful write. 4. Type in Pidgin's message entry and account/preferences fields. Check normal echo and editing feedback. Buddy-list updates and remote typing status must - not enter chat history. + not enter chat history. Send an unselected draft with plain Enter and confirm + it is spoken once through the transcript. In a native text editor, check + Backspace/Delete, Ctrl+Backspace, cut/paste, undo/redo, typing over a selection, + and Enter replacing a selection. Verify the empty input is reflected on + Braille after sending. Modified Enter and sending selected drafts retain + their earlier behavior and need separate evidence before changing them. 5. Switch to another application and check chat commands do not replay Pidgin history. Close a conversation window and check its messages are retired. Lock/unlock and verify prior history is cleared and messages received while diff --git a/internal/app/event_presentation.go b/internal/app/event_presentation.go index 18b046e..2005e40 100644 --- a/internal/app/event_presentation.go +++ b/internal/app/event_presentation.go @@ -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) diff --git a/internal/app/notification_test.go b/internal/app/notification_test.go index 1e37a78..8102fd5 100644 --- a/internal/app/notification_test.go +++ b/internal/app/notification_test.go @@ -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( diff --git a/internal/app/panel_menu_native_test.go b/internal/app/panel_menu_native_test.go new file mode 100644 index 0000000..6357ea2 --- /dev/null +++ b/internal/app/panel_menu_native_test.go @@ -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) + } +} diff --git a/internal/app/submitted_text.go b/internal/app/submitted_text.go new file mode 100644 index 0000000..55a4309 --- /dev/null +++ b/internal/app/submitted_text.go @@ -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 +} diff --git a/internal/app/submitted_text_native_test.go b/internal/app/submitted_text_native_test.go new file mode 100644 index 0000000..8c0312c --- /dev/null +++ b/internal/app/submitted_text_native_test.go @@ -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()) + } + } + }) + } +} diff --git a/internal/app/submitted_text_test.go b/internal/app/submitted_text_test.go new file mode 100644 index 0000000..d6f3899 --- /dev/null +++ b/internal/app/submitted_text_test.go @@ -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 +} diff --git a/internal/atspi/adapter_capture.c b/internal/atspi/adapter_capture.c index 8ceebae..52df873 100644 --- a/internal/atspi/adapter_capture.c +++ b/internal/atspi/adapter_capture.c @@ -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); diff --git a/internal/atspi/adapter_event_filter_native.go b/internal/atspi/adapter_event_filter_native.go index f5b9ea9..094b7ba 100644 --- a/internal/atspi/adapter_event_filter_native.go +++ b/internal/atspi/adapter_event_filter_native.go @@ -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) diff --git a/internal/atspi/adapter_events.c b/internal/atspi/adapter_events.c index 8303be8..9e12577 100644 --- a/internal/atspi/adapter_events.c +++ b/internal/atspi/adapter_events.c @@ -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); diff --git a/internal/atspi/adapter_foreground_native.go b/internal/atspi/adapter_foreground_native.go index 47e7775..727bff9 100644 --- a/internal/atspi/adapter_foreground_native.go +++ b/internal/atspi/adapter_foreground_native.go @@ -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 diff --git a/internal/atspi/panel_menu_native_test.go b/internal/atspi/panel_menu_native_test.go new file mode 100644 index 0000000..f14b06c --- /dev/null +++ b/internal/atspi/panel_menu_native_test.go @@ -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) + } +} diff --git a/internal/input/intent.go b/internal/input/intent.go index a775585..e33043d 100644 --- a/internal/input/intent.go +++ b/internal/input/intent.go @@ -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} } diff --git a/internal/input/intent_test.go b/internal/input/intent_test.go index ac09de3..1d71a82 100644 --- a/internal/input/intent_test.go +++ b/internal/input/intent_test.go @@ -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")) diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index 8fad7a8..6a69bb4 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -1,4 +1,6 @@ id,python_source,subsystem,behavior,owner,go_destination,status,automated_evidence,x11_evidence,wayland_evidence,speech_evidence,braille_evidence,notes +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. 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. diff --git a/tests/gtk_panel_menu.py b/tests/gtk_panel_menu.py new file mode 100644 index 0000000..f374d83 --- /dev/null +++ b/tests/gtk_panel_menu.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Storm Dragon +# Authors: Storm Dragon, Angus Kola +# SPDX-License-Identifier: GPL-3.0-or-later +"""A panel popup and an editor on separate AT-SPI 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("Gdk", "3.0") +gi.require_version("GdkX11", "3.0") +from gi.repository import Atk, Gdk, GdkX11, GLib, Gtk + + +processName = sys.argv[1] +GLib.set_prgname("editor" if processName == "editor" else "mate-panel") +if ctypes.CDLL(None).prctl(15, processName.encode(), 0, 0, 0) != 0: + raise RuntimeError("cannot set fixture process name") +window = Gtk.Window(title="Panel menu fixture " + processName) +window.set_default_size(400, 150) +box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) +window.add(box) +entry = Gtk.Entry() +entry.get_accessible().set_name("Editor entry") +box.pack_start(entry, False, False, 0) +button = Gtk.Button(label="Applications") +box.pack_start(button, False, False, 0) +barrier = Gtk.Label() +barrier.get_accessible().set_role(Atk.Role.ALERT) +box.pack_start(barrier, False, False, 0) +for iconName, description, parentRole, parentName in ( + ("", "", Atk.Role.PANEL, "Network applet"), + ("Named icon", "", Atk.Role.PANEL, "Unused applet name"), + ("", "Icon description", Atk.Role.PANEL, "Unused applet name"), + ("", "", Atk.Role.FILLER, "Unrelated container"), +): + parent = Gtk.Box() + parent.get_accessible().set_role(parentRole) + parent.get_accessible().set_name(parentName) + icon = Gtk.Image.new_from_icon_name("network-workgroup", Gtk.IconSize.MENU) + icon.get_accessible().set_role(Atk.Role.ICON) + icon.get_accessible().set_name(iconName) + icon.get_accessible().set_description(description) + parent.add(icon) + box.pack_start(parent, False, False, 0) +if processName != "editor": + window.set_accept_focus(False) + window.set_focus_on_map(False) +window.show_all() +Atk.get_root().set_name("editor" if processName == "editor" else "mate-panel") +menu = Gtk.Menu() +firstItem = Gtk.MenuItem(label="First application") +menu.append(firstItem) +submenuItem = Gtk.MenuItem(label="Utilities") +submenu = Gtk.Menu() +submenu.append(Gtk.MenuItem(label="Example utility")) +submenuItem.set_submenu(submenu) +menu.append(submenuItem) +menu.append(Gtk.MenuItem(label="Last application")) +menu.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 == "popup": + menu.popup_at_widget(button, Gdk.Gravity.SOUTH_WEST, Gdk.Gravity.NORTH_WEST, None) + elif command == "close": + menu.popdown() + elif command == "ordinary": + window.get_accessible().emit("activate") + elif command == "stale": + firstItem.get_accessible().notify_state_change(Atk.StateType.FOCUSED, True) + barrier.set_text("Stale barrier") + elif command.startswith("edit "): + entry.set_text(command.removeprefix("edit ")) + entry.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()), flush=True) or False) +Gtk.main() diff --git a/tests/gtk_submit_echo.py b/tests/gtk_submit_echo.py new file mode 100644 index 0000000..6f25d85 --- /dev/null +++ b/tests/gtk_submit_echo.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Storm Dragon +# Authors: Storm Dragon, Angus Kola +# SPDX-License-Identifier: GPL-3.0-or-later +"""Compare submission clearing with replacing selected text using Enter.""" +import sys + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import GLib, Gtk + + +Gtk.Settings.get_default().set_property("gtk-entry-select-on-focus", False) +window = Gtk.Window(title="Submission echo probe") +window.set_default_size(480, 200) +window.connect("destroy", Gtk.main_quit) +if sys.argv[1] == "submit": + editor = Gtk.Entry() + editor.set_text("A sample message.") + editor.set_position(-1) + editor.connect("activate", lambda entry: entry.set_text("")) +else: + editor = Gtk.TextView() + editor.set_accepts_tab(False) + buffer = editor.get_buffer() + buffer.set_text("A sample message.") + buffer.select_range(buffer.get_start_iter(), buffer.get_end_iter()) +editor.get_accessible().set_name("Message input") +window.add(editor) +window.show_all() +editor.grab_focus() + + +def ready(): + print("READY", flush=True) + return False + + +GLib.idle_add(ready) +Gtk.main()