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:
Storm Dragon committed 2026-10-14 19:51:07 -04:00
1 parent 9205e7280a
commit 12c514aab0
19 files changed
+798 -30

No files matched your search

+21 -4
View File
@@ -282,11 +282,18 @@ func TestNativeBrowserEditing(t *testing.T) {
}
}()
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{})
go func() {
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 }()
keys := func(chord string) {
@@ -315,11 +322,17 @@ func TestNativeBrowserEditing(t *testing.T) {
keys("ctrl+l")
collect()
for _, letter := range []string{"c", "t", "h"} {
for len(queue.interrupt) > 0 {
<-queue.interrupt
}
keys(letter)
got := collect()
if len(got) != 1 || got[0] != letter {
t.Fatalf("typing %q: %q", letter, got)
}
if len(queue.interrupt) == 0 {
t.Fatalf("typing %q did not interrupt speech", letter)
}
}
keys("Down")
got := collect()
@@ -380,9 +393,13 @@ func TestNativeBrowserEditing(t *testing.T) {
if len(got) != 1 || got[0] != "• Registered: September 2026" {
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")
if got := collect(); len(got) != 0 || !browserNameChanged.Load() {
t.Fatalf("browser title update: speech=%q, native event=%v", got, browserNameChanged.Load())
got = collect()
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
+1 -1
View File
@@ -151,7 +151,7 @@ func processEventsWithAttributes(
foregroundChanges = foreground.ForegroundChanges()
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)
var titleChanges <-chan struct{}
if titles.source != nil {
+1 -2
View File
@@ -19,6 +19,5 @@ func (policy *titleInputPolicy) observe(request speechRequest) {
}
func (policy *titleInputPolicy) preserves(event input.Event) bool {
return policy.protectRepeats && event.Repeat && event.Pressed &&
event.Name != "Control_L" && event.Name != "Control_R" && event.Name != "Escape"
return policy.protectRepeats && event.Pressed && !input.ShouldInterruptTitle(event)
}
+17 -7
View File
@@ -20,13 +20,14 @@ type windowTitleSource interface {
}
type pendingWindowTitle struct {
text string
window atspi.ObjectID
epoch uint64
native xorg.WindowTitle
changed bool
application string
observedAt time.Time
text string
window atspi.ObjectID
epoch uint64
native xorg.WindowTitle
changed bool
application string
observedAt time.Time
inputSequence uint64
}
// windowTitleController is owned by the accessibility event loop. Discovery
@@ -40,6 +41,7 @@ type windowTitleController struct {
logger *slog.Logger
source windowTitleSource
foreground foregroundMonitor
inputSource any
lastNative atspi.ForegroundTitle
lastTitle string
application string
@@ -96,6 +98,11 @@ func (titles *windowTitleController) applicationForEvent(event atspi.Event) stri
func (titles *windowTitleController) queue(pending pendingWindowTitle) {
if pending.observedAt.IsZero() {
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 &&
previous.window == pending.window && previous.native.Window == pending.native.Window &&
@@ -246,6 +253,9 @@ func (titles *windowTitleController) flush() {
return
}
titles.lastTitle = pending.text
if pending.inputSequence != recentInput(titles.inputSource).TitleInterruptionSequence {
return
}
if titles.logger != nil {
titles.logger.Debug("queued automatic window title", "title_changed", pending.changed,
"observed_to_queue_ms", time.Since(pending.observedAt).Milliseconds())
+41 -3
View File
@@ -15,6 +15,8 @@ import (
"time"
"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) {
@@ -46,9 +48,22 @@ func TestNativeAutomaticTitleDispatchLatency(t *testing.T) {
t.Fatal(err)
}
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)}
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)
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() {
cancel()
if err := <-completed; err != nil {
@@ -60,9 +75,10 @@ func TestNativeAutomaticTitleDispatchLatency(t *testing.T) {
for {
select {
case got := <-queue.spoken:
if got == want {
return
if got != want {
t.Fatalf("title = %q, want %q", got, want)
}
return
case <-deadline:
t.Fatalf("title %q was not queued promptly", want)
case <-ctx.Done():
@@ -84,4 +100,26 @@ func TestNativeAutomaticTitleDispatchLatency(t *testing.T) {
wait(title, deadline)
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):
}
}
+85
View File
@@ -13,6 +13,7 @@ import (
"git.stormux.org/storm/cthulhu/internal/atspi"
"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/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) {
titles, queue, changed := titleControllerFixture(t)
display := &flashRecorder{}