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()
|
event, _ := state.pending.terminalDeletion.take()
|
||||||
speak(event)
|
speak(event)
|
||||||
}
|
}
|
||||||
clearOwnedFocus := func() {
|
clearObjectFocus := func() {
|
||||||
ocrSession.Retire()
|
ocrSession.Retire()
|
||||||
titles.reset()
|
|
||||||
state.clearOwnedFocus(brailleOutput, browser, objectReview, speech)
|
state.clearOwnedFocus(brailleOutput, browser, objectReview, speech)
|
||||||
}
|
}
|
||||||
|
clearOwnedFocus := func() {
|
||||||
|
titles.reset()
|
||||||
|
clearObjectFocus()
|
||||||
|
}
|
||||||
var wineReview *wineReviewFocusController
|
var wineReview *wineReviewFocusController
|
||||||
var wineReviewChanges <-chan struct{}
|
var wineReviewChanges <-chan struct{}
|
||||||
if live != nil && live.wineReview != nil {
|
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()
|
wineReviewChanges = live.wineReview.Changes()
|
||||||
wineReview.changed()
|
wineReview.changed()
|
||||||
defer wineReview.retire()
|
defer wineReview.retire()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||||
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
|
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/wine"
|
||||||
)
|
)
|
||||||
|
|
||||||
type windowTitleSource interface {
|
type windowTitleSource interface {
|
||||||
@@ -151,10 +152,16 @@ func (titles *windowTitleController) nativeChanged() {
|
|||||||
}
|
}
|
||||||
switched := latest.Epoch != previous.Epoch || latest.Title.Window != previous.Title.Window ||
|
switched := latest.Epoch != previous.Epoch || latest.Title.Window != previous.Title.Window ||
|
||||||
latest.Title.TitleWindow != previous.Title.TitleWindow
|
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 {
|
if switched {
|
||||||
titles.clearPending()
|
titles.clearPending()
|
||||||
// An AT-SPI activation may already have introduced this client.
|
// An AT-SPI activation may already have introduced this client.
|
||||||
titles.lastTitle = titles.state.owner.title
|
titles.lastTitle = titles.state.owner.title
|
||||||
|
if wine.IsReviewID(titles.state.owner.window) {
|
||||||
|
titles.lastTitle = ""
|
||||||
|
}
|
||||||
if strings.EqualFold(latest.Title.Application, "xterm") {
|
if strings.EqualFold(latest.Title.Application, "xterm") {
|
||||||
titles.lastTitle = ""
|
titles.lastTitle = ""
|
||||||
}
|
}
|
||||||
@@ -167,7 +174,7 @@ func (titles *windowTitleController) nativeChanged() {
|
|||||||
changed := !switched
|
changed := !switched
|
||||||
// AT-SPI can introduce a window before its first X11 title sample. A
|
// 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.
|
// 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") {
|
latest.Title.TitleWindow == latest.Title.Window && !strings.EqualFold(latest.Title.Application, "xterm") {
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
@@ -212,7 +219,7 @@ func (titles *windowTitleController) flush() {
|
|||||||
}
|
}
|
||||||
pending.window = titles.state.owner.window
|
pending.window = titles.state.owner.window
|
||||||
pending.application = titles.application
|
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
|
pending.application = pending.native.Application
|
||||||
}
|
}
|
||||||
} else if pending.window != titles.state.owner.window {
|
} else if pending.window != titles.state.owner.window {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -395,3 +396,97 @@ func TestWineTitleReportsCannotClaimUnrelatedForeground(t *testing.T) {
|
|||||||
t.Fatal("background helper title was announced")
|
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