Compare commits
1
Commits
deedra
...
a005be1d2e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a005be1d2e |
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):
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user