Fix Wine window title announcements on foreground switches

Wine review establishes navigation ownership and stores the window title
without speaking an activation. The native title controller treated that
metadata as an already announced title, suppressing the introduction when
review focus arrived first. Frame-to-control focus updates also reset the
window title controller, cancelling pending introductions and discarding
duplicate history.

Distinguish Wine review ownership from spoken AT-SPI activations when
classifying and deduplicating native introductions. Use the native Wine
application identity when waiting for committed application preferences,
so introductions remain audible with automatic title changes disabled
while subsequent title changes honor that application's settings.

Separate object focus cleanup from window title cleanup. Wine review
revisions now retain the pending title and its duplicate history; actual
foreground transitions, native window lifecycle cleanup, screen lock,
and shutdown continue to reset title state. Preserve XTerm/Fenrir handoff
and ordinary AT-SPI activation deduplication.

Add regressions for both native/review arrival orders, ordinary Wine
windows and virtual desktop children, duplicate reports, return to the
same window, and application preference handoff. Add a private D-Bus
event-loop test for a frame-to-control focus burst and document focused
verification and live acceptance procedures in tests/window_titles.md.

Validation:
- Confirmed the new title introduction, preference handoff, and private
  focus-burst regressions fail before the fix and pass afterward.
- go test ./... -count=1 passed with local socket access; the sandboxed
  attempt was blocked by Unix and loopback speech fixture sockets.
- Focused Title, WineReview, XTerm, and Foreground tests passed with
  the race detector.
- Private D-Bus TestPrivateWineTitleSurvivesReviewFocusBurst passed
  with the race detector.
- go vet ./internal/app, the reader build, and git diff --check passed.

Installed-reader behavior, real Wine/X11 switching, physical Alt+Tab,
audible speech, and physical Braille acceptance remain pending. The
private fixture uses copied foreground metadata and a real registry,
not a real Wine window. No installed reader was replaced.
This commit is contained in:
deedra authored and Storm Dragon committed 2026-10-14 16:38:37 -04:00
1 parent 7f78177786
commit 6754c8ef6c
5 files changed
+236 -5

No files matched your search

