From d0fdce7285b61707d5607b445caecf600cd18608 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 1 Nov 2026 14:33:50 -0500 Subject: [PATCH] Descovered a but on Slint where if the reported sizes differ preferences would not rport. HOpefully fixed. This could probably affect more than just Slint, just happened to catch it there. --- docs/testing/speech-preferences.md | 28 +++++++++++++++++++ internal/app/app.go | 1 + internal/app/preferences.go | 1 + internal/app/preferences_session.go | 4 +++ internal/atspi/adapter.go | 1 + internal/atspi/adapter_foreground_native.go | 8 ++++++ internal/atspi/foreground.go | 11 ++++++++ internal/atspi/foreground_test.go | 31 +++++++++++++++++++++ internal/atspi/preferences_native_test.go | 5 ++++ 9 files changed, 90 insertions(+) diff --git a/docs/testing/speech-preferences.md b/docs/testing/speech-preferences.md index 54ac65f..8793681 100644 --- a/docs/testing/speech-preferences.md +++ b/docs/testing/speech-preferences.md @@ -47,3 +47,31 @@ check pitch and volume with the quick speech keys, then save and reopen the settings. Values such as 4.5 must remain 4.5 in announcements and TOML rather than exposing floating-point tails. Check all voice types and custom key echo; untouched inherited values must remain unset. + +## Preferences focus with differing window bounds + +On Slint/i3, GTK can report a frame at (0,0), sized 1916×994, while X11 +reports its client at (2,18), sized 1912×974. Rejecting that frame by geometry +silences control announcements even though Tab still moves focus. The reader +registers the PID of its own preferences helper until the helper exits. That +helper can bypass the geometry comparison when X11 identifies the same +foreground process and GTK reports an active, showing, non-iconified window. +Other applications retain the normal foreground correlation checks. + +The adapter dropdown regression also checks that preferences activation and +focused-control events are accepted: + +```sh +go test ./internal/atspi ./internal/app -count=1 +CTHULHU_BINARY=/tmp/cthulhu-speech-preferences CTHULHU_PREFS_ADAPTER_ONLY=1 python3 tests/native_preferences.py +``` + +On the affected Slint desktop, use the rebuilt reader to open preferences. +Tab and Shift+Tab through controls, switch notebook pages with Ctrl+PageUp and +Ctrl+PageDown, open a dropdown, change its selection, and close it with Escape. +Confirm each control's name and value is announced. Apply a change, close and +reopen preferences, and confirm it persists. Switch to another application +while preferences remains open: speech and Braille must follow that application, +then resume preferences announcements when returning. Repeat after closing and +reopening preferences and after changing window size. Private X11 tests do not +establish physical-key, audible-speech or Braille acceptance on Slint. diff --git a/internal/app/app.go b/internal/app/app.go index 1631d57..9113dda 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -313,6 +313,7 @@ func Run(ctx context.Context, options Options) error { preferences.speechWorker = speechMessages preferences.braille = brailleOutput preferences.documentReview = objectReview + preferences.preferencesProcess, _ = adapter.(interface{ SetPreferencesProcess(int) }) objectReview.SetDocumentBrailleEnabled(settings.EnableBraille) if updater, ok := sounds.(soundSettingsUpdater); ok { preferences.sound = updater diff --git a/internal/app/preferences.go b/internal/app/preferences.go index 7634fcb..1a0c908 100644 --- a/internal/app/preferences.go +++ b/internal/app/preferences.go @@ -65,6 +65,7 @@ type preferencesWorker struct { refreshHistory func() sound soundSettingsUpdater geometry soundGeometryCaptureConfigurer + preferencesProcess interface{ SetPreferencesProcess(int) } textAttributes textAttributeCaptureConfigurer bindings interface { SetKeyBindings(context.Context, []input.BindingOverride) error diff --git a/internal/app/preferences_session.go b/internal/app/preferences_session.go index 4e6ee7d..6005b5d 100644 --- a/internal/app/preferences_session.go +++ b/internal/app/preferences_session.go @@ -69,6 +69,10 @@ func (worker *preferencesWorker) window(ctx context.Context) (result error) { worker.live.preferencesPID.Store(int64(command.Process.Pid)) defer worker.live.preferencesPID.Store(0) } + if worker.preferencesProcess != nil { + worker.preferencesProcess.SetPreferencesProcess(command.Process.Pid) + defer worker.preferencesProcess.SetPreferencesProcess(0) + } defer func() { stop() in.Close() diff --git a/internal/atspi/adapter.go b/internal/atspi/adapter.go index bfe721b..101957c 100644 --- a/internal/atspi/adapter.go +++ b/internal/atspi/adapter.go @@ -121,6 +121,7 @@ type Adapter struct { foregroundReader *xorg.ForegroundReader // GLib thread only foregroundProcesses map[int]bool // GLib thread; provider PID matches for current client foreground atomic.Pointer[ForegroundState] + preferencesPID atomic.Int64 // Owned preferences helper; zero when closed. echoFocus atomic.Pointer[echoFocus] eventListener *C.AtspiEventListener // GLib owner; released with subscriptions. foregroundChanges chan struct{} diff --git a/internal/atspi/adapter_foreground_native.go b/internal/atspi/adapter_foreground_native.go index 727bff9..6e9623d 100644 --- a/internal/atspi/adapter_foreground_native.go +++ b/internal/atspi/adapter_foreground_native.go @@ -129,6 +129,14 @@ func (adapter *Adapter) matchForeground(window *C.AtspiAccessible, id ObjectID, } return false } + // Tiling window managers can report GTK's frame and X11's client at + // different origins and sizes. Our own active preferences child is known + // by its launched PID; require X11 to confirm that process is foreground. + if preferences_foreground_matches(state, pid, adapter.preferencesPID.Load()) { + adapter.foregroundWindow = id + adapter.foregroundRecovery = 0 + return true + } component := C.atspi_accessible_get_component_iface(window) if component == nil { if check != nil { diff --git a/internal/atspi/foreground.go b/internal/atspi/foreground.go index d15f807..6d3a3b3 100644 --- a/internal/atspi/foreground.go +++ b/internal/atspi/foreground.go @@ -26,6 +26,17 @@ type ForegroundState struct { Epoch uint64 } +// SetPreferencesProcess scopes geometry-independent foreground matching to the +// UI child launched by this reader. Clear it when that child exits. +func (adapter *Adapter) SetPreferencesProcess(pid int) { + adapter.preferencesPID.Store(int64(pid)) +} + +func preferences_foreground_matches(client xorg.Foreground, pid int, preferencesPID int64) bool { + return client.Known && client.Window != 0 && preferencesPID > 0 && + int64(pid) == preferencesPID && client.PID == pid +} + func foregroundWindowMatches(client xorg.Foreground, pid int, bounds Rectangle) bool { if client.PID > 0 && pid > 0 && client.PID != pid { return false diff --git a/internal/atspi/foreground_test.go b/internal/atspi/foreground_test.go index d9344c7..20aca0b 100644 --- a/internal/atspi/foreground_test.go +++ b/internal/atspi/foreground_test.go @@ -44,6 +44,37 @@ func TestForegroundWindowCorrelationSteamBigPictureScale(t *testing.T) { } } +func TestPreferencesForegroundIdentity(t *testing.T) { + // Slint/i3 reports GTK's frame and the X11 client with different borders. + client := xorg.Foreground{Known: true, Window: 45, PID: 123, + X: 2, Y: 18, Width: 1912, Height: 974, OuterX: 2, OuterY: 18, OuterWidth: 1912, OuterHeight: 974} + if foregroundWindowMatches(client, 123, Rectangle{Width: 1916, Height: 994}) { + t.Fatal("fixture must reproduce the reported bounds mismatch") + } + for _, test := range []struct { + name string + client xorg.Foreground + pid int + owned int64 + want bool + }{ + {"owned foreground helper", client, 123, 123, true}, + {"helper closed", client, 123, 0, false}, + {"another reader's helper", client, 123, 124, false}, + {"other provider", client, 124, 123, false}, + {"helper in background", xorg.Foreground{Known: true, Window: 46, PID: 124}, 123, 123, false}, + {"missing foreground PID", xorg.Foreground{Known: true, Window: 45}, 123, 123, false}, + {"empty workspace", xorg.Foreground{Known: true, PID: 123}, 123, 123, false}, + {"unknown foreground", xorg.Foreground{Window: 45, PID: 123}, 123, 123, false}, + } { + t.Run(test.name, func(t *testing.T) { + if got := preferences_foreground_matches(test.client, test.pid, test.owned); got != test.want { + t.Fatalf("match = %v, want %v", got, test.want) + } + }) + } +} + func TestForegroundMenuPopupCorrelation(t *testing.T) { client := xorg.Foreground{Known: true, Window: 45, PID: 123} main := NewObjectID(":1.40", "/main") diff --git a/internal/atspi/preferences_native_test.go b/internal/atspi/preferences_native_test.go index d6f554f..5286995 100644 --- a/internal/atspi/preferences_native_test.go +++ b/internal/atspi/preferences_native_test.go @@ -77,6 +77,8 @@ func TestNativePreferencesDropdownEscape(t *testing.T) { if err := command.Start(); err != nil { t.Fatal(err) } + adapter.SetPreferencesProcess(command.Process.Pid) + defer adapter.SetPreferencesProcess(0) helperDone := make(chan error, 1) go func() { helperDone <- command.Wait() }() helperJoined := false @@ -118,6 +120,9 @@ func TestNativePreferencesDropdownEscape(t *testing.T) { select { case event := <-adapter.Events(): t.Logf("%s %s %q capture=%s", event.Type, event.Source.Role, event.Source.Name, event.SnapshotDuration) + if (event.Type == WindowActivatedEvent || event.Type == FocusedStateEvent && event.Detail1 == 1) && event.ForegroundMismatch { + t.Fatalf("owned preferences focus rejected: %+v", event) + } if event.Type == FocusedStateEvent && event.Detail1 == 1 { // Match the running reader's browser-mode classification. // Without these fresh ancestry queries, stale cached focus