Ignore stale table row focus after selection changes
This commit is contained in:
1 parent
2fb4a1b9a0
commit
873967d18a
5 files changed
+106
-3
No files matched your search
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user