+8 -3
View File
@@ -181,15 +181,20 @@ func processEventsWithAttributes(
event, _ := state.pending.terminalDeletion.take()
speak(event)
}
clearOwnedFocus := func() {
clearObjectFocus := func() {
ocrSession.Retire()
titles.reset()
state.clearOwnedFocus(brailleOutput, browser, objectReview, speech)
}
clearOwnedFocus := func() {
titles.reset()
clearObjectFocus()
}
var wineReview *wineReviewFocusController
var wineReviewChanges <-chan struct{}
if live != nil && live.wineReview != nil {
wineReview = &wineReviewFocusController{registry: live.wineReview, logger: logger, state: &state, foreground: foreground, titles: titles.source, gate: focusGate, worker: objectReview, clear: clearOwnedFocus}
// Wine control revisions reset object review, but a pending title and
// its duplicate history belong to the foreground window's lifetime.
wineReview = &wineReviewFocusController{registry: live.wineReview, logger: logger, state: &state, foreground: foreground, titles: titles.source, gate: focusGate, worker: objectReview, clear: clearObjectFocus}
wineReviewChanges = live.wineReview.Changes()
wineReview.changed()
defer wineReview.retire()
+9 -2
View File
@@ -11,6 +11,7 @@ import (
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
"git.stormux.org/storm/cthulhu/internal/wine"
)
type windowTitleSource interface {
@@ -151,10 +152,16 @@ func (titles *windowTitleController) nativeChanged() {
}
switched := latest.Epoch != previous.Epoch || latest.Title.Window != previous.Title.Window ||
latest.Title.TitleWindow != previous.Title.TitleWindow
// Wine review establishes navigation ownership without speaking an
// activation. Its stored title is metadata, not an announced title.
accessibleActivation := titles.state.owner.activated && !wine.IsReviewID(titles.state.owner.window)
if switched {
titles.clearPending()
// An AT-SPI activation may already have introduced this client.
titles.lastTitle = titles.state.owner.title
if wine.IsReviewID(titles.state.owner.window) {
titles.lastTitle = ""
}
if strings.EqualFold(latest.Title.Application, "xterm") {
titles.lastTitle = ""
}
@@ -167,7 +174,7 @@ func (titles *windowTitleController) nativeChanged() {
changed := !switched
// AT-SPI can introduce a window before its first X11 title sample. A
// later differing sample is still a title change, not a new activation.
if switched && titles.state.owner.activated && titles.state.owner.title != "" &&
if switched && accessibleActivation && titles.state.owner.title != "" &&
latest.Title.TitleWindow == latest.Title.Window && !strings.EqualFold(latest.Title.Application, "xterm") {
changed = true
}
@@ -212,7 +219,7 @@ func (titles *windowTitleController) flush() {
}
pending.window = titles.state.owner.window
pending.application = titles.application
if !pending.window.Valid() || pending.native.TitleWindow != pending.native.Window {
if !pending.window.Valid() || wine.IsReviewID(pending.window) || pending.native.TitleWindow != pending.native.Window {
pending.application = pending.native.Application
}
} else if pending.window != titles.state.owner.window {
+95
View File
@@ -6,6 +6,7 @@ package app
import (
"context"
"fmt"
"log/slog"
"path/filepath"
"testing"
@@ -395,3 +396,97 @@ func TestWineTitleReportsCannotClaimUnrelatedForeground(t *testing.T) {
t.Fatal("background helper title was announced")
}
}
func TestWineReviewFocusDoesNotSuppressTitleIntroduction(t *testing.T) {
for _, desktop := range []bool{false, true} {
for _, reviewFirst := range []bool{false, true} {
t.Run(fmt.Sprintf("desktop=%t/reviewFirst=%t", desktop, reviewFirst), func(t *testing.T) {
c, registry, source, worker := reviewFocusSetup(t)
titles, queue, _ := titleControllerFixture(t)
titles.state = c.state
titles.source, titles.foreground = source, source
settings := titles.live.Load()
settings.Reading.AutomaticallyReadTitleChanges = false
titles.live.Store(settings)
if desktop {
source.title.Title.TitleWindow = 21
source.title.Title.Client.Window = 21
}
introduce := func() {
if reviewFirst {
c.changed()
}
titles.nativeChanged()
c.changed() // The event loop syncs review focus again before flushing.
titles.wineChanged(wineWindowTitle{text: "Same title", foreground: source.title})
titles.flush()
}
introduce()
if len(queue.requests) != 1 || queue.requests[0].Text != "Same title" {
t.Fatalf("Wine introduction = %+v", queue.requests)
}
if worker.Focus() != registry.notices[0].ID() || c.state.owner.title != "Same title" {
t.Fatal("title presentation changed Wine review ownership")
}
c.changed()
titles.nativeChanged()
titles.wineChanged(wineWindowTitle{text: "Same title", foreground: source.title})
titles.flush()
if len(queue.requests) != 1 {
t.Fatal("duplicate Wine title was spoken")
}
registry.notices[0].Title = "Status changed"
source.title.Title.Title = "Status changed"
c.changed()
titles.nativeChanged()
titles.flush()
if len(queue.requests) != 1 {
t.Fatal("Wine title change bypassed disabled preference")
}
// Returning to the same Wine window must introduce it again.
c.retire()
source.state.Epoch++
source.title.Epoch = source.state.Epoch
registry.notices[0].Title = "Same title"
source.title.Title.Title = "Same title"
introduce()
if len(queue.requests) != 2 || queue.requests[1].Text != "Same title" {
t.Fatalf("return to Wine window = %+v", queue.requests)
}
})
}
}
}
func TestWineReviewTitlesUseNativeApplicationPreferences(t *testing.T) {
c, registry, source, _ := reviewFocusSetup(t)
titles, queue, _ := titleControllerFixture(t)
titles.state = c.state
titles.source, titles.foreground = source, source
var application string
titles.live.onApplication = func(name string) { application = name }
previousApplication := "Other Application"
titles.live.application.Store(&previousApplication)
c.changed()
titles.nativeChanged()
titles.flush()
if application != "fixture.exe" || len(queue.requests) != 0 || titles.pending == nil {
t.Fatalf("Wine title raced application settings: application=%q requests=%+v", application, queue.requests)
}
settings := titles.live.Load()
settings.Reading.AutomaticallyReadTitleChanges = false
titles.live.Store(settings)
titles.live.application.Store(&application)
titles.flush()
if len(queue.requests) != 1 || queue.requests[0].Text != "Same title" {
t.Fatalf("Wine introduction = %+v", queue.requests)
}
registry.notices[0].Title = "Changed status"
source.title.Title.Title = "Changed status"
c.changed()
titles.nativeChanged()
titles.flush()
if len(queue.requests) != 1 {
t.Fatal("Wine title ignored its application preference")
}
}
@@ -0,0 +1,86 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"log/slog"
"os"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/wine"
)
type wineTitleLoopAdapter struct {
fakeAdapter
*reviewFocusMonitor
}
func TestPrivateWineTitleSurvivesReviewFocusBurst(t *testing.T) {
if os.Getenv("CTHULHU_WINE_TITLE_DBUS") != "1" {
t.Skip("requires a private D-Bus session")
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
logger := slog.New(slog.DiscardHandler)
registry := wine.NewReviewRegistry(logger)
if err := registry.Start(ctx); err != nil {
t.Fatal(err)
}
defer registry.Stop(context.Background())
_, fixture, monitor, worker := reviewFocusSetup(t)
notice := fixture.notices[0]
if !registry.RegisterProvider(notice.Sender, wine.ReviewObjectPath, wine.ReviewProtocolVersion) ||
!registry.PresentFocus(notice.Sender, notice) {
t.Fatal("could not register private Wine focus")
}
adapter := &wineTitleLoopAdapter{fakeAdapter: fakeAdapter{events: make(chan atspi.Event)}, reviewFocusMonitor: monitor}
live := &liveSettings{wineReview: registry}
settings := config.DefaultSettings()
settings.Reading.AutomaticallyReadTitleChanges = false
live.Store(settings)
queue := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)}
done := make(chan error, 1)
go func() {
done <- processEventsWithInput(ctx, logger, queue, adapter, nil, worker, 0, live, nil, nil, nil)
}()
defer func() {
cancel()
if err := <-done; err != nil {
t.Error(err)
}
}()
for worker.Focus() != notice.ID() {
select {
case <-ctx.Done():
t.Fatal("Wine review did not establish focus")
case <-time.After(time.Millisecond):
}
}
// Wine publishes the frame, then its focused edit control while the
// native title introduction is waiting. Both belong to the same window.
notice.Focus = "edit"
notice.Revision++
if !registry.PresentFocus(notice.Sender, notice) {
t.Fatal("could not publish focused edit control")
}
select {
case request := <-queue.requests:
if request.Text != "Same title" || request.Cause != "window-title" || !request.Append {
t.Fatalf("Wine window introduction = %+v", request)
}
case <-time.After(time.Second):
t.Fatal("Wine review focus burst cancelled the title introduction")
}
notice.Revision++
registry.PresentFocus(notice.Sender, notice)
select {
case request := <-queue.requests:
t.Fatalf("Wine control revision repeated the title: %+v", request)
case <-time.After(300 * time.Millisecond):
}
}
+38
View File
@@ -0,0 +1,38 @@
# Window title regression checks
Wine review focus provides navigation ownership and title metadata. It does
not speak a window activation. The native title controller must introduce
that window once, retain its pending announcement across Wine control focus
updates, and introduce it again after a foreground switch. Title changes
within the window still follow the application's title-change preference.
Run the focused checks from the repository root:
```sh
go test -race ./internal/app -run 'Title|WineReview|XTerm|Foreground' -count=1
dbus-run-session -- env CTHULHU_WINE_TITLE_DBUS=1 go test -race ./internal/app -run '^TestPrivateWineTitleSurvivesReviewFocusBurst$' -count=1 -v
```
The private D-Bus test runs the event loop against copied foreground metadata
and a real Wine review registry. It checks that a frame-to-control focus burst
keeps the pending title and that later review revisions do not repeat it.
Unit tests cover both arrival orders, Wine desktop children, returning to the
same window, native application preferences, and XTerm handoff.
For live acceptance, use a rebuilt reader after arranging any active reader
replacement with its user. Review and listening are required before submission.
1. Alt+Tab between a Wine application, an accessible Linux application, and
xterm. Each destination should speak its title once on every return.
2. Tab between controls inside Wine. Controls should speak normally without
repeating the title. Check Wine flat review still follows the focused window.
3. Check Fenrir retains xterm keyboard input and terminal reading. Return to
Linux controls and confirm ordinary focus speech and reader commands work.
4. Repeat with the application's automatic title changes disabled. Window
introductions should still speak; subsequent status/title changes should
stay quiet. With the preference enabled, changed titles should speak once.
5. If used, repeat with a Wine virtual desktop and check title messages on a
physical Braille display.
Automated queue assertions do not establish physical Alt+Tab, audible speech,
installed-binary behavior, or physical Braille acceptance.