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:
1 parent
7f78177786
commit
6754c8ef6c
5 files changed
+236
-5
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user