Ignore stale table row focus after selection changes

This commit is contained in:
ak47 committed 2026-11-10 21:49:04 -05:00
1 parent 2fb4a1b9a0
commit 873967d18a
5 files changed
+106 -3

No files matched your search

+4
View File
@@ -170,6 +170,10 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
if handler.handleLifecycle(event) { if handler.handleLifecycle(event) {
return 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) consumed := handler.acceptFocusOwnership(event, notification)
if focusGate != nil && isFocusGainEvent(event) && !notification && state.owner.applicationBus == event.Source.BusName { if focusGate != nil && isFocusGainEvent(event) && !notification && state.owner.applicationBus == event.Source.BusName {
focusGate.bypass.observeApplication(event.Source.BusName) focusGate.bypass.observeApplication(event.Source.BusName)
+21
View File
@@ -16,6 +16,27 @@ import (
"git.stormux.org/storm/cthulhu/internal/review" "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 { func (handler *accessibilityEventHandler) acceptFocusedEvent(event atspi.Event, activity input.Activity) bool {
state := handler.state state := handler.state
logger := handler.logger logger := handler.logger
+60
View File
@@ -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")
}
}
+18 -2
View File
@@ -450,7 +450,8 @@ func TestTerminalTextChangeSuppressesPromptReplayButPreservesNavigation(t *testi
} }
// Replay YAD's stdin-fed menu sequence: initial unselected row, stale active // 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) { func TestListPromptInitialRowAndFirstArrow(t *testing.T) {
speech := &fakeSpeechQueue{spoken: make(chan string, 16), requests: make(chan speechRequest, 16)} speech := &fakeSpeechQueue{spoken: make(chan string, 16), requests: make(chan speechRequest, 16)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 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.GUITable, cell.TablePositionKnown = true, true
cell.TableRowText = "Play" cell.TableRowText = "Play"
focus := atspi.NewEvent(atspi.FocusedStateEvent, cell) 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 adapter.events <- focus
expect := func(text string, appendSpeech bool) { expect := func(text string, appendSpeech bool) {
t.Helper() t.Helper()
@@ -494,10 +495,25 @@ func TestListPromptInitialRowAndFirstArrow(t *testing.T) {
second.TableRowIndex, second.Selected, second.Focused = 1, true, true second.TableRowIndex, second.Selected, second.Focused = 1, true, true
adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, second) adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, second)
expect("Install", false) 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) adapter.events <- atspi.NewManagedDescendantEvent(atspi.ActiveDescendantEvent, list.Path, second)
cell.Selected, cell.Focused = true, true cell.Selected, cell.Focused = true, true
adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, cell) adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, cell)
expect("Play", false) 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() cancel()
select { select {
case <-completed: case <-completed:
+3 -1
View File
@@ -362,7 +362,9 @@ func TestNativeYADListInitialRow(t *testing.T) {
case <-ctx.Done(): case <-ctx.Done():
t.Fatalf("menu event missing at navigation stage %d", stage) t.Fatalf("menu event missing at navigation stage %d", stage)
case event := <-adapter.Events(): 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 continue
} }
if stage == 0 { if stage == 0 {