Possible fix for mate's window switcher.

This commit is contained in:
Storm Dragon committed 2026-10-27 00:51:32 -04:00
1 parent eb5b2038d6
commit e9a2dc1975
11 files changed
+472

No files matched your search

+1
View File
@@ -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.
+66
View File
@@ -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.
+3
View File
@@ -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
} }
+30
View File
@@ -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
}
+132
View File
@@ -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
}
+31
View File
@@ -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)
}
}
+1
View File
@@ -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.
1 id python_source subsystem behavior owner go_destination status automated_evidence x11_evidence wayland_evidence speech_evidence braille_evidence notes
2 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.
3 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.
4 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.
5 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.
+61
View File
@@ -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()