diff --git a/AGENTS.md b/AGENTS.md index b19401e..bfdf7d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ Expose GUI controls through accessibility APIs. Keep outer preferences combo wid ## Testing Guidelines -Use Go's `testing` package, `*_test.go`, and `TestBehavior` functions. Start with the affected package. Use deterministic synchronization and generic fixtures. Native checks need their relevant harness, such as `python3 tests/native_atspi.py`. No numeric coverage target is configured. Report automated, installed-binary, physical-input, audible-speech, and Braille-display checks separately. +Use Go's `testing` package, `*_test.go`, and `TestBehavior` functions. Start with the affected package. Use deterministic synchronization and generic fixtures. Native checks need their relevant harness, such as `python3 tests/native_atspi.py`. Specify a 24-bit Xvfb screen for Qt fixtures: Slint's `xvfb-run` defaults to 8-bit, which can crash Qt text-cursor painting. No numeric coverage target is configured. Report automated, installed-binary, physical-input, audible-speech, and Braille-display checks separately. For performance work, run the relevant `BenchmarkBehavior` functions before and after changes using repeated runs and allocation reporting. Add or expand representative benchmarks when investigating a new bottleneck; keep fixture construction outside timed loops and check that the intended work actually happens. Preserve announcement content, ordering, interruption, focus, and Braille behavior. Benchmark timing alone does not establish a bug or live responsiveness, and noisy timing changes must not become automatic pass/fail thresholds. See [benchmark procedures](docs/testing/benchmarks.md) for commands and measurement limits. Plain `go test` does not run benchmarks. diff --git a/docs/testing/qt-control-states.md b/docs/testing/qt-control-states.md index f93b130..a1ef1cb 100644 --- a/docs/testing/qt-control-states.md +++ b/docs/testing/qt-control-states.md @@ -55,6 +55,52 @@ go test ./internal/atspi ./internal/presentation ./internal/app -count=1 CTHULHU_NATIVE_BROWSER_TESTS=menu /usr/bin/python3 tests/native_browser.py ``` +## Editable combos with read-only editors + +Some Qt applications make a combo editable, then make its internal line edit +read-only. Qt can expose that child with both EDITABLE and READ_ONLY states. +Closed Up/Down navigation sends child caret events followed by a name/value +event from the combo. A caret event can arrive without a new focus event. + +On Slint with Qt/PySide6 6.9.3 and AT-SPI 2.58.1, querying the word at that +child's caret reproduced an 800 ms timeout, followed by stale list-item +queries and loss of further accessibility events. Cthulhu identifies these +children using READ_ONLY plus a direct combo parent, copies that parent's +identity into each caret snapshot, and omits the unused word query. It keeps +native focus on the combo and lets its name/value event present the selection. +This does not depend on the last caret/focus event or on a coalescing delay. +Writable combo editors and standalone read-only text fields retain word capture. + +The app event loop suppresses a child caret only when its copied combo identity +matches the owned focus. Normal foreground checks still apply. Popup selection +followed by focus is presented once, and deselection of the previous managed +item does not replace the current item. Deselection of the current focused +item remains audible. + +Run the isolated regression with: + +```sh +python3 tests/native_toby_combo.py +``` + +This requires PySide6, Xvfb, xdotool and the native Go build dependencies. It +explicitly uses a 24-bit screen; Slint's 8-bit Xvfb default can crash Qt's text +cursor painter before accessibility checks complete. Fixture diagnostics are +saved in the printed `/tmp/cthulhu-combo-qt-*.log` path. The test +checks consecutive closed arrows, Home/End, popup Return and Escape, +Tab/Shift+Tab after each close, and word capture in writable fields, standalone +read-only fields and writable combo editors. It waits for each provider event +instead of filling the adapter queue with an unobserved key sequence. Snapshot +timings are reported for diagnosis, without a machine-speed pass threshold. + +For live acceptance, repeat those keys in Toby Doom Launcher for both IWAD and +game choices. Confirm each new value is spoken once, navigation continues after +several changes, and Tab/Shift+Tab still reaches narration and buttons. In an +ordinary text field and writable combo, check typing and Ctrl+Left/Right word +reading. Switch away with a popup open and confirm background events do not +interrupt the new foreground application. Check a physical Braille display when +available; private event tests do not establish audible or physical output. + ## Providers which omit checkability The tested Mumble 1.5.939 / Qt 5 installation exports Mute Self and Deafen Self diff --git a/internal/app/event_accessibility.go b/internal/app/event_accessibility.go index 86ceb03..175b557 100644 --- a/internal/app/event_accessibility.go +++ b/internal/app/event_accessibility.go @@ -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, diff --git a/internal/app/event_caret.go b/internal/app/event_caret.go index 85e0c41..a98fbf8 100644 --- a/internal/app/event_caret.go +++ b/internal/app/event_caret.go @@ -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 { diff --git a/internal/app/event_caret_test.go b/internal/app/event_caret_test.go index 4a9c35e..0425192 100644 --- a/internal/app/event_caret_test.go +++ b/internal/app/event_caret_test.go @@ -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)} diff --git a/internal/app/event_focus.go b/internal/app/event_focus.go index 4adb7aa..e166d3b 100644 --- a/internal/app/event_focus.go +++ b/internal/app/event_focus.go @@ -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) diff --git a/internal/app/event_focus_test.go b/internal/app/event_focus_test.go index 1d17443..ea75c52 100644 --- a/internal/app/event_focus_test.go +++ b/internal/app/event_focus_test.go @@ -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)} diff --git a/internal/atspi/adapter_event_callback_native.go b/internal/atspi/adapter_event_callback_native.go index 35d98c4..79eb7de 100644 --- a/internal/atspi/adapter_event_callback_native.go +++ b/internal/atspi/adapter_event_callback_native.go @@ -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()) } diff --git a/internal/atspi/adapter_events.c b/internal/atspi/adapter_events.c index e494791..60b0dec 100644 --- a/internal/atspi/adapter_events.c +++ b/internal/atspi/adapter_events.c @@ -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) { diff --git a/internal/atspi/adapter_native.h b/internal/atspi/adapter_native.h index 35f2b60..76c3414 100644 --- a/internal/atspi/adapter_native.h +++ b/internal/atspi/adapter_native.h @@ -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); diff --git a/internal/atspi/model.go b/internal/atspi/model.go index e9bb629..d031214 100644 --- a/internal/atspi/model.go +++ b/internal/atspi/model.go @@ -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 diff --git a/internal/atspi/toby_combo_native_test.go b/internal/atspi/toby_combo_native_test.go new file mode 100644 index 0000000..9eb510a --- /dev/null +++ b/internal/atspi/toby_combo_native_test.go @@ -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) +} diff --git a/tests/native_toby_combo.py b/tests/native_toby_combo.py new file mode 100644 index 0000000..c90b8d3 --- /dev/null +++ b/tests/native_toby_combo.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Storm Dragon +# Authors: Storm Dragon, Angus Kola +# SPDX-License-Identifier: GPL-3.0-or-later +"""Exercise Toby's editable read-only combo under a private AT-SPI session.""" +import os +from pathlib import Path +import site +import subprocess +import sys +import tempfile +import time + +from native_test_session import run_private_session + + +def wait_for_window(): + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + result = subprocess.run( + ["xdotool", "search", "--onlyvisible", "--name", "^Toby combo probe$"], + capture_output=True, text=True, + ) + if result.stdout.strip(): + return result.stdout.strip().splitlines()[0] + time.sleep(0.05) + raise RuntimeError("Toby combo fixture window missing") + + +def run_inside(): + root = Path(__file__).resolve().parents[1] + diagnostics = tempfile.NamedTemporaryFile(prefix="cthulhu-combo-qt-", suffix=".log", delete=False) + print("Qt fixture diagnostics:", diagnostics.name, flush=True) + fixture = subprocess.Popen( + [sys.executable, root / "tests" / "toby_combo_fixture.py"], + stderr=diagnostics, + stdout=diagnostics, + ) + try: + window = wait_for_window() + subprocess.run(["xdotool", "windowfocus", "--sync", window], check=True) + from native_x11 import set_active_window + set_active_window(int(window)) + subprocess.run( + ["go", "test", "./internal/atspi", "-run", "^TestNativeTobyComboPopupLifetime$", + "-count=1", "-v", "-timeout=45s"], + cwd=root, check=True, timeout=60, + env=dict(os.environ, CTHULHU_NATIVE_TOBY_COMBO="1", GOCACHE="/tmp/cthulhu-cache"), + ) + finally: + exited = fixture.poll() is not None + if not exited: + fixture.terminate() + fixture.wait(timeout=5) + diagnostics.close() + if exited: + raise RuntimeError(f"Qt fixture exited with status {fixture.returncode}; see {diagnostics.name}") + + +if __name__ == "__main__": + if "--inside" in sys.argv: + run_inside() + else: + with tempfile.TemporaryDirectory(prefix="cthulhu-toby-combo-") as temporary: + environment = dict( + os.environ, + HOME=temporary, + XDG_CONFIG_HOME=temporary, + XDG_CACHE_HOME=temporary, + XDG_DATA_HOME=temporary, + QT_QPA_PLATFORM="xcb", + QT_LINUX_ACCESSIBILITY_ALWAYS_ON="1", + NO_AT_BRIDGE="0", + PYTHONPATH=site.getusersitepackages(), + GOMODCACHE=subprocess.check_output( + ["go", "env", "GOMODCACHE"], text=True + ).strip(), + ) + environment.pop("AT_SPI_BUS_ADDRESS", None) + run_private_session( + ["xvfb-run", "-a", "-s", "-screen 0 1024x768x24", "dbus-run-session", "--", sys.executable, + __file__, "--inside"], + env=environment, check=True, + ) diff --git a/tests/toby_combo_fixture.py b/tests/toby_combo_fixture.py new file mode 100644 index 0000000..4291e14 --- /dev/null +++ b/tests/toby_combo_fixture.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Storm Dragon +# Authors: Storm Dragon, Angus Kola +# SPDX-License-Identifier: GPL-3.0-or-later +"""Minimal Toby Doom Launcher combo-box fixture for native diagnostics.""" +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QApplication, QComboBox, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget + + +class AccessibleComboBox(QComboBox): + def __init__(self, parent=None): + super().__init__(parent) + self.setEditable(True) + self.lineEdit().setReadOnly(True) + self.pageStep = 5 + + def keyPressEvent(self, event): + current_index = self.currentIndex() + item_count = self.count() + if event.key() == Qt.Key_PageUp: + self.setCurrentIndex(max(0, current_index - self.pageStep)) + elif event.key() == Qt.Key_PageDown: + self.setCurrentIndex(min(item_count - 1, current_index + self.pageStep)) + elif event.key() == Qt.Key_Home: + self.setCurrentIndex(0) + self.setFocus() + self.currentIndexChanged.emit(0) + self.activated.emit(0) + elif event.key() == Qt.Key_End: + last_index = item_count - 1 + self.setCurrentIndex(last_index) + self.setFocus() + self.currentIndexChanged.emit(last_index) + self.activated.emit(last_index) + else: + super().keyPressEvent(event) + + +application = QApplication([]) +window = QWidget() +window.setWindowTitle("Toby combo probe") +layout = QVBoxLayout(window) +label = QLabel("Game") +combo = AccessibleComboBox() +combo.setAccessibleName("Game") +combo.addItems(("Classic Doom", "Bring Back the 90s", "Funny Pack - Toby Doom")) +label.setBuddy(combo) +layout.addWidget(label) +layout.addWidget(combo) +button = QPushButton("Launch") +layout.addWidget(button) +editor = QLineEdit("alpha beta") +editor.setAccessibleName("Plain editor") +layout.addWidget(editor) +read_only = QLineEdit("alpha beta") +read_only.setReadOnly(True) +read_only.setAccessibleName("Read-only text") +layout.addWidget(read_only) +writable_combo = QComboBox() +writable_combo.setEditable(True) +writable_combo.addItems(("alpha beta", "gamma delta")) +layout.addWidget(writable_combo) +window.resize(420, 300) +window.show() +combo.setFocus() +application.exec()