diff --git a/internal/app/event_accessibility.go b/internal/app/event_accessibility.go index e8861dc..05d86d0 100644 --- a/internal/app/event_accessibility.go +++ b/internal/app/event_accessibility.go @@ -170,6 +170,10 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) { if handler.handleLifecycle(event) { return } + if state.staleTableRowFocus(event) { + logger.Debug("skipped stale table row focus", "type", event.Type, "role", event.Source.Role) + return + } consumed := handler.acceptFocusOwnership(event, notification) if focusGate != nil && isFocusGainEvent(event) && !notification && state.owner.applicationBus == event.Source.BusName { focusGate.bypass.observeApplication(event.Source.BusName) diff --git a/internal/app/event_focus.go b/internal/app/event_focus.go index d0d3081..87282a2 100644 --- a/internal/app/event_focus.go +++ b/internal/app/event_focus.go @@ -16,6 +16,27 @@ import ( "git.stormux.org/storm/cthulhu/internal/review" ) +// A provider can deliver an old cell's focus/active-descendant claim after a +// selection query has already established the new row. Do not let that claim +// roll back speech, review, or braille ownership. Container-normalized focus +// remains authoritative, including the unselected initial GTK row. +func (state *eventLoopState) staleTableRowFocus(event atspi.Event) bool { + source, current := event.Source, state.current + if source.Role != "table cell" || !source.GUITable || source.Focused || source.Selected || + current.Role != "table cell" || !current.GUITable || !(current.Focused || current.Selected) || + !source.TablePositionKnown || !current.TablePositionKnown || source.TableRowIndex == current.TableRowIndex { + return false + } + table := atspi.NewObjectID(source.BusName, source.TablePath) + if !table.Valid() || table != state.managedContainerID || + current.ID() != state.managedDescendantID || current.BusName != source.BusName || current.TablePath != source.TablePath { + return false + } + return event.Type == atspi.ActiveDescendantEvent && event.OriginPath == table.Path || + (event.Type == atspi.FocusEvent || event.Type == atspi.FocusedStateEvent && event.Detail1 != 0) && + event.OriginPath == source.Path +} + func (handler *accessibilityEventHandler) acceptFocusedEvent(event atspi.Event, activity input.Activity) bool { state := handler.state logger := handler.logger diff --git a/internal/app/event_focus_test.go b/internal/app/event_focus_test.go index 649aebc..38e598c 100644 --- a/internal/app/event_focus_test.go +++ b/internal/app/event_focus_test.go @@ -501,3 +501,63 @@ func TestProcessEventsSpeaksFileManagerSelectionStatusAfterPanelFocus(t *testing }) } } + +func TestStaleTableRowFocusPreservesCurrentSelection(t *testing.T) { + current := atspi.NewSnapshot("/table/install", "Install", "table cell", "", "") + current.BusName, current.TablePath = ":1.9", "/table" + current.GUITable, current.TablePositionKnown = true, true + current.TableRowIndex, current.Selected, current.Focused = 1, true, true + previous := current + previous.Path, previous.Name = "/table/play", "Play" + previous.TableRowIndex, previous.Selected, previous.Focused = 0, false, false + state := eventLoopState{} + state.current = current + state.managedContainerID = atspi.NewObjectID(current.BusName, current.TablePath) + state.managedDescendantID = current.ID() + for _, kind := range []string{atspi.FocusEvent, atspi.FocusedStateEvent, atspi.ActiveDescendantEvent} { + t.Run(kind, func(t *testing.T) { + event := atspi.NewEvent(kind, previous) + event.Detail1, event.OriginPath = 1, previous.Path + if kind == atspi.ActiveDescendantEvent { + event.OriginPath = previous.TablePath + } + if !state.staleTableRowFocus(event) { + t.Fatal("stale previous row accepted") + } + for _, change := range []struct { + name string + apply func(*atspi.Event) + }{ + {"real focus", func(e *atspi.Event) { e.Source.Focused = true }}, + {"real selection", func(e *atspi.Event) { e.Source.Selected = true }}, + {"same row column", func(e *atspi.Event) { e.Source.TableRowIndex = current.TableRowIndex }}, + {"other table", func(e *atspi.Event) { e.Source.TablePath = "/other" }}, + {"other application", func(e *atspi.Event) { e.Source.BusName = ":1.10" }}, + {"unknown position", func(e *atspi.Event) { e.Source.TablePositionKnown = false }}, + {"web table", func(e *atspi.Event) { e.Source.GUITable = false }}, + } { + t.Run(change.name, func(t *testing.T) { + valid := event + change.apply(&valid) + if state.staleTableRowFocus(valid) { + t.Fatal("valid focus suppressed") + } + }) + } + if kind != atspi.ActiveDescendantEvent { + event.OriginPath = previous.TablePath + if state.staleTableRowFocus(event) { + t.Fatal("normalized container focus suppressed") + } + } + }) + } + // Leaving the table clears managed ownership; old state cannot suppress + // a later return to a table with no selected row. + state.managedContainerID = atspi.ObjectID{} + event := atspi.NewEvent(atspi.FocusEvent, previous) + event.OriginPath = previous.Path + if state.staleTableRowFocus(event) { + t.Fatal("return to table suppressed") + } +} diff --git a/internal/app/prompt_presentation_test.go b/internal/app/prompt_presentation_test.go index 4ca816e..82d8b64 100644 --- a/internal/app/prompt_presentation_test.go +++ b/internal/app/prompt_presentation_test.go @@ -450,7 +450,8 @@ func TestTerminalTextChangeSuppressesPromptReplayButPreservesNavigation(t *testi } // Replay YAD's stdin-fed menu sequence: initial unselected row, stale active -// descendant for that row on Down, then the actual new selection. +// descendant for that row on Down, then the actual new selection. Also replay +// delayed focus/descendant events for Play after Install is already selected. func TestListPromptInitialRowAndFirstArrow(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 16), requests: make(chan speechRequest, 16)} adapter := &fakeAdapter{events: make(chan atspi.Event, 16)} @@ -472,7 +473,7 @@ func TestListPromptInitialRowAndFirstArrow(t *testing.T) { cell.GUITable, cell.TablePositionKnown = true, true cell.TableRowText = "Play" focus := atspi.NewEvent(atspi.FocusedStateEvent, cell) - focus.Window, focus.Detail1 = window, 1 + focus.Window, focus.Detail1, focus.OriginPath = window, 1, list.Path adapter.events <- focus expect := func(text string, appendSpeech bool) { t.Helper() @@ -494,10 +495,25 @@ func TestListPromptInitialRowAndFirstArrow(t *testing.T) { second.TableRowIndex, second.Selected, second.Focused = 1, true, true adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, second) expect("Install", false) + // GTK emits an old direct cell focus claim after the selection query + // already returned Install. Its snapshot says Play is no longer active. + staleFocus := focus + staleFocus.OriginPath = cell.Path + adapter.events <- staleFocus + adapter.events <- atspi.NewManagedDescendantEvent(atspi.ActiveDescendantEvent, list.Path, cell) + adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, second) adapter.events <- atspi.NewManagedDescendantEvent(atspi.ActiveDescendantEvent, list.Path, second) cell.Selected, cell.Focused = true, true adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, cell) expect("Play", false) + // A following focus is a barrier: all earlier row events must have been + // processed before cancellation, including any unwanted repeats. + button := atspi.NewSnapshot("/menu/cancel", "Cancel", "button", "", "") + button.BusName = window.BusName + buttonFocus := atspi.NewEvent(atspi.FocusEvent, button) + buttonFocus.Window = window + adapter.events <- buttonFocus + expect("Cancel, button", false) cancel() select { case <-completed: diff --git a/internal/atspi/alert_native_test.go b/internal/atspi/alert_native_test.go index 85742bc..310306f 100644 --- a/internal/atspi/alert_native_test.go +++ b/internal/atspi/alert_native_test.go @@ -362,7 +362,9 @@ func TestNativeYADListInitialRow(t *testing.T) { case <-ctx.Done(): t.Fatalf("menu event missing at navigation stage %d", stage) case event := <-adapter.Events(): - if event.Source.ApplicationName != "yad" { + // Startup can queue a focus event before X11 owns the window. + // Those events intentionally omit table metadata and are not presented. + if event.Source.ApplicationName != "yad" || event.ForegroundMismatch { continue } if stage == 0 {