Sometimes when switching from steam to a different workspace and back only the outer window takes focus so some actions don't work. Tentative fix for that.
This commit is contained in:
1 parent
9205e7280a
commit
12c514aab0
19 files changed
+798
-30
No files matched your search
@@ -6,6 +6,7 @@ Cthulhu is a Linux screen reader written in Go.
|
|||||||
- [Contributing](CONTRIBUTING.md): development, accessibility verification, licensing, and the AI-assisted contribution policy.
|
- [Contributing](CONTRIBUTING.md): development, accessibility verification, licensing, and the AI-assisted contribution policy.
|
||||||
- [LibreOffice support](LIBREOFFICE_SUPPORT.md): integration boundaries, initial Calc behavior, remaining work, and verification.
|
- [LibreOffice support](LIBREOFFICE_SUPPORT.md): integration boundaries, initial Calc behavior, remaining work, and verification.
|
||||||
- [Progress reporting](PROGRESS_BARS.md): web progress bars, Steam shader processing, and verification.
|
- [Progress reporting](PROGRESS_BARS.md): web progress bars, Steam shader processing, and verification.
|
||||||
|
- [Steam keyboard focus](STEAM_FOCUS.md): Xorg workspace focus recovery and verification.
|
||||||
|
|
||||||
AI-assisted contributions are welcome. Contributors remain responsible for human review and testing before submission; see the [full policy](CONTRIBUTING.md#ai-assisted-contributions).
|
AI-assisted contributions are welcome. Contributors remain responsible for human review and testing before submission; see the [full policy](CONTRIBUTING.md#ai-assisted-contributions).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Steam keyboard focus on Xorg
|
||||||
|
|
||||||
|
Steam Big Picture can retain its accessible focus while physical keyboard
|
||||||
|
events reach the outer Steam window instead of its embedded Chromium window.
|
||||||
|
In the reported game/workspace round trip, Escape stopped opening or closing
|
||||||
|
the menu, and switching browse/focus mode did not help. A delivery trace showed
|
||||||
|
both Escape edges reaching the outer window with no held modifiers. Restoring
|
||||||
|
native focus to the browser child recovered the open menu without restarting
|
||||||
|
Steam or Cthulhu. The original trigger remains unverified.
|
||||||
|
|
||||||
|
Cthulhu's Xorg command owner remembers the most recently focused, mapped
|
||||||
|
browser child of a Steam webhelper window. During the first second after that
|
||||||
|
window becomes foreground again, it allows Steam to restore its own focus.
|
||||||
|
Only if two consecutive focus samples still identify the outer window does it
|
||||||
|
attempt to restore the remembered child. A successful repair ends the attempt
|
||||||
|
for that activation.
|
||||||
|
|
||||||
|
The repair verifies the foreground window, Steam window class, process IDs,
|
||||||
|
child ancestry, and mapping state immediately before changing focus. It leaves
|
||||||
|
valid inner focus alone, including menus, search fields, and dialogs. It does
|
||||||
|
not run with keys held, reader modal input active, OCR active or pending, or
|
||||||
|
secure input active. Hidden, destroyed, unrelated, or unverified targets cause
|
||||||
|
it to leave focus unchanged. It does not guess another child or retain a
|
||||||
|
hard-coded window ID. The cache holds one Steam window and expires with the
|
||||||
|
command listener; restarting Cthulhu in an already broken session may require
|
||||||
|
fresh Steam focus before a valid child can be remembered.
|
||||||
|
|
||||||
|
This is an Xorg Steam workaround. Other applications and native Wayland focus
|
||||||
|
handling do not receive automatic keyboard-focus changes.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
The policy tests cover activation grace, remembered popup focus, bounded
|
||||||
|
attempts, process/window boundaries, and preservation of valid browser focus.
|
||||||
|
The native fixture verifies real Escape press/release delivery after repair and
|
||||||
|
rejects hidden or destroyed popups, unrelated children, changed process IDs,
|
||||||
|
held modifiers, other applications, and ordinary window classes.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test -race ./internal/platform/xorg ./internal/app -count=1
|
||||||
|
xvfb-run -a env CTHULHU_NATIVE_STEAM_FOCUS=1 GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test ./internal/platform/xorg -run '^TestNativeSteamFocusRepair$' -count=1 -v
|
||||||
|
CTHULHU_NATIVE_BROWSER_TESTS=keys,modes python3 tests/native_browser.py
|
||||||
|
python3 tests/native_x11.py
|
||||||
|
```
|
||||||
|
|
||||||
|
These checks passed for the initial patch. The existing native browser editing
|
||||||
|
check failed on both the patch and unchanged commit `9205e72`: its first typed
|
||||||
|
`c` was accompanied by `Cthulhu Browser Probe - Brave`. That separate title
|
||||||
|
announcement failure was not changed here.
|
||||||
|
|
||||||
|
Human review and live Steam acceptance remain required before submission:
|
||||||
|
|
||||||
|
1. Restart Cthulhu using the normal `cthulhu-go.sh -r` launcher, then start fresh
|
||||||
|
Steam Big Picture so the reader can observe a valid browser focus.
|
||||||
|
2. Verify Escape and game activation initially work. Launch a game, switch to
|
||||||
|
another workspace and back, and verify Escape and game activation again.
|
||||||
|
3. Repeat with the Steam menu open before switching away. Check Escape closes
|
||||||
|
it and arrows/Enter still select commands.
|
||||||
|
4. Type in Steam search, use Tab and Shift+Tab, and move away/back while search
|
||||||
|
or a dialog has focus. Check text entry, selection, and dialog buttons retain
|
||||||
|
their intended focus.
|
||||||
|
5. Switch to a normal browser and a GTK application. Check native typing,
|
||||||
|
browser structural navigation, and Cthulhu's XTerm/Fenrir handoff. Quit
|
||||||
|
Cthulhu and confirm normal application keys remain available.
|
||||||
|
|
||||||
|
Do not count a native fixture pass as audible speech, physical Braille, or
|
||||||
|
successful operation of Steam's actual Chromium/controller navigation.
|
||||||
@@ -282,11 +282,18 @@ func TestNativeBrowserEditing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
defer func() { cancel(); <-relayDone }()
|
defer func() { cancel(); <-relayDone }()
|
||||||
queue := &fakeSpeechQueue{spoken: make(chan string, 128)}
|
queue := &fakeSpeechQueue{spoken: make(chan string, 128), interrupt: make(chan struct{}, 128)}
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
inputDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(inputDone)
|
||||||
|
processInputEvents(ctx, logger, queue, listener, input.EchoSettings{}, nil, nil)
|
||||||
|
}()
|
||||||
|
defer func() { cancel(); <-inputDone }()
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
defer close(done)
|
defer close(done)
|
||||||
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, observed, nil, nil, 20*time.Millisecond, nil, listener, nil, nil)
|
processEventsWithInput(ctx, logger, queue, observed, nil, nil, 20*time.Millisecond, nil, listener, nil, nil)
|
||||||
}()
|
}()
|
||||||
defer func() { cancel(); <-done }()
|
defer func() { cancel(); <-done }()
|
||||||
keys := func(chord string) {
|
keys := func(chord string) {
|
||||||
@@ -315,11 +322,17 @@ func TestNativeBrowserEditing(t *testing.T) {
|
|||||||
keys("ctrl+l")
|
keys("ctrl+l")
|
||||||
collect()
|
collect()
|
||||||
for _, letter := range []string{"c", "t", "h"} {
|
for _, letter := range []string{"c", "t", "h"} {
|
||||||
|
for len(queue.interrupt) > 0 {
|
||||||
|
<-queue.interrupt
|
||||||
|
}
|
||||||
keys(letter)
|
keys(letter)
|
||||||
got := collect()
|
got := collect()
|
||||||
if len(got) != 1 || got[0] != letter {
|
if len(got) != 1 || got[0] != letter {
|
||||||
t.Fatalf("typing %q: %q", letter, got)
|
t.Fatalf("typing %q: %q", letter, got)
|
||||||
}
|
}
|
||||||
|
if len(queue.interrupt) == 0 {
|
||||||
|
t.Fatalf("typing %q did not interrupt speech", letter)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
keys("Down")
|
keys("Down")
|
||||||
got := collect()
|
got := collect()
|
||||||
@@ -380,9 +393,13 @@ func TestNativeBrowserEditing(t *testing.T) {
|
|||||||
if len(got) != 1 || got[0] != "• Registered: September 2026" {
|
if len(got) != 1 || got[0] != "• Registered: September 2026" {
|
||||||
t.Fatalf("embedded browser line text: %q", got)
|
t.Fatalf("embedded browser line text: %q", got)
|
||||||
}
|
}
|
||||||
|
// Preserve the browser-specific suffix. AT-SPI can report this change
|
||||||
|
// before the native X11 title cache catches up.
|
||||||
|
wantTitle := strings.Replace(adapter.ForegroundTitle().Title.Title, "Cthulhu Browser Probe", "Cthulhu Browser Probe updated", 1)
|
||||||
keys("F8")
|
keys("F8")
|
||||||
if got := collect(); len(got) != 0 || !browserNameChanged.Load() {
|
got = collect()
|
||||||
t.Fatalf("browser title update: speech=%q, native event=%v", got, browserNameChanged.Load())
|
if len(got) != 1 || got[0] != wantTitle || !strings.Contains(wantTitle, "Cthulhu Browser Probe updated") || !browserNameChanged.Load() {
|
||||||
|
t.Fatalf("browser title update: speech=%q, expected=%q, native event=%v", got, wantTitle, browserNameChanged.Load())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed a typed browser URL locally, then replace it with a prefix. This
|
// Seed a typed browser URL locally, then replace it with a prefix. This
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ func processEventsWithAttributes(
|
|||||||
foregroundChanges = foreground.ForegroundChanges()
|
foregroundChanges = foreground.ForegroundChanges()
|
||||||
state.foregroundState = foreground.Foreground()
|
state.foregroundState = foreground.Foreground()
|
||||||
}
|
}
|
||||||
titles := &windowTitleController{state: &state, live: live, gate: focusGate, speech: speech, logger: logger, foreground: foreground}
|
titles := &windowTitleController{state: &state, live: live, gate: focusGate, speech: speech, logger: logger, foreground: foreground, inputSource: inputSource}
|
||||||
titles.source, _ = adapter.(windowTitleSource)
|
titles.source, _ = adapter.(windowTitleSource)
|
||||||
var titleChanges <-chan struct{}
|
var titleChanges <-chan struct{}
|
||||||
if titles.source != nil {
|
if titles.source != nil {
|
||||||
|
|||||||
@@ -19,6 +19,5 @@ func (policy *titleInputPolicy) observe(request speechRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (policy *titleInputPolicy) preserves(event input.Event) bool {
|
func (policy *titleInputPolicy) preserves(event input.Event) bool {
|
||||||
return policy.protectRepeats && event.Repeat && event.Pressed &&
|
return policy.protectRepeats && event.Pressed && !input.ShouldInterruptTitle(event)
|
||||||
event.Name != "Control_L" && event.Name != "Control_R" && event.Name != "Escape"
|
|
||||||
}
|
}
|
||||||
@@ -20,13 +20,14 @@ type windowTitleSource interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type pendingWindowTitle struct {
|
type pendingWindowTitle struct {
|
||||||
text string
|
text string
|
||||||
window atspi.ObjectID
|
window atspi.ObjectID
|
||||||
epoch uint64
|
epoch uint64
|
||||||
native xorg.WindowTitle
|
native xorg.WindowTitle
|
||||||
changed bool
|
changed bool
|
||||||
application string
|
application string
|
||||||
observedAt time.Time
|
observedAt time.Time
|
||||||
|
inputSequence uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
// windowTitleController is owned by the accessibility event loop. Discovery
|
// windowTitleController is owned by the accessibility event loop. Discovery
|
||||||
@@ -40,6 +41,7 @@ type windowTitleController struct {
|
|||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
source windowTitleSource
|
source windowTitleSource
|
||||||
foreground foregroundMonitor
|
foreground foregroundMonitor
|
||||||
|
inputSource any
|
||||||
lastNative atspi.ForegroundTitle
|
lastNative atspi.ForegroundTitle
|
||||||
lastTitle string
|
lastTitle string
|
||||||
application string
|
application string
|
||||||
@@ -96,6 +98,11 @@ func (titles *windowTitleController) applicationForEvent(event atspi.Event) stri
|
|||||||
func (titles *windowTitleController) queue(pending pendingWindowTitle) {
|
func (titles *windowTitleController) queue(pending pendingWindowTitle) {
|
||||||
if pending.observedAt.IsZero() {
|
if pending.observedAt.IsZero() {
|
||||||
pending.observedAt = time.Now()
|
pending.observedAt = time.Now()
|
||||||
|
pending.inputSequence = recentInput(titles.inputSource).TitleInterruptionSequence
|
||||||
|
}
|
||||||
|
if titles.pending != nil {
|
||||||
|
// Coalescing must not revive a burst interrupted since its first sample.
|
||||||
|
pending.inputSequence = titles.pending.inputSequence
|
||||||
}
|
}
|
||||||
if previous := titles.pending; previous != nil && !previous.changed && previous.epoch == pending.epoch &&
|
if previous := titles.pending; previous != nil && !previous.changed && previous.epoch == pending.epoch &&
|
||||||
previous.window == pending.window && previous.native.Window == pending.native.Window &&
|
previous.window == pending.window && previous.native.Window == pending.native.Window &&
|
||||||
@@ -246,6 +253,9 @@ func (titles *windowTitleController) flush() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
titles.lastTitle = pending.text
|
titles.lastTitle = pending.text
|
||||||
|
if pending.inputSequence != recentInput(titles.inputSource).TitleInterruptionSequence {
|
||||||
|
return
|
||||||
|
}
|
||||||
if titles.logger != nil {
|
if titles.logger != nil {
|
||||||
titles.logger.Debug("queued automatic window title", "title_changed", pending.changed,
|
titles.logger.Debug("queued automatic window title", "title_changed", pending.changed,
|
||||||
"observed_to_queue_ms", time.Since(pending.observedAt).Milliseconds())
|
"observed_to_queue_ms", time.Since(pending.observedAt).Milliseconds())
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/input"
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNativeAutomaticTitleDispatchLatency(t *testing.T) {
|
func TestNativeAutomaticTitleDispatchLatency(t *testing.T) {
|
||||||
@@ -46,9 +48,22 @@ func TestNativeAutomaticTitleDispatchLatency(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer adapter.Stop(context.Background())
|
defer adapter.Stop(context.Background())
|
||||||
|
listener := xorg.New()
|
||||||
|
if err = listener.Start(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer listener.Stop(context.Background())
|
||||||
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
|
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
|
||||||
|
inputDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(inputDone)
|
||||||
|
processInputEvents(ctx, slog.New(slog.DiscardHandler), queue, listener, input.EchoSettings{}, nil, nil)
|
||||||
|
}()
|
||||||
|
defer func() { cancel(); <-inputDone }()
|
||||||
completed := make(chan error, 1)
|
completed := make(chan error, 1)
|
||||||
go func() { completed <- processEvents(ctx, slog.New(slog.DiscardHandler), queue, adapter, 0) }()
|
go func() {
|
||||||
|
completed <- processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, listener, nil, nil)
|
||||||
|
}()
|
||||||
defer func() {
|
defer func() {
|
||||||
cancel()
|
cancel()
|
||||||
if err := <-completed; err != nil {
|
if err := <-completed; err != nil {
|
||||||
@@ -60,9 +75,10 @@ func TestNativeAutomaticTitleDispatchLatency(t *testing.T) {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case got := <-queue.spoken:
|
case got := <-queue.spoken:
|
||||||
if got == want {
|
if got != want {
|
||||||
return
|
t.Fatalf("title = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
|
return
|
||||||
case <-deadline:
|
case <-deadline:
|
||||||
t.Fatalf("title %q was not queued promptly", want)
|
t.Fatalf("title %q was not queued promptly", want)
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -84,4 +100,26 @@ func TestNativeAutomaticTitleDispatchLatency(t *testing.T) {
|
|||||||
wait(title, deadline)
|
wait(title, deadline)
|
||||||
t.Logf("X11 title update to speech queue: %s", time.Since(began))
|
t.Logf("X11 title update to speech queue: %s", time.Since(began))
|
||||||
}
|
}
|
||||||
|
// The switch chord must not suppress the destination's introduction. The
|
||||||
|
// fixture has no window manager, so activate each destination explicitly.
|
||||||
|
for _, destination := range []struct{ window, title string }{
|
||||||
|
{"unrelated", "Unrelated title"}, {"normal", "Status three"},
|
||||||
|
} {
|
||||||
|
if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "alt+Tab").CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("switch keys: %v %s", err, out)
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(map[string]string{"action": "activate", "window": destination.window})
|
||||||
|
if _, err = stdin.Write(append(data, '\n')); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !scanner.Scan() || scanner.Text() != "ready" {
|
||||||
|
t.Fatal("fixture switch failed")
|
||||||
|
}
|
||||||
|
wait(destination.title, time.After(2*time.Second))
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case got := <-queue.spoken:
|
||||||
|
t.Fatalf("duplicate window title: %q", got)
|
||||||
|
case <-time.After(2 * windowTitleIntroductionDelay):
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||||
"git.stormux.org/storm/cthulhu/internal/config"
|
"git.stormux.org/storm/cthulhu/internal/config"
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/input"
|
||||||
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
|
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
|
||||||
"git.stormux.org/storm/cthulhu/internal/preferences"
|
"git.stormux.org/storm/cthulhu/internal/preferences"
|
||||||
)
|
)
|
||||||
@@ -80,6 +81,90 @@ func TestAutomaticTitlePolicyCoalescesAndPreservesSpeech(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScheduledTitleInterruptedBeforeSpeechQueue(t *testing.T) {
|
||||||
|
for _, name := range []string{"c", "Tab", "Control_L", "Escape"} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
titles, queue, changed := titleControllerFixture(t)
|
||||||
|
tracker := &input.IntentTracker{}
|
||||||
|
titles.inputSource = tracker
|
||||||
|
changed.Source.Name = "Scheduled status"
|
||||||
|
titles.accessible(changed)
|
||||||
|
tracker.Observe(input.NewKeyEvent(0, true, name))
|
||||||
|
// A newer sample in the same burst must not undo cancellation.
|
||||||
|
changed.Source.Name = "Latest status"
|
||||||
|
titles.accessible(changed)
|
||||||
|
titles.flush()
|
||||||
|
if len(queue.requests) != 0 || titles.pending != nil {
|
||||||
|
t.Fatal("interrupted title reached the speech queue")
|
||||||
|
}
|
||||||
|
// A genuinely later change still announces normally.
|
||||||
|
changed.Source.Name = "New status"
|
||||||
|
titles.accessible(changed)
|
||||||
|
titles.flush()
|
||||||
|
if len(queue.requests) != 1 || queue.requests[0].Text != "New status" {
|
||||||
|
t.Fatalf("later title = %+v", queue.requests)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduledNativeIntroductionPreservesSwitchAndRepeatInput(t *testing.T) {
|
||||||
|
titles, queue, _ := titleControllerFixture(t)
|
||||||
|
titles.state.owner.clear()
|
||||||
|
tracker := &input.IntentTracker{}
|
||||||
|
titles.inputSource = tracker
|
||||||
|
source := &titleProbeSource{title: atspi.ForegroundTitle{Epoch: 1, Title: xorg.WindowTitle{Window: 20, TitleWindow: 20, Title: "Other window"}}}
|
||||||
|
titles.source, titles.foreground = source, source
|
||||||
|
// Input initiating a switch precedes discovery of its destination title.
|
||||||
|
tracker.Observe(input.NewKeyEvent(0, true, "Tab"))
|
||||||
|
titles.nativeChanged()
|
||||||
|
repeat := input.NewKeyEvent(0, true, "Tab")
|
||||||
|
repeat.Repeat = true
|
||||||
|
tracker.Observe(repeat)
|
||||||
|
titles.flush()
|
||||||
|
if len(queue.requests) != 1 || queue.requests[0].Text != "Other window" {
|
||||||
|
t.Fatalf("destination title = %+v", queue.requests)
|
||||||
|
}
|
||||||
|
// Fresh typing after a subsequent switch cancels its delayed introduction.
|
||||||
|
source.title.Epoch++
|
||||||
|
source.title.Title.Window, source.title.Title.TitleWindow = 21, 21
|
||||||
|
source.title.Title.Title = "Next window"
|
||||||
|
titles.nativeChanged()
|
||||||
|
tracker.Observe(input.NewKeyEvent(0, true, "c"))
|
||||||
|
repeat = input.NewKeyEvent(0, true, "c")
|
||||||
|
repeat.Repeat = true
|
||||||
|
tracker.Observe(repeat)
|
||||||
|
titles.flush()
|
||||||
|
if len(queue.requests) != 1 {
|
||||||
|
t.Fatal("typing interruption was hidden by its later repeat")
|
||||||
|
}
|
||||||
|
// Returning to that window must introduce it again despite cancellation.
|
||||||
|
source.title.Epoch++
|
||||||
|
titles.nativeChanged()
|
||||||
|
titles.flush()
|
||||||
|
if len(queue.requests) != 2 || queue.requests[1].Text != "Next window" {
|
||||||
|
t.Fatalf("return title = %+v", queue.requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduledTitleCannotReviveWhileWaitingForSettings(t *testing.T) {
|
||||||
|
titles, queue, changed := titleControllerFixture(t)
|
||||||
|
tracker := &input.IntentTracker{}
|
||||||
|
titles.inputSource = tracker
|
||||||
|
titles.live.onApplication = func(string) {}
|
||||||
|
titles.application = "Example Application"
|
||||||
|
changed.Source.Name = "Pending application title"
|
||||||
|
titles.accessible(changed)
|
||||||
|
titles.flush() // Requeued until the application override is committed.
|
||||||
|
tracker.Observe(input.NewKeyEvent(0, true, "c"))
|
||||||
|
application := titles.application
|
||||||
|
titles.live.application.Store(&application)
|
||||||
|
titles.flush()
|
||||||
|
if len(queue.requests) != 0 || titles.pending != nil {
|
||||||
|
t.Fatal("settings retry revived an interrupted title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAutomaticTitleUsesBrailleMessagePathAndKeepsFocus(t *testing.T) {
|
func TestAutomaticTitleUsesBrailleMessagePathAndKeepsFocus(t *testing.T) {
|
||||||
titles, queue, changed := titleControllerFixture(t)
|
titles, queue, changed := titleControllerFixture(t)
|
||||||
display := &flashRecorder{}
|
display := &flashRecorder{}
|
||||||
|
|||||||
@@ -123,3 +123,9 @@ func (modifiers ModifierKeycodes) Contains(keycode uint8) bool {
|
|||||||
func ShouldInterruptSpeech(event Event) bool {
|
func ShouldInterruptSpeech(event Event) bool {
|
||||||
return event.Pressed
|
return event.Pressed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Automatic titles survive held-key repeats, but fresh input and explicit
|
||||||
|
// stop keys cancel both scheduled and already queued title speech.
|
||||||
|
func ShouldInterruptTitle(event Event) bool {
|
||||||
|
return event.Pressed && (!event.Repeat || event.Name == "Control_L" || event.Name == "Control_R" || event.Name == "Escape")
|
||||||
|
}
|
||||||
+21
-13
@@ -24,10 +24,13 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Activity struct {
|
type Activity struct {
|
||||||
Selecting bool
|
// Unlike presentation intent, this counter never expires. Deferred titles
|
||||||
Intent Intent
|
// may be waiting for application settings after ordinary input has aged out.
|
||||||
Sequence uint64
|
TitleInterruptionSequence uint64
|
||||||
Clipboard ClipboardAction
|
Selecting bool
|
||||||
|
Intent Intent
|
||||||
|
Sequence uint64
|
||||||
|
Clipboard ClipboardAction
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClipboardAction uint8
|
type ClipboardAction uint8
|
||||||
@@ -40,13 +43,14 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type IntentTracker struct {
|
type IntentTracker struct {
|
||||||
sequence uint64
|
titleInterruptionSequence uint64
|
||||||
mutex sync.Mutex
|
sequence uint64
|
||||||
held map[string]bool
|
mutex sync.Mutex
|
||||||
intent Intent
|
held map[string]bool
|
||||||
clipboard ClipboardAction
|
intent Intent
|
||||||
selecting bool
|
clipboard ClipboardAction
|
||||||
at time.Time
|
selecting bool
|
||||||
|
at time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tracker *IntentTracker) Observe(event Event) {
|
func (tracker *IntentTracker) Observe(event Event) {
|
||||||
@@ -55,6 +59,9 @@ func (tracker *IntentTracker) Observe(event Event) {
|
|||||||
if event.Pressed {
|
if event.Pressed {
|
||||||
tracker.sequence++
|
tracker.sequence++
|
||||||
}
|
}
|
||||||
|
if ShouldInterruptTitle(event) {
|
||||||
|
tracker.titleInterruptionSequence++
|
||||||
|
}
|
||||||
if tracker.held == nil {
|
if tracker.held == nil {
|
||||||
tracker.held = make(map[string]bool)
|
tracker.held = make(map[string]bool)
|
||||||
}
|
}
|
||||||
@@ -129,7 +136,8 @@ func (tracker *IntentTracker) RecentInput() Activity {
|
|||||||
tracker.mutex.Lock()
|
tracker.mutex.Lock()
|
||||||
defer tracker.mutex.Unlock()
|
defer tracker.mutex.Unlock()
|
||||||
if time.Since(tracker.at) > time.Second {
|
if time.Since(tracker.at) > time.Second {
|
||||||
return Activity{}
|
return Activity{TitleInterruptionSequence: tracker.titleInterruptionSequence}
|
||||||
}
|
}
|
||||||
return Activity{Intent: tracker.intent, Sequence: tracker.sequence, Clipboard: tracker.clipboard, Selecting: tracker.selecting}
|
return Activity{Intent: tracker.intent, Sequence: tracker.sequence, Clipboard: tracker.clipboard, Selecting: tracker.selecting,
|
||||||
|
TitleInterruptionSequence: tracker.titleInterruptionSequence}
|
||||||
}
|
}
|
||||||
@@ -83,3 +83,33 @@ func TestSelectionIntentEndsWithPlainNavigation(t *testing.T) {
|
|||||||
t.Fatalf("Right = %+v", got)
|
t.Fatalf("Right = %+v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTitleInterruptionTracksFreshPressesAndStopKeys(t *testing.T) {
|
||||||
|
tracker := &IntentTracker{}
|
||||||
|
for _, step := range []struct {
|
||||||
|
name string
|
||||||
|
pressed bool
|
||||||
|
repeat bool
|
||||||
|
want uint64
|
||||||
|
}{
|
||||||
|
{"a", true, false, 1}, {"a", false, false, 1},
|
||||||
|
{"a", true, true, 1}, {"Down", true, false, 2},
|
||||||
|
{"Down", true, true, 2}, {"Control_L", true, true, 3},
|
||||||
|
{"Control_R", true, true, 4}, {"Escape", true, true, 5},
|
||||||
|
} {
|
||||||
|
event := NewKeyEvent(0, step.pressed, step.name)
|
||||||
|
event.Repeat = step.repeat
|
||||||
|
tracker.Observe(event)
|
||||||
|
if got := tracker.RecentInput().TitleInterruptionSequence; got != step.want {
|
||||||
|
t.Fatalf("%+v: title interruption sequence = %d", step, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracker.at = time.Now().Add(-2 * time.Second)
|
||||||
|
if got := tracker.RecentInput(); got.Intent != NoIntent || got.TitleInterruptionSequence != 5 {
|
||||||
|
t.Fatalf("expired activity = %+v", got)
|
||||||
|
}
|
||||||
|
tracker.Reset()
|
||||||
|
if got := tracker.RecentInput().TitleInterruptionSequence; got != 5 {
|
||||||
|
t.Fatalf("reset lost interruption history: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,11 +30,14 @@ typedef struct {
|
|||||||
unsigned long window;
|
unsigned long window;
|
||||||
} Foreground;
|
} Foreground;
|
||||||
typedef struct { int known, pid, x, y; unsigned long window; } PointerPosition;
|
typedef struct { int known, pid, x, y; unsigned long window; } PointerPosition;
|
||||||
|
typedef struct { int known, pid, steam, innerFocus; Window window, focus; } SteamFocusSample;
|
||||||
|
|
||||||
int cthulhu_active_window_is_xterm(void);
|
int cthulhu_active_window_is_xterm(void);
|
||||||
int cthulhu_display_active_window_xterm_match(Display *display);
|
int cthulhu_display_active_window_xterm_match(Display *display);
|
||||||
Foreground read_foreground(Display *display);
|
Foreground read_foreground(Display *display);
|
||||||
PointerPosition cthulhu_read_pointer(Display *display);
|
PointerPosition cthulhu_read_pointer(Display *display);
|
||||||
|
SteamFocusSample cthulhu_read_steam_focus(Display *display);
|
||||||
|
int cthulhu_restore_steam_focus(Display *display, Window wrapper, int pid, Window target);
|
||||||
void cthulhu_x11_begin_read(Display *display);
|
void cthulhu_x11_begin_read(Display *display);
|
||||||
int cthulhu_x11_end_read(Display *display);
|
int cthulhu_x11_end_read(Display *display);
|
||||||
int set_command_keys(CommandDevice *device, int enabled);
|
int set_command_keys(CommandDevice *device, int enabled);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type commandOwner struct {
|
|||||||
uiWindow, uiFocus C.Window
|
uiWindow, uiFocus C.Window
|
||||||
lastLocks, lastFocusMatch C.int
|
lastLocks, lastFocusMatch C.int
|
||||||
lastFocusCheck, drainDeadline time.Time
|
lastFocusCheck, drainDeadline time.Time
|
||||||
|
steamFocus steamFocusRepair
|
||||||
}
|
}
|
||||||
|
|
||||||
func (listener *CommandListener) runCommandLoop(workerContext context.Context, device *C.CommandDevice, layout input.KeyboardLayout, modifiers []string, core, tentacles []input.BindingOverride, symbols []uint32) {
|
func (listener *CommandListener) runCommandLoop(workerContext context.Context, device *C.CommandDevice, layout input.KeyboardLayout, modifiers []string, core, tentacles []input.BindingOverride, symbols []uint32) {
|
||||||
@@ -72,6 +73,7 @@ func (owner *commandOwner) run() {
|
|||||||
}
|
}
|
||||||
if time.Since(owner.lastFocusCheck) >= xtermFocusCheckInterval {
|
if time.Since(owner.lastFocusCheck) >= xtermFocusCheckInterval {
|
||||||
owner.publishFocus()
|
owner.publishFocus()
|
||||||
|
owner.check_steam_focus()
|
||||||
}
|
}
|
||||||
owner.resetForSecureInput()
|
owner.resetForSecureInput()
|
||||||
if !owner.syncGrabs() {
|
if !owner.syncGrabs() {
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// Copyright (c) 2026 Storm Dragon
|
||||||
|
// Authors: Storm Dragon, Angus Kola
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "commands_native.h"
|
||||||
|
#include <X11/Xatom.h>
|
||||||
|
#include <X11/Xutil.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static int steam_active_window(Display *display, Window *window) {
|
||||||
|
Atom type; int format;
|
||||||
|
unsigned long count, remaining;
|
||||||
|
unsigned char *data = NULL;
|
||||||
|
int known = XGetWindowProperty(display, DefaultRootWindow(display), XInternAtom(display, "_NET_ACTIVE_WINDOW", False),
|
||||||
|
0, 1, False, XA_WINDOW, &type, &format, &count, &remaining, &data) == Success &&
|
||||||
|
data != NULL && type == XA_WINDOW && format == 32 && count == 1;
|
||||||
|
if (known) *window = ((Window *)data)[0];
|
||||||
|
if (data != NULL) XFree(data);
|
||||||
|
return known;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int steam_window_pid(Display *display, Window window) {
|
||||||
|
Atom type; int format;
|
||||||
|
unsigned long count, remaining;
|
||||||
|
unsigned char *data = NULL;
|
||||||
|
int pid = 0;
|
||||||
|
if (XGetWindowProperty(display, window, XInternAtom(display, "_NET_WM_PID", False),
|
||||||
|
0, 1, False, XA_CARDINAL, &type, &format, &count,
|
||||||
|
&remaining, &data) == Success && data != NULL &&
|
||||||
|
type == XA_CARDINAL && format == 32 && count == 1) {
|
||||||
|
pid = (int)((unsigned long *)data)[0];
|
||||||
|
}
|
||||||
|
if (data != NULL) XFree(data);
|
||||||
|
return pid;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int steam_wrapper(Display *display, Window window) {
|
||||||
|
XClassHint hint = {0};
|
||||||
|
int matches = XGetClassHint(display, window, &hint) &&
|
||||||
|
hint.res_name != NULL && hint.res_class != NULL &&
|
||||||
|
strcmp(hint.res_name, "steamwebhelper") == 0 && strcmp(hint.res_class, "steam") == 0;
|
||||||
|
if (hint.res_name != NULL) XFree(hint.res_name);
|
||||||
|
if (hint.res_class != NULL) XFree(hint.res_class);
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int steam_browser_child(Display *display, Window wrapper, int pid, Window child) {
|
||||||
|
XWindowAttributes attributes;
|
||||||
|
if (child == None || child == PointerRoot || child == wrapper ||
|
||||||
|
!XGetWindowAttributes(display, child, &attributes) || attributes.map_state != IsViewable ||
|
||||||
|
attributes.class != InputOutput || steam_window_pid(display, child) != pid) return 0;
|
||||||
|
for (int depth = 0; depth < 32; depth++) {
|
||||||
|
Window root, parent, *children = NULL;
|
||||||
|
unsigned int count;
|
||||||
|
if (!XQueryTree(display, child, &root, &parent, &children, &count)) return 0;
|
||||||
|
if (children != NULL) XFree(children);
|
||||||
|
if (parent == wrapper) return 1;
|
||||||
|
if (parent == None || parent == root) return 0;
|
||||||
|
child = parent;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
SteamFocusSample cthulhu_read_steam_focus(Display *display) {
|
||||||
|
SteamFocusSample sample = {0};
|
||||||
|
cthulhu_x11_begin_read(display);
|
||||||
|
sample.known = steam_active_window(display, &sample.window);
|
||||||
|
if (sample.known && sample.window != None && steam_wrapper(display, sample.window)) {
|
||||||
|
sample.pid = steam_window_pid(display, sample.window);
|
||||||
|
sample.steam = sample.pid > 0;
|
||||||
|
int revert;
|
||||||
|
XGetInputFocus(display, &sample.focus, &revert);
|
||||||
|
if (sample.steam) sample.innerFocus = steam_browser_child(display, sample.window, sample.pid, sample.focus);
|
||||||
|
}
|
||||||
|
if (cthulhu_x11_end_read(display)) sample = (SteamFocusSample){0};
|
||||||
|
return sample;
|
||||||
|
}
|
||||||
|
|
||||||
|
int cthulhu_restore_steam_focus(Display *display, Window wrapper, int pid, Window target) {
|
||||||
|
cthulhu_x11_begin_read(display);
|
||||||
|
int restored = 0, revert;
|
||||||
|
Window focus, active = None;
|
||||||
|
XGetInputFocus(display, &focus, &revert);
|
||||||
|
char heldKeys[32];
|
||||||
|
XQueryKeymap(display, heldKeys);
|
||||||
|
int keyHeld = 0;
|
||||||
|
for (int i = 0; i < 32; i++) keyHeld |= heldKeys[i];
|
||||||
|
if (!keyHeld && focus == wrapper && steam_active_window(display, &active) && active == wrapper &&
|
||||||
|
steam_wrapper(display, wrapper) &&
|
||||||
|
steam_window_pid(display, wrapper) == pid && steam_browser_child(display, wrapper, pid, target)) {
|
||||||
|
// Recheck foreground immediately before changing input focus: cached
|
||||||
|
// children must never pull keyboard input out of a different client.
|
||||||
|
if (steam_active_window(display, &active) && active == wrapper) {
|
||||||
|
XGetInputFocus(display, &focus, &revert);
|
||||||
|
if (focus == wrapper) {
|
||||||
|
XSetInputFocus(display, target, RevertToParent, CurrentTime);
|
||||||
|
XSync(display, False);
|
||||||
|
XGetInputFocus(display, &focus, &revert);
|
||||||
|
restored = focus == target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cthulhu_x11_end_read(display)) restored = 0;
|
||||||
|
return restored;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Copyright (c) 2026 Storm Dragon
|
||||||
|
// Authors: Storm Dragon, Angus Kola
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package xorg
|
||||||
|
|
||||||
|
/*
|
||||||
|
#include "commands_native.h"
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
type steamFocusSample struct {
|
||||||
|
window, focus uint64
|
||||||
|
pid int
|
||||||
|
known, steam bool
|
||||||
|
innerFocus bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// The command owner retains one verified Steam browser child. A foreground
|
||||||
|
// transition arms at most one repair during the next ten 100ms focus samples.
|
||||||
|
// Steam's own restoration wins; we never choose a child from its geometry.
|
||||||
|
type steamFocusRepair struct {
|
||||||
|
previousWindow, window, target uint64
|
||||||
|
pid int
|
||||||
|
checksLeft, outerSamples int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repair *steamFocusRepair) observe(sample steamFocusSample) uint64 {
|
||||||
|
if !sample.known {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if sample.window != repair.previousWindow {
|
||||||
|
repair.previousWindow = sample.window
|
||||||
|
repair.checksLeft, repair.outerSamples = 10, 0
|
||||||
|
}
|
||||||
|
if !sample.steam {
|
||||||
|
repair.checksLeft, repair.outerSamples = 0, 0
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if sample.innerFocus {
|
||||||
|
repair.window, repair.pid, repair.target = sample.window, sample.pid, sample.focus
|
||||||
|
repair.outerSamples = 0
|
||||||
|
}
|
||||||
|
if repair.checksLeft == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
repair.checksLeft--
|
||||||
|
if sample.focus != sample.window {
|
||||||
|
repair.outerSamples = 0
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
repair.outerSamples++
|
||||||
|
if repair.outerSamples < 2 || sample.window != repair.window || sample.pid != repair.pid {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return repair.target
|
||||||
|
}
|
||||||
|
|
||||||
|
func read_steam_focus(display *C.Display) steamFocusSample {
|
||||||
|
value := C.cthulhu_read_steam_focus(display)
|
||||||
|
return steamFocusSample{window: uint64(value.window), focus: uint64(value.focus), pid: int(value.pid),
|
||||||
|
known: value.known != 0, steam: value.steam != 0, innerFocus: value.innerFocus != 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restore_steam_focus(display *C.Display, window uint64, pid int, target uint64) bool {
|
||||||
|
return C.cthulhu_restore_steam_focus(display, C.Window(window), C.int(pid), C.Window(target)) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (owner *commandOwner) check_steam_focus() {
|
||||||
|
if owner.listener.secure.Load() || owner.workerContext.Err() != nil {
|
||||||
|
owner.steamFocus.checksLeft = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sample := read_steam_focus(owner.device.display)
|
||||||
|
target := owner.steamFocus.observe(sample)
|
||||||
|
if target == 0 || !owner.commandGrabsAllowed() || len(owner.held) != 0 || owner.device.active != 0 ||
|
||||||
|
owner.learnMode || owner.uiSession != 0 || owner.ocrReview || owner.ocrPending {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if restore_steam_focus(owner.device.display, sample.window, sample.pid, target) {
|
||||||
|
owner.steamFocus.checksLeft = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// Copyright (c) 2026 Storm Dragon
|
||||||
|
// Authors: Storm Dragon, Angus Kola
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package xorg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNativeSteamFocusRepair(t *testing.T) {
|
||||||
|
if os.Getenv("CTHULHU_NATIVE_STEAM_FOCUS") != "1" {
|
||||||
|
t.Skip("requires private X11 Steam fixture")
|
||||||
|
}
|
||||||
|
runtime.LockOSThread()
|
||||||
|
defer runtime.UnlockOSThread()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
fixture := exec.CommandContext(ctx, "python3", "-u", "../../../tests/steam_focus_fixture.py")
|
||||||
|
stdin, err := fixture.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stdout, err := fixture.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fixture.Stderr = os.Stderr
|
||||||
|
if err := fixture.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { stdin.Close(); fixture.Wait() }()
|
||||||
|
scanner := bufio.NewScanner(stdout)
|
||||||
|
if !scanner.Scan() {
|
||||||
|
t.Fatal("Steam fixture did not start")
|
||||||
|
}
|
||||||
|
var windows map[string]uint64
|
||||||
|
if err := json.Unmarshal(scanner.Bytes(), &windows); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
command := func(action, window string) {
|
||||||
|
t.Helper()
|
||||||
|
data, _ := json.Marshal(map[string]string{"action": action, "window": window})
|
||||||
|
if _, err := stdin.Write(append(data, '\n')); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !scanner.Scan() || scanner.Text() != "ready" {
|
||||||
|
t.Fatal("Steam fixture command failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
key := func(action, name string) {
|
||||||
|
t.Helper()
|
||||||
|
if out, err := exec.CommandContext(ctx, "xdotool", action, name).CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("key: %v %s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader := OpenForegroundReader()
|
||||||
|
if reader == nil {
|
||||||
|
t.Fatal("cannot open private X11 display")
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
sample := read_steam_focus(reader.display)
|
||||||
|
if !sample.steam || !sample.innerFocus || sample.focus != windows["browser"] {
|
||||||
|
t.Fatalf("initial browser focus not recognized: %+v", sample)
|
||||||
|
}
|
||||||
|
repair := steamFocusRepair{}
|
||||||
|
repair.observe(sample)
|
||||||
|
roundTrip := func() uint64 {
|
||||||
|
t.Helper()
|
||||||
|
command("activate", "other")
|
||||||
|
repair.observe(read_steam_focus(reader.display))
|
||||||
|
command("activate", "steam")
|
||||||
|
if target := repair.observe(read_steam_focus(reader.display)); target != 0 {
|
||||||
|
t.Fatal("Steam restoration grace sample was skipped")
|
||||||
|
}
|
||||||
|
return repair.observe(read_steam_focus(reader.display))
|
||||||
|
}
|
||||||
|
target := roundTrip()
|
||||||
|
if target != windows["browser"] || !restore_steam_focus(reader.display, sample.window, sample.pid, target) {
|
||||||
|
t.Fatal("main browser keyboard focus was not repaired")
|
||||||
|
}
|
||||||
|
key("key", "Escape")
|
||||||
|
data, _ := json.Marshal(map[string]string{"action": "events"})
|
||||||
|
if _, err := stdin.Write(append(data, '\n')); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !scanner.Scan() {
|
||||||
|
t.Fatal("fixture key delivery missing")
|
||||||
|
}
|
||||||
|
var events []struct {
|
||||||
|
Window uint64
|
||||||
|
Pressed bool
|
||||||
|
Escape bool
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(scanner.Bytes(), &events); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(events) != 2 || !events[0].Escape || !events[0].Pressed || !events[1].Escape || events[1].Pressed ||
|
||||||
|
events[0].Window != target || events[1].Window != target {
|
||||||
|
t.Fatalf("Escape was not delivered to restored browser: %+v", events)
|
||||||
|
}
|
||||||
|
command("focus", "popup")
|
||||||
|
if target := repair.observe(read_steam_focus(reader.display)); target != 0 {
|
||||||
|
t.Fatal("valid popup focus was overridden")
|
||||||
|
}
|
||||||
|
target = roundTrip()
|
||||||
|
if target != windows["popup"] || !restore_steam_focus(reader.display, sample.window, sample.pid, target) {
|
||||||
|
t.Fatal("popup keyboard focus was not repaired")
|
||||||
|
}
|
||||||
|
command("focus", "browser")
|
||||||
|
if restore_steam_focus(reader.display, sample.window, sample.pid, target) {
|
||||||
|
t.Fatal("valid search/control browser focus was overridden")
|
||||||
|
}
|
||||||
|
command("activate", "steam")
|
||||||
|
key("keydown", "Control_L")
|
||||||
|
blocked := restore_steam_focus(reader.display, sample.window, sample.pid, target)
|
||||||
|
key("keyup", "Control_L")
|
||||||
|
if blocked {
|
||||||
|
t.Fatal("focus moved while a physical modifier was held")
|
||||||
|
}
|
||||||
|
command("activate", "other")
|
||||||
|
if restore_steam_focus(reader.display, sample.window, sample.pid, target) {
|
||||||
|
t.Fatal("repair stole focus from another application")
|
||||||
|
}
|
||||||
|
command("activate", "steam")
|
||||||
|
if restore_steam_focus(reader.display, sample.window, sample.pid, windows["otherChild"]) {
|
||||||
|
t.Fatal("repair targeted an unrelated child")
|
||||||
|
}
|
||||||
|
command("unmap", "popupHost")
|
||||||
|
if restore_steam_focus(reader.display, sample.window, sample.pid, target) {
|
||||||
|
t.Fatal("repair focused a hidden popup")
|
||||||
|
}
|
||||||
|
command("map", "popupHost")
|
||||||
|
command("pid", "popup")
|
||||||
|
if restore_steam_focus(reader.display, sample.window, sample.pid, target) {
|
||||||
|
t.Fatal("repair focused a child from a different process")
|
||||||
|
}
|
||||||
|
command("destroy", "popup")
|
||||||
|
if restore_steam_focus(reader.display, sample.window, sample.pid, target) {
|
||||||
|
t.Fatal("repair focused a destroyed popup")
|
||||||
|
}
|
||||||
|
command("class", "steam")
|
||||||
|
if read_steam_focus(reader.display).steam || restore_steam_focus(reader.display, sample.window, sample.pid, windows["browser"]) {
|
||||||
|
t.Fatal("repair changed focus in an ordinary application")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Copyright (c) 2026 Storm Dragon
|
||||||
|
// Authors: Storm Dragon, Angus Kola
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package xorg
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSteamFocusRepairActivation(t *testing.T) {
|
||||||
|
repair := steamFocusRepair{}
|
||||||
|
inner := steamFocusSample{known: true, steam: true, window: 10, focus: 12, pid: 123, innerFocus: true}
|
||||||
|
if target := repair.observe(inner); target != 0 {
|
||||||
|
t.Fatal("valid browser focus requested a repair")
|
||||||
|
}
|
||||||
|
repair.observe(steamFocusSample{known: true, window: 20, focus: 20})
|
||||||
|
outer := inner
|
||||||
|
outer.focus, outer.innerFocus = 10, false
|
||||||
|
if target := repair.observe(outer); target != 0 {
|
||||||
|
t.Fatal("repair did not allow Steam its first restoration sample")
|
||||||
|
}
|
||||||
|
if target := repair.observe(outer); target != 12 {
|
||||||
|
t.Fatalf("repair target = %d, want remembered browser child 12", target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSteamFocusRepairPreservesControlAndWindowBoundaries(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
sample steamFocusSample
|
||||||
|
}{
|
||||||
|
{"other application", steamFocusSample{known: true, window: 20, focus: 20}},
|
||||||
|
{"other Steam window", steamFocusSample{known: true, steam: true, window: 30, focus: 30, pid: 123}},
|
||||||
|
{"reused window with different process", steamFocusSample{known: true, steam: true, window: 10, focus: 10, pid: 456}},
|
||||||
|
{"already focused browser popup", steamFocusSample{known: true, steam: true, window: 10, focus: 14, pid: 123, innerFocus: true}},
|
||||||
|
{"external keyboard focus", steamFocusSample{known: true, steam: true, window: 10, focus: 99, pid: 123}},
|
||||||
|
{"unknown foreground", steamFocusSample{}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
repair := steamFocusRepair{}
|
||||||
|
repair.observe(steamFocusSample{known: true, steam: true, window: 10, focus: 12, pid: 123, innerFocus: true})
|
||||||
|
repair.observe(steamFocusSample{known: true, window: 20})
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
if target := repair.observe(test.sample); target != 0 {
|
||||||
|
t.Fatalf("unexpected focus repair to %d", target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSteamFocusRepairRemembersPopupAndExpires(t *testing.T) {
|
||||||
|
repair := steamFocusRepair{}
|
||||||
|
inner := steamFocusSample{known: true, steam: true, window: 10, focus: 12, pid: 123, innerFocus: true}
|
||||||
|
repair.observe(inner)
|
||||||
|
inner.focus = 14
|
||||||
|
repair.observe(inner)
|
||||||
|
repair.observe(steamFocusSample{known: true, window: 20})
|
||||||
|
outer := inner
|
||||||
|
outer.focus, outer.innerFocus = 10, false
|
||||||
|
repair.observe(outer)
|
||||||
|
if target := repair.observe(outer); target != 14 {
|
||||||
|
t.Fatalf("target = %d, want most recently focused popup 14", target)
|
||||||
|
}
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
repair.observe(inner)
|
||||||
|
}
|
||||||
|
if target := repair.observe(outer); target != 0 {
|
||||||
|
t.Fatalf("repair remained armed after activation window expired: %d", target)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Steam-shaped X11 browser and popup fixture for a private X server."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from Xlib import X, Xatom, XK, display
|
||||||
|
from Xlib.xobject.drawable import Window
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
connection = display.Display()
|
||||||
|
root = connection.screen().root
|
||||||
|
activeAtom = connection.intern_atom("_NET_ACTIVE_WINDOW")
|
||||||
|
pidAtom = connection.intern_atom("_NET_WM_PID")
|
||||||
|
|
||||||
|
def make_window(parent: Window, width: int, height: int) -> Window:
|
||||||
|
window = parent.create_window(0, 0, width, height, 0,
|
||||||
|
connection.screen().root_depth,
|
||||||
|
event_mask=X.KeyPressMask | X.KeyReleaseMask)
|
||||||
|
window.change_property(pidAtom, Xatom.CARDINAL, 32, [os.getpid()])
|
||||||
|
window.map()
|
||||||
|
return window
|
||||||
|
|
||||||
|
steam = make_window(root, 800, 600)
|
||||||
|
steam.set_wm_class("steamwebhelper", "steam")
|
||||||
|
mainHost = make_window(steam, 800, 600)
|
||||||
|
browser = make_window(mainHost, 800, 600)
|
||||||
|
popupHost = make_window(steam, 300, 600)
|
||||||
|
popup = make_window(popupHost, 300, 600)
|
||||||
|
other = make_window(root, 800, 600)
|
||||||
|
other.set_wm_class("example", "Example")
|
||||||
|
otherChild = make_window(other, 800, 600)
|
||||||
|
windows = dict(steam=steam, browser=browser, popup=popup,
|
||||||
|
popupHost=popupHost, other=other, otherChild=otherChild)
|
||||||
|
root.change_property(activeAtom, Xatom.WINDOW, 32, [steam.id])
|
||||||
|
browser.set_input_focus(X.RevertToParent, X.CurrentTime)
|
||||||
|
connection.sync()
|
||||||
|
print(json.dumps({name: window.id for name, window in windows.items()}), flush=True)
|
||||||
|
for line in sys.stdin:
|
||||||
|
command = json.loads(line)
|
||||||
|
action = command["action"]
|
||||||
|
window = windows[command.get("window", "steam")]
|
||||||
|
if action == "activate":
|
||||||
|
root.change_property(activeAtom, Xatom.WINDOW, 32, [window.id])
|
||||||
|
window.set_input_focus(X.RevertToParent, X.CurrentTime)
|
||||||
|
elif action == "focus":
|
||||||
|
window.set_input_focus(X.RevertToParent, X.CurrentTime)
|
||||||
|
elif action == "unmap":
|
||||||
|
window.unmap()
|
||||||
|
elif action == "map":
|
||||||
|
window.map()
|
||||||
|
elif action == "destroy":
|
||||||
|
window.destroy()
|
||||||
|
elif action == "class":
|
||||||
|
window.set_wm_class("example", "Example")
|
||||||
|
elif action == "pid":
|
||||||
|
window.change_property(pidAtom, Xatom.CARDINAL, 32, [os.getpid() + 1])
|
||||||
|
elif action == "events":
|
||||||
|
connection.sync()
|
||||||
|
events = []
|
||||||
|
while connection.pending_events():
|
||||||
|
event = connection.next_event()
|
||||||
|
if event.type in (X.KeyPress, X.KeyRelease):
|
||||||
|
events.append({"window": event.window.id, "pressed": event.type == X.KeyPress,
|
||||||
|
"escape": event.detail == connection.keysym_to_keycode(XK.string_to_keysym("Escape"))})
|
||||||
|
print(json.dumps(events), flush=True)
|
||||||
|
continue
|
||||||
|
connection.sync()
|
||||||
|
print("ready", flush=True)
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -6,10 +6,18 @@ that window once, retain its pending announcement across Wine control focus
|
|||||||
updates, and introduce it again after a foreground switch. Title changes
|
updates, and introduce it again after a foreground switch. Title changes
|
||||||
within the window still follow the application's title-change preference.
|
within the window still follow the application's title-change preference.
|
||||||
|
|
||||||
|
Fresh input cancels titles still waiting for their coalescing deadline or an
|
||||||
|
application override, as well as titles already in the speech queue. Held-key
|
||||||
|
repeats retain automatic titles; Control and Escape remain explicit stop keys.
|
||||||
|
Input that initiates a window switch precedes the destination's title and must
|
||||||
|
not cancel that new introduction. Later title changes remain enabled normally.
|
||||||
|
|
||||||
Run the focused checks from the repository root:
|
Run the focused checks from the repository root:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test -race ./internal/app -run 'Title|WineReview|XTerm|Foreground' -count=1
|
go test -race ./internal/app -run 'Title|WineReview|XTerm|Foreground' -count=1
|
||||||
|
CTHULHU_NATIVE_BROWSER_TESTS=editing GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off python3 tests/native_browser.py
|
||||||
|
xvfb-run -a dbus-run-session -- env CTHULHU_NATIVE_WINDOW_TITLES=1 GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test ./internal/app -run '^TestNativeAutomaticTitleDispatchLatency$' -count=1 -v
|
||||||
dbus-run-session -- env CTHULHU_WINE_TITLE_DBUS=1 go test -race ./internal/app -run '^TestPrivateWineTitleSurvivesReviewFocusBurst$' -count=1 -v
|
dbus-run-session -- env CTHULHU_WINE_TITLE_DBUS=1 go test -race ./internal/app -run '^TestPrivateWineTitleSurvivesReviewFocusBurst$' -count=1 -v
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -33,6 +41,14 @@ replacement with its user. Review and listening are required before submission.
|
|||||||
stay quiet. With the preference enabled, changed titles should speak once.
|
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
|
5. If used, repeat with a Wine virtual desktop and check title messages on a
|
||||||
physical Braille display.
|
physical Braille display.
|
||||||
|
6. In Brave, use Ctrl+L and immediately type several characters. Each character
|
||||||
|
should echo and interrupt previous speech without an older title following
|
||||||
|
it. Repeat while a long title is speaking, and use Control and Escape to stop
|
||||||
|
it. Switch away and back without typing; the window title must still speak.
|
||||||
|
7. With automatic title reading enabled, change a page's title and verify it
|
||||||
|
speaks once. The editing fixture's F8 key changes its title to
|
||||||
|
"Cthulhu Browser Probe updated". Check address suggestions, deletion, and
|
||||||
|
native caret movement still announce their usual output.
|
||||||
|
|
||||||
Automated queue assertions do not establish physical Alt+Tab, audible speech,
|
Automated queue assertions do not establish physical Alt+Tab, audible speech,
|
||||||
installed-binary behavior, or physical Braille acceptance.
|
installed-binary behavior, or physical Braille acceptance.
|
||||||
Reference in new issue
Block a user