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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
Reference in new issue
Block a user