Found more weird breakage on Slint. Fixed now.

This commit is contained in:
Storm Dragon committed 2026-11-02 17:28:31 +00:00
1 parent 6df27c88de
commit 7918fc02c4
14 files changed
+502 -6

No files matched your search

+1
View File
@@ -100,6 +100,7 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
"snapshot_duration", event.SnapshotDuration,
"editable", event.Source.Editable,
"editable_root", event.Source.EditableRoot.Path,
"read_only_combo", event.Source.ReadOnlyCombo.Path,
"autocomplete", event.Source.Autocomplete,
"autocomplete_popup", event.Source.AutocompletePopup,
"event_detail1", event.Detail1,
+7
View File
@@ -21,6 +21,13 @@ func (handler *accessibilityEventHandler) handleCaret(event atspi.Event, activit
objectReview := handler.objectReview
debounce := handler.debounce
if event.Type == atspi.CaretEvent {
if event.Source.ReadOnlyCombo.Valid() && event.Source.ReadOnlyCombo == state.focusedID &&
state.current.ID() == state.focusedID && state.current.Role == "combo box" {
// The combo's name/value event presents this selection, even when
// provider requests delay it past the normal caret coalescing time.
logger.Debug("skipped read-only combo editor caret", "combo_path", event.Source.ReadOnlyCombo.Path)
return true
}
if event.Source.ID() == state.selectionCaretID {
state.selectionCaretID = atspi.ObjectID{}
if activity.Sequence == state.selectionCaretSequence && time.Since(state.selectionCaretAt) < 150*time.Millisecond {
+55
View File
@@ -20,10 +20,12 @@ package app
import (
"context"
"log/slog"
"reflect"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/input"
)
func TestProcessEventsSpeaksChangedCaretLines(t *testing.T) {
@@ -101,6 +103,59 @@ func TestProcessEventsSuppressesCaretAfterEditableTextChange(t *testing.T) {
<-completed
}
func TestProcessEventsReadOnlyComboUsesNameInsteadOfEditorCaret(t *testing.T) {
speech := &fakeSpeechQueue{spoken: make(chan string, 16)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 16)}
combo := atspi.Snapshot{BusName: ":1.7", Path: "/combo", Role: "combo box", Name: "Classic Doom", Focused: true}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, combo)
editor := atspi.Snapshot{BusName: combo.BusName, Path: "/combo/editor", Role: "text", Text: "Funny Pack - Classic Doom",
BrailleLine: "Funny Pack - Classic Doom", TextCaretKnown: true, Editable: true, ReadOnly: true,
Focused: true, ReadOnlyCombo: combo.ID()}
for _, value := range []string{"Funny Pack - Classic Doom", "Toby Doom", "Classic Doom"} {
editor.Text, editor.BrailleLine = value, value
adapter.events <- atspi.NewEvent(atspi.CaretEvent, editor)
combo.Name = value
adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, combo)
}
close(adapter.events)
processEvents(context.Background(), slog.New(slog.DiscardHandler), speech, adapter, 0)
close(speech.spoken)
var got []string
for text := range speech.spoken {
got = append(got, text)
}
want := []string{"Classic Doom, combo box", "Funny Pack - Classic Doom", "Toby Doom", "Classic Doom"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("combo speech = %q, want %q", got, want)
}
}
func TestReadOnlyComboCaretRequiresExactOwnedCombo(t *testing.T) {
combo := atspi.Snapshot{BusName: ":1.7", Path: "/combo", Role: "combo box"}
for _, test := range []struct {
name string
owner atspi.ObjectID
skip bool
}{
{"owned", combo.ID(), true},
{"different combo", atspi.NewObjectID(combo.BusName, "/other-combo"), false},
{"different provider", atspi.NewObjectID(":1.8", combo.Path), false},
{"ordinary editor", atspi.ObjectID{}, false},
} {
t.Run(test.name, func(t *testing.T) {
state := &eventLoopState{}
state.current, state.focusedID = combo, combo.ID()
handler := accessibilityEventHandler{state: state, logger: slog.New(slog.DiscardHandler), debounce: time.Hour}
event := atspi.NewEvent(atspi.CaretEvent, atspi.Snapshot{BusName: combo.BusName, Path: "/editor", Role: "text", ReadOnlyCombo: test.owner})
handler.handleCaret(event, input.Activity{}, input.NoIntent)
defer state.pending.clear()
if skipped := state.pending.caret.event.Type == ""; skipped != test.skip {
t.Fatalf("caret skipped = %v, want %v", skipped, test.skip)
}
})
}
}
func TestProcessEventsSpeaksCaretCharacters(t *testing.T) {
speech := &fakeSpeechQueue{spoken: make(chan string, 3)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 3)}
+9 -2
View File
@@ -33,11 +33,13 @@ func (handler *accessibilityEventHandler) acceptFocusedEvent(event atspi.Event,
// Compare with the owned focus snapshot, not the last speech request:
// a state/value update can intervene between two toolkit focus events.
// Still refresh review and braille below before suppressing presentation.
managedSelectionFocus := isFocusGainEvent(event) && state.managedDescendantID.Valid() &&
event.Source.ID() == state.managedDescendantID && sameSelectionItem(event.Source, state.current)
duplicateFocus := (event.Type == atspi.FocusEvent || event.Type == atspi.FocusedStateEvent && event.Detail1 != 0) &&
event.Source.Role != "terminal" && !presentsReusedRow(event.Source) &&
!(state.pendingPromptWindow.Valid() && event.Window == state.pendingPromptWindow) &&
state.focusedID.Valid() && event.Source.ID() == state.focusedID &&
event.Source.ID() == state.current.ID() &&
((state.focusedID.Valid() && event.Source.ID() == state.focusedID &&
event.Source.ID() == state.current.ID()) || managedSelectionFocus) &&
presentation.FocusTextWithOptions(event.Source, state.presentationOptions) == presentation.FocusTextWithOptions(state.current, state.presentationOptions) &&
slices.Equal(presentation.SoundCues(event.Source, state.presentationOptions), presentation.SoundCues(state.current, state.presentationOptions))
if isFocusEvent(event.Type) {
@@ -93,6 +95,11 @@ func (handler *accessibilityEventHandler) acceptFocusedEvent(event atspi.Event,
logger.Debug("skipped selection already presented by focus", "role", event.Source.Role)
return true
}
} else if event.Type == atspi.SelectedStateEvent && !event.Source.Selected &&
(event.Source.Role == "tree item" || event.Source.Role == "list item") &&
state.managedDescendantID.Valid() && event.Source.ID() != state.managedDescendantID {
logger.Debug("skipped deselected previous managed descendant", "role", event.Source.Role)
return true
} else if (isStateChangeEvent(event.Type) || isValueChangeEvent(event.Type)) &&
(state.focusedID.Path == "" || event.Source.ID() != state.focusedID) {
logger.Debug("skipped event outside focused object", "type", event.Type, "role", event.Source.Role)
+46
View File
@@ -67,6 +67,52 @@ func TestProcessEventsSuppressesDuplicateFocusObjects(t *testing.T) {
}
}
func TestProcessEventsSuppressesQtPopupSelectionRepeats(t *testing.T) {
speech := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
close(done)
}()
defer func() { cancel(); <-done }()
list := atspi.Snapshot{BusName: ":1.7", Path: "/combo/list", Role: "list"}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, list)
if got := <-speech.spoken; got != "list" {
t.Fatalf("list focus speech = %q", got)
}
item := func(path, name string) atspi.Snapshot {
return atspi.Snapshot{BusName: list.BusName, Path: path, Role: "list item", Name: name, Focused: true, Selected: true}
}
first := item("/combo/list/1", "Classic Doom")
adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, first)
if got := <-speech.spoken; got != "Classic Doom, list item, selected" {
t.Fatalf("first selection speech = %q", got)
}
firstFocus := atspi.NewEvent(atspi.FocusedStateEvent, first)
firstFocus.Detail1 = 1
adapter.events <- firstFocus
second := item("/combo/list/2", "Funny Pack - Toby Doom")
adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, second)
if got := <-speech.spoken; got != "Funny Pack - Toby Doom, list item, selected" {
t.Fatalf("second selection speech = %q", got)
}
first.Focused, first.Selected = false, false
adapter.events <- atspi.NewEvent(atspi.SelectedStateEvent, first)
secondFocus := atspi.NewEvent(atspi.FocusedStateEvent, second)
secondFocus.Detail1 = 1
adapter.events <- secondFocus
select {
case got := <-speech.spoken:
t.Fatalf("Qt popup selection repeated as %q", got)
case <-time.After(50 * time.Millisecond):
}
}
func TestProcessEventsSuppressesUnfocusedNameChangesDuringFileNavigation(t *testing.T) {
speech := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
@@ -114,6 +114,12 @@ func (adapter *Adapter) captureNativeEventSnapshot(event *C.AtspiEvent, target *
captureCaret = role != C.ATSPI_ROLE_DOCUMENT_WEB && role != C.ATSPI_ROLE_DOCUMENT_FRAME && role != C.ATSPI_ROLE_EMBEDDED
}
snapshot := snapshotFromNativeWithCaret(target, captureCaret, eventType == CaretEvent, false, adapter.captureSound.Load(), adapter.captureTextAttributes.Load())
if eventType == CaretEvent {
if combo := C.cthulhu_read_only_combo(target); combo != nil {
snapshot.ReadOnlyCombo = nativeObjectID(combo)
C.g_object_unref(C.gpointer(combo))
}
}
if snapshot.Editable && (eventType == CaretEvent || eventType == TextInsertedEvent ||
eventType == TextDeletedEvent || eventType == TextSelectionChangedEvent) {
if editor := C.cthulhu_editable_root(target); editor != nil {
@@ -212,7 +218,10 @@ func (adapter *Adapter) captureNativeEventSnapshot(event *C.AtspiEvent, target *
}
}
if eventType == CaretEvent && snapshot.Role != "password text" {
// A read-only combo child only mirrors the chosen value. Qt 6.9 can stop
// answering accessibility requests after its word-at-caret query; no word
// feedback is needed for this non-writable selection proxy.
if eventType == CaretEvent && snapshot.Role != "password text" && !snapshot.ReadOnlyCombo.Valid() {
word := C.cthulhu_capture_caret_unit(target, C.ATSPI_TEXT_GRANULARITY_WORD)
snapshot.CaretWord = sanitizeSnapshotField(C.GoString(word))
C.g_free(C.gpointer(word))
@@ -559,7 +568,7 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
focusEvent.Window = originID
}
if !focusEvent.ForegroundMismatch && !snapshot.Defunct && snapshot.Role != "invalid" {
if shouldRetainNativeFocus(eventType, focusEvent.Detail1, chromeFocus) &&
if shouldRetainNativeFocus(eventType, focusEvent.Detail1, chromeFocus) && !snapshot.ReadOnlyCombo.Valid() &&
!(eventType == SelectionChangedEvent && focusEvent.Source.LibreOffice != nil && focusEvent.Source.LibreOffice.InSpreadsheet) {
adapter.rememberNativeFocus(target, snapshot.ID())
}
+22
View File
@@ -746,6 +746,28 @@ AtspiAccessible *cthulhu_editable_root(AtspiAccessible *source) {
return NULL;
}
// Qt exposes an editable combo's read-only line edit as TEXT with EDITABLE
// still set. Require READ_ONLY and a direct combo parent; an unrelated editor
// in the same application must keep ordinary caret and word navigation.
AtspiAccessible *cthulhu_read_only_combo(AtspiAccessible *source) {
if (source == NULL) return NULL;
AtspiRole role = atspi_accessible_get_role(source, NULL);
if (role != ATSPI_ROLE_TEXT && role != ATSPI_ROLE_ENTRY) return NULL;
AtspiStateSet *states = atspi_accessible_get_state_set(source);
gboolean readOnly = states != NULL &&
atspi_state_set_contains(states, ATSPI_STATE_READ_ONLY) &&
!atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT);
if (states != NULL) g_object_unref(states);
if (!readOnly) return NULL;
GError *error = NULL;
AtspiAccessible *parent = atspi_accessible_get_parent(source, &error);
if (error == NULL && parent != NULL &&
atspi_accessible_get_role(parent, NULL) == ATSPI_ROLE_COMBO_BOX) return parent;
g_clear_error(&error);
if (parent != NULL) g_object_unref(parent);
return NULL;
}
// Chromium can detach an existing document from its frame's AT-SPI parent
// chain while returning to a tab. Its document identity remains usable.
AtspiAccessible *cthulhu_document_ancestor(AtspiAccessible *source) {
+1
View File
@@ -142,6 +142,7 @@ AtspiAccessible *cthulhu_window_ancestor(AtspiAccessible *source);
gboolean cthulhu_may_be_background_notification(AtspiAccessible *source);
AtspiAccessible *cthulhu_document_ancestor(AtspiAccessible *source);
AtspiAccessible *cthulhu_editable_root(AtspiAccessible *source);
AtspiAccessible *cthulhu_read_only_combo(AtspiAccessible *source);
AtspiEventListenerCB cthulhu_event_callback_pointer(void);
gboolean cthulhu_request_event_loop_quit(void);
gboolean cthulhu_request_tree_capture(gpointer userData);
+4 -1
View File
@@ -93,7 +93,10 @@ type Snapshot struct {
Editable bool
// EditableRoot identifies the nearest focusable writable editor or editable
// web document. Paragraph events can belong to its native focus.
EditableRoot ObjectID
EditableRoot ObjectID
// ReadOnlyCombo identifies the direct combo parent of a read-only text
// child. Its caret changes mirror selection; the combo owns presentation.
ReadOnlyCombo ObjectID
Expandable bool
Expanded bool
Indeterminate bool
+149
View File
@@ -0,0 +1,149 @@
// Cthulhu screen reader.
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"context"
"os"
"os/exec"
"testing"
"time"
)
func TestNativeTobyComboPopupLifetime(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_TOBY_COMBO") != "1" {
t.Skip("requires isolated Toby combo fixture")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
adapter := New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer func() {
stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second)
defer stopCancel()
if err := adapter.Stop(stopCtx); err != nil {
t.Error(err)
}
}()
if _, err := adapter.CaptureDesktop(ctx); err != nil {
t.Fatal(err)
}
key := func(keys ...string) {
t.Helper()
arguments := append([]string{"key", "--delay", "120"}, keys...)
if err := exec.CommandContext(ctx, "xdotool", arguments...).Run(); err != nil {
t.Fatal(err)
}
}
var combo ObjectID
var comboCarets int
var slowest time.Duration
wait := func(description string, match func(Event) bool) Event {
t.Helper()
deadline := time.NewTimer(4 * time.Second)
defer deadline.Stop()
var recent []string
for {
select {
case event := <-adapter.Events():
if event.Source.ApplicationName != "toby_combo_fixture.py" {
continue
}
recent = append(recent, event.Type+" "+event.Source.Role+" "+event.Source.Name)
if len(recent) > 12 {
recent = recent[1:]
}
slowest = max(slowest, event.SnapshotDuration)
if event.Type == CaretEvent && event.Source.ReadOnlyCombo.Valid() {
comboCarets++
if event.Source.ReadOnlyCombo != combo || event.Source.CaretWord != "" {
t.Fatalf("read-only combo caret: owner=%v word=%q", event.Source.ReadOnlyCombo, event.Source.CaretWord)
}
}
if match(event) {
return event
}
case <-deadline.C:
t.Fatalf("missing %s; recent events: %q; background filtered=%d", description, recent, adapter.backgroundEventsFiltered.Load())
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
}
focus := func(role, name string) Event {
t.Helper()
return wait("focus on "+role+" "+name, func(event Event) bool {
return event.Type == FocusedStateEvent && event.Detail1 == 1 &&
event.Source.Role == role && event.Source.Name == name
})
}
name := func(value string) Event {
t.Helper()
return wait("combo value "+value, func(event Event) bool {
return event.Type == AccessibleNameEvent && event.Source.Role == "combo box" && event.Source.Name == value
})
}
// Closed arrows produce consecutive caret events without another combo
// focus event between them. This was missing from the popup-only probe.
key("Tab", "shift+Tab")
combo = focus("combo box", "Classic Doom").Source.ID()
for _, step := range []struct{ key, value string }{
{"Down", "Bring Back the 90s"}, {"Down", "Funny Pack - Toby Doom"},
{"Up", "Bring Back the 90s"}, {"Up", "Classic Doom"},
{"End", "Funny Pack - Toby Doom"}, {"Home", "Classic Doom"},
} {
key(step.key)
name(step.value)
}
if comboCarets < 4 {
t.Fatalf("only %d read-only combo caret events observed", comboCarets)
}
for _, closeKey := range []string{"Return", "Escape"} {
key("alt+Down")
focus("list", "")
key("Down")
wait("popup selection", func(event Event) bool {
return event.Type == SelectionChangedEvent && event.Source.Role == "list item" && event.Source.Selected
})
key(closeKey)
wait("combo focus after "+closeKey, func(event Event) bool {
return event.Type == FocusedStateEvent && event.Detail1 == 1 && event.Source.ID() == combo
})
key("Tab")
focus("button", "Launch")
key("shift+Tab")
wait("return to combo", func(event Event) bool {
return event.Type == FocusedStateEvent && event.Detail1 == 1 && event.Source.ID() == combo
})
}
key("Tab")
focus("button", "Launch")
// Other text controls still need native word capture, including read-only
// fields outside combos and writable combo editors in this same provider.
for _, control := range []struct{ role, name string }{
{"text", "Plain editor"}, {"text", "Read-only text"}, {"combo box", "alpha beta"},
} {
key("Tab")
// Qt can expose entry into a line edit only through a text-selection
// event, without refreshing libatspi's focused state or emitting focus.
wait("entry into "+control.name, func(event Event) bool {
return event.Source.Role == control.role && event.Source.Name == control.name
})
key("Home", "ctrl+Right")
caret := wait("word caret in "+control.name, func(event Event) bool {
return event.Type == CaretEvent && event.Source.CaretWord == "beta"
})
if caret.Source.ReadOnlyCombo.Valid() {
t.Fatalf("ordinary text caret acquired combo selection owner: %+v", caret.Source)
}
}
if dropped := adapter.Overflow(); dropped != 0 {
t.Fatalf("native event queue dropped %d events", dropped)
}
t.Logf("closed arrows, Home/End, popup Return/Escape and text navigation completed; slowest snapshot %s", slowest)
}